FFmpegAnalyzer.java

/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community License, Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License. You may obtain a copy of the License
 * at:
 *
 *   http://opensource.org/licenses/ecl2.txt
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
 * License for the specific language governing permissions and limitations under
 * the License.
 *
 */
package org.opencastproject.inspection.ffmpeg;

import org.opencastproject.inspection.ffmpeg.api.AudioStreamMetadata;
import org.opencastproject.inspection.ffmpeg.api.MediaAnalyzer;
import org.opencastproject.inspection.ffmpeg.api.MediaAnalyzerException;
import org.opencastproject.inspection.ffmpeg.api.MediaContainerMetadata;
import org.opencastproject.inspection.ffmpeg.api.SubtitleStreamMetadata;
import org.opencastproject.inspection.ffmpeg.api.VideoStreamMetadata;
import org.opencastproject.util.IoSupport;

import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * This MediaAnalyzer implementation uses the ffprobe binary of FFmpeg for media analysis. Also this implementation does
 * not keep control-, text- or other non-audio or video streams and purposefully ignores them during the
 * <code>postProcess()</code> step.
 */
public class FFmpegAnalyzer implements MediaAnalyzer {

  /** Path to the executable */
  protected String binary;

  public static final String FFPROBE_BINARY_CONFIG = "org.opencastproject.inspection.ffprobe.path";
  public static final String FFPROBE_BINARY_DEFAULT = "ffprobe";

  /** Logging facility */
  private static final Logger logger = LoggerFactory.getLogger(FFmpegAnalyzer.class);

  /** Whether the calculation of the frames is accurate or not */
  private boolean accurateFrameCount;

  public FFmpegAnalyzer(boolean accurateFrameCount) {
    this.accurateFrameCount = accurateFrameCount;
    // instantiated using MediaAnalyzerFactory via newInstance()
    this.binary = FFPROBE_BINARY_DEFAULT;
  }

  /**
   * Returns the binary used to provide media inspection functionality.
   *
   * @return the binary
   */
  protected String getBinary() {
    return binary;
  }

  public void setBinary(String binary) {
    this.binary = binary;
  }

