001package net.filebot.format; 002 003import static java.util.Arrays.*; 004import static java.util.Collections.*; 005import static java.util.stream.Collectors.*; 006import static net.filebot.HistorySpooler.*; 007import static net.filebot.Logging.*; 008import static net.filebot.MediaTypes.*; 009import static net.filebot.WebServices.*; 010import static net.filebot.format.Define.*; 011import static net.filebot.format.ExpressionFormatMethods.*; 012import static net.filebot.hash.VerificationUtilities.*; 013import static net.filebot.media.CachedMediaCharacteristics.*; 014import static net.filebot.media.MediaDetection.*; 015import static net.filebot.media.MediaFileUtilities.*; 016import static net.filebot.media.XattrMetaInfo.*; 017import static net.filebot.similarity.Normalization.*; 018import static net.filebot.subtitle.SubtitleUtilities.*; 019import static net.filebot.util.FileUtilities.*; 020import static net.filebot.util.RegularExpressions.*; 021import static net.filebot.util.StringUtilities.*; 022import static net.filebot.web.EpisodeUtilities.*; 023 024import java.io.File; 025import java.io.IOException; 026import java.math.BigDecimal; 027import java.time.Duration; 028import java.time.Instant; 029import java.time.ZoneOffset; 030import java.time.temporal.ChronoUnit; 031import java.util.ArrayList; 032import java.util.Iterator; 033import java.util.List; 034import java.util.Locale; 035import java.util.Map; 036import java.util.Objects; 037import java.util.Optional; 038import java.util.function.Function; 039import java.util.regex.Pattern; 040import java.util.stream.Stream; 041 042import net.filebot.ApplicationFolder; 043import net.filebot.Language; 044import net.filebot.MemoryCache; 045import net.filebot.Resource; 046import net.filebot.Settings; 047import net.filebot.hash.HashType; 048import net.filebot.media.ImageMetadata; 049import net.filebot.media.LocalDatasource.PhotoFile; 050import net.filebot.media.MediaCharacteristics; 051import net.filebot.media.MetaAttributes; 052import net.filebot.media.NamingStandard; 053import net.filebot.media.VideoFormat; 054import net.filebot.media.XattrChecksum; 055import net.filebot.mediainfo.MediaInfo; 056import net.filebot.mediainfo.MediaInfo.StreamKind; 057import net.filebot.mediainfo.MediaInfoException; 058import net.filebot.similarity.Normalization; 059import net.filebot.util.FileKey; 060import net.filebot.util.FileUtilities; 061import net.filebot.vfs.VFS; 062import net.filebot.web.AnimeLists; 063import net.filebot.web.AudioTrack; 064import net.filebot.web.Episode; 065import net.filebot.web.EpisodeFormat; 066import net.filebot.web.Link; 067import net.filebot.web.Movie; 068import net.filebot.web.MovieInfo; 069import net.filebot.web.MoviePart; 070import net.filebot.web.SeriesDetails; 071import net.filebot.web.SeriesInfo; 072import net.filebot.web.SimpleDate; 073import net.filebot.web.SortOrder; 074import net.filebot.web.XDB; 075import net.filebot.web.XEM; 076 077public class MediaBindingBean { 078 079 private final Object infoObject; 080 private final File mediaFile; 081 private final Map<File, ?> context; 082 083 public MediaBindingBean(Object infoObject, File mediaFile) { 084 this(infoObject, mediaFile, emptyMap()); 085 } 086 087 public MediaBindingBean(Object infoObject, File mediaFile, Map<File, ?> context) { 088 this.infoObject = infoObject; 089 this.mediaFile = mediaFile; 090 this.context = context; 091 } 092 093 @Define("object") 094 public Object getInfoObject() { 095 return infoObject; 096 } 097 098 @Define("file") 099 public File getFileObject() { 100 return mediaFile; 101 } 102 103 @Define(undefined) 104 public <T> T undefined(String name) { 105 // omit expressions that depend on undefined values 106 throw new BindingException(name, "undefined", BindingException.Flag.UNDEFINED); 107 } 108 109 @Define("n") 110 public String getName() { 111 if (infoObject instanceof Episode) 112 return getEpisode().getSeriesName(); 113 if (infoObject instanceof Movie) 114 return getMovie().getName(); 115 if (infoObject instanceof AudioTrack) 116 return getAlbumArtist() != null ? getAlbumArtist() : getArtist(); 117 if (infoObject instanceof File) 118 return FileUtilities.getName((File) infoObject); 119 120 return null; 121 } 122 123 @Define("y") 124 public Integer getYear() throws Exception { 125 if (infoObject instanceof Episode) 126 return getStartDate().getYear(); 127 if (infoObject instanceof Movie) 128 return getMovie().getYear(); 129 if (infoObject instanceof AudioTrack) 130 return getReleaseDate().getYear(); 131 if (infoObject instanceof File) 132 return getReleaseDate().getYear(); 133 134 return null; 135 } 136 137 @Define("ny") 138 public String getNameWithYear() { 139 String n = getName(); 140 141 // check if {n} already includes the year 142 if (NamingStandard.isNameYear(n)) { 143 return n; 144 } 145 146 // account for TV Shows that contain the year in the series name, e.g. Doctor Who (2005) 147 try { 148 return String.format("%s (%s)", n, getYear()); 149 } catch (Exception e) { 150 debug.finest(cause(e)); 151 } 152 153 // default to {n} if {y} is undefined 154 return n; 155 } 156 157 @Define("s") 158 public Integer getSeasonNumber() { 159 // look up season numbers via TheTVDB for AniDB episode data 160 return mapSeasonEpisodeNumbers(getEpisode()).getSeason(); 161 } 162 163 @Define("sn") 164 public String getSeasonName() { 165 return getEpisode().getGroup(); 166 } 167 168 @Define("e") 169 public Integer getEpisodeNumber() { 170 return getEpisode().getEpisode(); 171 } 172 173 @Define("es") 174 public List<Integer> getEpisodeNumbers() { 175 return getEpisodes().stream().map(it -> { 176 return it.getEpisode() == null ? it.getSpecial() == null ? null : it.getSpecial() : it.getEpisode(); 177 }).filter(Objects::nonNull).collect(toList()); 178 } 179 180 @Define("s00") 181 public String getS00() { 182 return EpisodeFormat.SeasonEpisode.formatMultiRangeNumbers(getEpisodes(), "%02d", ""); 183 } 184 185 @Define("e00") 186 public String getE00() { 187 return EpisodeFormat.SeasonEpisode.formatMultiRangeNumbers(getEpisodes(), "", "%02d"); 188 } 189 190 @Define("sxe") 191 public String getSxE() { 192 return EpisodeFormat.SeasonEpisode.formatSxE(mapSeasonEpisodeNumbers(getEpisode())); // magically convert AniDB absolute numbers to TheTVDB SxE numbers 193 194 } 195 196 @Define("s00e00") 197 public String getS00E00() { 198 return EpisodeFormat.SeasonEpisode.formatS00E00(mapSeasonEpisodeNumbers(getEpisode())); // magically convert AniDB absolute numbers to TheTVDB SxE numbers 199 } 200 201 @Define("t") 202 public String getTitle() { 203 String t = null; 204 205 if (infoObject instanceof Episode) { 206 t = getEpisode().getTitle(); 207 } else if (infoObject instanceof Movie) { 208 t = getMovieDetails().getTagline(); 209 } else if (infoObject instanceof AudioTrack) { 210 t = getMusic().getTrackTitle() != null ? getMusic().getTrackTitle() : getMusic().getTitle(); 211 } 212 213 // enforce title length limit by default 214 return truncateText(t, NamingStandard.TITLE_MAX_LENGTH); 215 } 216 217 @Define("d") 218 public SimpleDate getReleaseDate() throws Exception { 219 if (infoObject instanceof Episode) 220 return getEpisode().getAirdate(); 221 if (infoObject instanceof Movie) 222 return getMovieDetails().getReleased(); 223 if (infoObject instanceof AudioTrack) 224 return getMusic().getAlbumReleaseDate(); 225 if (infoObject instanceof File) 226 return SimpleDate.from(getMediaCreationTime()); 227 228 return null; 229 } 230 231 @Define("dt") 232 public Instant getMediaCreationTime() throws Exception { 233 // use Encoded_Date for generic video files 234 if (VIDEO_FILES.accept(getMediaFile())) { 235 return getVideoCharacteristics().map(MediaCharacteristics::getCreationTime).orElse(null); 236 } 237 238 // use EXIF Date-Taken for image files or File Last-Modified for generic files 239 if (IMAGE_FILES.accept(getMediaFile())) { 240 return getPhoto().getDateTaken().orElse(null); 241 } 242 243 // use file creation date for generic files 244 return getCreationDate(getMediaFile()); 245 } 246 247 @Define("airdate") 248 public SimpleDate getAirdate() { 249 return getEpisode().getAirdate(); 250 } 251 252 @Define("age") 253 public Long getAgeInDays() throws Exception { 254 SimpleDate releaseDate = getReleaseDate(); 255 256 if (releaseDate != null) { 257 // avoid time zone issues by interpreting all dates and times as UTC 258 long days = ChronoUnit.DAYS.between(releaseDate.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant(), Instant.now()); 259 260 if (days >= 0) { 261 return days; 262 } 263 } 264 265 return null; 266 } 267 268 @Define("startdate") 269 public SimpleDate getStartDate() throws Exception { 270 SimpleDate startdate = getEpisode().getSeriesInfo().getStartDate(); 271 272 // use series metadata startdate if possible 273 if (startdate != null) { 274 return startdate; 275 } 276 277 // access episode list and find minimum airdate if necessary 278 return getEpisodeList().stream().filter(Episode::isRegular).map(Episode::getAirdate).filter(Objects::nonNull).min(SimpleDate::compareTo).get(); 279 } 280 281 @Define("absolute") 282 public Integer getAbsoluteEpisodeNumber() { 283 return getEpisode().getAbsolute(); 284 } 285 286 @Define("special") 287 public Integer getSpecialNumber() { 288 return getEpisode().getSpecial(); 289 } 290 291 @Define("series") 292 public SeriesInfo getSeriesInfo() { 293 return getEpisode().getSeriesInfo(); 294 } 295 296 @Define("alias") 297 public List<String> getAliasNames() { 298 if (infoObject instanceof Movie) 299 return asList(getMovie().getAliasNames()); 300 if (infoObject instanceof Episode) 301 return getSeriesInfo().getAliasNames(); 302 303 return null; 304 } 305 306 @Define("primaryTitle") 307 public String getPrimaryTitle() { 308 if (infoObject instanceof Movie) { 309 return getMovieDetails().getOriginalName(); 310 } 311 312 if (infoObject instanceof Episode) { 313 String romajiTitle = mapRomajiPrimaryTitle(getEpisode()); 314 // e.g. Boku no Hero Academia (2021) -> Boku no Hero Academia 315 if (romajiTitle != null) { 316 return replaceTrailingBrackets(romajiTitle); 317 } 318 319 SeriesDetails details = getSeriesDetails(); 320 // prefer original name field if available 321 if (details.getOriginalName() != null) { 322 return details.getOriginalName(); 323 } 324 return details.getName(); 325 } 326 327 return null; 328 } 329 330 @Define("id") 331 public Object getId() throws Exception { 332 if (infoObject instanceof Episode) 333 return getEpisode().getSeriesInfo().getId(); 334 if (infoObject instanceof Movie) 335 return getMovie().getId(); 336 if (infoObject instanceof AudioTrack) 337 return getMusic().getMBID(); 338 339 return null; 340 } 341 342 @Define("tmdbid") 343 public Integer getTmdbId() throws Exception { 344 if (infoObject instanceof Movie) { 345 if (getMovie().getTmdbId() > 0) { 346 return getMovie().getTmdbId(); 347 } 348 if (getMovie().getImdbId() > 0) { 349 return getMovieDetails().getId(); // lookup IMDbID for TMDbID 350 } 351 } 352 353 if (infoObject instanceof Episode) { 354 SeriesInfo s = getSeriesInfo(); 355 int id = XDB.get(s).getExternalId(s.getId(), XDB.TMDb); 356 if (id > 0) { 357 return id; 358 } 359 } 360 361 return null; 362 } 363 364 @Define("imdbid") 365 public String getImdbId() throws Exception { 366 if (infoObject instanceof Movie) { 367 if (getMovie().getImdbId() > 0) { 368 return Link.IMDb.getID(getMovie()); 369 } 370 if (getMovie().getTmdbId() > 0) { 371 return Link.IMDb.getID(getMovieDetails()); // lookup IMDbID for TMDbID 372 } 373 } 374 375 if (infoObject instanceof Episode) { 376 return Link.IMDb.getID(getSeriesDetails().getImdbId()); 377 } 378 379 return null; 380 } 381 382 @Define("vcf") 383 public String getVideoCompressionFormat() { 384 // e.g. AVC, HEVC, etc. 385 return getMediaInfo(StreamKind.Video, "Format"); 386 } 387 388 @Define("vc") 389 public String getVideoCodec() { 390 // e.g. XviD, x264, DivX 5, MPEG-4 Visual, AVC, etc. 391 String codec = getMediaInfo(StreamKind.Video, "Encoded_Library_Name", "Encoded_Library/Name", "CodecID/Hint", "Format"); 392 393 // get first token (e.g. DivX 5 => DivX) 394 return tokenize(codec).findFirst().orElse(null); 395 } 396 397 @Define("ac") 398 public String getAudioCodec() { 399 // e.g. AC-3, DTS, AAC, Vorbis, MP3, etc. 400 String codec = getMediaInfo(StreamKind.Audio, "CodecID/Hint", "Format/String", "Format"); 401 402 // take first word (e.g. AAC LC SBR => AAC) 403 // remove punctuation (e.g. AC-3 => AC3) 404 return tokenize(codec).findFirst().map(c -> normalizePunctuation(c, "", "")).orElse(null); 405 } 406 407 @Define("cf") 408 public String getContainerFormat() { 409 // container format extensions (e.g. avi, mkv mka mks, OGG, etc.) 410 String extensions = getMediaInfo(StreamKind.General, "Codec/Extensions", "Format"); 411 412 // get first extension 413 return tokenize(extensions).map(String::toLowerCase).findFirst().get(); 414 } 415 416 @Define("vf") 417 public String getVideoFormat() { 418 int w = Integer.parseInt(getMediaInfo(StreamKind.Video, "Width")); 419 int h = Integer.parseInt(getMediaInfo(StreamKind.Video, "Height")); 420 421 // e.g. 720p, nobody actually wants files to be tagged as interlaced, e.g. 720i 422 return String.format(Locale.ROOT, "%dp", VideoFormat.DEFAULT_GROUPS.guessFormat(w, h)); 423 } 424 425 @Define("hpi") 426 public String getExactVideoFormat() { 427 String height = getMediaInfo(StreamKind.Video, "Height"); 428 String scanType = getMediaInfo(StreamKind.Video, "ScanType"); 429 430 // progressive by default if ScanType is undefined 431 String p = scanType.codePoints().map(Character::toLowerCase).mapToObj(Character::toChars).map(String::valueOf).findFirst().orElse("p"); 432 433 // e.g. 720p 434 return height + p; 435 } 436 437 @Define("af") 438 public ChannelCount getAudioChannels() { 439 // get first number, e.g. 6ch 440 return getMediaInfo().stream(StreamKind.Audio, "Channel(s)_Original", "Channel(s)").map(channels -> { 441 // e.g. 15 objects / 6 channels 442 return tokenize(channels, SLASH).map(s -> matchInteger(s)).filter(Objects::nonNull).min(Integer::compare).orElse(null); 443 }).filter(Objects::nonNull).max(Integer::compare).map(ChannelCount::count).orElse(null); 444 } 445 446 @Define("channels") 447 public ChannelCount getAudioChannelLayout() { 448 // e.g. ChannelLayout: L R / L R C LFE Ls Rs / C L R LFE Lb Rb Lss Rss / Object Based 449 return getMediaInfo().stream(StreamKind.Audio, "ChannelLayout_Original", "ChannelLayout").map(layout -> { 450 if (layout.equals("Object Based")) { 451 return null; 452 } 453 return tokenize(layout, SPACE).map(c -> { 454 return !c.contains("LFE") ? "1" : "0.1"; 455 }).map(BigDecimal::new).reduce(BigDecimal::add).orElse(null); 456 }).filter(Objects::nonNull).max(BigDecimal::compareTo).map(ChannelCount::layout).orElseGet(() -> { 457 // e.g. 15 objects / 6 channels 458 return Optional.ofNullable(getAudioChannels()).map(ChannelCount::getValue).map(ChannelCount::layout).orElse(null); 459 }); 460 } 461 462 @Define("aco") 463 public String getAudioChannelObjects() { 464 return getMediaInfo().stream(StreamKind.Audio, "Codec_Profile", "Format_Profile", "Format_Commercial").map(s -> { 465 return SLASH.splitAsStream(s).findFirst().orElse(null); 466 }).filter(Objects::nonNull).map(String::trim).filter(s -> s.length() > 0).findFirst().orElse(null); 467 } 468 469 @Define("resolution") 470 public String getVideoResolution() { 471 return join(getDimension(), "x"); // e.g. 1280x720 472 } 473 474 @Define("bitdepth") 475 public Integer getVideoBitDepth() { 476 return matchInteger(getMediaInfo(StreamKind.Video, "BitDepth")); 477 } 478 479 @Define("hdr") 480 public String getHighDynamicRange() { 481 // try HDR_Format_Commercial (requires MediaInfo 19.04) 482 return getMediaInfo().find(StreamKind.Video, "HDR_Format", "HDR_Format_Commercial").flatMap(SLASH::splitAsStream).filter(s -> { 483 // search for specific HDR type values 484 return s.contains("HDR") || s.contains("Dolby Vision"); 485 }).findFirst().orElseGet(() -> { 486 // fallback for legacy libmediainfo 487 return getVideoBitDepth() >= 10 && getMediaInfo().stream(StreamKind.Video, "colour_primaries").anyMatch(s -> s.matches("BT.2020")) ? "HDR" : null; 488 }); 489 } 490 491 @Define("ar") 492 public String getAspectRatio() { 493 // e.g. 16:9 494 String ratio = getMediaInfo(StreamKind.Video, "DisplayAspectRatio/String"); 495 return tokenize(ratio, NON_DIGIT).collect(joining("∶")); // use ratio (U+2236) instead of colon (U+003A) 496 } 497 498 @Define("ws") 499 public String getWidescreen() { 500 // width-to-height aspect ratio greater than 1.37:1 501 float ratio = Float.parseFloat(getMediaInfo(StreamKind.Video, "DisplayAspectRatio")); 502 return ratio > 1.37f ? "WS" : null; 503 } 504 505 @Define("hd") 506 public String getVideoDefinitionCategory() { 507 List<Integer> dim = getDimension(); 508 // UHD 509 if (dim.get(0) >= 3840 || dim.get(1) >= 2160) { 510 return "UHD"; 511 } 512 // HD 513 if (dim.get(0) >= 1280 || dim.get(1) >= 720) { 514 return "HD"; 515 } 516 // SD 517 return "SD"; 518 } 519 520 @Define("dim") 521 public List<Integer> getDimension() { 522 // collect value from Video Stream 0 or Image Stream 0 523 return Stream.of(StreamKind.Video, StreamKind.Image).map(k -> { 524 // collect Width and Height as Integer List 525 return Stream.of("Width", "Height").map(p -> getMediaInfo().get(k, 0, p)).filter(s -> s.length() > 0).map(Integer::parseInt).collect(toList()); 526 }).filter(d -> d.size() == 2).findFirst().orElse(null); 527 } 528 529 @Define("width") 530 public Integer getWidth() { 531 return getDimension().get(0); 532 } 533 534 @Define("height") 535 public Integer getHeight() { 536 return getDimension().get(1); 537 } 538 539 @Define("original") 540 public String getOriginalFileName() { 541 // try xattr file name 542 return Optional.ofNullable(xattr.getOriginalName(getMediaFile())).map(FileUtilities::getNameWithoutExtension).orElseGet(() -> { 543 // try historic file name 544 return HISTORY.getOriginalPath(getMediaFile()).map(FileUtilities::getName).findFirst().orElseGet(() -> { 545 // default to current file name 546 return getFileName(); 547 }); 548 }); 549 } 550 551 @Define("xattr") 552 public Object getMetaAttributesObject() throws Exception { 553 return xattr.getMetaInfo(getMediaFile()); 554 } 555 556 @Define("crc32") 557 public String getCRC32() throws Exception { 558 // try to get checksum from file name 559 Optional<String> embeddedChecksum = stream(getFileNames(inferredMediaFile.get())).map(Normalization::getEmbeddedChecksum).filter(Objects::nonNull).findFirst(); 560 if (embeddedChecksum.isPresent()) { 561 return embeddedChecksum.get(); 562 } 563 564 // try to get checksum from sfv file 565 String checksum = getHashFromVerificationFile(inferredMediaFile.get(), HashType.SFV, 3); 566 if (checksum != null) { 567 return checksum; 568 } 569 570 // compute and store to xattr 571 return XattrChecksum.CRC32.computeIfAbsent(inferredMediaFile.get()); 572 } 573 574 @Define("fn") 575 public String getFileName() { 576 // name without file extension 577 return FileUtilities.getName(getMediaFile()); 578 } 579 580 @Define("ext") 581 public String getExtension() { 582 // file extension 583 return FileUtilities.getExtension(getMediaFile()); 584 } 585 586 @Define("vs") 587 public String getVideoSource() throws Exception { 588 // look for video source patterns in media file and it's parent folder (use inferred media file) 589 return releaseInfo.getVideoSource(mediaTitles.get()); 590 } 591 592 @Define("source") 593 public String getVideoSourceMatch() throws Exception { 594 // look for video source patterns in media file and it's parent folder (use inferred media file) 595 return releaseInfo.getVideoSourceMatch(mediaTitles.get()); 596 } 597 598 @Define("tags") 599 public List<String> getVideoTags() throws Exception { 600 // look for video source patterns in media file and it's parent folder (use inferred media file) 601 List<String> matches = releaseInfo.getVideoTags(mediaTitles.get()); 602 if (matches.isEmpty()) { 603 return null; 604 } 605 606 // heavy normalization for whatever text was captured with the tags pattern 607 return matches.stream().map(s -> { 608 return lowerTrail(upperInitial(normalizePunctuation(s))); 609 }).sorted().distinct().collect(toList()); 610 } 611 612 @Define("s3d") 613 public String getStereoscopic3D() throws Exception { 614 return releaseInfo.getStereoscopic3D(mediaTitles.get()); 615 } 616 617 @Define("group") 618 public String getReleaseGroup() throws Exception { 619 // reduce false positives by removing the know titles from the name 620 Pattern[] nonGroupPattern = { getKeywordExcludePattern(), releaseInfo.getVideoSourcePattern(), releaseInfo.getVideoFormatPattern(true), releaseInfo.getResolutionPattern(), releaseInfo.getStructureRootPattern() }; 621 622 // consider foldername, filename and original filename of inferred media file 623 String[] filenames = stream(mediaTitles.get()).map(s -> releaseInfo.clean(s, nonGroupPattern)).filter(s -> s.length() > 0).toArray(String[]::new); 624 625 // look for release group names in media file and it's parent folder 626 return releaseInfo.getReleaseGroup(filenames); 627 } 628 629 @Define("subt") 630 public String getSubtitleTags() throws Exception { 631 Language language = getLanguageTag(); 632 633 // require subtitle files for language suffix bindings 634 if (language == null) { 635 return null; 636 } 637 638 // Plex only supports ISO 639-2/B language codes 639 String tag = '.' + language.getISO3B(); 640 String category = releaseInfo.getSubtitleCategoryTag(getFileNames(getMediaFile())); 641 return category == null ? tag : tag + '.' + category; 642 } 643 644 @Define("lang") 645 public Language getLanguageTag() throws Exception { 646 File f = getMediaFile(); 647 648 // require subtitle files for language suffix bindings 649 if (!SUBTITLE_FILES.accept(f)) { 650 return null; 651 } 652 653 // grep language from filename 654 String[] fn = getFileNames(f); 655 Locale languageTag = releaseInfo.getSubtitleLanguageTag(fn); 656 if (languageTag != null) { 657 return Language.getLanguage(languageTag); 658 } 659 660 // check if file name matches a language name (e.g. English.srt) 661 Language languageName = stream(fn).map(Language::findLanguage).filter(Objects::nonNull).findFirst().orElse(null); 662 if (languageName != null) { 663 return languageName; 664 } 665 666 // detect language from subtitle text content 667 return detectSubtitleLanguage(f); 668 } 669 670 @Define("languages") 671 public List<Language> getSpokenLanguages() { 672 if (infoObject instanceof Movie) { 673 List<Locale> languages = getMovieDetails().getSpokenLanguages(); 674 return languages.stream().map(Language::getLanguage).filter(Objects::nonNull).collect(toList()); 675 } 676 if (infoObject instanceof Episode) { 677 List<Locale> languages = getSeriesInfo().getSpokenLanguages(); 678 return languages.stream().map(Language::getLanguage).filter(Objects::nonNull).collect(toList()); 679 } 680 return null; 681 } 682 683 @Define("country") 684 public String getOriginCountry() throws Exception { 685 if (infoObject instanceof Movie) { 686 return getMovieDetails().getProductionCountries().stream().findFirst().orElse(null); 687 } 688 if (infoObject instanceof Episode) { 689 return getSeriesDetails().getCountry().stream().findFirst().orElse(null); 690 } 691 return null; 692 } 693 694 @Define("runtime") 695 public Integer getRuntime() { 696 if (infoObject instanceof Movie) 697 return getMovieDetails().getRuntime(); 698 if (infoObject instanceof Episode) 699 return getSeriesInfo().getRuntime(); 700 701 return null; 702 } 703 704 @Define("actors") 705 public List<String> getActors() throws Exception { 706 if (infoObject instanceof Movie) 707 return getMovieDetails().getActors(); 708 if (infoObject instanceof Episode) 709 return ExtendedMetadataMethods.getActors(getSeriesInfo()); // use TheTVDB API v2 to retrieve actors info 710 711 return null; 712 } 713 714 @Define("genres") 715 public List<String> getGenres() { 716 if (infoObject instanceof Movie) 717 return getMovieDetails().getGenres(); 718 if (infoObject instanceof Episode) 719 return getSeriesInfo().getGenres(); 720 if (infoObject instanceof AudioTrack) 721 return Stream.of(getMusic().getGenre()).filter(Objects::nonNull).flatMap(SEMICOLON::splitAsStream).map(String::trim).filter(s -> s.length() > 0).collect(toList()); 722 723 return null; 724 } 725 726 @Define("genre") 727 public String getPrimaryGenre() { 728 return getGenres().iterator().next(); 729 } 730 731 @Define("director") 732 public String getDirector() throws Exception { 733 if (infoObject instanceof Movie) 734 return getMovieDetails().getDirector(); 735 if (infoObject instanceof Episode) 736 return ExtendedMetadataMethods.getInfo(getEpisode()).getDirector(); // use TheTVDB API v2 to retrieve extended episode info 737 738 return null; 739 } 740 741 @Define("certification") 742 public String getCertification() { 743 if (infoObject instanceof Movie) 744 return getMovieDetails().getCertification(); 745 if (infoObject instanceof Episode) 746 return getSeriesInfo().getCertification(); 747 748 return null; 749 } 750 751 @Define("rating") 752 public Double getRating() { 753 if (infoObject instanceof Movie) 754 return getMovieDetails().getRating(); 755 if (infoObject instanceof Episode) 756 return getSeriesInfo().getRating(); 757 758 return null; 759 } 760 761 @Define("votes") 762 public Integer getVotes() { 763 if (infoObject instanceof Movie) 764 return getMovieDetails().getVotes(); 765 if (infoObject instanceof Episode) 766 return getSeriesInfo().getRatingCount(); 767 768 return null; 769 } 770 771 @Define("collection") 772 public String getCollection() { 773 if (infoObject instanceof Movie) 774 return getMovieDetails().getCollection(); 775 776 return null; 777 } 778 779 @Define("ci") 780 public Integer getCollectionIndex() throws Exception { 781 return ExtendedMetadataMethods.getCollection(getMovie()).indexOf(getMovie()) + 1; 782 } 783 784 @Define("cy") 785 public List<Integer> getCollectionYears() throws Exception { 786 List<Integer> years = ExtendedMetadataMethods.getCollection(getMovie()).stream().map(Movie::getYear).sorted().distinct().collect(toList()); 787 if (years.size() > 1) { 788 return asList(years.get(0), years.get(years.size() - 1)); 789 } else { 790 return years; 791 } 792 } 793 794 @Define("info") 795 public synchronized AssociativeScriptObject getMetaInfo() throws Exception { 796 if (infoObject instanceof Movie) 797 return createPropertyBindings(getMovieDetails()); 798 if (infoObject instanceof Episode) 799 return createPropertyBindings(getSeriesDetails()); 800 801 return null; 802 } 803 804 @Define("omdb") 805 public synchronized AssociativeScriptObject getOmdbApiInfo() throws Exception { 806 if (infoObject instanceof Movie) { 807 if (getMovie().getImdbId() > 0) { 808 return createPropertyBindings(OMDb.getMovieInfo(getMovie())); 809 } 810 if (getMovie().getTmdbId() > 0) { 811 Integer imdbId = getMovieDetails().getImdbId(); 812 if (imdbId != null) { 813 return createPropertyBindings(OMDb.getMovieInfo(Movie.IMDB(imdbId))); 814 } 815 } 816 } 817 818 if (infoObject instanceof Episode) { 819 Integer imdbId = getSeriesDetails().getImdbId(); 820 if (imdbId != null) { 821 return createPropertyBindings(OMDb.getMovieInfo(Movie.IMDB(imdbId))); 822 } 823 } 824 825 return null; 826 } 827 828 @Define("order") 829 public DynamicBindings getSortOrderObject() { 830 return new DynamicBindings(SortOrder::names, k -> { 831 if (infoObject instanceof Episode) { 832 SortOrder order = SortOrder.forName(k); 833 Episode episode = reorderEpisode(getEpisode(), order); 834 return createBindingObject(episode); 835 } 836 return undefined(k); 837 }); 838 } 839 840 @Define("localize") 841 public DynamicBindings getLocalizedInfoObject() { 842 return new DynamicBindings(Language::availableLanguages, k -> { 843 Language language = Language.findLanguage(k); 844 Locale locale = language != null ? language.getLocale() : Locale.forLanguageTag(k); 845 846 if (locale.getLanguage().length() == 2 && locale.getCountry().length() == 2) { 847 if (infoObject instanceof Movie) { 848 Movie movie = TheMovieDB.getMovieDescriptor(getMovie(), locale); 849 return createBindingObject(movie); 850 } 851 if (infoObject instanceof Episode) { 852 Episode episode = fetchEpisode(getEpisode(), null, locale); 853 return createBindingObject(episode); 854 } 855 } 856 857 return undefined(k); 858 }); 859 } 860 861 @Define("db") 862 public DynamicBindings getDatabaseMapper() { 863 SeriesInfo s = getSeriesInfo(); 864 865 if (isInstance(TheMovieDB_TV, s) || isInstance(AniDB, s) || isInstance(TheTVDB, s) || isInstance(TheTVDBv4, s)) { 866 return new DynamicBindings(AnimeLists.DB::names, k -> { 867 Episode e = getEpisode(); 868 869 AnimeLists.DB source = AnimeLists.DB.get(e); 870 AnimeLists.DB target = AnimeLists.DB.get(k); 871 872 // AnimeList mappings are based on Airdate SxE numbers 873 e = reorderEpisode(e, source.order()); 874 875 if (source != target) { 876 Optional<Episode> mapping = AnimeList.map(e, source, target); 877 if (!mapping.isPresent()) { 878 throw new Exception("Mapping not found"); 879 } 880 // reload complete episode information based on mapped SxE numbers 881 e = hydrateEpisode(mapping.get()); 882 } 883 884 return createBindingObject(e); 885 }); 886 } 887 888 return null; 889 } 890 891 @Define("historic") 892 public AssociativeScriptObject getHistoricBindings() throws Exception { 893 return HISTORY.getOriginalPath(getMediaFile()).findFirst().map(f -> { 894 return createBindingObject(f, null, null, this::undefined); 895 }).orElse(null); 896 } 897 898 @Define("az") 899 public String getSortInitial() { 900 try { 901 return sortInitial(getCollection()); 902 } catch (Exception e) { 903 return sortInitial(getName()); 904 } 905 } 906 907 @Define("decade") 908 public Integer getDecade() throws Exception { 909 return (getYear() / 10) * 10; 910 } 911 912 @Define("anime") 913 public Boolean isAnimeEpisode() throws Exception { 914 if (infoObject instanceof Episode) { 915 Episode e = getEpisode(); 916 // check genres 917 if (e.getSeriesInfo().getGenres().contains("Anime")) { 918 return true; 919 } 920 // check keywords 921 if (isInstance(TheMovieDB_TV, e) && !e.isAnime()) { 922 return TheMovieDB_TV.getKeywords(e.getSeriesInfo().getId()).contains("anime"); 923 } 924 // check mapping table 925 if ((isInstance(TheTVDB, e) || isInstance(TheTVDBv4, e)) && !e.isAnime()) { 926 return AnimeList.find(AnimeLists.DB.TheTVDB, e.getSeriesInfo().getId()).findAny().isPresent(); 927 } 928 // check database affiliation 929 return e.isAnime(); 930 } 931 if (infoObject instanceof Movie) { 932 MovieInfo m = getMovieDetails(); 933 // check keywords or guess based on genre and production country 934 return m.getKeywords().contains("anime") || (m.getGenres().contains("Animation") && m.getProductionCountries().contains("JP")); 935 } 936 return null; 937 } 938 939 @Define("regular") 940 public boolean isRegularEpisode() { 941 return getEpisode().isRegular(); 942 } 943 944 @Define("episodelist") 945 public List<Episode> getEpisodeList() throws Exception { 946 return fetchEpisodeList(getEpisode()); 947 } 948 949 @Define("sy") 950 public List<Integer> getSeasonYears() throws Exception { 951 return getEpisodeList().stream().filter(e -> { 952 return e.isRegular() && e.getAirdate() != null && Objects.equals(e.getSeason(), getEpisode().getSeason()); 953 }).map(e -> e.getAirdate().getYear()).sorted().distinct().collect(toList()); 954 } 955 956 @Define("sc") 957 public Integer getSeasonCount() throws Exception { 958 return getEpisodeList().stream().filter(e -> e.isRegular() && e.getSeason() != null).map(Episode::getSeason).max(Integer::compare).get(); 959 } 960 961 @Define("mediaTitle") 962 public String getMediaTitle() { 963 return getVideoCharacteristics().map(MediaCharacteristics::getTitle).orElse(null); 964 } 965 966 @Define("mediaTags") 967 public Object getMediaTags() { 968 return getVideoCharacteristics().map(MediaCharacteristics::getMediaTags).orElse(null); 969 } 970 971 @Define("audioLanguages") 972 public List<Language> getAudioLanguageList() { 973 return getMediaInfo().stream(StreamKind.Audio, "Language").filter(Objects::nonNull).distinct().map(Language::findLanguage).filter(Objects::nonNull).collect(toList()); 974 } 975 976 @Define("textLanguages") 977 public List<Language> getTextLanguageList() { 978 return getMediaInfo().stream(StreamKind.Text, "Language").filter(Objects::nonNull).distinct().map(Language::findLanguage).filter(Objects::nonNull).collect(toList()); 979 } 980 981 @Define("bitrate") 982 public BitRate getOverallBitRate() { 983 return BitRate.parse(getMediaInfo(StreamKind.General, "OverallBitRate")); 984 } 985 986 @Define("vbr") 987 public BitRate getVideoBitRate() { 988 return BitRate.parse(getMediaInfo(StreamKind.Video, "BitRate")); 989 } 990 991 @Define("abr") 992 public BitRate getAudioBitRate() { 993 return BitRate.parse(getMediaInfo(StreamKind.Audio, "BitRate")); 994 } 995 996 @Define("kbps") 997 public BitRate getKiloBytesPerSecond() { 998 return getOverallBitRate().getKbps(); 999 } 1000 1001 @Define("mbps") 1002 public BitRate getMegaBytesPerSecond() { 1003 return getOverallBitRate().getMbps(); 1004 } 1005 1006 @Define("fps") 1007 public FrameRate getFrameRate() { 1008 return FrameRate.parse(getMediaInfo(StreamKind.Video, "FrameRate")); 1009 } 1010 1011 @Define("khz") 1012 public String getSamplingRate() { 1013 return getMediaInfo(StreamKind.Audio, "SamplingRate/String"); 1014 } 1015 1016 @Define("duration") 1017 public Duration getDuration() { 1018 return Duration.ofMillis(Math.round(Double.parseDouble(getMediaInfo(StreamKind.General, "Duration")))); 1019 } 1020 1021 @Define("seconds") 1022 public long getSeconds() { 1023 return getDuration().getSeconds(); 1024 } 1025 1026 @Define("minutes") 1027 public long getMinutes() { 1028 return getDuration().toMinutes(); 1029 } 1030 1031 @Define("hours") 1032 public String getHours() { 1033 return format(getDuration(), "H∶mm"); // use ratio (U+2236) instead of colon (U+003A) 1034 } 1035 1036 @Define("media") 1037 public AssociativeScriptObject getGeneralMediaInfo() { 1038 return createMediaInfoBindings(StreamKind.General).get(0); 1039 } 1040 1041 @Define("menu") 1042 public AssociativeScriptObject getMenuInfo() { 1043 return createMediaInfoBindings(StreamKind.Menu).get(0); 1044 } 1045 1046 @Define("image") 1047 public AssociativeScriptObject getImageInfo() { 1048 return createMediaInfoBindings(StreamKind.Image).get(0); 1049 } 1050 1051 @Define("video") 1052 public List<AssociativeScriptObject> getVideoInfoList() { 1053 return createMediaInfoBindings(StreamKind.Video); 1054 } 1055 1056 @Define("audio") 1057 public List<AssociativeScriptObject> getAudioInfoList() { 1058 return createMediaInfoBindings(StreamKind.Audio); 1059 } 1060 1061 @Define("text") 1062 public List<AssociativeScriptObject> getTextInfoList() { 1063 return createMediaInfoBindings(StreamKind.Text); 1064 } 1065 1066 @Define("chapters") 1067 public List<AssociativeScriptObject> getChaptersInfoList() { 1068 return createMediaInfoBindings(StreamKind.Chapters); 1069 } 1070 1071 @Define("exif") 1072 public AssociativeScriptObject getImageMetadata() throws Exception { 1073 return new AssociativeScriptObject(getPhoto().snapshot(), this::undefined); 1074 } 1075 1076 @Define("camera") 1077 public AssociativeEnumObject getCamera() throws Exception { 1078 return getPhoto().getCameraModel().map(AssociativeEnumObject::new).orElse(null); 1079 } 1080 1081 @Define("location") 1082 public AssociativeEnumObject getLocation() throws Exception { 1083 return getPhoto().getLocationTaken(GoogleMaps).map(AssociativeEnumObject::new).orElse(null); 1084 } 1085 1086 @Define("artist") 1087 public String getArtist() { 1088 return getMusic().getArtist(); 1089 } 1090 1091 @Define("albumArtist") 1092 public String getAlbumArtist() { 1093 return getMusic().getAlbumArtist(); 1094 } 1095 1096 @Define("album") 1097 public String getAlbum() { 1098 return getMusic().getAlbum(); 1099 } 1100 1101 @Define("episode") 1102 public Episode getEpisode() { 1103 return asType(infoObject, Episode.class); 1104 } 1105 1106 @Define("episodes") 1107 public List<Episode> getEpisodes() { 1108 return streamMultiEpisode(getEpisode()).collect(toList()); 1109 } 1110 1111 @Define("movie") 1112 public Movie getMovie() { 1113 return asType(infoObject, Movie.class); 1114 } 1115 1116 @Define("music") 1117 public AudioTrack getMusic() { 1118 return asType(infoObject, AudioTrack.class); 1119 } 1120 1121 @Define("photo") 1122 public ImageMetadata getPhoto() throws Exception { 1123 if (infoObject instanceof PhotoFile) { 1124 return ((PhotoFile) infoObject).getMetadata(); 1125 } 1126 return new ImageMetadata(getMediaFile()); 1127 } 1128 1129 @Define("pi") 1130 public Integer getPart() { 1131 if (infoObject instanceof AudioTrack) 1132 return getMusic().getTrack(); 1133 if (infoObject instanceof MoviePart) 1134 return ((MoviePart) infoObject).getPartIndex(); 1135 1136 return null; 1137 } 1138 1139 @Define("pn") 1140 public Integer getPartCount() { 1141 if (infoObject instanceof AudioTrack) 1142 return getMusic().getTrackCount(); 1143 if (infoObject instanceof MoviePart) 1144 return ((MoviePart) infoObject).getPartCount(); 1145 1146 return null; 1147 } 1148 1149 @Define("type") 1150 public String getInfoObjectType() { 1151 return infoObject.getClass().getSimpleName(); 1152 } 1153 1154 @Define("mediaFile") 1155 public File getInferredMediaFile() throws Exception { 1156 return inferredMediaFile.get(); 1157 } 1158 1159 @Define("f") 1160 public File getMediaFile() { 1161 // make sure file is not null, and that it is an existing file 1162 if (mediaFile == null) { 1163 throw new BindingException("file", "undefined", BindingException.Flag.SAMPLE_FILE_NOT_SET); 1164 } 1165 return mediaFile; 1166 } 1167 1168 @Define("folder") 1169 public File getMediaParentFolder() { 1170 return getMediaFile().getParentFile(); 1171 } 1172 1173 @Define("files") 1174 public List<File> files() throws Exception { 1175 File f = getMediaFile(); 1176 // list folder 1177 if (f.isDirectory()) { 1178 return FileUtilities.listFiles(f, FILES, HUMAN_NAME_ORDER); 1179 } 1180 // list archive 1181 if (VFS.hasIndex(f) && f.isFile()) { 1182 return VFS.getIndex(f).stream().map(path -> new File(f, path.toString())).collect(toList()); 1183 } 1184 // list self and primary video file 1185 return Stream.of(f, inferredMediaFile.get()).distinct().collect(toList()); 1186 } 1187 1188 @Define("bytes") 1189 public FileSize getFileSize() throws Exception { 1190 // sum size of all files 1191 if (getMediaFile().isDirectory()) { 1192 long totalSize = listFiles(getMediaFile(), FILES).stream().mapToLong(File::length).sum(); 1193 return new FileSize(totalSize); 1194 } 1195 1196 // size of inferred media file (e.g. video file size for subtitle file) 1197 long mediaSize = inferredMediaFile.get().length(); 1198 return new FileSize(mediaSize); 1199 } 1200 1201 @Define("megabytes") 1202 public FileSize getFileSizeInMegaBytes() throws Exception { 1203 return getFileSize().getMB(); 1204 } 1205 1206 @Define("gigabytes") 1207 public FileSize getFileSizeInGigaBytes() throws Exception { 1208 return getFileSize().getGB(); 1209 } 1210 1211 @Define("today") 1212 public SimpleDate getToday() { 1213 return SimpleDate.now(); 1214 } 1215 1216 @Define("drive") 1217 public File getMountPoint() throws Exception { 1218 // use ~ as file system root for all files within the user home 1219 if (FileUtilities.listPath(getMediaFile()).contains(getUserHome())) { 1220 return getUserHome(); 1221 } 1222 // find D:/ on Windows or /Volumes/Share on macOS 1223 return FileUtilities.getMountPoint(getMediaFile()); 1224 } 1225 1226 @Define("home") 1227 public File getUserHome() { 1228 return ApplicationFolder.UserHome.getDirectory(); 1229 } 1230 1231 @Define("output") 1232 public File getUserDefinedOutputFolder() throws IOException { 1233 return new File(Settings.getApplicationArguments().output).getCanonicalFile(); 1234 } 1235 1236 @Define("defines") 1237 public Map<String, String> getUserDefinedArguments() throws IOException { 1238 return unmodifiableMap(Settings.getApplicationArguments().defines); 1239 } 1240 1241 @Define("label") 1242 public String getUserDefinedLabel() throws IOException { 1243 return getUserDefinedArguments().entrySet().stream().filter(it -> { 1244 return it.getKey().endsWith("label") && it.getValue() != null && it.getValue().length() > 0; 1245 }).map(it -> it.getValue()).findFirst().orElse(null); 1246 } 1247 1248 @Define("i") 1249 public Integer getModelIndex() { 1250 return identityIndexOf(context.values().stream(), getInfoObject()).map(i -> i + 1).orElse(null); 1251 } 1252 1253 @Define("di") 1254 public Integer getDuplicateIndex() { 1255 return identityIndexOf(getDuplicateContext().map(MediaBindingBean::getInfoObject), getInfoObject()).map(i -> i + 1).orElse(null); 1256 } 1257 1258 @Define("dc") 1259 public Integer getDuplicateCount() { 1260 return getDuplicateContext().mapToInt(i -> 1).sum(); 1261 } 1262 1263 @Define("plex") 1264 public File getPlexStandardPath() throws Exception { 1265 return getStandardPath(NamingStandard.Plex); 1266 } 1267 1268 @Define("kodi") 1269 public File getKodiStandardPath() throws Exception { 1270 return getStandardPath(NamingStandard.Kodi); 1271 } 1272 1273 @Define("emby") 1274 public File getEmbyStandardPath() throws Exception { 1275 return getStandardPath(NamingStandard.Emby); 1276 } 1277 1278 @Define("self") 1279 public AssociativeScriptObject getSelf() { 1280 return createBindingObject(mediaFile, infoObject, context, property -> null); 1281 } 1282 1283 @Define("model") 1284 public List<AssociativeScriptObject> getModel() { 1285 return modelCache.get(context, c -> { 1286 return context.entrySet().stream().map(m -> { 1287 return createBindingObject(m.getKey(), m.getValue(), context, property -> null); 1288 }).collect(toList()); 1289 }); 1290 } 1291 1292 @Define("json") 1293 public String getInfoObjectDump() { 1294 return MetaAttributes.toJson(infoObject, false); 1295 } 1296 1297 @Define("XEM") 1298 public DynamicBindings getXrossEntityMapper() { 1299 return new DynamicBindings(XEM::names, k -> { 1300 if (infoObject instanceof Episode) { 1301 Episode e = getEpisode(); 1302 XEM origin = XEM.forName(e.getSeriesInfo().getDatabase()); 1303 XEM destination = XEM.forName(k); 1304 return origin == destination ? e : origin.map(e, destination).orElse(e); 1305 } 1306 return undefined(k); 1307 }); 1308 } 1309 1310 @Define("AnimeList") 1311 public DynamicBindings getAnimeLists() { 1312 return new DynamicBindings(AnimeLists.DB::names, k -> { 1313 if (infoObject instanceof Episode) { 1314 Episode e = getEpisode(); 1315 AnimeLists.DB source = AnimeLists.DB.get(e); 1316 AnimeLists.DB target = AnimeLists.DB.get(k); 1317 return source == target ? e : AnimeList.map(e, source, target).orElse(e); 1318 } 1319 return undefined(k); 1320 }); 1321 } 1322 1323 public StructuredFile getStandardPath(NamingStandard naming) throws Exception { 1324 StructuredFile path = StructuredFile.of(infoObject, naming); 1325 1326 // add subtitle suffix 1327 if (path != null) { 1328 try { 1329 path = path.suffix(getSubtitleTags()); 1330 } catch (Exception e) { 1331 // ignore => no language tags 1332 } 1333 } 1334 1335 return path; 1336 } 1337 1338 private final Resource<SeriesDetails> seriesDetails = Resource.lazy(() -> ExtendedMetadataMethods.getDetails(getSeriesInfo())); 1339 1340 public SeriesDetails getSeriesDetails() { 1341 try { 1342 return seriesDetails.get(); 1343 } catch (Exception e) { 1344 throw new BindingException("details", cause("Failed to retrieve series details", e), e); 1345 } 1346 } 1347 1348 private final Resource<MovieInfo> movieDetails = Resource.lazy(() -> ExtendedMetadataMethods.getInfo(getMovie())); 1349 1350 public MovieInfo getMovieDetails() { 1351 try { 1352 return movieDetails.get(); 1353 } catch (Exception e) { 1354 throw new BindingException("details", cause("Failed to retrieve movie details", e), e); 1355 } 1356 } 1357 1358 // lazy initialize and then keep in memory 1359 private final Resource<File> inferredMediaFile = Resource.lazy(() -> getInferredMediaFile(getMediaFile())); 1360 1361 private File getInferredMediaFile(File file) { 1362 // primary media file is the video file itself 1363 if (VIDEO_FILES.accept(file)) { 1364 return file; 1365 } 1366 1367 // never infere media info from nearby video files when processing music or photos 1368 if (infoObject instanceof AudioTrack || infoObject instanceof PhotoFile) { 1369 return file; 1370 } 1371 1372 // find primary video file (e.g. a video file with the same base name) 1373 return findPrimaryFile(file, VIDEO_FILES).orElseGet(() -> { 1374 List<File> options = new ArrayList<File>(); 1375 1376 // prefer equal match from current context if possible 1377 if (infoObject instanceof Episode || infoObject instanceof Movie) { 1378 context.forEach((f, i) -> { 1379 if (infoObject.equals(i) && VIDEO_FILES.accept(f)) { 1380 options.add(f); 1381 } 1382 }); 1383 } 1384 1385 // just pick any nearby video file for lack of better options 1386 if (options.isEmpty() && (TEXT_FILES.accept(file) || IMAGE_FILES.accept(file))) { 1387 try { 1388 options.addAll(findSiblingFiles(file, VIDEO_FILES)); 1389 } catch (Exception e) { 1390 debug.warning(cause("Failed to find sibling files", e)); 1391 } 1392 } 1393 1394 // find first best match or default to itself for lack of better options 1395 return findPrimaryFile(file, options).orElse(file); 1396 }); 1397 } 1398 1399 // lazy initialize and then keep in memory 1400 private final Resource<MediaInfo> mediaInfo = Resource.lazy(() -> { 1401 // use inferred media file (e.g. actual movie file instead of subtitle file) 1402 return mediaInfoCache.get(FileKey.of(inferredMediaFile.get()), f -> { 1403 try { 1404 return new MediaInfo().open(f.getFile()); 1405 } catch (IOException | IllegalArgumentException e) { 1406 throw new MediaInfoException(e.getMessage(), e); 1407 } 1408 }); 1409 }); 1410 1411 public MediaInfo getMediaInfo() { 1412 try { 1413 return mediaInfo.get(); 1414 } catch (BindingException e) { 1415 throw e; 1416 } catch (Exception e) { 1417 throw new BindingException("media", cause("Failed to read media info", e), e); 1418 } 1419 } 1420 1421 private String getMediaInfo(StreamKind streamKind, String... keys) { 1422 return getMediaInfo().stream(streamKind, keys).findFirst().orElseGet(() -> { 1423 return undefined(streamKind.name() + asList(keys)); 1424 }); 1425 } 1426 1427 private Stream<MediaBindingBean> getDuplicateContext() { 1428 return context.entrySet().stream().filter(e -> e.getKey() != null && e.getValue() != null).map(e -> new MediaBindingBean(e.getValue(), e.getKey())).filter(m -> { 1429 // distinct duplicate context per file type 1430 return Objects.equals(getInfoObject(), m.getInfoObject()) && Objects.equals(getExtension(), m.getExtension()); 1431 }); 1432 } 1433 1434 private Optional<Integer> identityIndexOf(Stream<?> list, Object object) { 1435 Iterator<?> itr = list.iterator(); 1436 for (int i = 0; itr.hasNext(); i++) { 1437 Object next = itr.next(); 1438 if (object == next) { 1439 return Optional.of(i); 1440 } 1441 } 1442 return Optional.empty(); 1443 } 1444 1445 private AssociativeScriptObject createBindingObject(Object info) { 1446 return createBindingObject(null, info, null, this::undefined); 1447 } 1448 1449 private AssociativeScriptObject createBindingObject(File file, Object info, Map<File, ?> context, Function<String, Object> defaultValue) { 1450 MediaBindingBean mediaBindingBean = new MediaBindingBean(info, file, context) { 1451 1452 @Override 1453 @Define(undefined) 1454 public <T> T undefined(String name) { 1455 return null; // never throw exceptions for empty or null values 1456 } 1457 }; 1458 1459 return new AssociativeScriptObject(new ExpressionBindings(mediaBindingBean), defaultValue); 1460 } 1461 1462 private AssociativeScriptObject createPropertyBindings(Object object) { 1463 return new AssociativeScriptObject(new PropertyBindings(object), this::undefined); 1464 } 1465 1466 private List<AssociativeScriptObject> createMediaInfoBindings(StreamKind kind) { 1467 return getMediaInfo().snapshot().get(kind).stream().map(m -> new AssociativeScriptObject(m, property -> null)).collect(toList()); 1468 } 1469 1470 private final Resource<String[]> mediaTitles = Resource.lazy(() -> { 1471 // try to place embedded media title first 1472 String prime = getVideoCharacteristics().map(MediaCharacteristics::getTitle).orElse(null); 1473 String[] names = getFileNames(inferredMediaFile.get()); 1474 1475 if (prime != null && !prime.isEmpty()) { 1476 return Stream.concat(Stream.of(prime), stream(names)).distinct().toArray(String[]::new); 1477 } 1478 1479 // default to file name and xattr 1480 return names; 1481 }); 1482 1483 private Optional<MediaCharacteristics> getVideoCharacteristics() { 1484 try { 1485 switch (getMediaCharacteristicsParser()) { 1486 case libmediainfo: 1487 return Optional.of(getMediaInfo()); 1488 case none: 1489 return Optional.empty(); 1490 default: 1491 return getMediaCharacteristics(inferredMediaFile.get()); 1492 } 1493 } catch (Exception e) { 1494 debug.finest(cause("Failed to read video characteristics", e)); 1495 } 1496 return Optional.empty(); 1497 } 1498 1499 private String[] getFileNames(File file) { 1500 List<String> names = new ArrayList<String>(); 1501 1502 // original file name via xattr 1503 String original = xattr.getOriginalName(file); 1504 if (original != null) { 1505 names.add(getNameWithoutExtension(original)); 1506 } 1507 1508 // current file name 1509 names.add(getNameWithoutExtension(file.getName())); 1510 1511 // current folder name 1512 File parent = file.getParentFile(); 1513 if (parent != null && parent.getParent() != null) { 1514 names.add(parent.getName()); 1515 } 1516 1517 return names.toArray(new String[0]); 1518 } 1519 1520 private Pattern getKeywordExcludePattern() { 1521 // collect key information 1522 List<Object> keys = new ArrayList<Object>(); 1523 1524 if (infoObject instanceof Episode || infoObject instanceof Movie) { 1525 keys.add(getName()); 1526 keys.addAll(getAliasNames()); 1527 1528 if (infoObject instanceof Episode) { 1529 for (Episode e : getEpisodes()) { 1530 keys.add(e.getTitle()); 1531 } 1532 } else if (infoObject instanceof Movie) { 1533 keys.add(getMovie().getYear()); 1534 } 1535 } 1536 1537 // word list for exclude pattern 1538 String pattern = keys.stream().filter(Objects::nonNull).map(Objects::toString).map(s -> { 1539 return normalizePunctuation(s, " ", "\\P{Alnum}+"); 1540 }).filter(s -> !s.isEmpty()).collect(joining("|", "\\b(", ")\\b")); 1541 1542 return Pattern.compile(pattern, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CHARACTER_CLASS); 1543 } 1544 1545 private <T> T asType(Object object, Class<T> type) { 1546 if (type.isInstance(object)) { 1547 return type.cast(object); 1548 } 1549 throw new ClassCastException(type.getSimpleName() + " type bindings are not available"); 1550 } 1551 1552 @Override 1553 public String toString() { 1554 return String.format("%s ⇔ %s", infoObject, mediaFile == null ? null : mediaFile.getName()); 1555 } 1556 1557 /** 1558 * Reading MediaInfo can be a very expensive operation, especially if network drives are involved, so we do our best to cache it for a while, because memory is cheap, and time is not. 1559 */ 1560 private static final MemoryCache<FileKey, MediaInfo> mediaInfoCache = MemoryCache.forMinutes(); 1561 1562 /** 1563 * Cache {model} binding value for each context (by object identity) 1564 */ 1565 private static final MemoryCache<Object, List<AssociativeScriptObject>> modelCache = MemoryCache.weak(); 1566 1567}