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