001package net.filebot.media;
002
003import static java.util.Arrays.*;
004import static java.util.Collections.*;
005import static java.util.stream.Collectors.*;
006import static net.filebot.Execute.*;
007import static net.filebot.util.DateTimeUtilities.*;
008import static net.filebot.util.RegularExpressions.*;
009
010import java.io.File;
011import java.io.IOException;
012import java.time.Duration;
013import java.time.Instant;
014import java.time.ZoneOffset;
015import java.util.LinkedHashMap;
016import java.util.List;
017import java.util.Map;
018import java.util.Objects;
019import java.util.Optional;
020import java.util.OptionalDouble;
021import java.util.function.Function;
022import java.util.stream.Stream;
023
024import com.cedarsoftware.util.io.JsonReader;
025import com.cedarsoftware.util.io.JsonWriter;
026
027import net.filebot.web.Episode;
028import net.filebot.web.Link;
029import net.filebot.web.Movie;
030import net.filebot.web.SimpleDate;
031
032public class FFProbe extends LinkedHashMap<String, Object> implements MediaCharacteristics {
033
034        @Override
035        public String getFileName() {
036                return getFormat("filename", f -> new File(f).getName());
037        }
038
039        @Override
040        public Long getFileSize() {
041                return getFormat("size", Long::parseLong);
042        }
043
044        @Override
045        public String getVideoCodec() {
046                return getString("video", "codec_name");
047        }
048
049        @Override
050        public String getVideoProfile() {
051                return getString("video", "profile");
052        }
053
054        @Override
055        public String getAudioCodec() {
056                return getString("audio", "codec_name");
057        }
058
059        @Override
060        public String getAudioLanguage() {
061                return getString("audio", "tags", "language");
062        }
063
064        @Override
065        public String getSubtitleCodec() {
066                return getString("subtitle", "codec_name");
067        }
068
069        @Override
070        public String getSubtitleLanguage() {
071                return getString("subtitle", "tags", "language");
072        }
073
074        @Override
075        public Duration getDuration() {
076                return getFormat("duration", s -> Duration.ofMillis(Math.round(Double.parseDouble(s) * 1000)));
077        }
078
079        @Override
080        public Integer getWidth() {
081                return getInteger("video", "width");
082        }
083
084        @Override
085        public Integer getHeight() {
086                return getInteger("video", "height");
087        }
088
089        @Override
090        public Double getBitRate() {
091                return getFormat("bit_rate", Double::parseDouble);
092        }
093
094        @Override
095        public Double getFrameRate() {
096                return find("video", "avg_frame_rate").map(fps -> {
097                        // normalize FPS fractional value (e.g. 500/21 or 24000/1001)
098                        return SLASH.splitAsStream(fps).mapToDouble(Double::parseDouble).reduce((a, b) -> a / b);
099                }).filter(OptionalDouble::isPresent).map(OptionalDouble::getAsDouble).orElse(null);
100        }
101
102        @Override
103        public String getTitle() {
104                return getTag("title").orElse(null);
105        }
106
107        @Override
108        public Instant getCreationTime() {
109                return getTag("creation_time").map(s -> matchDateTime(s, ZoneOffset.UTC)).orElse(null);
110        }
111
112        @Override
113        public Object getMediaTags() {
114                String series = getTag("show").orElse(null);
115                Integer season = getTag("season_number").map(Integer::parseInt).orElse(null);
116                Integer episode = getTag("episode_sort").map(Integer::parseInt).orElse(null);
117                String title = getTag("title").orElse(null);
118                Instant date = getTag("date").map(s -> matchDateTime(s, ZoneOffset.UTC)).orElse(null);
119
120                if (series != null && episode != null) {
121                        return new Episode(series, season, episode, title, null, null, SimpleDate.from(date), null, null, null);
122                }
123
124                // "Title": "Dune (2021)"
125                // "IMDB": "tt1160419"
126                // "TMDB": "movie/438631"
127                Integer imdb = getTag("IMDB").map(Link.IMDb::parseID).orElse(null);
128                Integer tmdb = getTag("TMDB").map(Link.TheMovieDB::parseID).orElse(null);
129
130                Movie movieNameYear = getTag("Title").map(Movie::matchNameYear).orElse(null);
131                String movieName = movieNameYear != null ? movieNameYear.getName() : title != null ? title : null;
132                Integer movieYear = movieNameYear != null ? (Integer) movieNameYear.getYear() : date != null ? (Integer) date.atOffset(ZoneOffset.UTC).getYear() : null;
133
134                return Movie.ID(tmdb, imdb, movieName, movieYear);
135        }
136
137        public Map<String, Object> getFormat() {
138                return (Map) get("format");
139        }
140
141        public <T> T getFormat(String property, Function<String, T> mapper) {
142                return Optional.ofNullable(getFormat()).map(f -> f.get(property)).map(Object::toString).filter(s -> !s.isEmpty()).map(mapper).orElse(null);
143        }
144
145        public Optional<String> getTag(String tag) {
146                Stream<Object> formatTags = Stream.of(getFormat()).filter(Objects::nonNull).map(m -> m.get("tags"));
147                Stream<Object> videoTags = stream("video", "tags");
148
149                // check format tags and video tags (and ensure that video tags take precedence over format tags)
150                return Stream.concat(videoTags, formatTags).filter(Map.class::isInstance).map(Map.class::cast).map(m -> m.get(tag)).filter(Objects::nonNull).map(Object::toString).filter(s -> !s.isEmpty()).findFirst();
151        }
152
153        public List<Map<String, Object>> getStreams() {
154                return (List) asList((Object[]) get("streams"));
155        }
156
157        protected String getString(String streamKind, String key) {
158                return stream(streamKind, key).map(Objects::toString).collect(joining(" / "));
159        }
160
161        protected String getString(String streamKind, String objectKey, String valueKey) {
162                return stream(streamKind, objectKey).map(t -> ((Map) t).get(valueKey)).map(Objects::toString).collect(joining(" / "));
163        }
164
165        protected Stream<Object> stream(String streamKind, String property) {
166                return getStreams().stream().filter(s -> streamKind.equals(s.get("codec_type"))).map(s -> s.get(property)).filter(Objects::nonNull);
167        }
168
169        protected Integer getInteger(String streamKind, String property) {
170                return find(streamKind, property).map(Integer::parseInt).orElse(null);
171        }
172
173        protected Optional<String> find(String streamKind, String property) {
174                return stream(streamKind, property).map(Object::toString).filter(s -> !s.isEmpty()).findFirst();
175        }
176
177        @Override
178        public void close() throws Exception {
179                // ignore and do nothing
180        }
181
182        @Override
183        public String toString() {
184                return JsonWriter.objectToJson(this);
185        }
186
187        public void parse(String json) {
188                putAll((Map) JsonReader.jsonToJava(json, singletonMap(JsonReader.USE_MAPS, true)));
189        }
190
191        public static FFProbe read(File file) throws Exception {
192                FFProbe ffprobe = new FFProbe();
193                ffprobe.parse(cache.get(file));
194
195                return ffprobe;
196        }
197
198        public static String ffprobe(File file) throws IOException {
199                return execute(ffprobe, asList("-show_streams", "-show_format", "-print_format", "json", "-v", "error", file.getAbsolutePath()), file.getParentFile(), null, false).toString();
200        }
201
202        public static String version() throws IOException {
203                return execute(ffprobe, "-show_program_version", "-hide_banner").toString().trim();
204        }
205
206        public static String minify(String json) {
207                return NEWLINE.splitAsStream(json).map(String::trim).collect(joining());
208        }
209
210        private static final String ffprobe = System.getProperty("net.filebot.media.ffprobe", "ffprobe");
211
212        // NOTE: We don't use xattr cache for ffprobe output because ffprobe is only used on Linux where xattr storage is limited to 4 kB in total per file (and so we need to save space for MediaInfo output)
213        private static final CachedFileAttribute cache = CachedFileAttribute.cache("ffprobe", null, f -> minify(ffprobe(f.getFile())));
214
215}