+ support for adjustable match mode Opportunistic (default, like always) and new Strict mode (which is very restrictive, but will most likely get it right, if it gets anything at all)

This commit is contained in:
Reinhard Pointner 2014-08-13 16:02:35 +00:00
parent 425bfb83ea
commit 097e001111
6 changed files with 123 additions and 62 deletions

View File

@ -620,7 +620,7 @@ public class MediaDetection {
return sortBySimilarity(options, terms, getMovieMatchMetric(), true);
}
// if matching name+year failed, try matching only by name
// if matching name+year failed, try matching only by name (in non-strict mode we would have checked these cases already by now)
if (movieNameMatches.isEmpty() && strict) {
movieNameMatches = matchMovieName(terms, false, 0);
if (movieNameMatches.isEmpty()) {
@ -736,6 +736,19 @@ public class MediaDetection {
return result;
}
public static List<Integer> parseMovieYear(String name) {
List<Integer> years = new ArrayList<Integer>();
for (String it : name.split("\\D+")) {
if (it.length() == 4) {
int year = Integer.parseInt(it);
if (1920 < year && year < 2050) {
years.add(year);
}
}
}
return years;
}
public static String reduceMovieName(String name, boolean strict) throws IOException {
Matcher matcher = compile(strict ? "^(.+)[\\[\\(]((?:19|20)\\d{2})[\\]\\)]" : "^(.+?)((?:19|20)\\d{2})").matcher(name);
if (matcher.find()) {

View File

@ -1,7 +1,5 @@
package net.filebot.ui.rename;
import java.awt.Component;
import java.io.File;
import java.util.List;
@ -10,8 +8,7 @@ import java.util.Locale;
import net.filebot.similarity.Match;
import net.filebot.web.SortOrder;
interface AutoCompleteMatcher {
List<Match<File, ?>> match(List<File> files, SortOrder order, Locale locale, boolean autodetection, Component parent) throws Exception;
List<Match<File, ?>> match(List<File> files, boolean strict, SortOrder order, Locale locale, boolean autodetection, Component parent) throws Exception;
}

View File

@ -48,7 +48,7 @@ import net.filebot.web.SortOrder;
class EpisodeListMatcher implements AutoCompleteMatcher {
private final EpisodeListProvider provider;
private EpisodeListProvider provider;
private boolean useAnimeIndex;
private boolean useSeriesIndex;
@ -170,7 +170,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
}
@Override
public List<Match<File, ?>> match(List<File> files, final SortOrder sortOrder, final Locale locale, final boolean autodetection, final Component parent) throws Exception {
public List<Match<File, ?>> match(List<File> files, final boolean strict, final SortOrder sortOrder, final Locale locale, final boolean autodetection, final Component parent) throws Exception {
if (files.isEmpty()) {
return justFetchEpisodeList(sortOrder, locale, parent);
}
@ -182,33 +182,49 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
final List<File> mediaFiles = filter(fileset, VIDEO_FILES, SUBTITLE_FILES);
// assume that many shows will be matched, do it folder by folder
List<Callable<List<Match<File, ?>>>> taskPerFolder = new ArrayList<Callable<List<Match<File, ?>>>>();
List<Callable<List<Match<File, ?>>>> tasks = new ArrayList<Callable<List<Match<File, ?>>>>();
// remember user decisions and only bother user once
final Map<String, SearchResult> selectionMemory = new TreeMap<String, SearchResult>(CommonSequenceMatcher.getLenientCollator(Locale.ROOT));
final Map<String, List<String>> inputMemory = new TreeMap<String, List<String>>(CommonSequenceMatcher.getLenientCollator(Locale.ROOT));
// detect series names and create episode list fetch tasks
for (Entry<Set<File>, Set<String>> sameSeriesGroup : mapSeriesNamesByFiles(mediaFiles, locale, useSeriesIndex, useAnimeIndex).entrySet()) {
final List<List<File>> batchSets = new ArrayList<List<File>>();
final Collection<String> queries = sameSeriesGroup.getValue();
if (strict) {
// in strict mode simply process file-by-file (ignoring all files that don't contain clear SxE patterns)
for (final File file : mediaFiles) {
if (parseEpisodeNumber(file, true) != null || parseDate(file) != null) {
tasks.add(new Callable<List<Match<File, ?>>>() {
if (queries != null && queries.size() > 0) {
// handle series name batch set all at once -> only 1 batch set
batchSets.add(new ArrayList<File>(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());
@Override
public List<Match<File, ?>> call() throws Exception {
return matchEpisodeSet(singletonList(file), detectSeriesNames(singleton(file), useSeriesIndex, useAnimeIndex, locale), sortOrder, strict, locale, autodetection, selectionMemory, inputMemory, parent);
}
});
}
}
} else {
// in non-strict mode use the complicated (more powerful but also more error prone) match-batch-by-batch logic
for (Entry<Set<File>, Set<String>> sameSeriesGroup : mapSeriesNamesByFiles(mediaFiles, locale, useSeriesIndex, useAnimeIndex).entrySet()) {
final List<List<File>> batchSets = new ArrayList<List<File>>();
final Collection<String> queries = sameSeriesGroup.getValue();
for (final List<File> batchSet : batchSets) {
taskPerFolder.add(new Callable<List<Match<File, ?>>>() {
if (queries != null && queries.size() > 0) {
// handle series name batch set all at once -> only 1 batch set
batchSets.add(new ArrayList<File>(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());
}
@Override
public List<Match<File, ?>> call() throws Exception {
return matchEpisodeSet(batchSet, queries, sortOrder, locale, autodetection, selectionMemory, inputMemory, parent);
}
});
for (final List<File> batchSet : batchSets) {
tasks.add(new Callable<List<Match<File, ?>>>() {
@Override
public List<Match<File, ?>> call() throws Exception {
return matchEpisodeSet(batchSet, queries, sortOrder, strict, locale, autodetection, selectionMemory, inputMemory, parent);
}
});
}
}
}
@ -218,7 +234,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
try {
// merge all episodes
List<Match<File, ?>> matches = new ArrayList<Match<File, ?>>();
for (Future<List<Match<File, ?>>> future : executor.invokeAll(taskPerFolder)) {
for (Future<List<Match<File, ?>>> future : executor.invokeAll(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()));
@ -259,7 +275,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
}
}
public List<Match<File, ?>> matchEpisodeSet(final List<File> files, Collection<String> queries, SortOrder sortOrder, Locale locale, boolean autodetection, Map<String, SearchResult> selectionMemory, Map<String, List<String>> inputMemory, Component parent) throws Exception {
public List<Match<File, ?>> matchEpisodeSet(final 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 {
Set<Episode> episodes = emptySet();
// detect series name and fetch episode list
@ -273,7 +289,7 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
}
// require user input if auto-detection has failed or has been disabled
if (episodes.isEmpty()) {
if (episodes.isEmpty() && !strict) {
List<String> detectedSeriesNames = detectSeriesNames(files, useSeriesIndex, useAnimeIndex, locale);
String parentPathHint = normalizePathSeparators(getRelativePathTail(files.get(0).getParentFile(), 2).getPath());
String suggestion = detectedSeriesNames.size() > 0 ? join(detectedSeriesNames, "; ") : parentPathHint;
@ -299,10 +315,12 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
List<Match<File, ?>> matches = new ArrayList<Match<File, ?>>();
// group by subtitles first and then by files in general
for (List<File> filesPerType : mapByMediaExtension(files).values()) {
EpisodeMatcher matcher = new EpisodeMatcher(filesPerType, episodes, false);
for (Match<File, Object> it : matcher.match()) {
matches.add(new Match<File, Episode>(it.getValue(), ((Episode) it.getCandidate()).clone()));
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()) {
matches.add(new Match<File, Episode>(it.getValue(), ((Episode) it.getCandidate()).clone()));
}
}
}

View File

@ -65,7 +65,7 @@ class MovieHashMatcher implements AutoCompleteMatcher {
}
@Override
public List<Match<File, ?>> match(final List<File> files, final SortOrder sortOrder, final Locale locale, final boolean autodetect, final Component parent) throws Exception {
public List<Match<File, ?>> match(final List<File> files, final boolean strict, final SortOrder sortOrder, final Locale locale, final boolean autodetect, final Component parent) throws Exception {
if (files.isEmpty()) {
return justFetchMovieInfo(locale, parent);
}
@ -183,7 +183,23 @@ class MovieHashMatcher implements AutoCompleteMatcher {
public Map<File, Collection<Movie>> call() throws Exception {
Map<File, Collection<Movie>> detection = new LinkedHashMap<File, Collection<Movie>>();
for (File f : folder) {
detection.put(f, detectMovie(f, null, service, locale, false));
if (strict) {
// in strict mode, only process movies that follow the name (year) pattern
List<Integer> year = parseMovieYear(getRelativePathTail(f, 3).getPath());
if (year.size() > 0) {
// allow only movie matches where the the movie year matches the year pattern in the filename
List<Movie> matches = new ArrayList<Movie>();
for (Movie movie : detectMovie(f, null, service, locale, strict)) {
if (year.contains(movie.getYear())) {
matches.add(movie);
}
}
detection.put(f, matches);
}
} else {
// in non-strict mode just allow all options
detection.put(f, detectMovie(f, null, service, locale, strict));
}
}
return detection;
}
@ -200,7 +216,7 @@ class MovieHashMatcher implements AutoCompleteMatcher {
// auto-select movie or ask user
for (Entry<File, Collection<Movie>> it : detection.get().entrySet()) {
File movieFile = it.getKey();
Movie movie = grabMovieName(movieFile, it.getValue(), locale, autodetect, memory, parent);
Movie movie = grabMovieName(movieFile, it.getValue(), strict, locale, autodetect, memory, parent);
if (movie != null) {
movieByFile.put(movieFile, movie);
}
@ -262,13 +278,9 @@ class MovieHashMatcher implements AutoCompleteMatcher {
return matches;
}
protected Movie grabMovieName(File movieFile, Collection<Movie> options, Locale locale, boolean autodetect, Map<String, Object> memory, Component parent) throws Exception {
protected Movie grabMovieName(File movieFile, Collection<Movie> options, boolean strict, Locale locale, boolean autodetect, Map<String, Object> memory, Component parent) throws Exception {
// allow manual user input
if (!autodetect || options.isEmpty()) {
if (autodetect && memory.containsKey("repeat")) {
return null;
}
if (!strict && (!autodetect || options.isEmpty()) && !(autodetect && memory.containsKey("repeat"))) {
String suggestion = options.isEmpty() ? stripReleaseInfo(getName(movieFile)) : options.iterator().next().getName();
@SuppressWarnings("unchecked")
@ -284,12 +296,12 @@ class MovieHashMatcher implements AutoCompleteMatcher {
if (input != null) {
options = service.searchMovie(input, locale);
if (options.size() > 0) {
return selectMovie(movieFile, input, options, memory, parent);
return selectMovie(movieFile, strict, input, options, memory, parent);
}
}
}
return options.isEmpty() ? null : selectMovie(movieFile, null, options, memory, parent);
return options.isEmpty() ? null : selectMovie(movieFile, strict, null, options, memory, parent);
}
protected String getQueryInputMessage(File file) throws Exception {
@ -314,12 +326,12 @@ class MovieHashMatcher implements AutoCompleteMatcher {
return html.toString();
}
protected String checkedStripReleaseInfo(File file) throws Exception {
protected String checkedStripReleaseInfo(File file, boolean strict) throws Exception {
String name = stripReleaseInfo(getName(file));
// try to redeem possible false negative matches
if (name.length() < 2) {
Movie match = checkMovie(file, false);
Movie match = checkMovie(file, strict);
if (match != null) {
return match.getName();
}
@ -328,18 +340,18 @@ class MovieHashMatcher implements AutoCompleteMatcher {
return name;
}
protected Movie selectMovie(final File movieFile, final String userQuery, final Collection<Movie> options, final Map<String, Object> memory, final Component parent) throws Exception {
protected Movie selectMovie(final File movieFile, final boolean strict, final String userQuery, final Collection<Movie> options, final Map<String, Object> memory, final Component parent) throws Exception {
// just auto-pick singleton results
if (options.size() == 1) {
return options.iterator().next();
}
// 1. movie by filename
final String fileQuery = (userQuery != null) ? userQuery : checkedStripReleaseInfo(movieFile);
final String fileQuery = (userQuery != null) ? userQuery : checkedStripReleaseInfo(movieFile, strict);
// 2. movie by directory
final File movieFolder = guessMovieFolder(movieFile);
final String folderQuery = (userQuery != null || movieFolder == null) ? "" : checkedStripReleaseInfo(movieFolder);
final String folderQuery = (userQuery != null || movieFolder == null) ? "" : checkedStripReleaseInfo(movieFolder, strict);
// auto-ignore invalid files
if (userQuery == null && fileQuery.length() < 2 && folderQuery.length() < 2) {
@ -364,7 +376,7 @@ class MovieHashMatcher implements AutoCompleteMatcher {
for (Movie result : options) {
float maxSimilarity = 0;
for (String query : new String[] { fileQuery, folderQuery }) {
for (String name : result.getEffectiveNamesWithoutYear()) {
for (String name : strict ? result.getEffectiveNames() : result.getEffectiveNamesWithoutYear()) {
if (maxSimilarity >= threshold)
continue;
@ -381,6 +393,11 @@ class MovieHashMatcher implements AutoCompleteMatcher {
return probableMatches.get(0);
}
// if we haven't confirmed a match at this point then the file is probably badly named and should be ignored
if (strict) {
return null;
}
// show selection dialog on EDT
final RunnableFuture<Movie> showSelectDialog = new FutureTask<Movie>(new Callable<Movie>() {

View File

@ -97,6 +97,7 @@ public class RenamePanel extends JComponent {
private static final PreferencesEntry<String> persistentFileFormat = Settings.forPackage(RenamePanel.class).entry("rename.format.file");
private static final PreferencesEntry<String> persistentLastFormatState = Settings.forPackage(RenamePanel.class).entry("rename.last.format.state");
private static final PreferencesEntry<String> persistentPreferredMatchMode = Settings.forPackage(RenamePanel.class).entry("rename.match.mode").defaultValue("Opportunistic");
private static final PreferencesEntry<String> persistentPreferredLanguage = Settings.forPackage(RenamePanel.class).entry("rename.language").defaultValue("en");
private static final PreferencesEntry<String> persistentPreferredEpisodeOrder = Settings.forPackage(RenamePanel.class).entry("rename.episode.order").defaultValue("Airdate");
@ -392,6 +393,9 @@ public class RenamePanel extends JComponent {
@Override
public void actionPerformed(ActionEvent evt) {
String[] modes = new String[] { "Opportunistic", "Strict" };
JComboBox modeCombo = new JComboBox(modes);
List<Language> languages = new ArrayList<Language>();
languages.addAll(Language.preferredLanguages()); // add preferred languages first
languages.addAll(Language.availableLanguages()); // then others
@ -411,31 +415,41 @@ public class RenamePanel extends JComponent {
}
});
// pre-select current preferences
// restore current preference values
try {
orderCombo.setSelectedItem(SortOrder.forName(persistentPreferredEpisodeOrder.getValue()));
} catch (IllegalArgumentException e) {
// ignore
}
for (Language language : languages) {
if (language.getCode().equals(persistentPreferredLanguage.getValue())) {
languageList.setSelectedValue(language, true);
break;
modeCombo.setSelectedItem(persistentPreferredMatchMode.getValue());
for (Language language : languages) {
if (language.getCode().equals(persistentPreferredLanguage.getValue())) {
languageList.setSelectedValue(language, true);
break;
}
}
orderCombo.setSelectedItem(SortOrder.forName(persistentPreferredEpisodeOrder.getValue()));
} catch (Exception e) {
Logger.getLogger(RenamePanel.class.getName()).log(Level.WARNING, e.getMessage(), e);
}
JScrollPane spModeCombo = new JScrollPane(modeCombo, JScrollPane.VERTICAL_SCROLLBAR_NEVER, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
spModeCombo.setBorder(new CompoundBorder(new TitledBorder("Match Mode"), spModeCombo.getBorder()));
JScrollPane spLanguageList = new JScrollPane(languageList);
spLanguageList.setBorder(new CompoundBorder(new TitledBorder("Preferred Language"), spLanguageList.getBorder()));
spLanguageList.setBorder(new CompoundBorder(new TitledBorder("Language"), spLanguageList.getBorder()));
JScrollPane spOrderCombo = new JScrollPane(orderCombo, JScrollPane.VERTICAL_SCROLLBAR_NEVER, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
spOrderCombo.setBorder(new CompoundBorder(new TitledBorder("Preferred Episode Order"), spOrderCombo.getBorder()));
spOrderCombo.setBorder(new CompoundBorder(new TitledBorder("Episode Order"), spOrderCombo.getBorder()));
// fix background issues on OSX
spModeCombo.setOpaque(false);
spLanguageList.setOpaque(false);
spOrderCombo.setOpaque(false);
JPanel message = new JPanel(new MigLayout("fill, flowy, insets 0"));
message.add(spLanguageList, "grow");
message.add(spModeCombo, "grow, hmin 24px");
message.add(spLanguageList, "grow, hmin 50px");
message.add(spOrderCombo, "grow, hmin 24px");
JOptionPane pane = new JOptionPane(message, PLAIN_MESSAGE, OK_CANCEL_OPTION);
pane.createDialog(getWindowAncestor(RenamePanel.this), "Preferences").setVisible(true);
if (pane.getValue() != null && pane.getValue().equals(OK_OPTION)) {
persistentPreferredMatchMode.setValue((String) modeCombo.getSelectedItem());
persistentPreferredLanguage.setValue(((Language) languageList.getSelectedValue()).getCode());
persistentPreferredEpisodeOrder.setValue(((SortOrder) orderCombo.getSelectedItem()).name());
}
@ -631,6 +645,7 @@ public class RenamePanel extends JComponent {
renameModel.values().clear();
final List<File> remainingFiles = new LinkedList<File>(renameModel.files());
final boolean strict = "strict".equalsIgnoreCase(persistentPreferredMatchMode.getValue());
final SortOrder order = SortOrder.forName(persistentPreferredEpisodeOrder.getValue());
final Locale locale = new Locale(persistentPreferredLanguage.getValue());
final boolean autodetection = !isShiftOrAltDown(evt); // skip name auto-detection if SHIFT is pressed
@ -645,7 +660,7 @@ public class RenamePanel extends JComponent {
@Override
protected List<Match<File, ?>> doInBackground() throws Exception {
List<Match<File, ?>> matches = matcher.match(remainingFiles, order, locale, autodetection, getWindow(RenamePanel.this));
List<Match<File, ?>> matches = matcher.match(remainingFiles, strict, order, locale, autodetection, getWindow(RenamePanel.this));
// remove matched files
for (Match<File, ?> match : matches) {

View File

@ -37,6 +37,7 @@
^BTN$
^Cartoon$
^Cinema$
^Classic$
^clean$
^cleaned$
^Comedy$