package net.filebot.similarity; import static java.util.Arrays.*; import static java.util.Collections.*; import static net.filebot.web.EpisodeUtilities.*; import java.io.File; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; import java.util.Set; import java.util.function.Function; import net.filebot.media.SmartSeasonEpisodeMatcher; import net.filebot.similarity.SeasonEpisodeMatcher.SxE; import net.filebot.web.Episode; import net.filebot.web.MultiEpisode; public class EpisodeMatcher extends Matcher { public EpisodeMatcher(Collection values, Collection candidates, boolean strict) { // use strict matcher as to force a result from the final top similarity set super(values, candidates, strict, new EpisodeMetrics().matchSequence()); } @Override protected void deepMatch(Collection> possibleMatches, int level) throws InterruptedException { Map> episodeSets = new IdentityHashMap>(); for (Match it : possibleMatches) { List episodes = episodeSets.get(it.getValue()); if (episodes == null) { episodes = new ArrayList(); episodeSets.put(it.getValue(), episodes); } episodes.add((Episode) it.getCandidate()); } Map> episodeIdentifierSets = new IdentityHashMap>(); for (Entry> it : episodeSets.entrySet()) { Set sxe = new HashSet(it.getValue().size()); for (Episode ep : it.getValue()) { if (ep.getSpecial() == null) { sxe.add(new SxE(ep.getSeason(), ep.getEpisode())); } else { sxe.add(new SxE(0, ep.getSpecial())); } } episodeIdentifierSets.put(it.getKey(), sxe); } boolean modified = false; for (Match it : possibleMatches) { File file = it.getValue(); Set uniqueFiles = normalizeIdentifierSet(parseEpisodeIdentifer(file)); Set uniqueEpisodes = normalizeIdentifierSet(episodeIdentifierSets.get(file)); if (uniqueFiles.equals(uniqueEpisodes)) { List episodes = episodeSets.get(file); if (episodes.size() > 1) { Episode[] episodeSequence = episodes.stream().sorted(episodeComparator()).distinct().toArray(Episode[]::new); if (isMultiEpisode(episodeSequence)) { MultiEpisode episode = new MultiEpisode(episodeSequence); disjointMatchCollection.add(new Match(file, episode)); modified = true; } } } } if (modified) { removeCollected(possibleMatches); } super.deepMatch(possibleMatches, level); } private final SeasonEpisodeMatcher seasonEpisodeMatcher = new SmartSeasonEpisodeMatcher(SeasonEpisodeMatcher.LENIENT_SANITY, false); private final Map> cache = synchronizedMap(new HashMap<>(64, 4)); private Set parseEpisodeIdentifer(File file) { return cache.computeIfAbsent(file, f -> { List sxe = seasonEpisodeMatcher.match(f.getName()); return sxe == null ? emptySet() : new HashSet(sxe); }); } private Set normalizeIdentifierSet(Set numbers) { // check if any episode exceeds the episodes per season limit int limit = 100; for (SxE it : numbers) { while (it.season > 0 && it.episode >= limit) { limit *= 10; } } // SxE 1x01 => 101 // Absolute 101 => 101 Set identifier = new HashSet(numbers.size()); for (SxE it : numbers) { if (it.season > 0 && it.episode > 0 && it.episode < limit) { identifier.add(it.season * limit + it.episode); } else if (it.season <= 0 && it.episode > 0) { identifier.add(it.episode); } } return identifier; } private boolean isMultiEpisode(Episode[] episodes) { if (episodes.length < 2) { return false; } // use getEpisode() or getSpecial() as number function Function number = stream(episodes).allMatch(e -> e.getSpecial() == null) ? e -> e.getEpisode() : e -> e.getSpecial(); // check episode sequence integrity Integer seqIndex = null; for (Episode it : episodes) { // any illegal episode object breaks the chain Integer i = number.apply(it); if (i == null) { return false; } // non-sequential next episode index breaks the chain (same episode is OK since DVD numbering allows for multiple episodes to share the same SxE numbers) if (seqIndex != null) { if (!(i.equals(seqIndex + 1) || i.equals(seqIndex))) { return false; } } seqIndex = i; } // check drill-down integrity return stream(episodes).skip(1).allMatch(e -> { // do an equals check but account for null values return Objects.equals(episodes[0].getSeriesName(), e.getSeriesName()); }); } }