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