All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.opencastproject.adminui.endpoint.ToolsEndpoint Maven / Gradle / Ivy

There is a newer version: 16.6
Show newest version
/*
 * 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.adminui.endpoint;

import static com.entwinemedia.fn.Stream.$;
import static com.entwinemedia.fn.data.json.Jsons.arr;
import static com.entwinemedia.fn.data.json.Jsons.f;
import static com.entwinemedia.fn.data.json.Jsons.obj;
import static com.entwinemedia.fn.data.json.Jsons.v;
import static java.lang.String.format;
import static java.util.Collections.emptyList;
import static java.util.Objects.requireNonNull;
import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
import static javax.servlet.http.HttpServletResponse.SC_OK;
import static org.opencastproject.util.data.Tuple.tuple;

import org.opencastproject.adminui.impl.AdminUIConfiguration;
import org.opencastproject.adminui.impl.ThumbnailImpl;
import org.opencastproject.assetmanager.api.AssetManager;
import org.opencastproject.assetmanager.api.AssetManagerException;
import org.opencastproject.assetmanager.util.WorkflowPropertiesUtil;
import org.opencastproject.assetmanager.util.Workflows;
import org.opencastproject.composer.api.ComposerService;
import org.opencastproject.composer.api.EncoderException;
import org.opencastproject.distribution.api.DistributionException;
import org.opencastproject.elasticsearch.api.SearchIndexException;
import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
import org.opencastproject.elasticsearch.index.objects.event.Event;
import org.opencastproject.index.service.api.IndexService;
import org.opencastproject.index.service.api.IndexService.Source;
import org.opencastproject.index.service.exception.IndexServiceException;
import org.opencastproject.index.service.util.RestUtils;
import org.opencastproject.mediapackage.Attachment;
import org.opencastproject.mediapackage.Catalog;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.mediapackage.MediaPackageElement;
import org.opencastproject.mediapackage.MediaPackageElement.Type;
import org.opencastproject.mediapackage.MediaPackageElementBuilder;
import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
import org.opencastproject.mediapackage.MediaPackageElementFlavor;
import org.opencastproject.mediapackage.MediaPackageException;
import org.opencastproject.mediapackage.Publication;
import org.opencastproject.mediapackage.Stream;
import org.opencastproject.mediapackage.Track;
import org.opencastproject.mediapackage.VideoStream;
import org.opencastproject.publication.api.ConfigurablePublicationService;
import org.opencastproject.publication.api.OaiPmhPublicationService;
import org.opencastproject.publication.api.PublicationException;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.urlsigning.exception.UrlSigningException;
import org.opencastproject.security.urlsigning.service.UrlSigningService;
import org.opencastproject.security.urlsigning.utils.UrlSigningServiceOsgiUtil;
import org.opencastproject.smil.api.SmilException;
import org.opencastproject.smil.api.SmilResponse;
import org.opencastproject.smil.api.SmilService;
import org.opencastproject.smil.entity.api.Smil;
import org.opencastproject.smil.entity.media.api.SmilMediaObject;
import org.opencastproject.smil.entity.media.container.api.SmilMediaContainer;
import org.opencastproject.smil.entity.media.element.api.SmilMediaElement;
import org.opencastproject.util.MimeTypes;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.util.RestUtil.R;
import org.opencastproject.util.UnknownFileTypeException;
import org.opencastproject.util.data.Tuple;
import org.opencastproject.util.doc.rest.RestParameter;
import org.opencastproject.util.doc.rest.RestQuery;
import org.opencastproject.util.doc.rest.RestResponse;
import org.opencastproject.util.doc.rest.RestService;
import org.opencastproject.workflow.api.ConfiguredWorkflow;
import org.opencastproject.workflow.api.WorkflowDatabaseException;
import org.opencastproject.workflow.api.WorkflowDefinition;
import org.opencastproject.workflow.api.WorkflowService;
import org.opencastproject.workflow.api.WorkflowUtil;
import org.opencastproject.workflow.handler.distribution.InternalPublicationChannel;
import org.opencastproject.workspace.api.Workspace;

import com.entwinemedia.fn.Fn;
import com.entwinemedia.fn.data.Opt;
import com.entwinemedia.fn.data.json.Field;
import com.entwinemedia.fn.data.json.JObject;
import com.entwinemedia.fn.data.json.JValue;
import com.entwinemedia.fn.data.json.Jsons;
import com.entwinemedia.fn.data.json.Jsons.Functions;

import org.apache.commons.fileupload.FileItemIterator;
import org.apache.commons.fileupload.FileItemStream;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.commons.fileupload.util.Streams;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
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.Modified;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.SAXException;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Dictionary;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalDouble;
import java.util.stream.Collectors;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.xml.bind.JAXBException;

@Path("/")
@RestService(name = "toolsService", title = "Tools API Service",
  abstractText = "Provides a location for the tools API.",
  notes = { "This service provides a location for the tools API for the admin UI.",
            "Important: "
              + "This service is for exclusive use by the module admin-ui. Its API might change "
              + "anytime without prior notice. Any dependencies other than the admin UI will be strictly ignored. "
              + "DO NOT use this for integration of third-party applications."})
@Component(
        immediate = true,
        service = ToolsEndpoint.class,
        property = {
                "service.description=Admin UI - Tools Endpoint",
                "opencast.service.type=org.opencastproject.adminui.ToolsEndpoint",
                "opencast.service.path=/admin-ng/tools",
        }
)
public class ToolsEndpoint {
  /** The logging facility */
  private static final Logger logger = LoggerFactory.getLogger(ToolsEndpoint.class);

  /** The default file name for generated Smil catalogs. */
  private static final String TARGET_FILE_NAME = "cut.smil";

  /** The Json key for the cutting details object. */
  private static final String CONCAT_KEY = "concat";

  /** The Json key for the end of a segment. */
  private static final String END_KEY = "end";

  /** The Json key for the beginning of a segment. */
  private static final String START_KEY = "start";

  /** The Json key for the segments array. */
  private static final String SEGMENTS_KEY = "segments";

  /** The Json key for the tracks array. */
  private static final String TRACKS_KEY = "tracks";

  /** The Json key for the default thumbnail position. */
  private static final String DEFAULT_THUMBNAIL_POSITION_KEY = "defaultThumbnailPosition";

  /** The Json key for the source_tracks array. */
  private static final String SOURCE_TRACKS_KEY = "source_tracks";

  /** Tag that marks workflow for being used from the editor tool */
  private static final String EDITOR_WORKFLOW_TAG = "editor";

  /** Field names in thumbnail request. */
  private static final String THUMBNAIL_FILE = "FILE";
  private static final String THUMBNAIL_PREVIEW_FILE = "PREVIEWFILE";
  private static final String THUMBNAIL_TRACK = "TRACK";
  private static final String THUMBNAIL_POSITION = "POSITION";
  private static final String THUMBNAIL_DEFAULT = "DEFAULT";

  /** Option to enable/disable thumbnail support */
  private static final String OPT_THUMBNAIL_ENABLED = "thumbnail.enabled";

  private long expireSeconds = UrlSigningServiceOsgiUtil.DEFAULT_URL_SIGNING_EXPIRE_DURATION;

  private Boolean signWithClientIP = UrlSigningServiceOsgiUtil.DEFAULT_SIGN_WITH_CLIENT_IP;

  // service references
  private AdminUIConfiguration adminUIConfiguration;
  private ElasticsearchIndex searchIndex;
  private AssetManager assetManager;
  private ComposerService composerService;
  private IndexService index;
  private OaiPmhPublicationService oaiPmhPublicationService;
  private ConfigurablePublicationService configurablePublicationService;
  private SecurityService securityService;
  private SmilService smilService;
  private UrlSigningService urlSigningService;
  private WorkflowService workflowService;
  private Workspace workspace;
  private boolean thumbnailEnabled = true;

  /** OSGi DI. */
  @Reference
  public void setConfigurablePublicationService(ConfigurablePublicationService configurablePublicationService) {
    this.configurablePublicationService = configurablePublicationService;
  }

  @Reference
  public void setAdminUIConfiguration(AdminUIConfiguration adminUIConfiguration) {
    this.adminUIConfiguration = adminUIConfiguration;
  }

  @Reference
  void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
    this.searchIndex = elasticsearchIndex;
  }

  @Reference
  public void setAssetManager(AssetManager assetManager) {
    this.assetManager = assetManager;
  }

  /** OSGi DI. */
  @Reference
  public void setIndexService(IndexService index) {
    this.index = index;
  }

  /** OSGi DI. */
  @Reference
  public void setOaiPmhPublicationService(OaiPmhPublicationService oaiPmhPublicationService) {
    this.oaiPmhPublicationService = oaiPmhPublicationService;
  }

  @Reference
  public void setSecurityService(SecurityService securityService) {
    this.securityService = securityService;
  }

  @Reference
  public void setSmilService(SmilService smilService) {
    this.smilService = smilService;
  }

  @Reference
  public void setUrlSigningService(UrlSigningService urlSigningService) {
    this.urlSigningService = urlSigningService;
  }

  @Reference
  public void setWorkflowService(WorkflowService workflowService) {
    this.workflowService = workflowService;
  }

  @Reference
  public void setWorkspace(Workspace workspace) {
    this.workspace = workspace;
  }

  @Reference
  public void setComposerService(ComposerService composerService) {
    this.composerService = composerService;
  }

  /** OSGi callback if properties file is present */
  @Activate
  @Modified
  public void activate(ComponentContext cc) {
    Dictionary properties = cc.getProperties();
    if (properties == null) {
      logger.info("No configuration available, using defaults");
      return;
    }

    expireSeconds = UrlSigningServiceOsgiUtil.getUpdatedSigningExpiration(properties, this.getClass().getSimpleName());
    signWithClientIP = UrlSigningServiceOsgiUtil.getUpdatedSignWithClientIP(properties,
            this.getClass().getSimpleName());

    thumbnailEnabled = BooleanUtils.toBoolean(Objects.toString(properties.get(OPT_THUMBNAIL_ENABLED), "false"));
    logger.debug("Thumbnail feature enabled: {}", thumbnailEnabled);
    logger.info("Configuration updated");
  }

  @GET
  @Path("{mediapackageid}.json")
  @RestQuery(name = "getAvailableTools", description = "Returns a list of tools which are currently available for the given media package.", returnDescription = "A JSON array with tools identifiers", pathParameters = {
          @RestParameter(name = "mediapackageid", description = "The id of the media package", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
                  @RestResponse(description = "Available tools evaluated", responseCode = HttpServletResponse.SC_OK) })
  public Response getAvailableTools(@PathParam("mediapackageid") final String mediaPackageId) {
    final List jTools = new ArrayList<>();
    if (isEditorAvailable(mediaPackageId))
      jTools.add(v("editor"));

    return RestUtils.okJson(obj(f("available", arr(jTools))));
  }

  private List getPreviewElementsFromPublication(Opt publication) {
    List previewElements = new LinkedList<>();
    for (Publication p : publication) {
      for (Attachment attachment : p.getAttachments()) {
        if (elementHasPreviewFlavor(attachment)) {
          previewElements.add(attachment);
        }
      }
      for (Catalog catalog : p.getCatalogs()) {
        if (elementHasPreviewFlavor(catalog)) {
          previewElements.add(catalog);
        }
      }
      for (Track track : p.getTracks()) {
        if (elementHasPreviewFlavor(track)) {
          previewElements.add(track);
        }
      }
    }
    return previewElements;
  }

  private Boolean elementHasPreviewFlavor(MediaPackageElement element) {
    return element.getFlavor() != null
            && adminUIConfiguration.getPreviewSubtype().equals(element.getFlavor().getSubtype());
  }

  private String signIfNecessary(final URI uri) {
    if (!urlSigningService.accepts(uri.toString())) {
      return uri.toString();
    }
    String clientIP = signWithClientIP ? securityService.getUserIP() : null;
    try {
      return new URI(urlSigningService.sign(uri.toString(), expireSeconds, null, clientIP)).toString();
    } catch (URISyntaxException | UrlSigningException e) {
      throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
    }
  }

  @GET
  @Path("{mediapackageid}/editor.json")
  @Produces(MediaType.APPLICATION_JSON)
  @RestQuery(name = "getVideoEditor", description = "Returns all the information required to get the editor tool started", returnDescription = "JSON object", pathParameters = {
          @RestParameter(name = "mediapackageid", description = "The id of the media package", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
                  @RestResponse(description = "Media package found", responseCode = SC_OK),
                  @RestResponse(description = "Media package not found", responseCode = SC_NOT_FOUND) })
  public Response getVideoEditor(@PathParam("mediapackageid") final String mediaPackageId)
          throws IndexServiceException, NotFoundException {

    // Select tracks
    final Event event = getEvent(mediaPackageId).get();
    final MediaPackage mp = index.getEventMediapackage(event);
    List previewPublications = getPreviewElementsFromPublication(getInternalPublication(mp));
    long previewDuration = 0;

    // Collect previews and tracks
    List jPreviews = new ArrayList<>();
    List jTracks = new ArrayList<>();
    for (MediaPackageElement element : previewPublications) {
      final String elementUri = signIfNecessary(element.getURI());
      JObject jPreview = obj(f("uri", v(elementUri)));
      // Get the elements frame rate for frame by frame skipping in the editor
      // Note that this assumes that the resulting video will have the same frame rate as the preview
      // and also that there is only one video stream for any preview element.
      if (element instanceof Track) {
        long trackDuration = ((Track) element).getDuration();
        // use duration of preview instead of mp since they can differ slightly
        // there should be only one preview track, but just in case, pick the longest
        if (trackDuration > previewDuration) {
          previewDuration = trackDuration;
        }

        for (Stream stream: ((Track) element).getStreams()) {
          if (stream instanceof VideoStream) {
            jPreview = jPreview.merge(obj(f("frameRate", v(((VideoStream) stream).getFrameRate()))));
            break;
          }
        }
      }
      jPreviews.add(jPreview);

      if (!Type.Track.equals(element.getElementType()))
        continue;

      JObject jTrack = obj(f("id", v(element.getIdentifier())), f("flavor", v(element.getFlavor().getType())));
      // Check if there's a waveform for the current track
      Opt optWaveform = getWaveformForTrack(mp, element);
      if (optWaveform.isSome()) {
        final String waveformUri = signIfNecessary(optWaveform.get().getURI());
        jTracks.add(jTrack.merge(obj(f("waveform", v(waveformUri)))));
      } else {
        jTracks.add(jTrack);
      }
    }

    // Get existing segments
    List jSegments = new ArrayList<>();
    for (Tuple segment : getSegments(mp)) {
      jSegments.add(obj(f(START_KEY, v(segment.getA())), f(END_KEY, v(segment.getB()))));
    }

    // Get workflows
    List jWorkflows = new ArrayList<>();
    for (WorkflowDefinition workflow : getEditingWorkflows()) {
      jWorkflows.add(obj(f("id", v(workflow.getId())), f("name", v(workflow.getTitle(), Jsons.BLANK)),
              f("displayOrder", v(workflow.getDisplayOrder()))));
    }

    // Get thumbnail
    final List thumbnailFields = new ArrayList<>();
    if (thumbnailEnabled) {
      try {
        final ThumbnailImpl thumbnailImpl = newThumbnailImpl();
        final Optional optThumbnail = thumbnailImpl
          .getThumbnail(mp, urlSigningService, expireSeconds);

        optThumbnail.ifPresent(thumbnail -> {
          thumbnailFields.add(f("type", thumbnail.getType().name()));
          thumbnailFields.add(f("url", thumbnail.getUrl().toString()));
          thumbnailFields.add(f("defaultPosition", thumbnailImpl.getDefaultPosition()));
          thumbnail.getPosition().ifPresent(p -> thumbnailFields.add(f("position", p)));
          thumbnail.getTrack().ifPresent(t -> thumbnailFields.add(f("track", t)));
        });
      } catch (UrlSigningException | URISyntaxException e) {
        throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
      }
    }


    final Map latestWfProperties = WorkflowPropertiesUtil
      .getLatestWorkflowProperties(assetManager, mediaPackageId);
    // The properties have the format "hide_flavor_audio" or "hide_flavor_video", where flavor is preconfigured.
    // We filter all the properties that have this format, and then those which have values "true".
    final Collection> hiddens = latestWfProperties.entrySet()
      .stream()
      .map(p -> Tuple.tuple(p.getKey().split("_"), p.getValue()))
      .filter(p -> p.getA().length == 3)
      .filter(p -> p.getA()[0].equals("hide"))
      .filter(p -> p.getB().equals("true"))
      .map(p -> Tuple.tuple(p.getA()[1], p.getA()[2]))
      .collect(Collectors.toSet());

    final Collection acceptedFlavors = Arrays
      .asList(this.adminUIConfiguration.getSourceTrackLeftFlavor(),
        this.adminUIConfiguration.getSourceTrackRightFlavor());

    // We already know the internal publication exists, so just "get" it here.
    final Publication internalPub = getInternalPublication(mp).get();

    final List sourceTracks = Arrays.stream(mp.getElements())
      .filter(e -> e.getElementType().equals(Type.Track))
      .map(e -> (Track)e)
      .filter(e -> acceptedFlavors.contains(e.getFlavor()))
      .map(e -> {
        String side = null;
        if (e.getFlavor().equals(this.adminUIConfiguration.getSourceTrackLeftFlavor())) {
          side = "left";
        } else if (e.getFlavor().equals(this.adminUIConfiguration.getSourceTrackRightFlavor())) {
          side = "right";
        }
        final boolean audioHidden = hiddens.contains(Tuple.tuple(e.getFlavor().getType(), "audio"));
        final String audioPreview = Arrays.stream(internalPub.getAttachments())
          .filter(a -> a.getFlavor().getType().equals(e.getFlavor().getType()))
          .filter(a -> a.getFlavor().getSubtype().equals(this.adminUIConfiguration.getPreviewAudioSubtype()))
          .map(MediaPackageElement::getURI).map(this::signIfNecessary)
          .findAny()
          .orElse(null);
        final SourceTrackSubInfo audio = new SourceTrackSubInfo(e.hasAudio(), audioPreview,
          audioHidden);
        final boolean videoHidden = hiddens.contains(Tuple.tuple(e.getFlavor().getType(), "video"));
        final String videoPreview = Arrays.stream(internalPub.getAttachments())
          .filter(a -> a.getFlavor().getType().equals(e.getFlavor().getType()))
          .filter(a -> a.getFlavor().getSubtype().equals(this.adminUIConfiguration.getPreviewVideoSubtype()))
          .map(MediaPackageElement::getURI).map(this::signIfNecessary)
          .findAny()
          .orElse(null);
        final SourceTrackSubInfo video = new SourceTrackSubInfo(e.hasVideo(), videoPreview,
          videoHidden);
        return new SourceTrackInfo(e.getFlavor().getType(), e.getFlavor().getSubtype(), audio, video, side);
      })
      .map(SourceTrackInfo::toJson)
      .collect(Collectors.toList());

    return RestUtils.okJson(obj(f("title", v(event.getTitle(), Jsons.BLANK)),
            f("date", v(event.getRecordingStartDate(), Jsons.BLANK)),
            f("series", obj(f("id", v(event.getSeriesId(), Jsons.BLANK)),
            f("title", v(event.getSeriesName(), Jsons.BLANK)))),
            f("presenters", arr($(event.getPresenters()).map(Functions.stringToJValue))),
            f(SOURCE_TRACKS_KEY, arr(sourceTracks)),
            f("previews", arr(jPreviews)),
            f(TRACKS_KEY, arr(jTracks)),
            f("thumbnail", obj(thumbnailFields)),
            f("thumbnail_enabled", v(thumbnailEnabled)),
            f("duration", v(previewDuration)),
            f(SEGMENTS_KEY, arr(jSegments)),
            f("workflows", arr(jWorkflows))));
  }

  private ThumbnailImpl newThumbnailImpl() {
    return new ThumbnailImpl(adminUIConfiguration, workspace, oaiPmhPublicationService, configurablePublicationService,
      assetManager, composerService);
  }

  @POST
  @Consumes(MediaType.MULTIPART_FORM_DATA)
  @Produces(MediaType.APPLICATION_JSON)
  @Path("{mediapackageid}/thumbnail.json")
  public Response changeThumbnail(@PathParam("mediapackageid") final String mediaPackageId,
    @Context HttpServletRequest request)
    throws IndexServiceException, NotFoundException, DistributionException, MediaPackageException {

    if (!thumbnailEnabled) {
      return R.badRequest("Thumbnail creation is prohibited");
    }

    final Opt optEvent = getEvent(mediaPackageId);
    if (optEvent.isNone()) {
      return R.notFound();
    }

    if (WorkflowUtil.isActive(optEvent.get().getWorkflowState())) {
      return R.locked();
    }

    final MediaPackage mp = index.getEventMediapackage(optEvent.get());

    try {
      final ThumbnailImpl thumbnail = newThumbnailImpl();

      Optional track = Optional.empty();
      OptionalDouble position = OptionalDouble.empty();

      final FileItemIterator iter = new ServletFileUpload().getItemIterator(request);
      while (iter.hasNext()) {
        final FileItemStream current = iter.next();
        if (!current.isFormField() && THUMBNAIL_FILE.equalsIgnoreCase(current.getFieldName())) {
          final MediaPackageElement distElement = thumbnail.upload(mp, current.openStream(), current.getContentType());
          return RestUtils.okJson(obj(f("thumbnail",
            obj(
              f("position", thumbnail.getDefaultPosition()),
              f("defaultPosition", thumbnail.getDefaultPosition()),
              f("type", ThumbnailImpl.ThumbnailSource.UPLOAD.name()),
              f("url", signIfNecessary(distElement.getURI()))))));
        } else if (!current.isFormField() && THUMBNAIL_PREVIEW_FILE.equalsIgnoreCase(current.getFieldName())) {

          if (!position.isPresent()) {
            return R.badRequest("Missing thumbnail position");
          }

          final MediaPackageElement distributedElement;
          final ThumbnailImpl.ThumbnailSource thumbnailSource;
          InputStream preview = current.openStream();
          try {
            if (track.isPresent()) {
              distributedElement = thumbnail.chooseThumbnail(mp, track.get(), position.getAsDouble(), Optional.of(Tuple.tuple(preview, current.getContentType())));
              thumbnailSource = ThumbnailImpl.ThumbnailSource.SNAPSHOT;
            } else {
              distributedElement = thumbnail.chooseDefaultThumbnail(mp, position.getAsDouble(), Optional.of(Tuple.tuple(preview, current.getContentType())));
              thumbnailSource = ThumbnailImpl.ThumbnailSource.DEFAULT;
            }
          } finally {
            preview.close();
          }
          return RestUtils.okJson(obj(f("thumbnail", obj(
            f("type", thumbnailSource.name()),
            f("position", position.getAsDouble()),
            f("defaultPosition", thumbnail.getDefaultPosition()),
            f("url", signIfNecessary(distributedElement.getURI()))
          ))));
        } else if (current.isFormField() && THUMBNAIL_TRACK.equalsIgnoreCase(current.getFieldName())) {
          final String value = Streams.asString(current.openStream());
          if (!THUMBNAIL_DEFAULT.equalsIgnoreCase(value)) {
            track = Optional.of(value);
          }
        } else if (current.isFormField() && THUMBNAIL_POSITION.equalsIgnoreCase(current.getFieldName())) {
          final String value = Streams.asString(current.openStream());
          position = OptionalDouble.of(Double.parseDouble(value));
        }
      }

      return R.badRequest("Missing thumbnail or preview");
    } catch (IOException | FileUploadException e) {
      logger.error("Error reading request body:", e);
      return R.serverError();
    } catch (PublicationException | UnknownFileTypeException | EncoderException e) {
      logger.error("Could not generate or publish thumbnail", e);
      return R.serverError();
    }
  }

  @POST
  @Path("{mediapackageid}/editor.json")
  @Consumes(MediaType.APPLICATION_JSON)
  @RestQuery(name = "editVideo", description = "Takes editing information from the client side and processes it", returnDescription = "", pathParameters = {
          @RestParameter(name = "mediapackageid", description = "The id of the media package", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
                  @RestResponse(description = "Editing information saved and processed", responseCode = HttpServletResponse.SC_OK),
                  @RestResponse(description = "Media package not found", responseCode = HttpServletResponse.SC_NOT_FOUND),
                  @RestResponse(description = "The editing information cannot be parsed", responseCode = HttpServletResponse.SC_BAD_REQUEST) })
  public Response editVideo(@PathParam("mediapackageid") final String mediaPackageId,
          @Context HttpServletRequest request) throws IndexServiceException, NotFoundException {
    String details;
    try (InputStream is = request.getInputStream()) {
      details = IOUtils.toString(is, request.getCharacterEncoding());
    } catch (IOException e) {
      logger.error("Error reading request body:", e);
      return R.serverError();
    }

    JSONParser parser = new JSONParser();
    EditingInfo editingInfo;
    try {
      JSONObject detailsJSON = (JSONObject) parser.parse(details);
      editingInfo = EditingInfo.parse(detailsJSON);
    } catch (Exception e) {
      logger.warn("Unable to parse concat information ({})", details, e);
      return R.badRequest("Unable to parse details");
    }

    final Opt optEvent = getEvent(mediaPackageId);
    if (optEvent.isNone()) {
      return R.notFound();
    } else {
      if (WorkflowUtil.isActive(optEvent.get().getWorkflowState())) {
        return R.locked();
      }

      MediaPackage mediaPackage = index.getEventMediapackage(optEvent.get());
      Smil smil;
      try {
        smil = createSmilCuttingCatalog(editingInfo, mediaPackage);
      } catch (Exception e) {
        logger.warn("Unable to create a SMIL cutting catalog ({}):", details, e);
        return R.badRequest("Unable to create SMIL cutting catalog");
      }

      final Map workflowProperties = java.util.stream.Stream
        .of(this.adminUIConfiguration.getSourceTrackLeftFlavor(), this.adminUIConfiguration.getSourceTrackRightFlavor())
        .flatMap(flavor -> {
          final java.util.stream.Stream.Builder> r = java.util.stream.Stream.builder();
          final Optional track = editingInfo.sourceTracks.stream().filter(s -> s.getFlavor().equals(flavor)).findAny();
          final boolean audioHidden = track.map(e -> e.audio.hidden).orElse(false);
          r.accept(Tuple.tuple("hide_" + flavor.getType() + "_audio", Boolean.toString(audioHidden)));
          final boolean videoHidden = track.map(e -> e.video.hidden).orElse(false);
          r.accept(Tuple.tuple("hide_" + flavor.getType() + "_video", Boolean.toString(videoHidden)));
          return r.build();
        }).collect(Collectors.toMap(Tuple::getA, Tuple::getB));

      WorkflowPropertiesUtil.storeProperties(assetManager, mediaPackage, workflowProperties);

      try {
        addSmilToArchive(mediaPackage, smil);
      } catch (IOException e) {
        logger.warn("Unable to add SMIL cutting catalog to archive:", e);
        return R.serverError();
      }

      // Update default thumbnail (if used) since position may change due to cutting
      MediaPackageElement distributedThumbnail = null;
      if (thumbnailEnabled && editingInfo.getDefaultThumbnailPosition().isPresent()) {
        try {
          final ThumbnailImpl thumbnailImpl = newThumbnailImpl();
          final Optional optThumbnail = thumbnailImpl
            .getThumbnail(mediaPackage, urlSigningService, expireSeconds);
          if (optThumbnail.isPresent() && optThumbnail.get().getType().equals(ThumbnailImpl.ThumbnailSource.DEFAULT)) {
            distributedThumbnail = thumbnailImpl
              .chooseDefaultThumbnail(mediaPackage, editingInfo.getDefaultThumbnailPosition().getAsDouble(), Optional.empty());
          }
        } catch (UrlSigningException | URISyntaxException e) {
          logger.error("Error while trying to serialize the thumbnail url because:", e);
          return R.serverError();
        } catch (IOException | DistributionException | EncoderException | PublicationException
            | UnknownFileTypeException | MediaPackageException e) {
          logger.error("Error while updating default thumbnail because:", e);
          return R.serverError();
        }
      }

      if (editingInfo.getPostProcessingWorkflow().isPresent()) {
        final String workflowId = editingInfo.getPostProcessingWorkflow().get();
        try {
          final Map workflowParameters = WorkflowPropertiesUtil
            .getLatestWorkflowProperties(assetManager, mediaPackage.getIdentifier().toString());
          final Workflows workflows = new Workflows(assetManager, workflowService);
          workflows.applyWorkflowToLatestVersion($(mediaPackage.getIdentifier().toString()),
            ConfiguredWorkflow.workflow(workflowService.getWorkflowDefinitionById(workflowId), workflowParameters))
            .run();
        } catch (AssetManagerException e) {
          logger.warn("Unable to start workflow '{}' on archived media package '{}':",
                  workflowId, mediaPackage, e);
          return R.serverError();
        } catch (WorkflowDatabaseException e) {
          logger.warn("Unable to load workflow '{}' from workflow service:", workflowId, e);
          return R.serverError();
        } catch (NotFoundException e) {
          logger.warn("Workflow '{}' not found", workflowId);
          return R.badRequest("Workflow not found");
        }
      }

      if (distributedThumbnail != null) {
        return getVideoEditor(mediaPackageId);
      }
    }

    return R.ok();
  }

  /**
   * Creates a SMIL cutting catalog based on the passed editing information and the media package.
   *
   * @param editingInfo
   *          the editing information
   * @param mediaPackage
   *          the media package
   * @return a SMIL catalog
   * @throws SmilException
   *           if creating the SMIL catalog failed
   */
  Smil createSmilCuttingCatalog(final EditingInfo editingInfo, final MediaPackage mediaPackage) throws SmilException {
    // Create initial SMIL catalog
    SmilResponse smilResponse = smilService.createNewSmil(mediaPackage);

    // Add tracks to the SMIL catalog
    ArrayList tracks = new ArrayList<>();

    for (final String trackId : editingInfo.getConcatTracks()) {
      Track track = mediaPackage.getTrack(trackId);
      if (track == null) {
        Opt trackOpt = getInternalPublication(mediaPackage).toStream().bind(new Fn>() {
          @Override
          public List apply(Publication a) {
            return Arrays.asList(a.getTracks());
          }
        }).filter(new Fn() {
          @Override
          public Boolean apply(Track a) {
            return trackId.equals(a.getIdentifier());
          }
        }).head();
        if (trackOpt.isNone())
          throw new IllegalStateException(
                  format("The track '%s' doesn't exist in media package '%s'", trackId, mediaPackage));

        track = trackOpt.get();
      }
      tracks.add(track);
    }

    for (Tuple segment : editingInfo.getConcatSegments()) {
      smilResponse = smilService.addParallel(smilResponse.getSmil());
      final String parentId = smilResponse.getEntity().getId();

      final Long duration = segment.getB() - segment.getA();
      smilResponse = smilService.addClips(smilResponse.getSmil(), parentId, tracks.toArray(new Track[tracks.size()]),
              segment.getA(), duration);
    }

    return smilResponse.getSmil();
  }

  /**
   * Adds the SMIL file as {@link Catalog} to the media package and sends the updated media package to the archive.
   *
   * @param mediaPackage
   *          the media package to at the SMIL catalog
   * @param smil
   *          the SMIL catalog
   * @return the updated media package
   * @throws IOException
   *           if the SMIL catalog cannot be read or not be written to the archive
   */
  MediaPackage addSmilToArchive(MediaPackage mediaPackage, final Smil smil) throws IOException {
   MediaPackageElementFlavor mediaPackageElementFlavor = adminUIConfiguration.getSmilCatalogFlavor();
   //set default catalog Id if there is none existing
    String catalogId = smil.getId();
    Catalog[] catalogs = mediaPackage.getCatalogs();

    //get the first smil/cutting  catalog-ID to overwrite it with new smil info
    for (Catalog p: catalogs) {
       if (p.getFlavor().matches(mediaPackageElementFlavor)) {
         logger.debug("Set Identifier for Smil-Catalog to: {}", p.getIdentifier());
         catalogId = p.getIdentifier();
       break;
       }
     }
     Catalog catalog = mediaPackage.getCatalog(catalogId);

    URI smilURI;
    try (InputStream is = IOUtils.toInputStream(smil.toXML(), "UTF-8")) {
      smilURI = workspace.put(mediaPackage.getIdentifier().toString(), catalogId, TARGET_FILE_NAME, is);
    } catch (SAXException e) {
      logger.error("Error while serializing the SMIL catalog to XML: {}", e.getMessage());
      throw new IOException(e);
    } catch (JAXBException e) {
      logger.error("Error while serializing the SMIL catalog to XML: {}", e.getMessage());
      throw new IOException(e);
    }

    if (catalog == null) {
      MediaPackageElementBuilder mpeBuilder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
      catalog = (Catalog) mpeBuilder.elementFromURI(smilURI, MediaPackageElement.Type.Catalog,
              adminUIConfiguration.getSmilCatalogFlavor());
      mediaPackage.add(catalog);
    }
    catalog.setURI(smilURI);
    catalog.setIdentifier(catalogId);
    catalog.setMimeType(MimeTypes.XML);
    for (String tag : adminUIConfiguration.getSmilCatalogTags()) {
      catalog.addTag(tag);
    }
    // setting the URI to a new source so the checksum will most like be invalid
    catalog.setChecksum(null);

    try {
      assetManager.takeSnapshot(mediaPackage);
    } catch (AssetManagerException e) {
      logger.error("Error while adding the updated media package ({}) to the archive", mediaPackage.getIdentifier(), e);
      throw new IOException(e);
    }

    return mediaPackage;
  }

  private Opt getInternalPublication(MediaPackage mp) {
    return $(mp.getPublications()).filter(new Fn() {
      @Override
      public Boolean apply(Publication a) {
        return InternalPublicationChannel.CHANNEL_ID.equals(a.getChannel());
      }
    }).head();
  }

  /**
   * Returns {@code true} if the media package is ready to be edited.
   *
   * @param mediaPackageId
   *          the media package identifier
   */
  private boolean isEditorAvailable(final String mediaPackageId) {
    final Opt optEvent = getEvent(mediaPackageId);
    if (optEvent.isSome()) {
      return Source.ARCHIVE.equals(index.getEventSource(optEvent.get()));
    } else {
      // No event found
      return false;
    }
  }

  /**
   * Get an {@link Event}
   *
   * @param mediaPackageId
   *          The mediapackage id that is also the event id.
   * @return The event if available or none if it is missing.
   */
  private Opt getEvent(final String mediaPackageId) {
    try {
      return index.getEvent(mediaPackageId, searchIndex);
    } catch (SearchIndexException e) {
      logger.error("Error while reading event '{}' from search index:", mediaPackageId, e);
      return Opt.none();
    }
  }

  /**
   * Tries to find a waveform for a given track in the media package. If a waveform is found the corresponding
   * {@link Publication} is returned, {@link Opt#none()} otherwise.
   *
   * @param mp
   *          the media package to scan for the waveform
   * @param track
   *          the track
   */
  private Opt getWaveformForTrack(final MediaPackage mp, final MediaPackageElement track) {
    return $(getInternalPublication(mp)).bind(new Fn>() {
      @Override
      public List apply(Publication a) {
        return Arrays.asList(a.getAttachments());
      }
    }).filter(new Fn() {
      @Override
      public Boolean apply(Attachment att) {
        if (track.getFlavor() == null || att.getFlavor() == null)
          return false;

        return track.getFlavor().getType().equals(att.getFlavor().getType())
                && att.getFlavor().getSubtype().equals(adminUIConfiguration.getWaveformSubtype());
      }
    }).head();
  }

  /**
   * Returns a list of workflow definitions that may be applied to a media package after segments have been defined with
   * the editor tool.
   *
   * @return a list of workflow definitions
   */
  private List getEditingWorkflows() {
    List workflows;
    try {
      workflows = workflowService.listAvailableWorkflowDefinitions();
    } catch (WorkflowDatabaseException e) {
      logger.warn("Error while retrieving list of workflow definitions:", e);
      return emptyList();
    }

    return $(workflows).filter(new Fn() {
      @Override
      public Boolean apply(WorkflowDefinition a) {
        return a.containsTag(EDITOR_WORKFLOW_TAG);
      }
    }).toList();
  }

  /**
   * Analyzes the media package and tries to get information about segments out of it.
   *
   * @param mediaPackage
   *          the media package
   * @return a list of segments or an empty list if no segments could be found.
   */
  private List> getSegments(final MediaPackage mediaPackage) {
    List> segments = new ArrayList<>();
    for (Catalog smilCatalog : mediaPackage.getCatalogs(adminUIConfiguration.getSmilCatalogFlavor())) {
      try {
        Smil smil = smilService.fromXml(workspace.get(smilCatalog.getURI())).getSmil();
        segments = mergeSegments(segments, getSegmentsFromSmil(smil));
      } catch (NotFoundException e) {
        logger.warn("File '{}' could not be loaded by workspace service:", smilCatalog.getURI(), e);
      } catch (IOException e) {
        logger.warn("Reading file '{}' from workspace service failed:", smilCatalog.getURI(), e);
      } catch (SmilException e) {
        logger.warn("Error while parsing SMIL catalog '{}':", smilCatalog.getURI(), e);
      }
    }

    if (!segments.isEmpty())
      return segments;

    // Read from silence detection flavors
    for (Catalog smilCatalog : mediaPackage.getCatalogs(adminUIConfiguration.getSmilSilenceFlavor())) {
      try {
        Smil smil = smilService.fromXml(workspace.get(smilCatalog.getURI())).getSmil();
        segments = getSegmentsFromSmil(smil);
      } catch (NotFoundException e) {
        logger.warn("File '{}' could not be loaded by workspace service:", smilCatalog.getURI(), e);
      } catch (IOException e) {
        logger.warn("Reading file '{}' from workspace service failed:", smilCatalog.getURI(), e);
      } catch (SmilException e) {
        logger.warn("Error while parsing SMIL catalog '{}':", smilCatalog.getURI(), e);
      }
    }

    // Check for single segment to ignore
    if (segments.size() == 1) {
      Tuple singleSegment = segments.get(0);
      if (singleSegment.getA() == 0 && singleSegment.getB() >= mediaPackage.getDuration())
        segments.remove(0);
    }

    return segments;
  }

  protected List> mergeSegments(List> segments, List> segments2) {
    // Merge conflicting segments
    List> mergedSegments = mergeInternal(segments, segments2);

    // Sort segments
    Collections.sort(mergedSegments, new Comparator>() {
      @Override
      public int compare(Tuple t1, Tuple t2) {
        return t1.getA().compareTo(t2.getA());
      }
    });

    return mergedSegments;
  }

  /**
   * Merges two different segments lists together. Keeps untouched segments and combines touching segments by the
   * overlapping points.
   *
   * @param segments
   *          the first segments to be merge
   * @param segments2
   *          the second segments to be merge
   * @return the merged segments
   */
  private List> mergeInternal(List> segments, List> segments2) {
    for (Iterator> it = segments.iterator(); it.hasNext();) {
      Tuple seg = it.next();
      for (Iterator> it2 = segments2.iterator(); it2.hasNext();) {
        Tuple seg2 = it2.next();
        long combinedStart = Math.max(seg.getA(), seg2.getA());
        long combinedEnd = Math.min(seg.getB(), seg2.getB());
        if (combinedEnd > combinedStart) {
          it.remove();
          it2.remove();
          List> newSegments = new ArrayList<>(segments);
          newSegments.add(tuple(combinedStart, combinedEnd));
          return mergeInternal(newSegments, segments2);
        }
      }
    }
    segments.addAll(segments2);
    return segments;
  }

  /**
   * Extracts the segments of a SMIL catalog and returns them as a list of tuples (start, end).
   *
   * @param smil
   *          the SMIL catalog
   * @return the list of segments
   */
  List> getSegmentsFromSmil(Smil smil) {
    List> segments = new ArrayList<>();
    for (SmilMediaObject elem : smil.getBody().getMediaElements()) {
      if (elem instanceof SmilMediaContainer) {
        SmilMediaContainer mediaContainer = (SmilMediaContainer) elem;

        Tuple tuple = null;
        for (SmilMediaObject video : mediaContainer.getElements()) {
          if (video instanceof SmilMediaElement) {
            SmilMediaElement videoElem = (SmilMediaElement) video;
            try {
              // pick longest element
              if (tuple == null || (videoElem.getClipEndMS() - videoElem.getClipBeginMS()) > (Long) tuple.getB() - (Long) tuple.getA()) {
                tuple = Tuple.tuple(videoElem.getClipBeginMS(), videoElem.getClipEndMS());
              }
            } catch (SmilException e) {
              logger.warn("Media element '{}' of SMIL catalog '{}' seems to be invalid: {}",
                      videoElem, smil, e);
            }
          }
        }
        if (tuple != null) {
          segments.add(tuple);
        }
      }
    }
    return segments;
  }

  static final class SourceTrackSubInfo {
    private final boolean present;
    private final String previewImage;
    private final boolean hidden;

    SourceTrackSubInfo(final boolean present, final String previewImage, final boolean hidden) {
      this.present = present;
      this.previewImage = previewImage;
      this.hidden = hidden;
    }

    public static SourceTrackSubInfo parse(final JSONObject object) {
      Boolean hidden = (Boolean) object.get("hidden");
      if (hidden == null) {
        hidden = Boolean.FALSE;
      }
      return new SourceTrackSubInfo((Boolean)object.get("present"), (String)object.get("preview_image"), hidden);
    }

    public JObject toJson() {
      if (present) {
        return obj(f("present", true), f("preview_image", previewImage == null ? Jsons.NULL : v(previewImage)),
          f("hidden", hidden));
      }
      return obj(f("present", false));
    }
  }

  static final class SourceTrackInfo {
    private final String flavorType;
    private final String flavorSubtype;
    private final SourceTrackSubInfo audio;
    private final SourceTrackSubInfo video;
    private final String side;

    MediaPackageElementFlavor getFlavor() {
      return new MediaPackageElementFlavor(flavorType, flavorSubtype);
    }

    SourceTrackInfo(final String flavorType, final String flavorSubtype, final SourceTrackSubInfo audio,
      final SourceTrackSubInfo video, final String side) {
      this.flavorType = flavorType;
      this.flavorSubtype = flavorSubtype;
      this.audio = audio;
      this.video = video;
      this.side = side;
    }

    public static SourceTrackInfo parse(final JSONObject object) {
      final JSONObject flavor = (JSONObject) object.get("flavor");
      return new SourceTrackInfo((String) flavor.get("type"), (String) flavor.get("subtype"),
        SourceTrackSubInfo.parse((JSONObject) object.get("audio")),
        SourceTrackSubInfo.parse((JSONObject) object.get("video")),
        (String) object.get("side"));
    }

    public JObject toJson() {
      final JObject flavor = obj(f("type", flavorType), f("subtype", flavorSubtype));
      return obj(f("flavor", flavor), f("audio", audio.toJson()), f("video", video.toJson()), f("side", side));
    }
  }

  /** Provides access to the parsed editing information */
  static final class EditingInfo {

    private final List> segments;
    private final List tracks;
    private final Optional workflow;
    private final OptionalDouble defaultThumbnailPosition;
    private final List sourceTracks;

    private EditingInfo(List> segments, List tracks, List sourceTracks,
      Optional workflow, OptionalDouble defaultThumbnailPosition) {
      this.segments = segments;
      this.tracks = tracks;
      this.sourceTracks = sourceTracks;
      this.workflow = workflow;
      this.defaultThumbnailPosition = defaultThumbnailPosition;
    }

    /**
     * Parse {@link JSONObject} to {@link EditingInfo}.
     *
     * @param obj
     *          the JSON object to parse
     * @return all editing information found in the JSON object
     */
    static EditingInfo parse(JSONObject obj) {

      JSONObject concatObject = requireNonNull((JSONObject) obj.get(CONCAT_KEY));
      JSONArray jsonSegments = requireNonNull((JSONArray) concatObject.get(SEGMENTS_KEY));
      JSONArray jsonTracks = requireNonNull((JSONArray) concatObject.get(TRACKS_KEY));
      JSONArray jsonSourceTracks = requireNonNull((JSONArray) concatObject.get(SOURCE_TRACKS_KEY));

      List> segments = new ArrayList<>();
      for (Object segment : jsonSegments) {
        final JSONObject jSegment = (JSONObject) segment;
        final Long start = (Long) jSegment.get(START_KEY);
        final Long end = (Long) jSegment.get(END_KEY);
        if (end < start)
          throw new IllegalArgumentException("The end date of a segment must be after the start date of the segment");
        segments.add(Tuple.tuple(start, end));
      }

      List tracks = new ArrayList<>();
      for (Object track : jsonTracks) {
        tracks.add((String) track);
      }

      OptionalDouble defaultThumbnailPosition = OptionalDouble.empty();
      final Object defaultThumbnailPositionObj = obj.get(DEFAULT_THUMBNAIL_POSITION_KEY);
      if (defaultThumbnailPositionObj != null) {
        defaultThumbnailPosition = OptionalDouble.of(Double.parseDouble(defaultThumbnailPositionObj.toString()));
      }

      List sourceTracks = new ArrayList<>();
      for (Object sourceTrack : jsonSourceTracks) {
        sourceTracks.add((SourceTrackInfo.parse((JSONObject) sourceTrack)));
      }

      return new EditingInfo(
        segments,
        tracks,
        sourceTracks,
        Optional.ofNullable((String) obj.get("workflow")),
        defaultThumbnailPosition);
    }

    /**
     * Returns a list of {@link Tuple} that each represents a segment. {@link Tuple#getA()} marks the start point,
     * {@link Tuple#getB()} the endpoint of the segement.
     */
    List> getConcatSegments() {
      return Collections.unmodifiableList(segments);
    }

    /** Returns a list of track identifiers. */
    List getConcatTracks() {
      return Collections.unmodifiableList(tracks);
    }

    /** Returns the optional workflow to start */
    Optional getPostProcessingWorkflow() {
      return workflow;
    }

    /** Returns the optional default thumbnail position. */
    OptionalDouble getDefaultThumbnailPosition() {
      return defaultThumbnailPosition;
    }
  }

}