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