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}