Package de.kunysch.tvbrowser.localimdb

Source Code of de.kunysch.tvbrowser.localimdb.Plugin$KnownMovie

// $Id: Plugin.java 124 2008-03-06 07:19:25Z bananeweizen $
// GNU GPL Version 2, Copyright (C) 2005-2006 Paul C. Kunysch
package de.kunysch.tvbrowser.localimdb;

import de.kunysch.localimdb.ImportGui;

import java.awt.event.ActionEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.security.InvalidParameterException;
import java.util.logging.Logger;
import java.util.prefs.Preferences;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import util.ui.FixedSizeIcon;
import util.ui.ImageUtilities;
import util.ui.Localizer;
import de.kunysch.localimdb.Movie;
import de.kunysch.localimdb.Movies;
import de.kunysch.tvbrowser.PluginBase;
import devplugin.ActionMenu;
import devplugin.Channel;
import devplugin.ChannelDayProgram;
import devplugin.Date;
import devplugin.PluginInfo;
import devplugin.PluginTreeNode;
import devplugin.Program;
import devplugin.ProgramFieldType;
import devplugin.ProgramItem;
import devplugin.Version;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.Icon;
import javax.swing.SwingUtilities;

/** The TV-Browser application communicates with this class. */
public class Plugin extends PluginBase implements PropertyChangeListener {
 
  private static class KnownMovie {
    int countPrograms = 0;
    Set<Movie>[] movies;
  }

  /**
   * plugin version
   */
  private static final Version PLUGIN_VERSION = new Version(2, 60, 1, false);

  private static Localizer mLocalizer = util.ui.Localizer.getLocalizerFor(Plugin.class);

  private File FILE_MOVIESDATA;

  private File FILE_DIR;

  private static final Logger LOG = Logger.getLogger(Plugin.class.getPackage().getName());

  final private Icon[] emptyIconArray = new Icon[0];

  private final IconFactory iconFac = new IconFactory();

  private Icon logoIcon;// = iconFac.getIcon(12, 8);

  private Movies movies = new Movies();

  private PluginTreeNode rootNode = new PluginTreeNode(this, false);

  private PluginTreeNode[] rankNode = new PluginTreeNode[9];
 
  /**
   * map of all currently decorated or listed programs with movie ratings
   */
  private HashMap<String, KnownMovie> knownMovies = new HashMap<String, KnownMovie>();
 
  /**
   * map of ignored program titles with the minimum program duration
   */
  private HashMap<String, Integer> ignoredPrograms;

  /**
   * The constructor initializes default settings, prepares contents for the
   * SettingsPanel and takes care that activatePlugin() will be called when
   * necessary.
   */
  public Plugin() {
    SettingsKeys.setSettings(getSettings());
    for (int i = rankNode.length - 1; i >= 0; i--) {
      String title = Integer.toString(i + 1) + "+";
      rankNode[i] = new PluginTreeNode(title);
      rankNode[i].setGroupingByDateEnabled(false);
      rootNode.add(rankNode[i]);
    }
    logoIcon = ImageUtilities.createImageIconFromJar("de/kunysch/localimdb/localimdb.png", getClass());
  }

  private void initFiles() {
    FILE_DIR = new File("LocalImdb"); //$NON-NLS-1
    FILE_MOVIESDATA = new File(FILE_DIR, "MoviesTable.dat"); //$NON-NLS-1$
    if (!FILE_MOVIESDATA.exists() && null != getPluginManager()) {
      try {
        String userHome = getPluginManager().getTvBrowserSettings().getTvBrowserUserHome();
        FILE_DIR = new File(userHome, "LocalImdb"); //$NON-NLS-1
        FILE_MOVIESDATA = new File(FILE_DIR, "MoviesTable.dat"); //$NON-NLS-1$
      } catch (Exception ex) {
        ex.printStackTrace();
      }
    }
    FILE_DIR.mkdirs();
    Preferences.userNodeForPackage(ImportGui.class).put(ImportGui.PREF_DATA_FOLDER, FILE_DIR.getPath());
  }

