/**
* 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.apache.commons.lang3.exception.ExceptionUtils.getStackTrace;
import static org.opencastproject.assetmanager.api.AssetManager.DEFAULT_OWNER;
import static org.opencastproject.util.data.Tuple.tuple;
import org.opencastproject.adminui.impl.AdminUIConfiguration;
import org.opencastproject.adminui.impl.index.AdminUISearchIndex;
import org.opencastproject.assetmanager.api.AssetManager;
import org.opencastproject.assetmanager.api.AssetManagerException;
import org.opencastproject.assetmanager.util.Workflows;
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.impl.index.event.Event;
import org.opencastproject.index.service.util.RestUtils;
import org.opencastproject.matterhorn.search.SearchIndexException;
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.Publication;
import org.opencastproject.mediapackage.Track;
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.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.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.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.io.IOUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.osgi.service.cm.ConfigurationException;
import org.osgi.service.cm.ManagedService;
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.Collections;
import java.util.Comparator;
import java.util.Dictionary;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
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.",
"<strong>Important:</strong> "
+ "<em>This service is for exclusive use by the module matterhorn-admin-ui-ng. 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.<em>"})
public class ToolsEndpoint implements ManagedService {
/** 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";
/** Tag that marks workflow for being used from the editor tool */
private static final String EDITOR_WORKFLOW_TAG = "editor";
private long expireSeconds = UrlSigningServiceOsgiUtil.DEFAULT_URL_SIGNING_EXPIRE_DURATION;
private Boolean signWithClientIP = UrlSigningServiceOsgiUtil.DEFAULT_SIGN_WITH_CLIENT_IP;
/** A parser for handling JSON documents inside the body of a request. **/
private final JSONParser parser = new JSONParser();
// service references
private AdminUIConfiguration adminUIConfiguration;
private AdminUISearchIndex searchIndex;
private AssetManager assetManager;
private IndexService index;
private SecurityService securityService;
private SmilService smilService;
private UrlSigningService urlSigningService;
private WorkflowService workflowService;
private Workspace workspace;
/** OSGi DI. */
void setAdminUIConfiguration(AdminUIConfiguration adminUIConfiguration) {
this.adminUIConfiguration = adminUIConfiguration;
}
/** OSGi DI */
void setAdminUISearchIndex(AdminUISearchIndex adminUISearchIndex) {
this.searchIndex = adminUISearchIndex;
}
/** OSGi DI */
void setAssetManager(AssetManager assetManager) {
this.assetManager = assetManager;
}
/** OSGi DI */
void setIndexService(IndexService index) {
this.index = index;
}
/** OSGi DI */
void setSecurityService(SecurityService securityService) {
this.securityService = securityService;
}
/** OSGi DI */
void setSmilService(SmilService smilService) {
this.smilService = smilService;
}
/** OSGi DI */
void setUrlSigningService(UrlSigningService urlSigningService) {
this.urlSigningService = urlSigningService;
}
/** OSGi DI */
void setWorkflowService(WorkflowService workflowService) {
this.workflowService = workflowService;
}
/** OSGi DI */
void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
/** OSGi callback if properties file is present */
@Override
public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
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());
}
@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) }, reponses = {
@RestResponse(description = "Available tools evaluated", responseCode = HttpServletResponse.SC_OK) })
public Response getAvailableTools(@PathParam("mediapackageid") final String mediaPackageId) {
final List<JValue> jTools = new ArrayList<>();
if (isEditorAvailable(mediaPackageId))
jTools.add(v("editor"));
return RestUtils.okJson(obj(f("available", arr(jTools))));
}
private List<MediaPackageElement> getPreviewElementsFromPublication(Opt<Publication> publication) {
List<MediaPackageElement> 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());
}
@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) }, reponses = {
@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 {
if (!isEditorAvailable(mediaPackageId))
return R.notFound();
// Select tracks
final Event event = getEvent(mediaPackageId).get();
final MediaPackage mp = index.getEventMediapackage(event).orError(new NotFoundException()).get();
List<MediaPackageElement> previewPublications = getPreviewElementsFromPublication(getInternalPublication(mp));
// Collect previews and tracks
List<JValue> jPreviews = new ArrayList<>();
List<JValue> jTracks = new ArrayList<>();
for (MediaPackageElement element : previewPublications) {
final URI elementUri;
if (urlSigningService.accepts(element.getURI().toString())) {
try {
String clientIP = null;
if (signWithClientIP) {
clientIP = securityService.getUserIP();
}
elementUri = new URI(urlSigningService.sign(element.getURI().toString(), expireSeconds, null, clientIP));
} catch (URISyntaxException e) {
logger.error("Error while trying to sign the preview urls because: {}", getStackTrace(e));
throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
} catch (UrlSigningException e) {
logger.error("Error while trying to sign the preview urls because: {}", getStackTrace(e));
throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
}
} else {
elementUri = element.getURI();
}
jPreviews.add(obj(f("uri", v(elementUri.toString())), f("flavor", v(element.getFlavor().getType()))));
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<Attachment> optWaveform = getWaveformForTrack(mp, element);
if (optWaveform.isSome()) {
final URI waveformUri;
if (urlSigningService.accepts(element.getURI().toString())) {
try {
waveformUri = new URI(
urlSigningService.sign(optWaveform.get().getURI().toString(), expireSeconds, null, null));
} catch (URISyntaxException e) {
logger.error("Error while trying to serialize the waveform urls because: {}", getStackTrace(e));
throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
} catch (UrlSigningException e) {
logger.error("Error while trying to sign the preview urls because: {}", getStackTrace(e));
throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
}
} else {
waveformUri = optWaveform.get().getURI();
}
jTracks.add(jTrack.merge(obj(f("waveform", v(waveformUri.toString())))));
} else {
jTracks.add(jTrack);
}
}
// Get existing segments
List<JValue> jSegments = new ArrayList<>();
for (Tuple<Long, Long> segment : getSegments(mp)) {
jSegments.add(obj(f(START_KEY, v(segment.getA())), f(END_KEY, v(segment.getB()))));
}
// Get workflows
List<JValue> jWorkflows = new ArrayList<>();
for (WorkflowDefinition workflow : getEditingWorkflows()) {
jWorkflows.add(obj(f("id", v(workflow.getId())), f("name", v(workflow.getTitle(), Jsons.BLANK))));
}
return RestUtils.okJson(obj(f("title", v(mp.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("previews", arr(jPreviews)), f(TRACKS_KEY, arr(jTracks)),
f("duration", v(mp.getDuration())), f(SEGMENTS_KEY, arr(jSegments)), f("workflows", arr(jWorkflows))));
}
@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) }, reponses = {
@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);
} catch (IOException e) {
logger.error("Error reading request body: {}", getStackTrace(e));
return R.serverError();
}
EditingInfo editingInfo;
try {
JSONObject detailsJSON = (JSONObject) parser.parse(details);
editingInfo = EditingInfo.parse(detailsJSON);
} catch (Exception e) {
logger.warn("Unable to parse concat information ({}): {}", details, ExceptionUtils.getStackTrace(e));
return R.badRequest("Unable to parse details");
}
final Opt<Event> optEvent = getEvent(mediaPackageId);
if (optEvent.isNone()) {
return R.notFound();
} else {
MediaPackage mediaPackage = index.getEventMediapackage(optEvent.get()).orError(new NotFoundException()).get();
Smil smil;
try {
smil = createSmilCuttingCatalog(editingInfo, mediaPackage);
} catch (Exception e) {
logger.warn("Unable to create a SMIL cutting catalog ({}): {}", details, getStackTrace(e));
return R.badRequest("Unable to create SMIL cutting catalog");
}
try {
addSmilToArchive(mediaPackage, smil);
} catch (IOException e) {
logger.warn("Unable to add SMIL cutting catalog to archive: {}", getStackTrace(e));
return R.serverError();
}
if (editingInfo.getPostProcessingWorkflow().isSome()) {
final String workflowId = editingInfo.getPostProcessingWorkflow().get();
try {
final Workflows workflows = new Workflows(assetManager, workspace, workflowService);
workflows.applyWorkflowToLatestVersion($(mediaPackage.getIdentifier().toString()),
ConfiguredWorkflow.workflow(workflowService.getWorkflowDefinitionById(workflowId))).run();
} catch (AssetManagerException e) {
logger.warn("Unable to start workflow '{}' on archived media package '{}': {}",
new Object[] { workflowId, mediaPackage, getStackTrace(e) });
return R.serverError();
} catch (WorkflowDatabaseException e) {
logger.warn("Unable to load workflow '{}' from workflow service: {}", workflowId, getStackTrace(e));
return R.serverError();
} catch (NotFoundException e) {
logger.warn("Workflow '{}' not found", workflowId);
return R.badRequest("Workflow not found");
}
}
}
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<Track> tracks = new ArrayList<>();
for (final String trackId : editingInfo.getConcatTracks()) {
Track track = mediaPackage.getTrack(trackId);
if (track == null) {
Opt<Track> trackOpt = getInternalPublication(mediaPackage).toStream().bind(new Fn<Publication, List<Track>>() {
@Override
public List<Track> apply(Publication a) {
return Arrays.asList(a.getTracks());
}
}).filter(new Fn<Track, Boolean>() {
@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<Long, Long> 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 {
final String catalogId = "editor-cutting-information";
Catalog catalog = mediaPackage.getCatalog(catalogId);
URI smilURI;
try (InputStream is = IOUtils.toInputStream(smil.toXML(), "UTF-8")) {
smilURI = workspace.put(mediaPackage.getIdentifier().compact(), smil.getId(), 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 {
// FIXME SWITCHP-333: Start in new thread
assetManager.takeSnapshot(DEFAULT_OWNER, mediaPackage);
} catch (AssetManagerException e) {
logger.error("Error while adding the updated media package ({}) to the archive: {}", mediaPackage.getIdentifier(),
e.getMessage());
throw new IOException(e);
}
return mediaPackage;
}
private Opt<Publication> getInternalPublication(MediaPackage mp) {
return $(mp.getPublications()).filter(new Fn<Publication, Boolean>() {
@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<Event> 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<Event> getEvent(final String mediaPackageId) {
try {
return index.getEvent(mediaPackageId, searchIndex);
} catch (SearchIndexException e) {
logger.error("Error while reading event '{}' from search index: {}", mediaPackageId, getStackTrace(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<Attachment> getWaveformForTrack(final MediaPackage mp, final MediaPackageElement track) {
return $(getInternalPublication(mp)).bind(new Fn<Publication, List<Attachment>>() {
@Override
public List<Attachment> apply(Publication a) {
return Arrays.asList(a.getAttachments());
}
}).filter(new Fn<Attachment, Boolean>() {
@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<WorkflowDefinition> getEditingWorkflows() {
List<WorkflowDefinition> workflows;
try {
workflows = workflowService.listAvailableWorkflowDefinitions();
} catch (WorkflowDatabaseException e) {
logger.warn("Error while retrieving list of workflow definitions: {}", getStackTrace(e));
return emptyList();
}
return $(workflows).filter(new Fn<WorkflowDefinition, Boolean>() {
@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<Tuple<Long, Long>> getSegments(final MediaPackage mediaPackage) {
List<Tuple<Long, Long>> 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(), getStackTrace(e));
} catch (IOException e) {
logger.warn("Reading file '{}' from workspace service failed: {}", smilCatalog.getURI(), getStackTrace(e));
} catch (SmilException e) {
logger.warn("Error while parsing SMIL catalog '{}': {}", smilCatalog.getURI(), getStackTrace(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 = mergeSegments(segments, getSegmentsFromSmil(smil));
} catch (NotFoundException e) {
logger.warn("File '{}' could not be loaded by workspace service: {}", smilCatalog.getURI(), getStackTrace(e));
} catch (IOException e) {
logger.warn("Reading file '{}' from workspace service failed: {}", smilCatalog.getURI(), getStackTrace(e));
} catch (SmilException e) {
logger.warn("Error while parsing SMIL catalog '{}': {}", smilCatalog.getURI(), getStackTrace(e));
}
}
// Check for single segment to ignore
if (segments.size() == 1) {
Tuple<Long, Long> singleSegment = segments.get(0);
if (singleSegment.getA() == 0 && singleSegment.getB() >= mediaPackage.getDuration())
segments.remove(0);
}
return segments;
}
protected List<Tuple<Long, Long>> mergeSegments(List<Tuple<Long, Long>> segments, List<Tuple<Long, Long>> segments2) {
// Merge conflicting segments
List<Tuple<Long, Long>> mergedSegments = mergeInternal(segments, segments2);
// Sort segments
Collections.sort(mergedSegments, new Comparator<Tuple<Long, Long>>() {
@Override
public int compare(Tuple<Long, Long> t1, Tuple<Long, Long> 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<Tuple<Long, Long>> mergeInternal(List<Tuple<Long, Long>> segments, List<Tuple<Long, Long>> segments2) {
for (Iterator<Tuple<Long, Long>> it = segments.iterator(); it.hasNext();) {
Tuple<Long, Long> seg = it.next();
for (Iterator<Tuple<Long, Long>> it2 = segments2.iterator(); it2.hasNext();) {
Tuple<Long, Long> 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<Tuple<Long, Long>> 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<Tuple<Long, Long>> getSegmentsFromSmil(Smil smil) {
List<Tuple<Long, Long>> segments = new ArrayList<>();
for (SmilMediaObject elem : smil.getBody().getMediaElements()) {
if (elem instanceof SmilMediaContainer) {
SmilMediaContainer mediaContainer = (SmilMediaContainer) elem;
for (SmilMediaObject video : mediaContainer.getElements()) {
if (video instanceof SmilMediaElement) {
SmilMediaElement videoElem = (SmilMediaElement) video;
try {
segments.add(Tuple.tuple(videoElem.getClipBeginMS(), videoElem.getClipEndMS()));
break;
} catch (SmilException e) {
logger.warn("Media element '{}' of SMIL catalog '{}' seems to be invalid: {}",
new Object[] { videoElem, smil, e });
}
}
}
}
}
return segments;
}
/** Provides access to the parsed editing information */
static final class EditingInfo {
private final List<Tuple<Long, Long>> segments;
private final List<String> tracks;
private final Opt<String> workflow;
private EditingInfo(List<Tuple<Long, Long>> segments, List<String> tracks, Opt<String> workflow) {
this.segments = segments;
this.tracks = tracks;
this.workflow = workflow;
}
/**
* 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));
List<Tuple<Long, Long>> 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<String> tracks = new ArrayList<>();
for (Object track : jsonTracks) {
tracks.add((String) track);
}
return new EditingInfo(segments, tracks, Opt.nul((String) obj.get("workflow")));
}
/**
* 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<Tuple<Long, Long>> getConcatSegments() {
return Collections.unmodifiableList(segments);
}
/** Returns a list of track identifiers. */
List<String> getConcatTracks() {
return Collections.unmodifiableList(tracks);
}
/** Returns the optional workflow to start */
Opt<String> getPostProcessingWorkflow() {
return workflow;
}
}
}