001package net.filebot.cli; 002 003import static java.nio.charset.StandardCharsets.*; 004import static java.util.Arrays.*; 005import static java.util.Collections.*; 006import static java.util.stream.Collectors.*; 007import static net.filebot.Cache.*; 008import static net.filebot.ExitCode.*; 009import static net.filebot.HistorySpooler.*; 010import static net.filebot.Logging.*; 011import static net.filebot.format.ExpressionFormatFunctions.*; 012import static net.filebot.media.XattrMetaInfo.*; 013import static net.filebot.util.FileUtilities.*; 014 015import java.io.BufferedReader; 016import java.io.File; 017import java.io.FileFilter; 018import java.io.IOException; 019import java.io.InputStream; 020import java.io.InputStreamReader; 021import java.io.PrintStream; 022import java.lang.reflect.Field; 023import java.math.BigInteger; 024import java.net.DatagramPacket; 025import java.net.DatagramSocket; 026import java.net.InetAddress; 027import java.net.Socket; 028import java.security.MessageDigest; 029import java.util.Collection; 030import java.util.Date; 031import java.util.LinkedHashMap; 032import java.util.List; 033import java.util.Locale; 034import java.util.Map; 035import java.util.Objects; 036import java.util.logging.Level; 037import java.util.logging.Logger; 038import java.util.regex.Pattern; 039import java.util.stream.Stream; 040 041import javax.script.Bindings; 042import javax.script.ScriptException; 043import javax.script.SimpleBindings; 044 045import org.codehaus.groovy.control.MultipleCompilationErrorsException; 046import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation; 047 048import com.sun.jna.Platform; 049 050import groovy.lang.Closure; 051import groovy.lang.Script; 052 053import net.filebot.ExitCode; 054import net.filebot.Parallelism; 055import net.filebot.RenameAction; 056import net.filebot.StandardPostProcessAction; 057import net.filebot.StandardRenameAction; 058import net.filebot.WebServices; 059import net.filebot.format.ExpressionFormat; 060import net.filebot.format.MediaBindingBean; 061import net.filebot.format.SuppressedThrowables; 062import net.filebot.media.MediaDetection; 063import net.filebot.postprocess.Apply; 064import net.filebot.postprocess.ApplyClosure; 065import net.filebot.similarity.SeasonEpisodeMatcher.SxE; 066import net.filebot.util.Builder; 067import net.filebot.util.TemporaryFolder; 068import net.filebot.web.Movie; 069import net.filebot.web.Series; 070 071public abstract class ScriptShellBaseClass extends Script { 072 073 private ArgumentBean getArgumentBean() { 074 return (ArgumentBean) getBinding().getVariable(ScriptShell.SHELL_ARGS_BINDING_NAME); 075 } 076 077 private Map getDefines() { 078 return (Map) getBinding().getVariable(ScriptShell.SHELL_DEFS_BINDING_NAME); 079 } 080 081 private ScriptShell getShell() { 082 return (ScriptShell) getBinding().getVariable(ScriptShell.SHELL_BINDING_NAME); 083 } 084 085 private CmdlineInterface getCLI() { 086 return (CmdlineInterface) getBinding().getVariable(ScriptShell.SHELL_CLI_BINDING_NAME); 087 } 088 089 public void runScript(String input, String... argv) throws Throwable { 090 try { 091 ArgumentBean args = argv == null || argv.length == 0 ? getArgumentBean() : new ArgumentBean(argv, false); 092 executeScript(input, args, args.defines, args.getFileArguments()); 093 } catch (Exception e) { 094 handleException(e); 095 } 096 } 097 098 public Object include(String input) throws Throwable { 099 try { 100 return executeScript(input, null, null, null); 101 } catch (Exception e) { 102 handleException(e); 103 } 104 return null; 105 } 106 107 public Object executeScript(String input, List args) throws Throwable { 108 return executeScript(input, getArgumentBean(), getDefines(), asFileList(args)); 109 } 110 111 public Object executeScript(String input, Map bindings, List args) throws Throwable { 112 return executeScript(input, getArgumentBean(), bindings, asFileList(args)); 113 } 114 115 private Object executeScript(String input, ArgumentBean options, Map bindings, List<File> args) throws Throwable { 116 // apply parent script defines 117 Bindings parameters = new SimpleBindings(); 118 119 // initialize default parameter 120 if (bindings != null) { 121 parameters.putAll(bindings); 122 } 123 124 parameters.put(ScriptShell.SHELL_ARGS_BINDING_NAME, options != null ? options : new ArgumentBean()); 125 parameters.put(ScriptShell.SHELL_DEFS_BINDING_NAME, bindings != null ? bindings : emptyMap()); 126 parameters.put(ScriptShell.ARGV_BINDING_NAME, args != null ? args : emptyList()); 127 128 // run given script 129 return getShell().runScript(input, parameters); 130 } 131 132 public List<?> parallel(Closure<?>... c) throws Exception { 133 return Parallelism.commonPool().map(asList(c), this::tryLogCatch); 134 } 135 136 public List<?> parallel(Collection<Closure<?>> c) throws Exception { 137 return Parallelism.commonPool().map(c, this::tryLogCatch); 138 } 139 140 public Object tryQuietly(Closure<?> c) { 141 try { 142 return c.call(); 143 } catch (Exception e) { 144 return null; 145 } 146 } 147 148 public Object tryLogCatch(Closure<?> c) { 149 try { 150 return c.call(); 151 } catch (Exception e) { 152 handleException(e); 153 } 154 return null; 155 } 156 157 private void handleException(Throwable t) { 158 // bubble up custom format syntax errors 159 ScriptException gse = findCause(t, ScriptException.class); 160 if (gse != null) { 161 // print compiler error message 162 MultipleCompilationErrorsException mce = findCause(gse, MultipleCompilationErrorsException.class); 163 // print generic error message 164 throw new CmdlineException("Script Error", mce != null ? mce.getMessage() : gse.getMessage(), gse); 165 } 166 167 // log and ignore known error messages 168 log.warning(cause(t)); 169 170 // print full stack trace 171 debug.log(Level.ALL, t, cause(t)); 172 } 173 174 public void die(Object cause) throws Throwable { 175 die(cause, ExitCode.DIE); 176 } 177 178 public void die(Object cause, int exitCode) throws Throwable { 179 throw new ScriptDeath(exitCode, String.valueOf(cause)); 180 } 181 182 // define global variable: _args 183 public ArgumentBean get_args() { 184 return getArgumentBean(); 185 } 186 187 // define global variable: _def 188 public Map get_def() { 189 return unmodifiableMap(getDefines()); 190 } 191 192 // define global variable: _system 193 public Map get_system() { 194 return unmodifiableMap(System.getProperties()); 195 } 196 197 // define global variable: _environment 198 public Map get_environment() { 199 return unmodifiableMap(System.getenv()); 200 } 201 202 // define global variable: _build 203 public Number get_build() { 204 try { 205 MessageDigest md = MessageDigest.getInstance("SHA-256"); 206 try (InputStream in = ScriptShellBaseClass.class.getProtectionDomain().getCodeSource().getLocation().openStream()) { 207 byte[] buffer = new byte[BUFFER_SIZE]; 208 int len = 0; 209 while ((len = in.read(buffer)) >= 0) { 210 md.update(buffer, 0, len); 211 } 212 } 213 return new BigInteger(1, md.digest()); 214 } catch (Exception e) { 215 return null; 216 } 217 } 218 219 // Complete or session rename history 220 public Map<File, File> getRenameLog() throws IOException { 221 return HISTORY.getSessionHistory().getRenameMap(); 222 } 223 224 public Map<File, File> getPersistentRenameLog() throws IOException { 225 return HISTORY.getCompleteHistory().getRenameMap(); 226 } 227 228 public void commit() { 229 // flush history 230 HISTORY.commit(); 231 // flush caches 232 DISK_STORE.flush(); 233 // flush logs 234 flushLog(); 235 } 236 237 // define global variable: log 238 public Logger getLog() { 239 return log; 240 } 241 242 // define global variable: console 243 public Object getConsole() { 244 return System.console() != null ? System.console() : PseudoConsole.getSystemConsole(); 245 } 246 247 public Date getNow() { 248 return new Date(); 249 } 250 251 public TemporaryFolder getTemporaryFolder(String name) { 252 return TemporaryFolder.getFolder(name); 253 } 254 255 public void help(Object object) { 256 log.log(Level.ALL, object::toString); 257 } 258 259 @Override 260 public Object run() { 261 return null; 262 } 263 264 public String getMediaInfo(File file, String format) throws Exception { 265 ExpressionFormat formatter = new ExpressionFormat(format); 266 267 try { 268 return formatter.format(new MediaBindingBean(xattr.getMetaInfo(file), file)); 269 } catch (SuppressedThrowables e) { 270 debug.finest(cause(file, format, e)); 271 } 272 273 return null; 274 } 275 276 public Series detectSeries(Object files) throws Exception { 277 return detectSeries(files, false); 278 } 279 280 public Series detectAnime(Object files) throws Exception { 281 return detectSeries(files, true); 282 } 283 284 public Series detectSeries(Object files, boolean anime) throws Exception { 285 List<File> input = asFileList(files); 286 if (input.isEmpty()) { 287 return null; 288 } 289 290 return MediaDetection.detectSeries(input, anime, Locale.US).stream().findFirst().orElse(null); 291 } 292 293 public SxE parseEpisodeNumber(Object object) { 294 List<SxE> matches = MediaDetection.parseEpisodeNumber(object.toString(), true); 295 return matches == null || matches.isEmpty() ? null : matches.get(0); 296 } 297 298 public Movie detectMovie(File file, boolean strict) { 299 // 1. xattr 300 Object metaObject = xattr.getMetaInfo(file); 301 if (metaObject instanceof Movie) { 302 return (Movie) metaObject; 303 } 304 305 // 2. perfect filename match 306 try { 307 Movie match = MediaDetection.matchMovie(file, 4); 308 if (match != null) { 309 return match; 310 } 311 } catch (Exception e) { 312 trace(e); // ignore and move on 313 } 314 315 // 3. run full-fledged movie detection 316 try { 317 List<Movie> options = MediaDetection.detectMovieWithYear(file, WebServices.TheMovieDB, Locale.US, strict); 318 319 // require exact name / year match in strict mode 320 if (strict) { 321 options = MediaDetection.matchMovieByFileFolderName(file, options); 322 } 323 324 if (options != null && options.size() > 0) { 325 return options.get(0); 326 } 327 } catch (Exception e) { 328 trace(e); // ignore and fail 329 } 330 331 return null; 332 } 333 334 public Movie matchMovie(String name) { 335 List<Movie> matches = MediaDetection.matchMovieName(singleton(name), true, 0); 336 return matches == null || matches.isEmpty() ? null : matches.get(0); 337 } 338 339 public int execute(Object... args) throws Exception { 340 Stream<String> cmd = stream(args).filter(Objects::nonNull).map(Objects::toString); 341 342 if (Platform.isWindows()) { 343 // normalize file separator for windows and run with powershell so any executable in PATH will just work 344 if (args.length == 1) { 345 cmd = Stream.concat(Stream.of("powershell", "-NonInteractive", "-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-Command"), cmd); 346 } else { 347 cmd = Stream.concat(Stream.of("powershell", "-NonInteractive", "-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-Command", "&"), cmd.map(a -> quotePowerShell(this, a))); 348 } 349 } else if (args.length == 1) { 350 // make Unix shell parse arguments 351 cmd = Stream.concat(Stream.of("sh", "-c"), cmd); 352 } 353 354 ProcessBuilder process = new ProcessBuilder(cmd.collect(toList())).inheritIO(); 355 356 // DEBUG 357 debug.finest(format("Execute %s", process.command())); 358 359 return process.start().waitFor(); 360 } 361 362 public File XML(File file, Closure delegate) throws Exception { 363 return writeFile(XML(delegate).getBytes(UTF_8), file); 364 } 365 366 public String XML(Closure<?> delegate) { 367 return Builder.XML.toString(delegate); 368 } 369 370 public void telnet(String host, int port, Closure<?> handler) throws IOException { 371 try (Socket socket = new Socket(host, port)) { 372 handler.call(new PrintStream(socket.getOutputStream(), true, "UTF-8"), new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"))); 373 } 374 } 375 376 public void wol(String mac) throws IOException { 377 int[] hex = Pattern.compile("[:-]").splitAsStream(mac).mapToInt(f -> Integer.parseInt(f, 16)).toArray(); 378 379 byte[] bytes = new byte[6 + 6 * 16]; 380 for (int i = 0; i < 6; i++) { 381 bytes[i] = (byte) 0xFF; 382 } 383 384 for (int i = 0; i < 16; i++) { 385 for (int m = 0; m < hex.length; m++) { 386 bytes[6 + i * hex.length + m] = (byte) hex[m]; 387 } 388 } 389 390 try (DatagramSocket socket = new DatagramSocket()) { 391 socket.send(new DatagramPacket(bytes, bytes.length, InetAddress.getByName("255.255.255.255"), 9)); 392 } 393 } 394 395 /** 396 * Retry given closure until it returns successfully (indefinitely if -1 is passed as retry count) 397 */ 398 public Object retry(int retryCountLimit, int retryWaitTime, Closure<?> c) throws InterruptedException { 399 for (int i = 0; retryCountLimit < 0 || i <= retryCountLimit; i++) { 400 try { 401 return c.call(); 402 } catch (Exception e) { 403 if (i >= 0 && i >= retryCountLimit) { 404 throw e; 405 } 406 Thread.sleep(retryWaitTime); 407 } 408 } 409 return null; 410 } 411 412 public List<File> rename(Map<String, ?> parameters) throws Exception { 413 // consume all parameters 414 List<File> files = getInputFileList(parameters); 415 List<File> list = emptyList(); 416 Map<File, File> map = emptyMap(); 417 418 if (files.isEmpty()) { 419 // list rename mode 420 map = getInputFileMap(parameters); 421 422 // map rename mode 423 if (map.isEmpty()) { 424 list = consumeInputFileList(parameters, "list").findFirst().orElse(emptyList()); 425 } 426 } 427 428 RenameAction action = getRenameAction(parameters); 429 Apply[] apply = getApplyActions(parameters); 430 ArgumentBean args = getArgumentBean(parameters); 431 try { 432 if (files.size() > 0) { 433 return getCLI().rename(files, args.getDatasource(), args.getQueryExpression(), args.getSortOrder(), args.getLanguage().getLocale(), args.getExpressionFilter(), args.getExpressionMapper(), args.isStrict(), args.getExpressionFileFormat(), args.getAbsoluteOutputFolder(), action, args.getConflictAction(), apply, args.getExecCommand()); 434 } 435 if (map.size() > 0) { 436 return getCLI().rename(map, action, args.getConflictAction()); 437 } 438 if (list.size() > 0) { 439 return getCLI().renameLinear(list, args.getDatasource(), args.getQueryExpression(), args.getSortOrder(), args.getLanguage().getLocale(), args.getExpressionFilter(), args.getExpressionMapper(), args.getExpressionFileFormat(), args.getAbsoluteOutputFolder(), action, args.getConflictAction(), apply, args.getExecCommand()); 440 } 441 } catch (Exception e) { 442 handleException(e); 443 } 444 return null; 445 } 446 447 public List<File> getSubtitles(Map<String, ?> parameters) throws Exception { 448 List<File> files = getInputFileList(parameters); 449 ArgumentBean args = getArgumentBean(parameters); 450 try { 451 return getCLI().getSubtitles(files, args.getQueryExpression(), args.getLanguage(), args.getSubtitleOutputFormat(), args.getEncoding(), args.getSubtitleNamingFormat(), args.isStrict()); 452 } catch (Exception e) { 453 handleException(e); 454 } 455 return null; 456 } 457 458 public List<File> getMissingSubtitles(Map<String, ?> parameters) throws Exception { 459 List<File> files = getInputFileList(parameters); 460 ArgumentBean args = getArgumentBean(parameters); 461 try { 462 return getCLI().getMissingSubtitles(files, args.getQueryExpression(), args.getLanguage(), args.getSubtitleOutputFormat(), args.getEncoding(), args.getSubtitleNamingFormat(), args.isStrict()); 463 } catch (Exception e) { 464 handleException(e); 465 } 466 return null; 467 } 468 469 public boolean check(Map<String, ?> parameters) throws Exception { 470 List<File> files = getInputFileList(parameters); 471 try { 472 getCLI().check(files); 473 return true; 474 } catch (Exception e) { 475 handleException(e); 476 } 477 return false; 478 } 479 480 public File compute(Map<String, ?> parameters) throws Exception { 481 List<File> files = getInputFileList(parameters); 482 ArgumentBean args = getArgumentBean(parameters); 483 try { 484 return getCLI().compute(files, args.getOutputHashType(), args.getOutputPath(), args.getEncoding()); 485 } catch (Exception e) { 486 handleException(e); 487 } 488 return null; 489 } 490 491 public List<File> extract(Map<String, ?> parameters) throws Exception { 492 List<File> files = getInputFileList(parameters); 493 FileFilter filter = getFileFilter(parameters); 494 ArgumentBean args = getArgumentBean(parameters); 495 try { 496 return getCLI().extract(files, args.getOutputPath(), filter, args.isStrict()); 497 } catch (Exception e) { 498 handleException(e); 499 } 500 return null; 501 } 502 503 public File zip(Map<String, ?> parameters) throws Exception { 504 List<File> files = getInputFileList(parameters); 505 ArgumentBean args = getArgumentBean(parameters); 506 try { 507 return getCLI().zip(files, args.getOutputPath(), args.getExpressionFileFilter()); 508 } catch (Exception e) { 509 handleException(e); 510 } 511 return null; 512 } 513 514 public List<String> list(Map<String, ?> parameters) throws Exception { 515 ArgumentBean args = getArgumentBean(parameters); 516 try { 517 return getCLI().list(args.getDatasource(), args.getQueryExpression(), args.getSortOrder(), args.getLanguage().getLocale(), args.getExpressionFilter(), args.getExpressionMapper(), args.getExpressionFormat(), args.isStrict()).collect(toList()); 518 } catch (Exception e) { 519 handleException(e); 520 } 521 return null; 522 } 523 524 public Object getMediaInfo(Map<String, ?> parameters) throws Exception { 525 List<File> files = getInputFileList(parameters); 526 ArgumentBean args = getArgumentBean(parameters); 527 Apply[] apply = getApplyActions(parameters); 528 ExecCommand exec = args.getExecCommand(); 529 try { 530 // execute command for each file 531 if (apply != null || exec != null) { 532 // return status code 533 return getCLI().execute(files, args.getExpressionFileFilter(), args.getExpressionFormat(), apply, exec).allMatch(r -> r == SUCCESS) ? SUCCESS : ERROR; 534 } 535 // return lines 536 return getCLI().getMediaInfo(files, args.getExpressionFileFilter(), args.getExpressionFormat()).peek(stdout).collect(toList()); 537 } catch (Exception e) { 538 handleException(e); 539 } 540 return null; 541 } 542 543 private ArgumentBean getArgumentBean(Map<String, ?> parameters) throws Exception { 544 // clone default arguments 545 ArgumentBean args = new ArgumentBean(getArgumentBean().getArgumentArray(), false); 546 547 // for compatibility reasons [forceExtractAll: true] and [strict: true] is the 548 // same as -non-strict 549 Stream.of("forceExtractAll", "strict").map(parameters::remove).filter(Objects::nonNull).forEach(v -> { 550 args.nonStrict = !DefaultTypeTransformation.castToBoolean(v); 551 }); 552 553 // override default values with given values 554 parameters.forEach((k, v) -> { 555 try { 556 Field field = args.getClass().getField(k); 557 Object value = DefaultTypeTransformation.castToType(v, field.getType()); 558 field.set(args, value); 559 } catch (Exception e) { 560 throw new IllegalArgumentException("Illegal parameter: " + k, e); 561 } 562 }); 563 564 return args; 565 } 566 567 private List<File> getInputFileList(Map<String, ?> parameters) { 568 // check file parameter add consume File values as they are 569 return consumeInputFileList(parameters, "file").findFirst().orElseGet(() -> { 570 // check folder parameter and resolve children 571 return consumeInputFileList(parameters, "folder").flatMap(d -> listFiles(d, 0, FILES, HUMAN_NAME_ORDER).stream()).collect(toList()); 572 }); 573 } 574 575 private Map<File, File> getInputFileMap(Map<String, ?> parameters) { 576 // convert keys and values to files 577 Map<File, File> map = new LinkedHashMap<File, File>(); 578 579 consumeParameter(parameters, "map").map(Map.class::cast).forEach(m -> { 580 m.forEach((k, v) -> { 581 File from = new File(k.toString()); 582 File to = new File(v.toString()); 583 map.put(from, to); 584 }); 585 }); 586 587 return map; 588 } 589 590 private RenameAction getRenameAction(Map<String, ?> parameters) throws Exception { 591 return consumeParameter(parameters, "action").map(action -> { 592 return getRenameAction(action); 593 }).findFirst().orElse(getArgumentBean().getRenameAction()); // default to global rename action 594 } 595 596 private FileFilter getFileFilter(Map<String, ?> parameters) { 597 return consumeParameter(parameters, "filter").map(filter -> { 598 return (FileFilter) DefaultTypeTransformation.castToType(filter, FileFilter.class); 599 }).findFirst().orElse(null); 600 } 601 602 private Apply[] getApplyActions(Map<String, ?> parameters) throws Exception { 603 return consumeParameter(parameters, "apply").map(action -> { 604 if (action instanceof Collection) { 605 return ((Collection<?>) action).stream().map(this::getApplyAction).toArray(Apply[]::new); 606 } else { 607 return new Apply[] { getApplyAction(action) }; 608 } 609 }).findFirst().orElse(getArgumentBean().getPostProcessActions()); // default to global apply actions 610 } 611 612 private Stream<List<File>> consumeInputFileList(Map<String, ?> parameters, String... names) { 613 // check file parameter add consume File values as they are 614 return consumeParameter(parameters, names).map(f -> asFileList(f)); 615 } 616 617 private Stream<?> consumeParameter(Map<String, ?> parameters, String... names) { 618 return Stream.of(names).map(parameters::remove).filter(Objects::nonNull); 619 } 620 621 public RenameAction getRenameAction(Object obj) { 622 if (obj instanceof RenameAction) { 623 return (RenameAction) obj; 624 } 625 626 if (obj instanceof CharSequence) { 627 return StandardRenameAction.forName(obj.toString()); 628 } 629 630 if (obj instanceof File) { 631 return ExecutableRenameAction.executable((File) obj, getArgumentBean().getOutputPath()); 632 } 633 634 if (obj instanceof Closure) { 635 return GroovyAction.wrap((Closure) obj); 636 } 637 638 // object probably can't be casted 639 return (RenameAction) DefaultTypeTransformation.castToType(obj, RenameAction.class); 640 } 641 642 public Apply getApplyAction(Object obj) { 643 if (obj instanceof Apply) { 644 return (Apply) obj; 645 } 646 647 if (obj instanceof CharSequence) { 648 return StandardPostProcessAction.forName(obj.toString()); 649 } 650 651 if (obj instanceof Closure) { 652 return new ApplyClosure((Closure) obj); 653 } 654 655 // object probably can't be casted 656 return (Apply) DefaultTypeTransformation.castToType(obj, Apply.class); 657 } 658 659 public <T> T showInputDialog(Collection<T> options, String title, String message) throws Exception { 660 if (options.isEmpty()) { 661 return null; 662 } 663 664 // use Text UI in interactive mode 665 if (getCLI() instanceof CmdlineOperationsTextUI) { 666 CmdlineOperationsTextUI cli = (CmdlineOperationsTextUI) getCLI(); 667 return cli.showInputDialog(options, title, message); 668 } 669 670 // just pick the first option 671 return options.iterator().next(); 672 } 673 674}