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 
134         // Checksum
135         try {
136           track.setChecksum(Checksum.create(ChecksumType.DEFAULT_TYPE, file));
137         } catch (IOException e) {
138           throw new MediaInspectionException("Unable to read " + file, e);
139         }
140 
141         // Mimetype
142         track.setMimeType(metadata.getMimeType());
143         track.setMaster(metadata.getAdaptiveMaster());
144 
145         // Audio metadata
146         try {
147           addAudioStreamMetadata(track, metadata);
148         } catch (Exception e) {
149           throw new MediaInspectionException("Unable to extract audio metadata from " + file, e);
150         }
151 
152         // Video metadata
153         try {
154           addVideoStreamMetadata(track, metadata);
155         } catch (Exception e) {
156           throw new MediaInspectionException("Unable to extract video metadata from " + file, e);
157         }
158 
159         // Subtitle metadata
160         try {
161           addSubtitleStreamMetadata(track, metadata);
162         } catch (Exception e) {
163           throw new MediaInspectionException("Unable to extract subtitle metadata from " + file, e);
164         }
165 
166         // File size
167         track.setSize(file.length());
168 
169         return track;
170       }
171     } catch (Exception e) {
172       logger.warn("Error inspecting " + trackURI, e);
173       if (e instanceof MediaInspectionException) {
174         throw (MediaInspectionException) e;
175       } else {
176         throw new MediaInspectionException(e);
177       }
178     }
179   }
180 
181   /**
182    * Enriches the given element's mediapackage.
183    *
184    * @param element
185    *          the element to enrich
186    * @param override
187    *          <code>true</code> to override existing metadata
188    * @return the enriched element
189    * @throws MediaInspectionException
190    *           if enriching fails
191    */
192   public MediaPackageElement enrich(MediaPackageElement element, boolean override, final Map<String, String> options)
193           throws MediaInspectionException {
194     throwExceptionIfInvalid(options);
195     if (element instanceof Track) {
196       final Track originalTrack = (Track) element;
197       return enrichTrack(originalTrack, override, options);
198     } else {
199       return enrichElement(element, override, options);
200     }
201   }
202 
203   /**
204    * Enriches the track's metadata and can be executed in an asynchronous way.
205    *
206    * @param originalTrack
207    *          the original track
208    * @param override
209    *          <code>true</code> to override existing metadata
210    * @return the media package element
211    * @throws MediaInspectionException
212    */
213   private MediaPackageElement enrichTrack(final Track originalTrack, final boolean override,
214       final Map<String, String> options) throws MediaInspectionException {
215     try {
216       URI originalTrackUrl = originalTrack.getURI();
217       MediaPackageElementFlavor flavor = originalTrack.getFlavor();
218       logger.debug("enrich(" + originalTrackUrl + ") called");
219 
220       // Get the file from the URL
221       File file = null;
222       try {
223         file = workspace.get(originalTrackUrl);
224       } catch (NotFoundException e) {
225         throw new MediaInspectionException("File " + originalTrackUrl + " was not found and can therefore not be "
226             + "inspected", e);
227       } catch (IOException e) {
228         throw new MediaInspectionException("Error accessing " + originalTrackUrl, e);
229       }
230 
231       // Make sure the file has an extension. Otherwise, tools like ffmpeg will not work.
232       // TODO: Try to guess the extension from the container's metadata
233       if ("".equals(FilenameUtils.getExtension(file.getName()))) {
234         throw new MediaInspectionException("Can not inspect files without a filename extension");
235       }
236 
237       MediaContainerMetadata metadata = getFileMetadata(file, getAccurateFrameCount(options));
238       if (metadata == null) {
239         throw new MediaInspectionException("Unable to acquire media metadata for " + originalTrackUrl);
240       } else {
241         TrackImpl track = null;
242         try {
243           track = (TrackImpl) MediaPackageElementBuilderFactory.newInstance().newElementBuilder()
244                   .elementFromURI(originalTrackUrl, MediaPackageElement.Type.Track, flavor);
245         } catch (UnsupportedElementException e) {
246           throw new MediaInspectionException("Unable to create track element from " + file, e);
247         }
248 
249         // init the new track with old
250         track.setChecksum(originalTrack.getChecksum());
251         track.setDuration(originalTrack.getDuration());
252         track.setElementDescription(originalTrack.getElementDescription());
253         track.setFlavor(flavor);
254         track.setIdentifier(originalTrack.getIdentifier());
255         // If HLS
256         if (!originalTrack.hasMaster() || override) {
257           track.setMaster(metadata.getAdaptiveMaster());
258         } else {
259           track.setMaster(originalTrack.isMaster());
260         }
261         track.setMimeType(originalTrack.getMimeType());
262         track.setReference(originalTrack.getReference());
263         track.setSize(file.length());
264         track.setURI(originalTrackUrl);
265         for (String tag : originalTrack.getTags()) {
266           track.addTag(tag);
267         }
268 
269         // enrich the new track with basic info
270         if (track.getDuration() == null || override) {
271           track.setDuration(metadata.getDuration());
272         }
273         if (track.getChecksum() == null || override) {
274           try {
275             track.setChecksum(Checksum.create(ChecksumType.DEFAULT_TYPE, file));
276           } catch (IOException e) {
277             throw new MediaInspectionException("Unable to read " + file, e);
278           }
279         }
280 
281         // Add the mime type if it's not already present
282         if (track.getMimeType() == null || override) {
283           track.setMimeType(metadata.getMimeType());
284         }
285 
286         // find all streams
287         Dictionary<String, Stream> streamsId2Stream = new Hashtable<String, Stream>();
288         for (Stream stream : originalTrack.getStreams()) {
289           streamsId2Stream.put(stream.getIdentifier(), stream);
290         }
291 
292         // audio list
293         try {
294           addAudioStreamMetadata(track, metadata);
295         } catch (Exception e) {
296           throw new MediaInspectionException("Unable to extract audio metadata from " + file, e);
297         }
298 
299         // video list
300         try {
301           addVideoStreamMetadata(track, metadata);
302         } catch (Exception e) {
303           throw new MediaInspectionException("Unable to extract video metadata from " + file, e);
304         }
305 
306         // Subtitle metadata
307         try {
308           addSubtitleStreamMetadata(track, metadata);
309         } catch (Exception e) {
310           throw new MediaInspectionException("Unable to extract subtitle metadata from " + file, e);
311         }
312 
313         logger.info("Successfully inspected track {}", track);
314         return track;
315       }
316     } catch (Exception e) {
317       logger.warn("Error enriching track " + originalTrack, e);
318       if (e instanceof MediaInspectionException) {
319         throw (MediaInspectionException) e;
320       } else {
321         throw new MediaInspectionException(e);
322       }
323     }
324   }
325 
326   /**
327    * Enriches the media package element metadata such as the mime type, the file size etc. The method mutates the
328    * argument element.
329    *
330    * @param element
331    *          the media package element
332    * @param override
333    *          <code>true</code> to overwrite existing metadata
334    * @return the enriched element
335    * @throws MediaInspectionException
336    *           if enriching fails
337    */
338   private MediaPackageElement enrichElement(final MediaPackageElement element, final boolean override,
339           final Map<String, String> options) throws MediaInspectionException {
340     try {
341       File file;
342       try {
343         file = workspace.get(element.getURI());
344       } catch (NotFoundException e) {
345         throw new MediaInspectionException("Unable to find " + element.getURI() + " in the workspace", e);
346       } catch (IOException e) {
347         throw new MediaInspectionException("Error accessing " + element.getURI() + " in the workspace", e);
348       }
349 
350       // Checksum
351       if (element.getChecksum() == null || override) {
352         try {
353           element.setChecksum(Checksum.create(ChecksumType.DEFAULT_TYPE, file));
354         } catch (IOException e) {
355           throw new MediaInspectionException("Error generating checksum for " + element.getURI(), e);
356         }
357       }
358 
359       // Mimetype
360       if (element.getMimeType() == null || override) {
361         try {
362           element.setMimeType(MimeTypes.fromString(file.getPath()));
363         } catch (UnknownFileTypeException e) {
364           logger.info("unable to determine the mime type for {}", file.getName());
365         }
366       }
367 
368       logger.info("Successfully inspected element {}", element);
369 
370       return element;
371     } catch (Exception e) {
372       logger.warn("Error enriching element " + element, e);
373       if (e instanceof MediaInspectionException) {
374         throw (MediaInspectionException) e;
375       } else {
376         throw new MediaInspectionException(e);
377       }
378     }
379   }
380 
381   /**
382    * Asks the media analyzer to extract the file's metadata.
383    *
384    * @param file
385    *          the file
386    * @return the file container metadata
387    * @throws MediaInspectionException
388    *           if metadata extraction fails
389    */
390   private MediaContainerMetadata getFileMetadata(File file, boolean accurateFrameCount)
391           throws MediaInspectionException {
392     if (file == null) {
393       throw new IllegalArgumentException("file to analyze cannot be null");
394     }
395     try {
396       MediaAnalyzer analyzer = new FFmpegAnalyzer(accurateFrameCount);
397       analyzer.setConfig(map(Tuple.<String, Object> tuple(FFmpegAnalyzer.FFPROBE_BINARY_CONFIG, ffprobePath)));
398 
399       MediaContainerMetadata metadata = analyzer.analyze(file);
400       // - setting mimetype for all media
401       try {
402         // Add mimeType - important for some browsers, eg: safari
403         MimeType mimeType = MimeTypes.fromString(file.getName());
404         // The mimetype library doesn't know about stream metadata, so the type might be wrong.
405         // TODO: these mime types might be incorrect
406         // application/ is a special case for HLS manifest files
407         if (metadata.hasVideoStreamMetadata()) {
408           if (!"video".equals(mimeType.getType()) && !"application".equals(mimeType.getType())) {
409             mimeType = MimeTypes.parseMimeType("video/" + mimeType.getSubtype());
410           }
411         } else if (metadata.hasAudioStreamMetadata()) {
412           if (!"audio".equals(mimeType.getType()) && !"application".equals(mimeType.getType())) {
413             mimeType = MimeTypes.parseMimeType("audio/" + mimeType.getSubtype());
414           }
415         } else if (metadata.hasSubtitleStreamMetadata()) {
416           if (!"text".equals(mimeType.getType()) && !"application".equals(mimeType.getType())) {
417             mimeType = MimeTypes.parseMimeType("text/" + mimeType.getSubtype());
418           }
419         }
420         metadata.setMimeType(mimeType);
421       } catch (UnknownFileTypeException e) {
422         logger.error("parsing mimeType failed for {} : {}", file, e.getMessage());
423         throw new MediaAnalyzerException("parsing mimetype failed for file" + file);
424       }
425       // - setting adaptive play list master
426       // ffmpeg will show format_name == hls
427       // but does not distinguish variant playlist from master
428       try { // parse text only for correct file extensions
429         metadata.setAdaptiveMaster(AdaptivePlaylist.checkForMaster(file));
430       } catch (IOException e) {
431         logger.error("parsing adaptive playlist failed for {} : {}", file, e.getMessage());
432         throw new MediaAnalyzerException("parsing for adaptive playlist master failed for file" + file);
433       }
434       return metadata;
435     } catch (MediaAnalyzerException e) {
436       throw new MediaInspectionException(e);
437     }
438   }
439 
440   /**
441    * Adds the video related metadata to the track.
442    *
443    * @param track
444    *          the track
445    * @param metadata
446    *          the container metadata
447    * @throws Exception
448    *           Media analysis is fragile, and may throw any kind of runtime exceptions due to inconsistencies in the
449    *           media's metadata
450    */
451   private Track addVideoStreamMetadata(TrackImpl track, MediaContainerMetadata metadata) throws Exception {
452     List<VideoStreamMetadata> videoList = metadata.getVideoStreamMetadata();
453     if (videoList != null && !videoList.isEmpty()) {
454       for (int i = 0; i < videoList.size(); i++) {
455         VideoStreamImpl video = new VideoStreamImpl("video-" + (i + 1));
456         VideoStreamMetadata v = videoList.get(i);
457         video.setBitRate(v.getBitRate());
458         video.setFormat(v.getFormat());
459         video.setFormatVersion(v.getFormatVersion());
460         video.setFrameCount(v.getFrames());
461         video.setFrameHeight(v.getFrameHeight());
462         video.setFrameRate(v.getFrameRate());
463         video.setFrameWidth(v.getFrameWidth());
464         video.setScanOrder(v.getScanOrder());
465         video.setScanType(v.getScanType());
466         // TODO: retain the original video metadata
467         track.addStream(video);
468       }
469     }
470     return track;
471   }
472 
473   /**
474    * Adds the audio related metadata to the track.
475    *
476    * @param track
477    *          the track
478    * @param metadata
479    *          the container metadata
480    * @throws Exception
481    *           Media analysis is fragile, and may throw any kind of runtime exceptions due to inconsistencies in the
482    *           media's metadata
483    */
484   private Track addAudioStreamMetadata(TrackImpl track, MediaContainerMetadata metadata) throws Exception {
485     List<AudioStreamMetadata> audioList = metadata.getAudioStreamMetadata();
486     if (audioList != null && !audioList.isEmpty()) {
487       for (int i = 0; i < audioList.size(); i++) {
488         AudioStreamImpl audio = new AudioStreamImpl("audio-" + (i + 1));
489         AudioStreamMetadata a = audioList.get(i);
490         audio.setBitRate(a.getBitRate());
491         audio.setChannels(a.getChannels());
492         audio.setFormat(a.getFormat());
493         audio.setFormatVersion(a.getFormatVersion());
494         audio.setFrameCount(a.getFrames());
495         audio.setBitDepth(a.getResolution());
496         audio.setSamplingRate(a.getSamplingRate());
497         // TODO: retain the original audio metadata
498         track.addStream(audio);
499       }
500     }
501     return track;
502   }
503 
504 
505   /**
506    * Adds the subtitle related metadata to the track.
507    *
508    * @param track
509    *          the track
510    * @param metadata
511    *          the container metadata
512    * @throws Exception
513    *           Media analysis is fragile, and may throw any kind of runtime exceptions due to inconsistencies in the
514    *           media's metadata
515    */
516   private Track addSubtitleStreamMetadata(TrackImpl track, MediaContainerMetadata metadata) throws Exception {
517     List<SubtitleStreamMetadata> subtitleList = metadata.getSubtitleStreamMetadata();
518     if (subtitleList != null && !subtitleList.isEmpty()) {
519       for (int i = 0; i < subtitleList.size(); i++) {
520         SubtitleStreamImpl subtitle = new SubtitleStreamImpl("subtitle-" + (i + 1));
521         SubtitleStreamMetadata s = subtitleList.get(i);
522         subtitle.setFormat(s.getFormat());
523         subtitle.setFormatVersion(s.getFormatVersion());
524         subtitle.setFrameCount(s.getFrames());
525         // TODO: retain the original subtitle metadata
526         track.addStream(subtitle);
527       }
528     }
529     return track;
530   }
531 
532   /* Return true if OPTION_ACCURATE_FRAME_COUNT is set to true, false otherwise */
533   private boolean getAccurateFrameCount(final Map<String, String> options) {
534     return BooleanUtils.toBoolean(options.get(OPTION_ACCURATE_FRAME_COUNT));
535   }
536 
537   /* Throws an exception if an unsupported option is set */
538   private void throwExceptionIfInvalid(final Map<String, String> options) throws MediaInspectionException {
539     if (options != null) {
540       for (Entry e : options.entrySet()) {
541         if (e.getKey().equals(OPTION_ACCURATE_FRAME_COUNT)) {
542           // This option is supported
543         } else {
544           throw new MediaInspectionException("Unsupported option " + e.getKey());
545         }
546       }
547     } else {
548       throw new MediaInspectionException("Options must not be null");
549     }
550   }
551 }