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