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 // NOTE: Dolby TrueHD uses Meridian Lossless Packing (MLP) for compressing audio samples 434 // Format/String : MLP FBA 16-ch 435 // Format_Commercial : Dolby TrueHD with Dolby Atmos 436 if (codec.startsWith("MLP FBA") && "Dolby TrueHD with Dolby Atmos".equals(getMediaInfo(StreamKind.Audio, "Format_Commercial"))) { 437 return "TrueHD Atmos"; 438 } 439 440 // take first word (e.g. AAC LC SBR => AAC) 441 // remove punctuation (e.g. AC-3 => AC3) 442 return tokenize(codec).findFirst().map(c -> normalizePunctuation(c, "", "")).orElse(null); 443 } 444 445 @Define("cf") 446 public String getContainerFormat() { 447 // container format extensions (e.g. avi, mkv mka mks, OGG, etc.) 448 String extensions = getMediaInfo(StreamKind.General, "Format/Extensions", "Codec/Extensions", "Format"); 449 450 // get first extension 451 return tokenize(extensions).map(String::toLowerCase).findFirst().get(); 452 } 453 454 @Define("vf") 455 public String getVideoFormat() { 456 int w = Integer.parseInt(getMediaInfo(StreamKind.Video, "Width")); 457 int h = Integer.parseInt(getMediaInfo(StreamKind.Video, "Height")); 458 459 // e.g. 720p, nobody actually wants files to be tagged as interlaced, e.g. 720i 460 return VideoFormat.DEFAULT_GROUPS.guessFormat(w, h) + "p"; 461 } 462 463 @Define("vk") 464 public String getVideoFormatK() { 465 int w = Integer.parseInt(getMediaInfo(StreamKind.Video, "Width")); 466 467 // e.g. 4K, 8K, 16K, etc 468 int k = (int) Math.floor((w / 800f) / 4) * 4; 469 return k >= 4 ? k + "K" : null; 470 } 471 472 @Define("hpi") 473 public String getExactVideoFormat() { 474 String height = getMediaInfo(StreamKind.Video, "Height"); 475 String scanType = getMediaInfo(StreamKind.Video, "ScanType"); 476 477 // progressive by default if ScanType is undefined 478 String p = scanType.codePoints().map(Character::toLowerCase).mapToObj(Character::toChars).map(String::valueOf).findFirst().orElse("p"); 479 480 // e.g. 720p 481 return height + p; 482 } 483 484 @Define("af") 485 public ChannelCount getAudioChannels() { 486 // get first number, e.g. 6ch 487 return getMediaInfo().find(StreamKind.Audio, "Channel(s)_Original", "Channel(s)").map(p -> { 488 // e.g. 15 objects / 6 channels 489 return tokenize(p.value, SLASH).map(s -> matchInteger(s)).filter(Objects::nonNull).min(Integer::compare).map(count -> ChannelCount.count(count, p.streamIndex)).orElse(null); 490 }).filter(Objects::nonNull).max(ChannelCount::compareTo).orElse(null); 491 } 492 493 @Define("channels") 494 public ChannelCount getAudioChannelLayout() { 495 // ChannelLayout : L R / L R C LFE Ls Rs / C L R LFE Lb Rb Lss Rss / Object Based 496 return getMediaInfo().find(StreamKind.Audio, "ChannelLayout_Original", "ChannelLayout").map(p -> { 497 if (p.value.equals("Object Based")) { 498 return null; 499 } 500 // count channel layout (e.g. 2.0 or 5.1) 501 return tokenize(p.value, SPACE).map(c -> { 502 if (c.contains("Objects")) { 503 return null; 504 } 505 if (c.contains("LFE")) { 506 return "0.1"; 507 } 508 return "1"; 509 }).filter(Objects::nonNull).map(BigDecimal::new).reduce(BigDecimal::add).map(count -> ChannelCount.layout(count, p.streamIndex)).orElse(null); 510 }).filter(Objects::nonNull).max(ChannelCount::compareTo).orElseGet(() -> { 511 // e.g. 15 objects / 6 channels 512 ChannelCount count = getAudioChannels(); 513 return count == null ? null : ChannelCount.layout(count.getValue(), count.getStreamIndex()); 514 }); 515 } 516 517 @Define("acf") 518 public String getAudioChannelFormatTag() { 519 ChannelCount channels = getAudioChannelLayout(); 520 if (channels == null) { 521 return null; 522 } 523 // Format_Commercial : Dolby Digital Plus 524 // Format_Commercial : DTS-HD MA + DTS:X 525 // Format_Commercial : DTS-HD Master Audio 526 // Format_Commercial : WMA 527 String format = getMediaInfo().get(StreamKind.Audio, channels.getStreamIndex(), "Format_Commercial"); 528 if (format == null || format.isEmpty()) { 529 return null; 530 } 531 // e.g. Dolby Digital Plus => DDP 532 return format.replace(':', '-').replaceAll("[\\P{Upper}&&[^-+]]", "") + channels; 533 } 534 535 @Define("aco") 536 public String getAudioChannelObjects() { 537 return getMediaInfo().stream(StreamKind.Audio, "Codec_Profile", "Format_Profile", "Format_Commercial").map(s -> { 538 // Format_Commercial: Dolby Digital Plus with Dolby Atmos 539 // Format_Profile: Blu-ray Disc 540 return SLASH.splitAsStream(s).map(String::trim).filter(v -> !v.isEmpty() && !v.contains("Disc")).findFirst().orElse(null); 541 }).filter(Objects::nonNull).findFirst().orElse(null); 542 } 543 544 @Define("resolution") 545 public Resolution getVideoResolution() { 546 // collect value from Video Stream 0 or Image Stream 0 547 return Stream.of(StreamKind.Video, StreamKind.Image).map(k -> { 548 String w = getMediaInfo().getString(k, "Width"); 549 String h = getMediaInfo().getString(k, "Height"); 550 return Resolution.parse(w, h); 551 }).filter(Objects::nonNull).findFirst().orElse(null); 552 } 553 554 @Define("bitdepth") 555 public Integer getBitDepth() { 556 // collect value from Video Stream 0 or Audio Stream 0 or Image Stream 0 557 return Stream.of(StreamKind.Video, StreamKind.Audio, StreamKind.Image).map(k -> { 558 return matchInteger(getMediaInfo().getString(k, "BitDepth")); 559 }).filter(Objects::nonNull).findFirst().orElse(null); 560 } 561 562 @Define("hdr") 563 public String getHighDynamicRange() { 564 // try HDR_Format_Commercial (requires MediaInfo 19.04) 565 return getMediaInfo().find(StreamKind.Video, "HDR_Format", "HDR_Format_Commercial").flatMap(p -> SLASH.splitAsStream(p.value)).filter(s -> { 566 // search for specific HDR type values 567 return s.contains("HDR") || s.contains("Dolby Vision"); 568 }).findFirst().map(hdr -> { 569 // check HDR_Format_Compatibility (requires MediaInfo 24.04) 570 if (hdr.contains("Dolby Vision")) { 571 if (getMediaInfo().find(StreamKind.Video, "HDR_Format_Compatibility").flatMap(p -> SLASH.splitAsStream(p.value)).anyMatch(s -> s.contains("HDR10"))) { 572 return "DV+HDR10"; 573 } else { 574 return "DV"; 575 } 576 } 577 // HDR or HDR10 or HDR10+ 578 return hdr; 579 }).orElseGet(() -> { 580 // generic HDR 581 return getBitDepth() >= 10 && getMediaInfo().stream(StreamKind.Video, "colour_primaries").anyMatch(s -> s.contains("BT.2020")) ? "HDR" : null; 582 }); 583 } 584 585 @Define("dovi") 586 public String getDolbyVision() { 587 return getMediaInfo().find(StreamKind.Video, "HDR_Format", "HDR_Format_Commercial").flatMap(p -> SLASH.splitAsStream(p.value)).filter(s -> { 588 return s.contains("Dolby Vision"); 589 }).findFirst().orElse(null); 590 } 591 592 @Define("ar") 593 public String getAspectRatio() { 594 // e.g. 16:9 or 2.35:1 595 String ratio = getMediaInfo(StreamKind.Video, "DisplayAspectRatio/String"); 596 // use ratio (U+2236) instead of colon (U+003A) 597 return colon(ratio, "∶"); 598 } 599 600 @Define("ws") 601 public String getWidescreen() { 602 // width-to-height aspect ratio greater than 1.37:1 603 float ratio = Float.parseFloat(getMediaInfo(StreamKind.Video, "DisplayAspectRatio")); 604 return ratio > 1.37f ? "WS" : null; 605 } 606 607 @Define("hd") 608 public String getVideoDefinitionCategory() { 609 Resolution resolution = getVideoResolution(); 610 int w = resolution.getWidth(); 611 int h = resolution.getHeight(); 612 613 if (w > 2560 || h > 1440) 614 return "UHD"; 615 if (w > 1920 || h > 1080) 616 return "QHD"; 617 if (w > 1280 || h > 720) 618 return "FHD"; 619 if (w > 1024 || h > 576) 620 return "HD"; 621 622 return "SD"; 623 } 624 625 @Define("width") 626 public Integer getWidth() { 627 return getVideoResolution().getWidth(); 628 } 629 630 @Define("height") 631 public Integer getHeight() { 632 return getVideoResolution().getHeight(); 633 } 634 635 @Define("original") 636 public String getOriginalFileName() { 637 // try xattr file name 638 String originalName = xattr.getOriginalName(getMediaFile()); 639 if (originalName != null) { 640 return FileUtilities.getNameWithoutExtension(originalName); 641 } 642 643 // try historic file name 644 File originalPath = HISTORY.getOriginalPath(getMediaFile()); 645 if (originalPath != null) { 646 return FileUtilities.getName(originalPath); 647 } 648 649 return null; 650 } 651 652 @Define("xattr") 653 public Object getMetaAttributesObject() throws Exception { 654 return xattr.getMetaInfo(getMediaFile()); 655 } 656 657 @Define("crc32") 658 public String getCRC32() throws Exception { 659 // try to get checksum from file name 660 Optional<String> embeddedChecksum = stream(getFileNames(inferredMediaFile.get())).map(Normalization::getEmbeddedChecksum).filter(Objects::nonNull).findFirst(); 661 if (embeddedChecksum.isPresent()) { 662 return embeddedChecksum.get(); 663 } 664 665 // try to get checksum from sfv file 666 String checksum = getHashFromVerificationFile(inferredMediaFile.get(), HashType.SFV, 3); 667 if (checksum != null) { 668 return checksum; 669 } 670 671 // compute and store to xattr 672 return XattrChecksum.CRC32.computeIfAbsent(inferredMediaFile.get()); 673 } 674 675 @Define("fn") 676 public String getFileName() { 677 // name without file extension 678 return FileUtilities.getName(getMediaFile()); 679 } 680 681 @Define("ext") 682 public String getExtension() { 683 // file extension 684 return FileUtilities.getExtension(getMediaFile()); 685 } 686 687 @Define("edition") 688 public String getVideoEdition() throws Exception { 689 // use {edition-XYZ} marker 690 String edition = matchLastOccurrence(getFileName(), Pattern.compile("(?<=[{]edition[-])[^{}]+(?=[}])")); 691 if (edition != null) { 692 return edition; 693 } 694 695 // use first {tags} match 696 return releaseInfo.getVideoTags(mediaTitles.get()).stream().findFirst().orElse(null); 697 } 698 699 @Define("tags") 700 public List<String> getVideoTags() throws Exception { 701 // reduce false positives by removing the know titles from the name 702 Pattern[] nonTagPattern = { getKeywordExcludePattern() }; 703 704 // consider foldername, filename and original filename of inferred media file 705 String[] filenames = stream(mediaTitles.get()).map(s -> releaseInfo.clean(s, nonTagPattern)).filter(s -> !s.isEmpty()).toArray(String[]::new); 706 707 // look for video source patterns in media file and it's parent folder (use inferred media file) 708 return releaseInfo.getVideoTags(filenames); 709 } 710 711 @Define("vs") 712 public String getVideoSource() throws Exception { 713 // look for video source patterns in media file and it's parent folder (use inferred media file) 714 String source = releaseInfo.getVideoSource(mediaTitles.get()).keySet().stream().findFirst().orElse(null); 715 if (source != null) { 716 return source; 717 } 718 // e.g. OriginalSourceMedium : DVD-Video | OriginalSourceMedium : Blu-ray 719 return releaseInfo.getVideoSource(getMediaInfo(StreamKind.Video, "OriginalSourceMedium")).keySet().stream().findFirst().orElse(null); 720 } 721 722 @Define("source") 723 public String getVideoSourceMatch() throws Exception { 724 // look for video source patterns in media file and it's parent folder (use inferred media file) 725 String source = releaseInfo.getVideoSource(mediaTitles.get()).values().stream().findFirst().orElse(null); 726 if (source != null) { 727 return source; 728 } 729 // e.g. OriginalSourceMedium : DVD-Video | OriginalSourceMedium : Blu-ray 730 return releaseInfo.getVideoSource(getMediaInfo(StreamKind.Video, "OriginalSourceMedium")).values().stream().findFirst().orElse(null); 731 } 732 733 @Define("s3d") 734 public String getStereoscopic3D() throws Exception { 735 return releaseInfo.getStereoscopic3D(mediaTitles.get()); 736 } 737 738 @Define("group") 739 public String getReleaseGroup() throws Exception { 740 // reduce false positives by removing the know titles from the name 741 Pattern[] nonGroupPattern = { getKeywordExcludePattern(), releaseInfo.getVideoSourcePattern(), releaseInfo.getVideoFormatPattern(true), releaseInfo.getResolutionPattern() }; 742 743 // consider foldername, filename and original filename of inferred media file 744 String[] filenames = stream(mediaTitles.get()).map(s -> releaseInfo.clean(s, nonGroupPattern)).filter(s -> !s.isEmpty()).toArray(String[]::new); 745 746 // look for release group names in media file and it's parent folder 747 return releaseInfo.getReleaseGroup(filenames); 748 } 749 750 @Define("subt") 751 public String getSubtitleTags() throws Exception { 752 Language language = getLanguageTag(); 753 String category = releaseInfo.getSubtitleCategoryTag(getFileNames(getMediaFile())); 754 755 // Plex only supports ISO 639-2/B language codes 756 if (language != null && category != null) { 757 return '.' + language.getISO3B() + '.' + category; 758 } 759 if (language != null) { 760 return '.' + language.getISO3B(); 761 } 762 if (category != null) { 763 return '.' + category; 764 } 765 return null; 766 } 767 768 @Define("lang") 769 public Language getLanguageTag() throws Exception { 770 File f = getMediaFile(); 771 772 // require subtitle files for language suffix bindings 773 if (!SUBTITLE_FILES.accept(f)) { 774 throw new Exception("Subtitle language tag is only defined for subtitle files"); 775 } 776 777 // grep language from filename 778 String[] fn = getFileNames(f); 779 Locale languageTag = releaseInfo.getSubtitleLanguageTag(fn); 780 if (languageTag != null) { 781 return Language.getLanguage(languageTag); 782 } 783 784 // check if file name matches a language name (e.g. English.srt) 785 Language languageName = stream(fn).map(s -> afterLast(s, '.').orElse(s)).map(Language::findLanguage).filter(Objects::nonNull).findFirst().orElse(null); 786 if (languageName != null) { 787 return languageName; 788 } 789 790 // detect language from subtitle text content 791 return detectSubtitleLanguage(f); 792 } 793 794 @Define("language") 795 public Language getOriginalLanguage() { 796 if (infoObject instanceof Movie) { 797 return Language.forName(getMovieDetails().getOriginalLanguage()); 798 } 799 if (infoObject instanceof Episode) { 800 return Language.forName(getSeriesDetails().getOriginalLanguage()); 801 } 802 return null; 803 } 804 805 @Define("languages") 806 public List<Language> getSpokenLanguages() { 807 if (infoObject instanceof Movie) { 808 List<String> languages = getMovieDetails().getSpokenLanguages(); 809 return languages.stream().map(Language::findLanguage).filter(Objects::nonNull).collect(toList()); 810 } 811 if (infoObject instanceof Episode) { 812 List<String> languages = getSeriesInfo().getSpokenLanguages(); 813 return languages.stream().map(Language::findLanguage).filter(Objects::nonNull).collect(toList()); 814 } 815 return null; 816 } 817 818 @Define("country") 819 public String getOriginCountry() throws Exception { 820 if (infoObject instanceof Movie) { 821 return getMovieDetails().getProductionCountries().stream().findFirst().orElse(null); 822 } 823 if (infoObject instanceof Episode) { 824 return getSeriesDetails().getCountry().stream().findFirst().orElse(null); 825 } 826 return null; 827 } 828 829 @Define("runtime") 830 public Integer getRuntime() throws Exception { 831 if (infoObject instanceof Movie) { 832 return getMovieDetails().getRuntime(); 833 } 834 if (infoObject instanceof Episode) { 835 // use episode-specific runtime if possible 836 // use series-level average episode runtime if necessary 837 return getEpisode().getRuntime() != null ? getEpisode().getRuntime() : getSeriesInfo().getRuntime(); 838 } 839 return null; 840 } 841 842 @Define("actors") 843 public List<String> getActors() throws Exception { 844 if (infoObject instanceof Movie) 845 return getMovieDetails().getActors(); 846 if (infoObject instanceof Episode) 847 return ExtendedMetadataMethods.getActors(getSeriesInfo()); // use TheTVDB API v2 to retrieve actors info 848 849 return null; 850 } 851 852 @Define("genres") 853 public List<String> getGenres() { 854 if (infoObject instanceof Movie) 855 return getMovieDetails().getGenres(); 856 if (infoObject instanceof Episode) 857 return getSeriesInfo().getGenres(); 858 if (infoObject instanceof AudioTrack) 859 return Stream.of(getMusic().getGenre()).filter(Objects::nonNull).flatMap(SEMICOLON::splitAsStream).map(String::trim).filter(s -> !s.isEmpty()).collect(toList()); 860 861 return null; 862 } 863 864 @Define("genre") 865 public String getPrimaryGenre() { 866 List<String> genres = getGenres(); 867 if (genres.size() > 0) { 868 return genres.get(0); 869 } 870 return null; 871 } 872 873 @Define("director") 874 public String getDirector() throws Exception { 875 if (infoObject instanceof Movie) 876 return getMovieDetails().getDirector(); 877 if (infoObject instanceof Episode) 878 return ExtendedMetadataMethods.getInfo(getEpisode()).getDirector(); 879 880 return null; 881 } 882 883 @Define("certification") 884 public String getCertification() { 885 if (infoObject instanceof Movie) 886 return getMovieDetails().getCertification(); 887 if (infoObject instanceof Episode) 888 return getSeriesInfo().getCertification(); 889 890 return null; 891 } 892 893 @Define("rating") 894 public Number getRating() { 895 Number r = null; 896 897 if (infoObject instanceof Movie) 898 r = getMovieDetails().getRating(); 899 else if (infoObject instanceof Episode) 900 r = getSeriesInfo().getRating(); 901 902 // round to 0.1 by default 903 return r == null ? null : round(r, 1); 904 } 905 906 @Define("votes") 907 public Integer getVotes() { 908 if (infoObject instanceof Movie) 909 return getMovieDetails().getVotes(); 910 if (infoObject instanceof Episode) 911 return getSeriesInfo().getRatingCount(); 912 913 return null; 914 } 915 916 @Define("collection") 917 public String getCollection() { 918 if (infoObject instanceof Movie) 919 return getMovieDetails().getCollection(); 920 921 return null; 922 } 923 924 @Define("ci") 925 public Integer getCollectionIndex() throws Exception { 926 return ExtendedMetadataMethods.getCollection(getMovie()).indexOf(getMovie()) + 1; 927 } 928 929 @Define("cy") 930 public List<Integer> getCollectionYears() throws Exception { 931 List<Integer> years = ExtendedMetadataMethods.getCollection(getMovie()).stream().map(Movie::getYear).sorted().distinct().collect(toList()); 932 if (years.size() > 1) { 933 return asList(years.get(0), years.get(years.size() - 1)); 934 } else { 935 return years; 936 } 937 } 938 939 @Define("info") 940 public AssociativeScriptObject getMetaInfo() throws Exception { 941 if (infoObject instanceof Movie) 942 return createPropertyBindings(getMovieDetails()); 943 if (infoObject instanceof Episode) 944 return createPropertyBindings(getSeriesDetails()); 945 946 return null; 947 } 948 949 @Define("omdb") 950 public AssociativeScriptObject getOmdbApiInfo() throws Exception { 951 Integer id = Link.IMDb.parseID(getImdbId()); 952 if (id != null) { 953 return createPropertyBindings(OMDb.getMovieInfo(Movie.IMDB(id))); 954 } 955 return null; 956 } 957 958 @Define("order") 959 public DynamicBindings getSortOrderObject() { 960 return new DynamicBindings(SortOrder::names, k -> { 961 if (infoObject instanceof Episode) { 962 SortOrder order = SortOrder.forName(k); 963 Episode episode = reorderEpisode(getEpisode(), order); 964 return createBindings(episode, null); 965 } 966 return undefined(k); 967 }); 968 } 969 970 @Define("localize") 971 public DynamicBindings getLocalizedInfoObject() { 972 return new DynamicBindings(Language::availableLanguages, k -> { 973 Language language = Language.findLanguage(k); 974 Locale locale = language != null ? language.getLocale() : Locale.forLanguageTag(k); 975 976 if (locale.getLanguage().length() == 2 && locale.getCountry().length() == 2) { 977 if (infoObject instanceof Movie) { 978 Movie movie = TheMovieDB.getMovieDescriptor(getMovie(), locale); 979 return createBindings(movie, null); 980 } 981 if (infoObject instanceof Episode) { 982 Episode episode = fetchEpisode(getEpisode(), null, locale); 983 return createBindings(episode, null); 984 } 985 } 986 987 return undefined(k); 988 }); 989 } 990 991 @Define("db") 992 public DynamicBindings getDatabaseMapper() { 993 SeriesInfo s = getSeriesInfo(); 994 995 if (isInstance(TheMovieDB_TV, s) || isInstance(AniDB, s) || isInstance(TheTVDB, s)) { 996 return new DynamicBindings(AnimeLists.DB::names, k -> { 997 Episode e = getEpisode(); 998 999 AnimeLists.DB source = AnimeLists.DB.get(e); 1000 AnimeLists.DB target = AnimeLists.DB.get(k); 1001 1002 if (source == target) { 1003 return createBindings(e, null); 1004 } 1005 1006 // map TMDB to TVDB 1007 if (source == AnimeLists.DB.TheMovieDB && target == AnimeLists.DB.TheTVDB) { 1008 return createBindings(XDB.map(XDB.TheMovieDB, e, XDB.TheTVDB, TheTVDB), null); 1009 } 1010 1011 // map TVDB to TMDB 1012 if (source == AnimeLists.DB.TheTVDB && target == AnimeLists.DB.TheMovieDB) { 1013 return createBindings(XDB.map(XDB.TheTVDB, e, XDB.TheMovieDB, TheMovieDB_TV), null); 1014 } 1015 1016 // use AnimeList mappings 1017 Episode mapping = AnimeList.map(e, source, target).orElse(null); 1018 if (mapping == null) { 1019 throw new Exception("AniDB mapping not found"); 1020 } 1021 1022 // reload complete episode information based on mapped SxE numbers 1023 return createBindings(hydrateEpisode(mapping, s.getLanguage()), null); 1024 }); 1025 } 1026 1027 return null; 1028 } 1029 1030 @Define("historic") 1031 public AssociativeScriptObject getHistoricBindings() throws Exception { 1032 File originalPath = HISTORY.getOriginalPath(getMediaFile()); 1033 return originalPath == null ? null : createBindings(null, originalPath); 1034 } 1035 1036 @Define("az") 1037 public String getSortInitial() { 1038 try { 1039 return sortInitial(getCollection()); 1040 } catch (Exception e) { 1041 return sortInitial(getName()); 1042 } 1043 } 1044 1045 @Define("decade") 1046 public Integer getDecade() throws Exception { 1047 return (getYear() / 10) * 10; 1048 } 1049 1050 @Define("anime") 1051 public boolean isAnime() throws Exception { 1052 // check database affiliation, genres, keywords, AnimeLists mappings, etc 1053 if (infoObject instanceof Episode) { 1054 SeriesInfo series = getSeriesInfo(); 1055 // check type and database 1056 if (SeriesInfo.TYPE_ANIME.equals(series.getType()) || isInstance(AniDB, series)) { 1057 return true; 1058 } 1059 // check genres and keywords 1060 if (series.getGenres().contains("Anime") || seriesDetails.get().getKeywords().contains("anime")) { 1061 return true; 1062 } 1063 // check AnimeLists mappings 1064 return AnimeList.find(AnimeLists.DB.get(series), series.getId()).findAny().isPresent(); 1065 } 1066 1067 // check keywords or guess based on genre and production country 1068 if (infoObject instanceof Movie) { 1069 MovieDetails m = getMovieDetails(); 1070 return m.getKeywords().contains("anime") || (m.getGenres().contains("Animation") && m.getSpokenLanguages().contains("ja") && m.getProductionCountries().contains("JP")); 1071 } 1072 1073 return false; 1074 } 1075 1076 @Define("regular") 1077 public boolean isRegular() { 1078 return isRegularEpisode(getEpisode()); 1079 } 1080 1081 @Define("sy") 1082 public List<Integer> getSeasonYears() throws Exception { 1083 return fetchEpisodeList(getEpisode()).stream().filter(e -> { 1084 return isRegularEpisode(e) && e.getAirdate() != null && Objects.equals(e.getSeason(), getEpisode().getSeason()); 1085 }).map(e -> e.getAirdate().getYear()).sorted().distinct().collect(toList()); 1086 } 1087 1088 @Define("sc") 1089 public Integer getSeasonCount() throws Exception { 1090 return fetchEpisodeList(getEpisode()).stream().filter(e -> isRegularEpisode(e) && e.getSeason() != null).map(Episode::getSeason).max(Integer::compare).get(); 1091 } 1092 1093 @Define("mediaTitle") 1094 public String getMediaTitle() { 1095 return getVideoCharacteristics().map(MediaCharacteristics::getTitle).orElse(null); 1096 } 1097 1098 @Define("mediaTags") 1099 public Object getMediaTags() { 1100 return getVideoCharacteristics().map(MediaCharacteristics::getMediaTags).orElse(null); 1101 } 1102 1103 @Define("audioLanguages") 1104 public List<Language> getAudioLanguageList() { 1105 return getMediaInfo().stream(StreamKind.Audio, "Language").filter(Objects::nonNull).distinct().map(Language::findLanguage).filter(Objects::nonNull).collect(toList()); 1106 } 1107 1108 @Define("textLanguages") 1109 public List<Language> getTextLanguageList() { 1110 return getMediaInfo().stream(StreamKind.Text, "Language").filter(Objects::nonNull).distinct().map(Language::findLanguage).filter(Objects::nonNull).collect(toList()); 1111 } 1112 1113 @Define("chapters") 1114 public Map<String, String> getChapters() { 1115 return getMediaInfo().list(StreamKind.Menu).stream().map(m -> { 1116 Map<String, String> chapters = new LinkedHashMap<String, String>(); 1117 m.forEach((k, v) -> { 1118 // e.g. 00:00:00.000 1119 if (k.contains(":") && k.contains(".")) { 1120 chapters.putIfAbsent(k, v); 1121 } 1122 }); 1123 return chapters; 1124 }).filter(m -> !m.isEmpty()).findFirst().orElse(null); 1125 } 1126 1127 @Define("bitrate") 1128 public BitRate getOverallBitRate() { 1129 return BitRate.parse(getMediaInfo(StreamKind.General, "OverallBitRate")); 1130 } 1131 1132 @Define("vbr") 1133 public BitRate getVideoBitRate() { 1134 return BitRate.parse(getMediaInfo(StreamKind.Video, "BitRate")); 1135 } 1136 1137 @Define("abr") 1138 public BitRate getAudioBitRate() { 1139 return BitRate.parse(getMediaInfo(StreamKind.Audio, "BitRate")); 1140 } 1141 1142 @Define("kbps") 1143 public BitRate getKiloBytesPerSecond() { 1144 return getOverallBitRate().getKbps(); 1145 } 1146 1147 @Define("mbps") 1148 public BitRate getMegaBytesPerSecond() { 1149 return getOverallBitRate().getMbps(); 1150 } 1151 1152 @Define("fps") 1153 public FrameRate getFrameRate() { 1154 return FrameRate.parse(getMediaInfo(StreamKind.Video, "FrameRate")); 1155 } 1156 1157 @Define("khz") 1158 public String getSamplingRate() { 1159 return getMediaInfo(StreamKind.Audio, "SamplingRate/String"); 1160 } 1161 1162 @Define("duration") 1163 public Duration getDuration() { 1164 // video duration and media duration may be different (e.g. time lapse video) 1165 return Stream.of(StreamKind.Video, StreamKind.Audio, StreamKind.General).map(k -> { 1166 return getMediaInfo().getString(k, "Duration"); 1167 }).filter(Objects::nonNull).map(Double::parseDouble).map(Math::round).map(Duration::ofMillis).findFirst().orElse(null); 1168 } 1169 1170 @Define("seconds") 1171 public long getSeconds() { 1172 return Math.round(getDuration().toMillis() / (double) 1000); 1173 } 1174 1175 @Define("minutes") 1176 public long getMinutes() { 1177 return Math.round(getDuration().toMillis() / (double) (60 * 1000)); 1178 } 1179 1180 @Define("hours") 1181 public String getHours() { 1182 return String.format(Locale.ROOT, "%d∶%02d", getMinutes() / 60, getMinutes() % 60); 1183 } 1184 1185 @Define("media") 1186 public AssociativeScriptObject getGeneralProperties() { 1187 return createMediaInfoBindings(StreamKind.General).findFirst().orElse(null); 1188 } 1189 1190 @Define("video") 1191 public List<AssociativeScriptObject> getVideoPropertiesList() { 1192 return createMediaInfoBindings(StreamKind.Video).collect(toList()); 1193 } 1194 1195 @Define("audio") 1196 public List<AssociativeScriptObject> getAudioPropertiesList() { 1197 return createMediaInfoBindings(StreamKind.Audio).collect(toList()); 1198 } 1199 1200 @Define("text") 1201 public List<AssociativeScriptObject> getTextPropertiesList() { 1202 return createMediaInfoBindings(StreamKind.Text).collect(toList()); 1203 } 1204 1205 @Define("image") 1206 public AssociativeScriptObject getImageProperties() { 1207 return createMediaInfoBindings(StreamKind.Image).findFirst().orElse(null); 1208 } 1209 1210 @Define("menu") 1211 public AssociativeScriptObject getMenuProperties() { 1212 return createMediaInfoBindings(StreamKind.Menu).findFirst().orElse(null); 1213 } 1214 1215 @Define("exif") 1216 public AssociativeScriptObject getImageMetadata() throws Exception { 1217 return new AssociativeScriptObject(getPhoto().snapshot(), this::undefined); 1218 } 1219 1220 @Define("camera") 1221 public AssociativeEnumObject getCamera() throws Exception { 1222 return getPhoto().getCameraModel().map(AssociativeEnumObject::new).orElse(null); 1223 } 1224 1225 @Define("location") 1226 public AssociativeEnumObject getLocation() throws Exception { 1227 return getPhoto().getLocationTaken(GoogleMaps).map(AssociativeEnumObject::new).orElse(null); 1228 } 1229 1230 @Define("medium") 1231 public Integer getMedium() { 1232 Integer index = getMusic().getMedium(); 1233 Integer count = getMusic().getMediumCount(); 1234 1235 // ignore CD 1 of 1 1236 if (index != null && count != null && count > 1) { 1237 return index; 1238 } 1239 return null; 1240 } 1241 1242 @Define("artist") 1243 public String getArtist() { 1244 return getMusic().getArtist(); 1245 } 1246 1247 @Define("albumArtist") 1248 public String getAlbumArtist() { 1249 return getMusic().getAlbumArtist(); 1250 } 1251 1252 @Define("album") 1253 public String getAlbum() { 1254 return getMusic().getAlbum(); 1255 } 1256 1257 @Define("episode") 1258 public Episode getEpisode() { 1259 return asType(infoObject, Episode.class); 1260 } 1261 1262 @Define("episodes") 1263 public List<Episode> getEpisodes() { 1264 return streamMultiEpisode(getEpisode()).collect(toList()); 1265 } 1266 1267 @Define("movie") 1268 public Movie getMovie() { 1269 return asType(infoObject, Movie.class); 1270 } 1271 1272 @Define("music") 1273 public AudioTrack getMusic() { 1274 return asType(infoObject, AudioTrack.class); 1275 } 1276 1277 @Define("photo") 1278 public ImageMetadata getPhoto() throws Exception { 1279 if (infoObject instanceof PhotoFile) { 1280 return ((PhotoFile) infoObject).getMetadata(); 1281 } 1282 return new ImageMetadata(getMediaFile()); 1283 } 1284 1285 @Define("pi") 1286 public Number getPartIndex() throws Exception { 1287 if (infoObject instanceof AudioTrack) { 1288 return getMusic().getTrack(); 1289 } 1290 if (infoObject instanceof Movie) { 1291 return infoObject instanceof MoviePart ? ((MoviePart) infoObject).getPartIndex() : null; 1292 } 1293 // infer part index from context 1294 List<File> group = getDuplicateGroup(inferredMediaFile.get()); 1295 return group.size() > 1 ? identityIndexOf(group, inferredMediaFile.get()) : null; 1296 } 1297 1298 @Deprecated 1299 @Define("pn") 1300 public Number getPartCountNumber() throws Exception { 1301 debug.severe("[DEPRECATED] {pn} is deprecated. Please use {pc} instead."); 1302 return getPartCount(); 1303 } 1304 1305 @Define("pc") 1306 public Number getPartCount() throws Exception { 1307 if (infoObject instanceof AudioTrack) { 1308 return getMusic().getTrackCount(); 1309 } 1310 if (infoObject instanceof Movie) { 1311 return infoObject instanceof MoviePart ? ((MoviePart) infoObject).getPartCount() : null; 1312 } 1313 // infer part count from context 1314 List<File> group = getDuplicateGroup(inferredMediaFile.get()); 1315 return group.size() > 1 ? group.size() : null; 1316 } 1317 1318 @Define("type") 1319 public String getInfoObjectType() { 1320 return infoObject.getClass().getSimpleName(); 1321 } 1322 1323 @Define("mediaFile") 1324 public File getInferredMediaFile() throws Exception { 1325 return inferredMediaFile.get(); 1326 } 1327 1328 @Define("mediaFileName") 1329 public String getInferredMediaFileName() throws Exception { 1330 return FileUtilities.getName(inferredMediaFile.get()); 1331 } 1332 1333 @Define("relativeFile") 1334 public File getRelativeFilePath() { 1335 File f = getMediaFile(); 1336 try { 1337 return getStructurePathTail(getMediaFile()); 1338 } catch (Exception e) { 1339 debug.warning(cause("Failed to detect relative library path", f, e)); 1340 } 1341 return FileUtilities.getRelativePathTail(f, 2); 1342 } 1343 1344 @Define("f") 1345 public File getMediaFile() { 1346 // make sure file is not null, and that it is an existing file 1347 if (mediaFile == null) { 1348 throw new BindingException("file", "undefined", BindingException.Flag.SAMPLE_FILE_NOT_SET); 1349 } 1350 return mediaFile; 1351 } 1352 1353 @Define("folder") 1354 public File getMediaParentFolder() { 1355 return getMediaFile().getParentFile(); 1356 } 1357 1358 @Define("files") 1359 public List<File> files() throws Exception { 1360 File mediaFile = inferredMediaFile.get(); 1361 1362 // list folder 1363 if (mediaFile.isDirectory()) { 1364 return FileUtilities.listFiles(mediaFile, FileUtilities.FILES, FileUtilities.HUMAN_NAME_ORDER); 1365 } 1366 1367 // list archive 1368 if (VFS.hasIndex(mediaFile) && mediaFile.isFile()) { 1369 return VFS.getIndex(mediaFile).stream().map(path -> new File(mediaFile, path.toString())).collect(toList()); 1370 } 1371 1372 // list primary video file and derived files 1373 return FileUtilities.getChildren(mediaFile.getParentFile(), f -> isDerived(f, mediaFile), FileUtilities.HUMAN_NAME_ORDER); 1374 } 1375 1376 @Define("bytes") 1377 public FileSize getFileSize() throws Exception { 1378 // sum size of all files 1379 if (getMediaFile().isDirectory()) { 1380 long totalSize = FileUtilities.listFiles(getMediaFile(), FileUtilities.FILES).stream().mapToLong(File::length).sum(); 1381 return new FileSize(totalSize); 1382 } 1383 1384 // size of inferred media file (e.g. video file size for subtitle file) 1385 long mediaSize = inferredMediaFile.get().length(); 1386 return new FileSize(mediaSize); 1387 } 1388 1389 @Define("megabytes") 1390 public FileSize getFileSizeInMegaBytes() throws Exception { 1391 return getFileSize().getMB(); 1392 } 1393 1394 @Define("gigabytes") 1395 public FileSize getFileSizeInGigaBytes() throws Exception { 1396 return getFileSize().getGB(); 1397 } 1398 1399 @Define("today") 1400 public SimpleDate getToday() { 1401 return SimpleDate.now(); 1402 } 1403 1404 @Define("root") 1405 public File getMediaRootFolder() throws Exception { 1406 return getStructureRoot(getMediaFile()); 1407 } 1408 1409 @Define("drive") 1410 public File getMountPoint() throws Exception { 1411 // find D:/ on Windows or /Volumes/Share on macOS 1412 return FileUtilities.getMountPoint(getMediaFile()); 1413 } 1414 1415 @Define("home") 1416 public File getUserHome() { 1417 return ApplicationFolder.UserHome.getDirectory(); 1418 } 1419 1420 @Define("output") 1421 public File getUserDefinedOutputFolder() throws IOException { 1422 return new File(Settings.getApplicationArguments().output).getCanonicalFile(); 1423 } 1424 1425 @Define("defines") 1426 public Map<String, String> getUserDefinedArguments() throws IOException { 1427 return unmodifiableMap(Settings.getApplicationArguments().defines); 1428 } 1429 1430 @Define("label") 1431 public String getUserDefinedLabel() throws IOException { 1432 return getUserDefinedArguments().entrySet().stream().filter(it -> { 1433 return it.getKey().endsWith("label") && it.getValue() != null && it.getValue().length() > 0; 1434 }).map(it -> it.getValue()).findFirst().orElse(null); 1435 } 1436 1437 @Define("i") 1438 public Number getModelIndex() { 1439 return identityIndexOf(context.values(), getInfoObject()); 1440 } 1441 1442 @Define("di") 1443 public Number getDuplicateIndex() { 1444 List<Object> duplicates = getDuplicateContext(MediaBindingBean::getInfoObject, MediaBindingBean::getExtension).map(MediaBindingBean::getInfoObject).collect(toList()); 1445 return identityIndexOf(duplicates, getInfoObject()); 1446 } 1447 1448 @Define("dc") 1449 public Number getDuplicateCount() { 1450 return getDuplicateContext(MediaBindingBean::getInfoObject, MediaBindingBean::getExtension).count(); 1451 } 1452 1453 @Define("plex") 1454 public StructuredFile getPlexStandardPath() throws Exception { 1455 return getStandardPath(NamingStandard.Plex); 1456 } 1457 1458 @Define("kodi") 1459 public StructuredFile getKodiStandardPath() throws Exception { 1460 return getStandardPath(NamingStandard.Kodi); 1461 } 1462 1463 @Define("emby") 1464 public StructuredFile getEmbyStandardPath() throws Exception { 1465 return getStandardPath(NamingStandard.Emby); 1466 } 1467 1468 @Define("jellyfin") 1469 public StructuredFile getJellyfinStandardPath() throws Exception { 1470 return getStandardPath(NamingStandard.Jellyfin); 1471 } 1472 1473 @Define("self") 1474 public AssociativeScriptObject getSelf() { 1475 return createNullableBindings(infoObject, mediaFile, context); 1476 } 1477 1478 @Define("model") 1479 public List<AssociativeScriptObject> getModel() { 1480 return modelCache.get(context, c -> { 1481 return context.entrySet().stream().map(m -> { 1482 return createNullableBindings(m.getValue(), m.getKey(), context); 1483 }).collect(toList()); 1484 }); 1485 } 1486 1487 @Define("episodelist") 1488 public List<AssociativeScriptObject> getEpisodeList() throws Exception { 1489 // fetch episode list from cache 1490 List<Episode> episodes = fetchEpisodeList(getEpisode()); 1491 1492 // use first episode from the episode list as cache key 1493 Object key = episodes.get(0).getSeriesInfo(); 1494 1495 // cache via context values view object identity 1496 return modelCache.get(key, k -> { 1497 try { 1498 return episodes.stream().map(e -> { 1499 return createNullableBindings(e, null, emptyMap()); 1500 }).collect(toList()); 1501 } catch (Exception e) { 1502 throw new BindingException("episodelist", cause("Failed to retrieve episode list", k, e), e); 1503 } 1504 }); 1505 } 1506 1507 @Define("json") 1508 public String getInfoObjectDump() { 1509 return MetaAttributes.toJson(infoObject, false); 1510 } 1511 1512 @Define("XEM") 1513 public DynamicBindings getXrossEntityMapper() { 1514 return new DynamicBindings(XEM::names, k -> { 1515 if (infoObject instanceof Episode) { 1516 Episode e = getEpisode(); 1517 XEM origin = XEM.forName(e.getSeriesInfo().getDatabase()); 1518 XEM destination = XEM.forName(k); 1519 return origin == destination ? e : origin.map(e, destination); 1520 } 1521 return undefined(k); 1522 }); 1523 } 1524 1525 @Define("AnimeList") 1526 public DynamicBindings getAnimeLists() { 1527 return new DynamicBindings(AnimeLists.DB::names, k -> { 1528 if (infoObject instanceof Episode) { 1529 Episode e = getEpisode(); 1530 AnimeLists.DB source = AnimeLists.DB.get(e); 1531 AnimeLists.DB target = AnimeLists.DB.get(k); 1532 return source == target ? e : AnimeList.map(e, source, target).orElse(null); 1533 } 1534 return undefined(k); 1535 }); 1536 } 1537 1538 public StructuredFile getStandardPath(NamingStandard naming) throws Exception { 1539 StructuredFile path = StructuredFile.of(infoObject, naming); 1540 1541 // add subtitle suffix 1542 if (path != null) { 1543 try { 1544 path = path.suffix(getSubtitleTags()); 1545 } catch (Exception e) { 1546 // ignore => no language tags 1547 } 1548 } 1549 1550 return path; 1551 } 1552 1553 private final Resource<SeriesDetails> seriesDetails = Resource.lazy(() -> ExtendedMetadataMethods.getDetails(getSeriesInfo())); 1554 1555 public SeriesDetails getSeriesDetails() { 1556 try { 1557 return seriesDetails.get(); 1558 } catch (Exception e) { 1559 throw new BindingException("details", cause("Failed to retrieve series details", e), e); 1560 } 1561 } 1562 1563 private final Resource<MovieDetails> movieDetails = Resource.lazy(() -> ExtendedMetadataMethods.getInfo(getMovie())); 1564 1565 public MovieDetails getMovieDetails() { 1566 try { 1567 return movieDetails.get(); 1568 } catch (Exception e) { 1569 throw new BindingException("details", cause("Failed to retrieve movie details", e), e); 1570 } 1571 } 1572 1573 // lazy initialize and then keep in memory 1574 private final Resource<File> inferredMediaFile = Resource.lazy(() -> getInferredMediaFile(getMediaFile())); 1575 1576 private File getInferredMediaFile(File file) { 1577 // primary media file is the video file itself 1578 if (VIDEO_FILES.accept(file)) { 1579 return file; 1580 } 1581 1582 // never infere media info from nearby video files when processing music or photos 1583 if (infoObject instanceof AudioTrack || infoObject instanceof PhotoFile) { 1584 return file; 1585 } 1586 1587 // find primary video file (e.g. a video file with the same base name) 1588 return findPrimaryFile(file, VIDEO_FILES).orElseGet(() -> { 1589 List<File> options = new ArrayList<File>(); 1590 1591 // prefer equal match from current context if possible 1592 if (infoObject instanceof Episode || infoObject instanceof Movie) { 1593 context.forEach((f, i) -> { 1594 if (infoObject.equals(i) && VIDEO_FILES.accept(f)) { 1595 options.add(f); 1596 } 1597 }); 1598 } 1599 1600 // just pick any nearby video file for lack of better options 1601 if (options.isEmpty() && (TEXT_FILES.accept(file) || IMAGE_FILES.accept(file))) { 1602 try { 1603 options.addAll(findSiblingFiles(file, VIDEO_FILES)); 1604 } catch (Exception e) { 1605 debug.warning(cause("Failed to find sibling files", e)); 1606 } 1607 } 1608 1609 // find first best match or default to itself for lack of better options 1610 return findPrimaryFile(file, options).orElse(file); 1611 }); 1612 } 1613 1614 // lazy initialize and then keep in memory 1615 private final Resource<MediaInfoTable> mediaInfo = Resource.lazy(() -> { 1616 // use inferred media file (e.g. actual movie file instead of subtitle file) 1617 try { 1618 return MediaInfoTable.read(inferredMediaFile.get()); 1619 } catch (BindingException e) { 1620 throw e; 1621 } catch (Exception e) { 1622 throw new MediaInfoException(e.getMessage(), e); 1623 } 1624 }); 1625 1626 public MediaInfoTable getMediaInfo() { 1627 try { 1628 return mediaInfo.get(); 1629 } catch (BindingException e) { 1630 throw e; 1631 } catch (Exception e) { 1632 throw new BindingException("media", cause("Failed to read media info", e), e); 1633 } 1634 } 1635 1636 private String getMediaInfo(StreamKind streamKind, String... keys) { 1637 String value = getMediaInfo().stream(streamKind, keys).filter(s -> { 1638 // ignore fake media properties 1639 return s.length() < NamingStandard.TITLE_MAX_LENGTH; 1640 }).findFirst().orElse(null); 1641 1642 return value != null ? value : undefined(streamKind.name() + asList(keys)); 1643 } 1644 1645 private Stream<MediaBindingBean> getDuplicateContext(Function<MediaBindingBean, Object>... key) { 1646 return context.entrySet().stream().filter(e -> e.getKey() != null && e.getValue() != null).map(e -> new MediaBindingBean(e.getValue(), e.getKey())).filter(m -> { 1647 return stream(key).allMatch(k -> Objects.equals(k.apply(this), k.apply(m))); 1648 }); 1649 } 1650 1651 private List<File> getDuplicateGroup(File object) throws Exception { 1652 // use duplicate matches 1653 List<File> files = getDuplicateContext(MediaBindingBean::getInfoObject).map(MediaBindingBean::getFileObject).collect(toList()); 1654 // group by media file characteristics 1655 if (files.size() > 1) { 1656 for (List<File> group : groupByMediaCharacteristics(files)) { 1657 if (identityIndexOf(group, object) != null) { 1658 return group; 1659 } 1660 } 1661 } 1662 return emptyList(); 1663 } 1664 1665 private Integer identityIndexOf(Collection<?> collection, Object object) { 1666 int i = 0; 1667 for (Object item : collection) { 1668 i++; 1669 if (item == object) { 1670 return i; 1671 } 1672 } 1673 return null; 1674 } 1675 1676 private AssociativeScriptObject createBindings(Object info, File file) { 1677 return new AssociativeScriptObject(new ExpressionBindings(new MediaBindingBean(info, file)), this::undefined); 1678 } 1679 1680 private AssociativeScriptObject createNullableBindings(Object info, File file, Map<File, ?> context) { 1681 return new AssociativeScriptObject(new ExpressionBindings(new MediaBindingBean(info, file, context)), property -> null); 1682 } 1683 1684 private AssociativeScriptObject createPropertyBindings(Object object) { 1685 return new AssociativeScriptObject(new PropertyBindings(object), this::undefined); 1686 } 1687 1688 private Stream<AssociativeScriptObject> createMediaInfoBindings(StreamKind kind) { 1689 return getMediaInfo().list(kind).stream().map(m -> new AssociativeScriptObject(m, property -> null)); 1690 } 1691 1692 private final Resource<String[]> mediaTitles = Resource.lazy(() -> { 1693 // try to place embedded media title first 1694 String prime = getVideoCharacteristics().map(MediaCharacteristics::getTitle).orElse(null); 1695 String[] names = getFileNames(inferredMediaFile.get()); 1696 1697 if (prime != null && !prime.isEmpty()) { 1698 return Stream.concat(Stream.of(prime), stream(names)).distinct().toArray(String[]::new); 1699 } 1700 1701 // default to file name and xattr 1702 return names; 1703 }); 1704 1705 private Optional<MediaCharacteristics> getVideoCharacteristics() { 1706 try { 1707 switch (getMediaCharacteristicsParser()) { 1708 case mediainfo: 1709 return Optional.of(getMediaInfo()); 1710 case none: 1711 return Optional.empty(); 1712 default: 1713 return getMediaCharacteristics(inferredMediaFile.get()); 1714 } 1715 } catch (Exception e) { 1716 debug.finest(cause("Failed to read video characteristics", e)); 1717 } 1718 return Optional.empty(); 1719 } 1720 1721 private String[] getFileNames(File file) { 1722 List<String> names = new ArrayList<String>(); 1723 1724 // original file name via xattr 1725 String original = xattr.getOriginalName(file); 1726 if (original != null) { 1727 names.add(FileUtilities.getNameWithoutExtension(original)); 1728 } 1729 1730 // current file name 1731 names.add(FileUtilities.getNameWithoutExtension(file.getName())); 1732 1733 // current folder name 1734 File parent = file.getParentFile(); 1735 if (parent != null && parent.getParent() != null) { 1736 names.add(parent.getName()); 1737 } 1738 1739 return names.toArray(new String[0]); 1740 } 1741 1742 private Pattern getKeywordExcludePattern() { 1743 // collect key information 1744 List<Object> keys = new ArrayList<Object>(); 1745 1746 if (infoObject instanceof Episode || infoObject instanceof Movie) { 1747 keys.add(getName()); 1748 keys.addAll(getAliasNames()); 1749 1750 if (infoObject instanceof Episode) { 1751 for (Episode e : getEpisodes()) { 1752 keys.add(e.getTitle()); 1753 } 1754 } else if (infoObject instanceof Movie) { 1755 keys.add(getMovie().getYear()); 1756 } 1757 } 1758 1759 // word list for exclude pattern 1760 String pattern = keys.stream().filter(Objects::nonNull).map(Objects::toString).map(s -> { 1761 return normalizePunctuation(s, " ", "\\P{Alnum}+"); 1762 }).filter(s -> !s.isEmpty()).collect(joining("|", "\\b(?:", ")\\b")); 1763 1764 return Pattern.compile(pattern, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CHARACTER_CLASS); 1765 } 1766 1767 private <T> T asType(Object object, Class<T> type) { 1768 if (type.isInstance(object)) { 1769 return type.cast(object); 1770 } 1771 throw new ClassCastException(type.getSimpleName() + " type bindings are not available for " + object.getClass().getSimpleName() + " type objects"); 1772 } 1773 1774 @Override 1775 public String toString() { 1776 return String.format(Locale.ROOT, "%s ⇔ %s", infoObject, mediaFile == null ? null : mediaFile.getName()); 1777 } 1778 1779 /** 1780 * Cache {model} binding value for each context (by object identity) 1781 */ 1782 private static final MemoryCache<Object, List<AssociativeScriptObject>> modelCache = MemoryCache.weak(); 1783 1784}