  @Override
  public ActionMenu getButtonAction() {
    AbstractAction action = new AbstractAction() {
      public void actionPerformed(ActionEvent e) {
        new DaySummaryDialog(Plugin.this, getParentFrame()).setVisible(true);
      }
    };
    action.putValue(Action.NAME, mLocalizer.msg("0", "IMDb ratings"));
    action.putValue(Action.SHORT_DESCRIPTION, getInfo().getDescription());
    Icon icon = ImageUtilities.createImageIconFromJar("de/kunysch/localimdb/localimdb.png", getClass());
    action.putValue(Action.SMALL_ICON, icon);
    action.putValue(BIG_ICON, new FixedSizeIcon(24, 24, icon));
    return new ActionMenu(action);
  }

  @Override
  public ActionMenu getContextMenuActions(Program program) {
    String label = mLocalizer.msg("21", "Show IMDb ratings");
    AbstractAction action = null;
    // handle example program so the plugin is shown in the configuration dialog
    // for context menu order
    if (program == getPluginManager().getExampleProgram()) {
      action = new AbstractAction() {
        public void actionPerformed(ActionEvent e) {
          // do nothing for example program
        }
      };
    } else {
      Set<Movie>[] movies = findMovies(program);
      if (null == movies) {
        return null;
      }
      if (null != movies[1] && movies[0] != movies[1]) {
        movies[0].addAll(movies[1]);
      }
      if (movies[0].size() == 0) {
        return null;
      }
      final Program finalProgram = program;
      action = new AbstractAction() {
        public void actionPerformed(ActionEvent evt) {
          try {
            new DaySummaryDialog(Plugin.this, getParentFrame(), finalProgram).setVisible(true);
          } catch (InvalidParameterException e) {
          }
        }
      };
      if (movies[0].size() == 1) {
        Movie movie = movies[0].iterator().next();
        Object[] args = { movie.getRank() / 10.0, movie.getVotes() };
        label = mLocalizer.msg("showRatingSingle", "Show IMDb rating (\u2205 {0,number,#.#} #{1})", args);
      }
    }
    action.putValue(Action.NAME, label);
    action.putValue(Action.SMALL_ICON, getLogoIcon());
    return new ActionMenu(action);
  }

  private Icon getLogoIcon() {
    return logoIcon;
  }

  /**
   * This finds <code>Movie</code> objects that match the regular or the
   * original title of the given <code>Program</code>. A <code>Set</code>
   * array with two elements is returned. The first contains results for the
   * regular title. The second contains results for the original title. If there
   * is no distinct original title the second element of the array will be null.
   *
   * @return an array with two elements. The first element is never null.
   */
  private Set<Movie>[] findAllMovies(Program prog) {
    KnownMovie movie = knownMovies.get(prog.getTitle());
    if (movie != null) {
      return movie.movies;
    }
    Set<Movie>[] result = new Set[2];
    final String localizedTitle = prog.getTextField(ProgramFieldType.TITLE_TYPE).replaceAll("\\([/A-Za-z]*\\)", ""); //$NON-NLS-1$ //$NON-NLS-2$
    final String originalTitle = prog.getTextField(ProgramFieldType.ORIGINAL_TITLE_TYPE);
    result[0] = findMovies(localizedTitle);
    if (null == originalTitle) {
      if (0 == result[0].size()) {
        result[0] = findMovies(localizedTitle.replaceAll(".*[:\\n](?=.{10})", "")); //$NON-NLS-1$ //$NON-NLS-2$
      }
    } else if (!localizedTitle.equals(originalTitle)) {
      result[1] = findMovies(originalTitle);
    }
    return result;
  }

  private int findMaxYearDeviation(final int year, Set<Movie> allMovies) {
    List<Integer> deviations = new ArrayList<Integer>(allMovies.size() + 1);
    Iterator<Movie> iter = allMovies.iterator();
    deviations.add(new Integer(getSettings().getInt(SettingsKeys.YEARDEV)));
    while (iter.hasNext()) {
      deviations.add(new Integer(Math.abs((iter.next()).getYear() - year)));
    }
    final int maxYearDev = (Collections.min(deviations)).intValue();
    return maxYearDev;
  }

