/**
  * Test the level of functionality available on this system
  *
  * @return String indicating the level of available functionality
  */
 private String testExecLevel() {
   // Make sure we can start
   if (ffmpeg == null) {
     ffmpeg =
         new FfmpegImpl(get(config, "binaries/transcoding"), get(config, "binaries/metadata"));
   }
   return ffmpeg.testAvailability();
 }
 /**
  * Wrap a basic conversion used repeatedly during file merges
  *
  * @param in: The source file
  * @param out: The outout file
  * @return String: FFmpeg's console output
  * @thorws IOException: if there are file access errors
  */
 private String mergeRender(File in, File out) throws IOException {
   // Render config
   List<String> params = new ArrayList();
   params.add("-i");
   params.add(in.getAbsolutePath());
   params.add("-sameq");
   params.add("-r");
   if (out.getName().equals(MERGED_PAYLOAD + finalFormat)) {
     params.add(finalRate);
   } else {
     params.add(mergeRate);
   }
   params.add(out.getAbsolutePath());
   // Render
   return ffmpeg.transform(params, outputDir);
 }
  /** Reset the transformer in preparation for a new object */
  private void reset() throws TransformerException {
    if (firstRun) {
      firstRun = false;
      testExecLevel();

      // Prep output area
      String outputPath = config.get("outputPath");
      outputRoot = new File(outputPath);
      outputRoot.mkdirs();

      // Database
      String useDB = config.get("database/enabled", "false");
      if (Boolean.parseBoolean(useDB)) {
        try {
          stats = new FfmpegDatabase(config);
        } catch (Exception ex) {
          log.error("Statistics database failed to initialise!");
        }
      }

      // Set system variable for presets location
      String presetsPath = config.get("presetsPath");
      if (presetsPath != null) {
        File presetDir = new File(presetsPath);
        // Make sure it's valid
        if (presetDir.exists() && presetDir.isDirectory()) {
          // And let FFmpeg know about it
          ffmpeg.setEnvironmentVariable("FFMPEG_DATADIR", presetDir.getAbsolutePath());
        } else {
          log.error("Invalid FFmpeg presets path provided: '{}'", presetsPath);
        }
      }
    }

    itemConfig = null;
    info = null;
    format = null;
    errors = new LinkedHashMap();
    metadata = new LinkedHashMap();
    oldMetadata = new LinkedHashMap();
  }
  /**
   * Convert audio/video to required output(s)
   *
   * @param sourceFile : The file to be converted
   * @param render : Configuration to use during the render
   * @param info : Parsed metadata about the source
   * @return File containing converted media
   * @throws TransformerException if the conversion failed
   */
  private File convert(File sourceFile, JsonConfigHelper render, FfmpegInfo info)
      throws TransformerException {

    // Statistics variables
    long startTime, timeSpent;
    String resolution;
    // One list for all settings, the other is a subset for statistics
    List<String> statParams = new ArrayList<String>();
    List<String> params = new ArrayList<String>();

    // Prepare the output location
    String outputName = render.get("name");
    if (outputName == null) {
      return null;
    }
    File outputFile = new File(outputDir, outputName);
    if (outputFile.exists()) {
      FileUtils.deleteQuietly(outputFile);
    }
    log.info("Converting '{}': '{}'", sourceFile.getName(), outputFile.getName());

    // Get metadata ready
    JsonConfigHelper renderMetadata = new JsonConfigHelper();
    String key = jsonKey(outputName);
    String formatString = render.get("formatMetadata");
    if (formatString != null) {
      renderMetadata.set("format", formatString);
    }
    String codecString = render.get("codecMetadata");
    if (codecString != null) {
      renderMetadata.set("codec", codecString);
    }

    try {
      // *************
      // 1) Input file
      // *************
      params.add("-i");
      params.add(sourceFile.getAbsolutePath());
      // Overwrite output file if it exists
      params.add("-y");

      // *************
      // 2) Configurable options
      // *************
      String optionStr = render.get("options", "");
      List<String> options = split(optionStr, " ");
      // Replace the offset placeholder now that we know the duration
      long start = 0;
      for (int i = 0; i < options.size(); i++) {
        String option = options.get(i);
        // For stats, use placeholder.. random data messes with hashing
        statParams.add(option);
        // If it even exists that is...
        if (option.equalsIgnoreCase("[[OFFSET]]")) {
          start = (long) (Math.random() * info.getDuration() * 0.25);
          option = Long.toString(start);
        }
        // Store the parameter for usage
        params.add(option);
      }

      // *************
      // 3) Video resolution / padding
      // *************
      String audioStr = render.get("audioOnly");
      boolean audio = Boolean.parseBoolean(audioStr);

      // Non-audio files need some resolution work
      if (!audio) {
        List<String> dimensions = getPaddedParams(render, info, renderMetadata, statParams);
        if (dimensions == null || dimensions.isEmpty()) {
          addError(key, "Error calculating dimensions");
          return null;
        }
        // Merge resultion parameters into standard parameters
        params.addAll(dimensions);
      }
      // Statistics
      String width = renderMetadata.get("width");
      String height = renderMetadata.get("height");
      if (width == null || height == null) {
        // Audio... or an error
        resolution = "0x0";
      } else {
        resolution = width + "x" + height;
      }

      // *************
      // 4) Output options
      // *************
      optionStr = render.get("output", "");
      options = split(optionStr, " ");
      // Merge option parameters into standard parameters
      if (!options.isEmpty()) {
        params.addAll(options);
        statParams.addAll(options);
      }
      params.add(outputFile.getAbsolutePath());

      // *************
      // 5) All done. Perform the transcoding
      // *************
      startTime = new Date().getTime();
      String stderr = ffmpeg.transform(params, outputDir);
      timeSpent = (new Date().getTime()) - startTime;

      renderMetadata.set("timeSpent", String.valueOf(timeSpent));
      renderMetadata.set("debugOutput", stderr);
      if (outputFile.exists()) {
        long fileSize = outputFile.length();
        if (fileSize == 0) {
          throw new TransformerException("File conversion failed!\n=====\n" + stderr);
        } else {
          renderMetadata.set("size", String.valueOf(fileSize));
        }
      } else {
        throw new TransformerException("File conversion failed!\n=====\n" + stderr);
      }

      // log.debug("FFMPEG Output:\n=====\n\\/\\/\\/\\/\n{}/\\/\\/\\/\\\n=====\n",
      // stderr);
    } catch (IOException ioe) {
      addError(key, "Failed to convert!", ioe);
      throw new TransformerException(ioe);
    }

    // On a multi-pass encoding we may be asked to
    // throw away the video from some passes.
    if (outputFile.getName().contains("nullFile")) {
      return null;
    } else {
      // For anything else, record metadata
      metadata.put(key, renderMetadata);
      // And statistics
      if (stats != null) {
        Map<String, String> data = new HashMap();
        data.put("oid", oid);
        data.put("datetime", String.valueOf(startTime));
        data.put("timespent", String.valueOf(timeSpent));
        data.put("renderString", StringUtils.join(statParams, " "));
        data.put("mediaduration", String.valueOf(info.getDuration()));
        data.put("inresolution", info.getWidth() + "x" + info.getHeight());
        data.put("outresolution", resolution);
        data.put("insize", String.valueOf(sourceFile.length()));
        data.put("outsize", String.valueOf(outputFile.length()));
        data.put("infile", sourceFile.getName());
        data.put("outfile", outputFile.getName());
        try {
          stats.storeTranscoding(data);
        } catch (Exception ex) {
          log.error("Error storing statistics: ", ex);
        }
      }
    }
    return outputFile;
  }
  /**
   * Transforming digital object method
   *
   * @params object: DigitalObject to be transformed
   * @return transformed DigitalObject after transformation
   * @throws TransformerException if the transformation fails
   */
  @Override
  public DigitalObject transform(DigitalObject object, String jsonConfig)
      throws TransformerException {
    if (testExecLevel() == null) {
      log.error("FFmpeg is either not installed, or not executing!");
      return object;
    }
    // Purge old data
    reset();
    oid = object.getId();
    outputDir = new File(outputRoot, oid);
    outputDir.mkdirs();

    try {
      itemConfig = new JsonConfigHelper(jsonConfig);
    } catch (IOException ex) {
      throw new TransformerException("Invalid configuration! '{}'", ex);
    }

    // Resolve multi-segment files first
    mergeRate = get(itemConfig, "merging/mpegFrameRate", "25");
    finalRate = get(itemConfig, "merging/finalFrameRate", "10");
    finalFormat = get(itemConfig, "merging/finalFormat", "avi");
    mergeSegments(object);

    // Find the format 'group' this file is in
    String sourceId = object.getSourceId();
    String ext = FilenameUtils.getExtension(sourceId);
    format = getFormat(ext);

    // Return now if this isn't a format we care about
    if (format == null) {
      return object;
    }
    // log.debug("Supported format found: '{}' => '{}'", ext, format);

    // Cache the file from storage
    File file;
    try {
      file = cacheFile(object, sourceId);
    } catch (IOException ex) {
      addError(sourceId, "Error writing temp file", ex);
      errorAndClose(object);
      return object;
    } catch (StorageException ex) {
      addError(sourceId, "Error accessing storage data", ex);
      errorAndClose(object);
      return object;
    }
    if (!file.exists()) {
      addError(sourceId, "Unknown error writing cache: does not exist");
      errorAndClose(object);
      return object;
    }

    // **************************************************************
    // From here on we know (assume) that we SHOULD be able to support
    // this object, so errors can't just throw exceptions. We should
    // only return under certain circumstances (ie. not just because
    // one rendition fails), and the object must get closed.
    // **************************************************************

    // Read any pre-existing rendition metadata from previous tranformations
    // ++++++++++++++++++++++++++++
    // TODO: This is useless until the last modified date can be retrieved
    // against the source file. Storage API does not currently support this,
    // it just returns a data stream.
    //
    // Once this feature exists the basic algorithm should be:
    // 1) Retrieve old metadata
    // 2) Loop through each rendition preparation as normal
    // 3) When the transcoding is ready to start, use the parameters to
    // query the database for the last time the exact transcoding was
    // and comparing against last modifed.
    // 4) If the transcoding is newer than the source, skip running FFmpeg
    // and just use the same metadata as last time.
    // ++++++++++++++++++++++++++++
    // readMetadata(object);

    // Check for a custom display type
    String display = get(itemConfig, "displayTypes/" + format);
    if (display != null) {
      try {
        Properties prop = object.getMetadata();
        prop.setProperty("displayType", display);
        prop.setProperty("previewType", display);
      } catch (StorageException ex) {
        addError("display", "Could not access object metadata", ex);
      }
    }

    // Gather metadata
    try {
      info = ffmpeg.getInfo(file);
    } catch (IOException ex) {
      addError("metadata", "Error accessing metadata", ex);
      errorAndClose(object);
      return object;
    }

    // Can we even process this file?
    if (!info.isSupported()) {
      closeObject(object);
      return object;
    }

    // What conversions are required for this format?
    List<JsonConfigHelper> conversions = getJsonList(itemConfig, "transcodings/" + format);
    for (JsonConfigHelper conversion : conversions) {
      String name = conversion.get("alias");
      // And what/how many renditions does it have?
      List<JsonConfigHelper> renditions = conversion.getJsonList("renditions");
      if (renditions == null || renditions.isEmpty()) {
        addError(
            "transcodings",
            "Invalid or missing transcoding data:" + " '/transcodings/" + format + "'");
      } else {
        // Config look valid, lets give it a try
        // log.debug("Starting renditions for '{}'", name);
        for (JsonConfigHelper render : renditions) {
          File converted = null;
          // Render the output
          try {
            converted = convert(file, render, info);
          } catch (Exception ex) {
            String outputFile = render.get("name");
            if (outputFile != null) {
              addError(jsonKey(outputFile), "Error converting file", ex);
            } else {
              // Couldn't read the config for a name
              addError("unknown", "Error converting file", ex);
            }
          }

          // Now store the output if valid
          if (converted != null) {
            try {
              Payload payload = createFfmpegPayload(object, converted);
              // TODO: Type checking needs more work
              // Indexing fails silently if you add two thumbnails
              // or two previews
              payload.setType(resolveType(render.get("type")));
              payload.close();
            } catch (Exception ex) {
              addError(jsonKey(converted.getName()), "Error storing output", ex);
            } finally {
              converted.delete();
            }
          }
        }
      }
    }

    // Write metadata to storage
    if (compileMetadata(object)) {
      // Close normally
      closeObject(object);
    } else {
      // Close with some errors
      errorAndClose(object);
    }
    // Cleanup
    if (file.exists()) {
      file.delete();
    }
    return object;
  }