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