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}