  /**
   * This adds a program to an ignore list. Programs with an identical title and
   * equal or shorter length won't be rated in the future.
   *
   * The current implementation is not optimized for larger lists. Titles with a
   * semicolon are currently rejected.
   */
  public void ignoreProgram(Program prog) {
    String[] ignoreTitles = getSettings().getList(SettingsKeys.IGNORETITLES);
    final String progTitle = prog.getTextField(ProgramFieldType.TITLE_TYPE);
    if (-1 != progTitle.indexOf(';')) {
      // TODO Escape semicolons or don't store titles with Settings.setList.
      System.err.println("Ignoring programs with ';' in the title not supported.");
      return;
    }
    final int progLength = prog.getLength();
    for (int i = 1; i < ignoreTitles.length; i += 2) {
      if (!progTitle.equals(ignoreTitles[i - 1])) {
        continue;
      }
      if (Integer.parseInt(ignoreTitles[i]) >= progLength) {
        return;
      }
      ignoreTitles[i] = "" + progLength;
      getSettings().setList(SettingsKeys.IGNORETITLES, ignoreTitles);
      return;
    }
    String[] newIgnoreTitles = new String[ignoreTitles.length + 2];
    System.arraycopy(ignoreTitles, 0, newIgnoreTitles, 0, ignoreTitles.length);
    newIgnoreTitles[ignoreTitles.length] = progTitle;
    newIgnoreTitles[ignoreTitles.length + 1] = "" + progLength;
    getSettings().setList(SettingsKeys.IGNORETITLES, newIgnoreTitles);
  }

  private boolean programIsIgnored(Program prog) {
    // create hash map on demand from string based settings
    if (ignoredPrograms == null) {
      ignoredPrograms = new HashMap<String, Integer>();
      String[] ignoreTitles = getSettings().getList(SettingsKeys.IGNORETITLES);
      for (int i = 1; i < ignoreTitles.length; i += 2) {
        ignoredPrograms.put(ignoreTitles[i-1], Integer.valueOf(ignoreTitles[i]));
      }
    }
    Integer minutes = ignoredPrograms.get(prog.getTextField(ProgramFieldType.TITLE_TYPE));
    // program not found
    if (minutes == null) {
      return false;
    }
    // program found, check minimum time
    return minutes >= prog.getLength();
  }

  /**
   * This finds <code>Movie</code> objects that match the regular or the
   * original title of the given <code>Program</code>. A <code>Set</code>
   * array with two elements is returned. The first contains results for the
   * regular title. The second contains results for the original title. If there
   * is no distinct original title the second element of the array will be null.
   *
   * The results are filtered according to various options. If there are no
   * results null is returned. If there are movies that match both (distinct)
   * titles only these movies will be kept. In that case the same
   * <code>Set</code> will be returned in both elements.
   *
   * @return an array with two elements or null.
   */
  public Set<Movie>[] findMovies(Program prog) {
    if (isChannelHidden(prog.getChannel())) {
      return null;
    }
    if (getSettings().getBoolean(SettingsKeys.REQYEAR) && -1 == getProductionYear(prog)) {
      return null;
    }
    if (getSettings().getBoolean(SettingsKeys.SKIPEPS)
        && (null != prog.getTextField(ProgramFieldType.EPISODE_TYPE) || null != prog
            .getTextField(ProgramFieldType.ORIGINAL_EPISODE_TYPE))) {
      return null;
    }
    if (getSettings().getBoolean(SettingsKeys.SKIPMOD) && null != prog.getTextField(ProgramFieldType.MODERATION_TYPE)) {
      return null;
    }
    Set<Movie>[] result = findAllMovies(prog);
    if (0 == result[0].size() && (null == result[1] || 0 == result[1].size())) {
      return null;
    }
    if (programIsIgnored(prog)) {
      return null;
    }
    if (getSettings().getBoolean(SettingsKeys.SKIPREP)) {
      if (isRepeatedProgram(prog, 60)) {
        return null;
      }
    }
    tryMergingResults(result);
    tryDiscardingByYear(prog, result);
    if (0 == result[0].size() && (null == result[1] || 0 == result[1].size())) {
      return null;
    }
    removeUnrepresentativeRatings(result[0]);
    removeUnrepresentativeRatings(result[1]);
    if (0 == result[0].size() && (null == result[1] || 0 == result[1].size())) {
      return null;
    }
    // we finally have a result, remember it for faster access
    KnownMovie known = knownMovies.get(prog.getTitle());
    if (known == null) {
      known = new KnownMovie();
      known.movies = result;
    }
    known.countPrograms ++;
    knownMovies.put(prog.getTitle(), known);
    return result;
  }

