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