+ 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:
parent
425bfb83ea
commit
097e001111
|
@ -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()) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,13 +182,28 @@ 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
|
||||
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, ?>>>() {
|
||||
|
||||
@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();
|
||||
|
@ -202,15 +217,16 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
|
|||
}
|
||||
|
||||
for (final List<File> batchSet : batchSets) {
|
||||
taskPerFolder.add(new Callable<List<Match<File, ?>>>() {
|
||||
tasks.add(new Callable<List<Match<File, ?>>>() {
|
||||
|
||||
@Override
|
||||
public List<Match<File, ?>> call() throws Exception {
|
||||
return matchEpisodeSet(batchSet, queries, sortOrder, locale, autodetection, selectionMemory, inputMemory, parent);
|
||||
return matchEpisodeSet(batchSet, queries, sortOrder, strict, locale, autodetection, selectionMemory, inputMemory, parent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// match folder per folder in parallel
|
||||
ExecutorService executor = Executors.newFixedThreadPool(getPreferredThreadPoolSize());
|
||||
|
@ -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,12 +315,14 @@ class EpisodeListMatcher implements AutoCompleteMatcher {
|
|||
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, false);
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
|
|
@ -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>() {
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
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) {
|
||||
|
|
|
@ -37,6 +37,7 @@
|
|||
^BTN$
|
||||
^Cartoon$
|
||||
^Cinema$
|
||||
^Classic$
|
||||
^clean$
|
||||
^cleaned$
|
||||
^Comedy$
|
||||
|
|
Loading…
Reference in New Issue