  /**
   * remove all ratings with less votes than required
   * @param set set of movies
   */
  private void removeUnrepresentativeRatings(Set set) {
    if (null == set) {
      return;
    }
    final int minVotes = getSettings().getInt(SettingsKeys.MINVOTES);
    Iterator iter = set.iterator();
    while (iter.hasNext()) {
      Movie movie = (Movie) iter.next();
      if (movie.getVotes() < minVotes) {
        iter.remove();
      }
    }
  }

  /**
   * This internal function returns true if a Program titled prog.getTitle() is
   * shown on the same channel one week later or earlier. The time may differ at
   * most MAXDIFFMINUTES.
   */
  private final boolean isRepeatedProgram(final Program prog, final int MAXDIFFMINUTES) {
    final int[] checkedDays = { -7, 7 };
    final String title = prog.getTitle();
    if (null == title) {
      return false;
    }
    final int start = prog.getStartTime();
    for (int i = 0; i < checkedDays.length; ++i) {
      final Iterator cdp = getPluginManager().getChannelDayProgram(prog.getDate().addDays(checkedDays[i]),
          prog.getChannel());
      if (null == cdp) {
        continue;
      }
      while (cdp.hasNext()) {
        final Program otherProg = (Program) cdp.next();
        if (start - MAXDIFFMINUTES > otherProg.getStartTime()) {
          continue;
        }
        if (start + MAXDIFFMINUTES < otherProg.getStartTime()) {
          break;
        }
        if (title.equals(otherProg.getTitle())) {
          return true;
        }
      }
    }
    return false;
  }

  /**
   * This returns all <code>Movie</code> s that match the given title. If no
   * <code>Movie</code> is found an empty <code>Set</code> is returned.
   *
   * @param title
   *          is a movie title.
   * @return a <code>Set</code> with corresponding <code>Movie</code>s.
   */
  private Set<Movie> findMovies(final String title) {
    if (null == title) {
      return Collections.emptySet();
    }
    return new TreeSet<Movie>(Arrays.asList(movies.findByTitle(title)));
  }

  /** This returns an icon that represents the specified movie. */
  public Icon getMovieIcon(Movie movie) {
    return iconFac.getIcon(movie);
  }

  public static Version getVersion() {
    return PLUGIN_VERSION;
  }

  /** GetInfo() returns information about the program. */
  @Override
  public PluginInfo getInfo() {
    return new PluginInfo(mLocalizer.msg("1", "LocalIMDb"), mLocalizer.msg("23", "Displays IMDb ratings."),
        "P. Kunysch, Michael Keppler", PLUGIN_VERSION, mLocalizer.msg("3", "GNU GPL Version 2"));
  }

  /**
   * This returns the <code>Movies</code> object.
   *
   * @return the movies.
   */
  public Movies getMovies() {
    return movies;
  }

  /**
   * This returns icons that represent the IMDb rating of the given program.
   *
   * @param prog
   *          is the examined program.
   * @return icons that represent IMDb ratings.
   */
  @Override
  public Icon[] getProgramTableIcons(final Program prog) {
    Set<Movie>[] movies = findMovies(prog);
    if (null == movies) {
      return null;
    }
    if (null != movies[1] && movies[0] != movies[1]) {
      movies[0].addAll(movies[1]);
    }
    final Iterator iter = movies[0].iterator();
    List<Icon> icons = new ArrayList<Icon>(movies[0].size());
    while (iter.hasNext()) {
      icons.add(getMovieIcon((Movie) iter.next()));
    }
    if (icons.isEmpty()) {
      return null;
    }
    return icons.toArray(emptyIconArray);
  }

  private static final Pattern YEAR_PATTERN = Pattern.compile(".*((18|19|20)\\d{2})(,|$|\\z).*", Pattern.DOTALL);

