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