From 26c49fb96b7c16d5c105ab49ceb7f080f206f4fb Mon Sep 17 00:00:00 2001 From: Reinhard Pointner Date: Fri, 8 Apr 2016 22:59:33 +0000 Subject: [PATCH] Refactor EpisodeListMatcher --- .../filebot/ui/rename/EpisodeListMatcher.java | 352 +++++++----------- .../net/filebot/ui/rename/OriginalOrder.java | 26 ++ 2 files changed, 169 insertions(+), 209 deletions(-) create mode 100644 source/net/filebot/ui/rename/OriginalOrder.java diff --git a/source/net/filebot/ui/rename/EpisodeListMatcher.java b/source/net/filebot/ui/rename/EpisodeListMatcher.java index 75045991..b3ff0130 100644 --- a/source/net/filebot/ui/rename/EpisodeListMatcher.java +++ b/source/net/filebot/ui/rename/EpisodeListMatcher.java @@ -1,8 +1,9 @@ package net.filebot.ui.rename; import static java.util.Collections.*; +import static java.util.Comparator.*; +import static java.util.stream.Collectors.*; import static net.filebot.MediaTypes.*; -import static net.filebot.Settings.*; import static net.filebot.WebServices.*; import static net.filebot.media.MediaDetection.*; import static net.filebot.similarity.Normalization.*; @@ -11,29 +12,23 @@ import static net.filebot.util.StringUtilities.*; import static net.filebot.util.ui.SwingUI.*; import java.awt.Component; -import java.awt.Dimension; import java.io.File; import java.util.ArrayList; 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.TreeMap; import java.util.TreeSet; -import java.util.concurrent.Callable; import java.util.concurrent.CancellationException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; import java.util.concurrent.RunnableFuture; +import java.util.prefs.Preferences; +import java.util.regex.Pattern; import javax.swing.Action; import javax.swing.SwingUtilities; @@ -41,7 +36,6 @@ import javax.swing.SwingUtilities; import net.filebot.Cache; import net.filebot.Cache.TypedCache; import net.filebot.CacheType; -import net.filebot.Settings; import net.filebot.similarity.CommonSequenceMatcher; import net.filebot.similarity.EpisodeMatcher; import net.filebot.similarity.Match; @@ -56,19 +50,19 @@ class EpisodeListMatcher implements AutoCompleteMatcher { private EpisodeListProvider provider; private boolean anime; - // remember user selections - private TypedCache persistentSelectionMemory; - // only allow one fetch session at a time so later requests can make use of cached results private final Object providerLock = new Object(); public EpisodeListMatcher(EpisodeListProvider provider, boolean anime) { this.provider = provider; this.anime = anime; - this.persistentSelectionMemory = Cache.getCache("selection_" + provider.getName(), CacheType.Persistent).cast(SearchResult.class); } - protected SearchResult selectSearchResult(final String query, final List searchResults, Map selectionMemory, boolean autodetection, final Component parent) throws Exception { + public TypedCache getPersistentSelectionMemory() { + return Cache.getCache("selection_" + provider.getName(), CacheType.Persistent).cast(SearchResult.class); + } + + protected SearchResult selectSearchResult(List files, String query, List searchResults, Map selectionMemory, boolean autodetection, Component parent) throws Exception { if (searchResults.size() == 1) { return searchResults.get(0); } @@ -82,104 +76,80 @@ class EpisodeListMatcher implements AutoCompleteMatcher { } // show selection dialog on EDT - final RunnableFuture showSelectDialog = new FutureTask(new Callable() { + RunnableFuture showSelectDialog = new FutureTask(() -> { + // multiple results have been found, user must select one + SelectDialog selectDialog = new SelectDialog(parent, searchResults, true, false); + selectDialog.setTitle(provider.getName()); + selectDialog.getHeaderLabel().setText(getQueryInputMessage(String.format("Select best match for \"%s\":", query), getFilesForQuery(files, query))); + selectDialog.getCancelAction().putValue(Action.NAME, "Ignore"); + selectDialog.pack(); - @Override - public SearchResult call() throws Exception { - // multiple results have been found, user must select one - SelectDialog selectDialog = new SelectDialog(parent, searchResults, true, false); + // show dialog + selectDialog.restoreState(Preferences.userNodeForPackage(EpisodeListMatcher.class)); + selectDialog.setLocation(getOffsetLocation(selectDialog.getOwner())); + selectDialog.setVisible(true); - selectDialog.getHeaderLabel().setText(String.format("Shows matching '%s':", query)); - selectDialog.getCancelAction().putValue(Action.NAME, "Ignore"); + // remember dialog state + selectDialog.saveState(Preferences.userNodeForPackage(EpisodeListMatcher.class)); - // restore original dialog size - Settings prefs = Settings.forPackage(EpisodeListMatcher.class); - int w = Integer.parseInt(prefs.get("dialog.select.w", "280")); - int h = Integer.parseInt(prefs.get("dialog.select.h", "300")); - selectDialog.setPreferredSize(new Dimension(w, h)); - selectDialog.pack(); - - // show dialog - selectDialog.setLocation(getOffsetLocation(selectDialog.getOwner())); - selectDialog.setVisible(true); - - // remember dialog size - prefs.put("dialog.select.w", Integer.toString(selectDialog.getWidth())); - prefs.put("dialog.select.h", Integer.toString(selectDialog.getHeight())); - - if (selectDialog.getSelectedAction() == null) { - throw new CancellationException(); - } - - // remember if we should auto-repeat the chosen action in the future - if (selectDialog.getAutoRepeatCheckBox().isSelected() && selectDialog.getSelectedValue() != null) { - persistentSelectionMemory.put(query, selectDialog.getSelectedValue()); - } - - // selected value or null if the dialog was canceled by the user - return selectDialog.getSelectedValue(); + if (selectDialog.getSelectedAction() == null) { + throw new CancellationException(); } + + // remember if we should auto-repeat the chosen action in the future + if (selectDialog.getAutoRepeatCheckBox().isSelected() && selectDialog.getSelectedValue() != null) { + getPersistentSelectionMemory().put(query, selectDialog.getSelectedValue()); + } + + // selected value or null if the dialog was canceled by the user + return selectDialog.getSelectedValue(); }); // allow only one select dialog at a time - synchronized (this) { - synchronized (selectionMemory) { - if (selectionMemory.containsKey(query)) { - return selectionMemory.get(query); - } - - // check persistent memory - if (autodetection) { - SearchResult persistentSelection = persistentSelectionMemory.get(query); - if (persistentSelection != null) { - return persistentSelection; - } - } - - // ask user - SwingUtilities.invokeAndWait(showSelectDialog); - SearchResult userSelection = showSelectDialog.get(); - - // remember selected value - selectionMemory.put(query, userSelection); - return userSelection; + synchronized (parent) { + if (selectionMemory.containsKey(query)) { + return selectionMemory.get(query); } + + // check persistent memory + if (autodetection) { + SearchResult persistentSelection = getPersistentSelectionMemory().get(query); + if (persistentSelection != null) { + return persistentSelection; + } + } + + // ask user + SwingUtilities.invokeAndWait(showSelectDialog); + SearchResult userSelection = showSelectDialog.get(); + + // remember selected value + selectionMemory.put(query, userSelection); + return userSelection; } } - protected Set fetchEpisodeSet(Collection seriesNames, final SortOrder sortOrder, final Locale locale, final Map selectionMemory, final boolean autodetection, final Component parent) throws Exception { - List>> tasks = new ArrayList>>(); - - // detect series names and create episode list fetch tasks - for (final String query : seriesNames) { - tasks.add(new Callable>() { - - @Override - public List call() throws Exception { - List results = provider.search(query, locale); - - // select search result - if (results.size() > 0) { - SearchResult selectedSearchResult = selectSearchResult(query, results, selectionMemory, autodetection, parent); - - if (selectedSearchResult != null) { - return provider.getEpisodeList(selectedSearchResult, sortOrder, locale); - } + protected Set fetchEpisodeSet(List files, Collection querySet, SortOrder sortOrder, Locale locale, Map selectionMemory, boolean autodetection, Component parent) throws Exception { + // detect series names and fetch episode lists in parallel + List>> tasks = querySet.stream().map(q -> { + return requestThreadPool.submit(() -> { + // select search result + List results = provider.search(q, locale); + if (results.size() > 0) { + SearchResult selectedSearchResult = selectSearchResult(files, q, results, selectionMemory, autodetection, parent); + if (selectedSearchResult != null) { + return provider.getEpisodeList(selectedSearchResult, sortOrder, locale); } - - return Collections.emptyList(); } + return new ArrayList(); }); - } + }).collect(toList()); - // fetch episode lists concurrently and merge all episodes + // merge all episodes Set episodes = new LinkedHashSet(); - - for (Future> future : requestThreadPool.invokeAll(tasks)) { - episodes.addAll(future.get()); + for (Future> it : tasks) { + episodes.addAll(it.get()); } - - // all background workers have finished return episodes; } @@ -195,101 +165,68 @@ class EpisodeListMatcher implements AutoCompleteMatcher { // focus on movie and subtitle files List mediaFiles = filter(fileset, VIDEO_FILES, SUBTITLE_FILES); - // assume that many shows will be matched, do it folder by folder - List>>> tasks = new ArrayList>>>(); - // remember user decisions and only bother user once Map selectionMemory = new TreeMap(CommonSequenceMatcher.getLenientCollator(Locale.ENGLISH)); Map> inputMemory = new TreeMap>(CommonSequenceMatcher.getLenientCollator(Locale.ENGLISH)); // 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) - for (File file : mediaFiles) { - if (parseEpisodeNumber(file, false) != null || parseDate(file) != null) { - tasks.add(new Callable>>() { - - @Override - public List> call() throws Exception { - return matchEpisodeSet(singletonList(file), detectSeriesNames(singleton(file), anime, locale), sortOrder, strict, locale, autodetection, selectionMemory, inputMemory, parent); - } - }); - } - } + 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 - for (Entry, Set> sameSeriesGroup : mapSeriesNamesByFiles(mediaFiles, locale, anime).entrySet()) { - final List> batchSets = new ArrayList>(); - final Collection queries = sameSeriesGroup.getValue(); + 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(); - if (queries != null && queries.size() > 0) { - // handle series name batch set all at once -> only 1 batch set - batchSets.add(new ArrayList(sameSeriesGroup.getKey())); - } else { - // these files don't seem to belong to any series -> handle folder per folder -> multiple batch sets - batchSets.addAll(mapByFolder(sameSeriesGroup.getKey()).values()); - } - - for (final List batchSet : batchSets) { - tasks.add(new Callable>>() { - - @Override - public List> call() throws Exception { - return matchEpisodeSet(batchSet, queries, sortOrder, strict, locale, autodetection, selectionMemory, inputMemory, parent); - } + batches.stream().map(b -> { + return workerThreadPool.submit(() -> { + return matchEpisodeSet(b, n, sortOrder, strict, locale, autodetection, selectionMemory, inputMemory, parent); }); - } - } - } - - // match folder per folder in parallel - ExecutorService executor = Executors.newFixedThreadPool(getPreferredThreadPoolSize()); - - try { - // merge all episodes - List> matches = new ArrayList>(); - for (Future>> future : executor.invokeAll(tasks)) { - // make sure each episode has unique object data - for (Match it : future.get()) { - matches.add(new Match(it.getValue(), ((Episode) it.getCandidate()).clone())); - } - } - - // handle derived files - List> derivateMatches = new ArrayList>(); - SortedSet 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 - Collections.sort(matches, new Comparator>() { - - @Override - public int compare(Match o1, Match o2) { - return fileset.indexOf(o1.getValue()) - fileset.indexOf(o2.getValue()); - } + }).forEach(tasks::add); }); - - // all background workers have finished - return matches; - } finally { - // destroy background threads - executor.shutdownNow(); } + + // merge episode matches + List> matches = new ArrayList>(); + 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())); + } + } + + // 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(final List files, Collection queries, SortOrder sortOrder, boolean strict, Locale locale, boolean autodetection, Map selectionMemory, Map> inputMemory, Component parent) throws Exception { + public List> matchEpisodeSet(List files, Collection queries, SortOrder sortOrder, boolean strict, Locale locale, boolean autodetection, Map selectionMemory, Map> inputMemory, Component parent) throws Exception { Set episodes = emptySet(); // detect series name and fetch episode list @@ -297,7 +234,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher { if (queries != null && queries.size() > 0) { // only allow one fetch session at a time so later requests can make use of cached results synchronized (providerLock) { - episodes = fetchEpisodeSet(queries, sortOrder, locale, selectionMemory, autodetection, parent); + episodes = fetchEpisodeSet(files, queries, sortOrder, locale, selectionMemory, autodetection, parent); } } } @@ -312,7 +249,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher { synchronized (inputMemory) { input = inputMemory.get(suggestion); if (input == null || suggestion == null || suggestion.isEmpty()) { - input = showMultiValueInputDialog(getQueryInputMessage(files), suggestion, parentPathHint, parent); + input = showMultiValueInputDialog(getQueryInputMessage("Enter series name:", files), suggestion, parentPathHint, parent); inputMemory.put(suggestion, input); } } @@ -320,7 +257,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher { if (input != null && input.size() > 0) { // only allow one fetch session at a time so later requests can make use of cached results synchronized (providerLock) { - episodes = fetchEpisodeSet(input, sortOrder, locale, new HashMap(), false, parent); + episodes = fetchEpisodeSet(files, input, sortOrder, locale, new HashMap(), false, parent); } } } @@ -344,56 +281,53 @@ class EpisodeListMatcher implements AutoCompleteMatcher { return matches; } - protected String getQueryInputMessage(List files) throws Exception { - int limit = 20; - List selection = new ArrayList(files); - if (selection.size() > limit) { - sort(selection, new Comparator() { + 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()); + return selection.size() > 0 ? selection : files; + } - @Override - public int compare(File o1, File o2) { - return Long.compare(o2.length(), o1.length()); - } - }); - selection = selection.subList(0, limit); - } + protected String getQueryInputMessage(String message, Collection files) throws Exception { + List selection = files.stream().sorted(comparing(File::length).reversed()).limit(5).collect(toList()); StringBuilder html = new StringBuilder(512); html.append(""); - html.append("Unable to identify the following files:").append("
"); + if (selection.size() > 0) { + html.append("Failed to identify some of the following files:").append("
"); - for (File file : sortByUniquePath(selection)) { - html.append(""); - html.append("• "); + for (File file : sortByUniquePath(selection)) { + html.append(""); + html.append("• "); - File path = getStructurePathTail(file); - if (path == null) { - path = getRelativePathTail(file, 3); + File path = getStructurePathTail(file); + if (path == null) { + path = getRelativePathTail(file, 3); + } + + new TextColorizer().colorizePath(html, path, true); + html.append(""); + html.append("
"); + } + + if (selection.size() < files.size()) { + html.append("• ").append("…").append("
"); } - new TextColorizer().colorizePath(html, path, true); - html.append(""); html.append("
"); } - - if (selection.size() < files.size()) { - html.append("• ").append("…").append("
"); - } - - html.append("
"); - html.append("Please enter series name:"); + html.append(message); html.append(""); return html.toString(); } - public List> justFetchEpisodeList(final SortOrder sortOrder, final Locale locale, final Component parent) throws Exception { + public List> justFetchEpisodeList(SortOrder sortOrder, Locale locale, Component parent) throws Exception { // require user input List input = showMultiValueInputDialog("Enter series name:", "", "Fetch Episode List", parent); List> matches = new ArrayList>(); if (input.size() > 0) { synchronized (providerLock) { - Set episodes = fetchEpisodeSet(input, sortOrder, locale, new HashMap(), false, parent); + Set episodes = fetchEpisodeSet(emptyList(), input, sortOrder, locale, new HashMap(), false, parent); for (Episode it : episodes) { matches.add(new Match(null, it)); } diff --git a/source/net/filebot/ui/rename/OriginalOrder.java b/source/net/filebot/ui/rename/OriginalOrder.java new file mode 100644 index 00000000..7b4ccca3 --- /dev/null +++ b/source/net/filebot/ui/rename/OriginalOrder.java @@ -0,0 +1,26 @@ +package net.filebot.ui.rename; + +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; + +class OriginalOrder implements Comparator { + + private Map index; + + public OriginalOrder(Collection values) { + this.index = new HashMap(values.size()); + + int i = 0; + for (T it : values) { + index.put(it, i++); + } + } + + @Override + public int compare(T a, T b) { + return index.get(a).compareTo(index.get(b)); + } + +}