package net.filebot.cli; import static java.nio.charset.StandardCharsets.*; import static java.util.Arrays.*; import static java.util.Collections.*; import static java.util.stream.Collectors.*; import static net.filebot.Logging.*; import static net.filebot.MediaTypes.*; import static net.filebot.Settings.*; import static net.filebot.WebServices.*; import static net.filebot.hash.VerificationUtilities.*; import static net.filebot.media.MediaDetection.*; import static net.filebot.media.XattrMetaInfo.*; import static net.filebot.subtitle.SubtitleUtilities.*; import static net.filebot.util.FileUtilities.*; import java.io.File; import java.io.FileFilter; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; import java.util.Set; import java.util.SortedSet; import java.util.TreeMap; import java.util.TreeSet; import java.util.logging.Level; import java.util.stream.IntStream; import java.util.stream.Stream; import net.filebot.CacheManager; import net.filebot.HistorySpooler; import net.filebot.Language; import net.filebot.RenameAction; import net.filebot.StandardRenameAction; import net.filebot.archive.Archive; import net.filebot.archive.FileMapper; import net.filebot.format.ExpressionFileFormat; import net.filebot.format.ExpressionFilter; import net.filebot.format.ExpressionFormat; import net.filebot.format.ExpressionMapper; import net.filebot.format.MediaBindingBean; import net.filebot.hash.HashType; import net.filebot.hash.VerificationFileReader; import net.filebot.hash.VerificationFileWriter; import net.filebot.media.AutoDetection; import net.filebot.media.AutoDetection.Group; import net.filebot.media.AutoDetection.Type; import net.filebot.media.LocalDatasource; import net.filebot.media.VideoQuality; import net.filebot.similarity.CommonSequenceMatcher; import net.filebot.similarity.EpisodeMatcher; import net.filebot.similarity.Match; import net.filebot.subtitle.SubtitleFormat; import net.filebot.subtitle.SubtitleNaming; import net.filebot.util.EntryList; import net.filebot.util.FileUtilities.ParentFilter; import net.filebot.vfs.FileInfo; import net.filebot.vfs.MemoryFile; import net.filebot.vfs.SimpleFileInfo; import net.filebot.web.AudioTrack; import net.filebot.web.Datasource; import net.filebot.web.Episode; import net.filebot.web.EpisodeListProvider; import net.filebot.web.MappedEpisode; import net.filebot.web.Movie; import net.filebot.web.MovieIdentificationService; import net.filebot.web.MoviePart; import net.filebot.web.MusicIdentificationService; import net.filebot.web.OpenSubtitlesClient; import net.filebot.web.SearchResult; import net.filebot.web.SortOrder; import net.filebot.web.SubtitleDescriptor; import net.filebot.web.SubtitleProvider; import net.filebot.web.VideoHashSubtitleService; public class CmdlineOperations implements CmdlineInterface { @Override public List rename(Collection files, Datasource db, String query, SortOrder order, Locale locale, ExpressionFilter filter, ExpressionMapper mapper, boolean strict, ExpressionFileFormat format, File output, RenameAction action, ConflictAction conflict, ExecCommand exec) throws Exception { // movie mode if (db instanceof MovieIdentificationService) { return renameMovie(files, action, conflict, output, format, (MovieIdentificationService) db, query, filter, locale, strict, exec); } // series mode if (db instanceof EpisodeListProvider) { return renameSeries(files, action, conflict, output, format, (EpisodeListProvider) db, query, order, filter, mapper, locale, strict, exec); } // music mode if (db instanceof MusicIdentificationService) { return renameMusic(files, action, conflict, output, format, singletonList((MusicIdentificationService) db), exec); } // photo / xattr / plain file mode if (db instanceof LocalDatasource) { return renameFiles(files, action, conflict, output, format, (LocalDatasource) db, filter, strict, exec); } // auto-detect mode for each fileset AutoDetection auto = new AutoDetection(files, false, locale); List results = new ArrayList(); for (Entry> it : auto.group().entrySet()) { if (it.getKey().types().length == 1) { for (Type key : it.getKey().types()) { switch (key) { case Movie: results.addAll(renameMovie(it.getValue(), action, conflict, output, format, TheMovieDB, query, filter, locale, strict, exec)); break; case Series: results.addAll(renameSeries(it.getValue(), action, conflict, output, format, TheTVDB, query, order, filter, mapper, locale, strict, exec)); break; case Anime: results.addAll(renameSeries(it.getValue(), action, conflict, output, format, TheTVDB, query, SortOrder.Absolute, filter, mapper, locale, strict, exec)); break; case Music: results.addAll(renameMusic(it.getValue(), action, conflict, output, format, asList(MediaInfoID3, AcoustID), exec)); // prefer existing ID3 tags and use acoustid only when necessary break; } } } else { debug.warning(format("Failed to process group: %s => %s", it.getKey(), it.getValue())); } } if (results.isEmpty()) { throw new CmdlineException("Failed to identify or process any files"); } return results; } @Override public List renameLinear(List files, EpisodeListProvider db, String query, SortOrder order, Locale locale, ExpressionFilter filter, ExpressionMapper mapper, ExpressionFileFormat format, File output, RenameAction action, ConflictAction conflict, ExecCommand exec) throws Exception { // match files and episodes in linear order List episodes = fetchEpisodeList(db, query, filter, mapper, order, locale, false); List> matches = new ArrayList>(); for (int i = 0; i < files.size() && i < episodes.size(); i++) { matches.add(new Match(files.get(i), episodes.get(i))); } // rename episodes return renameAll(formatMatches(matches, format, output), action, conflict, matches, exec); } @Override public List rename(Map renameMap, RenameAction renameAction, ConflictAction conflict) throws Exception { // generic rename function that can be passed any set of files return renameAll(renameMap, renameAction, conflict, null, null); } public List renameSeries(Collection files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, EpisodeListProvider db, String query, SortOrder sortOrder, ExpressionFilter filter, ExpressionMapper mapper, Locale locale, boolean strict, ExecCommand exec) throws Exception { log.config(format("Rename episodes using [%s] with [%s]", db.getName(), db.vetoRequestParameter(sortOrder))); // ignore sample files List fileset = sortByUniquePath(filter(files, not(getClutterFileFilter()))); List mediaFiles = filter(fileset, VIDEO_FILES, SUBTITLE_FILES); if (mediaFiles.isEmpty()) { throw new CmdlineException("No media files: " + files); } // similarity metrics for matching List> matches = new ArrayList>(); // auto-determine optimal batch sets for (Entry, Set> sameSeriesGroup : mapSeriesNamesByFiles(mediaFiles, locale, db == AniDB).entrySet()) { List> batchSets = new ArrayList>(); if (sameSeriesGroup.getValue() != null && sameSeriesGroup.getValue().size() > 0) { // handle series name batch set all at once batchSets.add(new ArrayList(sameSeriesGroup.getKey())); } else { // these files don't seem to belong to any series -> handle folder per folder batchSets.addAll(mapByFolder(sameSeriesGroup.getKey()).values()); } for (List batch : batchSets) { // fetch episode data List episodes; if (query == null) { Collection seriesNames = detectSeriesNames(batch, db == AniDB, locale); // detect series name by common word sequence log.config("Auto-detected query: " + seriesNames); if (seriesNames.size() == 0) { log.warning("Failed to detect query for files: " + batch); continue; } if (strict && seriesNames.size() > 1) { throw new CmdlineException("Multiple queries: Processing multiple shows at once requires -non-strict matching: " + seriesNames); } episodes = fetchEpisodeSet(db, seriesNames, sortOrder, locale, strict, 5); // consider episodes of up to N search results for each query } else { if (isSeriesID(query)) { episodes = db.getEpisodeList(Integer.parseInt(query), sortOrder, locale); } else { episodes = fetchEpisodeSet(db, singleton(query), sortOrder, locale, false, 1); // use --q option and pick first result } } if (episodes.isEmpty()) { continue; } // filter episodes and apply custom mappings episodes = applyExpressionFilter(episodes, filter); episodes = applyEpisodeExpressionMapper(episodes, mapper); for (List filesPerType : mapByMediaExtension(filter(batch, VIDEO_FILES, SUBTITLE_FILES)).values()) { matchEpisodes(filesPerType, episodes, strict).stream().map(this::unmap).forEach(matches::add); } } } if (matches.isEmpty()) { throw new CmdlineException("Failed to match files to episode data"); } // 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); // rename episodes return renameAll(formatMatches(matches, format, outputDir), renameAction, conflictAction, matches, exec); } private Match unmap(Match match) { // add matches and unmap mapped episodes if (match.getCandidate() instanceof MappedEpisode) { MappedEpisode mapping = (MappedEpisode) match.getCandidate(); log.fine(format("Reverse Map [%s] to [%s]", mapping.getMapping(), mapping.getOriginal())); return new Match(match.getValue(), mapping.getOriginal()); } return match; } private List> matchEpisodes(Collection files, Collection episodes, boolean strict) throws Exception { // always use strict fail-fast matcher EpisodeMatcher matcher = new EpisodeMatcher(files, episodes, strict); List> matches = matcher.match(); for (File failedMatch : matcher.remainingValues()) { log.warning("No matching episode: " + failedMatch.getName()); } // in non-strict mode just pass back results as we got it from the matcher if (!strict) { return matches; } // in strict mode sanity check the result and only pass back good matches List> validMatches = new ArrayList>(); for (Match it : matches) { if (isEpisodeNumberMatch(it.getValue(), (Episode) it.getCandidate())) { validMatches.add(it); } } return validMatches; } private List fetchEpisodeSet(EpisodeListProvider db, Collection names, SortOrder sortOrder, Locale locale, boolean strict, int limit) throws Exception { Set shows = new LinkedHashSet(); Set episodes = new LinkedHashSet(); // detect series names and create episode list fetch tasks for (String query : names) { List results = db.search(query, locale); // select search result if (results.size() > 0) { List selectedSearchResults = selectSearchResult(query, results, true, true, strict, limit); if (selectedSearchResults != null) { for (SearchResult it : selectedSearchResults) { if (shows.add(it)) { try { log.fine(format("Fetching episode data for [%s]", it.getName())); episodes.addAll(db.getEpisodeList(it, sortOrder, locale)); } catch (IOException e) { throw new CmdlineException(String.format("Failed to fetch episode data for [%s]: %s", it, e.getMessage()), e); } } } } } } if (episodes.isEmpty()) { log.warning("Failed to fetch episode data: " + names); } return new ArrayList(episodes); } public List renameMovie(Collection files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, MovieIdentificationService service, String query, ExpressionFilter filter, Locale locale, boolean strict, ExecCommand exec) throws Exception { log.config(format("Rename movies using [%s]", service.getName())); // ignore sample files List fileset = sortByUniquePath(filter(files, not(getClutterFileFilter()))); // handle movie files Set movieFiles = new TreeSet(filter(fileset, VIDEO_FILES)); Set nfoFiles = new TreeSet(filter(fileset, NFO_FILES)); List orphanedFiles = new ArrayList(filter(fileset, FILES)); orphanedFiles.removeAll(movieFiles); orphanedFiles.removeAll(nfoFiles); Map> derivatesByMovieFile = new HashMap>(); for (File movieFile : movieFiles) { derivatesByMovieFile.put(movieFile, new ArrayList()); } for (File file : orphanedFiles) { List orphanParent = listPath(file); for (File movieFile : movieFiles) { if (orphanParent.contains(movieFile.getParentFile()) && isDerived(file, movieFile)) { derivatesByMovieFile.get(movieFile).add(file); break; } } } for (List derivates : derivatesByMovieFile.values()) { orphanedFiles.removeAll(derivates); } // match movie hashes online Map movieByFile = new TreeMap(); if (query == null) { // collect useful nfo files even if they are not part of the selected fileset Set effectiveNfoFileSet = new TreeSet(nfoFiles); for (File dir : mapByFolder(movieFiles).keySet()) { effectiveNfoFileSet.addAll(getChildren(dir, NFO_FILES)); } for (File dir : filter(fileset, FOLDERS)) { effectiveNfoFileSet.addAll(getChildren(dir, NFO_FILES)); } for (File nfo : effectiveNfoFileSet) { try { Movie movie = grepMovie(nfo, service, locale); // ignore illegal nfos if (movie == null) { continue; } if (nfoFiles.contains(nfo)) { movieByFile.put(nfo, movie); } if (isDiskFolder(nfo.getParentFile())) { // special handling for disk folders for (File folder : fileset) { if (nfo.getParentFile().equals(folder)) { movieByFile.put(folder, movie); } } } else { // match movie info to movie files that match the nfo file name SortedSet siblingMovieFiles = new TreeSet(filter(movieFiles, new ParentFilter(nfo.getParentFile()))); String baseName = stripReleaseInfo(getName(nfo)).toLowerCase(); for (File movieFile : siblingMovieFiles) { if (!baseName.isEmpty() && stripReleaseInfo(getName(movieFile)).toLowerCase().startsWith(baseName)) { movieByFile.put(movieFile, movie); } } } } catch (Exception e) { log.log(Level.WARNING, "Failed to grep IMDbID: " + nfo.getName(), e); } } } else { log.fine(format("Looking up movie by query [%s]", query)); List results = service.searchMovie(query, locale); List options = applyExpressionFilter(results, filter); if (options.isEmpty()) { throw new CmdlineException("Failed to find a valid match: " + results); } // force all mappings Movie movie = selectSearchResult(query, options); for (File file : files) { movieByFile.put(file, movie); } } // collect files that will be matched one by one List movieMatchFiles = new ArrayList(); movieMatchFiles.addAll(movieFiles); movieMatchFiles.addAll(nfoFiles); movieMatchFiles.addAll(filter(files, FOLDERS)); movieMatchFiles.addAll(filter(orphanedFiles, SUBTITLE_FILES)); // run movie detection only on orphaned subtitle files // sanity check that we have something to do if (fileset.isEmpty() || movieMatchFiles.isEmpty()) { throw new CmdlineException("No media files: " + files); } // map movies to (possibly multiple) files (in natural order) Map> filesByMovie = new HashMap>(); // map all files by movie for (File file : movieMatchFiles) { Movie movie = movieByFile.get(file); // unknown hash, try via imdb id from nfo file if (movie == null) { log.fine(format("Auto-detect movie from context: [%s]", file)); List options = detectMovieWithYear(file, service, locale, strict); // ignore files that cannot yield any acceptable matches (e.g. movie files without year in strict mode) if (options == null) { continue; } // apply filter if defined options = applyExpressionFilter(options, filter); // reduce options to perfect matches if possible List perfectMatches = matchMovieByWordSequence(getName(file), options, 0); // narrow down options if possible if (perfectMatches.size() > 0) { options = perfectMatches; } try { // select first element if matches are reliable if (options.size() > 0) { movie = selectSearchResult(stripReleaseInfo(getName(file)), options); // make sure to get the language-specific movie object for the selected option movie = getLocalizedMovie(service, movie, locale); } } catch (Exception e) { log.warning(cause(e)); } } // check if we managed to lookup the movie descriptor if (movie != null) { // add to file list for movie filesByMovie.computeIfAbsent(movie, k -> new TreeSet()).add(file); } } // collect all File/MoviePart matches List> matches = new ArrayList>(); filesByMovie.forEach((movie, fs) -> { groupByMediaCharacteristics(fs).forEach(moviePartFiles -> { // resolve movie parts for (int i = 0; i < moviePartFiles.size(); i++) { Movie moviePart = moviePartFiles.size() == 1 ? movie : new MoviePart(movie, i + 1, moviePartFiles.size()); matches.add(new Match(moviePartFiles.get(i), moviePart.clone())); // automatically add matches for derived files List derivates = derivatesByMovieFile.get(moviePartFiles.get(i)); if (derivates != null) { for (File derivate : derivates) { matches.add(new Match(derivate, moviePart.clone())); } } } }); }); // rename movies return renameAll(formatMatches(matches, format, outputDir), renameAction, conflictAction, matches, exec); } public List renameMusic(Collection files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, List services, ExecCommand exec) throws Exception { List audioFiles = sortByUniquePath(filter(files, AUDIO_FILES, VIDEO_FILES)); // check audio files against all services if necessary List> matches = new ArrayList>(); LinkedHashSet remaining = new LinkedHashSet(audioFiles); // check audio files against all services for (MusicIdentificationService service : services) { if (remaining.size() > 0) { log.config(format("Rename music using %s", service.getIdentifier())); service.lookup(remaining).forEach((file, music) -> { if (music != null) { matches.add(new Match(file, music.clone())); remaining.remove(file); } }); } } // error logging remaining.forEach(f -> log.warning(format("Failed to process music file: %s", f))); // rename movies return renameAll(formatMatches(matches, format, outputDir), renameAction, conflictAction, null, exec); } public List renameFiles(Collection files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, LocalDatasource service, ExpressionFilter filter, boolean strict, ExecCommand exec) throws Exception { log.config(format("Rename files using [%s]", service.getName())); Map renameMap = new LinkedHashMap(); // match to xattr metadata object or the file itself Map matches = service.match(files, strict); service.match(files, strict).forEach((k, v) -> { MediaBindingBean bindingBean = new MediaBindingBean(v, k, matches); if (filter == null || filter.matches(bindingBean)) { String destinationPath = format != null ? format.format(bindingBean) : v instanceof File ? v.toString() : validateFileName(v.toString()); renameMap.put(k, getDestinationFile(k, destinationPath, outputDir)); } }); return renameAll(renameMap, renameAction, conflictAction, null, exec); } private Map getContext(List> matches) { return new AbstractMap() { @Override public Set> entrySet() { return matches.stream().collect(toMap(it -> it.getValue(), it -> (Object) it.getCandidate(), (a, b) -> a, LinkedHashMap::new)).entrySet(); } }; } private File getDestinationFile(File original, String newName, File outputDir) { String extension = getExtension(original); File newFile = new File(extension != null ? newName + '.' + extension.toLowerCase() : newName); // resolve against output dir if (outputDir != null && !newFile.isAbsolute()) { newFile = new File(outputDir, newFile.getPath()); } if (isInvalidFilePath(newFile) && !isUnixFS()) { log.config("Stripping invalid characters from new path: " + newName); newFile = validateFilePath(newFile); } return newFile; } private Map formatMatches(List> matches, ExpressionFileFormat format, File outputDir) throws Exception { // map old files to new paths by applying formatting and validating filenames Map renameMap = new LinkedHashMap(); for (Match match : matches) { File file = match.getValue(); Object object = match.getCandidate(); String destinationPath = format != null ? format.format(new MediaBindingBean(object, file, getContext(matches))) : validateFileName(object.toString()); renameMap.put(file, getDestinationFile(file, destinationPath, outputDir)); } return renameMap; } protected List renameAll(Map renameMap, RenameAction renameAction, ConflictAction conflictAction, List> matches, ExecCommand exec) throws Exception { // flush all memory caches to disk (before starting any long running file system operations that might be cancelled by the user) CacheManager.getInstance().flushAll(); if (renameMap.isEmpty()) { throw new CmdlineException("Failed to identify or process any files"); } // allow --action test for evaluation purposes if (renameAction != StandardRenameAction.TEST) { LICENSE.check(); } // rename files Map renameLog = new LinkedHashMap(); try { for (Entry it : renameMap.entrySet()) { try { File source = it.getKey(); File destination = it.getValue(); // resolve destination if (!destination.isAbsolute()) { // same folder, different name destination = resolve(source, destination); } if (!destination.equals(source) && existsNoFollowLinks(destination)) { if (conflictAction == ConflictAction.FAIL) { throw new CmdlineException(String.format("Failed to process [%s] because [%s] already exists", source, destination)); } // do not allow abuse of online databases by repeatedly processing the same files if (matches != null && renameAction.canRevert() && source.length() > 0 && equalsLastModified(source, destination, 2000) && equalsFileContent(source, destination)) { throw new CmdlineException(String.format("Failed to process [%s] because [%s] is an exact copy and already exists [Last-Modified: %tc]", source, destination, destination.lastModified())); } // delete existing destination path if necessary if (conflictAction == ConflictAction.OVERRIDE || (conflictAction == ConflictAction.AUTO && VideoQuality.isBetter(source, destination))) { log.fine(format("[%s] Delete [%s]", conflictAction, destination)); // do not actually delete files in test mode if (renameAction != StandardRenameAction.TEST) { try { delete(destination); } catch (Exception e) { log.warning(format("[%s] Failed to delete [%s]: %s", conflictAction, destination, e)); } } } // generate indexed destination path if necessary if (conflictAction == ConflictAction.INDEX) { destination = nextAvailableIndexedName(destination); } } // rename file, throw exception on failure if (!destination.equals(source) && !destination.exists()) { log.info(format("[%s] from [%s] to [%s]", renameAction, source, destination)); destination = renameAction.rename(source, destination); // remember successfully renamed matches for history entry and possible revert renameLog.put(source, destination); } else { log.info(format("Skipped [%s] because [%s] already exists", source, destination)); } } catch (IOException e) { log.warning(format("[%s] Failure: %s", renameAction, e)); throw e; } } } finally { // update history and xattr metadata if (renameLog.size() > 0) { writeHistory(renameAction, renameLog, matches); } // print number of processed files log.fine(format("Processed %d files", renameLog.size())); } // execute command if (exec != null) { exec.execute(renameLog.values().stream().map(f -> new MediaBindingBean(xattr.getMetaInfo(f), f))).forEach(r -> { // consume stream and ignore exit codes }); } return new ArrayList(renameLog.values()); } protected void writeHistory(RenameAction action, Map log, List> matches) { // write rename history if (action.canRevert()) { HistorySpooler.getInstance().append(log.entrySet()); } // write xattr metadata if (matches != null) { for (Match match : matches) { if (match.getCandidate() != null) { File source = match.getValue(); File destination = log.get(source); if (destination != null && destination.isFile()) { // remember Last Modified date long timestamp = source.isFile() ? source.lastModified() : destination.lastModified(); // store xattr xattr.setMetaInfo(destination, match.getCandidate(), match.getValue().getName()); // restore Last Modified date destination.setLastModified(timestamp); } } } } } protected File nextAvailableIndexedName(File file) { File parent = file.getParentFile(); String name = getName(file); String ext = getExtension(file); return IntStream.range(1, 100).mapToObj(i -> new File(parent, name + '.' + i + '.' + ext)).filter(f -> !f.exists()).findFirst().get(); } @Override public List getSubtitles(Collection files, String query, Language language, SubtitleFormat output, Charset encoding, SubtitleNaming format, boolean strict) throws Exception { // ignore anything that is not a video files = filter(files, VIDEO_FILES); // ignore sample files files = sortByUniquePath(filter(files, not(getClutterFileFilter()))); // try to find subtitles for each video file List remainingVideos = new ArrayList(files); // parallel download List subtitleFiles = new ArrayList(); log.finest(format("Get [%s] subtitles for %d files", language.getName(), remainingVideos.size())); if (remainingVideos.isEmpty()) { throw new CmdlineException("No video files: " + files); } // lookup subtitles by hash for (VideoHashSubtitleService service : getVideoHashSubtitleServices(language.getLocale())) { if (remainingVideos.isEmpty() || !requireLogin(service)) { continue; } try { log.fine("Looking up subtitles by hash via " + service.getName()); Map> options = lookupSubtitlesByHash(service, remainingVideos, language.getLocale(), false, strict); Map downloads = downloadSubtitleBatch(service, options, output, encoding, format); remainingVideos.removeAll(downloads.keySet()); subtitleFiles.addAll(downloads.values()); } catch (Exception e) { log.warning("Lookup by hash failed: " + e.getMessage()); } } for (SubtitleProvider service : getSubtitleProviders(language.getLocale())) { if (strict || remainingVideos.isEmpty() || !requireLogin(service)) { continue; } try { log.fine(format("Looking up subtitles by name via %s", service.getName())); Map> options = findSubtitlesByName(service, remainingVideos, language.getLocale(), query, false, strict); Map downloads = downloadSubtitleBatch(service, options, output, encoding, format); remainingVideos.removeAll(downloads.keySet()); subtitleFiles.addAll(downloads.values()); } catch (Exception e) { log.warning(format("Search by name failed: %s", e.getMessage())); } } // no subtitles for remaining video files for (File it : remainingVideos) { log.warning("No matching subtitles found: " + it); } return subtitleFiles; } protected static boolean requireLogin(Object service) { if (service instanceof OpenSubtitlesClient) { OpenSubtitlesClient osdb = (OpenSubtitlesClient) service; if (osdb.isAnonymous()) { throw new CmdlineException(String.format("%s: Please enter your login details by calling `filebot -script fn:configure`", osdb.getName())); } } return true; // no login => logged in by default } @Override public List getMissingSubtitles(Collection files, String query, Language language, SubtitleFormat output, Charset encoding, SubtitleNaming format, boolean strict) throws Exception { List videoFiles = filter(filter(files, VIDEO_FILES), new FileFilter() { // save time on repeating filesystem calls private Map> cache = new HashMap>(); public boolean matchesLanguageCode(File f) { Language languageSuffix = Language.getLanguage(releaseInfo.getSubtitleLanguageTag(getName(f))); if (languageSuffix != null) { return languageSuffix.getCode().equals(language.getCode()); } return false; } @Override public boolean accept(File video) { if (!video.isFile()) { return false; } List subtitleFiles = cache.computeIfAbsent(video.getParentFile(), parent -> { return getChildren(parent, SUBTITLE_FILES); }); // can't tell which subtitle belongs to which file -> if any subtitles exist skip the whole folder if (format == SubtitleNaming.ORIGINAL) { return subtitleFiles.size() == 0; } return subtitleFiles.stream().allMatch(f -> { if (isDerived(f, video)) { return format != SubtitleNaming.MATCH_VIDEO && !matchesLanguageCode(f); } return true; }); } }); if (videoFiles.isEmpty()) { log.info("No missing subtitles"); return emptyList(); } return getSubtitles(videoFiles, query, language, output, encoding, format, strict); } private Map downloadSubtitleBatch(Datasource service, Map> subtitles, SubtitleFormat outputFormat, Charset outputEncoding, SubtitleNaming naming) { // flush all memory caches to disk (before starting any long running file system operations that might be cancelled by the user) CacheManager.getInstance().flushAll(); Map downloads = new LinkedHashMap(); // fetch subtitle subtitles.forEach((movie, options) -> { if (options.size() > 0) { SubtitleDescriptor subtitle = options.get(0); try { downloads.put(movie, downloadSubtitle(service, subtitle, movie, outputFormat, outputEncoding, naming)); } catch (Exception e) { log.warning(format("Failed to download %s: %s", subtitle, e)); } } }); return downloads; } private File downloadSubtitle(Datasource service, SubtitleDescriptor descriptor, File movieFile, SubtitleFormat outputFormat, Charset outputEncoding, SubtitleNaming naming) throws Exception { // fetch subtitle archive log.config(format("Fetching [%s] subtitles [%s] from [%s]", descriptor.getLanguageName(), descriptor.getPath(), service.getName())); MemoryFile subtitleFile = fetchSubtitle(descriptor); // subtitle filename is based on movie filename String extension = getExtension(subtitleFile.getName()); ByteBuffer data = subtitleFile.getData(); if (outputFormat != null || outputEncoding != null) { // adjust extension of the output file if (outputFormat != null) { extension = outputFormat.getFilter().extension(); } // default to UTF-8 if no other encoding is given if (outputEncoding == null) { outputEncoding = UTF_8; } log.finest(format("Export [%s] as [%s / %s]", subtitleFile.getName(), outputFormat, outputEncoding)); data = exportSubtitles(subtitleFile, outputFormat, 0, outputEncoding); } File destination = new File(movieFile.getParentFile(), naming.format(movieFile, descriptor, extension)); log.info(format("Writing [%s] to [%s]", subtitleFile.getName(), destination.getName())); writeFile(data, destination); return destination; } protected List applyExpressionFilter(List input, ExpressionFilter filter) { if (filter == null) { return input; } log.fine(formatSingleLine("Apply filter [%s] on [%d] items", filter.getExpression(), input.size())); // support context bindings Map context = new EntryList(null, input); return input.stream().filter(it -> { if (filter.matches(new MediaBindingBean(it, null, context))) { log.finest(format("Include [%s]", it)); return true; } return false; }).collect(toList()); } protected List applyEpisodeExpressionMapper(List episodes, ExpressionMapper mapper) { if (mapper == null) { return episodes; } log.fine(formatSingleLine("Apply mapper [%s] on [%d] items", mapper.getExpression(), episodes.size())); // support episode list context Map context = new EntryList(null, episodes); return episodes.stream().map(episode -> { try { Episode mapping = mapper.map(new MediaBindingBean(episode, null, context), Episode.class); log.finest(format("Map [%s] to [%s]", episode, mapping)); return new MappedEpisode(episode, mapping); } catch (Exception e) { debug.warning(format("Exclude [%s] due to map failure: %s", episode, e)); return null; } }).filter(Objects::nonNull).distinct().collect(toList()); } protected T selectSearchResult(String query, Collection options) throws Exception { List matches = selectSearchResult(query, options, false, false, false, 1); return matches.size() > 0 ? matches.get(0) : null; } protected List selectSearchResult(String query, Collection options, boolean sort, boolean alias, boolean strict, int limit) throws Exception { List probableMatches = getProbableMatches(sort ? query : null, options, alias, strict); if (probableMatches.isEmpty() || (strict && probableMatches.size() != 1)) { // allow single search results to just pass through in non-strict mode even if match confidence is low if (options.size() == 1 && !strict) { return options.stream().collect(toList()); } if (strict) { throw new CmdlineException("Multiple options: Advanced auto-selection requires -non-strict matching: " + probableMatches); } // just pick the best N matches if (sort) { probableMatches = sortBySimilarity(options, singleton(query), getSeriesMatchMetric()).stream().collect(toList()); } } // return first and only value return probableMatches.size() <= limit ? probableMatches : probableMatches.subList(0, limit); // trust that the correct match is in the Top N } @Override public boolean check(Collection files) throws Exception { // only check existing hashes boolean result = true; for (File it : filter(files, VERIFICATION_FILES)) { result &= check(it, it.getParentFile()); } return result; } @Override public File compute(Collection files, HashType hash, File output, Charset encoding) throws Exception { // ignore folders and any sort of special files files = filter(files, FILES); if (files.isEmpty()) { throw new CmdlineException("No files: " + files); } // find common parent folder of all files File[] fileList = files.toArray(new File[0]); File[][] pathArray = new File[fileList.length][]; for (int i = 0; i < fileList.length; i++) { pathArray[i] = listPath(fileList[i].getParentFile()).toArray(new File[0]); } CommonSequenceMatcher csm = new CommonSequenceMatcher(null, 0, true); File[] common = csm.matchFirstCommonSequence(pathArray); if (common == null) { throw new CmdlineException("All paths must be on the same filesystem: " + files); } // last element in the common sequence must be the root folder File root = common[common.length - 1]; if (output == null) { output = new File(root, root.getName() + '.' + hash.getFilter().extension()); } else if (!output.isAbsolute()) { output = new File(root, output.getPath()); } log.info(format("Compute %s hash for %s files [%s]", hash, files.size(), output)); compute(root, files, output, hash, encoding); return output; } private boolean check(File verificationFile, File root) throws Exception { HashType type = getHashType(verificationFile); // check if type is supported if (type == null) { throw new CmdlineException("Unsupported format: " + verificationFile); } // add all file names from verification file log.fine(format("Checking [%s]", verificationFile.getName())); VerificationFileReader parser = new VerificationFileReader(createTextReader(verificationFile), type.getFormat()); boolean status = true; try { while (parser.hasNext()) { try { Entry it = parser.next(); File file = new File(root, it.getKey().getPath()).getAbsoluteFile(); String current = computeHash(new File(root, it.getKey().getPath()), type); log.info(format("%s %s", current, file)); if (current.compareToIgnoreCase(it.getValue()) != 0) { throw new IOException(String.format("Corrupted file found: %s [hash mismatch: %s vs %s]", it.getKey(), current, it.getValue())); } } catch (IOException e) { status = false; log.warning(e.getMessage()); } } } finally { parser.close(); } return status; } private void compute(File root, Collection files, File outputFile, HashType hashType, Charset encoding) throws IOException, Exception { // compute hashes recursively and write to file VerificationFileWriter out = new VerificationFileWriter(outputFile, hashType.getFormat(), encoding != null ? encoding : UTF_8); try { for (File it : files) { if (it.isHidden() || VERIFICATION_FILES.accept(it)) { continue; } String relativePath = normalizePathSeparators(it.getPath().substring(root.getPath().length() + 1)); // skip root and first slash String hash = computeHash(it, hashType); log.info(format("%s %s", hash, relativePath)); out.write(relativePath, hash); } } catch (Exception e) { outputFile.deleteOnExit(); // delete only partially written files throw e; } finally { out.close(); } } private List fetchEpisodeList(EpisodeListProvider db, String query, ExpressionFilter filter, ExpressionMapper mapper, SortOrder order, Locale locale, boolean strict) throws Exception { // sanity check if (query == null) { throw new CmdlineException(String.format("%s: query parameter is required", db.getName())); } // collect all episode objects first List episodes = new ArrayList(); if (isSeriesID(query)) { // lookup by id episodes.addAll(db.getEpisodeList(Integer.parseInt(query), order, locale)); } else { // search by name and select search result List options = selectSearchResult(query, db.search(query, locale), false, false, false, strict ? 1 : 5); // fetch episodes for (SearchResult option : options) { episodes.addAll(db.getEpisodeList(option, order, locale)); } } // sanity check if (episodes.isEmpty()) { throw new CmdlineException(String.format("%s: no results", db.getName())); } // filter episodes and apply custom mappings episodes = applyExpressionFilter(episodes, filter); episodes = applyEpisodeExpressionMapper(episodes, mapper); return episodes; } private boolean isSeriesID(String query) { return query.matches("\\d{5,9}"); } @Override public Stream fetchEpisodeList(EpisodeListProvider db, String query, SortOrder order, Locale locale, ExpressionFilter filter, ExpressionMapper mapper, ExpressionFormat format, boolean strict) throws Exception { // collect all episode objects first List episodes = fetchEpisodeList(db, query, filter, mapper, order, locale, strict); // instant format if (format == null) { return episodes.stream().map(Episode::toString); } // lazy format return episodes.stream().map(episode -> { try { return format.format(new MediaBindingBean(episode, null, new EntryList(null, episodes))); } catch (Exception e) { debug.warning(e::getMessage); } return null; }).filter(Objects::nonNull); } @Override public Stream getMediaInfo(Collection files, FileFilter filter, ExpressionFormat format) throws Exception { // use default expression format if not set if (format == null) { return getMediaInfo(files, filter, new ExpressionFormat("{fn} [{resolution} {vc} {channels} {ac} {hours} {mbps}]")); } return files.stream().filter(filter::accept).map(f -> { try { return format.format(new MediaBindingBean(xattr.getMetaInfo(f), f)); } catch (Exception e) { debug.warning(e::getMessage); } return null; }).filter(Objects::nonNull); } @Override public IntStream execute(Collection files, FileFilter filter, ExpressionFormat format, ExecCommand exec) { // filter / map / execute return exec.execute(files.stream().filter(filter::accept).map(f -> new MediaBindingBean(xattr.getMetaInfo(f), f)).peek(b -> { if (format != null) { try { log.info(format.format(b)); } catch (Exception e) { debug.warning(e::getMessage); } } })); } @Override public List revert(Collection files, FileFilter filter, RenameAction action) throws Exception { if (files.isEmpty()) { throw new CmdlineException("Expecting at least one input path"); } Set whitelist = new HashSet(files); Map history = HistorySpooler.getInstance().getCompleteHistory().getRenameMap(); return history.entrySet().stream().filter(it -> { File original = it.getKey(); File current = it.getValue(); return Stream.of(current, original).flatMap(f -> listPath(f).stream()).anyMatch(whitelist::contains) && current.exists() && filter.accept(current); }).map(it -> { File original = it.getKey(); File current = it.getValue(); log.info(format("Revert [%s] to [%s]", current, original)); if (action.canRevert()) { try { return StandardRenameAction.revert(current, original); } catch (Exception e) { log.warning("Failed to revert file: " + e); } } return null; }).filter(Objects::nonNull).collect(toList()); } @Override public List extract(Collection files, File output, ConflictAction conflict, FileFilter filter, boolean forceExtractAll) throws Exception { // only keep single-volume archives or first part of multi-volume archives List archiveFiles = filter(files, Archive.VOLUME_ONE_FILTER); List extractedFiles = new ArrayList(); for (File file : archiveFiles) { Archive archive = Archive.open(file); try { File outputFolder = output; if (outputFolder == null || !outputFolder.isAbsolute()) { outputFolder = new File(file.getParentFile(), outputFolder == null ? getName(file) : outputFolder.getPath()).getCanonicalFile(); } log.info(format("Read archive [%s] and extract to [%s]", file.getName(), outputFolder)); createFolders(outputFolder); FileMapper outputMapper = new FileMapper(outputFolder); List outputMapping = new ArrayList(); for (FileInfo it : archive.listFiles()) { File outputPath = outputMapper.getOutputFile(it.toFile()); outputMapping.add(new SimpleFileInfo(outputPath.getPath(), it.getLength())); } // print warning message if archive appears empty if (outputMapping.isEmpty()) { log.warning(format("[%s] contains [%s] files", file.getName(), outputMapping.size())); } Set selection = new TreeSet(); for (FileInfo future : outputMapping) { if (filter == null || filter.accept(future.toFile())) { selection.add(future); } } // check if there is anything to extract at all if (selection.isEmpty()) { continue; } boolean skip = true; for (FileInfo future : filter == null || forceExtractAll ? outputMapping : selection) { if (conflict == ConflictAction.AUTO) { skip &= (future.toFile().exists() && future.getLength() == future.toFile().length()); } else { skip &= (future.toFile().exists()); } } if (!skip || conflict == ConflictAction.OVERRIDE) { if (filter == null || forceExtractAll) { log.finest("Extracting files " + outputMapping); // extract all files archive.extract(outputMapper.getOutputDir()); for (FileInfo it : outputMapping) { extractedFiles.add(it.toFile()); } } else { log.finest("Extracting files " + selection); // extract files selected by the given filter archive.extract(outputMapper.getOutputDir(), outputMapper.newPathFilter(selection)); for (FileInfo it : selection) { extractedFiles.add(it.toFile()); } } } else { log.finest("Skipped extracting files " + selection); } } finally { archive.close(); } } return extractedFiles; } }