diff --git a/source/net/sourceforge/filebot/cli/CmdlineOperations.java b/source/net/sourceforge/filebot/cli/CmdlineOperations.java index f4ea2033..615afd79 100644 --- a/source/net/sourceforge/filebot/cli/CmdlineOperations.java +++ b/source/net/sourceforge/filebot/cli/CmdlineOperations.java @@ -3,6 +3,7 @@ package net.sourceforge.filebot.cli; import static java.lang.String.*; +import static java.util.Arrays.*; import static java.util.Collections.*; import static net.sourceforge.filebot.MediaTypes.*; import static net.sourceforge.filebot.WebServices.*; @@ -21,7 +22,6 @@ import java.util.AbstractMap.SimpleImmutableEntry; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -31,6 +31,7 @@ import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Set; +import java.util.SortedSet; import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.Callable; @@ -51,6 +52,7 @@ import net.sourceforge.filebot.similarity.Match; import net.sourceforge.filebot.similarity.Matcher; import net.sourceforge.filebot.similarity.NameSimilarityMetric; import net.sourceforge.filebot.similarity.SeriesNameMatcher; +import net.sourceforge.filebot.similarity.SimilarityComparator; import net.sourceforge.filebot.similarity.SimilarityMetric; import net.sourceforge.filebot.similarity.StrictEpisodeMetrics; import net.sourceforge.filebot.subtitle.SubtitleFormat; @@ -62,6 +64,7 @@ import net.sourceforge.filebot.web.EpisodeFormat; import net.sourceforge.filebot.web.EpisodeListProvider; import net.sourceforge.filebot.web.Movie; import net.sourceforge.filebot.web.MovieIdentificationService; +import net.sourceforge.filebot.web.MoviePart; import net.sourceforge.filebot.web.SearchResult; import net.sourceforge.filebot.web.SubtitleDescriptor; import net.sourceforge.filebot.web.SubtitleProvider; @@ -257,81 +260,112 @@ public class CmdlineOperations implements CmdlineInterface { } - public List renameMovie(Collection mediaFiles, String query, ExpressionFormat format, MovieIdentificationService db, Locale locale, boolean strict) throws Exception { - CLILogger.config(format("Rename movies using [%s]", db.getName())); + public List renameMovie(Collection mediaFiles, String query, ExpressionFormat format, MovieIdentificationService service, Locale locale, boolean strict) throws Exception { + CLILogger.config(format("Rename movies using [%s]", service.getName())); + // handle movie files File[] movieFiles = filter(mediaFiles, VIDEO_FILES).toArray(new File[0]); File[] subtitleFiles = filter(mediaFiles, SUBTITLE_FILES).toArray(new File[0]); - Movie[] movieDescriptors; + Movie[] movieByFileHash = null; - if (movieFiles.length > 0) { + if (movieFiles.length > 0 && query == null) { // match movie hashes online - CLILogger.fine(format("Looking up movie by filehash via [%s]", db.getName())); - movieDescriptors = db.getMovieDescriptors(movieFiles, locale); - } else { - // allow subtitles without video files - movieDescriptors = new Movie[subtitleFiles.length]; + CLILogger.fine(format("Looking up movie by filehash via [%s]", service.getName())); + movieByFileHash = service.getMovieDescriptors(movieFiles, locale); + Analytics.trackEvent(service.getName(), "HashLookup", "Movie", movieByFileHash.length - frequency(asList(movieByFileHash), null)); // number of positive hash lookups + } + + if (subtitleFiles.length > 0 && movieFiles.length == 0) { + // special handling if there is only subtitle files + movieByFileHash = new Movie[subtitleFiles.length]; movieFiles = subtitleFiles; subtitleFiles = new File[0]; } - // fill in movie information from nfo file imdb when necessary + if (query != null) { + CLILogger.fine(format("Looking up movie by query [%s]", query)); + Movie result = (Movie) selectSearchResult(query, service.searchMovie(query, locale), strict).get(0); + fill(movieByFileHash, result); + } + + // map movies to (possibly multiple) files (in natural order) + Map> filesByMovie = new HashMap>(); + + // map all files by movie for (int i = 0; i < movieFiles.length; i++) { - if (movieDescriptors[i] == null) { - Set imdbid = grepImdbIdFor(movieFiles[i]); - if (imdbid.size() > 1) { - CLILogger.warning(String.format("Multiple imdb ids found for %s: %s", movieFiles[i].getName(), imdbid)); + Movie movie = movieByFileHash[i]; + + // unknown hash, try via imdb id from nfo file + if (movie == null) { + Collection results = detectMovie(movieFiles[i], service, locale, strict); + movie = (Movie) selectSearchResult(query, results, strict).get(0); + + if (movie != null) { + Analytics.trackEvent(service.getName(), "SearchMovie", movie.toString(), 1); + } + } + + // check if we managed to lookup the movie descriptor + if (movie != null) { + // get file list for movie + SortedSet movieParts = filesByMovie.get(movie); + + if (movieParts == null) { + movieParts = new TreeSet(); + filesByMovie.put(movie, movieParts); } - if (imdbid.size() == 1 || (imdbid.size() > 1 && !strict)) { - movieDescriptors[i] = db.getMovieDescriptor(imdbid.iterator().next(), locale); - CLILogger.fine(String.format("Identified %s as %s via imdb id", movieFiles[i].getName(), movieDescriptors[i])); - } + movieParts.add(movieFiles[i]); } } - // use user query if search by hash did not return any results, only one query for one movie though - if (query != null && movieDescriptors.length == 1 && movieDescriptors[0] == null) { - CLILogger.fine(format("Looking up movie by query [%s]", query)); - movieDescriptors[0] = (Movie) selectSearchResult(query, db.searchMovie(query, locale), strict).get(0); + // collect all File / MoviePart matches + List> matches = new ArrayList>(); + + for (Entry> entry : filesByMovie.entrySet()) { + Movie movie = entry.getKey(); + + int partIndex = 0; + int partCount = entry.getValue().size(); + + // add all movie parts + for (File file : entry.getValue()) { + Movie part = movie; + if (partCount > 1) { + part = new MoviePart(movie, ++partIndex, partCount); + } + + matches.add(new Match(file, part)); + } + } + + // handle subtitle files + for (File subtitle : subtitleFiles) { + // check if subtitle corresponds to a movie file (same name, different extension) + for (Match movieMatch : matches) { + if (isDerived(subtitle, movieMatch.getValue())) { + matches.add(new Match(subtitle, movieMatch.getCandidate())); + // movie match found, we're done + break; + } + } } // map old files to new paths by applying formatting and validating filenames Map renameMap = new LinkedHashMap(); - for (int i = 0; i < movieFiles.length; i++) { - if (movieDescriptors[i] != null) { - Movie movie = movieDescriptors[i]; - File file = movieFiles[i]; - String newName = (format != null) ? format.format(new MediaBindingBean(movie, file)) : movie.toString(); - File newFile = new File(newName + "." + getExtension(file)); - - if (isInvalidFilePath(newFile)) { - CLILogger.config("Stripping invalid characters from new path: " + newName); - newFile = validateFilePath(newFile); - } - - renameMap.put(file, newFile); - } else { - CLILogger.warning("No matching movie: " + movieFiles[i]); - } - } - - // handle subtitle files - for (File subtitleFile : subtitleFiles) { - // check if subtitle corresponds to a movie file (same name, different extension) - for (int i = 0; i < movieDescriptors.length; i++) { - if (movieDescriptors[i] != null) { - if (isDerived(subtitleFile, movieFiles[i])) { - File movieDestination = renameMap.get(movieFiles[i]); - File subtitleDestination = new File(movieDestination.getParentFile(), getName(movieDestination) + "." + getExtension(subtitleFile)); - renameMap.put(subtitleFile, subtitleDestination); - - // movie match found, we're done - break; - } - } + for (Match match : matches) { + File file = match.getValue(); + Object movie = match.getCandidate(); + String newName = (format != null) ? format.format(new MediaBindingBean(movie, file)) : movie.toString(); + File newFile = new File(newName + "." + getExtension(file)); + + if (isInvalidFilePath(newFile)) { + CLILogger.config("Stripping invalid characters from new path: " + newName); + newFile = validateFilePath(newFile); } + + renameMap.put(file, newFile); } // rename movies @@ -340,7 +374,7 @@ public class CmdlineOperations implements CmdlineInterface { } - private List renameAll(Map renameMap) throws Exception { + public List renameAll(Map renameMap) throws Exception { // rename files final List> renameLog = new ArrayList>(); @@ -389,8 +423,9 @@ public class CmdlineOperations implements CmdlineInterface { // new file names List destinationList = new ArrayList(); - for (Entry it : renameLog) + for (Entry it : renameLog) { destinationList.add(it.getValue()); + } return destinationList; } @@ -615,21 +650,12 @@ public class CmdlineOperations implements CmdlineInterface { // sort results by similarity to query List results = new ArrayList(probableMatches.values()); - sort(results, new Comparator() { - - @Override - public int compare(SearchResult o1, SearchResult o2) { - float f1 = metric.getSimilarity(o1.getName(), query); - float f2 = metric.getSimilarity(o2.getName(), query); - return f1 > f2 ? f1 == f2 ? 0 : -1 : 1; - } - }); - + sort(results, new SimilarityComparator(query)); return results; } - private List selectSearchResult(String query, Iterable searchResults, boolean strict) throws Exception { + public List selectSearchResult(String query, Iterable searchResults, boolean strict) throws Exception { List probableMatches = findProbableMatches(query, searchResults); if (probableMatches.isEmpty() || (strict && probableMatches.size() != 1)) { diff --git a/source/net/sourceforge/filebot/media/MediaDetection.java b/source/net/sourceforge/filebot/media/MediaDetection.java index 6ea32205..286638e3 100644 --- a/source/net/sourceforge/filebot/media/MediaDetection.java +++ b/source/net/sourceforge/filebot/media/MediaDetection.java @@ -2,10 +2,13 @@ package net.sourceforge.filebot.media; +import static java.util.Arrays.*; +import static java.util.Collections.*; import static net.sourceforge.filebot.MediaTypes.*; import static net.sourceforge.tuned.FileUtilities.*; import java.io.File; +import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; @@ -29,6 +32,9 @@ import java.util.regex.Pattern; import net.sourceforge.filebot.MediaTypes; import net.sourceforge.filebot.WebServices; import net.sourceforge.filebot.similarity.SeriesNameMatcher; +import net.sourceforge.filebot.similarity.SimilarityComparator; +import net.sourceforge.filebot.web.Movie; +import net.sourceforge.filebot.web.MovieIdentificationService; import net.sourceforge.filebot.web.SearchResult; import net.sourceforge.filebot.web.TheTVDBClient.TheTVDBSearchResult; @@ -125,7 +131,7 @@ public class MediaDetection { Collection matches = new SeriesNameMatcher().matchAll(files.toArray(new File[files.size()])); try { - matches = new ReleaseInfo().cleanRG(matches); + matches = stripReleaseInfo(matches); } catch (Exception e) { Logger.getLogger(MediaDetection.class.getClass().getName()).log(Level.WARNING, "Failed to clean matches: " + e.getMessage(), e); } @@ -138,6 +144,48 @@ public class MediaDetection { } + public static Collection detectMovie(File movieFile, MovieIdentificationService service, Locale locale, boolean strict) throws Exception { + Set options = new LinkedHashSet(); + + // try to grep imdb id from nfo files + for (int imdbid : grepImdbIdFor(movieFile)) { + Movie movie = service.getMovieDescriptor(imdbid, locale); + + if (movie != null) { + options.add(movie); + } + } + + // search by file name or folder name + Collection searchQueries = new LinkedHashSet(); + searchQueries.add(getName(movieFile)); + searchQueries.add(getName(movieFile.getParentFile())); + + // remove blacklisted terms + searchQueries = stripReleaseInfo(searchQueries); + + if (!strict && options.isEmpty()) { + for (String query : searchQueries) { + Movie[] results = service.searchMovie(query, locale).toArray(new Movie[0]); + sort(results, new SimilarityComparator(query)); // sort by similarity to original query + addAll(options, results); + } + } + + return options; + } + + + public static String stripReleaseInfo(String name) throws IOException { + return new ReleaseInfo().cleanRelease(name); + } + + + public static List stripReleaseInfo(Collection names) throws IOException { + return new ReleaseInfo().cleanRelease(names); + } + + public static Set grepImdbIdFor(File file) throws Exception { Set collection = new LinkedHashSet(); diff --git a/source/net/sourceforge/filebot/media/ReleaseInfo.java b/source/net/sourceforge/filebot/media/ReleaseInfo.java index 4ffa464c..ce644631 100644 --- a/source/net/sourceforge/filebot/media/ReleaseInfo.java +++ b/source/net/sourceforge/filebot/media/ReleaseInfo.java @@ -13,6 +13,9 @@ import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -50,23 +53,13 @@ public class ReleaseInfo { } - public List clean(Iterable items) throws IOException { - return clean(items, getVideoSourcePattern(), getCodecPattern()); + public List cleanRelease(Iterable items) throws IOException { + return clean(items, getReleaseGroupPattern(), getLanguageSuffixPattern(), getVideoSourcePattern(), getCodecPattern(), getResolutionPattern()); } - public String clean(String item) throws IOException { - return clean(item, getVideoSourcePattern(), getCodecPattern()); - } - - - public List cleanRG(Iterable items) throws IOException { - return clean(items, getReleaseGroupPattern(), getVideoSourcePattern(), getCodecPattern()); - } - - - public String cleanRG(String item) throws IOException { - return clean(item, getReleaseGroupPattern(), getVideoSourcePattern(), getCodecPattern()); + public String cleanRelease(String item) throws IOException { + return clean(item, getReleaseGroupPattern(), getLanguageSuffixPattern(), getVideoSourcePattern(), getCodecPattern(), getResolutionPattern()); } @@ -89,6 +82,30 @@ public class ReleaseInfo { } + public Pattern getLanguageSuffixPattern() { + Set tokens = new TreeSet(); + + for (String code : Locale.getISOLanguages()) { + Locale locale = new Locale(code); + tokens.add(locale.getLanguage()); + tokens.add(locale.getISO3Language()); + tokens.add(locale.getDisplayLanguage(Locale.ENGLISH)); + } + + // remove illegal tokens + tokens.remove(""); + + // .{language}[.srt] + return compile("(?<=[.])(" + join(tokens, "|") + ")(?=$)", CASE_INSENSITIVE); + } + + + public Pattern getResolutionPattern() { + // match screen resolutions 640x480, 1280x720, etc + return compile("(? { + + private SimilarityMetric metric; + private Object paragon; + + + public SimilarityComparator(SimilarityMetric metric, Object paragon) { + this.metric = metric; + this.paragon = paragon; + } + + + public SimilarityComparator(String paragon) { + this(new NameSimilarityMetric(), paragon); + } + + + @Override + public int compare(Object o1, Object o2) { + float f1 = metric.getSimilarity(o1, paragon); + float f2 = metric.getSimilarity(o2, paragon); + + if (f1 == f2) + return 0; + + return f1 > f2 ? -1 : 1; + } + +} diff --git a/source/net/sourceforge/filebot/ui/rename/EpisodeListMatcher.java b/source/net/sourceforge/filebot/ui/rename/EpisodeListMatcher.java index 3a01db9b..d8bb8888 100644 --- a/source/net/sourceforge/filebot/ui/rename/EpisodeListMatcher.java +++ b/source/net/sourceforge/filebot/ui/rename/EpisodeListMatcher.java @@ -33,7 +33,6 @@ import javax.swing.Action; import javax.swing.SwingUtilities; import net.sourceforge.filebot.Analytics; -import net.sourceforge.filebot.media.ReleaseInfo; import net.sourceforge.filebot.similarity.EpisodeMetrics; import net.sourceforge.filebot.similarity.Match; import net.sourceforge.filebot.similarity.Matcher; @@ -234,7 +233,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher { String suggestion = new SeriesNameMatcher().matchBySeasonEpisodePattern(getName(files.get(0))); if (suggestion != null) { // clean media info / release group info / etc - suggestion = new ReleaseInfo().cleanRG(suggestion); + suggestion = stripReleaseInfo(suggestion); } else { // use folder name suggestion = files.get(0).getParentFile().getName(); diff --git a/source/net/sourceforge/filebot/ui/rename/MovieHashMatcher.java b/source/net/sourceforge/filebot/ui/rename/MovieHashMatcher.java index 2ebe37dd..908296b9 100644 --- a/source/net/sourceforge/filebot/ui/rename/MovieHashMatcher.java +++ b/source/net/sourceforge/filebot/ui/rename/MovieHashMatcher.java @@ -16,10 +16,12 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; +import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.Callable; @@ -30,7 +32,6 @@ import javax.swing.Action; import javax.swing.SwingUtilities; import net.sourceforge.filebot.Analytics; -import net.sourceforge.filebot.media.ReleaseInfo; import net.sourceforge.filebot.similarity.Match; import net.sourceforge.filebot.ui.SelectDialog; import net.sourceforge.filebot.web.Movie; @@ -52,10 +53,19 @@ class MovieHashMatcher implements AutoCompleteMatcher { public List> match(final List files, Locale locale, boolean autodetect, Component parent) throws Exception { // handle movie files File[] movieFiles = filter(files, VIDEO_FILES).toArray(new File[0]); + File[] subtitleFiles = filter(files, SUBTITLE_FILES).toArray(new File[0]); + Movie[] movieByFileHash = null; - // match movie hashes online - Movie[] movieByFileHash = service.getMovieDescriptors(movieFiles, locale); - Analytics.trackEvent(service.getName(), "HashLookup", "Movie", movieByFileHash.length - frequency(asList(movieByFileHash), null)); // number of positive hash lookups + if (movieFiles.length > 0) { + // match movie hashes online + movieByFileHash = service.getMovieDescriptors(movieFiles, locale); + Analytics.trackEvent(service.getName(), "HashLookup", "Movie", movieByFileHash.length - frequency(asList(movieByFileHash), null)); // number of positive hash lookups + } else if (subtitleFiles.length > 0) { + // special handling if there is only subtitle files + movieByFileHash = new Movie[subtitleFiles.length]; + movieFiles = subtitleFiles; + subtitleFiles = new File[0]; + } // map movies to (possibly multiple) files (in natural order) Map> filesByMovie = new HashMap>(); @@ -108,10 +118,10 @@ class MovieHashMatcher implements AutoCompleteMatcher { } // handle subtitle files - for (File subtitle : filter(files, SUBTITLE_FILES)) { + for (File subtitle : subtitleFiles) { // check if subtitle corresponds to a movie file (same name, different extension) for (Match movieMatch : matches) { - if (isDerived(getName(subtitle), movieMatch.getValue())) { + if (isDerived(subtitle, movieMatch.getValue())) { matches.add(new Match(subtitle, movieMatch.getCandidate())); // movie match found, we're done break; @@ -133,7 +143,7 @@ class MovieHashMatcher implements AutoCompleteMatcher { protected Movie grabMovieName(File movieFile, Locale locale, boolean autodetect, Component parent, Movie... suggestions) throws Exception { - List options = new ArrayList(); + Set options = new LinkedHashSet(); // add default value if any for (Movie it : suggestions) { @@ -142,52 +152,33 @@ class MovieHashMatcher implements AutoCompleteMatcher { } } - // try to grep imdb id from nfo files - for (int imdbid : grepImdbIdFor(movieFile)) { - Movie movie = service.getMovieDescriptor(imdbid, locale); - - if (movie != null) { - options.add(movie); - } - } - - // search by file name or folder name - Collection searchQueries = new TreeSet(String.CASE_INSENSITIVE_ORDER); - searchQueries.add(getName(movieFile)); - searchQueries.add(getName(movieFile.getParentFile())); - - // remove blacklisted terms - searchQueries = new ReleaseInfo().cleanRG(searchQueries); - - for (String query : searchQueries) { - if (autodetect && options.isEmpty()) { - options = service.searchMovie(query, locale); - } - } + // auto-detect movie from nfo or folder / file name + options.addAll(detectMovie(movieFile, service, locale, false)); // allow manual user input if (options.isEmpty() || !autodetect) { - String suggestion = options.isEmpty() ? searchQueries.iterator().next() : options.get(0).getName(); + String suggestion = options.isEmpty() ? stripReleaseInfo(getName(movieFile)) : options.iterator().next().getName(); String input = null; synchronized (this) { - input = showInputDialog("Enter movie name:", suggestion, searchQueries.iterator().next(), parent); + input = showInputDialog("Enter movie name:", suggestion, movieFile.getPath(), parent); } + // we only care about results from manual input from here on out + options.clear(); + if (input != null) { - options = service.searchMovie(input, locale); - } else { - options.clear(); // cancel search + options.addAll(service.searchMovie(input, locale)); } } - return options.isEmpty() ? null : selectMovie(options, parent); + return options.isEmpty() ? null : selectMovie(movieFile, options, parent); } - protected Movie selectMovie(final List options, final Component parent) throws Exception { + protected Movie selectMovie(final File movieFile, final Collection options, final Component parent) throws Exception { if (options.size() == 1) { - return options.get(0); + return options.iterator().next(); } // show selection dialog on EDT @@ -198,7 +189,8 @@ class MovieHashMatcher implements AutoCompleteMatcher { // multiple results have been found, user must select one SelectDialog selectDialog = new SelectDialog(parent, options); - selectDialog.getHeaderLabel().setText("Select Movie:"); + selectDialog.setTitle(movieFile.getPath()); + selectDialog.getHeaderLabel().setText(String.format("Movies matching '%s':", stripReleaseInfo(getName(movieFile)))); selectDialog.getCancelAction().putValue(Action.NAME, "Ignore"); // show dialog diff --git a/source/net/sourceforge/filebot/web/OpenSubtitlesXmlRpc.java b/source/net/sourceforge/filebot/web/OpenSubtitlesXmlRpc.java index 5814e6ea..1f2c72ba 100644 --- a/source/net/sourceforge/filebot/web/OpenSubtitlesXmlRpc.java +++ b/source/net/sourceforge/filebot/web/OpenSubtitlesXmlRpc.java @@ -134,7 +134,7 @@ public class OpenSubtitlesXmlRpc { try { String imdbid = movie.get("id"); if (!imdbid.matches("\\d{1,7}")) - throw new IllegalArgumentException("Illegal IMDbID"); + throw new IllegalArgumentException("Illegal IMDb movie ID"); // match movie name and movie year from search result Matcher matcher = pattern.matcher(movie.get("title"));