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