diff --git a/source/net/sourceforge/filebot/Main.java b/source/net/sourceforge/filebot/Main.java index 07ef70a5..3b4018cb 100644 --- a/source/net/sourceforge/filebot/Main.java +++ b/source/net/sourceforge/filebot/Main.java @@ -21,11 +21,11 @@ import javax.swing.SwingUtilities; import javax.swing.UIManager; import org.kohsuke.args4j.CmdLineException; -import org.kohsuke.args4j.CmdLineParser; import net.sf.ehcache.CacheManager; import net.sourceforge.filebot.cli.ArgumentBean; import net.sourceforge.filebot.cli.ArgumentProcessor; +import net.sourceforge.filebot.cli.CmdlineOperations; import net.sourceforge.filebot.format.ExpressionFormat; import net.sourceforge.filebot.ui.MainFrame; import net.sourceforge.filebot.ui.SinglePanelFrame; @@ -43,58 +43,52 @@ public class Main { initializeCache(); initializeSecurityManager(); - // parse arguments - final ArgumentBean argumentBean = new ArgumentBean(); - - if (args != null && args.length > 0) { - try { - CmdLineParser parser = new CmdLineParser(argumentBean); - parser.parseArgument(args); - } catch (CmdLineException e) { - System.out.println(e.getMessage()); - - // just print CLI error message and stop - System.exit(-1); + try { + // parse arguments + final ArgumentProcessor cli = new ArgumentProcessor(); + final ArgumentBean argumentBean = cli.parse(args); + + // initialize analytics + Analytics.setEnabled(!argumentBean.disableAnalytics); + + if (argumentBean.printHelp()) { + // just print help message and exit afterwards + cli.printHelp(argumentBean); + System.exit(0); } - } - - if (argumentBean.printHelp()) { - new CmdLineParser(argumentBean).printUsage(System.out); - // just print help message and exit afterwards - System.exit(0); - } - - if (argumentBean.clearUserData()) { - // clear preferences and cache - Settings.forPackage(Main.class).clear(); - CacheManager.getInstance().clearAll(); - } - - // initialize analytics - Analytics.setEnabled(!argumentBean.disableAnalytics); - - // run command-line interface and then exit - if (argumentBean.runCLI()) { - int status = new ArgumentProcessor().process(argumentBean); - System.exit(status); - } - - // start user interface - SwingUtilities.invokeLater(new Runnable() { + if (argumentBean.clearUserData()) { + // clear preferences and cache + Settings.forPackage(Main.class).clear(); + CacheManager.getInstance().clearAll(); + } - @Override - public void run() { - try { - // use native laf an all platforms - UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); - } catch (Exception e) { - Logger.getLogger(Main.class.getName()).log(Level.WARNING, e.getMessage(), e); + // CLI mode => run command-line interface and then exit + if (argumentBean.runCLI()) { + int status = cli.process(argumentBean, new CmdlineOperations()); + System.exit(status); + } + + // GUI mode => start user interface + SwingUtilities.invokeLater(new Runnable() { + + @Override + public void run() { + try { + // use native laf an all platforms + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception e) { + Logger.getLogger(Main.class.getName()).log(Level.WARNING, e.getMessage(), e); + } + + startUserInterface(argumentBean); } - - startUserInterface(argumentBean); - } - }); + }); + } catch (CmdLineException e) { + // illegal arguments => just print CLI error message and stop + System.err.println(e.getMessage()); + System.exit(-1); + } } diff --git a/source/net/sourceforge/filebot/cli/ArgumentBean.java b/source/net/sourceforge/filebot/cli/ArgumentBean.java index e07ad17a..42a6e7ad 100644 --- a/source/net/sourceforge/filebot/cli/ArgumentBean.java +++ b/source/net/sourceforge/filebot/cli/ArgumentBean.java @@ -6,20 +6,18 @@ import static java.util.Collections.*; import static net.sourceforge.tuned.FileUtilities.*; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; -import java.nio.charset.Charset; +import java.net.MalformedURLException; +import java.net.URL; import java.util.ArrayList; import java.util.List; import java.util.logging.Level; -import javax.script.ScriptException; - import org.kohsuke.args4j.Argument; import org.kohsuke.args4j.Option; import net.sourceforge.filebot.MediaTypes; -import net.sourceforge.filebot.format.ExpressionFormat; -import net.sourceforge.filebot.ui.Language; public class ArgumentBean { @@ -66,6 +64,9 @@ public class ArgumentBean { @Option(name = "-clear", usage = "Clear cache and application settings") public boolean clear = false; + @Option(name = "-script", usage = "Run Groovy script") + public String script = null; + @Option(name = "-no-analytics", usage = "Disable analytics") public boolean disableAnalytics = false; @@ -77,7 +78,7 @@ public class ArgumentBean { public boolean runCLI() { - return rename || getSubtitles || check || list; + return rename || getSubtitles || check || list || script != null; } @@ -96,39 +97,6 @@ public class ArgumentBean { } - public ExpressionFormat getEpisodeFormat() throws ScriptException { - return format != null ? new ExpressionFormat(format) : null; - } - - - public Language getLanguage() { - // try to look up by language code - Language language = Language.getLanguage(lang); - - if (language == null) { - // try too look up by language name - language = Language.getLanguageByName(lang); - - if (language == null) { - // unable to lookup language - throw new IllegalArgumentException("Illegal language code: " + lang); - } - } - - return language; - } - - - public Charset getEncoding() { - return encoding != null ? Charset.forName(encoding) : null; - } - - - public Level getLogLevel() { - return Level.parse(log.toUpperCase()); - } - - public List getFiles(boolean resolveFolders) { List files = new ArrayList(); @@ -147,4 +115,26 @@ public class ArgumentBean { return files; } + + public URL getScriptLocation() { + try { + return new URL(script); + } catch (MalformedURLException eu) { + try { + File file = new File(script); + if (!file.exists()) + throw new FileNotFoundException(file.getPath()); + + return file.toURI().toURL(); + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + } + + + public Level getLogLevel() { + return Level.parse(log.toUpperCase()); + } + } diff --git a/source/net/sourceforge/filebot/cli/ArgumentProcessor.java b/source/net/sourceforge/filebot/cli/ArgumentProcessor.java index b0a75eea..6b2d1363 100644 --- a/source/net/sourceforge/filebot/cli/ArgumentProcessor.java +++ b/source/net/sourceforge/filebot/cli/ArgumentProcessor.java @@ -2,673 +2,99 @@ package net.sourceforge.filebot.cli; -import static java.lang.String.*; -import static java.util.Collections.*; -import static net.sourceforge.filebot.MediaTypes.*; -import static net.sourceforge.filebot.WebServices.*; import static net.sourceforge.filebot.cli.CLILogging.*; -import static net.sourceforge.filebot.hash.VerificationUtilities.*; -import static net.sourceforge.filebot.subtitle.SubtitleUtilities.*; import static net.sourceforge.tuned.FileUtilities.*; import java.io.File; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.charset.Charset; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; +import java.io.InputStreamReader; +import java.security.AccessController; import java.util.LinkedHashSet; import java.util.List; -import java.util.ListIterator; -import java.util.Locale; -import java.util.Map; import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import java.util.AbstractMap.SimpleImmutableEntry; -import java.util.Map.Entry; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; + +import javax.script.Bindings; +import javax.script.SimpleBindings; + +import org.kohsuke.args4j.CmdLineException; +import org.kohsuke.args4j.CmdLineParser; import net.sourceforge.filebot.Analytics; import net.sourceforge.filebot.MediaTypes; -import net.sourceforge.filebot.WebServices; -import net.sourceforge.filebot.format.ExpressionFormat; -import net.sourceforge.filebot.format.MediaBindingBean; -import net.sourceforge.filebot.hash.HashType; -import net.sourceforge.filebot.hash.VerificationFileReader; -import net.sourceforge.filebot.hash.VerificationFileWriter; -import net.sourceforge.filebot.similarity.Match; -import net.sourceforge.filebot.similarity.Matcher; -import net.sourceforge.filebot.similarity.NameSimilarityMetric; -import net.sourceforge.filebot.similarity.SeriesNameMatcher; -import net.sourceforge.filebot.similarity.SimilarityMetric; -import net.sourceforge.filebot.subtitle.SubtitleFormat; -import net.sourceforge.filebot.ui.Language; -import net.sourceforge.filebot.ui.rename.HistorySpooler; -import net.sourceforge.filebot.ui.rename.MatchSimilarityMetric; -import net.sourceforge.filebot.vfs.ArchiveType; -import net.sourceforge.filebot.vfs.MemoryFile; -import net.sourceforge.filebot.web.Episode; -import net.sourceforge.filebot.web.EpisodeFormat; -import net.sourceforge.filebot.web.EpisodeListProvider; -import net.sourceforge.filebot.web.Movie; -import net.sourceforge.filebot.web.MovieIdentificationService; -import net.sourceforge.filebot.web.SearchResult; -import net.sourceforge.filebot.web.SubtitleDescriptor; -import net.sourceforge.filebot.web.SubtitleProvider; -import net.sourceforge.filebot.web.VideoHashSubtitleService; public class ArgumentProcessor { - public int process(ArgumentBean args) throws Exception { + public ArgumentBean parse(String[] args) throws CmdLineException { + final ArgumentBean bean = new ArgumentBean(); + + if (args != null && args.length > 0) { + CmdLineParser parser = new CmdLineParser(bean); + parser.parseArgument(args); + } + + return bean; + } + + + public int process(ArgumentBean args, CmdlineInterface cli) throws Exception { Analytics.trackView(ArgumentProcessor.class, "FileBot CLI"); CLILogger.setLevel(args.getLogLevel()); - // print operations - if (args.list) { - printEpisodeList(args.query, args.getEpisodeFormat(), args.db, args.getLanguage().toLocale()); - return 0; - } - - // file operations try { - Set files = new LinkedHashSet(args.getFiles(true)); - - if (args.getSubtitles) { - List subtitles = getSubtitles(files, args.query, args.getLanguage(), args.output, args.getEncoding()); - files.addAll(subtitles); + // print operations + if (args.list) { + for (String eps : cli.fetchEpisodeList(args.query, args.format, args.db, args.lang)) { + System.out.println(eps); + } + return 0; } - if (args.rename) { - rename(files, args.query, args.getEpisodeFormat(), args.db, args.getLanguage().toLocale(), !args.nonStrict); - } - - if (args.check) { - check(files, args.output, args.getEncoding()); + if (args.script == null) { + // file operations + Set files = new LinkedHashSet(args.getFiles(true)); + + if (args.getSubtitles) { + List subtitles = cli.getSubtitles(files, args.query, args.lang, args.output, args.encoding); + files.addAll(subtitles); + } + + if (args.rename) { + cli.rename(files, args.query, args.format, args.db, args.lang, !args.nonStrict); + } + + if (args.check) { + // check verification file + if (containsOnly(files, MediaTypes.getDefaultFilter("verification"))) { + if (!cli.check(files)) { + throw new Exception("Data corruption detected"); // one or more hashes mismatch + } + } else { + cli.compute(files, args.output, args.encoding); + } + } + } else { + // execute user script + String script = readAll(new InputStreamReader(args.getScriptLocation().openStream(), "UTF-8")); + + Bindings bindings = new SimpleBindings(); + bindings.put("args", args.getFiles(false)); + + ScriptShell shell = new ScriptShell(cli, args, AccessController.getContext()); + shell.evaluate(script, bindings); } CLILogger.finest("Done ヾ(@⌒ー⌒@)ノ"); return 0; } catch (Exception e) { - CLILogger.severe(e.getMessage()); + CLILogger.severe(e.toString()); CLILogger.finest("Failure (°_°)"); return -1; } } - public Set rename(Collection files, String query, ExpressionFormat format, String db, Locale locale, boolean strict) throws Exception { - List videoFiles = filter(files, VIDEO_FILES); - - if (videoFiles.isEmpty()) { - throw new IllegalArgumentException("No video files: " + files); - } - - if (getEpisodeListProvider(db) != null) { - // tv series mode - return renameSeries(files, query, format, getEpisodeListProvider(db), locale, strict); - } - - if (getMovieIdentificationService(db) != null) { - // movie mode - return renameMovie(files, query, format, getMovieIdentificationService(db), locale, strict); - } - - // auto-determine mode - int sxe = 0; // SxE - int cws = 0; // common word sequence - double max = videoFiles.size(); - - SeriesNameMatcher matcher = new SeriesNameMatcher(); - String[] cwsList = (max >= 5) ? matcher.matchAll(videoFiles.toArray(new File[0])).toArray(new String[0]) : new String[0]; - - for (File f : videoFiles) { - // count SxE matches - if (matcher.matchBySeasonEpisodePattern(f.getName()) != null) { - sxe++; - } - - // count CWS matches - for (String base : cwsList) { - if (base.equalsIgnoreCase(matcher.matchByFirstCommonWordSequence(base, f.getName()))) { - cws++; - break; - } - } - } - - CLILogger.finest(format(Locale.ROOT, "Filename pattern: [%.02f] SxE, [%.02f] CWS", sxe / max, cws / max)); - if (sxe >= (max * 0.65) || cws >= (max * 0.65)) { - return renameSeries(files, query, format, getEpisodeListProviders()[0], locale, strict); // use default episode db - } else { - return renameMovie(files, query, format, getMovieIdentificationServices()[0], locale, strict); // use default movie db - } - } - - - public Set renameSeries(Collection files, String query, ExpressionFormat format, EpisodeListProvider db, Locale locale, boolean strict) throws Exception { - CLILogger.config(format("Rename episodes using [%s]", db.getName())); - List mediaFiles = filter(files, VIDEO_FILES, SUBTITLE_FILES); - Collection seriesNames; - - // auto-detect series name if not given - if (query == null) { - seriesNames = new SeriesNameMatcher().matchAll(mediaFiles.toArray(new File[0])); - - if (seriesNames.isEmpty() || (strict && seriesNames.size() > 1)) { - throw new Exception("Failed to auto-detect series name: " + seriesNames); - } - - query = seriesNames.iterator().next(); - CLILogger.config("Auto-detected series name: " + seriesNames); - } else { - seriesNames = singleton(query); - } - - // fetch episode data - Set episodes = fetchEpisodeSet(db, seriesNames, locale, strict); - - if (episodes.isEmpty()) { - throw new RuntimeException("Failed to fetch episode data"); - } - - // similarity metrics for matching - SimilarityMetric[] sequence; - if (strict) { - sequence = new SimilarityMetric[] { StrictMetric.EpisodeIdentifier, StrictMetric.Title, StrictMetric.Name }; // use SEI for matching and SN for excluding false positives - } else { - sequence = MatchSimilarityMetric.defaultSequence(); // same as in GUI - } - - List> matches = new ArrayList>(); - matches.addAll(match(filter(mediaFiles, VIDEO_FILES), episodes, sequence)); - matches.addAll(match(filter(mediaFiles, SUBTITLE_FILES), episodes, sequence)); - - if (matches.isEmpty()) { - throw new RuntimeException("Unable to match files to episode data"); - } - - // map old files to new paths by applying formatting and validating filenames - Map renameMap = new LinkedHashMap(); - - for (Match match : matches) { - File file = match.getValue(); - Episode episode = match.getCandidate(); - String newName = (format != null) ? format.format(new MediaBindingBean(episode, file)) : EpisodeFormat.SeasonEpisode.format(episode); - File newFile = new File(newName + "." + getExtension(file)); - - if (isInvalidFilePath(newFile)) { - CLILogger.config("Stripping invalid characters from new name: " + newName); - newFile = validateFilePath(newFile); - } - - renameMap.put(file, newFile); - } - - // rename episodes - Analytics.trackEvent("CLI", "Rename", "Episode", renameMap.size()); - return renameAll(renameMap); - } - - - private Set fetchEpisodeSet(final EpisodeListProvider db, final Collection names, final Locale locale, final boolean strict) throws Exception { - List>> tasks = new ArrayList>>(); - - // detect series names and create episode list fetch tasks - for (final String query : names) { - tasks.add(new Callable>() { - - @Override - public List call() throws Exception { - List results = db.search(query, locale); - - // select search result - if (results.size() > 0) { - SearchResult selectedSearchResult = selectSearchResult(query, results, strict); - - if (selectedSearchResult != null) { - CLILogger.fine(format("Fetching episode data for [%s]", selectedSearchResult.getName())); - Analytics.trackEvent(db.getName(), "FetchEpisodeList", selectedSearchResult.getName()); - return db.getEpisodeList(selectedSearchResult, locale); - } - } - - return Collections.emptyList(); - } - }); - } - - // fetch episode lists concurrently - ExecutorService executor = Executors.newCachedThreadPool(); - - try { - // merge all episodes - Set episodes = new LinkedHashSet(); - - for (Future> future : executor.invokeAll(tasks)) { - try { - episodes.addAll(future.get()); - } catch (Exception e) { - CLILogger.finest(e.getMessage()); - } - } - - // all background workers have finished - return episodes; - } finally { - // destroy background threads - executor.shutdown(); - } - } - - - public Set renameMovie(Collection mediaFiles, String query, ExpressionFormat format, MovieIdentificationService db, Locale locale, boolean strict) throws Exception { - CLILogger.config(format("Rename movies using [%s]", db.getName())); - - File[] movieFiles = filter(mediaFiles, VIDEO_FILES).toArray(new File[0]); - CLILogger.fine(format("Looking up movie by filehash via [%s]", db.getName())); - - // match movie hashes online - Movie[] movieDescriptors = db.getMovieDescriptors(movieFiles, locale); - - // use user query if search by hash did not return any results, only one query for one movie though - if (query != null && movieDescriptors.length == 1 && movieDescriptors[0] == null) { - CLILogger.fine(format("Looking up movie by query [%s]", query)); - movieDescriptors[0] = (Movie) selectSearchResult(query, new ArrayList(db.searchMovie(query, locale)), strict); - } - - // map old files to new paths by applying formatting and validating filenames - Map renameMap = new LinkedHashMap(); - - for (int i = 0; i < movieFiles.length; i++) { - if (movieDescriptors[i] != null) { - Movie movie = movieDescriptors[i]; - File file = movieFiles[i]; - String newName = (format != null) ? format.format(new MediaBindingBean(movie, file)) : movie.toString(); - File newFile = new File(newName + "." + getExtension(file)); - - if (isInvalidFilePath(newFile)) { - CLILogger.config("Stripping invalid characters from new path: " + newName); - newFile = validateFilePath(newFile); - } - - renameMap.put(file, newFile); - } else { - CLILogger.warning("No matching movie: " + movieFiles[i]); - } - } - - // handle subtitle files - for (File subtitleFile : filter(mediaFiles, SUBTITLE_FILES)) { - // check if subtitle corresponds to a movie file (same name, different extension) - for (int i = 0; i < movieDescriptors.length; i++) { - if (movieDescriptors != null) { - String subtitleName = getName(subtitleFile); - String movieName = getName(movieFiles[i]); - - if (subtitleName.equalsIgnoreCase(movieName)) { - File movieDestination = renameMap.get(movieFiles[i]); - File subtitleDestination = new File(movieDestination.getParentFile(), getName(movieDestination) + "." + getExtension(subtitleFile)); - renameMap.put(subtitleFile, subtitleDestination); - - // movie match found, we're done - break; - } - } - } - } - - // rename movies - Analytics.trackEvent("CLI", "Rename", "Movie", renameMap.size()); - return renameAll(renameMap); - } - - - public List getSubtitles(Collection files, String query, Language language, String output, Charset outputEncoding) throws Exception { - // match movie hashes online - Set remainingVideos = new TreeSet(filter(files, VIDEO_FILES)); - List downloadedSubtitles = new ArrayList(); - - if (remainingVideos.isEmpty()) { - throw new IllegalArgumentException("No video files: " + files); - } - - SubtitleFormat outputFormat = null; - if (output != null) { - outputFormat = getSubtitleFormatByName(output); - - // when rewriting subtitles to target format an encoding must be defined, default to UTF-8 - if (outputEncoding == null) { - outputEncoding = Charset.forName("UTF-8"); - } - } - - // lookup subtitles by hash - for (VideoHashSubtitleService service : WebServices.getVideoHashSubtitleServices()) { - if (remainingVideos.isEmpty()) { - break; - } - - CLILogger.fine("Looking up subtitles by filehash via " + service.getName()); - - for (Entry> it : service.getSubtitleList(remainingVideos.toArray(new File[0]), language.getName()).entrySet()) { - if (it.getValue() != null && it.getValue().size() > 0) { - // auto-select first element if there are multiple hash matches for the same video files - File subtitle = fetchSubtitle(it.getValue().get(0), it.getKey(), outputFormat, outputEncoding); - Analytics.trackEvent(service.getName(), "DownloadSubtitle", it.getValue().get(0).getLanguageName(), 1); - - // download complete, cross this video off the list - remainingVideos.remove(it.getKey()); - downloadedSubtitles.add(subtitle); - } - } - } - - // lookup subtitles by query and filename - if (query != null && remainingVideos.size() > 0) { - for (SubtitleProvider service : WebServices.getSubtitleProviders()) { - if (remainingVideos.isEmpty()) { - break; - } - - try { - CLILogger.fine(format("Searching for [%s] at [%s]", query, service.getName())); - SearchResult searchResult = selectSearchResult(query, service.search(query), false); - - CLILogger.config(format("Retrieving subtitles for [%s]", searchResult.getName())); - List subtitles = service.getSubtitleList(searchResult, language.getName()); - - for (File video : remainingVideos.toArray(new File[0])) { - for (SubtitleDescriptor descriptor : subtitles) { - if (isDerived(descriptor.getName(), video)) { - File subtitle = fetchSubtitle(descriptor, video, outputFormat, outputEncoding); - Analytics.trackEvent(service.getName(), "DownloadSubtitle", descriptor.getLanguageName(), 1); - - // download complete, cross this video off the list - remainingVideos.remove(video); - downloadedSubtitles.add(subtitle); - break; - } - } - } - } catch (Exception e) { - CLILogger.warning(e.getMessage()); - } - } - } - - // no subtitles for remaining video files - for (File video : remainingVideos) { - CLILogger.warning("No matching subtitles found: " + video); - } - - Analytics.trackEvent("CLI", "Download", "Subtitle", downloadedSubtitles.size()); - return downloadedSubtitles; - } - - - private File fetchSubtitle(SubtitleDescriptor descriptor, File movieFile, SubtitleFormat outputFormat, Charset outputEncoding) throws Exception { - // fetch subtitle archive - CLILogger.info(format("Fetching [%s.%s]", descriptor.getName(), descriptor.getType())); - ByteBuffer downloadedData = descriptor.fetch(); - - // extract subtitles from archive - ArchiveType type = ArchiveType.forName(descriptor.getType()); - MemoryFile subtitleFile; - - if (type != ArchiveType.UNDEFINED) { - // extract subtitle from archive - subtitleFile = type.fromData(downloadedData).iterator().next(); - } else { - // assume that the fetched data is the subtitle - subtitleFile = new MemoryFile(descriptor.getName() + "." + descriptor.getType(), downloadedData); - } - - // subtitle filename is based on movie filename - String name = getName(movieFile); - String ext = getExtension(subtitleFile.getName()); - ByteBuffer data = subtitleFile.getData(); - - if (outputFormat != null || outputEncoding != null) { - if (outputFormat != null) { - ext = outputFormat.getFilter().extension(); // adjust extension of the output file - } - - CLILogger.finest(format("Export [%s] as: %s / %s", subtitleFile.getName(), outputFormat, outputEncoding.displayName(Locale.ROOT))); - data = exportSubtitles(subtitleFile, outputFormat, 0, outputEncoding); - } - - File destination = new File(movieFile.getParentFile(), name + "." + ext); - CLILogger.config(format("Writing [%s] to [%s]", subtitleFile.getName(), destination.getName())); - - writeFile(data, destination); - return destination; - } - - - private Set renameAll(Map renameMap) throws Exception { - // rename files - final List> renameLog = new ArrayList>(); - - try { - for (Entry it : renameMap.entrySet()) { - try { - // rename file, throw exception on failure - File destination = renameFile(it.getKey(), it.getValue()); - CLILogger.info(format("Renamed [%s] to [%s]", it.getKey(), it.getValue())); - - // remember successfully renamed matches for history entry and possible revert - renameLog.add(new SimpleImmutableEntry(it.getKey(), destination)); - } catch (IOException e) { - CLILogger.warning(format("Failed to rename [%s]", it.getKey())); - throw e; - } - } - } catch (Exception e) { - // could not rename one of the files, revert all changes - CLILogger.severe(e.getMessage()); - - // revert rename operations in reverse order - for (ListIterator> iterator = renameLog.listIterator(renameLog.size()); iterator.hasPrevious();) { - Entry mapping = iterator.previous(); - - // revert rename - if (mapping.getValue().renameTo(mapping.getKey())) { - // remove reverted rename operation from log - CLILogger.info("Reverted filename: " + mapping.getKey()); - } else { - // failed to revert rename operation - CLILogger.severe("Failed to revert filename: " + mapping.getValue()); - } - } - - throw new Exception("Renaming failed", e); - } finally { - if (renameLog.size() > 0) { - // update rename history - HistorySpooler.getInstance().append(renameMap.entrySet()); - - // printer number of renamed files if any - CLILogger.fine(format("Renamed %d files", renameLog.size())); - } - } - - // new file names - Set newFiles = new LinkedHashSet(); - for (Entry it : renameLog) - newFiles.add(it.getValue()); - - return newFiles; - } - - - private List> match(Collection files, Collection episodes, SimilarityMetric[] sequence) throws Exception { - // always use strict fail-fast matcher - Matcher matcher = new Matcher(files, episodes, true, sequence); - List> matches = matcher.match(); - - for (File failedMatch : matcher.remainingValues()) { - CLILogger.warning("No matching episode: " + failedMatch.getName()); - } - - return matches; - } - - - private SearchResult selectSearchResult(String query, Iterable searchResults, boolean strict) throws IllegalArgumentException { - // auto-select most probable search result - Map probableMatches = new TreeMap(String.CASE_INSENSITIVE_ORDER); - - // use name similarity metric - SimilarityMetric metric = new NameSimilarityMetric(); - - // find probable matches using name similarity > 0.9 - for (SearchResult result : searchResults) { - if (metric.getSimilarity(query, result.getName()) > 0.9) { - if (!probableMatches.containsKey(result.getName())) { - probableMatches.put(result.getName(), result); - } - } - } - - if (probableMatches.isEmpty() || (strict && probableMatches.size() != 1)) { - throw new IllegalArgumentException("Failed to auto-select search result: " + probableMatches.values()); - } - - // return first and only value - return probableMatches.values().iterator().next(); - } - - - public void check(Collection files, String output, Charset outputEncoding) throws Exception { - // check verification file - if (containsOnly(files, MediaTypes.getDefaultFilter("verification"))) { - // only check existing hashes - boolean ok = true; - - for (File it : files) { - ok &= check(it, it.getParentFile()); - } - - if (!ok) { - throw new Exception("Data corruption detected"); // one or more hashes mismatch - } - - // all hashes match - return; - } - - // check common parent for all given files - File root = null; - for (File it : files) { - if (root == null || root.getPath().startsWith(it.getParent())) - root = it.getParentFile(); - - if (!it.getParent().startsWith(root.getPath())) - throw new IllegalArgumentException("Path don't share a common root: " + files); - } - - // create verification file - File outputFile; - HashType hashType; - - if (output != null && getExtension(output) != null) { - // use given filename - hashType = getHashTypeByExtension(getExtension(output)); - outputFile = new File(root, output); - } else { - // auto-select the filename based on folder and type - hashType = (output != null) ? getHashTypeByExtension(output) : HashType.SFV; - outputFile = new File(root, root.getName() + "." + hashType.getFilter().extension()); - } - - if (hashType == null) { - throw new IllegalArgumentException("Illegal output type: " + output); - } - - CLILogger.config("Using output file: " + outputFile); - compute(root.getPath(), files, outputFile, hashType, outputEncoding); - } - - - private boolean check(File verificationFile, File root) throws Exception { - HashType type = getHashType(verificationFile); - - // check if type is supported - if (type == null) - throw new IllegalArgumentException("Unsupported format: " + verificationFile); - - // add all file names from verification file - CLILogger.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); - CLILogger.info(format("%s %s", current, file)); - - if (current.compareToIgnoreCase(it.getValue()) != 0) { - throw new IOException(format("Corrupted file found: %s [hash mismatch: %s vs %s]", it.getKey(), current, it.getValue())); - } - } catch (IOException e) { - status = false; - CLILogger.warning(e.getMessage()); - } - } - } finally { - parser.close(); - } - - return status; - } - - - private void compute(String root, Collection files, File outputFile, HashType hashType, Charset outputEncoding) throws IOException, Exception { - // compute hashes recursively and write to file - VerificationFileWriter out = new VerificationFileWriter(outputFile, hashType.getFormat(), outputEncoding != null ? outputEncoding.name() : "UTF-8"); - - try { - CLILogger.fine("Computing hashes"); - for (File it : files) { - if (it.isHidden() || MediaTypes.getDefaultFilter("verification").accept(it)) - continue; - - String relativePath = normalizePathSeparators(it.getPath().replace(root, "")).substring(1); - String hash = computeHash(it, hashType); - CLILogger.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 void printEpisodeList(String query, ExpressionFormat format, String db, Locale locale) throws Exception { - // find series on the web and fetch episode list - EpisodeListProvider service = (db == null) ? TVRage : getEpisodeListProvider(db); - SearchResult hit = selectSearchResult(query, service.search(query, locale), false); - - Analytics.trackEvent("CLI", "PrintEpisodeList", hit.getName()); - for (Episode it : service.getEpisodeList(hit, locale)) { - String string = (format != null) ? format.format(new MediaBindingBean(it, null)) : EpisodeFormat.SeasonEpisode.format(it); - System.out.println(string); - } + public void printHelp(ArgumentBean argumentBean) { + new CmdLineParser(argumentBean).printUsage(System.out); } } diff --git a/source/net/sourceforge/filebot/cli/CmdlineInterface.java b/source/net/sourceforge/filebot/cli/CmdlineInterface.java new file mode 100644 index 00000000..09c2a663 --- /dev/null +++ b/source/net/sourceforge/filebot/cli/CmdlineInterface.java @@ -0,0 +1,27 @@ + +package net.sourceforge.filebot.cli; + + +import java.io.File; +import java.util.Collection; +import java.util.List; +import java.util.Set; + + +public interface CmdlineInterface { + + Set rename(Collection files, String query, String format, String db, String lang, boolean strict) throws Exception; + + + List getSubtitles(Collection files, String query, String lang, String output, String encoding) throws Exception; + + + boolean check(Collection files) throws Exception; + + + File compute(Collection files, String output, String encoding) throws Exception; + + + List fetchEpisodeList(String query, String format, String db, String lang) throws Exception; + +} diff --git a/source/net/sourceforge/filebot/cli/CmdlineOperations.java b/source/net/sourceforge/filebot/cli/CmdlineOperations.java new file mode 100644 index 00000000..01e4ae4a --- /dev/null +++ b/source/net/sourceforge/filebot/cli/CmdlineOperations.java @@ -0,0 +1,670 @@ + +package net.sourceforge.filebot.cli; + + +import static java.lang.String.*; +import static java.util.Collections.*; +import static net.sourceforge.filebot.MediaTypes.*; +import static net.sourceforge.filebot.WebServices.*; +import static net.sourceforge.filebot.cli.CLILogging.*; +import static net.sourceforge.filebot.hash.VerificationUtilities.*; +import static net.sourceforge.filebot.subtitle.SubtitleUtilities.*; +import static net.sourceforge.tuned.FileUtilities.*; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.ListIterator; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.Map.Entry; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import net.sourceforge.filebot.Analytics; +import net.sourceforge.filebot.MediaTypes; +import net.sourceforge.filebot.WebServices; +import net.sourceforge.filebot.format.ExpressionFormat; +import net.sourceforge.filebot.format.MediaBindingBean; +import net.sourceforge.filebot.hash.HashType; +import net.sourceforge.filebot.hash.VerificationFileReader; +import net.sourceforge.filebot.hash.VerificationFileWriter; +import net.sourceforge.filebot.similarity.Match; +import net.sourceforge.filebot.similarity.Matcher; +import net.sourceforge.filebot.similarity.NameSimilarityMetric; +import net.sourceforge.filebot.similarity.SeriesNameMatcher; +import net.sourceforge.filebot.similarity.SimilarityMetric; +import net.sourceforge.filebot.subtitle.SubtitleFormat; +import net.sourceforge.filebot.ui.Language; +import net.sourceforge.filebot.ui.rename.HistorySpooler; +import net.sourceforge.filebot.ui.rename.MatchSimilarityMetric; +import net.sourceforge.filebot.vfs.ArchiveType; +import net.sourceforge.filebot.vfs.MemoryFile; +import net.sourceforge.filebot.web.Episode; +import net.sourceforge.filebot.web.EpisodeFormat; +import net.sourceforge.filebot.web.EpisodeListProvider; +import net.sourceforge.filebot.web.Movie; +import net.sourceforge.filebot.web.MovieIdentificationService; +import net.sourceforge.filebot.web.SearchResult; +import net.sourceforge.filebot.web.SubtitleDescriptor; +import net.sourceforge.filebot.web.SubtitleProvider; +import net.sourceforge.filebot.web.VideoHashSubtitleService; + + +public class CmdlineOperations implements CmdlineInterface { + + @Override + public Set rename(Collection files, String query, String expression, String db, String languageName, boolean strict) throws Exception { + ExpressionFormat format = (expression != null) ? new ExpressionFormat(expression) : null; + Locale locale = getLanguage(languageName).toLocale(); + + List videoFiles = filter(files, VIDEO_FILES); + + if (videoFiles.isEmpty()) { + throw new IllegalArgumentException("No video files: " + files); + } + + if (getEpisodeListProvider(db) != null) { + // tv series mode + return renameSeries(files, query, format, getEpisodeListProvider(db), locale, strict); + } + + if (getMovieIdentificationService(db) != null) { + // movie mode + return renameMovie(files, query, format, getMovieIdentificationService(db), locale, strict); + } + + // auto-determine mode + int sxe = 0; // SxE + int cws = 0; // common word sequence + double max = videoFiles.size(); + + SeriesNameMatcher matcher = new SeriesNameMatcher(); + String[] cwsList = (max >= 5) ? matcher.matchAll(videoFiles.toArray(new File[0])).toArray(new String[0]) : new String[0]; + + for (File f : videoFiles) { + // count SxE matches + if (matcher.matchBySeasonEpisodePattern(f.getName()) != null) { + sxe++; + } + + // count CWS matches + for (String base : cwsList) { + if (base.equalsIgnoreCase(matcher.matchByFirstCommonWordSequence(base, f.getName()))) { + cws++; + break; + } + } + } + + CLILogger.finest(format(Locale.ROOT, "Filename pattern: [%.02f] SxE, [%.02f] CWS", sxe / max, cws / max)); + if (sxe >= (max * 0.65) || cws >= (max * 0.65)) { + return renameSeries(files, query, format, getEpisodeListProviders()[0], locale, strict); // use default episode db + } else { + return renameMovie(files, query, format, getMovieIdentificationServices()[0], locale, strict); // use default movie db + } + } + + + public Set renameSeries(Collection files, String query, ExpressionFormat format, EpisodeListProvider db, Locale locale, boolean strict) throws Exception { + CLILogger.config(format("Rename episodes using [%s]", db.getName())); + List mediaFiles = filter(files, VIDEO_FILES, SUBTITLE_FILES); + Collection seriesNames; + + // auto-detect series name if not given + if (query == null) { + seriesNames = new SeriesNameMatcher().matchAll(mediaFiles.toArray(new File[0])); + + if (seriesNames.isEmpty() || (strict && seriesNames.size() > 1)) { + throw new Exception("Failed to auto-detect series name: " + seriesNames); + } + + query = seriesNames.iterator().next(); + CLILogger.config("Auto-detected series name: " + seriesNames); + } else { + seriesNames = singleton(query); + } + + // fetch episode data + Set episodes = fetchEpisodeSet(db, seriesNames, locale, strict); + + if (episodes.isEmpty()) { + throw new RuntimeException("Failed to fetch episode data"); + } + + // similarity metrics for matching + SimilarityMetric[] sequence; + if (strict) { + sequence = new SimilarityMetric[] { StrictMetric.EpisodeIdentifier, StrictMetric.Title, StrictMetric.Name }; // use SEI for matching and SN for excluding false positives + } else { + sequence = MatchSimilarityMetric.defaultSequence(); // same as in GUI + } + + List> matches = new ArrayList>(); + matches.addAll(match(filter(mediaFiles, VIDEO_FILES), episodes, sequence)); + matches.addAll(match(filter(mediaFiles, SUBTITLE_FILES), episodes, sequence)); + + if (matches.isEmpty()) { + throw new RuntimeException("Unable to match files to episode data"); + } + + // map old files to new paths by applying formatting and validating filenames + Map renameMap = new LinkedHashMap(); + + for (Match match : matches) { + File file = match.getValue(); + Episode episode = match.getCandidate(); + String newName = (format != null) ? format.format(new MediaBindingBean(episode, file)) : EpisodeFormat.SeasonEpisode.format(episode); + File newFile = new File(newName + "." + getExtension(file)); + + if (isInvalidFilePath(newFile)) { + CLILogger.config("Stripping invalid characters from new name: " + newName); + newFile = validateFilePath(newFile); + } + + renameMap.put(file, newFile); + } + + // rename episodes + Analytics.trackEvent("CLI", "Rename", "Episode", renameMap.size()); + return renameAll(renameMap); + } + + + private Set fetchEpisodeSet(final EpisodeListProvider db, final Collection names, final Locale locale, final boolean strict) throws Exception { + List>> tasks = new ArrayList>>(); + + // detect series names and create episode list fetch tasks + for (final String query : names) { + tasks.add(new Callable>() { + + @Override + public List call() throws Exception { + List results = db.search(query, locale); + + // select search result + if (results.size() > 0) { + SearchResult selectedSearchResult = selectSearchResult(query, results, strict); + + if (selectedSearchResult != null) { + CLILogger.fine(format("Fetching episode data for [%s]", selectedSearchResult.getName())); + Analytics.trackEvent(db.getName(), "FetchEpisodeList", selectedSearchResult.getName()); + return db.getEpisodeList(selectedSearchResult, locale); + } + } + + return Collections.emptyList(); + } + }); + } + + // fetch episode lists concurrently + ExecutorService executor = Executors.newCachedThreadPool(); + + try { + // merge all episodes + Set episodes = new LinkedHashSet(); + + for (Future> future : executor.invokeAll(tasks)) { + try { + episodes.addAll(future.get()); + } catch (Exception e) { + CLILogger.finest(e.getMessage()); + } + } + + // all background workers have finished + return episodes; + } finally { + // destroy background threads + executor.shutdown(); + } + } + + + public Set renameMovie(Collection mediaFiles, String query, ExpressionFormat format, MovieIdentificationService db, Locale locale, boolean strict) throws Exception { + CLILogger.config(format("Rename movies using [%s]", db.getName())); + + File[] movieFiles = filter(mediaFiles, VIDEO_FILES).toArray(new File[0]); + CLILogger.fine(format("Looking up movie by filehash via [%s]", db.getName())); + + // match movie hashes online + Movie[] movieDescriptors = db.getMovieDescriptors(movieFiles, locale); + + // use user query if search by hash did not return any results, only one query for one movie though + if (query != null && movieDescriptors.length == 1 && movieDescriptors[0] == null) { + CLILogger.fine(format("Looking up movie by query [%s]", query)); + movieDescriptors[0] = (Movie) selectSearchResult(query, new ArrayList(db.searchMovie(query, locale)), strict); + } + + // map old files to new paths by applying formatting and validating filenames + Map renameMap = new LinkedHashMap(); + + for (int i = 0; i < movieFiles.length; i++) { + if (movieDescriptors[i] != null) { + Movie movie = movieDescriptors[i]; + File file = movieFiles[i]; + String newName = (format != null) ? format.format(new MediaBindingBean(movie, file)) : movie.toString(); + File newFile = new File(newName + "." + getExtension(file)); + + if (isInvalidFilePath(newFile)) { + CLILogger.config("Stripping invalid characters from new path: " + newName); + newFile = validateFilePath(newFile); + } + + renameMap.put(file, newFile); + } else { + CLILogger.warning("No matching movie: " + movieFiles[i]); + } + } + + // handle subtitle files + for (File subtitleFile : filter(mediaFiles, SUBTITLE_FILES)) { + // check if subtitle corresponds to a movie file (same name, different extension) + for (int i = 0; i < movieDescriptors.length; i++) { + if (movieDescriptors != null) { + String subtitleName = getName(subtitleFile); + String movieName = getName(movieFiles[i]); + + if (subtitleName.equalsIgnoreCase(movieName)) { + File movieDestination = renameMap.get(movieFiles[i]); + File subtitleDestination = new File(movieDestination.getParentFile(), getName(movieDestination) + "." + getExtension(subtitleFile)); + renameMap.put(subtitleFile, subtitleDestination); + + // movie match found, we're done + break; + } + } + } + } + + // rename movies + Analytics.trackEvent("CLI", "Rename", "Movie", renameMap.size()); + return renameAll(renameMap); + } + + + @Override + public List getSubtitles(Collection files, String query, String languageName, String output, String csn) throws Exception { + Language language = getLanguage(languageName); + Charset outputEncoding = (csn != null) ? Charset.forName(csn) : null; + + // match movie hashes online + Set remainingVideos = new TreeSet(filter(files, VIDEO_FILES)); + List downloadedSubtitles = new ArrayList(); + + if (remainingVideos.isEmpty()) { + throw new IllegalArgumentException("No video files: " + files); + } + + SubtitleFormat outputFormat = null; + if (output != null) { + outputFormat = getSubtitleFormatByName(output); + + // when rewriting subtitles to target format an encoding must be defined, default to UTF-8 + if (outputEncoding == null) { + outputEncoding = Charset.forName("UTF-8"); + } + } + + // lookup subtitles by hash + for (VideoHashSubtitleService service : WebServices.getVideoHashSubtitleServices()) { + if (remainingVideos.isEmpty()) { + break; + } + + CLILogger.fine("Looking up subtitles by filehash via " + service.getName()); + + for (Entry> it : service.getSubtitleList(remainingVideos.toArray(new File[0]), language.getName()).entrySet()) { + if (it.getValue() != null && it.getValue().size() > 0) { + // auto-select first element if there are multiple hash matches for the same video files + File subtitle = fetchSubtitle(it.getValue().get(0), it.getKey(), outputFormat, outputEncoding); + Analytics.trackEvent(service.getName(), "DownloadSubtitle", it.getValue().get(0).getLanguageName(), 1); + + // download complete, cross this video off the list + remainingVideos.remove(it.getKey()); + downloadedSubtitles.add(subtitle); + } + } + } + + // lookup subtitles by query and filename + if (query != null && remainingVideos.size() > 0) { + for (SubtitleProvider service : WebServices.getSubtitleProviders()) { + if (remainingVideos.isEmpty()) { + break; + } + + try { + CLILogger.fine(format("Searching for [%s] at [%s]", query, service.getName())); + SearchResult searchResult = selectSearchResult(query, service.search(query), false); + + CLILogger.config(format("Retrieving subtitles for [%s]", searchResult.getName())); + List subtitles = service.getSubtitleList(searchResult, language.getName()); + + for (File video : remainingVideos.toArray(new File[0])) { + for (SubtitleDescriptor descriptor : subtitles) { + if (isDerived(descriptor.getName(), video)) { + File subtitle = fetchSubtitle(descriptor, video, outputFormat, outputEncoding); + Analytics.trackEvent(service.getName(), "DownloadSubtitle", descriptor.getLanguageName(), 1); + + // download complete, cross this video off the list + remainingVideos.remove(video); + downloadedSubtitles.add(subtitle); + break; + } + } + } + } catch (Exception e) { + CLILogger.warning(e.getMessage()); + } + } + } + + // no subtitles for remaining video files + for (File video : remainingVideos) { + CLILogger.warning("No matching subtitles found: " + video); + } + + Analytics.trackEvent("CLI", "Download", "Subtitle", downloadedSubtitles.size()); + return downloadedSubtitles; + } + + + private File fetchSubtitle(SubtitleDescriptor descriptor, File movieFile, SubtitleFormat outputFormat, Charset outputEncoding) throws Exception { + // fetch subtitle archive + CLILogger.info(format("Fetching [%s.%s]", descriptor.getName(), descriptor.getType())); + ByteBuffer downloadedData = descriptor.fetch(); + + // extract subtitles from archive + ArchiveType type = ArchiveType.forName(descriptor.getType()); + MemoryFile subtitleFile; + + if (type != ArchiveType.UNDEFINED) { + // extract subtitle from archive + subtitleFile = type.fromData(downloadedData).iterator().next(); + } else { + // assume that the fetched data is the subtitle + subtitleFile = new MemoryFile(descriptor.getName() + "." + descriptor.getType(), downloadedData); + } + + // subtitle filename is based on movie filename + String name = getName(movieFile); + String ext = getExtension(subtitleFile.getName()); + ByteBuffer data = subtitleFile.getData(); + + if (outputFormat != null || outputEncoding != null) { + if (outputFormat != null) { + ext = outputFormat.getFilter().extension(); // adjust extension of the output file + } + + CLILogger.finest(format("Export [%s] as: %s / %s", subtitleFile.getName(), outputFormat, outputEncoding.displayName(Locale.ROOT))); + data = exportSubtitles(subtitleFile, outputFormat, 0, outputEncoding); + } + + File destination = new File(movieFile.getParentFile(), name + "." + ext); + CLILogger.config(format("Writing [%s] to [%s]", subtitleFile.getName(), destination.getName())); + + writeFile(data, destination); + return destination; + } + + + private Set renameAll(Map renameMap) throws Exception { + // rename files + final List> renameLog = new ArrayList>(); + + try { + for (Entry it : renameMap.entrySet()) { + try { + // rename file, throw exception on failure + File destination = renameFile(it.getKey(), it.getValue()); + CLILogger.info(format("Renamed [%s] to [%s]", it.getKey(), it.getValue())); + + // remember successfully renamed matches for history entry and possible revert + renameLog.add(new SimpleImmutableEntry(it.getKey(), destination)); + } catch (IOException e) { + CLILogger.warning(format("Failed to rename [%s]", it.getKey())); + throw e; + } + } + } catch (Exception e) { + // could not rename one of the files, revert all changes + CLILogger.severe(e.getMessage()); + + // revert rename operations in reverse order + for (ListIterator> iterator = renameLog.listIterator(renameLog.size()); iterator.hasPrevious();) { + Entry mapping = iterator.previous(); + + // revert rename + if (mapping.getValue().renameTo(mapping.getKey())) { + // remove reverted rename operation from log + CLILogger.info("Reverted filename: " + mapping.getKey()); + } else { + // failed to revert rename operation + CLILogger.severe("Failed to revert filename: " + mapping.getValue()); + } + } + + throw new Exception("Renaming failed", e); + } finally { + if (renameLog.size() > 0) { + // update rename history + HistorySpooler.getInstance().append(renameMap.entrySet()); + + // printer number of renamed files if any + CLILogger.fine(format("Renamed %d files", renameLog.size())); + } + } + + // new file names + Set newFiles = new LinkedHashSet(); + for (Entry it : renameLog) + newFiles.add(it.getValue()); + + return newFiles; + } + + + private List> match(Collection files, Collection episodes, SimilarityMetric[] sequence) throws Exception { + // always use strict fail-fast matcher + Matcher matcher = new Matcher(files, episodes, true, sequence); + List> matches = matcher.match(); + + for (File failedMatch : matcher.remainingValues()) { + CLILogger.warning("No matching episode: " + failedMatch.getName()); + } + + return matches; + } + + + private SearchResult selectSearchResult(String query, Iterable searchResults, boolean strict) throws IllegalArgumentException { + // auto-select most probable search result + Map probableMatches = new TreeMap(String.CASE_INSENSITIVE_ORDER); + + // use name similarity metric + SimilarityMetric metric = new NameSimilarityMetric(); + + // find probable matches using name similarity > 0.9 + for (SearchResult result : searchResults) { + if (metric.getSimilarity(query, result.getName()) > 0.9) { + if (!probableMatches.containsKey(result.getName())) { + probableMatches.put(result.getName(), result); + } + } + } + + if (probableMatches.isEmpty() || (strict && probableMatches.size() != 1)) { + throw new IllegalArgumentException("Failed to auto-select search result: " + probableMatches.values()); + } + + // return first and only value + return probableMatches.values().iterator().next(); + } + + + @Override + public boolean check(Collection files) throws Exception { + // only check existing hashes + boolean result = true; + + for (File it : filter(files, MediaTypes.getDefaultFilter("verification"))) { + result &= check(it, it.getParentFile()); + } + + return result; + } + + + @Override + public File compute(Collection files, String output, String csn) throws Exception { + // check common parent for all given files + File root = null; + for (File it : files) { + if (root == null || root.getPath().startsWith(it.getParent())) + root = it.getParentFile(); + + if (!it.getParent().startsWith(root.getPath())) + throw new IllegalArgumentException("Paths don't share a common root: " + files); + } + + // create verification file + File outputFile; + HashType hashType; + + if (output != null && getExtension(output) != null) { + // use given filename + hashType = getHashTypeByExtension(getExtension(output)); + outputFile = new File(root, output); + } else { + // auto-select the filename based on folder and type + hashType = (output != null) ? getHashTypeByExtension(output) : HashType.SFV; + outputFile = new File(root, root.getName() + "." + hashType.getFilter().extension()); + } + + if (hashType == null) { + throw new IllegalArgumentException("Illegal output type: " + output); + } + + CLILogger.config("Using output file: " + outputFile); + compute(root.getPath(), files, outputFile, hashType, csn); + + return outputFile; + } + + + private boolean check(File verificationFile, File root) throws Exception { + HashType type = getHashType(verificationFile); + + // check if type is supported + if (type == null) + throw new IllegalArgumentException("Unsupported format: " + verificationFile); + + // add all file names from verification file + CLILogger.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); + CLILogger.info(format("%s %s", current, file)); + + if (current.compareToIgnoreCase(it.getValue()) != 0) { + throw new IOException(format("Corrupted file found: %s [hash mismatch: %s vs %s]", it.getKey(), current, it.getValue())); + } + } catch (IOException e) { + status = false; + CLILogger.warning(e.getMessage()); + } + } + } finally { + parser.close(); + } + + return status; + } + + + private void compute(String root, Collection files, File outputFile, HashType hashType, String csn) throws IOException, Exception { + // compute hashes recursively and write to file + VerificationFileWriter out = new VerificationFileWriter(outputFile, hashType.getFormat(), csn != null ? csn : "UTF-8"); + + try { + CLILogger.fine("Computing hashes"); + for (File it : files) { + if (it.isHidden() || MediaTypes.getDefaultFilter("verification").accept(it)) + continue; + + String relativePath = normalizePathSeparators(it.getPath().replace(root, "")).substring(1); + String hash = computeHash(it, hashType); + CLILogger.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(); + } + } + + + @Override + public List fetchEpisodeList(String query, String expression, String db, String languageName) throws Exception { + // find series on the web and fetch episode list + ExpressionFormat format = (expression != null) ? new ExpressionFormat(expression) : null; + EpisodeListProvider service = (db == null) ? TVRage : getEpisodeListProvider(db); + Locale locale = getLanguage(languageName).toLocale(); + + SearchResult hit = selectSearchResult(query, service.search(query, locale), false); + + Analytics.trackEvent("CLI", "PrintEpisodeList", hit.getName()); + List episodes = new ArrayList(); + + for (Episode it : service.getEpisodeList(hit, locale)) { + String name = (format != null) ? format.format(new MediaBindingBean(it, null)) : EpisodeFormat.SeasonEpisode.format(it); + episodes.add(name); + } + + return episodes; + } + + + private Language getLanguage(String lang) { + // try to look up by language code + Language language = Language.getLanguage(lang); + + if (language == null) { + // try too look up by language name + language = Language.getLanguageByName(lang); + + if (language == null) { + // unable to lookup language + throw new IllegalArgumentException("Illegal language code: " + lang); + } + } + + return language; + } + +} diff --git a/source/net/sourceforge/filebot/cli/ScriptShell.java b/source/net/sourceforge/filebot/cli/ScriptShell.java new file mode 100644 index 00000000..c4c3446f --- /dev/null +++ b/source/net/sourceforge/filebot/cli/ScriptShell.java @@ -0,0 +1,80 @@ + +package net.sourceforge.filebot.cli; + + +import static net.sourceforge.filebot.cli.CLILogging.*; + +import java.io.FilePermission; +import java.io.InputStreamReader; +import java.net.SocketPermission; +import java.security.AccessControlContext; +import java.security.AccessController; +import java.security.Permissions; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.security.ProtectionDomain; +import java.util.PropertyPermission; + +import javax.script.Bindings; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptException; +import javax.script.SimpleBindings; +import javax.script.SimpleScriptContext; + +import org.codehaus.groovy.jsr223.GroovyScriptEngineFactory; + +import net.sourceforge.filebot.MediaTypes; +import net.sourceforge.filebot.format.PrivilegedInvocation; + + +public class ScriptShell { + + private final ScriptEngine engine = new GroovyScriptEngineFactory().getScriptEngine();; + + + public ScriptShell(CmdlineInterface cli, ArgumentBean defaults, AccessControlContext acc) throws ScriptException { + Bindings bindings = new SimpleBindings(); + bindings.put("_cli", PrivilegedInvocation.newProxy(CmdlineInterface.class, cli, acc)); + bindings.put("_args", defaults); + bindings.put("_types", MediaTypes.getDefault()); + bindings.put("_log", CLILogger); + + ScriptContext context = new SimpleScriptContext(); + context.setBindings(bindings, ScriptContext.ENGINE_SCOPE); + engine.setContext(context); + + // import additional functions into the shell environment + engine.eval(new InputStreamReader(ScriptShell.class.getResourceAsStream("ScriptShell.lib.groovy"))); + } + + + public Object evaluate(final String script, final Bindings bindings) throws Exception { + try { + return AccessController.doPrivileged(new PrivilegedExceptionAction() { + + @Override + public Object run() throws ScriptException { + return engine.eval(script, bindings); + } + }, getSandboxAccessControlContext()); + } catch (PrivilegedActionException e) { + throw e.getException(); + } + } + + + protected AccessControlContext getSandboxAccessControlContext() { + Permissions permissions = new Permissions(); + + permissions.add(new RuntimePermission("createClassLoader")); + permissions.add(new RuntimePermission("accessDeclaredMembers")); + permissions.add(new FilePermission("<>", "read")); + permissions.add(new SocketPermission("*", "connect")); + permissions.add(new PropertyPermission("*", "read")); + permissions.add(new RuntimePermission("getenv.*")); + + return new AccessControlContext(new ProtectionDomain[] { new ProtectionDomain(null, permissions) }); + } + +} diff --git a/source/net/sourceforge/filebot/cli/ScriptShell.lib.groovy b/source/net/sourceforge/filebot/cli/ScriptShell.lib.groovy new file mode 100644 index 00000000..9a6d29a8 --- /dev/null +++ b/source/net/sourceforge/filebot/cli/ScriptShell.lib.groovy @@ -0,0 +1,70 @@ + +import static groovy.io.FileType.*; + + +File.metaClass.isVideo = { _types.getFilter("video").accept(delegate) } +File.metaClass.isSubtitle = { _types.getFilter("subtitle").accept(delegate) } +File.metaClass.isVerification = { _types.getFilter("verification").accept(delegate) } + +File.metaClass.hasFile = { c -> isDirectory() && listFiles().find{ c.call(it) }} + +File.metaClass.getFiles = { def files = []; traverse(type:FILES) { files += it }; return files } +List.metaClass.getFiles = { findResults{ it.getFiles() }.flatten().unique() } + +File.metaClass.getFolders = { def folders = []; traverse(type:DIRECTORIES, visitRoot:true) { folders += it }; return folders } +List.metaClass.getFolders = { findResults{ it.getFolders() }.flatten().unique() } + +List.metaClass.eachMediaFolder = { c -> getFolders().findAll{ it.hasFile{ it.isVideo() } }.each(c) } + + + + +def rename(args) { args = _defaults(args) + _guarded { _cli.rename(_files(args), args.query, args.format, args.db, args.lang, args.strict) } +} + +def getSubtitles(args) { args = _defaults(args) + _guarded { _cli.getSubtitles(_files(args), args.query, args.lang, args.output, args.encoding) } +} + +def check(args) { + _guarded { _cli.check(_files(args)) } +} + +def compute(args) { args = _defaults(args) + _guarded { _cli.compute(_files(args), args.output, args.encoding) } +} + + + +/** + * Resolve folders/files to lists of one or more files + */ +def _files(args) { + def files = []; + if (args.folder) + args.folder.traverse(type:FILES, maxDepth:0) { files += it } + if (args.file) + files += args.file + + return files +} + +/** + * Fill in default values from cmdline arguments + */ +def _defaults(args) { + args.query = args.query ?: _args.query + args.format = args.format ?: _args.format + args.db = args.db ?: _args.db + args.lang = args.lang ?: _args.lang + args.output = args.output ?: _args.output + args.encoding = args.encoding ?: _args.encoding + args.strict = args.strict ?: !_args.nonStrict + return args +} + +/** + * Catch and log exceptions thrown by the closure + */ +this.metaClass._guarded = { c -> try { return c() } catch (e) { _log.severe(e.getMessage()); return null }} diff --git a/website/data/shell/src.groovy b/website/data/shell/src.groovy new file mode 100644 index 00000000..5cf41272 --- /dev/null +++ b/website/data/shell/src.groovy @@ -0,0 +1,5 @@ +args.eachMediaFolder { + getSubtitles(folder:it) + rename(folder:it) + compute(file:it.listFiles().findAll{ it.isVideo() }) +}