  /**
   * This allows the settings dialog to disable our icons.
   *
   * @return the name of our icons for the settings dialog.
   */
  @Override
  public String getProgramTableIconText() {
    return mLocalizer.msg("4", "IMDb ratings"); //$NON-NLS-1$
  }

  /**
   * This returns a SettingsTab impementation.
   *
   * @return a SettingsTab impementation.
   */
  @Override
  public devplugin.SettingsTab getSettingsTab() {
    return new SettingsTab(this, getSettings());
  }

  /**
   * This tests if a channel was disabled by the user.
   *
   * @return true if a channel was disabled by the user.
   */
  public boolean isChannelHidden(Channel channel) {
    return isChannelHidden(channel.getName());
  }

  /**
   * This tests if a channel was disabled by the user.
   *
   * @return true if a channel was disabled by the user.
   */
  public boolean isChannelHidden(String channelName) {
    final String hideChannels = ";" + getSettings().getProperty(SettingsKeys.HIDE); //$NON-NLS-1$
    return -1 != hideChannels.indexOf(";" + channelName + ";"); //$NON-NLS-1$ //$NON-NLS-2$
  }

  /**
   * This is called when the parentFrame gets available. It tries to load the
   * MOVIES_FILE. If that fails the user is asked to load the necessary data.
   */
  @Override
  public void onLateActivation() {
    LOG.info("LocalImdb.onLateActivation"); //$NON-NLS-1$
    if (!movies.isEmpty()) {
      return;
    }
    try {
      initFiles();
      ImportGui gui = new ImportGui(getParentFrame());
      gui.addPropertyChangeListener(ImportGui.PROP_MOVIES, this);
      gui.onStartup();
    } catch (IOException ex) {
      ex.printStackTrace();
    }
  }

  /** This removes search results depending on the production year. */
  private void tryDiscardingByYear(Program prog, Set<Movie>[] result) {
    int year = getProductionYear(prog);
    if (year == -1) {
      return;
    }
    TreeSet<Movie> allMovies = new TreeSet<Movie>(result[0]);
    if (null != result[1]) {
      allMovies.addAll(result[1]);
    }
    final int maxYearDev = findMaxYearDeviation(year, allMovies);
    final Iterator<Movie> iter = allMovies.iterator();
    while (iter.hasNext()) {
      if (maxYearDev < Math.abs(iter.next().getYear() - year)) {
        iter.remove();
      }
    }
    result[0].retainAll(allMovies);
    if (null != result[1]) {
      result[1].retainAll(allMovies);
    }
  }

  public static int getProductionYear(Program prog) {
    int year = prog.getIntField(ProgramFieldType.PRODUCTION_YEAR_TYPE);
    if (-1 == year) {
      // search the production year in the ORIGIN field
      final String origin = prog.getTextField(ProgramFieldType.ORIGIN_TYPE);
      if (origin != null && origin.length() > 0) {
        Matcher matcher = YEAR_PATTERN.matcher(origin);
        if (matcher.matches()) {
          year = Integer.parseInt(matcher.group(1));
        }
      }
    }
    if (-1 == year) {
      // search the production year in the SHORT_DESCRIPTION field
      final String description = prog.getShortInfo();
      if (description != null && description.length() > 0) {
        Matcher matcher = YEAR_PATTERN.matcher(description);
        if (matcher.matches()) {
          year = Integer.parseInt(matcher.group(1));
        }
      }
    }
    return year;
  }

  /**
   * This tries to return a nonempty intersection of the result Sets. If the
   * intersection is empty nothing is changed.
   */
  private void tryMergingResults(Set<Movie>[] movieSets) {
    if (null == movieSets[1]) {
      return;
    }
    Set<Movie> merged = new TreeSet<Movie>(movieSets[0]);
    merged.retainAll(movieSets[1]);
    if (0 == merged.size()) {
      return;
    }
    movieSets[0] = movieSets[1] = merged;
  }

  public void propertyChange(PropertyChangeEvent evt) {
    if (ImportGui.PROP_MOVIES.equals(evt.getPropertyName())) {
      movies = (Movies) evt.getNewValue();
      if (null == movies) {
        movies = new Movies();
      } else {
        LOG.info("loaded IMDb movies");
        SwingUtilities.invokeLater(new Runnable() {
          public void run() {
            fireDayProgramsChanged(getPluginManager().getCurrentDate());
            fireDayProgramsChanged(getPluginManager().getCurrentDate().addDays(1));
            updatePluginTree();
          }
        });
      }
    }
  }

