/**
* 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.distribution.streaming;
import static java.lang.String.format;
import static org.opencastproject.util.OsgiUtil.getOptContextProperty;
import static org.opencastproject.util.PathSupport.path;
import static org.opencastproject.util.UrlSupport.concat;
import static org.opencastproject.util.data.Option.none;
import static org.opencastproject.util.data.Option.some;
import org.opencastproject.distribution.api.AbstractDistributionService;
import org.opencastproject.distribution.api.DistributionException;
import org.opencastproject.distribution.api.DistributionService;
import org.opencastproject.job.api.Job;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.mediapackage.MediaPackageElement;
import org.opencastproject.mediapackage.MediaPackageElementParser;
import org.opencastproject.mediapackage.MediaPackageException;
import org.opencastproject.mediapackage.MediaPackageParser;
import org.opencastproject.mediapackage.track.TrackImpl;
import org.opencastproject.serviceregistry.api.ServiceRegistryException;
import org.opencastproject.util.FileSupport;
import org.opencastproject.util.LoadUtil;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.util.OsgiUtil;
import org.opencastproject.util.RequireUtil;
import org.opencastproject.util.data.Option;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.osgi.service.cm.ConfigurationException;
import org.osgi.service.cm.ManagedService;
import org.osgi.service.component.ComponentContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Dictionary;
import java.util.List;
/**
* Distributes media to the local media delivery directory.
*/
public class StreamingDistributionService extends AbstractDistributionService implements DistributionService, ManagedService {
/** Logging facility */
private static final Logger logger = LoggerFactory.getLogger(StreamingDistributionService.class);
/** Receipt type */
public static final String JOB_TYPE = "org.opencastproject.distribution.streaming";
/** List of available operations on jobs */
private enum Operation {
Distribute, Retract
}
/** The load on the system introduced by creating a distribute job */
public static final float DEFAULT_DISTRIBUTE_JOB_LOAD = 0.1f;
/** The load on the system introduced by creating a retract job */
public static final float DEFAULT_RETRACT_JOB_LOAD = 1.0f;
/** The key to look for in the service configuration file to override the {@link DEFAULT_DISTRIBUTE_JOB_LOAD} */
public static final String DISTRIBUTE_JOB_LOAD_KEY = "job.load.streaming.distribute";
/** The key to look for in the service configuration file to override the {@link DEFAULT_RETRACT_JOB_LOAD} */
public static final String RETRACT_JOB_LOAD_KEY = "job.load.streaming.retract";
/** The load on the system introduced by creating a distribute job */
private float distributeJobLoad = DEFAULT_DISTRIBUTE_JOB_LOAD;
/** The load on the system introduced by creating a retract job */
private float retractJobLoad = DEFAULT_RETRACT_JOB_LOAD;
private Option<Locations> locations = none();
/** Creates a new instance of the streaming distribution service. */
public StreamingDistributionService() {
super(JOB_TYPE);
}
@Override
public void activate(ComponentContext cc) {
super.activate(cc);
// Get the configured streaming and server URLs
if (cc != null) {
for (final String streamingUrl : getOptContextProperty(cc, "org.opencastproject.streaming.url")) {
for (final String distributionDirectoryPath : getOptContextProperty(cc,
"org.opencastproject.streaming.directory")) {
final File distributionDirectory = new File(distributionDirectoryPath);
if (!distributionDirectory.isDirectory()) {
try {
FileUtils.forceMkdir(distributionDirectory);
} catch (IOException e) {
throw new IllegalStateException("Distribution directory does not exist and can't be created", e);
}
}
String compatibility = StringUtils
.trimToNull(cc.getBundleContext().getProperty("org.opencastproject.streaming.flvcompatibility"));
boolean flvCompatibilityMode = false;
if (compatibility != null) {
flvCompatibilityMode = Boolean.parseBoolean(compatibility);
logger.info("Streaming distribution is using FLV compatibility mode");
}
locations = some(new Locations(URI.create(streamingUrl), distributionDirectory, flvCompatibilityMode));
logger.info("Streaming url is {}", streamingUrl);
logger.info("Streaming distribution directory is {}", distributionDirectory);
return;
}
logger.info("No streaming distribution directory configured (org.opencastproject.streaming.directory)");
}
logger.info("No streaming url configured (org.opencastproject.streaming.url)");
}
this.distributionChannel = OsgiUtil.getComponentContextProperty(cc, CONFIG_KEY_STORE_TYPE);
}
public String getDistributionType() {
return this.distributionChannel;
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.distribution.api.DistributionService#distribute(String,
* org.opencastproject.mediapackage.MediaPackage, String)
*/
@Override
public Job distribute(String channelId, MediaPackage mediapackage, String elementId)
throws DistributionException, MediaPackageException {
if (locations.isNone())
return null;
RequireUtil.notNull(mediapackage, "mediapackage");
RequireUtil.notNull(elementId, "elementId");
RequireUtil.notNull(channelId, "channelId");
//
try {
return serviceRegistry.createJob(JOB_TYPE, Operation.Distribute.toString(),
Arrays.asList(channelId, MediaPackageParser.getAsXml(mediapackage), elementId), distributeJobLoad);
} catch (ServiceRegistryException e) {
throw new DistributionException("Unable to create a job", e);
}
}
/**
* Distribute a Mediapackage element to the download distribution service.
*
* @param mp
* The media package that contains the element to distribute.
* @param mpeId
* The id of the element that should be distributed contained within the media package.
* @return A reference to the MediaPackageElement that has been distributed.
* @throws DistributionException
* Thrown if the parent directory of the MediaPackageElement cannot be created, if the MediaPackageElement
* cannot be copied or another unexpected exception occurs.
*/
private MediaPackageElement distributeElement(String channelId, final MediaPackage mp, String mpeId)
throws DistributionException {
RequireUtil.notNull(channelId, "channelId");
RequireUtil.notNull(mp, "mp");
RequireUtil.notNull(mpeId, "mpeId");
//
final MediaPackageElement element = mp.getElementById(mpeId);
// Make sure the element exists
if (element == null) {
throw new IllegalStateException("No element " + mpeId + " found in media package");
}
// Streaming servers only deal with tracks
if (!MediaPackageElement.Type.Track.equals(element.getElementType())) {
logger.debug("Skipping {} {} for distribution to the streaming server",
element.getElementType().toString().toLowerCase(), element.getIdentifier());
return null;
}
try {
File source;
try {
source = workspace.get(element.getURI());
} catch (NotFoundException e) {
throw new DistributionException("Unable to find " + element.getURI() + " in the workspace", e);
} catch (IOException e) {
throw new DistributionException("Error loading " + element.getURI() + " from the workspace", e);
}
// Try to find a duplicated element source
try {
source = findDuplicatedElementSource(source, mp.getIdentifier().compact());
} catch (IOException e) {
logger.warn("Unable to find duplicated source {}: {}", source, ExceptionUtils.getMessage(e));
}
final File destination = locations.get().createDistributionFile(securityService.getOrganization().getId(),
channelId, mp.getIdentifier().compact(), element.getIdentifier(), element.getURI());
if (!destination.equals(source)) {
// Put the file in place if sourcesfile differs destinationfile
try {
FileUtils.forceMkdir(destination.getParentFile());
} catch (IOException e) {
throw new DistributionException("Unable to create " + destination.getParentFile(), e);
}
logger.info("Distributing {} to {}", mpeId, destination);
try {
FileSupport.link(source, destination, true);
} catch (IOException e) {
throw new DistributionException("Unable to copy " + source + " to " + destination, e);
}
}
// Create a representation of the distributed file in the mediapackage
final MediaPackageElement distributedElement = (MediaPackageElement) element.clone();
distributedElement.setURI(locations.get().createDistributionUri(securityService.getOrganization().getId(),
channelId, mp.getIdentifier().compact(), element.getIdentifier(), element.getURI()));
distributedElement.setIdentifier(null);
((TrackImpl) distributedElement).setTransport(TrackImpl.StreamingProtocol.RTMP);
logger.info("Finished distribution of {}", element);
return distributedElement;
} catch (Exception e) {
logger.warn("Error distributing " + element, e);
if (e instanceof DistributionException) {
throw (DistributionException) e;
} else {
throw new DistributionException(e);
}
}
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.distribution.api.DistributionService#retract(String,
* org.opencastproject.mediapackage.MediaPackage, String) java.lang.String)
*/
@Override
public Job retract(String channelId, MediaPackage mediaPackage, String elementId) throws DistributionException {
if (locations.isNone())
return null;
RequireUtil.notNull(mediaPackage, "mediaPackage");
RequireUtil.notNull(elementId, "elementId");
RequireUtil.notNull(channelId, "channelId");
//
try {
return serviceRegistry.createJob(JOB_TYPE, Operation.Retract.toString(),
Arrays.asList(channelId, MediaPackageParser.getAsXml(mediaPackage), elementId), retractJobLoad);
} catch (ServiceRegistryException e) {
throw new DistributionException("Unable to create a job", e);
}
}
/**
* Retracts the mediapackage with the given identifier from the distribution channel.
*
* @param channelId
* the channel id
* @param mp
* the mediapackage
* @param mpeId
* the element identifier
* @return the retracted element or <code>null</code> if the element was not retracted
*/
private MediaPackageElement retractElement(final String channelId, final MediaPackage mp, final String mpeId)
throws DistributionException {
RequireUtil.notNull(channelId, "channelId");
RequireUtil.notNull(mp, "mp");
RequireUtil.notNull(mpeId, "elementId");
// Make sure the element exists
final MediaPackageElement mpe = mp.getElementById(mpeId);
if (mpe == null) {
throw new IllegalStateException("No element " + mpeId + " found in media package");
}
try {
for (final File mpeFile : locations.get().getDistributionFileFrom(mpe.getURI())) {
logger.info("Retracting element {} from {}", mpe, mpeFile);
// Does the file exist? If not, the current element has not been distributed to this channel
// or has been removed otherwise
if (mpeFile.exists()) {
// Try to remove the file and - if possible - the parent folder
final File parentDir = mpeFile.getParentFile();
FileUtils.forceDelete(mpeFile);
FileSupport.deleteHierarchyIfEmpty(new File(locations.get().getBaseDir()), parentDir);
logger.info("Finished retracting element {} of media package {}", mpeId, mp);
return mpe;
} else {
logger.info(format("Element %s@%s has already been removed from publication channel %s", mpeId,
mp.getIdentifier(), channelId));
return mpe;
}
}
// could not extract a file from the element's URI
logger.info(format("Element %s has not been published to publication channel %s", mpe.getURI(), channelId));
return mpe;
} catch (Exception e) {
logger.warn(format("Error retracting element %s of media package %s", mpeId, mp), e);
if (e instanceof DistributionException) {
throw (DistributionException) e;
} else {
throw new DistributionException(e);
}
}
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.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);
String channelId = arguments.get(0);
MediaPackage mediapackage = MediaPackageParser.getFromXml(arguments.get(1));
String elementId = arguments.get(2);
switch (op) {
case Distribute:
MediaPackageElement distributedElement = distributeElement(channelId, mediapackage, elementId);
return (distributedElement != null) ? MediaPackageElementParser.getAsXml(distributedElement) : null;
case Retract:
return locations.isSome()
? MediaPackageElementParser.getAsXml(retractElement(channelId, mediapackage, elementId)) : null;
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);
}
}
/**
* Try to find the same file being already distributed in one of the other channels
*
* @param source
* the source file
* @param mpId
* the element's mediapackage id
* @return the found duplicated file or the given source if nothing has been found
* @throws IOException
* if an I/O error occurs
*/
private File findDuplicatedElementSource(final File source, final String mpId) throws IOException {
String orgId = securityService.getOrganization().getId();
final Path rootPath = new File(path(locations.get().getBaseDir(), orgId)).toPath();
// Check if root path exists, if not you're file system has not been migrated to the new distribution service yet
// and does not support this function
if (!Files.exists(rootPath))
return source;
// Find matching mediapackage directories
List<Path> mediaPackageDirectories = new ArrayList<>();
try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(rootPath)) {
for (Path path : directoryStream) {
Path mpDir = new File(path.toFile(), mpId).toPath();
if (Files.exists(mpDir)) {
mediaPackageDirectories.add(mpDir);
}
}
}
if (mediaPackageDirectories.isEmpty())
return source;
final long size = Files.size(source.toPath());
final File[] result = new File[1];
for (Path p : mediaPackageDirectories) {
// Walk through found mediapackage directories to find duplicated element
Files.walkFileTree(p, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
// Walk through files only
if (Files.isDirectory(file))
return FileVisitResult.CONTINUE;
// Check for same file size
if (size != attrs.size())
return FileVisitResult.CONTINUE;
// If size less than 4096 bytes use readAllBytes method which performs better
if (size < 4096) {
if (!Arrays.equals(Files.readAllBytes(source.toPath()), Files.readAllBytes(file)))
return FileVisitResult.CONTINUE;
} else {
// Otherwise compare file input stream
try (InputStream is1 = Files.newInputStream(source.toPath());
InputStream is2 = Files.newInputStream(file)) {
if (!IOUtils.contentEquals(is1, is2))
return FileVisitResult.CONTINUE;
}
}
// File is equal, store file and terminate file walking
result[0] = file.toFile();
return FileVisitResult.TERMINATE;
}
});
// A duplicate has already been found, no further file walking is needed
if (result[0] != null)
break;
}
// Return found duplicate otherwise source
if (result[0] != null)
return result[0];
return source;
}
@Override
public void updated(@SuppressWarnings("rawtypes") Dictionary properties) throws ConfigurationException {
distributeJobLoad = LoadUtil.getConfiguredLoadValue(properties, DISTRIBUTE_JOB_LOAD_KEY,
DEFAULT_DISTRIBUTE_JOB_LOAD, serviceRegistry);
retractJobLoad = LoadUtil.getConfiguredLoadValue(properties, RETRACT_JOB_LOAD_KEY, DEFAULT_RETRACT_JOB_LOAD,
serviceRegistry);
}
public static class Locations {
private final URI baseUri;
private final String baseDir;
/** Compatibility mode for nginx and maybe other streaming servers */
private boolean flvCompatibilityMode = false;
/**
* @param baseUri
* the base URL of the distributed streaming artifacts
* @param baseDir
* the file system base directory below which streaming distribution artifacts are stored
*/
public Locations(URI baseUri, File baseDir, boolean flvCompatibilityMode) {
this.flvCompatibilityMode = flvCompatibilityMode;
try {
final String ensureSlash = baseUri.getSchemeSpecificPart().endsWith("/") ? baseUri.getSchemeSpecificPart()
: baseUri.getSchemeSpecificPart() + "/";
this.baseUri = new URI(baseUri.getScheme(), ensureSlash, null);
this.baseDir = baseDir.getAbsolutePath();
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
}
public String getBaseUri() {
return baseUri.toString();
}
public String getBaseDir() {
return baseDir;
}
public boolean isDistributionUrl(URI mpeUrl) {
return mpeUrl.toString().startsWith(getBaseUri());
}
public Option<URI> dropBase(URI mpeUrl) {
if (isDistributionUrl(mpeUrl)) {
return some(baseUri.relativize(mpeUrl));
} else {
return none();
}
}
/**
* Try to retrieve the distribution file from a distribution URI. This is the the inverse function of
* {@link #createDistributionUri(String, String, String, String, java.net.URI)}.
*
* @param mpeDistUri
* the URI of a distributed media package element
* @see #createDistributionUri(String, String, String, String, java.net.URI)
* @see #createDistributionFile(String, String, String, String, java.net.URI)
*/
public Option<File> getDistributionFileFrom(final URI mpeDistUri) {
// if the given URI is not a distribution URI there cannot be a corresponding file
for (URI distPath : dropBase(mpeDistUri)) {
// 0: orgId | [extension ":" ] orgId ;
// extension = "mp4" | ...
// 1: channelId
// 2: mediaPackageId
// 3: mediaPackageElementId
// 4: fileName
final String[] splitUrl = distPath.toString().split("/");
if (splitUrl.length == 5) {
final String[] split = splitUrl[0].split(":");
final String ext;
final String orgId;
if (split.length == 2) {
ext = split[0];
orgId = split[1];
} else {
ext = "flv";
orgId = split[0];
}
return some(new File(path(baseDir, orgId, splitUrl[1], splitUrl[2], splitUrl[3], splitUrl[4] + "." + ext)));
} else {
return none();
}
}
return none();
}
/**
* Create a file to distribute a media package element to.
*
* @param orgId
* the id of the organization
* @param channelId
* the id of the distribution channel
* @param mpId
* the media package id
* @param mpeId
* the media package element id
* @param mpeUri
* the URI of the media package element to distribute
* @see #createDistributionUri(String, String, String, String, java.net.URI)
* @see #getDistributionFileFrom(java.net.URI)
*/
public File createDistributionFile(final String orgId, final String channelId, final String mpId,
final String mpeId, final URI mpeUri) {
for (File f : getDistributionFileFrom(mpeUri)) {
return f;
}
return new File(path(baseDir, orgId, channelId, mpId, mpeId, FilenameUtils.getName(mpeUri.toString())));
}
/**
* Create a distribution URI for a media package element. This is the inverse function of
* {@link #getDistributionFileFrom(java.net.URI)}.
* <p>
* Distribution URIs look like this:
*
* <pre>
* Flash video (flv)
* rtmp://localhost/matterhorn-engage/mh_default_org/engage-player/9f411edb-edf5-4308-8df5-f9b111d9d346/bed1cdba-2d42-49b1-b78f-6c6745fb064a/Hans_Arp_1m10s
* H.264 (mp4)
* rtmp://localhost/matterhorn-engage/mp4:mh_default_org/engage-player/9f411edb-edf5-4308-8df5-f9b111d9d346/bd4d5a48-41a8-4362-93dc-be41aaae77f8/Hans_Arp_1m10s
* </pre>
*
* @param orgId
* the id of the organization
* @param channelId
* the id of the distribution channel
* @param mpId
* the media package id
* @param mpeId
* the media package element id
* @param mpeUri
* the URI of the media package element to distribute
* @see #createDistributionFile(String, String, String, String, java.net.URI)
* @see #getDistributionFileFrom(java.net.URI)
*/
public URI createDistributionUri(final String orgId, String channelId, String mpId, String mpeId, URI mpeUri) {
// if the given media package element URI is already a distribution URI just return it
if (!isDistributionUrl(mpeUri)) {
final String ext = FilenameUtils.getExtension(mpeUri.toString());
final String fileName = FilenameUtils.getBaseName(mpeUri.toString());
String tag = ext + ":";
// removes the tag for flv files, but keeps it for all others (mp4 needs it)
if (flvCompatibilityMode && "flv:".equals(tag))
tag = "";
return URI.create(concat(getBaseUri(), tag + orgId, channelId, mpId, mpeId, fileName));
} else {
return mpeUri;
}
}
}
}