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