CaptionServiceImpl.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.caption.impl;

import static org.opencastproject.util.MimeType.mimeType;

import org.opencastproject.caption.api.Caption;
import org.opencastproject.caption.api.CaptionConverter;
import org.opencastproject.caption.api.CaptionConverterException;
import org.opencastproject.caption.api.CaptionService;
import org.opencastproject.caption.api.UnsupportedCaptionFormatException;
import org.opencastproject.job.api.AbstractJobProducer;
import org.opencastproject.job.api.Job;
import org.opencastproject.mediapackage.MediaPackageElement;
import org.opencastproject.mediapackage.MediaPackageElementBuilder;
import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
import org.opencastproject.mediapackage.MediaPackageElementFlavor;
import org.opencastproject.mediapackage.MediaPackageElementParser;
import org.opencastproject.mediapackage.MediaPackageException;
import org.opencastproject.security.api.OrganizationDirectoryService;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.api.UserDirectoryService;
import org.opencastproject.serviceregistry.api.ServiceRegistry;
import org.opencastproject.serviceregistry.api.ServiceRegistryException;
import org.opencastproject.util.IoSupport;
import org.opencastproject.util.LoadUtil;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.workspace.api.Workspace;

import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.osgi.framework.InvalidSyntaxException;
import org.osgi.framework.ServiceReference;
import org.osgi.service.cm.ConfigurationException;
import org.osgi.service.cm.ManagedService;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.util.Arrays;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.List;

import javax.activation.FileTypeMap;

/**
 * Implementation of {@link CaptionService}. Uses {@link ComponentContext} to get all registered
 * {@link CaptionConverter}s. Converters are searched based on <code>caption.format</code> property. If there is no
 * match for specified input or output format {@link UnsupportedCaptionFormatException} is thrown.
 *
 */
@Component(
    immediate = true,
    service = { CaptionService.class,ManagedService.class },
    property = {
        "service.description=Caption Converter Service",
        "service.pid=org.opencastproject.caption.impl.CaptionServiceImpl"
    }
)
public class CaptionServiceImpl extends AbstractJobProducer implements CaptionService, ManagedService {

  /**
   * Creates a new caption service.
   */
  public CaptionServiceImpl() {
    super(JOB_TYPE);
  }

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

  /** List of available operations on jobs */
  private enum Operation {
    Convert, ConvertWithLanguage
  };

  /** The collection name */
  public static final String COLLECTION = "captions";

  /** The load introduced on the system by creating a caption job */
  public static final float DEFAULT_CAPTION_JOB_LOAD = 0.1f;

  /** The key to look for in the service configuration file to override the {@link DEFAULT_CAPTION_JOB_LOAD} */
  public static final String CAPTION_JOB_LOAD_KEY = "job.load.caption";

  /** The load introduced on the system by creating a caption job */
  private float captionJobLoad = DEFAULT_CAPTION_JOB_LOAD;

  /** Reference to workspace */
  protected Workspace workspace;

  /** Reference to remote service manager */
  protected ServiceRegistry serviceRegistry;

  /** The security service */
  protected SecurityService securityService = null;

  /** The user directory service */
  protected UserDirectoryService userDirectoryService = null;

  /** The organization directory service */
  protected OrganizationDirectoryService organizationDirectoryService = null;

  /** Component context needed for retrieving Converter Engines */
  protected ComponentContext componentContext = null;

