package net.filebot.cli; import static java.awt.GraphicsEnvironment.*; 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.Settings.*; import static net.filebot.hash.VerificationUtilities.*; import static net.filebot.media.XattrMetaInfo.*; import static net.filebot.subtitle.SubtitleUtilities.*; import static net.filebot.util.FileUtilities.*; import static net.filebot.util.PGP.*; import java.io.File; import java.io.FileFilter; import java.io.StringWriter; import java.nio.charset.Charset; import java.nio.file.LinkOption; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.function.Supplier; import java.util.logging.Level; import java.util.regex.Pattern; import org.kohsuke.args4j.Argument; import org.kohsuke.args4j.CmdLineException; import org.kohsuke.args4j.CmdLineParser; import org.kohsuke.args4j.Option; import org.kohsuke.args4j.ParserProperties; import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler; import org.kohsuke.args4j.spi.RestOfArgumentsHandler; import net.filebot.ApplicationFolder; import net.filebot.Language; import net.filebot.RenameAction; import net.filebot.StandardRenameAction; import net.filebot.WebServices; import net.filebot.format.ExpressionFileFilter; import net.filebot.format.ExpressionFileFormat; import net.filebot.format.ExpressionFilter; import net.filebot.format.ExpressionFormat; import net.filebot.format.ExpressionMapper; import net.filebot.hash.HashType; import net.filebot.subtitle.SubtitleFormat; import net.filebot.subtitle.SubtitleNaming; import net.filebot.ui.PanelBuilder; import net.filebot.web.Datasource; import net.filebot.web.EpisodeListProvider; import net.filebot.web.SortOrder; public class ArgumentBean { @Option(name = "--mode", usage = "Enable CLI interactive mode", metaVar = "[interactive]") public String mode = null; @Option(name = "-rename", usage = "Rename media files") public boolean rename = false; @Option(name = "--db", usage = "Database", metaVar = "[TheTVDB, AniDB, TheMovieDB::TV] or [TheMovieDB] or [AcoustID, ID3] or [xattr, exif, file]") public String db; @Option(name = "--order", usage = "Episode order", metaVar = "[Airdate, DVD, Absolute, AbsoluteAirdate]") public String order = "Airdate"; @Option(name = "--action", usage = "Rename action", metaVar = "[move, copy, keeplink, symlink, hardlink, clone, test]") public String action = "move"; @Option(name = "--conflict", usage = "Conflict resolution", metaVar = "[skip, override, auto, index, fail]") public String conflict = "skip"; @Option(name = "--filter", usage = "Filter expression", handler = GroovyExpressionHandler.class) public String filter = null; @Option(name = "--mapper", usage = "Mapper expression", handler = GroovyExpressionHandler.class) public String mapper = null; @Option(name = "--format", usage = "Format expression", handler = GroovyExpressionHandler.class) public String format; @Option(name = "-non-strict", usage = "Enable advanced matching and more aggressive guessing") public boolean nonStrict = false; @Option(name = "-get-subtitles", usage = "Fetch subtitles") public boolean getSubtitles; @Option(name = "--q", usage = "Force lookup query", metaVar = "series / movie query") public String query; @Option(name = "--lang", usage = "Language", metaVar = "language code") public String lang = "en"; @Option(name = "-check", usage = "Create / Check verification files") public boolean check; @Option(name = "--output", usage = "Output path", metaVar = "path") public String output; @Option(name = "--encoding", usage = "Output character encoding", metaVar = "[UTF-8, Windows-1252]") public String encoding; @Option(name = "-list", usage = "Print episode list") public boolean list = false; @Option(name = "-mediainfo", usage = "Print media info") public boolean mediaInfo = false; @Option(name = "-revert", usage = "Revert files") public boolean revert = false; @Option(name = "-extract", usage = "Extract archives") public boolean extract = false; @Option(name = "-script", usage = "Run Groovy script", metaVar = "[fn:name] or [script.groovy]") public String script = null; @Option(name = "--def", usage = "Define script variables", handler = BindingsHandler.class) public Map defines = new LinkedHashMap(); @Option(name = "-r", usage = "Recursively process folders") public boolean recursive = false; @Option(name = "--file-filter", usage = "Input file filter expression", handler = GroovyExpressionHandler.class) public String inputFileFilter = null; @Option(name = "-exec", usage = "Execute command", metaVar = "echo {f} [+]", handler = RestOfArgumentsHandler.class) public List exec = new ArrayList(); @Option(name = "-unixfs", usage = "Allow special characters in file paths") public boolean unixfs = false; @Option(name = "-no-xattr", usage = "Disable extended attributes") public boolean disableExtendedAttributes = false; @Option(name = "-no-history", usage = "Disable history") public boolean disableHistory = false; @Option(name = "--log", usage = "Log level", metaVar = "[all, fine, info, warning]") public String log = "all"; @Option(name = "--log-file", usage = "Log file", metaVar = "log.txt") public String logFile = null; @Option(name = "--log-lock", usage = "Lock log file", metaVar = "[yes, no]", handler = ExplicitBooleanOptionHandler.class) public boolean logLock = true; @Option(name = "-clear-cache", usage = "Clear cached and temporary data") public boolean clearCache = false; @Option(name = "-clear-prefs", usage = "Clear application settings") public boolean clearPrefs = false; @Option(name = "-clear-history", usage = "Clear application history") public boolean clearHistory = false; @Option(name = "-version", usage = "Print version identifier") public boolean version = false; @Option(name = "-help", usage = "Print this help message") public boolean help = false; @Option(name = "--license", usage = "Import license file", metaVar = "*.psm") public String license = null; @Argument public List arguments = new ArrayList(); public boolean runCLI() { return rename || getSubtitles || check || list || mediaInfo || revert || extract || script != null || (license != null && (isHeadless() || System.console() != null)); } public boolean isInteractive() { return "interactive".equalsIgnoreCase(mode) && System.console() != null; } public boolean printVersion() { return version; } public boolean printHelp() { return help; } public boolean clearCache() { return clearCache; } public boolean clearUserData() { return clearPrefs; } public boolean clearHistory() { return clearHistory; } public List getFiles(boolean resolveFolders) throws Exception { if (arguments == null || arguments.isEmpty()) { return emptyList(); } // resolve given paths List files = new ArrayList(); for (String it : arguments) { // ignore empty arguments if (it.trim().isEmpty()) { continue; } // resolve relative paths File file = new File(it); // since we don't want to follow symlinks, we need to take the scenic route through the Path class try { file = file.toPath().toRealPath(LinkOption.NOFOLLOW_LINKS).toFile(); } catch (Exception e) { debug.warning(format("Illegal Argument: %s (%s)", e, file)); } if (resolveFolders && file.isDirectory()) { if (recursive) { files.addAll(listFiles(file, FILES, HUMAN_NAME_ORDER)); } else { files.addAll(getChildren(file, f -> f.isFile() && !f.isHidden(), HUMAN_NAME_ORDER)); } } else { files.add(file); } } // input file filter (e.g. useful on Windows where find -exec is not an option) if (inputFileFilter != null) { return filter(files, new ExpressionFileFilter(inputFileFilter, f -> f)); } return files; } public RenameAction getRenameAction() throws Exception { // support custom executables (via absolute path) if (action.startsWith("/")) { return new ExecutableRenameAction(action, getOutputPath()); } // support custom groovy scripts (via closures) if (action.startsWith("{") || action.endsWith("}")) { return new GroovyRenameAction(action); } // support custom groovy scripts (via files) if (action.endsWith(".groovy")) { return new GroovyRenameAction(readTextFile(new File(action))); } return StandardRenameAction.forName(action); } public ConflictAction getConflictAction() { return ConflictAction.forName(conflict); } public SortOrder getSortOrder() { return SortOrder.forName(order); } public ExpressionFormat getExpressionFormat() throws Exception { return format == null ? null : new ExpressionFormat(format); } public ExpressionFileFormat getExpressionFileFormat() throws Exception { return format == null ? null : new ExpressionFileFormat(format); } public ExpressionFilter getExpressionFilter() throws Exception { return filter == null ? null : new ExpressionFilter(filter); } public FileFilter getFileFilter() throws Exception { return filter == null ? FILES : new ExpressionFileFilter(filter, xattr::getMetaInfo); } public ExpressionMapper getExpressionMapper() throws Exception { return mapper == null ? null : new ExpressionMapper(mapper); } public Datasource getDatasource() { return db == null ? null : WebServices.getService(db); } public EpisodeListProvider getEpisodeListProvider() { return db == null ? WebServices.TheTVDB : WebServices.getEpisodeListProvider(db); // default to TheTVDB if --db is not set } public String getSearchQuery() { return query == null || query.isEmpty() ? null : query; } public File getOutputPath() { return output == null ? null : new File(output); } public File getAbsoluteOutputFolder() throws Exception { return output == null ? null : new File(output).getCanonicalFile(); } public SubtitleFormat getSubtitleOutputFormat() { return output == null ? null : getSubtitleFormatByName(output); } public SubtitleNaming getSubtitleNamingFormat() { return optional(format).map(SubtitleNaming::forName).orElse(SubtitleNaming.MATCH_VIDEO_ADD_LANGUAGE_TAG); } public HashType getOutputHashType() { // support --output checksum.sfv return optional(output).map(File::new).map(f -> getHashType(f)).orElseGet(() -> { // support --format SFV return optional(format).map(k -> getHashTypeByExtension(k)).orElse(HashType.SFV); }); } public Charset getEncoding() { return encoding == null ? null : Charset.forName(encoding); } public Language getLanguage() { // find language code for any input (en, eng, English, etc) return optional(lang).map(Language::findLanguage).orElseThrow(error("Illegal language code", lang)); } public File getLogFile() { File file = new File(logFile); if (file.isAbsolute()) { return file; } // by default resolve relative paths against {applicationFolder}/logs/{logFile} return ApplicationFolder.AppData.resolve("logs/" + logFile); } public boolean isStrict() { return !nonStrict; } public Level getLogLevel() { return Level.parse(log.toUpperCase()); } public ExecCommand getExecCommand() { try { return exec == null || exec.isEmpty() ? null : ExecCommand.parse(exec, getOutputPath()); } catch (Exception e) { throw new CmdlineException("Illegal exec expression: " + exec); } } public List getPanelBuilders() { if (mode == null) { // MAS does not allow subtitle applications if (isMacSandbox()) { return stream(PanelBuilder.defaultSequence()).filter(p -> !p.getName().equals("Subtitles")).collect(toList()); } // default multi panel mode return asList(PanelBuilder.defaultSequence()); } // only selected panels return optional(mode).map(m -> { Pattern pattern = Pattern.compile(mode, Pattern.CASE_INSENSITIVE); List panel = stream(PanelBuilder.defaultSequence()).filter(p -> pattern.matcher(p.getName()).matches()).collect(toList()); // throw exception if illegal pattern was passed in if (panel.isEmpty()) { return null; } return panel; }).orElseThrow(error("Illegal mode", mode)); } public String getLicenseKey() { try { return license == null || license.isEmpty() ? null : findClearSignMessage(readTextFile(new File(license))); } catch (Exception e) { throw new CmdlineException("Invalid License File: " + e.getMessage(), e); } } private final String[] args; public ArgumentBean() { this.args = new String[0]; } public ArgumentBean(String[] args) throws CmdLineException { this.args = args.clone(); CmdLineParser parser = new CmdLineParser(this); parser.parseArgument(args); } public String[] getArgumentArray() { return args.clone(); } public String usage() { StringWriter buffer = new StringWriter(); CmdLineParser parser = new CmdLineParser(this, ParserProperties.defaults().withShowDefaults(false).withOptionSorter(null)); parser.printUsage(buffer, null); return buffer.toString(); } private static Optional optional(T value) { return Optional.ofNullable(value); } private static Supplier error(String message, Object value) { return () -> new CmdlineException(message + ": " + value); } @Override public String toString() { return deepToString(args); } public static ArgumentBean parse(String... args) throws CmdLineException { try { return new ArgumentBean(args); } catch (CmdLineException e) { // MAS does not support or allow command-line applications and may run executables with strange arguments for no apparent reason (e.g. filebot.launcher -psn_0_774333) so we ignore arguments completely in this case if (Boolean.parseBoolean(System.getProperty("apple.app.launcher"))) { return new ArgumentBean(); } // just throw exception as usual when called from command-line and display argument errors throw e; } } }