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