  /**
   * Activate this service implementation via the OSGI service component runtime.
   *
   * @param componentContext
   *          the component context
   */
  @Override
  @Activate
  public void activate(ComponentContext componentContext) {
    super.activate(componentContext);
    this.componentContext = componentContext;
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.caption.api.CaptionService#convert(org.opencastproject.mediapackage.MediaPackageElement,
   *      java.lang.String, java.lang.String)
   */
  @Override
  public Job convert(MediaPackageElement input, String inputFormat, String outputFormat)
          throws UnsupportedCaptionFormatException,
          CaptionConverterException, MediaPackageException {

    if (input == null)
      throw new IllegalArgumentException("Input catalog can't be null");
    if (StringUtils.isBlank(inputFormat))
      throw new IllegalArgumentException("Input format is null");
    if (StringUtils.isBlank(outputFormat))
      throw new IllegalArgumentException("Output format is null");

    try {
      return serviceRegistry.createJob(JOB_TYPE, Operation.Convert.toString(),
              Arrays.asList(MediaPackageElementParser.getAsXml(input), inputFormat, outputFormat), captionJobLoad);
    } catch (ServiceRegistryException e) {
      throw new CaptionConverterException("Unable to create a job", e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.caption.api.CaptionService#convert(org.opencastproject.mediapackage.MediaPackageElement,
   *      java.lang.String, java.lang.String, java.lang.String)
   */
  @Override
  public Job convert(MediaPackageElement input, String inputFormat, String outputFormat, String language)
          throws UnsupportedCaptionFormatException, CaptionConverterException, MediaPackageException {

    if (input == null)
      throw new IllegalArgumentException("Input catalog can't be null");
    if (StringUtils.isBlank(inputFormat))
      throw new IllegalArgumentException("Input format is null");
    if (StringUtils.isBlank(outputFormat))
      throw new IllegalArgumentException("Output format is null");
    if (StringUtils.isBlank(language))
      throw new IllegalArgumentException("Language format is null");

    try {
      return serviceRegistry.createJob(JOB_TYPE, Operation.ConvertWithLanguage.toString(),
              Arrays.asList(MediaPackageElementParser.getAsXml(input), inputFormat, outputFormat, language), captionJobLoad);
    } catch (ServiceRegistryException e) {
      throw new CaptionConverterException("Unable to create a job", e);
    }
  }

  /**
   * Converts the captions and returns them in a new catalog.
   *
   * @return the converted catalog
   */
  protected MediaPackageElement convert(Job job, MediaPackageElement input, String inputFormat, String outputFormat,
          String language)
          throws UnsupportedCaptionFormatException, CaptionConverterException, MediaPackageException {
    try {

      // check parameters
      if (input == null)
        throw new IllegalArgumentException("Input element can't be null");
      if (StringUtils.isBlank(inputFormat))
        throw new IllegalArgumentException("Input format is null");
      if (StringUtils.isBlank(outputFormat))
        throw new IllegalArgumentException("Output format is null");

      // get input file
      File captionsFile;
      try {
        captionsFile = workspace.get(input.getURI());
      } catch (NotFoundException e) {
        throw new CaptionConverterException("Requested media package element " + input + " could not be found.");
      } catch (IOException e) {
        throw new CaptionConverterException("Requested media package element " + input + "could not be accessed.");
      }

      logger.debug("Atempting to convert from {} to {}...", inputFormat, outputFormat);

      List<Caption> collection = null;
      try {
        collection = importCaptions(captionsFile, inputFormat, language);
        logger.debug("Parsing to collection succeeded.");
      } catch (UnsupportedCaptionFormatException e) {
        throw new UnsupportedCaptionFormatException(inputFormat);
      } catch (CaptionConverterException e) {
        throw e;
      }

      URI exported;
      try {
        exported = exportCaptions(collection,
                job.getId() + "." + FilenameUtils.getExtension(captionsFile.getAbsolutePath()), outputFormat, language);
        logger.debug("Exporting captions succeeding.");
      } catch (UnsupportedCaptionFormatException e) {
        throw new UnsupportedCaptionFormatException(outputFormat);
      } catch (IOException e) {
        throw new CaptionConverterException("Could not export caption collection.", e);
      }

      // create catalog and set properties
      CaptionConverter converter = getCaptionConverter(outputFormat);
      MediaPackageElementBuilder elementBuilder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
      MediaPackageElement mpe = elementBuilder.elementFromURI(exported, converter.getElementType(),
              new MediaPackageElementFlavor(
                      "captions", outputFormat + (language == null ? "" : "+" + language)));
      if (mpe.getMimeType() == null) {
        String[] mimetype = FileTypeMap.getDefaultFileTypeMap().getContentType(exported.getPath()).split("/");
        mpe.setMimeType(mimeType(mimetype[0], mimetype[1]));
      }
     // Don't need to add language tag if it doesn't exist or used for different purpose
      if (language != null && !isNumeric(language)) {
        mpe.addTag("lang:" + language);
      }

      return mpe;

    } catch (Exception e) {
      logger.warn("Error converting captions in " + input, e);
      if (e instanceof CaptionConverterException) {
        throw (CaptionConverterException) e;
      } else if (e instanceof UnsupportedCaptionFormatException) {
        throw (UnsupportedCaptionFormatException) e;
      } else {
        throw new CaptionConverterException(e);
      }
    }
  }

  /**
   *
   * {@inheritDoc}
   *
   */
  @Override
  public String[] getLanguageList(MediaPackageElement input, String format) throws UnsupportedCaptionFormatException,
          CaptionConverterException {

    if (format == null) {
      throw new UnsupportedCaptionFormatException("<null>");
    }
    CaptionConverter converter = getCaptionConverter(format);
    if (converter == null) {
      throw new UnsupportedCaptionFormatException(format);
    }

    File captions;
    try {
      captions = workspace.get(input.getURI());
    } catch (NotFoundException e) {
      throw new CaptionConverterException("Requested media package element " + input + " could not be found.");
    } catch (IOException e) {
      throw new CaptionConverterException("Requested media package element " + input + "could not be accessed.");
    }

    FileInputStream stream = null;
    String[] languageList;
    try {
      stream = new FileInputStream(captions);
      languageList = converter.getLanguageList(stream);
    } catch (FileNotFoundException e) {
      throw new CaptionConverterException("Requested file " + captions + "could not be found.");
    } finally {
      IoSupport.closeQuietly(stream);
    }

    return languageList == null ? new String[0] : languageList;
  }

  /**
   * Returns all registered CaptionFormats.
   */
  protected HashMap<String, CaptionConverter> getAvailableCaptionConverters() {
    HashMap<String, CaptionConverter> captionConverters = new HashMap<String, CaptionConverter>();
    ServiceReference[] refs = null;
    try {
      refs = componentContext.getBundleContext().getServiceReferences(CaptionConverter.class.getName(), null);
    } catch (InvalidSyntaxException e) {
      // should not happen since it is called with null argument
    }

    if (refs != null) {
      for (ServiceReference ref : refs) {
        CaptionConverter converter = (CaptionConverter) componentContext.getBundleContext().getService(ref);
        String format = (String) ref.getProperty("caption.format");
        if (captionConverters.containsKey(format)) {
          logger.warn("Caption converter with format {} has already been registered. Ignoring second definition.",
                  format);
        } else {
          captionConverters.put((String) ref.getProperty("caption.format"), converter);
        }
      }
    }

    return captionConverters;
  }

  /**
   * Returns specific {@link CaptionConverter}. Registry is searched based on formatName, so in order for
   * {@link CaptionConverter} to be found, it has to have <code>caption.format</code> property set with
   * {@link CaptionConverter} format. If none is found, null is returned, if more than one is found then the first
   * reference is returned.
   *
   * @param formatName
   *          name of the caption format
   * @return {@link CaptionConverter} or null if none is found
   */
  protected CaptionConverter getCaptionConverter(String formatName) {
    ServiceReference[] ref = null;
    try {
      ref = componentContext.getBundleContext().getServiceReferences(CaptionConverter.class.getName(),
              "(caption.format=" + formatName + ")");
    } catch (InvalidSyntaxException e) {
      throw new RuntimeException(e);
    }
    if (ref == null) {
      logger.warn("No caption format available for {}.", formatName);
      return null;
    }
    if (ref.length > 1)
      logger.warn("Multiple references for caption format {}! Returning first service reference.", formatName);
    CaptionConverter converter = (CaptionConverter) componentContext.getBundleContext().getService(ref[0]);
    return converter;
  }

  /**
   * Imports captions using registered converter engine and specified language.
   *
   * @param input
   *          file containing captions
   * @param inputFormat
   *          format of imported captions
   * @param language
   *          (optional) captions' language
   * @return {@link List} of parsed captions
   * @throws UnsupportedCaptionFormatException
   *           if there is no registered engine for given format
   * @throws IllegalCaptionFormatException
   *           if parser encounters exception
   */
  private List<Caption> importCaptions(File input, String inputFormat, String language)
          throws UnsupportedCaptionFormatException, CaptionConverterException {
    // get input format
    CaptionConverter converter = getCaptionConverter(inputFormat);
    if (converter == null) {
      logger.error("No available caption format found for {}.", inputFormat);
      throw new UnsupportedCaptionFormatException(inputFormat);
    }

    FileInputStream fileStream = null;
    try {
      fileStream = new FileInputStream(input);
      List<Caption> collection = converter.importCaption(fileStream, language);
      return collection;
    } catch (FileNotFoundException e) {
      throw new CaptionConverterException("Could not locate file " + input);
    } finally {
      IOUtils.closeQuietly(fileStream);
    }
  }

  /**
   * Exports captions {@link List} to specified format. Extension is added to exported file name. Throws
   * {@link UnsupportedCaptionFormatException} if format is not supported.
   *
   * @param captions
   *          {@link {@link List} to be exported
   * @param outputName
   *          name under which exported captions will be stored
   * @param outputFormat
   *          format of exported collection
   * @param language
   *          (optional) captions' language
   * @throws UnsupportedCaptionFormatException
   *           if there is no registered engine for given format
   * @return location of converted captions
   * @throws IOException
   *           if exception occurs while writing to output stream
   */
  private URI exportCaptions(List<Caption> captions, String outputName, String outputFormat, String language)
          throws UnsupportedCaptionFormatException, IOException {
    CaptionConverter converter = getCaptionConverter(outputFormat);
    if (converter == null) {
      logger.error("No available caption format found for {}.", outputFormat);
      throw new UnsupportedCaptionFormatException(outputFormat);
    }

    // TODO instead of first writing it all in memory, write it directly to disk
    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    try {
      converter.exportCaption(outputStream, captions, language);
    } catch (IOException e) {
      // since we're writing to memory, this should not happen
    }
    ByteArrayInputStream in = new ByteArrayInputStream(outputStream.toByteArray());
    return workspace.putInCollection(COLLECTION, outputName + "." + converter.getExtension(), in);
  }

  private boolean isNumeric(String str) {
    try {
      Integer.parseInt(str);
    } catch (NumberFormatException e) {
      return false;
    }
    return true;
  }
  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.job.api.AbstractJobProducer#process(Job)
   */
  @Override
  protected String process(Job job) throws Exception {
    Operation op = null;
    String operation = job.getOperation();
    List<String> arguments = job.getArguments();
    try {
      op = Operation.valueOf(operation);

      MediaPackageElement catalog = MediaPackageElementParser.getFromXml(arguments.get(0));
      String inputFormat = arguments.get(1);
      String outputFormat = arguments.get(2);

      MediaPackageElement resultingCatalog = null;

      switch (op) {
        case Convert:
          resultingCatalog = convert(job, catalog, inputFormat, outputFormat, null);
          return MediaPackageElementParser.getAsXml(resultingCatalog);
        case ConvertWithLanguage:
          String language = arguments.get(3);
          resultingCatalog = convert(job, catalog, inputFormat, outputFormat, language);
          return MediaPackageElementParser.getAsXml(resultingCatalog);
        default:
          throw new IllegalStateException("Don't know how to handle operation '" + operation + "'");
      }
    } catch (IllegalArgumentException e) {
      throw new ServiceRegistryException("This service can't handle operations of type '" + op + "'", e);
    } catch (IndexOutOfBoundsException e) {
      throw new ServiceRegistryException("This argument list for operation '" + op + "' does not meet expectations", e);
    } catch (Exception e) {
      throw new ServiceRegistryException("Error handling operation '" + op + "'", e);
    }
  }

  /**
   * Setter for workspace via declarative activation
   */
  @Reference
  protected void setWorkspace(Workspace workspace) {
    this.workspace = workspace;
  }

  /**
   * Setter for remote service manager via declarative activation
   */
  @Reference
  protected void setServiceRegistry(ServiceRegistry serviceRegistry) {
    this.serviceRegistry = serviceRegistry;
  }

  /**
   * Callback for setting the security service.
   *
   * @param securityService
   *          the securityService to set
   */
  @Reference
  public void setSecurityService(SecurityService securityService) {
    this.securityService = securityService;
  }

  /**
   * Callback for setting the user directory service.
   *
   * @param userDirectoryService
   *          the userDirectoryService to set
   */
  @Reference
  public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
    this.userDirectoryService = userDirectoryService;
  }

  /**
   * Sets a reference to the organization directory service.
   *
   * @param organizationDirectory
   *          the organization directory
   */
  @Reference
  public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectory) {
    this.organizationDirectoryService = organizationDirectory;
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.job.api.AbstractJobProducer#getSecurityService()
   */
  @Override
  protected SecurityService getSecurityService() {
    return securityService;
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.job.api.AbstractJobProducer#getOrganizationDirectoryService()
   */
  @Override
  protected OrganizationDirectoryService getOrganizationDirectoryService() {
    return organizationDirectoryService;
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.job.api.AbstractJobProducer#getUserDirectoryService()
   */
  @Override
  protected UserDirectoryService getUserDirectoryService() {
    return userDirectoryService;
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.job.api.AbstractJobProducer#getServiceRegistry()
   */
  @Override
  protected ServiceRegistry getServiceRegistry() {
    return serviceRegistry;
  }

  @Override
  public void updated(@SuppressWarnings("rawtypes") Dictionary properties) throws ConfigurationException {
    captionJobLoad = LoadUtil.getConfiguredLoadValue(properties, CAPTION_JOB_LOAD_KEY, DEFAULT_CAPTION_JOB_LOAD, serviceRegistry);
  }

}