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}