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.Logging.*;
007import static net.filebot.MediaTypes.*;
008import static net.filebot.WebServices.*;
009import static net.filebot.format.Define.*;
010import static net.filebot.format.ExpressionFormatMethods.*;
011import static net.filebot.hash.VerificationUtilities.*;
012import static net.filebot.media.MediaDetection.*;
013import static net.filebot.media.XattrMetaInfo.*;
014import static net.filebot.similarity.Normalization.*;
015import static net.filebot.subtitle.SubtitleUtilities.*;
016import static net.filebot.util.FileUtilities.*;
017import static net.filebot.util.RegularExpressions.*;
018import static net.filebot.util.StringUtilities.*;
019import static net.filebot.web.EpisodeUtilities.*;
020
021import java.io.File;
022import java.io.IOException;
023import java.math.BigDecimal;
024import java.math.RoundingMode;
025import java.time.Duration;
026import java.time.Instant;
027import java.time.LocalDateTime;
028import java.time.ZoneOffset;
029import java.time.ZonedDateTime;
030import java.time.temporal.ChronoUnit;
031import java.util.ArrayList;
032import java.util.Iterator;
033import java.util.List;
034import java.util.Locale;
035import java.util.Map;
036import java.util.Map.Entry;
037import java.util.Objects;
038import java.util.Optional;
039import java.util.concurrent.TimeUnit;
040import java.util.function.Function;
041import java.util.regex.Pattern;
042import java.util.stream.IntStream;
043import java.util.stream.Stream;
044
045import com.github.benmanes.caffeine.cache.Cache;
046import com.github.benmanes.caffeine.cache.Caffeine;
047
048import net.filebot.ApplicationFolder;
049import net.filebot.Language;
050import net.filebot.MediaTypes;
051import net.filebot.Resource;
052import net.filebot.Settings;
053import net.filebot.hash.HashType;
054import net.filebot.media.FFProbe;
055import net.filebot.media.ImageMetadata;
056import net.filebot.media.LocalDatasource.PhotoFile;
057import net.filebot.media.MetaAttributes;
058import net.filebot.media.NamingStandard;
059import net.filebot.media.VideoFormat;
060import net.filebot.media.XattrChecksum;
061import net.filebot.mediainfo.MediaInfo;
062import net.filebot.mediainfo.MediaInfo.StreamKind;
063import net.filebot.mediainfo.MediaInfoException;
064import net.filebot.similarity.Normalization;
065import net.filebot.similarity.SimilarityComparator;
066import net.filebot.util.FileUtilities;
067import net.filebot.web.AudioTrack;
068import net.filebot.web.Episode;
069import net.filebot.web.EpisodeFormat;
070import net.filebot.web.Movie;
071import net.filebot.web.MovieInfo;
072import net.filebot.web.MoviePart;
073import net.filebot.web.MultiEpisode;
074import net.filebot.web.SeriesInfo;
075import net.filebot.web.SimpleDate;
076import net.filebot.web.SortOrder;
077import net.filebot.web.TheTVDBSeriesInfo;
078
079public class MediaBindingBean {
080
081        private final Object infoObject;
082        private final File mediaFile;
083        private final Map<File, ?> context;
084
085        public MediaBindingBean(Object infoObject, File mediaFile) {
086                this(infoObject, mediaFile, null);
087        }
088
089        public MediaBindingBean(Object infoObject, File mediaFile, Map<File, ?> context) {
090                this.infoObject = infoObject;
091                this.mediaFile = mediaFile;
092                this.context = context;
093        }
094
095        @Define("object")
096        public Object getInfoObject() {
097                return infoObject;
098        }
099
100        @Define("file")
101        public File getFileObject() {
102                return mediaFile;
103        }
104
105        @Define(undefined)
106        public <T> T undefined(String name) {
107                // omit expressions that depend on undefined values
108                throw new BindingException(name, EXCEPTION_UNDEFINED);
109        }
110
111        @Define("n")
112        public String getName() {
113                if (infoObject instanceof Episode)
114                        return getEpisode().getSeriesName();
115                else if (infoObject instanceof Movie)
116                        return getMovie().getName();
117                else if (infoObject instanceof AudioTrack)
118                        return getAlbumArtist() != null ? getAlbumArtist() : getArtist();
119                else if (infoObject instanceof File)
120                        return FileUtilities.getName((File) infoObject);
121
122                return null;
123        }
124
125        @Define("y")
126        public Integer getYear() throws Exception {
127                if (infoObject instanceof Episode)
128                        return getStartDate().getYear();
129                if (infoObject instanceof Movie)
130                        return getMovie().getYear();
131
132                return getReleaseDate().getYear();
133        }
134
135        @Define("ny")
136        public String getNameWithYear() {
137                String n = getName();
138                try {
139                        // account for TV Shows that contain the year in the series name, e.g. Doctor Who (2005)
140                        String y = String.format(" (%d)", getYear());
141                        return n.endsWith(y) ? n : n + y;
142                } catch (Exception e) {
143                        // default to {n} if {y} is undefined
144                }
145                return n;
146        }
147
148        @Define("s")
149        public Integer getSeasonNumber() {
150                // look up season numbers via TheTVDB for AniDB episode data
151                if (isAnime(getEpisode())) {
152                        return getSeasonEpisode().getSeason();
153                }
154
155                return getEpisode().getSeason();
156        }
157
158        @Define("e")
159        public Integer getEpisodeNumber() {
160                return getEpisode().getEpisode();
161        }
162
163        @Define("es")
164        public List<Integer> getEpisodeNumbers() {
165                return getEpisodes().stream().map(it -> {
166                        return it.getEpisode() == null ? it.getSpecial() == null ? null : it.getSpecial() : it.getEpisode();
167                }).filter(Objects::nonNull).collect(toList());
168        }
169
170        @Define("e00")
171        public String getE00() {
172                if (isRegularEpisode())
173                        return getEpisodeNumbers().stream().map(i -> String.format("%02d", i)).collect(joining("-"));
174                else
175                        return "Special " + join(getEpisodeNumbers(), "-");
176        }
177
178        @Define("sxe")
179        public String getSxE() {
180                return EpisodeFormat.SeasonEpisode.formatSxE(getSeasonEpisode()); // try to convert absolute numbers to SxE numbers
181        }
182
183        @Define("s00e00")
184        public String getS00E00() {
185                return EpisodeFormat.SeasonEpisode.formatS00E00(getSeasonEpisode()); // try to convert absolute numbers to SxE numbers
186        }
187
188        @Define("t")
189        public String getTitle() {
190                String t = null;
191
192                if (infoObject instanceof Episode) {
193                        t = infoObject instanceof MultiEpisode ? EpisodeFormat.SeasonEpisode.formatMultiTitle(getEpisodes()) : getEpisode().getTitle(); // implicit support for multi-episode title formatting
194                } else if (infoObject instanceof Movie) {
195                        t = getMovieInfo().getTagline();
196                } else if (infoObject instanceof AudioTrack) {
197                        t = getMusic().getTrackTitle() != null ? getMusic().getTrackTitle() : getMusic().getTitle();
198                }
199
200                // enforce title length limit by default
201                return truncateText(t, NamingStandard.TITLE_MAX_LENGTH);
202        }
203
204        @Define("d")
205        public SimpleDate getReleaseDate() {
206                if (infoObject instanceof Episode)
207                        return getEpisode().getAirdate();
208                if (infoObject instanceof Movie)
209                        return getMovieInfo().getReleased();
210                if (infoObject instanceof AudioTrack)
211                        return getMusic().getAlbumReleaseDate();
212                if (infoObject instanceof File)
213                        return new SimpleDate(getTimeStamp());
214
215                return null;
216        }
217
218        @Define("dt")
219        public ZonedDateTime getTimeStamp() {
220                File f = getMediaFile();
221
222                // try EXIF Date-Taken for image files or File Last-Modified for generic files
223                try {
224                        return getPhoto().getDateTaken().get();
225                } catch (Exception e) {
226                        // ignore and default to file creation date
227                }
228
229                try {
230                        return Instant.ofEpochMilli(getCreationDate(f)).atZone(ZoneOffset.systemDefault());
231                } catch (Exception e) {
232                        debug.warning(e::toString);
233                }
234
235                return null;
236        }
237
238        @Define("airdate")
239        public SimpleDate getAirdate() {
240                return getEpisode().getAirdate();
241        }
242
243        @Define("age")
244        public Long getAgeInDays() throws Exception {
245                SimpleDate releaseDate = getReleaseDate();
246
247                if (releaseDate != null) {
248                        // avoid time zone issues by interpreting all dates and times as UTC
249                        long days = ChronoUnit.DAYS.between(releaseDate.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant(), Instant.now());
250
251                        if (days >= 0) {
252                                return days;
253                        }
254                }
255
256                return null;
257        }
258
259        @Define("startdate")
260        public SimpleDate getStartDate() throws Exception {
261                SimpleDate startdate = getEpisode().getSeriesInfo().getStartDate();
262
263                // use series metadata startdate if possible
264                if (startdate != null) {
265                        return startdate;
266                }
267
268                // access episode list and find minimum airdate if necessary
269                return getEpisodeList().stream().filter(e -> isRegular(e)).map(Episode::getAirdate).filter(Objects::nonNull).min(SimpleDate::compareTo).get();
270        }
271
272        @Define("absolute")
273        public Integer getAbsoluteEpisodeNumber() {
274                return getEpisode().getAbsolute();
275        }
276
277        @Define("special")
278        public Integer getSpecialNumber() {
279                return getEpisode().getSpecial();
280        }
281
282        @Define("series")
283        public SeriesInfo getSeriesInfo() {
284                return getEpisode().getSeriesInfo();
285        }
286
287        @Define("alias")
288        public List<String> getAliasNames() {
289                if (infoObject instanceof Movie)
290                        return asList(getMovie().getAliasNames());
291                if (infoObject instanceof Episode)
292                        return getSeriesInfo().getAliasNames();
293
294                return null;
295        }
296
297        @Define("primaryTitle")
298        public String getPrimaryTitle() {
299                if (infoObject instanceof Movie)
300                        return getPrimaryMovieInfo().getOriginalName();
301                if (infoObject instanceof Episode)
302                        return getPrimarySeriesInfo().getName(); // force English series name for TheTVDB data or default to SeriesInfo name (for AniDB episode data this would be the primary title)
303
304                return null;
305        }
306
307        @Define("id")
308        public Object getId() throws Exception {
309                if (infoObject instanceof Episode)
310                        return getEpisode().getSeriesInfo().getId();
311                if (infoObject instanceof Movie)
312                        return getMovie().getId();
313                if (infoObject instanceof AudioTrack)
314                        return getMusic().getMBID();
315
316                return null;
317        }
318
319        @Define("tmdbid")
320        public String getTmdbId() {
321                if (getMovie().getTmdbId() > 0)
322                        return String.valueOf(getMovie().getTmdbId());
323                if (getMovie().getImdbId() > 0)
324                        return getPrimaryMovieInfo().getId().toString(); // lookup IMDbID for TMDbID
325
326                return null;
327        }
328
329        @Define("imdbid")
330        public String getImdbId() {
331                if (getMovie().getImdbId() > 0)
332                        return String.format("tt%07d", getMovie().getImdbId());
333                if (getMovie().getTmdbId() > 0)
334                        return String.format("tt%07d", getPrimaryMovieInfo().getImdbId()); // lookup IMDbID for TMDbID
335
336                return null;
337        }
338
339        @Define("vc")
340        public String getVideoCodec() {
341                // e.g. XviD, x264, DivX 5, MPEG-4 Visual, AVC, etc.
342                String codec = getMediaInfo(StreamKind.Video, 0, "Encoded_Library_Name", "Encoded_Library/Name", "CodecID/Hint", "Format");
343
344                // get first token (e.g. DivX 5 => DivX)
345                return tokenize(codec).findFirst().orElse(null);
346        }
347
348        @Define("ac")
349        public String getAudioCodec() {
350                // e.g. AC-3, DTS, AAC, Vorbis, MP3, etc.
351                String codec = getMediaInfo(StreamKind.Audio, 0, "CodecID/Hint", "Format");
352
353                // remove punctuation (e.g. AC-3 => AC3)
354                return normalizePunctuation(codec, "", "");
355        }
356
357        @Define("cf")
358        public String getContainerFormat() {
359                // container format extensions (e.g. avi, mkv mka mks, OGG, etc.)
360                String extensions = getMediaInfo(StreamKind.General, 0, "Codec/Extensions", "Format");
361
362                // get first extension
363                return tokenize(extensions).map(String::toLowerCase).findFirst().get();
364        }
365
366        @Define("vf")
367        public String getVideoFormat() {
368                int w = Integer.parseInt(getMediaInfo(StreamKind.Video, 0, "Width"));
369                int h = Integer.parseInt(getMediaInfo(StreamKind.Video, 0, "Height"));
370
371                // e.g. 720p, nobody actually wants files to be tagged as interlaced, e.g. 720i
372                return String.format("%dp", VideoFormat.DEFAULT_GROUPS.guessFormat(w, h));
373        }
374
375        @Define("hpi")
376        public String getExactVideoFormat() {
377                String height = getMediaInfo(StreamKind.Video, 0, "Height");
378                String scanType = getMediaInfo(StreamKind.Video, 0, "ScanType");
379
380                // progressive by default if ScanType is undefined
381                String p = scanType.codePoints().map(Character::toLowerCase).mapToObj(Character::toChars).map(String::valueOf).findFirst().orElse("p");
382
383                // e.g. 720p
384                return height + p;
385        }
386
387        @Define("af")
388        public String getAudioChannels() {
389                String channels = getMediaInfo(StreamKind.Audio, 0, "Channel(s)_Original", "Channel(s)");
390
391                // e.g. 15 objects / 6 channels
392                int ch = tokenize(channels, SLASH).map(s -> matchInteger(s)).filter(Objects::nonNull).min(Integer::compare).get();
393
394                // get first number, e.g. 6ch
395                return ch + "ch";
396        }
397
398        @Define("channels")
399        public String getAudioChannelPositions() {
400                String channels = getMediaInfo(StreamKind.Audio, 0, "ChannelPositions/String2", "Channel(s)_Original", "Channel(s)");
401
402                // e.g. ChannelPositions/String2: 3/2/2.1 / 3/2/0.1 (one audio stream may contain multiple multi-channel streams)
403                double d = tokenize(channels).mapToDouble(s -> {
404                        try {
405                                return tokenize(s, SLASH).mapToDouble(Double::parseDouble).reduce(0, (a, b) -> a + b);
406                        } catch (NumberFormatException e) {
407                                return 0;
408                        }
409                }).filter(it -> it > 0).max().getAsDouble();
410
411                return BigDecimal.valueOf(d).setScale(1, RoundingMode.HALF_UP).toPlainString();
412        }
413
414        @Define("aco")
415        public String getAudioChannelObjects() {
416                return getMediaInfo(StreamKind.Audio, "Codec_Profile", "Format_Profile", "Format_Commercial").filter(Objects::nonNull).map(s -> {
417                        return SLASH.splitAsStream(s).findFirst().orElse(null);
418                }).filter(Objects::nonNull).map(String::trim).filter(s -> s.length() > 0).findFirst().orElse(null);
419        }
420
421        @Define("resolution")
422        public String getVideoResolution() {
423                return join(getDimension(), "x"); // e.g. 1280x720
424        }
425
426        @Define("bitdepth")
427        public int getVideoBitDepth() {
428                String bitdepth = getMediaInfo(StreamKind.Video, 0, "BitDepth");
429
430                return Integer.parseInt(bitdepth);
431        }
432
433        @Define("hdr")
434        public String getHighDynamicRange() {
435                if (getVideoBitDepth() >= 10 && getMediaInfo(StreamKind.Video, 0, "colour_primaries").matches("BT.2020")) {
436                        return "HDR";
437                }
438                return null;
439        }
440
441        @Define("ws")
442        public String getWidescreen() {
443                List<Integer> dim = getDimension();
444
445                // width-to-height aspect ratio greater than 1.37:1
446                return (float) dim.get(0) / dim.get(1) > 1.37f ? "WS" : null;
447        }
448
449        @Define("hd")
450        public String getVideoDefinitionCategory() {
451                List<Integer> dim = getDimension();
452
453                // UHD
454                if (dim.get(0) >= 3840 || dim.get(1) >= 2160)
455                        return "UHD";
456
457                // HD
458                if (dim.get(0) >= 1280 || dim.get(1) >= 720)
459                        return "HD";
460
461                // SD
462                return "SD";
463        }
464
465        @Define("dim")
466        public List<Integer> getDimension() {
467                // collect value from Video Stream 0 or Image Stream 0
468                return Stream.of(StreamKind.Video, StreamKind.Image).map(k -> {
469                        // collect Width and Height as Integer List
470                        return Stream.of("Width", "Height").map(p -> getMediaInfo().get(k, 0, p)).filter(s -> s.length() > 0).map(Integer::parseInt).collect(toList());
471                }).filter(d -> d.size() == 2).findFirst().orElse(null);
472        }
473
474        @Define("width")
475        public Integer getWidth() {
476                return getDimension().get(0);
477        }
478
479        @Define("height")
480        public Integer getHeight() {
481                return getDimension().get(1);
482        }
483
484        @Define("original")
485        public String getOriginalFileName() {
486                String name = xattr.getOriginalName(getMediaFile());
487
488                return name != null ? getNameWithoutExtension(name) : null;
489        }
490
491        @Define("xattr")
492        public Object getMetaAttributesObject() throws Exception {
493                return xattr.getMetaInfo(getMediaFile());
494        }
495
496        @Define("crc32")
497        public String getCRC32() throws Exception {
498                // use inferred media file
499                File inferredMediaFile = getInferredMediaFile();
500
501                // try to get checksum from file name
502                Optional<String> embeddedChecksum = stream(getFileNames(inferredMediaFile)).map(Normalization::getEmbeddedChecksum).filter(Objects::nonNull).findFirst();
503                if (embeddedChecksum.isPresent()) {
504                        return embeddedChecksum.get();
505                }
506
507                // try to get checksum from sfv file
508                String checksum = getHashFromVerificationFile(inferredMediaFile, HashType.SFV, 3);
509                if (checksum != null) {
510                        return checksum;
511                }
512
513                // compute and store to xattr
514                return XattrChecksum.CRC32.computeIfAbsent(inferredMediaFile);
515        }
516
517        @Define("fn")
518        public String getFileName() {
519                // name without file extension
520                return FileUtilities.getName(getMediaFile());
521        }
522
523        @Define("ext")
524        public String getExtension() {
525                // file extension
526                return FileUtilities.getExtension(getMediaFile());
527        }
528
529        @Define("source")
530        public String getVideoSource() throws Exception {
531                // look for video source patterns in media file and it's parent folder (use inferred media file)
532                return releaseInfo.getVideoSource(mediaTitles.get());
533        }
534
535        @Define("tags")
536        public List<String> getVideoTags() throws Exception {
537                // look for video source patterns in media file and it's parent folder (use inferred media file)
538                List<String> matches = releaseInfo.getVideoTags(mediaTitles.get());
539                if (matches.isEmpty()) {
540                        return null;
541                }
542
543                // heavy normalization for whatever text was captured with the tags pattern
544                return matches.stream().map(s -> {
545                        return lowerTrail(upperInitial(normalizePunctuation(s)));
546                }).sorted().distinct().collect(toList());
547        }
548
549        @Define("s3d")
550        public String getStereoscopic3D() throws Exception {
551                return releaseInfo.getStereoscopic3D(mediaTitles.get());
552        }
553
554        @Define("group")
555        public String getReleaseGroup() throws Exception {
556                // reduce false positives by removing the know titles from the name
557                Pattern[] nonGroupPattern = { getKeywordExcludePattern(), releaseInfo.getVideoSourcePattern(), releaseInfo.getVideoFormatPattern(true), releaseInfo.getResolutionPattern(), releaseInfo.getStructureRootPattern() };
558
559                // consider foldername, filename and original filename of inferred media file
560                String[] filenames = stream(mediaTitles.get()).map(s -> releaseInfo.clean(s, nonGroupPattern)).filter(s -> s.length() > 0).toArray(String[]::new);
561
562                // look for release group names in media file and it's parent folder
563                return releaseInfo.getReleaseGroup(filenames);
564        }
565
566        @Define("subt")
567        public String getSubtitleTags() throws Exception {
568                if (!SUBTITLE_FILES.accept(getMediaFile())) {
569                        return null;
570                }
571
572                Language language = getLanguageTag();
573                if (language != null) {
574                        String tag = '.' + language.getISO3B(); // Plex only supports ISO 639-2/B language codes
575                        String category = releaseInfo.getSubtitleCategoryTag(getFileNames(getMediaFile()));
576                        if (category != null) {
577                                return tag + '.' + category;
578                        }
579                        return tag;
580                }
581
582                return null;
583        }
584
585        @Define("lang")
586        public Language getLanguageTag() throws Exception {
587                // grep language from filename
588                Locale languageTag = releaseInfo.getSubtitleLanguageTag(getFileNames(getMediaFile()));
589                if (languageTag != null) {
590                        return Language.getLanguage(languageTag);
591                }
592
593                // detect language from subtitle text content
594                if (SUBTITLE_FILES.accept(getMediaFile())) {
595                        try {
596                                return detectSubtitleLanguage(getMediaFile());
597                        } catch (Exception e) {
598                                throw new RuntimeException("Failed to detect subtitle language: " + e, e);
599                        }
600                }
601
602                return null;
603        }
604
605        @Define("languages")
606        public List<Language> getSpokenLanguages() {
607                if (infoObject instanceof Movie) {
608                        List<Locale> languages = getMovieInfo().getSpokenLanguages();
609                        return languages.stream().map(Language::getLanguage).filter(Objects::nonNull).collect(toList());
610                }
611                if (infoObject instanceof Episode) {
612                        String language = getSeriesInfo().getLanguage();
613                        return Stream.of(language).map(Language::findLanguage).filter(Objects::nonNull).collect(toList());
614                }
615                return null;
616        }
617
618        @Define("runtime")
619        public Integer getRuntime() {
620                if (infoObject instanceof Movie)
621                        return getMovieInfo().getRuntime();
622                if (infoObject instanceof Episode)
623                        return getSeriesInfo().getRuntime();
624
625                return null;
626        }
627
628        @Define("actors")
629        public List<String> getActors() throws Exception {
630                if (infoObject instanceof Movie)
631                        return getMovieInfo().getActors();
632                if (infoObject instanceof Episode)
633                        return ExpressionFormatMethods.getActors(getSeriesInfo()); // use TheTVDB API v2 to retrieve actors info
634
635                return null;
636        }
637
638        @Define("genres")
639        public List<String> getGenres() {
640                if (infoObject instanceof Movie)
641                        return getMovieInfo().getGenres();
642                if (infoObject instanceof Episode)
643                        return getSeriesInfo().getGenres();
644                if (infoObject instanceof AudioTrack)
645                        return Stream.of(getMusic().getGenre()).filter(Objects::nonNull).flatMap(SEMICOLON::splitAsStream).map(String::trim).filter(s -> s.length() > 0).collect(toList());
646
647                return null;
648        }
649
650        @Define("genre")
651        public String getPrimaryGenre() {
652                return getGenres().iterator().next();
653        }
654
655        @Define("director")
656        public String getDirector() throws Exception {
657                if (infoObject instanceof Movie)
658                        return getMovieInfo().getDirector();
659                if (infoObject instanceof Episode)
660                        return ExpressionFormatMethods.getInfo(getEpisode()).getDirector(); // use TheTVDB API v2 to retrieve extended episode info
661
662                return null;
663        }
664
665        @Define("certification")
666        public String getCertification() {
667                if (infoObject instanceof Movie)
668                        return getMovieInfo().getCertification();
669                if (infoObject instanceof Episode)
670                        return getSeriesInfo().getCertification();
671
672                return null;
673        }
674
675        @Define("rating")
676        public Double getRating() {
677                if (infoObject instanceof Movie)
678                        return getMovieInfo().getRating();
679                if (infoObject instanceof Episode)
680                        return getSeriesInfo().getRating();
681
682                return null;
683        }
684
685        @Define("votes")
686        public Integer getVotes() {
687                if (infoObject instanceof Movie)
688                        return getMovieInfo().getVotes();
689                if (infoObject instanceof Episode)
690                        return getSeriesInfo().getRatingCount();
691
692                return null;
693        }
694
695        @Define("collection")
696        public String getCollection() {
697                if (infoObject instanceof Movie)
698                        return getMovieInfo().getCollection();
699
700                return null;
701        }
702
703        @Define("ci")
704        public Integer getCollectionIndex() throws Exception {
705                if (infoObject instanceof Movie && getMovie().getTmdbId() > 0)
706                        return TheMovieDB.getCollection(getMovie().getTmdbId(), Locale.US).indexOf(getMovie()) + 1;
707
708                return null;
709        }
710
711        @Define("info")
712        public synchronized AssociativeScriptObject getMetaInfo() {
713                if (infoObject instanceof Movie)
714                        return createPropertyBindings(getMovieInfo());
715                if (infoObject instanceof Episode)
716                        return createPropertyBindings(getSeriesInfo());
717
718                return null;
719        }
720
721        @Define("omdb")
722        public synchronized AssociativeScriptObject getOmdbApiInfo() throws Exception {
723                if (infoObject instanceof Movie) {
724                        if (getMovie().getImdbId() > 0) {
725                                return createPropertyBindings(OMDb.getMovieInfo(getMovie()));
726                        }
727                        if (getMovie().getTmdbId() > 0) {
728                                Integer imdbId = getPrimaryMovieInfo().getImdbId();
729                                return createPropertyBindings(OMDb.getMovieInfo(new Movie(imdbId)));
730                        }
731                }
732
733                if (infoObject instanceof Episode) {
734                        TheTVDBSeriesInfo info = (TheTVDBSeriesInfo) getPrimarySeriesInfo();
735                        int imdbId = matchInteger(info.getImdbId());
736                        return createPropertyBindings(OMDb.getMovieInfo(new Movie(imdbId)));
737                }
738
739                return null;
740        }
741
742        @Define("order")
743        public DynamicBindings getSortOrderObject() {
744                return new DynamicBindings(SortOrder::names, k -> {
745                        if (infoObject instanceof Episode) {
746                                SortOrder order = SortOrder.forName(k);
747                                Episode episode = fetchEpisode(getEpisode(), order, null);
748                                return createBindingObject(episode);
749                        }
750                        return undefined(k);
751                });
752        }
753
754        @Define("localize")
755        public DynamicBindings getLocalizedInfoObject() {
756                return new DynamicBindings(Language::availableLanguages, k -> {
757                        Language language = Language.findLanguage(k);
758
759                        if (language != null && infoObject instanceof Movie) {
760                                Movie movie = TheMovieDB.getMovieDescriptor(getMovie(), language.getLocale());
761                                return createBindingObject(movie);
762                        }
763
764                        if (language != null && infoObject instanceof Episode) {
765                                Episode episode = fetchEpisode(getEpisode(), null, language.getLocale());
766                                return createBindingObject(episode);
767                        }
768
769                        return undefined(k);
770                });
771        }
772
773        @Define("az")
774        public String getSortInitial() {
775                try {
776                        return sortInitial(getCollection());
777                } catch (Exception e) {
778                        return sortInitial(getName());
779                }
780        }
781
782        @Define("anime")
783        public boolean isAnimeEpisode() {
784                return getEpisodes().stream().anyMatch(it -> isAnime(it));
785        }
786
787        @Define("regular")
788        public boolean isRegularEpisode() {
789                return getEpisodes().stream().anyMatch(it -> isRegular(it));
790        }
791
792        @Define("episodelist")
793        public List<Episode> getEpisodeList() throws Exception {
794                return fetchEpisodeList(getEpisode());
795        }
796
797        @Define("sy")
798        public List<Integer> getSeasonYears() throws Exception {
799                return getEpisodeList().stream().filter(e -> isRegular(e) && e.getSeason().equals(getSeasonNumber()) && e.getAirdate() != null).map(e -> e.getAirdate().getYear()).sorted().distinct().collect(toList());
800        }
801
802        @Define("sc")
803        public Integer getSeasonCount() throws Exception {
804                return getEpisodeList().stream().filter(e -> isRegular(e) && e.getSeason() != null).map(Episode::getSeason).max(Integer::compare).get();
805        }
806
807        @Define("mediaTitle")
808        public String getMediaTitle() {
809                return getMediaInfo(StreamKind.General, 0, "Title", "Movie");
810        }
811
812        @Define("audioLanguages")
813        public List<Language> getAudioLanguageList() {
814                return getMediaInfo(StreamKind.Audio, "Language").filter(Objects::nonNull).distinct().map(Language::findLanguage).filter(Objects::nonNull).collect(toList());
815        }
816
817        @Define("textLanguages")
818        public List<Language> getTextLanguageList() {
819                return getMediaInfo(StreamKind.Text, "Language").filter(Objects::nonNull).distinct().map(Language::findLanguage).filter(Objects::nonNull).collect(toList());
820        }
821
822        @Define("bitrate")
823        public Long getOverallBitRate() {
824                return (long) Double.parseDouble(getMediaInfo(StreamKind.General, 0, "OverallBitRate"));
825        }
826
827        @Define("kbps")
828        public String getKiloBytesPerSecond() {
829                return String.format("%.0f kbps", getOverallBitRate() / 1e3f);
830        }
831
832        @Define("mbps")
833        public String getMegaBytesPerSecond() {
834                return String.format("%.1f Mbps", getOverallBitRate() / 1e6f);
835        }
836
837        @Define("fps")
838        public Number getFrameRate() {
839                return new BigDecimal(getMediaInfo(StreamKind.Video, 0, "FrameRate"));
840        }
841
842        @Define("khz")
843        public String getSamplingRate() {
844                return getMediaInfo(StreamKind.Audio, 0, "SamplingRate/String");
845        }
846
847        @Define("duration")
848        public Duration getDuration() {
849                long d = (long) Double.parseDouble(getMediaInfo(StreamKind.General, 0, "Duration"));
850                return Duration.ofMillis(d);
851        }
852
853        @Define("seconds")
854        public long getSeconds() {
855                return getDuration().getSeconds();
856        }
857
858        @Define("minutes")
859        public long getMinutes() {
860                return getDuration().toMinutes();
861        }
862
863        @Define("hours")
864        public String getHours() {
865                return ExpressionFormatMethods.format(getDuration(), "H:mm");
866        }
867
868        @Define("media")
869        public AssociativeScriptObject getGeneralMediaInfo() {
870                return createMediaInfoBindings(StreamKind.General).get(0);
871        }
872
873        @Define("menu")
874        public AssociativeScriptObject getMenuInfo() {
875                return createMediaInfoBindings(StreamKind.Menu).get(0);
876        }
877
878        @Define("image")
879        public AssociativeScriptObject getImageInfo() {
880                return createMediaInfoBindings(StreamKind.Image).get(0);
881        }
882
883        @Define("video")
884        public List<AssociativeScriptObject> getVideoInfoList() {
885                return createMediaInfoBindings(StreamKind.Video);
886        }
887
888        @Define("audio")
889        public List<AssociativeScriptObject> getAudioInfoList() {
890                return createMediaInfoBindings(StreamKind.Audio);
891        }
892
893        @Define("text")
894        public List<AssociativeScriptObject> getTextInfoList() {
895                return createMediaInfoBindings(StreamKind.Text);
896        }
897
898        @Define("chapters")
899        public List<AssociativeScriptObject> getChaptersInfoList() {
900                return createMediaInfoBindings(StreamKind.Chapters);
901        }
902
903        @Define("exif")
904        public AssociativeScriptObject getImageMetadata() throws Exception {
905                return new AssociativeScriptObject(getPhoto().snapshot());
906        }
907
908        @Define("camera")
909        public AssociativeEnumObject getCamera() throws Exception {
910                return getPhoto().getCameraModel().map(AssociativeEnumObject::new).orElse(null);
911        }
912
913        @Define("location")
914        public AssociativeEnumObject getLocation() throws Exception {
915                return getPhoto().getLocationTaken().map(AssociativeEnumObject::new).orElse(null);
916        }
917
918        @Define("artist")
919        public String getArtist() {
920                return getMusic().getArtist();
921        }
922
923        @Define("albumArtist")
924        public String getAlbumArtist() {
925                return getMusic().getAlbumArtist();
926        }
927
928        @Define("album")
929        public String getAlbum() {
930                return getMusic().getAlbum();
931        }
932
933        @Define("episode")
934        public Episode getEpisode() {
935                return (Episode) infoObject;
936        }
937
938        @Define("episodes")
939        public List<Episode> getEpisodes() {
940                return getMultiEpisodeList(getEpisode());
941        }
942
943        @Define("movie")
944        public Movie getMovie() {
945                return (Movie) infoObject;
946        }
947
948        @Define("music")
949        public AudioTrack getMusic() {
950                return (AudioTrack) infoObject;
951        }
952
953        @Define("photo")
954        public ImageMetadata getPhoto() throws Exception {
955                if (infoObject instanceof PhotoFile) {
956                        return ((PhotoFile) infoObject).getMetadata();
957                }
958                return new ImageMetadata((File) infoObject);
959        }
960
961        @Define("pi")
962        public Integer getPart() {
963                if (infoObject instanceof AudioTrack)
964                        return getMusic().getTrack();
965                if (infoObject instanceof MoviePart)
966                        return ((MoviePart) infoObject).getPartIndex();
967
968                return null;
969        }
970
971        @Define("pn")
972        public Integer getPartCount() {
973                if (infoObject instanceof AudioTrack)
974                        return getMusic().getTrackCount();
975                if (infoObject instanceof MoviePart)
976                        return ((MoviePart) infoObject).getPartCount();
977
978                return null;
979        }
980
981        @Define("type")
982        public String getInfoObjectType() {
983                return infoObject.getClass().getSimpleName();
984        }
985
986        @Define("mime")
987        public List<String> getMediaType() throws Exception {
988                // format engine does not allow / in binding value
989                return SLASH.splitAsStream(MediaTypes.getMediaType(getExtension())).collect(toList());
990        }
991
992        @Define("mediaPath")
993        public File getMediaPath() throws Exception {
994                return getStructurePathTail(getMediaFile());
995        }
996
997        @Define("f")
998        public File getMediaFile() {
999                // make sure file is not null, and that it is an existing file
1000                if (mediaFile == null) {
1001                        throw new IllegalStateException(EXCEPTION_SAMPLE_FILE_NOT_SET);
1002                }
1003                return mediaFile;
1004        }
1005
1006        @Define("folder")
1007        public File getMediaParentFolder() {
1008                return getMediaFile().getParentFile();
1009        }
1010
1011        @Define("bytes")
1012        public long getFileSize() {
1013                File inferredMediaFile = getInferredMediaFile();
1014
1015                // sum size of all files
1016                if (inferredMediaFile.isDirectory()) {
1017                        return listFiles(getMediaFile(), FILES).stream().mapToLong(File::length).sum();
1018                }
1019
1020                // size of inferred media file (e.g. video file size for subtitle file)
1021                return inferredMediaFile.length();
1022        }
1023
1024        @Define("megabytes")
1025        public String getFileSizeInMegaBytes() {
1026                return String.format("%.0f", getFileSize() / 1e6);
1027        }
1028
1029        @Define("gigabytes")
1030        public String getFileSizeInGigaBytes() {
1031                return String.format("%.1f", getFileSize() / 1e9);
1032        }
1033
1034        @Define("encodedDate")
1035        public SimpleDate getEncodedDate() {
1036                return new SimpleDate(getMediaInfo().getCreationTime().toEpochMilli());
1037        }
1038
1039        @Define("today")
1040        public SimpleDate getToday() {
1041                return new SimpleDate(LocalDateTime.now());
1042        }
1043
1044        @Define("home")
1045        public File getUserHome() {
1046                return ApplicationFolder.UserHome.get();
1047        }
1048
1049        @Define("output")
1050        public File getUserDefinedOutputFolder() throws IOException {
1051                return new File(Settings.getApplicationArguments().output).getCanonicalFile();
1052        }
1053
1054        @Define("defines")
1055        public Map<String, String> getUserDefinedArguments() throws IOException {
1056                return unmodifiableMap(Settings.getApplicationArguments().defines);
1057        }
1058
1059        @Define("label")
1060        public String getUserDefinedLabel() throws IOException {
1061                return getUserDefinedArguments().entrySet().stream().filter(it -> {
1062                        return it.getKey().endsWith("label") && it.getValue() != null && it.getValue().length() > 0;
1063                }).map(it -> it.getValue()).findFirst().orElse(null);
1064        }
1065
1066        @Define("i")
1067        public Integer getModelIndex() {
1068                return 1 + identityIndexOf(context.values(), getInfoObject());
1069        }
1070
1071        @Define("di")
1072        public Integer getDuplicateIndex() {
1073                return 1 + identityIndexOf(context.values().stream().filter(getInfoObject()::equals).collect(toList()), getInfoObject());
1074        }
1075
1076        @Define("dc")
1077        public Integer getDuplicateCount() {
1078                return context.values().stream().filter(getInfoObject()::equals).mapToInt(i -> 1).sum();
1079        }
1080
1081        @Define("plex")
1082        public File getPlexStandardPath() throws Exception {
1083                String path = NamingStandard.PLEX.getPath(infoObject);
1084                try {
1085                        path = path.concat(getSubtitleTags()); // NPE if {subt} is undefined
1086                } catch (Exception e) {
1087                        // ignore => no language tags
1088                }
1089                return new File(path);
1090        }
1091
1092        @Define("kodi")
1093        public File getKodiStandardPath() throws Exception {
1094                String path = NamingStandard.KODI.getPath(infoObject);
1095                try {
1096                        path = path.concat(getSubtitleTags()); // NPE if {subt} is undefined
1097                } catch (Exception e) {
1098                        // ignore => no language tags
1099                }
1100                return new File(path);
1101        }
1102
1103        @Define("self")
1104        public AssociativeScriptObject getSelf() {
1105                return createBindingObject(mediaFile, infoObject, context, property -> null);
1106        }
1107
1108        @Define("model")
1109        public List<AssociativeScriptObject> getModel() {
1110                List<AssociativeScriptObject> result = new ArrayList<AssociativeScriptObject>();
1111                for (Entry<File, ?> it : context.entrySet()) {
1112                        result.add(createBindingObject(it.getKey(), it.getValue(), context, property -> null));
1113                }
1114                return result;
1115        }
1116
1117        @Define("json")
1118        public String getInfoObjectDump() {
1119                return MetaAttributes.toJson(infoObject);
1120        }
1121
1122        @Define("ffprobe")
1123        public Object getFFProbeDump() throws Exception {
1124                return new FFProbe().open(getInferredMediaFile());
1125        }
1126
1127        public File getInferredMediaFile() {
1128                File file = getMediaFile();
1129
1130                if (file.isDirectory()) {
1131                        // just select the first video file in the folder as media sample
1132                        List<File> videos = listFiles(file, VIDEO_FILES, CASE_INSENSITIVE_PATH_ORDER);
1133                        if (videos.size() > 0) {
1134                                return videos.get(0);
1135                        }
1136                } else if ((SUBTITLE_FILES.accept(file) || IMAGE_FILES.accept(file)) || ((infoObject instanceof Episode || infoObject instanceof Movie) && !VIDEO_FILES.accept(file))) {
1137                        // prefer equal match from current context if possible
1138                        if (context != null) {
1139                                for (Entry<File, ?> it : context.entrySet()) {
1140                                        if (infoObject.equals(it.getValue()) && VIDEO_FILES.accept(it.getKey())) {
1141                                                return it.getKey();
1142                                        }
1143                                }
1144                        }
1145
1146                        // file is a subtitle, or nfo, etc
1147                        String baseName = stripReleaseInfo(FileUtilities.getName(file)).toLowerCase();
1148                        List<File> videos = getChildren(file.getParentFile(), VIDEO_FILES);
1149
1150                        // find corresponding movie file
1151                        for (File movieFile : videos) {
1152                                if (!baseName.isEmpty() && stripReleaseInfo(FileUtilities.getName(movieFile)).toLowerCase().startsWith(baseName)) {
1153                                        return movieFile;
1154                                }
1155                        }
1156
1157                        // still no good match found -> just take the most probable video from the same folder
1158                        if (videos.size() > 0) {
1159                                sort(videos, SimilarityComparator.compareTo(FileUtilities.getName(file), FileUtilities::getName));
1160                                return videos.get(0);
1161                        }
1162                }
1163
1164                return file;
1165        }
1166
1167        public Episode getSeasonEpisode() {
1168                // magically convert AniDB absolute numbers to TheTVDB SxE numbers if AniDB is selected with airdate SxE episode sort order
1169                if (getEpisodes().stream().allMatch(it -> isAnime(it) && isRegular(it) && !isAbsolute(it))) {
1170                        try {
1171                                return getEpisodeByAbsoluteNumber(getEpisode(), TheTVDB, SortOrder.Airdate);
1172                        } catch (Exception e) {
1173                                debug.warning(e::toString);
1174                        }
1175                }
1176                return getEpisode();
1177        }
1178
1179        public SeriesInfo getPrimarySeriesInfo() {
1180                if (TheTVDB.getIdentifier().equals(getSeriesInfo().getDatabase())) {
1181                        try {
1182                                return TheTVDB.getSeriesInfo(getSeriesInfo().getId(), Locale.ENGLISH);
1183                        } catch (Exception e) {
1184                                debug.warning("Failed to retrieve primary series info: " + e); // default to seriesInfo property
1185                        }
1186                }
1187
1188                return getSeriesInfo();
1189        }
1190
1191        private final Resource<MovieInfo> primaryMovieInfo = Resource.lazy(() -> TheMovieDB.getMovieInfo(getMovie(), Locale.US, false));
1192        private final Resource<MovieInfo> extendedMovieInfo = Resource.lazy(() -> getMovieInfo(getMovie().getLanguage(), true));
1193
1194        public MovieInfo getPrimaryMovieInfo() {
1195                try {
1196                        return primaryMovieInfo.get();
1197                } catch (Exception e) {
1198                        throw new BindingException("info", "Failed to retrieve primary movie info: " + e, e);
1199                }
1200        }
1201
1202        public MovieInfo getMovieInfo() {
1203                try {
1204                        return extendedMovieInfo.get();
1205                } catch (Exception e) {
1206                        throw new BindingException("info", "Failed to retrieve extended movie info: " + e, e);
1207                }
1208        }
1209
1210        public synchronized MovieInfo getMovieInfo(Locale locale, boolean extendedInfo) throws Exception {
1211                Movie m = getMovie();
1212
1213                if (m.getTmdbId() > 0)
1214                        return TheMovieDB.getMovieInfo(m, locale == null ? Locale.US : locale, extendedInfo);
1215                if (m.getImdbId() > 0)
1216                        return OMDb.getMovieInfo(m);
1217
1218                return null;
1219        }
1220
1221        private static final Cache<File, MediaInfo> mediaInfoCache = Caffeine.newBuilder().expireAfterAccess(5, TimeUnit.MINUTES).build();
1222
1223        private synchronized MediaInfo getMediaInfo() {
1224                // use inferred media file (e.g. actual movie file instead of subtitle file)
1225                File inferredMediaFile = getInferredMediaFile();
1226
1227                return mediaInfoCache.get(inferredMediaFile, f -> {
1228                        try {
1229                                return new MediaInfo().open(f);
1230                        } catch (Exception e) {
1231                                throw new MediaInfoException(e.getMessage());
1232                        }
1233                });
1234        }
1235
1236        private Integer identityIndexOf(Iterable<?> c, Object o) {
1237                Iterator<?> itr = c.iterator();
1238                for (int i = 0; itr.hasNext(); i++) {
1239                        Object next = itr.next();
1240                        if (o == next) {
1241                                return i;
1242                        }
1243                }
1244                return null;
1245        }
1246
1247        private String getMediaInfo(StreamKind streamKind, int streamNumber, String... keys) {
1248                for (String key : keys) {
1249                        String value = getMediaInfo().get(streamKind, streamNumber, key);
1250                        if (value.length() > 0) {
1251                                return value;
1252                        }
1253                }
1254                return undefined(String.format("%s[%d][%s]", streamKind, streamNumber, join(keys, ", ")));
1255        }
1256
1257        private Stream<String> getMediaInfo(StreamKind streamKind, String... keys) {
1258                return IntStream.range(0, getMediaInfo().streamCount(streamKind)).mapToObj(streamNumber -> {
1259                        return stream(keys).map(key -> {
1260                                return getMediaInfo().get(streamKind, streamNumber, key);
1261                        }).filter(s -> s.length() > 0).findFirst().orElse(null);
1262                });
1263        }
1264
1265        private AssociativeScriptObject createBindingObject(Object info) {
1266                return createBindingObject(null, info, null, this::undefined);
1267        }
1268
1269        private AssociativeScriptObject createBindingObject(File file, Object info, Map<File, ?> context, Function<String, Object> defaultValue) {
1270                MediaBindingBean mediaBindingBean = new MediaBindingBean(info, file, context) {
1271
1272                        @Override
1273                        @Define(undefined)
1274                        public <T> T undefined(String name) {
1275                                return null; // never throw exceptions for empty or null values
1276                        }
1277                };
1278
1279                return new AssociativeScriptObject(new ExpressionBindings(mediaBindingBean), defaultValue);
1280        }
1281
1282        private AssociativeScriptObject createPropertyBindings(Object object) {
1283                return new AssociativeScriptObject(new PropertyBindings(object), this::undefined);
1284        }
1285
1286        private List<AssociativeScriptObject> createMediaInfoBindings(StreamKind kind) {
1287                return getMediaInfo().snapshot().get(kind).stream().map(m -> new AssociativeScriptObject(m, this::undefined)).collect(toList());
1288        }
1289
1290        private final Resource<String[]> mediaTitles = Resource.lazy(() -> {
1291                // try to place embedded media title first
1292                try {
1293                        String mediaTitle = getMediaInfo().getTitle();
1294                        if (mediaTitle.length() > 0) {
1295                                return Stream.concat(Stream.of(mediaTitle), stream(getFileNames(getInferredMediaFile()))).toArray(String[]::new);
1296                        }
1297                } catch (Exception e) {
1298                        debug.warning("Failed to read media title: " + e.getMessage());
1299                }
1300
1301                // default to file name and xattr
1302                return getFileNames(getInferredMediaFile());
1303        });
1304
1305        private String[] getFileNames(File file) {
1306                List<String> names = new ArrayList<String>();
1307
1308                // original file name via xattr
1309                String original = xattr.getOriginalName(file);
1310                if (original != null) {
1311                        names.add(getNameWithoutExtension(original));
1312                }
1313
1314                // current file name
1315                names.add(getNameWithoutExtension(file.getName()));
1316
1317                // current folder name
1318                File parent = file.getParentFile();
1319                if (parent != null && parent.getParent() != null) {
1320                        names.add(parent.getName());
1321                }
1322
1323                return names.toArray(new String[0]);
1324        }
1325
1326        private Pattern getKeywordExcludePattern() {
1327                // collect key information
1328                List<Object> keys = new ArrayList<Object>();
1329
1330                if (infoObject instanceof Episode || infoObject instanceof Movie) {
1331                        keys.add(getName());
1332                        keys.addAll(getAliasNames());
1333
1334                        if (infoObject instanceof Episode) {
1335                                for (Episode e : getEpisodes()) {
1336                                        keys.add(e.getTitle());
1337                                }
1338                        } else if (infoObject instanceof Movie) {
1339                                keys.add(getMovie().getYear());
1340                        }
1341                }
1342
1343                // word list for exclude pattern
1344                String pattern = keys.stream().filter(Objects::nonNull).map(Objects::toString).map(s -> {
1345                        return normalizePunctuation(s, " ", "\\P{Alnum}+");
1346                }).filter(s -> s.length() > 0).collect(joining("|", "\\b(", ")\\b"));
1347
1348                return Pattern.compile(pattern, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CHARACTER_CLASS);
1349        }
1350
1351        @Override
1352        public String toString() {
1353                return String.format("%s ⇔ %s", infoObject, mediaFile == null ? null : mediaFile.getName());
1354        }
1355
1356        public static final String EXCEPTION_UNDEFINED = "undefined";
1357        public static final String EXCEPTION_SAMPLE_FILE_NOT_SET = "Sample file has not been set. Click \"Change Sample\" to select a sample file.";
1358
1359}