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