// $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() {
}
}