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