001package net.filebot.cli;
002
003import static java.awt.GraphicsEnvironment.*;
004import static java.util.Arrays.*;
005import static java.util.Collections.*;
006import static java.util.stream.Collectors.*;
007import static net.filebot.hash.VerificationUtilities.*;
008import static net.filebot.media.XattrMetaInfo.*;
009import static net.filebot.subtitle.SubtitleUtilities.*;
010import static net.filebot.util.FileUtilities.*;
011
012import java.io.File;
013import java.io.StringWriter;
014import java.nio.charset.Charset;
015import java.util.ArrayList;
016import java.util.LinkedHashMap;
017import java.util.List;
018import java.util.Map;
019import java.util.Optional;
020import java.util.function.BiFunction;
021import java.util.function.Function;
022import java.util.logging.Level;
023import java.util.stream.IntStream;
024import java.util.stream.Stream;
025
026import org.kohsuke.args4j.Argument;
027import org.kohsuke.args4j.CmdLineException;
028import org.kohsuke.args4j.CmdLineParser;
029import org.kohsuke.args4j.Option;
030import org.kohsuke.args4j.ParserProperties;
031import org.kohsuke.args4j.spi.StopOptionHandler;
032
033import net.filebot.ApplicationFolder;
034import net.filebot.GroovyEngine;
035import net.filebot.Language;
036import net.filebot.RenameAction;
037import net.filebot.StandardPostProcessAction;
038import net.filebot.StandardRenameAction;
039import net.filebot.WebServices;
040import net.filebot.format.ExpressionFileComparator;
041import net.filebot.format.ExpressionFileFilter;
042import net.filebot.format.ExpressionFileFormat;
043import net.filebot.format.ExpressionFilter;
044import net.filebot.format.ExpressionFormat;
045import net.filebot.format.ExpressionMapper;
046import net.filebot.format.QueryExpression;
047import net.filebot.hash.HashType;
048import net.filebot.postprocess.Apply;
049import net.filebot.postprocess.Script;
050import net.filebot.subtitle.SubtitleFormat;
051import net.filebot.subtitle.SubtitleNaming;
052import net.filebot.ui.Mode;
053import net.filebot.ui.rename.MatchMode;
054import net.filebot.ui.rename.Preset;
055import net.filebot.web.Datasource;
056import net.filebot.web.SortOrder;
057
058public class ArgumentBean {
059
060        @Option(name = "-rename", usage = "Rename media files")
061        public boolean rename = false;
062
063        @Option(name = "--db", usage = "Database", metaVar = "[TheTVDB, AniDB, TheMovieDB::TV] or [TheMovieDB] or [AcoustID, ID3] or [xattr, exif, file]")
064        public String db;
065
066        @Option(name = "--order", usage = "Episode order", metaVar = "[Airdate, DVD, Absolute, Digital, Production, Date]")
067        public String order = "Airdate";
068
069        @Option(name = "--format", usage = "Format expression", handler = GroovyExpressionHandler.class)
070        public String format;
071
072        @Option(name = "--action", usage = "Rename action", metaVar = "[move, copy, keeplink, symlink, hardlink, clone, test]")
073        public String action = "move";
074
075        @Option(name = "--conflict", usage = "Conflict resolution", metaVar = "[skip, replace, auto, index, fail]")
076        public String conflict = "skip";
077
078        @Option(name = "--filter", usage = "Filter expression", handler = GroovyExpressionHandler.class)
079        public String filter = null;
080
081        @Option(name = "--mapper", usage = "Mapper expression", handler = GroovyExpressionHandler.class)
082        public String mapper = null;
083
084        @Option(name = "--q", usage = "Query expression", metaVar = "[name] or [id] or {expression}", handler = GroovyExpressionHandler.class)
085        public String query;
086
087        @Option(name = "--lang", usage = "Language", metaVar = "[English, German, ...]")
088        public String lang = "en";
089
090        @Option(name = "-non-strict", usage = "Enable advanced matching and more aggressive guess work")
091        public boolean nonStrict = false;
092
093        @Option(name = "-r", usage = "Select files from folders recursively")
094        public boolean recursive = false;
095
096        @Option(name = "-d", usage = "Select folders")
097        public boolean directory = false;
098
099        @Option(name = "--file-filter", usage = "Input file filter expression", handler = GroovyExpressionHandler.class)
100        public String inputFileFilter = null;
101
102        @Option(name = "--file-order", usage = "Input file order expression", handler = GroovyExpressionHandler.class)
103        public String inputFileOrder = null;
104
105        @Option(name = "--output", usage = "Output directory", metaVar = "/path/to/folder")
106        public String output;
107
108        @Option(name = "--apply", usage = "Apply post-processing actions", metaVar = "[artwork, cover, nfo, metadata, import, srt, date, tags, chmod, touch, prune, clean]", handler = ApplyOptionsHandler.class)
109        public List<String> apply = new ArrayList<String>();
110
111        @Option(name = "-exec", usage = "Execute command", metaVar = "echo {f} [+*]", handler = ExecOptionsHandler.class)
112        public List<String> exec = new ArrayList<String>();
113
114        @Option(name = "-extract", usage = "Extract archives")
115        public boolean extract = false;
116
117        @Option(name = "-check", usage = "Create / Check verification files")
118        public boolean check;
119
120        @Option(name = "-get-subtitles", usage = "Fetch subtitles")
121        public boolean getSubtitles;
122
123        @Option(name = "--encoding", usage = "Output character encoding", metaVar = "[UTF-8, Windows-1252]")
124        public String encoding;
125
126        @Option(name = "-list", usage = "Print episode list")
127        public boolean list = false;
128
129        @Option(name = "-find", usage = "Print file paths")
130        public boolean find = false;
131
132        @Option(name = "-mediainfo", usage = "Print media info")
133        public boolean mediaInfo = false;
134
135        @Option(name = "-script", usage = "Run Groovy script", metaVar = "[fn:name] or [script.groovy]")
136        public String script = null;
137
138        @Option(name = "--def", usage = "Define script variables", handler = BindingsHandler.class)
139        public Map<String, String> defines = new LinkedHashMap<String, String>();
140
141        @Option(name = "-revert", usage = "Revert files")
142        public boolean revert = false;
143
144        @Option(name = "--mode", usage = "Enable CLI interactive mode", metaVar = "[interactive]")
145        public String mode = null;
146
147        @Option(name = "--log", usage = "Log level", metaVar = "[all, fine, info, warning, off]")
148        public String log;
149
150        @Option(name = "--log-file", usage = "Log file", metaVar = "*.txt")
151        public String logFile = null;
152
153        @Option(name = "-clear-cache", usage = "Clear cached and temporary data")
154        public boolean clearCache = false;
155
156        @Option(name = "-clear-prefs", usage = "Clear application settings")
157        public boolean clearPrefs = false;
158
159        @Option(name = "-clear-history", usage = "Clear rename history")
160        public boolean clearHistory = false;
161
162        @Option(name = "-unixfs", usage = "Allow special characters in file paths")
163        public boolean unixfs = false;
164
165        @Option(name = "-no-xattr", usage = "Disable extended attributes")
166        public boolean disableExtendedAttributes = false;
167
168        @Option(name = "-no-probe", usage = "Disable media parser")
169        public boolean disableMediaParser = false;
170
171        @Option(name = "-no-history", usage = "Disable history")
172        public boolean disableHistory = false;
173
174        @Option(name = "-no-index", usage = "Disable media index")
175        public boolean disableMediaIndex = false;
176
177        @Option(name = "-version", usage = "Print version identifier")
178        public boolean version = false;
179
180        @Option(name = "-help", usage = "Print this help message")
181        public boolean help = false;
182
183        @Option(name = "--license", usage = "Import license file", handler = LicenseOptionHandler.class)
184        public String license = null;
185
186        @Option(name = "--single-instance", usage = "Enable single instance mode", hidden = true)
187        public String singleInstance = null;
188
189        @Argument
190        @Option(name = "--", handler = StopOptionHandler.class)
191        public List<String> arguments = new ArrayList<String>();
192
193        public boolean runCLI() {
194                return rename || getSubtitles || check || list || find || mediaInfo || revert || extract || script != null || (license != null && (System.console() != null || isHeadless()));
195        }
196
197        public boolean isInteractive() {
198                return "interactive".equalsIgnoreCase(mode);
199        }
200
201        public boolean printVersion() {
202                return version;
203        }
204
205        public boolean printHelp() {
206                return help;
207        }
208
209        public boolean clearCache() {
210                return clearCache;
211        }
212
213        public boolean clearUserData() {
214                return clearPrefs;
215        }
216
217        public boolean clearHistory() {
218                return clearHistory;
219        }
220
221        public List<File> getFileArguments() throws Exception {
222                if (arguments.isEmpty()) {
223                        return emptyList();
224                }
225
226                if (recursive || inputFileFilter != null || inputFileOrder != null) {
227                        return getFiles();
228                }
229
230                // resolve relative paths
231                return getInputArguments();
232        }
233
234        public List<File> getFiles() throws Exception {
235                List<File> selection = getInputArguments();
236
237                // use the current working directory as input folder if no input arguments were given
238                if (selection.isEmpty() && arguments.isEmpty() && find) {
239                        File workingDirectory = new File(".").getCanonicalFile();
240                        selection = asList(workingDirectory);
241                }
242
243                // resolve given paths
244                List<File> files = new ArrayList<File>();
245
246                // resolve relative paths
247                for (File file : selection) {
248                        if (file.isDirectory()) {
249                                if (directory) {
250                                        if (find || recursive) {
251                                                files.addAll(listFiles(file, FOLDERS, HUMAN_NAME_ORDER));
252                                        } else {
253                                                files.add(file);
254                                        }
255                                } else if (find || recursive) {
256                                        // traverse only normal folders by default (i.e. make sure to exclude system folders such as @eaDir folders, #recycle folders, etc)
257                                        files.addAll(listFiles(singleton(file), FILES, HUMAN_NAME_ORDER, find ? File::isDirectory : FOLDERS, FILE_WALK_MAX_DEPTH));
258                                } else {
259                                        files.addAll(getChildren(file, f -> f.isFile() && !f.isHidden(), HUMAN_NAME_ORDER));
260                                }
261                        } else {
262                                files.add(file);
263                        }
264                }
265
266                // input file filter (e.g. useful on Windows where find -exec is not an option)
267                if (inputFileFilter != null && !files.isEmpty()) {
268                        files = filter(files, new ExpressionFileFilter(inputFileFilter));
269                }
270
271                if (inputFileOrder != null && !files.isEmpty()) {
272                        files.sort(new ExpressionFileComparator(inputFileOrder));
273                }
274
275                return files;
276        }
277
278        public List<File> getInputArguments() {
279                return arguments.stream().filter(s -> !s.isEmpty()).map(File::new).map(f -> getRealPath(f)).collect(toList());
280        }
281
282        public RenameAction getRenameAction() {
283                // support custom executables (via absolute path)
284                if (isExecutable(action)) {
285                        return ExecutableRenameAction.executable(new File(action), getOutputPath());
286                }
287
288                // support custom groovy scripts (via files or closures)
289                if (isGroovyScript(action)) {
290                        return resolveGroovyScript(action, GroovyAction::new, "Invalid --action script");
291                }
292
293                return optional(action, StandardRenameAction::forName, "Invalid --action value").orElse(StandardRenameAction.MOVE);
294        }
295
296        public ConflictAction getConflictAction() {
297                // support custom groovy scripts (via files or closures)
298                if (isGroovyScript(conflict)) {
299                        return resolveGroovyScript(conflict, GroovyAction::new, "Invalid --conflict script");
300                }
301
302                return optional(conflict, StandardConflictAction::forName, "Invalid --conflict value").orElse(StandardConflictAction.SKIP);
303        }
304
305        public SortOrder getSortOrder() {
306                return optional(order, SortOrder::forName, "Invalid --order value").orElse(SortOrder.Airdate);
307        }
308
309        public ExpressionFormat getExpressionFormat() throws Exception {
310                return format == null ? null : new ExpressionFormat(format);
311        }
312
313        public ExpressionFileFormat getExpressionFileFormat() throws Exception {
314                return format == null ? null : new ExpressionFileFormat(format);
315        }
316
317        public ExpressionFilter getExpressionFilter() throws Exception {
318                return filter == null ? null : new ExpressionFilter(filter);
319        }
320
321        public ExpressionFileFilter getExpressionFileFilter() throws Exception {
322                return filter == null ? null : new ExpressionFileFilter(filter, xattr::getMetaInfo);
323        }
324
325        public ExpressionMapper getExpressionMapper() throws Exception {
326                return mapper == null ? null : new ExpressionMapper(mapper);
327        }
328
329        public Datasource getDatasource() {
330                return optional(db, WebServices::getService, "Invalid --db value").orElse(null);
331        }
332
333        public QueryExpression getQueryExpression() throws Exception {
334                return query == null ? null : new QueryExpression(query);
335        }
336
337        public File getOutputPath() {
338                return output == null ? null : new File(output);
339        }
340
341        public File getAbsoluteOutputFolder() {
342                return optional(output, s -> prepareOutputPath(s, null), "Invalid --output folder path").orElse(null);
343        }
344
345        public SubtitleFormat getSubtitleOutputFormat() {
346                return output == null ? null : getSubtitleFormatByName(output);
347        }
348
349        public SubtitleNaming getSubtitleNamingFormat() {
350                return optional(format, SubtitleNaming::forName, "Invalid subtitle naming --format value").orElse(SubtitleNaming.MATCH_VIDEO_ADD_LANGUAGE_TAG);
351        }
352
353        public HashType getOutputHashType() {
354                // support --format SFV
355                return optional(format, s -> getHashType(s), "Invalid checksum --format value").orElseGet(() -> {
356                        // support --output checksum.sfv
357                        return optional(output, s -> getHashType(new File(s)), "Invalid checksum --output path").orElse(HashType.SFV);
358                });
359        }
360
361        public Charset getEncoding() {
362                return optional(encoding, Charset::forName, "Invalid --encoding value").orElse(null);
363        }
364
365        public Language getLanguage() {
366                // find language code for any input (en, eng, English, etc)
367                return optional(lang, Language::forName, "Invalid --lang value").orElseGet(Language::defaultLanguage);
368        }
369
370        public File getLogFile() {
371                // resolve relative paths against {application.dir}/logs
372                return optional(logFile, s -> prepareOutputPath(s, ApplicationFolder.Logs.getDirectory()), "Invalid --log-file path").orElse(null);
373        }
374
375        public boolean isStrict() {
376                return !nonStrict;
377        }
378
379        public Level getLogLevel() {
380                return optional(log, s -> Level.parse(s.toUpperCase()), "Invalid --log level").orElse(Level.ALL);
381        }
382
383        public ExecCommand getExecCommand() {
384                return exec.isEmpty() ? null : optional(exec, arguments -> ExecCommand.parse(arguments, getOutputPath()), "Invalid --exec expression").orElse(null);
385        }
386
387        public Apply[] getPostProcessActions() {
388                return apply.isEmpty() ? null : optional(apply, arguments -> {
389                        return arguments.stream().map(v -> {
390                                if (isGroovyScript(v)) {
391                                        return resolveGroovyScript(v, Script::new, "Invalid --apply post-processing script");
392                                }
393                                return StandardPostProcessAction.forName(v);
394                        }).toArray(Apply[]::new);
395                }, "Invalid --apply post-processing action").orElse(null);
396        }
397
398        public Mode[] getPanelMode() {
399                return optional(mode, s -> new Mode[] { Mode.forName(mode) }, "Invalid --mode value").orElseGet(Mode::modes);
400        }
401
402        public Preset getPreset() throws Exception {
403                if (mode == null || db == null || arguments.isEmpty()) {
404                        return null;
405                }
406
407                List<File> input = getInputArguments();
408                Datasource database = getDatasource();
409                SortOrder order = getSortOrder();
410                MatchMode mode = isStrict() ? MatchMode.Strict : MatchMode.Opportunistic;
411                Language language = getLanguage();
412                StandardRenameAction action = (StandardRenameAction) getRenameAction();
413
414                ExpressionFileFilter fileFilter = inputFileFilter == null ? null : new ExpressionFileFilter(inputFileFilter);
415                ExpressionFileComparator fileOrder = inputFileOrder == null ? null : new ExpressionFileComparator(inputFileOrder);
416                ExpressionFileFormat format = getExpressionFileFormat();
417
418                return new Preset("", "", input, fileFilter, fileOrder, format, database, order, mode, language, action, null);
419        }
420
421        public String getLicenseKey() {
422                return license;
423        }
424
425        private final String[] args;
426
427        public ArgumentBean() {
428                this.args = new String[0];
429        }
430
431        public ArgumentBean(String[] args, boolean expand) throws CmdLineException {
432                // can't use built-in @file syntax because args4j doesn't use UTF-8 on Windows and fails to ignore BOM markers
433                CmdLineParser parser = new CmdLineParser(this, ParserProperties.defaults().withAtSyntax(false).withOptionValueDelimiter("="));
434                try {
435                        this.args = expand ? expandAtFiles(args) : args.clone();
436                } catch (Exception e) {
437                        throw new CmdLineException(parser, e.getMessage(), e);
438                }
439                // NOTE: CmdLineParser::parseArgument may modify the String[] input parameter
440                parser.parseArgument(this.args.clone());
441        }
442
443        public String[] getArgumentArray() {
444                return args.clone();
445        }
446
447        public String usage() {
448                StringWriter buffer = new StringWriter(4096);
449                CmdLineParser parser = new CmdLineParser(this, ParserProperties.defaults().withShowDefaults(false).withOptionSorter(null));
450                parser.printUsage(buffer, null);
451                return buffer.toString();
452        }
453
454        @Override
455        public String toString() {
456                return toString(args);
457        }
458
459        private File prepareOutputPath(String path, File directory) {
460                try {
461                        File file = new File(path);
462                        // resolve relative path is necessary
463                        if (directory != null && !file.isAbsolute()) {
464                                file = new File(directory, path);
465                        }
466                        // get canonical absolute path
467                        return file.getCanonicalFile();
468                } catch (Exception e) {
469                        throw new IllegalArgumentException(e.getMessage());
470                }
471        }
472
473        private boolean isExecutable(String executable) {
474                if (UNIX) {
475                        return executable.startsWith("/") || executable.endsWith(".sh");
476                } else {
477                        return executable.endsWith(".ps1") || executable.endsWith(".cmd") || executable.endsWith(".bat") || executable.endsWith(".exe");
478                }
479        }
480
481        private static boolean isGroovyScript(String value) {
482                return value.startsWith("{") || value.endsWith("}") || value.endsWith(".groovy");
483        }
484
485        private static <T> T resolveGroovyScript(String value, BiFunction<String, String, T> mapper, String error) {
486                try {
487                        // as external source file
488                        if (GroovyEngine.isGroovyFile(value)) {
489                                File source = new File(value).getAbsoluteFile();
490                                return mapper.apply(source.getName(), GroovyEngine.resolveExternalScript(source));
491                        }
492                        // as code
493                        return mapper.apply("GROOVY", GroovyEngine.resolveScript(value));
494                } catch (Exception e) {
495                        // return helpful error messages
496                        throw new CmdlineException(error + ": " + quote(value) + ": " + e.getMessage());
497                }
498        }
499
500        private static <S, T> Optional<T> optional(S value, Function<S, T> mapper, String error) {
501                try {
502                        return Optional.ofNullable(value).map(mapper);
503                } catch (CmdlineException e) {
504                        // pass through helpful error messages
505                        throw e;
506                } catch (Exception e) {
507                        // return helpful error messages
508                        throw new CmdlineException(error + ": " + quote(value) + " " + e.getMessage());
509                }
510        }
511
512        private static String quote(Object value) {
513                return value instanceof List ? value.toString() : "'" + value + "'";
514        }
515
516        public static String toString(String[] args) {
517                return IntStream.range(0, args.length).mapToObj(i -> {
518                        return String.format("args[%s] = %s", i + 1, args[i]);
519                }).collect(joining(System.lineSeparator()));
520        }
521
522        public static String[] expandAtFiles(String[] args) {
523                return stream(args).flatMap(a -> {
524                        // fix accidental @groovy files
525                        if (a.startsWith("@") && !a.endsWith(".groovy")) {
526                                File f = new File(a.substring(1)).getAbsoluteFile();
527                                // fix accidental @arguments
528                                if (f.exists()) {
529                                        try {
530                                                return readLines(f).stream().map(line -> {
531                                                        // fix superfluous quotes
532                                                        if (line.startsWith("\"") && line.endsWith("\"")) {
533                                                                return line.substring(1, line.length() - 1);
534                                                        }
535                                                        // fix superfluous spaces
536                                                        return line.trim();
537                                                });
538                                        } catch (Exception e) {
539                                                throw new CmdlineException("Invalid @file path", f, e);
540                                        }
541                                }
542                        }
543                        return Stream.of(a);
544                }).toArray(String[]::new);
545        }
546
547        public static ArgumentBean parse(String... args) throws CmdLineException {
548                try {
549                        return new ArgumentBean(args, true);
550                } catch (CmdLineException e) {
551                        // 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
552                        if (Boolean.parseBoolean(System.getProperty("apple.app.launcher"))) {
553                                return new ArgumentBean();
554                        }
555                        // just throw exception as usual when called from command-line and display argument errors
556                        throw e;
557                }
558        }
559
560}