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