diff --git a/source/net/filebot/media/AutoDetection.java b/source/net/filebot/media/AutoDetection.java index 1f362f06..3cc7e661 100644 --- a/source/net/filebot/media/AutoDetection.java +++ b/source/net/filebot/media/AutoDetection.java @@ -89,7 +89,7 @@ public class AutoDetection { } public boolean isAnime(File f) { - if (MediaDetection.parseEpisodeNumber(f.getName(), false).isEmpty()) { + if (MediaDetection.parseEpisodeNumber(f.getName(), false) == null) { return false; } if (anyMatch(f.getParentFile(), ANIME_PATTERN) || find(f.getName(), ANIME_EPISODE_PATTERN) || find(f.getName(), EMBEDDED_CHECKSUM)) { diff --git a/source/net/filebot/ui/rename/EpisodeListMatcher.java b/source/net/filebot/ui/rename/EpisodeListMatcher.java index a97ee974..ca683343 100644 --- a/source/net/filebot/ui/rename/EpisodeListMatcher.java +++ b/source/net/filebot/ui/rename/EpisodeListMatcher.java @@ -18,7 +18,6 @@ import java.awt.Component; import java.io.File; import java.util.ArrayList; import java.util.Collection; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -55,6 +54,10 @@ class EpisodeListMatcher implements AutoCompleteMatcher { private EpisodeListProvider provider; private boolean anime; + // remember user decisions + private Map selectionMemory = new TreeMap(getLenientCollator(Locale.ENGLISH)); + private Map> inputMemory = new TreeMap>(getLenientCollator(Locale.ENGLISH)); + public EpisodeListMatcher(EpisodeListProvider provider, boolean anime) { this.provider = provider; this.anime = anime; @@ -64,7 +67,159 @@ class EpisodeListMatcher implements AutoCompleteMatcher { return Cache.getCache("selection_" + provider.getName(), CacheType.Persistent).cast(SearchResult.class); } - protected SearchResult selectSearchResult(List files, String query, List options, Map selectionMemory, boolean autodetection, Component parent) throws Exception { + @Override + public List> match(Collection files, boolean strict, SortOrder sortOrder, Locale locale, boolean autodetection, Component parent) throws Exception { + if (files.isEmpty()) { + return justFetchEpisodeList(sortOrder, locale, parent); + } + + // ignore sample files + List fileset = autodetection ? filter(files, not(getClutterFileFilter())) : new ArrayList(files); + + // focus on movie and subtitle files + List mediaFiles = filter(fileset, VIDEO_FILES, SUBTITLE_FILES); + + // merge episode matches + List> matches = new ArrayList>(); + + ExecutorService workerThreadPool = Executors.newFixedThreadPool(getPreferredThreadPoolSize()); + try { + // detect series names and create episode list fetch tasks + List>>> tasks = new ArrayList>>>(); + + if (strict) { + // in strict mode simply process file-by-file (ignoring all files that don't contain clear SxE patterns) + mediaFiles.stream().filter(f -> isEpisode(f, false)).map(f -> { + return workerThreadPool.submit(() -> { + return matchEpisodeSet(singletonList(f), detectSeriesNames(singleton(f), anime, locale), sortOrder, strict, locale, autodetection, parent); + }); + }).forEach(tasks::add); + } else { + // in non-strict mode use the complicated (more powerful but also more error prone) match-batch-by-batch logic + mapSeriesNamesByFiles(mediaFiles, locale, anime).forEach((f, n) -> { + // 1. handle series name batch set all at once -> only 1 batch set + // 2. files don't seem to belong to any series -> handle folder per folder -> multiple batch sets + Collection> batches = n != null && n.size() > 0 ? singleton(new ArrayList(f)) : mapByFolder(f).values(); + + batches.stream().map(b -> { + return workerThreadPool.submit(() -> { + return matchEpisodeSet(b, n, sortOrder, strict, locale, autodetection, parent); + }); + }).forEach(tasks::add); + }); + } + + for (Future>> future : tasks) { + // make sure each episode has unique object data + for (Match it : future.get()) { + matches.add(new Match(it.getValue(), ((Episode) it.getCandidate()).clone())); + } + } + } finally { + workerThreadPool.shutdownNow(); + } + + // handle derived files + List> derivateMatches = new ArrayList>(); + Set derivateFiles = new TreeSet(fileset); + derivateFiles.removeAll(mediaFiles); + + for (File file : derivateFiles) { + for (Match match : matches) { + if (file.getPath().startsWith(match.getValue().getParentFile().getPath()) && isDerived(file, match.getValue()) && match.getCandidate() instanceof Episode) { + derivateMatches.add(new Match(file, ((Episode) match.getCandidate()).clone())); + break; + } + } + } + + // add matches from other files that are linked via filenames + matches.addAll(derivateMatches); + + // restore original order + matches.sort(comparing(Match::getValue, new OriginalOrder(files))); + + return matches; + } + + public List> matchEpisodeSet(List files, Collection queries, SortOrder sortOrder, boolean strict, Locale locale, boolean autodetection, Component parent) throws Exception { + Collection episodes = emptySet(); + + // detect series name and fetch episode list + if (autodetection) { + if (queries != null && queries.size() > 0) { + // only allow one fetch session at a time so later requests can make use of cached results + episodes = fetchEpisodeSet(files, queries, sortOrder, locale, autodetection, parent); + } + } + + // require user input if auto-detection has failed or has been disabled + if (episodes.isEmpty() && !strict) { + List detectedSeriesNames = detectSeriesNames(files, anime, locale); + String suggestion = detectedSeriesNames.size() > 0 ? join(detectedSeriesNames, "; ") : normalizePunctuation(getName(files.get(0))); + + synchronized (inputMemory) { + List input = inputMemory.get(suggestion); + if (input == null || suggestion == null || suggestion.isEmpty()) { + synchronized (parent) { + input = showMultiValueInputDialog(getQueryInputMessage("Please identify the following files:", "Enter series name:", files), suggestion, provider.getName(), parent); + } + inputMemory.put(suggestion, input); + } + + if (input != null && input.size() > 0) { + // only allow one fetch session at a time so later requests can make use of cached results + episodes = fetchEpisodeSet(files, input, sortOrder, locale, false, parent); + } + } + } + + // find file/episode matches + List> matches = new ArrayList>(); + + // group by subtitles first and then by files in general + if (episodes.size() > 0) { + for (List filesPerType : mapByMediaExtension(files).values()) { + EpisodeMatcher matcher = new EpisodeMatcher(filesPerType, episodes, strict); + for (Match it : matcher.match()) { + // in strict mode sanity check the result and only pass back good matches + if (!strict || isEpisodeNumberMatch(it.getValue(), (Episode) it.getCandidate())) { + matches.add(new Match(it.getValue(), ((Episode) it.getCandidate()).clone())); + } + } + } + } + + return matches; + } + + protected Set fetchEpisodeSet(List files, Collection querySet, SortOrder sortOrder, Locale locale, boolean autodetection, Component parent) throws Exception { + // only allow one fetch session at a time so later requests can make use of cached results + // detect series names and fetch episode lists in parallel + List>> tasks = querySet.stream().map(q -> { + return requestThreadPool.submit(() -> { + // select search result + List options = provider.search(q, locale); + + if (options.size() > 0) { + SearchResult selectedSearchResult = selectSearchResult(files, q, options, autodetection, parent); + if (selectedSearchResult != null) { + return provider.getEpisodeList(selectedSearchResult, sortOrder, locale); + } + } + return (List) EMPTY_LIST; + }); + }).collect(toList()); + + // merge all episodes + Set episodes = new LinkedHashSet(); + for (Future> it : tasks) { + episodes.addAll(it.get()); + } + return episodes; + } + + protected SearchResult selectSearchResult(List files, String query, List options, boolean autodetection, Component parent) throws Exception { if (options.size() == 1) { return options.get(0); } @@ -85,7 +240,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher { // multiple results have been found, user must select one SelectDialog selectDialog = new SelectDialog(parent, options, true, false, header); selectDialog.setTitle(provider.getName()); - selectDialog.getMessageLabel().setText("Select best match for \"" + query + "\":"); + selectDialog.getMessageLabel().setText("Select best match for \"" + escapeHTML(query) + "\":"); selectDialog.getCancelAction().putValue(Action.NAME, "Skip"); selectDialog.pack(); @@ -135,162 +290,6 @@ class EpisodeListMatcher implements AutoCompleteMatcher { } } - protected Set fetchEpisodeSet(List files, Collection querySet, SortOrder sortOrder, Locale locale, Map selectionMemory, boolean autodetection, Component parent) throws Exception { - // only allow one fetch session at a time so later requests can make use of cached results - synchronized (this) { - // detect series names and fetch episode lists in parallel - List>> tasks = querySet.stream().map(q -> { - return requestThreadPool.submit(() -> { - // select search result - List options = provider.search(q, locale); - - if (options.size() > 0) { - SearchResult selectedSearchResult = selectSearchResult(files, q, options, selectionMemory, autodetection, parent); - if (selectedSearchResult != null) { - return provider.getEpisodeList(selectedSearchResult, sortOrder, locale); - } - } - return (List) EMPTY_LIST; - }); - }).collect(toList()); - - // merge all episodes - Set episodes = new LinkedHashSet(); - for (Future> it : tasks) { - episodes.addAll(it.get()); - } - return episodes; - } - } - - @Override - public List> match(Collection files, boolean strict, SortOrder sortOrder, Locale locale, boolean autodetection, Component parent) throws Exception { - if (files.isEmpty()) { - return justFetchEpisodeList(sortOrder, locale, parent); - } - - // ignore sample files - List fileset = autodetection ? filter(files, not(getClutterFileFilter())) : new ArrayList(files); - - // focus on movie and subtitle files - List mediaFiles = filter(fileset, VIDEO_FILES, SUBTITLE_FILES); - - // remember user decisions and only bother user once - Map selectionMemory = new TreeMap(getLenientCollator(Locale.ENGLISH)); - Map> inputMemory = new TreeMap>(getLenientCollator(Locale.ENGLISH)); - - // merge episode matches - List> matches = new ArrayList>(); - - ExecutorService workerThreadPool = Executors.newFixedThreadPool(getPreferredThreadPoolSize()); - try { - // detect series names and create episode list fetch tasks - List>>> tasks = new ArrayList>>>(); - - if (strict) { - // in strict mode simply process file-by-file (ignoring all files that don't contain clear SxE patterns) - mediaFiles.stream().filter(f -> isEpisode(f, false)).map(f -> { - return workerThreadPool.submit(() -> { - return matchEpisodeSet(singletonList(f), detectSeriesNames(singleton(f), anime, locale), sortOrder, strict, locale, autodetection, selectionMemory, inputMemory, parent); - }); - }).forEach(tasks::add); - } else { - // in non-strict mode use the complicated (more powerful but also more error prone) match-batch-by-batch logic - mapSeriesNamesByFiles(mediaFiles, locale, anime).forEach((f, n) -> { - // 1. handle series name batch set all at once -> only 1 batch set - // 2. files don't seem to belong to any series -> handle folder per folder -> multiple batch sets - Collection> batches = n != null && n.size() > 0 ? singleton(new ArrayList(f)) : mapByFolder(f).values(); - - batches.stream().map(b -> { - return workerThreadPool.submit(() -> { - return matchEpisodeSet(b, n, sortOrder, strict, locale, autodetection, selectionMemory, inputMemory, parent); - }); - }).forEach(tasks::add); - }); - } - - for (Future>> future : tasks) { - // make sure each episode has unique object data - for (Match it : future.get()) { - matches.add(new Match(it.getValue(), ((Episode) it.getCandidate()).clone())); - } - } - } finally { - workerThreadPool.shutdownNow(); - } - - // handle derived files - List> derivateMatches = new ArrayList>(); - Set derivateFiles = new TreeSet(fileset); - derivateFiles.removeAll(mediaFiles); - - for (File file : derivateFiles) { - for (Match match : matches) { - if (file.getPath().startsWith(match.getValue().getParentFile().getPath()) && isDerived(file, match.getValue()) && match.getCandidate() instanceof Episode) { - derivateMatches.add(new Match(file, ((Episode) match.getCandidate()).clone())); - break; - } - } - } - - // add matches from other files that are linked via filenames - matches.addAll(derivateMatches); - - // restore original order - matches.sort(comparing(Match::getValue, new OriginalOrder(files))); - - return matches; - } - - public List> matchEpisodeSet(List files, Collection queries, SortOrder sortOrder, boolean strict, Locale locale, boolean autodetection, Map selectionMemory, Map> inputMemory, Component parent) throws Exception { - Collection episodes = emptySet(); - - // detect series name and fetch episode list - if (autodetection) { - if (queries != null && queries.size() > 0) { - // only allow one fetch session at a time so later requests can make use of cached results - episodes = fetchEpisodeSet(files, queries, sortOrder, locale, selectionMemory, autodetection, parent); - } - } - - // require user input if auto-detection has failed or has been disabled - if (episodes.isEmpty() && !strict) { - List detectedSeriesNames = detectSeriesNames(files, anime, locale); - String suggestion = detectedSeriesNames.size() > 0 ? join(detectedSeriesNames, "; ") : normalizePunctuation(getName(files.get(0))); - - synchronized (inputMemory) { - List input = inputMemory.get(suggestion); - if (input == null || suggestion == null || suggestion.isEmpty()) { - input = showMultiValueInputDialog(getQueryInputMessage("Please identify the following files:", "Enter series name:", files), suggestion, provider.getName(), parent); - inputMemory.put(suggestion, input); - } - - if (input != null && input.size() > 0) { - // only allow one fetch session at a time so later requests can make use of cached results - episodes = fetchEpisodeSet(files, input, sortOrder, locale, new HashMap(), false, parent); - } - } - } - - // find file/episode matches - List> matches = new ArrayList>(); - - // group by subtitles first and then by files in general - if (episodes.size() > 0) { - for (List filesPerType : mapByMediaExtension(files).values()) { - EpisodeMatcher matcher = new EpisodeMatcher(filesPerType, episodes, strict); - for (Match it : matcher.match()) { - // in strict mode sanity check the result and only pass back good matches - if (!strict || isEpisodeNumberMatch(it.getValue(), (Episode) it.getCandidate())) { - matches.add(new Match(it.getValue(), ((Episode) it.getCandidate()).clone())); - } - } - } - } - - return matches; - } - protected Collection getFilesForQuery(Collection files, String query) { Pattern pattern = Pattern.compile(query.isEmpty() ? ".+" : normalizePunctuation(query).replaceAll("\\W+", ".+"), Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CHARACTER_CLASS); List selection = files.stream().filter(f -> find(f.getPath(), pattern)).collect(toList()); @@ -304,7 +303,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher { html.append(""); if (selection.size() > 0) { if (header != null) { - html.append(header).append("
"); + html.append(escapeHTML(header)).append("
"); } for (File file : sortByUniquePath(selection)) { html.append(""); @@ -325,7 +324,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher { html.append("
"); } if (message != null) { - html.append(message); + html.append(escapeHTML(message)); } html.append(""); return html.toString(); @@ -337,7 +336,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher { List> matches = new ArrayList>(); if (input.size() > 0) { - Collection episodes = fetchEpisodeSet(emptyList(), input, sortOrder, locale, new HashMap(), false, parent); + Collection episodes = fetchEpisodeSet(emptyList(), input, sortOrder, locale, false, parent); for (Episode it : episodes) { matches.add(new Match(null, it)); } diff --git a/source/net/filebot/ui/rename/MovieMatcher.java b/source/net/filebot/ui/rename/MovieMatcher.java index 27357451..18880012 100644 --- a/source/net/filebot/ui/rename/MovieMatcher.java +++ b/source/net/filebot/ui/rename/MovieMatcher.java @@ -54,7 +54,12 @@ import net.filebot.web.SortOrder; class MovieMatcher implements AutoCompleteMatcher { - private final MovieIdentificationService service; + private MovieIdentificationService service; + + // remember user decisions and only bother user once + private Set autoSelectionMode = EnumSet.noneOf(AutoSelection.class); + private Map selectionMemory = new TreeMap(getLenientCollator(Locale.ENGLISH)); + private Map inputMemory = new TreeMap(getLenientCollator(Locale.ENGLISH)); public MovieMatcher(MovieIdentificationService service) { this.service = service; @@ -170,15 +175,10 @@ class MovieMatcher implements AutoCompleteMatcher { }); }).collect(toList()); - // remember user decisions and only bother user once - Set selectionMode = EnumSet.noneOf(AutoSelection.class); - Map selectionMemory = new TreeMap(getLenientCollator(locale)); - Map inputMemory = new TreeMap(getLenientCollator(locale)); - for (Future>> future : tasks) { for (Entry> it : future.get().entrySet()) { // auto-select movie or ask user - Movie movie = grabMovieName(it.getKey(), it.getValue(), strict, locale, autodetect, selectionMode, selectionMemory, inputMemory, parent); + Movie movie = grabMovieName(it.getKey(), it.getValue(), strict, locale, autodetect, parent); // make sure to use language-specific movie object if (movie != null) { @@ -222,7 +222,7 @@ class MovieMatcher implements AutoCompleteMatcher { return matches; } - protected Movie grabMovieName(File movieFile, Collection options, boolean strict, Locale locale, boolean autodetect, Set autoSelectionMode, Map selectionMemory, Map inputMemory, Component parent) throws Exception { + protected Movie grabMovieName(File movieFile, Collection options, boolean strict, Locale locale, boolean autodetect, Component parent) throws Exception { // allow manual user input synchronized (selectionMemory) { if (!strict && (!autodetect || options.isEmpty()) && !(autodetect && autoSelectionMode.size() > 0)) { @@ -231,27 +231,29 @@ class MovieMatcher implements AutoCompleteMatcher { if (input == null || suggestion == null || suggestion.isEmpty()) { File movieFolder = guessMovieFolder(movieFile); - input = showInputDialog(getQueryInputMessage("Please identify the following files:", "Enter movie name:", movieFile), suggestion != null && suggestion.length() > 0 ? suggestion : getName(movieFile), movieFolder == null ? movieFile.getName() : String.join(" / ", movieFolder.getName(), movieFile.getName()), parent); + synchronized (parent) { + input = showInputDialog(getQueryInputMessage("Please identify the following files:", "Enter movie name:", movieFile), suggestion != null && suggestion.length() > 0 ? suggestion : getName(movieFile), movieFolder == null ? movieFile.getName() : String.join(" / ", movieFolder.getName(), movieFile.getName()), parent); + } inputMemory.put(suggestion, input); } if (input != null && input.length() > 0) { options = service.searchMovie(input, locale); if (options.size() > 0) { - return selectMovie(movieFile, strict, input, options, autoSelectionMode, selectionMemory, parent); + return selectMovie(movieFile, strict, input, options, parent); } } } } - return options.isEmpty() ? null : selectMovie(movieFile, strict, null, options, autoSelectionMode, selectionMemory, parent); + return options.isEmpty() ? null : selectMovie(movieFile, strict, null, options, parent); } protected String getQueryInputMessage(String header, String message, File file) throws Exception { StringBuilder html = new StringBuilder(512); html.append(""); if (header != null) { - html.append(header).append("
"); + html.append(escapeHTML(header)).append("
"); } html.append("
"); @@ -268,7 +270,7 @@ class MovieMatcher implements AutoCompleteMatcher { html.append("
"); if (message != null) { - html.append(message); + html.append(escapeHTML(message)); } html.append(""); return html.toString(); @@ -288,7 +290,7 @@ class MovieMatcher implements AutoCompleteMatcher { return name; } - protected Movie selectMovie(File movieFile, boolean strict, String userQuery, Collection options, Set autoSelectionMode, Map selectionMemory, Component parent) throws Exception { + protected Movie selectMovie(File movieFile, boolean strict, String userQuery, Collection options, Component parent) throws Exception { // just auto-pick singleton results if (options.size() == 1) { return options.iterator().next(); @@ -356,7 +358,7 @@ class MovieMatcher implements AutoCompleteMatcher { SelectDialog selectDialog = new SelectDialog(parent, options, true, false, header); selectDialog.setTitle(service.getName()); - selectDialog.getMessageLabel().setText("Select best match for \"" + query + "\":"); + selectDialog.getMessageLabel().setText("Select best match for \"" + escapeHTML(query) + "\":"); selectDialog.getCancelAction().putValue(Action.NAME, "Skip"); selectDialog.pack();