Bugfixes, optimizations, improved user-interaction behaviour

This commit is contained in:
Reinhard Pointner 2016-04-09 22:08:37 +00:00
parent 397fb14be7
commit 612a243518
3 changed files with 179 additions and 178 deletions

View File

@ -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)) {

View File

@ -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<String, SearchResult> selectionMemory = new TreeMap<String, SearchResult>(getLenientCollator(Locale.ENGLISH));
private Map<String, List<String>> inputMemory = new TreeMap<String, List<String>>(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<File> files, String query, List<SearchResult> options, Map<String, SearchResult> selectionMemory, boolean autodetection, Component parent) throws Exception {
@Override
public List<Match<File, ?>> match(Collection<File> 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<File> fileset = autodetection ? filter(files, not(getClutterFileFilter())) : new ArrayList<File>(files);
// focus on movie and subtitle files
List<File> mediaFiles = filter(fileset, VIDEO_FILES, SUBTITLE_FILES);
// merge episode matches
List<Match<File, ?>> matches = new ArrayList<Match<File, ?>>();
ExecutorService workerThreadPool = Executors.newFixedThreadPool(getPreferredThreadPoolSize());
try {
// detect series names and create episode list fetch tasks
List<Future<List<Match<File, ?>>>> tasks = new ArrayList<Future<List<Match<File, ?>>>>();
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<List<File>> batches = n != null && n.size() > 0 ? singleton(new ArrayList<File>(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<List<Match<File, ?>>> future : tasks) {
// make sure each episode has unique object data
for (Match<File, ?> it : future.get()) {
matches.add(new Match<File, Episode>(it.getValue(), ((Episode) it.getCandidate()).clone()));
}
}
} finally {
workerThreadPool.shutdownNow();
}
// handle derived files
List<Match<File, ?>> derivateMatches = new ArrayList<Match<File, ?>>();
Set<File> derivateFiles = new TreeSet<File>(fileset);
derivateFiles.removeAll(mediaFiles);
for (File file : derivateFiles) {
for (Match<File, ?> match : matches) {
if (file.getPath().startsWith(match.getValue().getParentFile().getPath()) && isDerived(file, match.getValue()) && match.getCandidate() instanceof Episode) {
derivateMatches.add(new Match<File, Object>(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<File>(files)));
return matches;
}
public List<Match<File, ?>> matchEpisodeSet(List<File> files, Collection<String> queries, SortOrder sortOrder, boolean strict, Locale locale, boolean autodetection, Component parent) throws Exception {
Collection<Episode> 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<String> detectedSeriesNames = detectSeriesNames(files, anime, locale);
String suggestion = detectedSeriesNames.size() > 0 ? join(detectedSeriesNames, "; ") : normalizePunctuation(getName(files.get(0)));
synchronized (inputMemory) {
List<String> 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<Match<File, ?>> matches = new ArrayList<Match<File, ?>>();
// group by subtitles first and then by files in general
if (episodes.size() > 0) {
for (List<File> filesPerType : mapByMediaExtension(files).values()) {
EpisodeMatcher matcher = new EpisodeMatcher(filesPerType, episodes, strict);
for (Match<File, Object> 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<File, Episode>(it.getValue(), ((Episode) it.getCandidate()).clone()));
}
}
}
}
return matches;
}
protected Set<Episode> fetchEpisodeSet(List<File> files, Collection<String> 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<Future<List<Episode>>> tasks = querySet.stream().map(q -> {
return requestThreadPool.submit(() -> {
// select search result
List<SearchResult> 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<Episode>) EMPTY_LIST;
});
}).collect(toList());
// merge all episodes
Set<Episode> episodes = new LinkedHashSet<Episode>();
for (Future<List<Episode>> it : tasks) {
episodes.addAll(it.get());
}
return episodes;
}
protected SearchResult selectSearchResult(List<File> files, String query, List<SearchResult> 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<SearchResult> selectDialog = new SelectDialog<SearchResult>(parent, options, true, false, header);
selectDialog.setTitle(provider.getName());
selectDialog.getMessageLabel().setText("<html>Select best match for \"<b>" + query + "</b>\":</html>");
selectDialog.getMessageLabel().setText("<html>Select best match for \"<b>" + escapeHTML(query) + "</b>\":</html>");
selectDialog.getCancelAction().putValue(Action.NAME, "Skip");
selectDialog.pack();
@ -135,162 +290,6 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
}
}
protected Set<Episode> fetchEpisodeSet(List<File> files, Collection<String> querySet, SortOrder sortOrder, Locale locale, Map<String, SearchResult> 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<Future<List<Episode>>> tasks = querySet.stream().map(q -> {
return requestThreadPool.submit(() -> {
// select search result
List<SearchResult> 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<Episode>) EMPTY_LIST;
});
}).collect(toList());
// merge all episodes
Set<Episode> episodes = new LinkedHashSet<Episode>();
for (Future<List<Episode>> it : tasks) {
episodes.addAll(it.get());
}
return episodes;
}
}
@Override
public List<Match<File, ?>> match(Collection<File> 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<File> fileset = autodetection ? filter(files, not(getClutterFileFilter())) : new ArrayList<File>(files);
// focus on movie and subtitle files
List<File> mediaFiles = filter(fileset, VIDEO_FILES, SUBTITLE_FILES);
// remember user decisions and only bother user once
Map<String, SearchResult> selectionMemory = new TreeMap<String, SearchResult>(getLenientCollator(Locale.ENGLISH));
Map<String, List<String>> inputMemory = new TreeMap<String, List<String>>(getLenientCollator(Locale.ENGLISH));
// merge episode matches
List<Match<File, ?>> matches = new ArrayList<Match<File, ?>>();
ExecutorService workerThreadPool = Executors.newFixedThreadPool(getPreferredThreadPoolSize());
try {
// detect series names and create episode list fetch tasks
List<Future<List<Match<File, ?>>>> tasks = new ArrayList<Future<List<Match<File, ?>>>>();
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<List<File>> batches = n != null && n.size() > 0 ? singleton(new ArrayList<File>(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<List<Match<File, ?>>> future : tasks) {
// make sure each episode has unique object data
for (Match<File, ?> it : future.get()) {
matches.add(new Match<File, Episode>(it.getValue(), ((Episode) it.getCandidate()).clone()));
}
}
} finally {
workerThreadPool.shutdownNow();
}
// handle derived files
List<Match<File, ?>> derivateMatches = new ArrayList<Match<File, ?>>();
Set<File> derivateFiles = new TreeSet<File>(fileset);
derivateFiles.removeAll(mediaFiles);
for (File file : derivateFiles) {
for (Match<File, ?> match : matches) {
if (file.getPath().startsWith(match.getValue().getParentFile().getPath()) && isDerived(file, match.getValue()) && match.getCandidate() instanceof Episode) {
derivateMatches.add(new Match<File, Object>(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<File>(files)));
return matches;
}
public List<Match<File, ?>> matchEpisodeSet(List<File> files, Collection<String> queries, SortOrder sortOrder, boolean strict, Locale locale, boolean autodetection, Map<String, SearchResult> selectionMemory, Map<String, List<String>> inputMemory, Component parent) throws Exception {
Collection<Episode> 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<String> detectedSeriesNames = detectSeriesNames(files, anime, locale);
String suggestion = detectedSeriesNames.size() > 0 ? join(detectedSeriesNames, "; ") : normalizePunctuation(getName(files.get(0)));
synchronized (inputMemory) {
List<String> 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<String, SearchResult>(), false, parent);
}
}
}
// find file/episode matches
List<Match<File, ?>> matches = new ArrayList<Match<File, ?>>();
// group by subtitles first and then by files in general
if (episodes.size() > 0) {
for (List<File> filesPerType : mapByMediaExtension(files).values()) {
EpisodeMatcher matcher = new EpisodeMatcher(filesPerType, episodes, strict);
for (Match<File, Object> 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<File, Episode>(it.getValue(), ((Episode) it.getCandidate()).clone()));
}
}
}
}
return matches;
}
protected Collection<File> getFilesForQuery(Collection<File> files, String query) {
Pattern pattern = Pattern.compile(query.isEmpty() ? ".+" : normalizePunctuation(query).replaceAll("\\W+", ".+"), Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CHARACTER_CLASS);
List<File> selection = files.stream().filter(f -> find(f.getPath(), pattern)).collect(toList());
@ -304,7 +303,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
html.append("<html>");
if (selection.size() > 0) {
if (header != null) {
html.append(header).append("<br>");
html.append(escapeHTML(header)).append("<br>");
}
for (File file : sortByUniquePath(selection)) {
html.append("<nobr>");
@ -325,7 +324,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
html.append("<br>");
}
if (message != null) {
html.append(message);
html.append(escapeHTML(message));
}
html.append("</html>");
return html.toString();
@ -337,7 +336,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
List<Match<File, ?>> matches = new ArrayList<Match<File, ?>>();
if (input.size() > 0) {
Collection<Episode> episodes = fetchEpisodeSet(emptyList(), input, sortOrder, locale, new HashMap<String, SearchResult>(), false, parent);
Collection<Episode> episodes = fetchEpisodeSet(emptyList(), input, sortOrder, locale, false, parent);
for (Episode it : episodes) {
matches.add(new Match<File, Episode>(null, it));
}

View File

@ -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<AutoSelection> autoSelectionMode = EnumSet.noneOf(AutoSelection.class);
private Map<String, Movie> selectionMemory = new TreeMap<String, Movie>(getLenientCollator(Locale.ENGLISH));
private Map<String, String> inputMemory = new TreeMap<String, String>(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<AutoSelection> selectionMode = EnumSet.noneOf(AutoSelection.class);
Map<String, Movie> selectionMemory = new TreeMap<String, Movie>(getLenientCollator(locale));
Map<String, String> inputMemory = new TreeMap<String, String>(getLenientCollator(locale));
for (Future<Map<File, List<Movie>>> future : tasks) {
for (Entry<File, List<Movie>> 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<Movie> options, boolean strict, Locale locale, boolean autodetect, Set<AutoSelection> autoSelectionMode, Map<String, Movie> selectionMemory, Map<String, String> inputMemory, Component parent) throws Exception {
protected Movie grabMovieName(File movieFile, Collection<Movie> 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("<html>");
if (header != null) {
html.append(header).append("<br>");
html.append(escapeHTML(header)).append("<br>");
}
html.append("<nobr>");
@ -268,7 +270,7 @@ class MovieMatcher implements AutoCompleteMatcher {
html.append("<br>");
if (message != null) {
html.append(message);
html.append(escapeHTML(message));
}
html.append("</html>");
return html.toString();
@ -288,7 +290,7 @@ class MovieMatcher implements AutoCompleteMatcher {
return name;
}
protected Movie selectMovie(File movieFile, boolean strict, String userQuery, Collection<Movie> options, Set<AutoSelection> autoSelectionMode, Map<String, Movie> selectionMemory, Component parent) throws Exception {
protected Movie selectMovie(File movieFile, boolean strict, String userQuery, Collection<Movie> 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<Movie> selectDialog = new SelectDialog<Movie>(parent, options, true, false, header);
selectDialog.setTitle(service.getName());
selectDialog.getMessageLabel().setText("<html>Select best match for \"<b>" + query + "</b>\":</html>");
selectDialog.getMessageLabel().setText("<html>Select best match for \"<b>" + escapeHTML(query) + "</b>\":</html>");
selectDialog.getCancelAction().putValue(Action.NAME, "Skip");
selectDialog.pack();