001package net.filebot.mediainfo;
002
003import static java.nio.charset.StandardCharsets.*;
004import static java.util.stream.Collectors.*;
005import static net.filebot.Logging.*;
006import static net.filebot.util.DateTimeUtilities.*;
007import static net.filebot.util.FileUtilities.*;
008import static net.filebot.util.RegularExpressions.*;
009
010import java.io.File;
011import java.io.IOException;
012import java.io.RandomAccessFile;
013import java.lang.ref.Cleaner;
014import java.time.Duration;
015import java.time.Instant;
016import java.time.ZoneOffset;
017import java.util.ArrayList;
018import java.util.EnumMap;
019import java.util.LinkedHashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Objects;
023import java.util.function.Function;
024import java.util.stream.IntStream;
025import java.util.stream.Stream;
026
027import com.sun.jna.Platform;
028import com.sun.jna.Pointer;
029import com.sun.jna.WString;
030
031import net.filebot.media.ImageMetadata;
032import net.filebot.media.MediaCharacteristics;
033import net.filebot.web.Episode;
034import net.filebot.web.Movie;
035import net.filebot.web.SimpleDate;
036
037public class MediaInfo implements MediaCharacteristics {
038
039        private Pointer handle;
040        private Cleaner.Cleanable cleanable;
041
042        public MediaInfo() {
043                try {
044                        handle = MediaInfoLibrary.INSTANCE.New();
045                        cleanable = cleaner.register(this, new Finalizer(handle));
046                } catch (LinkageError e) {
047                        throw new MediaInfoException(e);
048                }
049        }
050
051        public synchronized MediaInfo open(File file) throws IOException, IllegalArgumentException {
052                if (!file.isFile() || file.length() <= 0) {
053                        throw new IllegalArgumentException("Invalid media file: " + file);
054                }
055
056                if (preferOpenViaBuffer(file)) {
057                        try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
058                                if (openViaBuffer(raf)) {
059                                        return this;
060                                }
061                                throw new IOException("Failed to initialize media info buffer: " + file);
062                        }
063                }
064
065                // open via file path
066                if (0 != MediaInfoLibrary.INSTANCE.Open(handle, new WString(file.getPath()))) {
067                        return this;
068                }
069                throw new IOException("Failed to open media file: " + file);
070        }
071
072        private boolean preferOpenViaBuffer(File file) {
073                // on Windows file paths that are longer than 260 characters cannot be opened
074                if (Platform.isWindows() && file.getPath().length() > 250)
075                        return true;
076
077                // on Mac files that contain accents cannot be opened via JNA WString file paths due to encoding differences so we use the buffer interface instead for these files
078                if (Platform.isMac() && !US_ASCII.newEncoder().canEncode(file.getPath()))
079                        return true;
080
081                return false;
082        }
083
084        private boolean openViaBuffer(RandomAccessFile f) throws IOException {
085                long size = f.length();
086                byte[] buffer = new byte[LARGE_BUFFER_SIZE]; // use large buffer to reduce JNA calls
087                int read = -1;
088
089                if (0 == MediaInfoLibrary.INSTANCE.Open_Buffer_Init(handle, size, 0)) {
090                        return false;
091                }
092
093                while ((read = f.read(buffer)) >= 0) {
094                        if ((MediaInfoLibrary.INSTANCE.Open_Buffer_Continue(handle, buffer, read) & 0x08) == 0x08) {
095                                break;
096                        }
097
098                        long seek = MediaInfoLibrary.INSTANCE.Open_Buffer_Continue_GoTo_Get(handle);
099                        if (seek >= 0) {
100                                f.seek(seek);
101                                MediaInfoLibrary.INSTANCE.Open_Buffer_Init(handle, size, seek);
102                        }
103                }
104
105                MediaInfoLibrary.INSTANCE.Open_Buffer_Finalize(handle);
106                return true;
107        }
108
109        public synchronized String inform() {
110                return MediaInfoLibrary.INSTANCE.Inform(handle).toString();
111        }
112
113        public String option(String option) {
114                return option(option, "");
115        }
116
117        public synchronized String option(String option, String value) {
118                return MediaInfoLibrary.INSTANCE.Option(handle, new WString(option), new WString(value)).toString();
119        }
120
121        public String get(StreamKind streamKind, int streamNumber, String parameter) {
122                return get(streamKind, streamNumber, parameter, InfoKind.Text, InfoKind.Name);
123        }
124
125        public String get(StreamKind streamKind, int streamNumber, String parameter, InfoKind infoKind) {
126                return get(streamKind, streamNumber, parameter, infoKind, InfoKind.Name);
127        }
128
129        public synchronized String get(StreamKind streamKind, int streamNumber, String parameter, InfoKind infoKind, InfoKind searchKind) {
130                return MediaInfoLibrary.INSTANCE.Get(handle, streamKind.ordinal(), streamNumber, new WString(parameter), infoKind.ordinal(), searchKind.ordinal()).toString();
131        }
132
133        public String get(StreamKind streamKind, int streamNumber, int parameterIndex) {
134                return get(streamKind, streamNumber, parameterIndex, InfoKind.Text);
135        }
136
137        public synchronized String get(StreamKind streamKind, int streamNumber, int parameterIndex, InfoKind infoKind) {
138                return MediaInfoLibrary.INSTANCE.GetI(handle, streamKind.ordinal(), streamNumber, parameterIndex, infoKind.ordinal()).toString();
139        }
140
141        public synchronized int streamCount(StreamKind streamKind) {
142                return MediaInfoLibrary.INSTANCE.Count_Get(handle, streamKind.ordinal(), -1);
143        }
144
145        public synchronized int parameterCount(StreamKind streamKind, int streamNumber) {
146                return MediaInfoLibrary.INSTANCE.Count_Get(handle, streamKind.ordinal(), streamNumber);
147        }
148
149        @Override
150        public String getVideoCodec() {
151                return getString(StreamKind.General, "Video_Codec_List");
152        }
153
154        @Override
155        public String getAudioCodec() {
156                return getString(StreamKind.General, "Audio_Codec_List");
157        }
158
159        @Override
160        public String getAudioLanguage() {
161                return getString(StreamKind.General, "Audio_Language_List");
162        }
163
164        @Override
165        public String getSubtitleCodec() {
166                return getString(StreamKind.General, "Text_Codec_List");
167        }
168
169        @Override
170        public String getSubtitleLanguage() {
171                return getString(StreamKind.General, "Text_Language_List");
172        }
173
174        @Override
175        public Duration getDuration() {
176                return get(StreamKind.General, "Duration", s -> Duration.ofMillis(Math.round(Double.parseDouble(s))));
177        }
178
179        @Override
180        public Integer getWidth() {
181                return getInteger(StreamKind.Video, "Width");
182        }
183
184        @Override
185        public Integer getHeight() {
186                return getInteger(StreamKind.Video, "Height");
187        }
188
189        public Double getBitRate() {
190                return getDouble(StreamKind.General, "OverallBitRate");
191        }
192
193        @Override
194        public Double getFrameRate() {
195                return getDouble(StreamKind.Video, "FrameRate");
196        }
197
198        @Override
199        public String getTitle() {
200                // e.g. video.264#trackID=1:fps=23.976 - Imported with GPAC 0.5.0-rev
201                return Stream.of(StreamKind.General, StreamKind.Video).flatMap(k -> stream(k, "Title", "Movie")).filter(s -> !s.contains("#")).findFirst().orElse(null);
202        }
203
204        @Override
205        public Instant getCreationTime() {
206                // Check for fake MediaInfo properties (e.g. fake encoding date)
207                // e.g.
208                // Encoded_Date: UTC 2010-02-22 21:41:29
209                // Encoded_Application: no_variable_data
210                if (stream(StreamKind.General, "Encoded_Application").anyMatch("no_variable_data"::equals)) {
211                        return null;
212                }
213
214                // e.g. UTC 2008-01-08 19:54:39
215                return get(StreamKind.General, "Encoded_Date", s -> matchDateTime(s, ZoneOffset.UTC));
216        }
217
218        @Override
219        public Object getMediaTags() {
220                String movie = getString(StreamKind.General, "Movie");
221                Instant date = get(StreamKind.General, "Recorded_Date", s -> matchDateTime(s, ZoneOffset.UTC));
222
223                if (movie != null && date != null) {
224                        return new Movie(movie, date.atOffset(ZoneOffset.UTC).getYear());
225                }
226
227                String series = getString(StreamKind.General, "Collection");
228                Integer season = getInteger(StreamKind.General, "Season");
229                Integer episode = getInteger(StreamKind.General, "Part");
230                String title = getString(StreamKind.General, "Title");
231
232                if (series != null && episode != null) {
233                        return new Episode(series, season, episode, title, null, null, SimpleDate.from(date), null, null);
234                }
235
236                return null;
237        }
238
239        public String getString(StreamKind kind, String property) {
240                return get(kind, property, Object::toString);
241        }
242
243        public Integer getInteger(StreamKind kind, String property) {
244                return get(kind, property, Integer::parseInt);
245        }
246
247        public Double getDouble(StreamKind kind, String property) {
248                return get(kind, property, Double::parseDouble);
249        }
250
251        public <T> T get(StreamKind kind, String property, Function<String, T> mapper) {
252                return stream(kind, property).findFirst().map(mapper).orElse(null);
253        }
254
255        public Stream<String> stream(StreamKind streamKind, String... keys) {
256                return IntStream.range(0, streamCount(streamKind)).mapToObj(i -> {
257                        return Stream.of(keys).map(key -> {
258                                return get(streamKind, i, key);
259                        }).filter(s -> !s.isEmpty()).findFirst().orElse(null);
260                }).filter(Objects::nonNull);
261        }
262
263        public Stream<String> find(StreamKind streamKind, String... keys) {
264                int streamCount = streamCount(streamKind);
265                return Stream.of(keys).flatMap(key -> {
266                        return IntStream.range(0, streamCount).mapToObj(i -> {
267                                return get(streamKind, i, key);
268                        }).filter(s -> !s.isEmpty());
269                });
270        }
271
272        public Map<StreamKind, List<Map<String, String>>> snapshot() {
273                Map<StreamKind, List<Map<String, String>>> mediaInfo = new EnumMap<StreamKind, List<Map<String, String>>>(StreamKind.class);
274
275                for (StreamKind streamKind : StreamKind.values()) {
276                        int streamCount = streamCount(streamKind);
277
278                        List<Map<String, String>> streamInfoList = new ArrayList<Map<String, String>>(streamCount);
279                        for (int i = 0; i < streamCount; i++) {
280                                streamInfoList.add(snapshot(streamKind, i));
281                        }
282
283                        mediaInfo.put(streamKind, streamInfoList);
284                }
285
286                return mediaInfo;
287        }
288
289        public Map<String, String> snapshot(StreamKind streamKind, int streamNumber) {
290                Map<String, String> streamInfo = new LinkedHashMap<String, String>();
291
292                for (int i = 0, count = parameterCount(streamKind, streamNumber); i < count; i++) {
293                        String value = get(streamKind, streamNumber, i, InfoKind.Text);
294
295                        if (value.length() > 0) {
296                                streamInfo.put(get(streamKind, streamNumber, i, InfoKind.Name), value);
297                        }
298                }
299
300                // MediaInfo does not support EXIF image metadata natively so we use the metadata-extractor library and implicitly merge that information in
301                if (streamKind == StreamKind.Image && streamNumber == 0) {
302                        String path = get(StreamKind.General, 0, "CompleteName");
303                        try {
304                                Map<String, String> values = new ImageMetadata(new File(path)).snapshot(t -> {
305                                        return Stream.of(t.getDirectoryName(), t.getTagName()).flatMap(NON_WORD::splitAsStream).distinct().collect(joining("_"));
306                                });
307                                streamInfo.putAll(values);
308                        } catch (Throwable e) {
309                                debug.warning(message(e, path));
310                        }
311                }
312
313                return streamInfo;
314        }
315
316        @Override
317        public synchronized void close() {
318                cleanable.clean();
319        }
320
321        public enum StreamKind {
322                General, Video, Audio, Text, Chapters, Image, Menu;
323        }
324
325        public enum InfoKind {
326                /**
327                 * Unique name of parameter.
328                 */
329                Name,
330
331                /**
332                 * Value of parameter.
333                 */
334                Text,
335
336                /**
337                 * Unique name of measure unit of parameter.
338                 */
339                Measure,
340
341                Options,
342
343                /**
344                 * Translated name of parameter.
345                 */
346                Name_Text,
347
348                /**
349                 * Translated name of measure unit.
350                 */
351                Measure_Text,
352
353                /**
354                 * More information about the parameter.
355                 */
356                Info,
357
358                /**
359                 * How this parameter is supported, could be N (No), B (Beta), R (Read only), W (Read/Write).
360                 */
361                HowTo,
362
363                /**
364                 * Domain of this piece of information.
365                 */
366                Domain;
367        }
368
369        public static String version() {
370                return staticOption("Info_Version");
371        }
372
373        public static String parameters() {
374                return staticOption("Info_Parameters");
375        }
376
377        public static String codecs() {
378                return staticOption("Info_Codecs");
379        }
380
381        public static String capacities() {
382                return staticOption("Info_Capacities");
383        }
384
385        public static String staticOption(String option) {
386                return staticOption(option, "");
387        }
388
389        public static String staticOption(String option, String value) {
390                try {
391                        return MediaInfoLibrary.INSTANCE.Option(null, new WString(option), new WString(value)).toString();
392                } catch (LinkageError e) {
393                        throw new MediaInfoException(e);
394                }
395        }
396
397        public static Map<StreamKind, List<Map<String, String>>> snapshot(File file) throws IOException {
398                try (MediaInfo mi = new MediaInfo().open(file)) {
399                        return mi.snapshot();
400                }
401        }
402
403        /**
404         * Use {@link Cleaner} instead of Object.finalize()
405         */
406        private static final Cleaner cleaner = Cleaner.create();
407
408        private static class Finalizer implements Runnable {
409
410                private Pointer handle;
411
412                public Finalizer(Pointer handle) {
413                        this.handle = handle;
414                }
415
416                @Override
417                public void run() {
418                        MediaInfoLibrary.INSTANCE.Close(handle);
419                        MediaInfoLibrary.INSTANCE.Delete(handle);
420                }
421        }
422
423}