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}