View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.inspection.ffmpeg;
23  
24  import static org.opencastproject.inspection.api.MediaInspectionOptions.OPTION_ACCURATE_FRAME_COUNT;
25  import static org.opencastproject.util.data.Collections.map;
26  
27  import org.opencastproject.inspection.api.MediaInspectionException;
28  import org.opencastproject.inspection.ffmpeg.api.AudioStreamMetadata;
29  import org.opencastproject.inspection.ffmpeg.api.MediaAnalyzer;
30  import org.opencastproject.inspection.ffmpeg.api.MediaAnalyzerException;
31  import org.opencastproject.inspection.ffmpeg.api.MediaContainerMetadata;
32  import org.opencastproject.inspection.ffmpeg.api.SubtitleStreamMetadata;
33  import org.opencastproject.inspection.ffmpeg.api.VideoStreamMetadata;
34  import org.opencastproject.mediapackage.AdaptivePlaylist;
35  import org.opencastproject.mediapackage.MediaPackageElement;
36  import org.opencastproject.mediapackage.MediaPackageElementBuilder;
37  import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
38  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
39  import org.opencastproject.mediapackage.Stream;
40  import org.opencastproject.mediapackage.Track;
41  import org.opencastproject.mediapackage.UnsupportedElementException;
42  import org.opencastproject.mediapackage.track.AudioStreamImpl;
43  import org.opencastproject.mediapackage.track.SubtitleStreamImpl;
44  import org.opencastproject.mediapackage.track.TrackImpl;
45  import org.opencastproject.mediapackage.track.VideoStreamImpl;
46  import org.opencastproject.util.Checksum;
47  import org.opencastproject.util.ChecksumType;
48  import org.opencastproject.util.MimeType;
49  import org.opencastproject.util.MimeTypes;
50  import org.opencastproject.util.NotFoundException;
51  import org.opencastproject.util.UnknownFileTypeException;
52  import org.opencastproject.util.data.Tuple;
53  import org.opencastproject.workspace.api.Workspace;
54  
55  import org.apache.commons.io.FilenameUtils;
56  import org.apache.commons.lang3.BooleanUtils;
57  import org.slf4j.Logger;
58  import org.slf4j.LoggerFactory;
59  
60  import java.io.File;
61  import java.io.IOException;
62  import java.net.URI;
63  import java.util.Dictionary;
64  import java.util.Hashtable;
65  import java.util.List;
66  import java.util.Map;
67  import java.util.Map.Entry;
68  
69  /**
70   * Contains the business logic for media inspection. Its primary purpose is to decouple the inspection logic from all
71   * OSGi/MH job management boilerplate.
72   */
73  public class MediaInspector {
74  
75    private static final Logger logger = LoggerFactory.getLogger(MediaInspector.class);
76  
77    private final Workspace workspace;
78    private final String ffprobePath;
79  
80    public MediaInspector(Workspace workspace, String ffprobePath) {
81      this.workspace = workspace;
82      this.ffprobePath = ffprobePath;
83    }
84  
85    /**
86     * Inspects the element that is passed in as uri.
87     *
88     * @param trackURI
89     *          the element uri
90     * @return the inspected track
91     * @throws org.opencastproject.inspection.api.MediaInspectionException
92     *           if inspection fails
93     */
94    public Track inspectTrack(URI trackURI, Map<String, String> options) throws MediaInspectionException {
95      logger.debug("inspect(" + trackURI + ") called, using workspace " + workspace);
96      throwExceptionIfInvalid(options);
97  
98      try {
99        // Get the file from the URL (runtime exception if invalid)
100       File file = null;
101       try {
102         file = workspace.get(trackURI);
103       } catch (NotFoundException notFound) {
104         throw new MediaInspectionException("Unable to find resource " + trackURI, notFound);
105       } catch (IOException ioe) {
106         throw new MediaInspectionException("Error reading " + trackURI + " from workspace", ioe);
107       }
108 
109       // Make sure the file has an extension. Otherwise, tools like ffmpeg will not work.
110       // TODO: Try to guess the extension from the container's metadata
111       if ("".equals(FilenameUtils.getExtension(file.getName()))) {
112         throw new MediaInspectionException("Can not inspect files without a filename extension");
113       }
114 
115       MediaContainerMetadata metadata = getFileMetadata(file, getAccurateFrameCount(options));
116       if (metadata == null) {
117         throw new MediaInspectionException("Media analyzer returned no metadata from " + file);
118       } else {
119         MediaPackageElementBuilder elementBuilder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
120         TrackImpl track;
121         MediaPackageElement element;
122         try {
123           element = elementBuilder.elementFromURI(trackURI, MediaPackageElement.Type.Track, null);
124         } catch (UnsupportedElementException e) {
125           throw new MediaInspectionException("Unable to create track element from " + file, e);
126         }
127         track = (TrackImpl) element;
128 
129         // Duration
130         if (metadata.getDuration() != null && metadata.getDuration() > 0)
131           track.setDuration(metadata.getDuration());
132 
133         // Checksum
134         try {
135           track.setChecksum(Checksum.create(ChecksumType.DEFAULT_TYPE, file));
136         } catch (IOException e) {
137           throw new MediaInspectionException("Unable to read " + file, e);
138         }
139 
140         // Mimetype
141         track.setMimeType(metadata.getMimeType());
142         track.setMaster(metadata.getAdaptiveMaster());
143 
144         // Audio metadata
145         try {
146           addAudioStreamMetadata(track, metadata);
147         } catch (Exception e) {
148           throw new MediaInspectionException("Unable to extract audio metadata from " + file, e);
149         }
150 
151         // Video metadata
152         try {
153           addVideoStreamMetadata(track, metadata);
154         } catch (Exception e) {
155           throw new MediaInspectionException("Unable to extract video metadata from " + file, e);
156         }
157 
158         // Subtitle metadata
159         try {
160           addSubtitleStreamMetadata(track, metadata);
161         } catch (Exception e) {
162           throw new MediaInspectionException("Unable to extract subtitle metadata from " + file, e);
163         }
164 
165         // File size
166         track.setSize(file.length());
167 
168         return track;
169       }
170     } catch (Exception e) {
171       logger.warn("Error inspecting " + trackURI, e);
172       if (e instanceof MediaInspectionException) {
173         throw (MediaInspectionException) e;
174       } else {
175         throw new MediaInspectionException(e);
176       }
177     }
178   }
179 
180   /**
181    * Enriches the given element's mediapackage.
182    *
183    * @param element
184    *          the element to enrich
185    * @param override
186    *          <code>true</code> to override existing metadata
187    * @return the enriched element
188    * @throws MediaInspectionException
189    *           if enriching fails
190    */
191   public MediaPackageElement enrich(MediaPackageElement element, boolean override, final Map<String, String> options)
192           throws MediaInspectionException {
193     throwExceptionIfInvalid(options);
194     if (element instanceof Track) {
195       final Track originalTrack = (Track) element;
196       return enrichTrack(originalTrack, override, options);
197     } else {
198       return enrichElement(element, override, options);
199     }
200   }
201 
202   /**
203    * Enriches the track's metadata and can be executed in an asynchronous way.
204    *
205    * @param originalTrack
206    *          the original track
207    * @param override
208    *          <code>true</code> to override existing metadata
209    * @return the media package element
210    * @throws MediaInspectionException
211    */
212   private MediaPackageElement enrichTrack(final Track originalTrack, final boolean override, final Map<String, String> options)
213           throws MediaInspectionException {
214     try {
215       URI originalTrackUrl = originalTrack.getURI();
216       MediaPackageElementFlavor flavor = originalTrack.getFlavor();
217       logger.debug("enrich(" + originalTrackUrl + ") called");
218 
219       // Get the file from the URL
220       File file = null;
221       try {
222         file = workspace.get(originalTrackUrl);
223       } catch (NotFoundException e) {
224         throw new MediaInspectionException("File " + originalTrackUrl + " was not found and can therefore not be "
225             + "inspected", e);
226       } catch (IOException e) {
227         throw new MediaInspectionException("Error accessing " + originalTrackUrl, e);
228       }
229 
230       // Make sure the file has an extension. Otherwise, tools like ffmpeg will not work.
231       // TODO: Try to guess the extension from the container's metadata
232       if ("".equals(FilenameUtils.getExtension(file.getName()))) {
233         throw new MediaInspectionException("Can not inspect files without a filename extension");
234       }
235 
236       MediaContainerMetadata metadata = getFileMetadata(file, getAccurateFrameCount(options));
237       if (metadata == null) {
238         throw new MediaInspectionException("Unable to acquire media metadata for " + originalTrackUrl);
239       } else {
240         TrackImpl track = null;
241         try {
242           track = (TrackImpl) MediaPackageElementBuilderFactory.newInstance().newElementBuilder()
243                   .elementFromURI(originalTrackUrl, MediaPackageElement.Type.Track, flavor);
244         } catch (UnsupportedElementException e) {
245           throw new MediaInspectionException("Unable to create track element from " + file, e);
246         }
247 
248         // init the new track with old
249         track.setChecksum(originalTrack.getChecksum());
250         track.setDuration(originalTrack.getDuration());
251         track.setElementDescription(originalTrack.getElementDescription());
252         track.setFlavor(flavor);
253         track.setIdentifier(originalTrack.getIdentifier());
254         // If HLS
255         if (!originalTrack.hasMaster() || override)
256           track.setMaster(metadata.getAdaptiveMaster());
257         else
258           track.setMaster(originalTrack.isMaster());
259         track.setMimeType(originalTrack.getMimeType());
260         track.setReference(originalTrack.getReference());
261         track.setSize(file.length());
262         track.setURI(originalTrackUrl);
263         for (String tag : originalTrack.getTags()) {
264           track.addTag(tag);
265         }
266 
267         // enrich the new track with basic info
268         if (track.getDuration() == null || override)
269           track.setDuration(metadata.getDuration());
270         if (track.getChecksum() == null || override) {
271           try {
272             track.setChecksum(Checksum.create(ChecksumType.DEFAULT_TYPE, file));
273           } catch (IOException e) {
274             throw new MediaInspectionException("Unable to read " + file, e);
275           }
276         }
277 
278         // Add the mime type if it's not already present
279         if (track.getMimeType() == null || override) {
280             track.setMimeType(metadata.getMimeType());
281         }
282 
283         // find all streams
284         Dictionary<String, Stream> streamsId2Stream = new Hashtable<String, Stream>();
285         for (Stream stream : originalTrack.getStreams()) {
286           streamsId2Stream.put(stream.getIdentifier(), stream);
287         }
288 
289         // audio list
290         try {
291           addAudioStreamMetadata(track, metadata);
292         } catch (Exception e) {
293           throw new MediaInspectionException("Unable to extract audio metadata from " + file, e);
294         }
295 
296         // video list
297         try {
298           addVideoStreamMetadata(track, metadata);
299         } catch (Exception e) {
300           throw new MediaInspectionException("Unable to extract video metadata from " + file, e);
301         }
302 
303         // Subtitle metadata
304         try {
305           addSubtitleStreamMetadata(track, metadata);
306         } catch (Exception e) {
307           throw new MediaInspectionException("Unable to extract subtitle metadata from " + file, e);
308         }
309 
310         logger.info("Successfully inspected track {}", track);
311         return track;
312       }
313     } catch (Exception e) {
314       logger.warn("Error enriching track " + originalTrack, e);
315       if (e instanceof MediaInspectionException) {
316         throw (MediaInspectionException) e;
317       } else {
318         throw new MediaInspectionException(e);
319       }
320     }
321   }
322 
323   /**
324    * Enriches the media package element metadata such as the mime type, the file size etc. The method mutates the
325    * argument element.
326    *
327    * @param element
328    *          the media package element
329    * @param override
330    *          <code>true</code> to overwrite existing metadata
331    * @return the enriched element
332    * @throws MediaInspectionException
333    *           if enriching fails
334    */
335   private MediaPackageElement enrichElement(final MediaPackageElement element, final boolean override,
336           final Map<String, String> options) throws MediaInspectionException {
337     try {
338       File file;
339       try {
340         file = workspace.get(element.getURI());
341       } catch (NotFoundException e) {
342         throw new MediaInspectionException("Unable to find " + element.getURI() + " in the workspace", e);
343       } catch (IOException e) {
344         throw new MediaInspectionException("Error accessing " + element.getURI() + " in the workspace", e);
345       }
346 
347       // Checksum
348       if (element.getChecksum() == null || override) {
349         try {
350           element.setChecksum(Checksum.create(ChecksumType.DEFAULT_TYPE, file));
351         } catch (IOException e) {
352           throw new MediaInspectionException("Error generating checksum for " + element.getURI(), e);
353         }
354       }
355 
356       // Mimetype
357       if (element.getMimeType() == null || override) {
358         try {
359           element.setMimeType(MimeTypes.fromString(file.getPath()));
360         } catch (UnknownFileTypeException e) {
361           logger.info("unable to determine the mime type for {}", file.getName());
362         }
363       }
364 
365       logger.info("Successfully inspected element {}", element);
366 
367       return element;
368     } catch (Exception e) {
369       logger.warn("Error enriching element " + element, e);
370       if (e instanceof MediaInspectionException) {
371         throw (MediaInspectionException) e;
372       } else {
373         throw new MediaInspectionException(e);
374       }
375     }
376   }
377 
378   /**
379    * Asks the media analyzer to extract the file's metadata.
380    *
381    * @param file
382    *          the file
383    * @return the file container metadata
384    * @throws MediaInspectionException
385    *           if metadata extraction fails
386    */
387   private MediaContainerMetadata getFileMetadata(File file, boolean accurateFrameCount) throws MediaInspectionException {
388     if (file == null)
389       throw new IllegalArgumentException("file to analyze cannot be null");
390     try {
391       MediaAnalyzer analyzer = new FFmpegAnalyzer(accurateFrameCount);
392       analyzer.setConfig(map(Tuple.<String, Object> tuple(FFmpegAnalyzer.FFPROBE_BINARY_CONFIG, ffprobePath)));
393 
394       MediaContainerMetadata metadata = analyzer.analyze(file);
395       // - setting mimetype for all media
396       try {
397         // Add mimeType - important for some browsers, eg: safari
398         MimeType mimeType = MimeTypes.fromString(file.getName());
399         // The mimetype library doesn't know about stream metadata, so the type might be wrong.
400         // TODO: these mime types might be incorrect
401         // application/ is a special case for HLS manifest files
402         if (metadata.hasVideoStreamMetadata()) {
403           if (!"video".equals(mimeType.getType()) && !"application".equals(mimeType.getType())) {
404             mimeType = MimeTypes.parseMimeType("video/" + mimeType.getSubtype());
405           }
406         } else if (metadata.hasAudioStreamMetadata()) {
407           if (!"audio".equals(mimeType.getType()) && !"application".equals(mimeType.getType())) {
408             mimeType = MimeTypes.parseMimeType("audio/" + mimeType.getSubtype());
409           }
410         } else if (metadata.hasSubtitleStreamMetadata()) {
411           if (!"text".equals(mimeType.getType()) && !"application".equals(mimeType.getType())) {
412             mimeType = MimeTypes.parseMimeType("text/" + mimeType.getSubtype());
413           }
414         }
415         metadata.setMimeType(mimeType);
416       } catch (UnknownFileTypeException e) {
417         logger.error("parsing mimeType failed for {} : {}", file, e.getMessage());
418         throw new MediaAnalyzerException("parsing mimetype failed for file" + file);
419       }
420       // - setting adaptive play list master
421       // ffmpeg will show format_name == hls
422       // but does not distinguish variant playlist from master
423       try { // parse text only for correct file extensions
424         metadata.setAdaptiveMaster(AdaptivePlaylist.checkForMaster(file));
425       } catch (IOException e) {
426         logger.error("parsing adaptive playlist failed for {} : {}", file, e.getMessage());
427         throw new MediaAnalyzerException("parsing for adaptive playlist master failed for file" + file);
428       }
429       return metadata;
430     } catch (MediaAnalyzerException e) {
431       throw new MediaInspectionException(e);
432     }
433   }
434 
435   /**
436    * Adds the video related metadata to the track.
437    *
438    * @param track
439    *          the track
440    * @param metadata
441    *          the container metadata
442    * @throws Exception
443    *           Media analysis is fragile, and may throw any kind of runtime exceptions due to inconsistencies in the
444    *           media's metadata
445    */
446   private Track addVideoStreamMetadata(TrackImpl track, MediaContainerMetadata metadata) throws Exception {
447     List<VideoStreamMetadata> videoList = metadata.getVideoStreamMetadata();
448     if (videoList != null && !videoList.isEmpty()) {
449       for (int i = 0; i < videoList.size(); i++) {
450         VideoStreamImpl video = new VideoStreamImpl("video-" + (i + 1));
451         VideoStreamMetadata v = videoList.get(i);
452         video.setBitRate(v.getBitRate());
453         video.setFormat(v.getFormat());
454         video.setFormatVersion(v.getFormatVersion());
455         video.setFrameCount(v.getFrames());
456         video.setFrameHeight(v.getFrameHeight());
457         video.setFrameRate(v.getFrameRate());
458         video.setFrameWidth(v.getFrameWidth());
459         video.setScanOrder(v.getScanOrder());
460         video.setScanType(v.getScanType());
461         // TODO: retain the original video metadata
462         track.addStream(video);
463       }
464     }
465     return track;
466   }
467 
468   /**
469    * Adds the audio related metadata to the track.
470    *
471    * @param track
472    *          the track
473    * @param metadata
474    *          the container metadata
475    * @throws Exception
476    *           Media analysis is fragile, and may throw any kind of runtime exceptions due to inconsistencies in the
477    *           media's metadata
478    */
479   private Track addAudioStreamMetadata(TrackImpl track, MediaContainerMetadata metadata) throws Exception {
480     List<AudioStreamMetadata> audioList = metadata.getAudioStreamMetadata();
481     if (audioList != null && !audioList.isEmpty()) {
482       for (int i = 0; i < audioList.size(); i++) {
483         AudioStreamImpl audio = new AudioStreamImpl("audio-" + (i + 1));
484         AudioStreamMetadata a = audioList.get(i);
485         audio.setBitRate(a.getBitRate());
486         audio.setChannels(a.getChannels());
487         audio.setFormat(a.getFormat());
488         audio.setFormatVersion(a.getFormatVersion());
489         audio.setFrameCount(a.getFrames());
490         audio.setBitDepth(a.getResolution());
491         audio.setSamplingRate(a.getSamplingRate());
492         // TODO: retain the original audio metadata
493         track.addStream(audio);
494       }
495     }
496     return track;
497   }
498 
499 
500   /**
501    * Adds the subtitle related metadata to the track.
502    *
503    * @param track
504    *          the track
505    * @param metadata
506    *          the container metadata
507    * @throws Exception
508    *           Media analysis is fragile, and may throw any kind of runtime exceptions due to inconsistencies in the
509    *           media's metadata
510    */
511   private Track addSubtitleStreamMetadata(TrackImpl track, MediaContainerMetadata metadata) throws Exception {
512     List<SubtitleStreamMetadata> subtitleList = metadata.getSubtitleStreamMetadata();
513     if (subtitleList != null && !subtitleList.isEmpty()) {
514       for (int i = 0; i < subtitleList.size(); i++) {
515         SubtitleStreamImpl subtitle = new SubtitleStreamImpl("subtitle-" + (i + 1));
516         SubtitleStreamMetadata s = subtitleList.get(i);
517         subtitle.setFormat(s.getFormat());
518         subtitle.setFormatVersion(s.getFormatVersion());
519         subtitle.setFrameCount(s.getFrames());
520         // TODO: retain the original subtitle metadata
521         track.addStream(subtitle);
522       }
523     }
524     return track;
525   }
526 
527   /* Return true if OPTION_ACCURATE_FRAME_COUNT is set to true, false otherwise */
528   private boolean getAccurateFrameCount(final Map<String, String> options) {
529     return BooleanUtils.toBoolean(options.get(OPTION_ACCURATE_FRAME_COUNT));
530   }
531 
532   /* Throws an exception if an unsupported option is set */
533   private void throwExceptionIfInvalid(final Map<String, String> options) throws MediaInspectionException {
534     if (options != null) {
535       for (Entry e : options.entrySet()) {
536         if (e.getKey().equals(OPTION_ACCURATE_FRAME_COUNT)) {
537           // This option is supported
538         } else {
539           throw new MediaInspectionException("Unsupported option " + e.getKey());
540         }
541       }
542     } else {
543       throw new MediaInspectionException("Options must not be null");
544     }
545   }
546 }