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