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}