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.Logging.*;
008import static net.filebot.Settings.*;
009import static net.filebot.hash.VerificationUtilities.*;
010import static net.filebot.media.XattrMetaInfo.*;
011import static net.filebot.subtitle.SubtitleUtilities.*;
012import static net.filebot.util.FileUtilities.*;
013
014import java.io.File;
015import java.io.FileFilter;
016import java.io.StringWriter;
017import java.nio.charset.Charset;
018import java.nio.file.LinkOption;
019import java.util.ArrayList;
020import java.util.LinkedHashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Optional;
024import java.util.function.Supplier;
025import java.util.logging.Level;
026import java.util.regex.Pattern;
027import java.util.stream.IntStream;
028
029import org.kohsuke.args4j.Argument;
030import org.kohsuke.args4j.CmdLineException;
031import org.kohsuke.args4j.CmdLineParser;
032import org.kohsuke.args4j.Option;
033import org.kohsuke.args4j.ParserProperties;
034import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
035
036import net.filebot.ApplicationFolder;
037import net.filebot.Language;
038import net.filebot.RenameAction;
039import net.filebot.StandardRenameAction;
040import net.filebot.WebServices;
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.hash.HashType;
047import net.filebot.subtitle.SubtitleFormat;
048import net.filebot.subtitle.SubtitleNaming;
049import net.filebot.ui.PanelBuilder;
050import net.filebot.web.Datasource;
051import net.filebot.web.EpisodeListProvider;
052import net.filebot.web.SortOrder;
053
054public class ArgumentBean {
055
056        @Option(name = "--mode", usage = "Enable CLI interactive mode", metaVar = "[interactive]")
057        public String mode = null;
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, AbsoluteAirdate]")
066        public String order = "Airdate";
067
068        @Option(name = "--action", usage = "Rename action", metaVar = "[move, copy, keeplink, symlink, hardlink, clone, test]")
069        public String action = "move";
070
071        @Option(name = "--conflict", usage = "Conflict resolution", metaVar = "[skip, override, auto, index, fail]")
072        public String conflict = "skip";
073
074        @Option(name = "--filter", usage = "Filter expression", handler = GroovyExpressionHandler.class)
075        public String filter = null;
076
077        @Option(name = "--mapper", usage = "Mapper expression", handler = GroovyExpressionHandler.class)
078        public String mapper = null;
079
080        @Option(name = "--format", usage = "Format expression", handler = GroovyExpressionHandler.class)
081        public String format;
082
083        @Option(name = "-non-strict", usage = "Enable advanced matching and more aggressive guessing")
084        public boolean nonStrict = false;
085
086        @Option(name = "-get-subtitles", usage = "Fetch subtitles")
087        public boolean getSubtitles;
088
089        @Option(name = "--q", usage = "Force lookup query", metaVar = "series / movie query")
090        public String query;
091
092        @Option(name = "--lang", usage = "Language", metaVar = "language code")
093        public String lang = "en";
094
095        @Option(name = "-check", usage = "Create / Check verification files")
096        public boolean check;
097
098        @Option(name = "--output", usage = "Output path", metaVar = "path")
099        public String output;
100
101        @Option(name = "--encoding", usage = "Output character encoding", metaVar = "[UTF-8, Windows-1252]")
102        public String encoding;
103
104        @Option(name = "-list", usage = "Print episode list")
105        public boolean list = false;
106
107        @Option(name = "-mediainfo", usage = "Print media info")
108        public boolean mediaInfo = false;
109
110        @Option(name = "-revert", usage = "Revert files")
111        public boolean revert = false;
112
113        @Option(name = "-extract", usage = "Extract archives")
114        public boolean extract = false;
115
116        @Option(name = "-script", usage = "Run Groovy script", metaVar = "[fn:name] or [script.groovy]")
117        public String script = null;
118
119        @Option(name = "--def", usage = "Define script variables", handler = BindingsHandler.class)
120        public Map<String, String> defines = new LinkedHashMap<String, String>();
121
122        @Option(name = "-r", usage = "Recursively process folders")
123        public boolean recursive = false;
124
125        @Option(name = "--file-filter", usage = "Input file filter expression", handler = GroovyExpressionHandler.class)
126        public String inputFileFilter = null;
127
128        @Option(name = "-exec", usage = "Execute command", metaVar = "echo {f} [+]", handler = ExecOptionsHandler.class)
129        public List<String> exec = new ArrayList<String>();
130
131        @Option(name = "-unixfs", usage = "Allow special characters in file paths")
132        public boolean unixfs = false;
133
134        @Option(name = "-no-xattr", usage = "Disable extended attributes")
135        public boolean disableExtendedAttributes = false;
136
137        @Option(name = "-no-history", usage = "Disable history")
138        public boolean disableHistory = false;
139
140        @Option(name = "--log", usage = "Log level", metaVar = "[all, fine, info, warning]")
141        public String log = "all";
142
143        @Option(name = "--log-file", usage = "Log file", metaVar = "log.txt")
144        public String logFile = null;
145
146        @Option(name = "--log-lock", usage = "Lock log file", metaVar = "[yes, no]", handler = ExplicitBooleanOptionHandler.class)
147        public boolean logLock = true;
148
149        @Option(name = "-clear-cache", usage = "Clear cached and temporary data")
150        public boolean clearCache = false;
151
152        @Option(name = "-clear-prefs", usage = "Clear application settings")
153        public boolean clearPrefs = false;
154
155        @Option(name = "-clear-history", usage = "Clear application history")
156        public boolean clearHistory = false;
157
158        @Option(name = "-version", usage = "Print version identifier")
159        public boolean version = false;
160
161        @Option(name = "-help", usage = "Print this help message")
162        public boolean help = false;
163
164        @Option(name = "--license", usage = "Import license file", handler = LicenseOptionHandler.class)
165        public String license = null;
166
167        @Argument
168        public List<String> arguments = new ArrayList<String>();
169
170        public boolean runCLI() {
171                return rename || getSubtitles || check || list || mediaInfo || revert || extract || script != null || (license != null && (isHeadless() || System.console() != null));
172        }
173
174        public boolean isInteractive() {
175                return "interactive".equalsIgnoreCase(mode) && System.console() != null;
176        }
177
178        public boolean printVersion() {
179                return version;
180        }
181
182        public boolean printHelp() {
183                return help;
184        }
185
186        public boolean clearCache() {
187                return clearCache;
188        }
189
190        public boolean clearUserData() {
191                return clearPrefs;
192        }
193
194        public boolean clearHistory() {
195                return clearHistory;
196        }
197
198        public List<File> getFiles(boolean resolveFolders) throws Exception {
199                if (arguments == null || arguments.isEmpty()) {
200                        return emptyList();
201                }
202
203                // resolve given paths
204                List<File> files = new ArrayList<File>();
205
206                for (String it : arguments) {
207                        // ignore empty arguments
208                        if (it.trim().isEmpty()) {
209                                continue;
210                        }
211
212                        // resolve relative paths
213                        File file = new File(it);
214
215                        // since we don't want to follow symlinks, we need to take the scenic route through the Path class
216                        try {
217                                file = file.toPath().toRealPath(LinkOption.NOFOLLOW_LINKS).toFile();
218                        } catch (Exception e) {
219                                debug.warning(format("Illegal Argument: %s (%s)", e, file));
220                        }
221
222                        if (resolveFolders && file.isDirectory()) {
223                                if (recursive) {
224                                        files.addAll(listFiles(file, FILES, HUMAN_NAME_ORDER));
225                                } else {
226                                        files.addAll(getChildren(file, f -> f.isFile() && !f.isHidden(), HUMAN_NAME_ORDER));
227                                }
228                        } else {
229                                files.add(file);
230                        }
231                }
232
233                // input file filter (e.g. useful on Windows where find -exec is not an option)
234                if (inputFileFilter != null) {
235                        return filter(files, new ExpressionFileFilter(inputFileFilter, f -> f));
236                }
237
238                return files;
239        }
240
241        public RenameAction getRenameAction() throws Exception {
242                // support custom executables (via absolute path)
243                if (action.startsWith("/")) {
244                        return new ExecutableRenameAction(action, getOutputPath());
245                }
246
247                // support custom groovy scripts (via closures)
248                if (action.startsWith("{") || action.endsWith("}")) {
249                        return new GroovyRenameAction(action);
250                }
251
252                // support custom groovy scripts (via files)
253                if (action.endsWith(".groovy")) {
254                        return new GroovyRenameAction(readTextFile(new File(action)));
255                }
256
257                return StandardRenameAction.forName(action);
258        }
259
260        public ConflictAction getConflictAction() {
261                return ConflictAction.forName(conflict);
262        }
263
264        public SortOrder getSortOrder() {
265                return SortOrder.forName(order);
266        }
267
268        public ExpressionFormat getExpressionFormat() throws Exception {
269                return format == null ? null : new ExpressionFormat(format);
270        }
271
272        public ExpressionFileFormat getExpressionFileFormat() throws Exception {
273                return format == null ? null : new ExpressionFileFormat(format);
274        }
275
276        public ExpressionFilter getExpressionFilter() throws Exception {
277                return filter == null ? null : new ExpressionFilter(filter);
278        }
279
280        public FileFilter getFileFilter() throws Exception {
281                return filter == null ? FILES : new ExpressionFileFilter(filter, xattr::getMetaInfo);
282        }
283
284        public ExpressionMapper getExpressionMapper() throws Exception {
285                return mapper == null ? null : new ExpressionMapper(mapper);
286        }
287
288        public Datasource getDatasource() {
289                return db == null ? null : WebServices.getService(db);
290        }
291
292        public EpisodeListProvider getEpisodeListProvider() {
293                // default to TheTVDB if --db is not set
294                if (db == null) {
295                        return WebServices.TheTVDB;
296                }
297
298                return optional(db).map(WebServices::getEpisodeListProvider).orElseThrow(error("Illegal database identifier", db));
299        }
300
301        public String getSearchQuery() {
302                return query == null || query.isEmpty() ? null : query;
303        }
304
305        public File getOutputPath() {
306                return output == null ? null : new File(output);
307        }
308
309        public File getAbsoluteOutputFolder() throws Exception {
310                return output == null ? null : new File(output).getCanonicalFile();
311        }
312
313        public SubtitleFormat getSubtitleOutputFormat() {
314                return output == null ? null : getSubtitleFormatByName(output);
315        }
316
317        public SubtitleNaming getSubtitleNamingFormat() {
318                return optional(format).map(SubtitleNaming::forName).orElse(SubtitleNaming.MATCH_VIDEO_ADD_LANGUAGE_TAG);
319        }
320
321        public HashType getOutputHashType() {
322                // support --output checksum.sfv
323                return optional(output).map(File::new).map(f -> getHashType(f)).orElseGet(() -> {
324                        // support --format SFV
325                        return optional(format).map(k -> getHashTypeByExtension(k)).orElse(HashType.SFV);
326                });
327        }
328
329        public Charset getEncoding() {
330                return encoding == null ? null : Charset.forName(encoding);
331        }
332
333        public Language getLanguage() {
334                // find language code for any input (en, eng, English, etc)
335                return optional(lang).map(Language::findLanguage).orElseThrow(error("Illegal language code", lang));
336        }
337
338        public File getLogFile() {
339                File file = new File(logFile);
340
341                if (file.isAbsolute()) {
342                        return file;
343                }
344
345                // by default resolve relative paths against {applicationFolder}/logs/{logFile}
346                return ApplicationFolder.AppData.resolve("logs/" + logFile);
347        }
348
349        public boolean isStrict() {
350                return !nonStrict;
351        }
352
353        public Level getLogLevel() {
354                return Level.parse(log.toUpperCase());
355        }
356
357        public ExecCommand getExecCommand() {
358                try {
359                        return exec == null || exec.isEmpty() ? null : ExecCommand.parse(exec, getOutputPath());
360                } catch (Exception e) {
361                        throw new CmdlineException("Illegal exec expression: " + exec);
362                }
363        }
364
365        public List<PanelBuilder> getPanelBuilders() {
366                if (mode == null) {
367                        // MAS does not allow subtitle applications
368                        if (isMacSandbox()) {
369                                return stream(PanelBuilder.defaultSequence()).filter(p -> !p.getName().equals("Subtitles")).collect(toList());
370                        }
371
372                        // default multi panel mode
373                        return asList(PanelBuilder.defaultSequence());
374                }
375
376                // only selected panels
377                return optional(mode).map(m -> {
378                        Pattern pattern = Pattern.compile(mode, Pattern.CASE_INSENSITIVE);
379                        List<PanelBuilder> panel = stream(PanelBuilder.defaultSequence()).filter(p -> pattern.matcher(p.getName()).matches()).collect(toList());
380
381                        // throw exception if illegal pattern was passed in
382                        if (panel.isEmpty()) {
383                                return null;
384                        }
385
386                        return panel;
387                }).orElseThrow(error("Illegal mode", mode));
388        }
389
390        public String getLicenseKey() {
391                return license;
392        }
393
394        private final String[] args;
395
396        public ArgumentBean() {
397                this.args = new String[0];
398        }
399
400        public ArgumentBean(String[] args) throws CmdLineException {
401                this.args = args.clone();
402
403                CmdLineParser parser = new CmdLineParser(this);
404                parser.parseArgument(args);
405        }
406
407        public String[] getArgumentArray() {
408                return args.clone();
409        }
410
411        public String usage() {
412                StringWriter buffer = new StringWriter();
413                CmdLineParser parser = new CmdLineParser(this, ParserProperties.defaults().withShowDefaults(false).withOptionSorter(null));
414                parser.printUsage(buffer, null);
415                return buffer.toString();
416        }
417
418        private static <T> Optional<T> optional(T value) {
419                return Optional.ofNullable(value);
420        }
421
422        private static Supplier<CmdlineException> error(String message, Object value) {
423                return () -> new CmdlineException(message + ": " + value);
424        }
425
426        @Override
427        public String toString() {
428                return IntStream.range(0, args.length).mapToObj(i -> {
429                        return String.format("args[%d] = %s", i + 1, args[i]);
430                }).collect(joining(System.lineSeparator()));
431        }
432
433        public static ArgumentBean parse(String... args) throws CmdLineException {
434                try {
435                        return new ArgumentBean(args);
436                } catch (CmdLineException e) {
437                        // 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
438                        if (Boolean.parseBoolean(System.getProperty("apple.app.launcher"))) {
439                                return new ArgumentBean();
440                        }
441
442                        // just throw exception as usual when called from command-line and display argument errors
443                        throw e;
444                }
445        }
446
447}