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