001package net.filebot.postprocess;
002
003import static java.nio.charset.StandardCharsets.*;
004import static java.util.Collections.*;
005import static java.util.stream.Collectors.*;
006import static net.filebot.Logging.*;
007import static net.filebot.MediaTypes.*;
008import static net.filebot.format.ExpressionFormatFunctions.*;
009import static net.filebot.util.FileUtilities.*;
010import static net.filebot.util.RegularExpressions.*;
011
012import java.io.File;
013import java.net.URI;
014import java.net.URL;
015import java.nio.ByteBuffer;
016import java.nio.file.Files;
017import java.nio.file.StandardOpenOption;
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.Iterator;
021import java.util.LinkedHashMap;
022import java.util.List;
023import java.util.Locale;
024import java.util.Map;
025import java.util.Objects;
026import java.util.Optional;
027import java.util.function.Function;
028import java.util.function.Predicate;
029import java.util.regex.Pattern;
030
031import org.codehaus.groovy.runtime.DefaultGroovyMethods;
032import org.codehaus.groovy.runtime.InvokerHelper;
033import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation;
034
035import groovy.json.JsonOutput;
036import groovy.json.JsonSlurper;
037import groovy.lang.Closure;
038import groovy.lang.GroovyObjectSupport;
039import groovy.lang.Script;
040import groovy.xml.XmlSlurper;
041
042import net.filebot.Execute;
043import net.filebot.Language;
044import net.filebot.StandardRenameAction;
045import net.filebot.UserFiles;
046import net.filebot.UserInteraction;
047import net.filebot.WebServices;
048import net.filebot.subtitle.SubtitleFormat;
049import net.filebot.subtitle.SubtitleUtilities;
050import net.filebot.util.Builder;
051import net.filebot.util.ByteBufferInputStream;
052import net.filebot.vfs.MemoryFile;
053import net.filebot.web.SubtitleDescriptor;
054import net.filebot.web.SubtitleLookupService;
055import net.filebot.web.SubtitleProvider;
056import net.filebot.web.WebRequest;
057
058public abstract class ScriptBaseClass extends Script {
059
060        public void alert(Object... messages) throws Exception {
061                String message = message(messages).toString();
062
063                println("ALERT " + message);
064                log.warning(message);
065        }
066
067        public void system(String executable, Object... args) throws Exception {
068                system(EMPTY_MAP, executable, args);
069        }
070
071        public void system(Map env, String executable, Object... args) throws Exception {
072                Collection<Object> values = DefaultGroovyMethods.flatten(args);
073                List<String> arguments = values.stream().filter(Objects::nonNull).map(Objects::toString).collect(toList());
074
075                println("EXECUTE " + executable + " " + arguments);
076                Execute.system(executable, arguments, null, toStringMap(env));
077        }
078
079        public void trash(Object... args) throws Exception {
080                for (File f : asFileList(args)) {
081                        println("TRASH " + f);
082                        UserFiles.trash(f);
083                }
084        }
085
086        public void move(Object... args) throws Exception {
087                Iterator<File> iterator = asFileList(args).iterator();
088                while (iterator.hasNext()) {
089                        File from = iterator.next();
090                        File to = iterator.next();
091
092                        if (StandardRenameAction.MOVE.canRename(from, to)) {
093                                if (from.exists() && to.exists()) {
094                                        trash(to);
095                                }
096                                println("MOVE " + from + " to " + to);
097                                StandardRenameAction.MOVE.rename(from, to);
098                        }
099                }
100        }
101
102        public void reveal(Object... args) throws Exception {
103                for (File f : asFileList(args)) {
104                        println("REVEAL " + f);
105                        UserInteraction.reveal(f);
106                }
107        }
108
109        public File curl(Object url, File file) throws Exception {
110                if (file.exists()) {
111                        println("SKIP " + file);
112                        return null;
113                }
114
115                println("GET " + url + " (" + file + ")");
116                ByteBuffer bytes = WebRequest.fetch(url(url));
117
118                createFolders(file.getParentFile());
119                return writeFile(bytes, file);
120        }
121
122        public Response curl(Object url) throws Exception {
123                return request(url, EMPTY_MAP, null);
124        }
125
126        public Response curl(Map header, Object url) throws Exception {
127                return request(url, header, null);
128        }
129
130        public Response curl(Object url, Map json) throws Exception {
131                return request(url, EMPTY_MAP, Post.of(WebRequest.HTTP_POST, json, EMPTY_MAP));
132        }
133
134        public Response curl(Object url, String text) throws Exception {
135                return request(url, EMPTY_MAP, Post.of(WebRequest.HTTP_POST, text, EMPTY_MAP));
136        }
137
138        public Response submit(Object url, Map form) throws Exception {
139                return request(url, EMPTY_MAP, Post.of(WebRequest.HTTP_POST, form, singletonMap("Content-Type", "application/x-www-form-urlencoded")));
140        }
141
142        public Response curl(Map header, Object url, Object data) throws Exception {
143                return request(url, header, Post.of(WebRequest.HTTP_POST, data, header));
144        }
145
146        public Response http(String method, Object url, Object data) throws Exception {
147                return request(url, EMPTY_MAP, Post.of(method, data, EMPTY_MAP));
148        }
149
150        public Response http(Map header, String method, Object url, Object data) throws Exception {
151                return request(url, header, Post.of(method, data, header));
152        }
153
154        private Response request(Object url, Map header, Post data) throws Exception {
155                URL resource = url(url);
156
157                Map<String, String> responseHeader = new LinkedHashMap<String, String>();
158
159                // send HTTP GET request
160                if (data == null) {
161                        println("GET " + resource);
162
163                        ByteBuffer response = WebRequest.fetch(resource, 0, null, toStringMap(header), responseHeader::put);
164                        return new Response(response, responseHeader);
165                }
166
167                // send HTTP POST request
168                println(data.getRequestMethod() + " " + resource + " " + data.toString());
169
170                ByteBuffer response = WebRequest.post(data.getRequestMethod(), resource, data.toByteArray(), data.getContentType(), toStringMap(header), responseHeader::put);
171                return new Response(response, responseHeader);
172        }
173
174        public static class Response extends GroovyObjectSupport implements Iterable<Object> {
175
176                private final ByteBuffer content;
177                private final Map<String, String> header;
178
179                private Object object;
180
181                public Response(ByteBuffer content, Map<String, String> header) {
182                        this.content = content;
183                        this.header = header;
184                }
185
186                public ByteBuffer content() {
187                        return content.duplicate();
188                }
189
190                public Map<String, String> header() {
191                        return header;
192                }
193
194                public String header(String name) {
195                        // make response headers case-insensitive
196                        return header().getOrDefault(name.toLowerCase(Locale.ROOT), "");
197                }
198
199                public synchronized Object object() {
200                        if (object == null) {
201                                object = ContentType.forContentType(header("content-type")).parse(content());
202                        }
203                        return object;
204                }
205
206                @Override
207                public Object invokeMethod(String name, Object args) {
208                        return InvokerHelper.invokeMethod(object(), name, args);
209                }
210
211                @Override
212                public Object getProperty(String property) {
213                        return InvokerHelper.getProperty(object(), property);
214                }
215
216                @Override
217                public Iterator<Object> iterator() {
218                        return DefaultGroovyMethods.iterator(object());
219                }
220
221                public boolean asBoolean() {
222                        return DefaultTypeTransformation.castToBoolean(object());
223                }
224
225                @Override
226                public String toString() {
227                        return UTF_8.decode(content()).toString();
228                }
229        }
230
231        private static class Post {
232
233                private final String requestMethod;
234                private final Object content;
235                private final String contentType;
236
237                private Post(String requestMethod, Object content, String contentType) {
238                        this.requestMethod = requestMethod;
239                        this.content = content;
240                        this.contentType = contentType;
241                }
242
243                public String getRequestMethod() {
244                        return requestMethod;
245                }
246
247                public String getContentType() {
248                        return contentType;
249                }
250
251                public byte[] toByteArray() {
252                        // application/octet-stream
253                        if (content instanceof byte[]) {
254                                return (byte[]) content;
255                        }
256
257                        // application/json or application/x-www-form-urlencoded or text/plain
258                        return content.toString().getBytes(UTF_8);
259                }
260
261                @Override
262                public String toString() {
263                        // application/octet-stream
264                        if (content instanceof byte[]) {
265                                byte[] bytes = (byte[]) content;
266                                return "[" + bytes.length + " bytes]";
267                        }
268
269                        // application/json or application/x-www-form-urlencoded or text/plain
270                        return content.toString();
271                }
272
273                public static Post of(String method, Object data, Map header) {
274                        if (data instanceof Map && "application/x-www-form-urlencoded".equals(header.get("Content-Type"))) {
275                                return new Post(method, WebRequest.encodeParameters((Map) data), "application/x-www-form-urlencoded");
276                        }
277                        if (data instanceof CharSequence) {
278                                return new Post(method, data.toString(), "text/plain");
279                        }
280                        if (data instanceof byte[]) {
281                                return new Post(method, (byte[]) data, "application/octet-stream");
282                        }
283                        if (data == null) {
284                                return new Post(method, new byte[0], "application/octet-stream");
285                        }
286                        // post as application/json by default
287                        return new Post(method, JsonOutput.toJson(data), "application/json");
288                }
289
290        }
291
292        public static enum ContentType {
293
294                JSON, XML, TEXT, BYTES;
295
296                public Object parse(ByteBuffer bytes) {
297                        try {
298                                switch (this) {
299                                        case JSON:
300                                                return new JsonSlurper().parse(new ByteBufferInputStream(bytes));
301                                        case XML:
302                                                return new XmlSlurper().parse(new ByteBufferInputStream(bytes));
303                                        case TEXT:
304                                                return UTF_8.decode(bytes).toString();
305                                        default:
306                                                return bytes;
307                                }
308                        } catch (Exception e) {
309                                throw new IllegalStateException("Invalid " + this, e);
310                        }
311                }
312
313                public static ContentType forContentType(String contentType) {
314                        if (contentType.contains("json")) {
315                                return JSON;
316                        }
317                        if (contentType.contains("xml")) {
318                                return XML;
319                        }
320                        if (contentType.contains("text")) {
321                                return TEXT;
322                        }
323                        return BYTES;
324                }
325
326        }
327
328        public List<File> getSubtitles(File file, String... languages) throws Exception {
329                return getSubtitles(EMPTY_MAP, file, languages);
330        }
331
332        public List<File> getSubtitles(Map options, File file, String... languages) throws Exception {
333                List<File> subtitleFiles = new ArrayList<File>(languages.length);
334
335                // strict by default
336                boolean strict = optional(options, "strict", ScriptBaseClass::flag, true);
337
338                // accept any subtitle descriptor by default
339                Predicate<SubtitleDescriptor> filter = optional(options, "filter", ScriptBaseClass::selector, selectAll());
340
341                // SubRip is the only supported transcode target so the subtitle format is not configurable
342                SubtitleFormat format = SubtitleFormat.SubRip;
343
344                // ignore non-video files
345                if (VIDEO_FILES.accept(file) && file.length() > ONE_MEGABYTE) {
346                        // ignore video files that already have subtitles
347                        for (String language : languages) {
348                                File destination = new File(file.getParentFile(), SubtitleUtilities.formatSubtitle(getName(file), language, format.getFilter().extension()));
349                                if (destination.exists()) {
350                                        continue;
351                                }
352
353                                println("FIND " + destination.getName());
354                                SubtitleDescriptor descriptor = findSubtitles(file, language, strict, filter);
355                                if (descriptor == null) {
356                                        continue;
357                                }
358
359                                println("FETCH " + descriptor);
360                                MemoryFile subtitles = SubtitleUtilities.fetchSubtitle(descriptor);
361                                ByteBuffer bytes = SubtitleUtilities.exportSubtitles(subtitles, format, UTF_8);
362                                subtitleFiles.add(writeFile(bytes, destination));
363                        }
364                }
365
366                return subtitleFiles;
367        }
368
369        public SubtitleDescriptor findSubtitles(File file, String language, boolean strict, Predicate<SubtitleDescriptor> selector) throws Exception {
370                // resolve language code or language name
371                Locale locale = Language.forName(language).getLocale();
372
373                // lookup subtitles by hash
374                for (SubtitleLookupService service : WebServices.getSubtitleLookupServices(locale)) {
375                        SubtitleDescriptor descriptor = SubtitleUtilities.lookupSubtitlesByHash(service, singleton(file), locale, true, strict).values().stream().flatMap(List::stream).filter(selector).findFirst().orElse(null);
376                        if (descriptor != null) {
377                                return descriptor;
378                        }
379                }
380
381                // only lookup subtitles by hash in strict mode
382                if (strict) {
383                        return null;
384                }
385
386                // lookup subtitles by name
387                for (SubtitleProvider service : WebServices.getSubtitleProviders(locale)) {
388                        SubtitleDescriptor descriptor = SubtitleUtilities.findSubtitlesByName(service, singleton(file), locale, null, true, strict).values().stream().flatMap(List::stream).filter(selector).findFirst().orElse(null);
389                        if (descriptor != null) {
390                                return descriptor;
391                        }
392                }
393
394                return null;
395        }
396
397        private static <T> Predicate<T> selectAll() {
398                return object -> true;
399        }
400
401        private static <T> Predicate<T> selector(Object value) {
402                if (value instanceof Closure) {
403                        Closure closure = (Closure) value;
404                        return object -> DefaultTypeTransformation.castToBoolean(closure.call(object));
405                }
406                if (value instanceof Pattern) {
407                        Pattern pattern = (Pattern) value;
408                        return object -> pattern.matcher(object.toString()).find();
409                }
410                return null;
411        }
412
413        private static boolean flag(Object value) {
414                if (value instanceof Boolean) {
415                        return (Boolean) value;
416                }
417                if (value instanceof String) {
418                        return Boolean.parseBoolean((String) value);
419                }
420                return DefaultTypeTransformation.castToBoolean(value);
421        }
422
423        private static URL url(Object value) throws Exception {
424                if (value instanceof URL) {
425                        return (URL) value;
426                }
427                if (value instanceof URI) {
428                        return ((URI) value).toURL();
429                }
430                return WebRequest.newURL(value.toString());
431        }
432
433        private static <T> T optional(Map options, String name, Function<Object, T> value, T defaultValue) {
434                return Optional.ofNullable(options.get(name)).map(value).orElse(defaultValue);
435        };
436
437        public void NEWLINE(File file, Object c1, Object... cN) throws Exception {
438                if (file.length() > MemoryFile.LARGE_FILE_SIZE) {
439                        alert(format("NEWLINE file exists already: %s (%s)", file, formatSize(file.length())));
440                        return;
441                }
442
443                // check existing lines
444                List<String> current = file.exists() ? Files.readAllLines(file.toPath(), UTF_8) : emptyList();
445
446                // generate new lines
447                List<String> lines = list(this, c1, cN).stream().filter(v -> !isEmptyValue(this, v)).map(Object::toString).flatMap(NEWLINE::splitAsStream).map(String::trim).distinct().filter(line -> {
448                        // skip lines that already exist
449                        return !line.isEmpty() && !current.contains(line);
450                }).peek(line -> {
451                        // add feedback
452                        println(format("NEWLINE %s (%s)", line, file));
453                }).collect(toList());
454
455                // add the missing lines
456                if (lines.size() > 0) {
457                        Files.write(file.toPath(), lines, UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
458                }
459        }
460
461        public File XML(File file, Closure delegate) throws Exception {
462                return write(file, Builder.XML, delegate);
463        }
464
465        public String XML(Closure delegate) {
466                return Builder.XML.toString(delegate);
467        }
468
469        public File INI(File file, Closure delegate) throws Exception {
470                return write(file, Builder.INI, delegate);
471        }
472
473        public String INI(Closure delegate) throws Exception {
474                return Builder.INI.toString(delegate);
475        }
476
477        public File JSON(File file, Closure delegate) throws Exception {
478                return write(file, Builder.JSON, delegate);
479        }
480
481        public String JSON(Closure delegate) {
482                return Builder.JSON.toString(delegate);
483        }
484
485        private File write(File file, Builder builder, Closure delegate) throws Exception {
486                if (file.length() > MemoryFile.LARGE_FILE_SIZE) {
487                        alert(format("%s file exists already: %s (%s)", builder, file, formatSize(file.length())));
488                        return null;
489                }
490
491                byte[] bytes = builder.toString(delegate).getBytes(UTF_8);
492                println(format("%s %s (%s)", builder, file, formatSize(bytes.length)));
493
494                createFolders(file.getParentFile());
495                return writeFile(bytes, file);
496        }
497
498        private static Map<String, String> toStringMap(Map map) {
499                if (map.isEmpty()) {
500                        return EMPTY_MAP;
501                }
502
503                Map<String, String> properties = new LinkedHashMap<String, String>(map.size());
504                map.forEach((k, v) -> {
505                        if (k != null && v != null) {
506                                properties.put(k.toString(), v.toString());
507                        }
508                });
509                return properties;
510        }
511
512}