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}