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}