  @Override
  public MediaContainerMetadata analyze(File media) throws MediaAnalyzerException {
    if (binary == null) {
      throw new IllegalStateException("Binary is not set");
    }

    List<String> command = new ArrayList<>();
    command.add(binary);
    command.add("-show_format");
    command.add("-show_streams");
    if (accurateFrameCount)
      command.add("-count_frames");
    command.add("-of");
    command.add("json");
    command.add(media.getAbsolutePath().replaceAll(" ", "\\ "));

    /* Execute ffprobe and obtain the result */
    logger.debug("Running {} {}", binary, command);

    MediaContainerMetadata metadata = new MediaContainerMetadata();

    final StringBuilder sb = new StringBuilder();
    Process encoderProcess = null;
    try {
      encoderProcess = new ProcessBuilder(command)
          .redirectError(ProcessBuilder.Redirect.DISCARD)
          .start();

      // tell encoder listeners about output
      try (var in = new BufferedReader(new InputStreamReader(encoderProcess.getInputStream()))) {
        String line;
        while ((line = in.readLine()) != null) {
          logger.debug(line);
          sb.append(line).append(System.getProperty("line.separator"));
        }
      }
      // wait until the task is finished
      int exitCode = encoderProcess.waitFor();
      if (exitCode != 0) {
        throw new MediaAnalyzerException("Frame analyzer " + binary + " exited with code " + exitCode);
      }
    } catch (IOException | InterruptedException e) {
      logger.error("Error executing ffprobe", e);
      throw new MediaAnalyzerException("Error while running " + binary, e);
    } finally {
      IoSupport.closeQuietly(encoderProcess);
    }

    JSONParser parser = new JSONParser();

    try {
      JSONObject jsonObject = (JSONObject) parser.parse(sb.toString());
      Object obj;
      Double duration;

      /* Get format specific stuff */
      JSONObject jsonFormat = (JSONObject) jsonObject.get("format");

      /* File Name */
      obj = jsonFormat.get("filename");
      if (obj != null) {
        metadata.setFileName((String) obj);
      }

      /* Format */
      obj = jsonFormat.get("format_long_name");
      if (obj != null) {
        metadata.setFormat((String) obj);
      }

      /*
       * Mediainfo does not return a duration if there is no stream but FFprobe will return 0. For compatibility
       * reasons, check if there are any streams before reading the duration:
       */
      obj = jsonFormat.get("nb_streams");
      if (obj != null && (Long) obj > 0) {
        obj = jsonFormat.get("duration");
        if (obj != null) {
          duration = Double.parseDouble((String) obj) * 1000;
          metadata.setDuration(duration.longValue());
        }
      }

      /* File Size */
      obj = jsonFormat.get("size");
      if (obj != null) {
        metadata.setSize(Long.parseLong((String) obj));
      }

      /* Bitrate */
      obj = jsonFormat.get("bit_rate");
      if (obj != null) {
        metadata.setBitRate(Float.parseFloat((String) obj));
      }

      /* Loop through streams */
      /*
       * FFprobe will return an empty stream array if there are no streams. Thus we do not need to check.
       */
      JSONArray streams = (JSONArray) jsonObject.get("streams");
      for (JSONObject stream : (Iterable<JSONObject>) streams) {
        /* Check type of string */
        String codecType = (String) stream.get("codec_type");

        /* Handle audio streams ----------------------------- */

        if ("audio".equals(codecType)) {
          /* Extract audio stream metadata */
          AudioStreamMetadata aMetadata = new AudioStreamMetadata();

          /* Codec */
          obj = stream.get("codec_long_name");
          if (obj != null) {
            aMetadata.setFormat((String) obj);
          }

          /* Duration */
          obj = stream.get("duration");
          if (obj != null) {
            duration = new Double((String) obj) * 1000;
            aMetadata.setDuration(duration.longValue());
          } else {
            /*
             * If no duration for this stream is specified assume the duration of the file for this as well.
             */
            aMetadata.setDuration(metadata.getDuration());
          }

          /* Bitrate */
          obj = stream.get("bit_rate");
          if (obj != null) {
            aMetadata.setBitRate(new Float((String) obj));
          }

          /* Channels */
          obj = stream.get("channels");
          if (obj != null) {
            aMetadata.setChannels(((Long) obj).intValue());
          }

          /* Sample Rate */
          obj = stream.get("sample_rate");
          if (obj != null) {
            aMetadata.setSamplingRate(Integer.parseInt((String) obj));
          }

          /* Frame Count */
          obj = stream.get("nb_read_frames");
          if (obj != null) {
            aMetadata.setFrames(Long.parseLong((String) obj));
          } else {

            /* alternate JSON element if accurate frame count is not requested from ffmpeg */
            obj = stream.get("nb_frames");
            if (obj != null) {
              aMetadata.setFrames(Long.parseLong((String) obj));
            }
          }

          /* Add video stream metadata to overall metadata */
          metadata.getAudioStreamMetadata().add(aMetadata);

          /* Handle video streams ----------------------------- */

        } else if ("video".equals(codecType)) {
          /* Extract video stream metadata */
          VideoStreamMetadata vMetadata = new VideoStreamMetadata();

          /* Codec */
          obj = stream.get("codec_long_name");
          if (obj != null) {
            vMetadata.setFormat((String) obj);
          }

          /* Duration */
          obj = stream.get("duration");
          if (obj != null) {
            duration = new Double((String) obj) * 1000;
            vMetadata.setDuration(duration.longValue());
          } else {
            /*
             * If no duration for this stream is specified assume the duration of the file for this as well.
             */
            vMetadata.setDuration(metadata.getDuration());
          }

          /* Bitrate */
          obj = stream.get("bit_rate");
          if (obj != null) {
            vMetadata.setBitRate(new Float((String) obj));
          }

          /* Width */
          obj = stream.get("width");
          if (obj != null) {
            vMetadata.setFrameWidth(((Long) obj).intValue());
          }

          /* Height */
          obj = stream.get("height");
          if (obj != null) {
            vMetadata.setFrameHeight(((Long) obj).intValue());
          }

          /* Profile */
          obj = stream.get("profile");
          if (obj != null) {
            vMetadata.setFormatProfile((String) obj);
          }

          /* Aspect Ratio */
          obj = stream.get("sample_aspect_ratio");
          if (obj != null) {
            vMetadata.setPixelAspectRatio(parseFloat((String) obj));
          }

          /* Frame Rate */
          obj = stream.get("avg_frame_rate");
          if (obj != null) {
            vMetadata.setFrameRate(parseFloat((String) obj));
          }

          /* Frame Count */
          obj = stream.get("nb_read_frames");
          if (obj != null) {
            vMetadata.setFrames(Long.parseLong((String) obj));
          } else {

            /* alternate JSON element if accurate frame count is not requested from ffmpeg */
            obj = stream.get("nb_frames");
            if (obj != null) {
              vMetadata.setFrames(Long.parseLong((String) obj));
            } else if (vMetadata.getDuration() != null && vMetadata.getFrameRate() != null) {
              long framesEstimation = Double.valueOf(vMetadata.getDuration() / 1000.0 * vMetadata.getFrameRate())
                  .longValue();
              if (framesEstimation >= 1) {
                vMetadata.setFrames(framesEstimation);
              }
            }
          }

          /* Add video stream metadata to overall metadata */
          metadata.getVideoStreamMetadata().add(vMetadata);

          /* Handle subtitle streams ----------------------------- */

        } else if ("subtitle".equals(codecType)) {
          /* Extract subtitle stream metadata */
          SubtitleStreamMetadata sMetadata = new SubtitleStreamMetadata();

          /* Codec */
          obj = stream.get("codec_long_name");
          if (obj != null) {
            sMetadata.setFormat((String) obj);
          }

          metadata.getSubtitleStreamMetadata().add(sMetadata);
        }
      }

    } catch (ParseException e) {
      logger.error("Error parsing ffprobe output: {}", e.getMessage());
    }

    return metadata;
  }

  /**
   * Allows configuration {@inheritDoc}
   *
   * @see org.opencastproject.inspection.ffmpeg.api.MediaAnalyzer#setConfig(java.util.Map)
   */
  @Override
  public void setConfig(Map<String, Object> config) {
    if (config != null) {
      if (config.containsKey(FFPROBE_BINARY_CONFIG)) {
        String binary = (String) config.get(FFPROBE_BINARY_CONFIG);
        setBinary(binary);
        logger.debug("FFmpegAnalyzer config binary: " + binary);
      }
    }
  }

  private float parseFloat(String val) {
    if (val.contains("/")) {
      String[] v = val.split("/");
      if (Float.parseFloat(v[1]) == 0) {
        return 0;
      } else {
        return Float.parseFloat(v[0]) / Float.parseFloat(v[1]);
      }
    } else if (val.contains(":")) {
      String[] v = val.split(":");
      if (Float.parseFloat(v[1]) == 0) {
        return 0;
      } else {
        return Float.parseFloat(v[0]) / Float.parseFloat(v[1]);
      }
    } else {
      return Float.parseFloat(val);
    }
  }

}