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