package net.filebot.format; import static java.util.Arrays.*; import static java.util.Collections.*; import static java.util.stream.Collectors.*; import static net.filebot.Logging.*; import static net.filebot.MediaTypes.*; import static net.filebot.WebServices.*; import static net.filebot.format.Define.*; import static net.filebot.format.ExpressionFormatMethods.*; import static net.filebot.hash.VerificationUtilities.*; import static net.filebot.media.MediaDetection.*; import static net.filebot.media.XattrMetaInfo.*; import static net.filebot.similarity.Normalization.*; import static net.filebot.subtitle.SubtitleUtilities.*; import static net.filebot.util.FileUtilities.*; import static net.filebot.util.RegularExpressions.*; import static net.filebot.util.StringUtilities.*; import static net.filebot.web.EpisodeUtilities.*; import java.io.File; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; import java.util.Optional; import java.util.regex.Pattern; import java.util.stream.IntStream; import java.util.stream.Stream; import net.filebot.ApplicationFolder; import net.filebot.Cache; import net.filebot.CacheType; import net.filebot.Language; import net.filebot.MediaTypes; import net.filebot.MetaAttributeView; import net.filebot.Resource; import net.filebot.Settings; import net.filebot.hash.HashType; import net.filebot.media.MetaAttributes; import net.filebot.media.NamingStandard; import net.filebot.mediainfo.ImageMetadata; import net.filebot.mediainfo.MediaInfo; import net.filebot.mediainfo.MediaInfo.StreamKind; import net.filebot.mediainfo.MediaInfoException; import net.filebot.similarity.Normalization; import net.filebot.similarity.SimilarityComparator; import net.filebot.util.FileUtilities; import net.filebot.util.WeakValueHashMap; import net.filebot.web.AudioTrack; import net.filebot.web.Episode; import net.filebot.web.EpisodeFormat; import net.filebot.web.Movie; import net.filebot.web.MovieInfo; import net.filebot.web.MoviePart; import net.filebot.web.MultiEpisode; import net.filebot.web.SeriesInfo; import net.filebot.web.SimpleDate; import net.filebot.web.SortOrder; import net.filebot.web.TheTVDBSeriesInfo; public class MediaBindingBean { private final Object infoObject; private final File mediaFile; private final Map<File, ?> context; private MediaInfo mediaInfo; public MediaBindingBean(Object infoObject, File mediaFile) { this(infoObject, mediaFile, null); } public MediaBindingBean(Object infoObject, File mediaFile, Map<File, ?> context) { this.infoObject = infoObject; this.mediaFile = mediaFile; this.context = context; } @Define("object") public Object getInfoObject() { return infoObject; } @Define("file") public File getFileObject() { return mediaFile; } @Define(undefined) public <T> T undefined(String name) { // omit expressions that depend on undefined values throw new BindingException(name, EXCEPTION_UNDEFINED); } @Define("n") public String getName() { if (infoObject instanceof Episode) return getEpisode().getSeriesName(); else if (infoObject instanceof Movie) return getMovie().getName(); else if (infoObject instanceof AudioTrack) return getAlbumArtist() != null ? getAlbumArtist() : getArtist(); else if (infoObject instanceof File) return FileUtilities.getName((File) infoObject); return null; } @Define("y") public Integer getYear() { if (infoObject instanceof Episode) return getEpisode().getSeriesInfo().getStartDate().getYear(); if (infoObject instanceof Movie) return getMovie().getYear(); return getReleaseDate().getYear(); } @Define("ny") public String getNameWithYear() { String n = getName().toString(); String y = " (" + getYear().toString() + ")"; // account for TV Shows that contain the year in the series name, e.g. Doctor Who (2005) return n.endsWith(y) ? n : n + y; } @Define("s") public Integer getSeasonNumber() { // look up season numbers via TheTVDB for AniDB episode data if (isAnime(getEpisode())) { return getSeasonEpisode().getSeason(); } return getEpisode().getSeason(); } @Define("e") public Integer getEpisodeNumber() { return getEpisode().getEpisode(); } @Define("es") public List<Integer> getEpisodeNumbers() { return getEpisodes().stream().map(it -> { return it.getEpisode() == null ? it.getSpecial() == null ? null : it.getSpecial() : it.getEpisode(); }).filter(Objects::nonNull).collect(toList()); } @Define("e00") public String getE00() { if (isRegularEpisode()) return getEpisodeNumbers().stream().map(i -> String.format("%02d", i)).collect(joining("-")); else return "Special " + join(getEpisodeNumbers(), "-"); } @Define("sxe") public String getSxE() { return EpisodeFormat.SeasonEpisode.formatSxE(getSeasonEpisode()); // try to convert absolute numbers to SxE numbers } @Define("s00e00") public String getS00E00() { return EpisodeFormat.SeasonEpisode.formatS00E00(getSeasonEpisode()); // try to convert absolute numbers to SxE numbers } @Define("t") public String getTitle() { String t = null; if (infoObject instanceof Episode) { t = infoObject instanceof MultiEpisode ? EpisodeFormat.SeasonEpisode.formatMultiTitle(getEpisodes()) : getEpisode().getTitle(); // implicit support for multi-episode title formatting } else if (infoObject instanceof Movie) { t = getMovieInfo().getTagline(); } else if (infoObject instanceof AudioTrack) { t = getMusic().getTrackTitle() != null ? getMusic().getTrackTitle() : getMusic().getTitle(); } // enforce title length limit by default return truncateText(t, NamingStandard.TITLE_MAX_LENGTH); } @Define("d") public SimpleDate getReleaseDate() { if (infoObject instanceof Episode) return getEpisode().getAirdate(); if (infoObject instanceof Movie) return getMovieInfo().getReleased(); if (infoObject instanceof AudioTrack) return getMusic().getAlbumReleaseDate(); if (infoObject instanceof File) return new SimpleDate(getTimeStamp()); return null; } @Define("dt") public ZonedDateTime getTimeStamp() { File f = getMediaFile(); // try EXIF Date-Taken for image files or File Last-Modified for generic files try { return new ImageMetadata(f).getDateTaken().get(); } catch (Exception e) { // ignore and default to file creation date } try { return Instant.ofEpochMilli(getCreationDate(f)).atZone(ZoneOffset.systemDefault()); } catch (Exception e) { debug.warning(e::toString); } return null; } @Define("airdate") public SimpleDate getAirdate() { return getEpisode().getAirdate(); } @Define("age") public Long getAgeInDays() throws Exception { SimpleDate releaseDate = getReleaseDate(); if (releaseDate != null) { // avoid time zone issues by interpreting all dates and times as UTC long days = ChronoUnit.DAYS.between(releaseDate.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant(), Instant.now()); if (days >= 0) { return days; } } return null; } @Define("startdate") public SimpleDate getStartDate() { return getEpisode().getSeriesInfo().getStartDate(); } @Define("absolute") public Integer getAbsoluteEpisodeNumber() { return getEpisode().getAbsolute(); } @Define("special") public Integer getSpecialNumber() { return getEpisode().getSpecial(); } @Define("series") public SeriesInfo getSeriesInfo() { return getEpisode().getSeriesInfo(); } @Define("alias") public List<String> getAliasNames() { if (infoObject instanceof Movie) return asList(getMovie().getAliasNames()); if (infoObject instanceof Episode) return getSeriesInfo().getAliasNames(); return null; } @Define("primaryTitle") public String getPrimaryTitle() { if (infoObject instanceof Movie) return getPrimaryMovieInfo().getOriginalName(); if (infoObject instanceof Episode) 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) return null; } @Define("id") public Object getId() throws Exception { if (infoObject instanceof Episode) return getEpisode().getSeriesInfo().getId(); if (infoObject instanceof Movie) return getMovie().getId(); if (infoObject instanceof AudioTrack) return getMusic().getMBID(); return null; } @Define("tmdbid") public String getTmdbId() { if (getMovie().getTmdbId() > 0) return String.valueOf(getMovie().getTmdbId()); if (getMovie().getImdbId() > 0) return getPrimaryMovieInfo().getId().toString(); // lookup IMDbID for TMDbID return null; } @Define("imdbid") public String getImdbId() { if (getMovie().getImdbId() > 0) return String.format("tt%07d", getMovie().getImdbId()); if (getMovie().getTmdbId() > 0) return String.format("tt%07d", getPrimaryMovieInfo().getImdbId()); // lookup IMDbID for TMDbID return null; } @Define("vc") public String getVideoCodec() { // e.g. XviD, x264, DivX 5, MPEG-4 Visual, AVC, etc. String codec = getMediaInfo(StreamKind.Video, 0, "Encoded_Library_Name", "Encoded_Library/Name", "CodecID/Hint", "Format"); // get first token (e.g. DivX 5 => DivX) return tokenize(codec).findFirst().get(); } @Define("ac") public String getAudioCodec() { // e.g. AC-3, DTS, AAC, Vorbis, MP3, etc. String codec = getMediaInfo(StreamKind.Audio, 0, "CodecID/Hint", "Format"); // remove punctuation (e.g. AC-3 => AC3) return normalizePunctuation(codec, "", ""); } @Define("cf") public String getContainerFormat() { // container format extensions (e.g. avi, mkv mka mks, OGG, etc.) String extensions = getMediaInfo(StreamKind.General, 0, "Codec/Extensions", "Format"); // get first extension return tokenize(extensions).map(String::toLowerCase).findFirst().get(); } @Define("vf") public String getVideoFormat() { int width = Integer.parseInt(getMediaInfo(StreamKind.Video, 0, "Width")); int height = Integer.parseInt(getMediaInfo(StreamKind.Video, 0, "Height")); int[] ws = new int[] { 15360, 7680, 3840, 1920, 1280, 1024, 854, 852, 720, 688, 512, 320 }; int[] hs = new int[] { 8640, 4320, 2160, 1080, 720, 576, 576, 480, 480, 360, 240, 240 }; int ns = 0; for (int i = 0; i < ws.length - 1; i++) { if ((width >= ws[i] || height >= hs[i]) || (width > ws[i + 1] && height > hs[i + 1])) { ns = hs[i]; break; } } if (ns > 0) { // e.g. 720p, nobody actually wants files to be tagged as interlaced, e.g. 720i return String.format("%dp", ns); } return null; // video too small } @Define("hpi") public String getExactVideoFormat() { String height = getMediaInfo(StreamKind.Video, 0, "Height"); String scanType = getMediaInfo(StreamKind.Video, 0, "ScanType"); // e.g. 720p return height + Character.toLowerCase(scanType.charAt(0)); } @Define("af") public String getAudioChannels() { String channels = getMediaInfo(StreamKind.Audio, 0, "Channel(s)_Original", "Channel(s)"); // get first number, e.g. 6ch return String.format("%dch", matchInteger(channels)); } @Define("channels") public String getAudioChannelPositions() { String channels = getMediaInfo(StreamKind.Audio, 0, "ChannelPositions/String2", "Channel(s)_Original", "Channel(s)"); // e.g. ChannelPositions/String2: 3/2/2.1 / 3/2/0.1 (one audio stream may contain multiple multi-channel streams) double d = tokenize(channels).mapToDouble(s -> { try { return tokenize(s, SLASH).mapToDouble(Double::parseDouble).reduce(0, (a, b) -> a + b); } catch (NumberFormatException e) { return 0; } }).filter(it -> it > 0).max().getAsDouble(); return BigDecimal.valueOf(d).setScale(1, RoundingMode.HALF_UP).toPlainString(); } @Define("resolution") public String getVideoResolution() { return join(getDimension(), "x"); // e.g. 1280x720 } @Define("bitdepth") public int getVideoBitDepth() { String bitdepth = getMediaInfo(StreamKind.Video, 0, "BitDepth"); return Integer.parseInt(bitdepth); } @Define("ws") public String getWidescreen() { List<Integer> dim = getDimension(); // width-to-height aspect ratio greater than 1.37:1 return (float) dim.get(0) / dim.get(1) > 1.37f ? "WS" : null; } @Define("hd") public String getVideoDefinitionCategory() { List<Integer> dim = getDimension(); // UHD if (dim.get(0) >= 3840 || dim.get(1) >= 2160) return "UHD"; // HD if (dim.get(0) >= 1280 || dim.get(1) >= 720) return "HD"; // SD return "SD"; } @Define("dim") public List<Integer> getDimension() { // collect value from Video Stream 0 or Image Stream 0 return Stream.of(StreamKind.Video, StreamKind.Image).map(k -> { // collect Width and Height as Integer List return Stream.of("Width", "Height").map(p -> getMediaInfo().get(k, 0, p)).filter(s -> s.length() > 0).map(Integer::new).collect(toList()); }).filter(d -> d.size() == 2).findFirst().orElse(null); } @Define("width") public Integer getWidth() { return getDimension().get(0); } @Define("height") public Integer getHeight() { return getDimension().get(1); } @Define("original") public String getOriginalFileName() { String name = xattr.getOriginalName(getMediaFile()); return name != null ? getNameWithoutExtension(name) : null; } @Define("xattr") public Object getMetaAttributesObject() throws Exception { return xattr.getMetaInfo(getMediaFile()); } @Define("crc32") public String getCRC32() throws Exception { // use inferred media file File inferredMediaFile = getInferredMediaFile(); // try to get checksum from file name Optional<String> embeddedChecksum = stream(getFileNames(inferredMediaFile)).map(Normalization::getEmbeddedChecksum).filter(Objects::nonNull).findFirst(); if (embeddedChecksum.isPresent()) { return embeddedChecksum.get(); } // try to get checksum from sfv file String checksum = getHashFromVerificationFile(inferredMediaFile, HashType.SFV, 3); if (checksum != null) { return checksum; } // try CRC32 xattr (as stored by verify script) try { MetaAttributeView xattr = new MetaAttributeView(inferredMediaFile); checksum = xattr.get("CRC32"); if (checksum != null) { return checksum; } } catch (Exception e) { // ignore if xattr metadata is not supported for the given file } // calculate checksum from file Cache cache = Cache.getCache("crc32", CacheType.Ephemeral); return (String) cache.computeIfAbsent(inferredMediaFile, it -> crc32(inferredMediaFile)); } @Define("fn") public String getFileName() { // name without file extension return FileUtilities.getName(getMediaFile()); } @Define("ext") public String getExtension() { // file extension return FileUtilities.getExtension(getMediaFile()); } @Define("source") public String getVideoSource() { // look for video source patterns in media file and it's parent folder (use inferred media file) return releaseInfo.getVideoSource(getFileNames(getInferredMediaFile())); } @Define("tags") public List<String> getVideoTags() { // look for video source patterns in media file and it's parent folder (use inferred media file) List<String> matches = releaseInfo.getVideoTags(getFileNames(getInferredMediaFile())); if (matches.isEmpty()) { return null; } // heavy normalization for whatever text was captured with the tags pattern return matches.stream().map(s -> { return lowerTrail(upperInitial(normalizePunctuation(s))); }).sorted().distinct().collect(toList()); } @Define("s3d") public String getStereoscopic3D() { return releaseInfo.getStereoscopic3D(getFileNames(getInferredMediaFile())); } @Define("group") public String getReleaseGroup() throws Exception { // reduce false positives by removing the know titles from the name Pattern[] nonGroupPattern = { getKeywordExcludePattern(), releaseInfo.getVideoSourcePattern(), releaseInfo.getVideoFormatPattern(true), releaseInfo.getResolutionPattern(), releaseInfo.getStructureRootPattern() }; // consider foldername, filename and original filename of inferred media file String[] filenames = stream(getFileNames(getInferredMediaFile())).map(s -> releaseInfo.clean(s, nonGroupPattern)).filter(s -> s.length() > 0).toArray(String[]::new); // look for release group names in media file and it's parent folder return releaseInfo.getReleaseGroup(filenames); } @Define("subt") public String getSubtitleTags() throws Exception { if (!SUBTITLE_FILES.accept(getMediaFile())) { return null; } Language language = getLanguageTag(); if (language != null) { String tag = '.' + language.getISO3B(); // Plex only supports ISO 639-2/B language codes String category = releaseInfo.getSubtitleCategoryTag(getFileNames(getMediaFile())); if (category != null) { return tag + '.' + category; } return tag; } return null; } @Define("lang") public Language getLanguageTag() throws Exception { // grep language from filename Locale languageTag = releaseInfo.getSubtitleLanguageTag(getFileNames(getMediaFile())); if (languageTag != null) { return Language.getLanguage(languageTag); } // detect language from subtitle text content if (SUBTITLE_FILES.accept(getMediaFile())) { try { return detectSubtitleLanguage(getMediaFile()); } catch (Exception e) { throw new RuntimeException("Failed to detect subtitle language: " + e, e); } } return null; } @Define("languages") public List<Language> getSpokenLanguages() { if (infoObject instanceof Movie) { List<Locale> languages = getMovieInfo().getSpokenLanguages(); return languages.stream().map(Language::getLanguage).filter(Objects::nonNull).collect(toList()); } if (infoObject instanceof Episode) { String language = getSeriesInfo().getLanguage(); return Stream.of(language).map(Language::findLanguage).filter(Objects::nonNull).collect(toList()); } return null; } @Define("runtime") public Integer getRuntime() { if (infoObject instanceof Movie) return getMovieInfo().getRuntime(); if (infoObject instanceof Episode) return getSeriesInfo().getRuntime(); return null; } @Define("actors") public List<String> getActors() throws Exception { if (infoObject instanceof Movie) return getMovieInfo().getActors(); if (infoObject instanceof Episode) return ExpressionFormatMethods.getActors(getSeriesInfo()); // use TheTVDB API v2 to retrieve actors info return null; } @Define("genres") public List<String> getGenres() { if (infoObject instanceof Movie) return getMovieInfo().getGenres(); if (infoObject instanceof Episode) return getSeriesInfo().getGenres(); return null; } @Define("genre") public String getPrimaryGenre() { return getGenres().iterator().next(); } @Define("director") public String getDirector() throws Exception { if (infoObject instanceof Movie) return getMovieInfo().getDirector(); if (infoObject instanceof Episode) return ExpressionFormatMethods.getInfo(getEpisode()).getDirector(); // use TheTVDB API v2 to retrieve extended episode info return null; } @Define("certification") public String getCertification() { if (infoObject instanceof Movie) return getMovieInfo().getCertification(); if (infoObject instanceof Episode) return getSeriesInfo().getCertification(); return null; } @Define("rating") public Double getRating() { if (infoObject instanceof Movie) return getMovieInfo().getRating(); if (infoObject instanceof Episode) return getSeriesInfo().getRating(); return null; } @Define("votes") public Integer getVotes() { if (infoObject instanceof Movie) return getMovieInfo().getVotes(); if (infoObject instanceof Episode) return getSeriesInfo().getRatingCount(); return null; } @Define("collection") public String getCollection() { if (infoObject instanceof Movie) return getMovieInfo().getCollection(); return null; } @Define("info") public synchronized AssociativeScriptObject getMetaInfo() { if (infoObject instanceof Movie) return createPropertyBindings(getMovieInfo()); if (infoObject instanceof Episode) return createPropertyBindings(getSeriesInfo()); return null; } @Define("omdb") public synchronized AssociativeScriptObject getOmdbApiInfo() throws Exception { if (infoObject instanceof Movie) { if (getMovie().getImdbId() > 0) { return createPropertyBindings(OMDb.getMovieInfo(getMovie())); } if (getMovie().getTmdbId() > 0) { Integer imdbId = getPrimaryMovieInfo().getImdbId(); return createPropertyBindings(OMDb.getMovieInfo(new Movie(imdbId))); } } if (infoObject instanceof Episode) { TheTVDBSeriesInfo info = (TheTVDBSeriesInfo) getPrimarySeriesInfo(); int imdbId = matchInteger(info.getImdbId()); return createPropertyBindings(OMDb.getMovieInfo(new Movie(imdbId))); } return null; } @Define("order") public DynamicBindings getSortOrderObject() { return new DynamicBindings(SortOrder::names, k -> { if (infoObject instanceof Episode) { SortOrder order = SortOrder.forName(k); Episode episode = fetchEpisode(getEpisode(), order, null); return createBindingObject(null, episode, null); } return undefined(k); }); } @Define("localize") public DynamicBindings getLocalizedInfoObject() { return new DynamicBindings(Language::availableLanguages, k -> { Language language = Language.findLanguage(k); if (language != null && infoObject instanceof Movie) { Movie movie = TheMovieDB.getMovieDescriptor(getMovie(), language.getLocale()); return createBindingObject(null, movie, null); } if (language != null && infoObject instanceof Episode) { Episode episode = fetchEpisode(getEpisode(), null, language.getLocale()); return createBindingObject(null, episode, null); } return undefined(k); }); } @Define("az") public String getSortInitial() { try { return sortInitial(getCollection()); } catch (Exception e) { return sortInitial(getName()); } } @Define("anime") public boolean isAnimeEpisode() { return getEpisodes().stream().anyMatch(it -> isAnime(it)); } @Define("regular") public boolean isRegularEpisode() { return getEpisodes().stream().anyMatch(it -> isRegular(it)); } @Define("episodelist") public List<Episode> getEpisodeList() throws Exception { return fetchEpisodeList(getEpisode()); } @Define("sy") public List<Integer> getSeasonYears() throws Exception { return getEpisodeList().stream().filter(e -> isRegular(e) && e.getSeason().equals(getSeasonNumber()) && e.getAirdate() != null).map(e -> e.getAirdate().getYear()).sorted().distinct().collect(toList()); } @Define("sc") public Integer getSeasonCount() throws Exception { return getEpisodeList().stream().filter(e -> isRegular(e) && e.getSeason() != null).map(Episode::getSeason).max(Integer::compare).get(); } @Define("mediaTitle") public String getMediaTitle() { return getMediaInfo(StreamKind.General, 0, "Title", "Movie"); } @Define("audioLanguages") public List<Language> getAudioLanguageList() { return getMediaInfo(StreamKind.Audio, "Language").filter(Objects::nonNull).distinct().map(Language::findLanguage).filter(Objects::nonNull).collect(toList()); } @Define("textLanguages") public List<Language> getTextLanguageList() { return getMediaInfo(StreamKind.Text, "Language").filter(Objects::nonNull).distinct().map(Language::findLanguage).filter(Objects::nonNull).collect(toList()); } @Define("bitrate") public Long getOverallBitRate() { return new Double(getMediaInfo(StreamKind.General, 0, "OverallBitRate")).longValue(); } @Define("kbps") public String getKiloBytesPerSecond() { return String.format("%.0f kbps", getOverallBitRate() / 1e3f); } @Define("mbps") public String getMegaBytesPerSecond() { return String.format("%.1f Mbps", getOverallBitRate() / 1e6f); } @Define("khz") public String getSamplingRate() { return getMediaInfo(StreamKind.Audio, 0, "SamplingRate/String"); } @Define("duration") public Duration getDuration() { long d = new Double(getMediaInfo(StreamKind.General, 0, "Duration")).longValue(); return Duration.ofMillis(d); } @Define("seconds") public long getSeconds() { return getDuration().getSeconds(); } @Define("minutes") public long getMinutes() { return getDuration().toMinutes(); } @Define("hours") public String getHours() { return ExpressionFormatMethods.format(getDuration(), "H:mm"); } @Define("media") public AssociativeScriptObject getGeneralMediaInfo() { return createMediaInfoBindings(StreamKind.General).get(0); } @Define("menu") public AssociativeScriptObject getMenuInfo() { return createMediaInfoBindings(StreamKind.Menu).get(0); } @Define("image") public AssociativeScriptObject getImageInfo() { return createMediaInfoBindings(StreamKind.Image).get(0); } @Define("video") public List<AssociativeScriptObject> getVideoInfoList() { return createMediaInfoBindings(StreamKind.Video); } @Define("audio") public List<AssociativeScriptObject> getAudioInfoList() { return createMediaInfoBindings(StreamKind.Audio); } @Define("text") public List<AssociativeScriptObject> getTextInfoList() { return createMediaInfoBindings(StreamKind.Text); } @Define("chapters") public List<AssociativeScriptObject> getChaptersInfoList() { return createMediaInfoBindings(StreamKind.Chapters); } @Define("exif") public AssociativeScriptObject getImageMetadata() throws Exception { return new AssociativeScriptObject(new ImageMetadata(getMediaFile()).snapshot()); } @Define("camera") public AssociativeEnumObject getCamera() throws Exception { return new ImageMetadata(getMediaFile()).getCameraModel().map(AssociativeEnumObject::new).orElse(null); } @Define("location") public AssociativeEnumObject getLocation() throws Exception { return new ImageMetadata(getMediaFile()).getLocationTaken().map(AssociativeEnumObject::new).orElse(null); } @Define("artist") public String getArtist() { return getMusic().getArtist(); } @Define("albumArtist") public String getAlbumArtist() { return getMusic().getAlbumArtist(); } @Define("album") public String getAlbum() { return getMusic().getAlbum(); } @Define("episode") public Episode getEpisode() { return (Episode) infoObject; } @Define("episodes") public List<Episode> getEpisodes() { return getMultiEpisodeList(getEpisode()); } @Define("movie") public Movie getMovie() { return (Movie) infoObject; } @Define("music") public AudioTrack getMusic() { return (AudioTrack) infoObject; } @Define("pi") public Integer getPart() { if (infoObject instanceof AudioTrack) return getMusic().getTrack(); if (infoObject instanceof MoviePart) return ((MoviePart) infoObject).getPartIndex(); return null; } @Define("pn") public Integer getPartCount() { if (infoObject instanceof AudioTrack) return getMusic().getTrackCount(); if (infoObject instanceof MoviePart) return ((MoviePart) infoObject).getPartCount(); return null; } @Define("type") public String getInfoObjectType() { return infoObject.getClass().getSimpleName(); } @Define("mime") public List<String> getMediaType() throws Exception { // format engine does not allow / in binding value return SLASH.splitAsStream(MediaTypes.getMediaType(getExtension())).collect(toList()); } @Define("mediaPath") public File getMediaPath() throws Exception { return getStructurePathTail(getMediaFile()); } @Define("f") public File getMediaFile() { // make sure file is not null, and that it is an existing file if (mediaFile == null) { throw new IllegalStateException(EXCEPTION_SAMPLE_FILE_NOT_SET); } return mediaFile; } @Define("folder") public File getMediaParentFolder() { return getMediaFile().getParentFile(); } @Define("bytes") public long getFileSize() { // sum size of all files if (getMediaFile().isDirectory()) { return listFiles(getMediaFile(), FILES).stream().mapToLong(File::length).sum(); } // size of inferred media file (e.g. video file size for subtitle file) return getInferredMediaFile().length(); } @Define("megabytes") public String getFileSizeInMegaBytes() { return String.format("%.0f", getFileSize() / 1e6); } @Define("gigabytes") public String getFileSizeInGigaBytes() { return String.format("%.1f", getFileSize() / 1e9); } @Define("encodedDate") public SimpleDate getEncodedDate() { String date = getMediaInfo(StreamKind.General, 0, "Encoded_Date"); // e.g. UTC 2008-01-08 19:54:39 ZonedDateTime time = ZonedDateTime.parse(date, DateTimeFormatter.ofPattern("zzz uuuu-MM-dd HH:mm:ss")); return new SimpleDate(time); } @Define("today") public SimpleDate getToday() { return new SimpleDate(LocalDateTime.now()); } @Define("home") public File getUserHome() { return ApplicationFolder.UserHome.get(); } @Define("output") public File getUserDefinedOutputFolder() throws IOException { return new File(Settings.getApplicationArguments().output).getCanonicalFile(); } @Define("defines") public Map<String, String> getUserDefinedArguments() throws IOException { return unmodifiableMap(Settings.getApplicationArguments().defines); } @Define("label") public String getUserDefinedLabel() throws IOException { return getUserDefinedArguments().entrySet().stream().filter(it -> { return it.getKey().endsWith("label") && it.getValue() != null && it.getValue().length() > 0; }).map(it -> it.getValue()).findFirst().orElse(null); } @Define("i") public Integer getModelIndex() { return 1 + identityIndexOf(context.values(), getInfoObject()); } @Define("di") public Integer getDuplicateIndex() { return 1 + identityIndexOf(context.values().stream().filter(getInfoObject()::equals).collect(toList()), getInfoObject()); } @Define("dc") public Integer getDuplicateCount() { return context.values().stream().filter(getInfoObject()::equals).mapToInt(i -> 1).sum(); } @Define("plex") public File getPlexStandardPath() throws Exception { String path = NamingStandard.Plex.getPath(infoObject); try { path = path.concat(getSubtitleTags()); // NPE if {subt} is undefined } catch (Exception e) { // ignore => no language tags } return new File(path); } @Define("self") public AssociativeScriptObject getSelf() { return createBindingObject(mediaFile, infoObject, context); } @Define("model") public List<AssociativeScriptObject> getModel() { List<AssociativeScriptObject> result = new ArrayList<AssociativeScriptObject>(); for (Entry<File, ?> it : context.entrySet()) { result.add(createBindingObject(it.getKey(), it.getValue(), context)); } return result; } @Define("json") public String getInfoObjectDump() { return MetaAttributes.toJson(infoObject); } public File getInferredMediaFile() { File file = getMediaFile(); if (file.isDirectory()) { // just select the first video file in the folder as media sample List<File> videos = listFiles(file, VIDEO_FILES, CASE_INSENSITIVE_PATH_ORDER); if (videos.size() > 0) { return videos.get(0); } } else if ((SUBTITLE_FILES.accept(file) || IMAGE_FILES.accept(file)) || ((infoObject instanceof Episode || infoObject instanceof Movie) && !VIDEO_FILES.accept(file))) { // prefer equal match from current context if possible if (context != null) { for (Entry<File, ?> it : context.entrySet()) { if (infoObject.equals(it.getValue()) && VIDEO_FILES.accept(it.getKey())) { return it.getKey(); } } } // file is a subtitle, or nfo, etc String baseName = stripReleaseInfo(FileUtilities.getName(file)).toLowerCase(); List<File> videos = getChildren(file.getParentFile(), VIDEO_FILES); // find corresponding movie file for (File movieFile : videos) { if (!baseName.isEmpty() && stripReleaseInfo(FileUtilities.getName(movieFile)).toLowerCase().startsWith(baseName)) { return movieFile; } } // still no good match found -> just take the most probable video from the same folder if (videos.size() > 0) { sort(videos, SimilarityComparator.compareTo(FileUtilities.getName(file), FileUtilities::getName)); return videos.get(0); } } return file; } public Episode getSeasonEpisode() { // magically convert AniDB absolute numbers to TheTVDB SxE numbers if AniDB is selected with airdate SxE episode sort order if (getEpisodes().stream().allMatch(it -> isAnime(it) && isRegular(it) && !isAbsolute(it))) { try { return getEpisodeByAbsoluteNumber(getEpisode(), TheTVDB, SortOrder.Airdate); } catch (Exception e) { debug.warning(e::toString); } } return getEpisode(); } public SeriesInfo getPrimarySeriesInfo() { if (TheTVDB.getIdentifier().equals(getSeriesInfo().getDatabase())) { try { return TheTVDB.getSeriesInfo(getSeriesInfo().getId(), Locale.ENGLISH); } catch (Exception e) { debug.warning("Failed to retrieve primary series info: " + e); // default to seriesInfo property } } return getSeriesInfo(); } private final Resource<MovieInfo> primaryMovieInfo = Resource.lazy(() -> TheMovieDB.getMovieInfo(getMovie(), Locale.ENGLISH, false)); private final Resource<MovieInfo> extendedMovieInfo = Resource.lazy(() -> getMovieInfo(getMovie().getLanguage(), true)); public MovieInfo getPrimaryMovieInfo() { try { return primaryMovieInfo.get(); } catch (Exception e) { throw new BindingException("info", "Failed to retrieve primary movie info: " + e, e); } } public MovieInfo getMovieInfo() { try { return extendedMovieInfo.get(); } catch (Exception e) { throw new BindingException("info", "Failed to retrieve extended movie info: " + e, e); } } public synchronized MovieInfo getMovieInfo(Locale locale, boolean extendedInfo) throws Exception { Movie m = getMovie(); if (m.getTmdbId() > 0) return TheMovieDB.getMovieInfo(m, locale == null ? Locale.ENGLISH : locale, extendedInfo); if (m.getImdbId() > 0) return OMDb.getMovieInfo(m); return null; } private static final Map<File, MediaInfo> sharedMediaInfoObjects = synchronizedMap(new WeakValueHashMap<File, MediaInfo>(64)); private synchronized MediaInfo getMediaInfo() { // lazy initialize if (mediaInfo == null) { // use inferred media file (e.g. actual movie file instead of subtitle file) File inferredMediaFile = getInferredMediaFile(); mediaInfo = sharedMediaInfoObjects.computeIfAbsent(inferredMediaFile, f -> { try { return new MediaInfo().open(f); } catch (IOException e) { throw new MediaInfoException(e.getMessage()); } }); } return mediaInfo; } private Integer identityIndexOf(Iterable<?> c, Object o) { Iterator<?> itr = c.iterator(); for (int i = 0; itr.hasNext(); i++) { Object next = itr.next(); if (o == next) { return i; } } return null; } private String getMediaInfo(StreamKind streamKind, int streamNumber, String... keys) { for (String key : keys) { String value = getMediaInfo().get(streamKind, streamNumber, key); if (value.length() > 0) { return value; } } return undefined(String.format("%s[%d][%s]", streamKind, streamNumber, join(keys, ", "))); } private Stream<String> getMediaInfo(StreamKind streamKind, String... keys) { return IntStream.range(0, getMediaInfo().streamCount(streamKind)).mapToObj(streamNumber -> { return stream(keys).map(key -> { return getMediaInfo().get(streamKind, streamNumber, key); }).filter(s -> s.length() > 0).findFirst().orElse(null); }); } private AssociativeScriptObject createBindingObject(File file, Object info, Map<File, ?> context) { MediaBindingBean mediaBindingBean = new MediaBindingBean(info, file, context) { @Override @Define(undefined) public <T> T undefined(String name) { return null; // never throw exceptions for empty or null values } }; return new AssociativeScriptObject(new ExpressionBindings(mediaBindingBean)); } private AssociativeScriptObject createPropertyBindings(Object object) { return new AssociativeScriptObject(new PropertyBindings(object, null)) { @Override public Object getProperty(String name) { Object value = super.getProperty(name); if (value == null) { return undefined(name); } if (value instanceof CharSequence) { return replacePathSeparators(value.toString()).trim(); // auto-clean value of path separators } return value; } }; } private List<AssociativeScriptObject> createMediaInfoBindings(StreamKind kind) { return getMediaInfo().snapshot().get(kind).stream().map(AssociativeScriptObject::new).collect(toList()); } private String[] getFileNames(File file) { List<String> names = new ArrayList<String>(3); names.add(getNameWithoutExtension(file.getName())); // current file name String original = xattr.getOriginalName(file); if (original != null) { names.add(getNameWithoutExtension(original)); // original file name } File parent = file.getParentFile(); if (parent != null && parent.getParent() != null) { names.add(parent.getName()); // current folder name } return names.toArray(new String[0]); } private Pattern getKeywordExcludePattern() { // collect key information List<Object> keys = new ArrayList<Object>(); keys.add(getName()); if (infoObject instanceof Episode || infoObject instanceof Movie) { keys.addAll(getAliasNames()); if (infoObject instanceof Episode) { for (Episode e : getEpisodes()) { keys.add(e.getTitle()); } } else if (infoObject instanceof Movie) { keys.add(getYear()); } } // word list for exclude pattern String pattern = keys.stream().filter(Objects::nonNull).map(Objects::toString).map(s -> { return normalizePunctuation(s, " ", "\\P{Alnum}+"); }).filter(s -> s.length() > 0).collect(joining("|", "\\b(", ")\\b")); return Pattern.compile(pattern, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CHARACTER_CLASS); } @Override public String toString() { return String.format("%s ⇔ %s", infoObject, mediaFile == null ? null : mediaFile.getName()); } public static final String EXCEPTION_UNDEFINED = "undefined"; public static final String EXCEPTION_SAMPLE_FILE_NOT_SET = "Sample file has not been set. Click \"Change Sample\" to select a sample file."; }