  @Override
  public boolean canUseProgramTree() {
    return true;
  }

  @Override
  public PluginTreeNode getRootNode() {
    return rootNode;
  }

  private void updatePluginTree() {
    PluginTreeNode root = getRootNode();
    Channel[] channels = devplugin.Plugin.getPluginManager().getSubscribedChannels();
    if (null == channels) {
      return;
    }
    // iterate over all programs to find programs with ratings
    Date day = getPluginManager().getCurrentDate();
    HashMap<Program, ArrayList<Movie>> programs = new HashMap<Program, ArrayList<Movie>>();
    final int startOfDay = getPluginManager().getTvBrowserSettings().getProgramTableStartOfDay();
    final int endOfDay = getPluginManager().getTvBrowserSettings().getProgramTableEndOfDay();
    for (Channel channel : channels) {
      if (isChannelHidden(channel)) {
        continue;
      }
      // show only today and tomorrow
      for (int dayOffset = 0; dayOffset < 2; dayOffset++) {
        Iterator<Program> iter = devplugin.Plugin.getPluginManager().getChannelDayProgram(day.addDays(dayOffset),
            channel);
        if (iter != null) {
          try {
            while (iter.hasNext()) {
              Program prog = iter.next();
              if (dayOffset == 0 && prog.getTimeField(ProgramFieldType.START_TIME_TYPE) < startOfDay) {
                continue;
              }
              if (dayOffset == 1 && prog.getTimeField(ProgramFieldType.START_TIME_TYPE) > endOfDay) {
                continue;
              }
              Set<Movie>[] movies = findMovies(prog);
              if (null != movies) {
                ArrayList<Movie> list = new ArrayList<Movie>();
                if (movies[0] != null) {
                  list.addAll(movies[0]);
                }
                if (movies[1] != null) {
                  list.addAll(movies[1]);
                }
                if (list.size() > 0) {
                  programs.put(prog, list);
                }
              }
            }
          } catch (NullPointerException e) {
          }
        }
      }
    }
    // show programs under sub nodes with rating from 1+ to 9+
    for (Program program : programs.keySet()) {
      ArrayList<Movie> movies = programs.get(program);
      for (Movie movie : movies) {
        int index = movie.getRank() / 10 - 1;
        if (index == rankNode.length) {
          index = rankNode.length - 1;
        }
        addPluginTreeNode(rankNode[index], movie, program);
      }
    }
    root.update();
  }

  private synchronized void addPluginTreeNode(PluginTreeNode parent, Movie movie, Program program) {
    // don't use parent.addProgram(program)
    // otherwise all programs get marked automatically
    if (parent.contains(program, false)) {
      return;
    }
    ProgramItem item = new ProgramItem(program);
    PluginTreeNode node = new PluginTreeNode(item);
    parent.add(node);
  }

  /**
   * This notifies the model of the MainTable that all programs on the specified
   * day were changed. This is necessary to force an update of all currently
   * shown programs after the movies database has been lazy loaded.
   *
   * @param day
   *          selects which <code>Program</code> objects will be processed.
   */
  public void fireDayProgramsChanged(devplugin.Date day) {
    if (getParentFrame() == null) {
      return;
    }
    final Channel[] channels = getPluginManager().getSubscribedChannels();
    for (int i = 0; i < channels.length; ++i) {
      final Iterator<Program> iter = getPluginManager().getChannelDayProgram(day, channels[i]);
      if (null == iter) {
        continue;
      }
      while (iter.hasNext()) {
        Program prog = iter.next();
        Set<Movie>[] progMovies = findMovies(prog);
        if (progMovies != null && (progMovies[0].size() > 0 || progMovies[1].size() > 0)) {
          prog.validateMarking();
        }
      }
    }
  }

  @Override
  public void handleTvDataUpdateFinished() {
   
  }

}
TOP

Related Classes of de.kunysch.tvbrowser.localimdb.Plugin$KnownMovie

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.