/** * 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.aws.s3; import static java.lang.String.format; import static org.opencastproject.util.RequireUtil.notNull; import org.opencastproject.distribution.api.AbstractDistributionService; import org.opencastproject.distribution.api.DistributionException; import org.opencastproject.distribution.api.DistributionService; import org.opencastproject.distribution.aws.s3.api.AwsS3DistributionService; 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.serviceregistry.api.ServiceRegistryException; import org.opencastproject.util.ConfigurationException; import org.opencastproject.util.NotFoundException; import org.opencastproject.util.OsgiUtil; import com.amazonaws.AmazonClientException; import com.amazonaws.AmazonServiceException; import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; import com.amazonaws.auth.policy.Policy; import com.amazonaws.auth.policy.Principal; import com.amazonaws.auth.policy.Statement; import com.amazonaws.auth.policy.Statement.Effect; import com.amazonaws.auth.policy.actions.S3Actions; import com.amazonaws.auth.policy.resources.S3ObjectResource; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.amazonaws.services.s3.model.DeleteVersionRequest; import com.amazonaws.services.s3.model.GetObjectMetadataRequest; import com.amazonaws.services.s3.model.ListVersionsRequest; import com.amazonaws.services.s3.model.VersionListing; import com.amazonaws.services.s3.transfer.TransferManager; import com.amazonaws.services.s3.transfer.Upload; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang3.StringUtils; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpHead; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.osgi.service.component.ComponentContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; import javax.servlet.http.HttpServletResponse; public class AwsS3DistributionServiceImpl extends AbstractDistributionService implements AwsS3DistributionService, DistributionService { /** Logging facility */ private static final Logger logger = LoggerFactory.getLogger(AwsS3DistributionServiceImpl.class); /** Job type */ public static final String JOB_TYPE = "org.opencastproject.distribution.aws.s3"; /** List of available operations on jobs */ public enum Operation { Distribute, Retract, Restore }; // Service configuration public static final String AWS_S3_DISTRIBUTION_ENABLE = "org.opencastproject.distribution.aws.s3.distribution.enable"; public static final String AWS_S3_DISTRIBUTION_BASE_CONFIG = "org.opencastproject.distribution.aws.s3.distribution.base"; public static final String AWS_S3_ACCESS_KEY_ID_CONFIG = "org.opencastproject.distribution.aws.s3.access.id"; public static final String AWS_S3_SECRET_ACCESS_KEY_CONFIG = "org.opencastproject.distribution.aws.s3.secret.key"; public static final String AWS_S3_REGION_CONFIG = "org.opencastproject.distribution.aws.s3.region"; public static final String AWS_S3_BUCKET_CONFIG = "org.opencastproject.distribution.aws.s3.bucket"; // config.properties public static final String OPENCAST_DOWNLOAD_URL = "org.opencastproject.download.url"; /** Maximum number of tries for checking availability of distributed file */ private static final int MAX_TRIES = 10; /** Interval time in millis to sleep between checks of availability */ private static final long SLEEP_INTERVAL = 30000L; /** The default AWS region name */ private static final String DEFAULT_AWS_REGION = "us-east-1"; /** The AWS client and transfer manager */ private AmazonS3 s3 = null; private TransferManager s3TransferManager = null; /** The AWS S3 bucket name */ private String bucketName = null; /** The opencast download distribution url */ private String opencastDistributionUrl = null; private Gson gson = new Gson(); /** * Creates a new instance of the AWS S3 distribution service. */ public AwsS3DistributionServiceImpl() { super(JOB_TYPE); } private String getAWSConfigKey(ComponentContext cc, String key) { try { return OsgiUtil.getComponentContextProperty(cc, key); } catch (RuntimeException e) { throw new ConfigurationException(key + " is missing or invalid", e); } } public void activate(ComponentContext cc) { // Get the configuration if (cc != null) { if (!Boolean.valueOf(getAWSConfigKey(cc, AWS_S3_DISTRIBUTION_ENABLE))) { logger.info("AWS S3 distribution disabled"); return; } // AWS S3 bucket name bucketName = getAWSConfigKey(cc, AWS_S3_BUCKET_CONFIG); logger.info("AWS S3 bucket name is {}", bucketName); // AWS region String regionStr = getAWSConfigKey(cc, AWS_S3_REGION_CONFIG); logger.info("AWS region is {}", regionStr); opencastDistributionUrl = getAWSConfigKey(cc, AWS_S3_DISTRIBUTION_BASE_CONFIG); if (!opencastDistributionUrl.endsWith("/")) { opencastDistributionUrl = opencastDistributionUrl + "/"; } logger.info("AWS distribution url is {}", opencastDistributionUrl); String accessKeyId = getAWSConfigKey(cc, AWS_S3_ACCESS_KEY_ID_CONFIG); String accessKeySecret = getAWSConfigKey(cc, AWS_S3_SECRET_ACCESS_KEY_CONFIG); // Create AWS client. // Use the default credentials provider chain, which // will look at the environment variables, java system props, credential files, and instance // profile credentials BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKeyId, accessKeySecret); s3 = AmazonS3ClientBuilder.standard() .withRegion(regionStr) .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) .build(); s3TransferManager = new TransferManager(s3); // Create AWS S3 bucket if not there yet createAWSBucket(); this.distributionChannel = OsgiUtil.getComponentContextProperty(cc, CONFIG_KEY_STORE_TYPE); logger.info("AwsS3DistributionService activated!"); } } public String getDistributionType() { return this.distributionChannel; } public void deactivate() { s3TransferManager.shutdownNow(); logger.info("AwsS3DistributionService deactivated!"); } /** * {@inheritDoc} * * @see org.opencastproject.distribution.api.DownloadDistributionService#distribute(String, * org.opencastproject.mediapackage.MediaPackage, String, boolean) */ @Override public Job distribute(String channelId, MediaPackage mediaPackage, Set<String> elementIds, boolean checkAvailability) throws DistributionException, MediaPackageException { notNull(mediaPackage, "mediapackage"); notNull(elementIds, "elementIds"); notNull(channelId, "channelId"); try { return serviceRegistry.createJob( JOB_TYPE, Operation.Distribute.toString(), Arrays.asList(channelId, MediaPackageParser.getAsXml(mediaPackage), gson.toJson(elementIds), Boolean.toString(checkAvailability))); } catch (ServiceRegistryException e) { throw new DistributionException("Unable to create a job", e); } } /** * {@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 { return distribute(channelId, mediapackage, elementId, true); } /** * {@inheritDoc} * * @see org.opencastproject.distribution.api.DownloadDistributionService#distribute(String, * org.opencastproject.mediapackage.MediaPackage, String, boolean) */ @Override public Job distribute(String channelId, MediaPackage mediaPackage, String elementId, boolean checkAvailability) throws DistributionException, MediaPackageException { Set<String> elementIds = new HashSet<String>(); elementIds.add(elementId); return distribute(channelId, mediaPackage, elementIds, checkAvailability); } /** * Distribute Mediapackage elements to the download distribution service. * * @param channelId # The id of the publication channel to be distributed to. * @param mediapackage * The media package that contains the elements to be distributed. * @param elementIds * The ids of the elements that should be distributed contained within the media package. * @param checkAvailability * Check the availability of the distributed element via http. * @return A reference to the MediaPackageElements that have 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. */ public MediaPackageElement[] distributeElements(String channelId, MediaPackage mediapackage, Set<String> elementIds, boolean checkAvailability) throws DistributionException { notNull(mediapackage, "mediapackage"); notNull(elementIds, "elementIds"); notNull(channelId, "channelId"); final Set<MediaPackageElement> elements = getElements(mediapackage, elementIds); List<MediaPackageElement> distributedElements = new ArrayList<MediaPackageElement>(); for (MediaPackageElement element : elements) { MediaPackageElement distributedElement = distributeElement(channelId, mediapackage, element, checkAvailability); distributedElements.add(distributedElement); } return distributedElements.toArray(new MediaPackageElement[distributedElements.size()]); } private Set<MediaPackageElement> getElements(MediaPackage mediapackage, Set<String> elementIds) throws IllegalStateException { final Set<MediaPackageElement> elements = new HashSet<MediaPackageElement>(); for (String elementId : elementIds) { MediaPackageElement element = mediapackage.getElementById(elementId); if (element != null) { elements.add(element); } else { throw new IllegalStateException(format("No element %s found in mediapackage %s", elementId, mediapackage.getIdentifier())); } } return elements; } /** * Distribute a media package element to AWS S3. * * @param mediaPackage * The media package that contains the element to distribute. * @param element * The element that should be distributed contained within the media package. * @param checkAvailability * Checks if the distributed element is available * @return A reference to the MediaPackageElement that has been distributed. * @throws DistributionException */ public MediaPackageElement distributeElement(String channelId, final MediaPackage mediaPackage, MediaPackageElement element, boolean checkAvailability) throws DistributionException { notNull(channelId, "channelId"); notNull(mediaPackage, "mediapackage"); notNull(element, "element"); 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); } // Use TransferManager to take advantage of multipart upload. // TransferManager processes all transfers asynchronously, so this call will return immediately. String objectName = buildObjectName(channelId, mediaPackage.getIdentifier().toString(), element); logger.info("Uploading {} to bucket {}...", objectName, bucketName); Upload upload = s3TransferManager.upload(bucketName, objectName, source); long start = System.currentTimeMillis(); try { // Block and wait for the upload to finish upload.waitForCompletion(); logger.info("Upload of {} to bucket {} completed in {} seconds", new Object[] { objectName, bucketName, (System.currentTimeMillis() - start) / 1000 }); } catch (AmazonClientException e) { throw new DistributionException("AWS error: " + e.getMessage(), e); } // Create a representation of the distributed file in the media package MediaPackageElement distributedElement = (MediaPackageElement) element.clone(); try { distributedElement.setURI(getDistributionUri(objectName)); } catch (URISyntaxException e) { throw new DistributionException("Distributed element produces an invalid URI", e); } logger.info("Distributed element {}, object {}", element.getIdentifier(), objectName); if (checkAvailability) { URI uri = distributedElement.getURI(); int tries = 0; CloseableHttpResponse response = null; boolean success = false; while (tries < MAX_TRIES) { try { CloseableHttpClient httpClient = HttpClients.createDefault(); logger.trace("Trying to access {}", uri); response = httpClient.execute(new HttpHead(uri)); if (response.getStatusLine().getStatusCode() == HttpServletResponse.SC_OK) { logger.trace("Successfully got {}", uri); success = true; break; // Exit the loop, response is closed } else { logger.debug("Http status code when checking distributed element {} is {}", objectName, response .getStatusLine().getStatusCode()); } } catch (Exception e) { logger.info("Checking availability of {} threw exception {}. Trying again.", objectName, e.getMessage()); // Just try again } finally { if (null != response) { response.close(); } } tries++; logger.trace("Sleeping for {} seconds...", SLEEP_INTERVAL / 1000); Thread.sleep(SLEEP_INTERVAL); } if (!success) { logger.warn("Could not check availability of distributed file {}", uri); // throw new DistributionException("Unable to load distributed file " + uri.toString()); } } return distributedElement; } catch (Exception e) { logger.warn("Error distributing element " + element.getIdentifier() + " of media package " + mediaPackage, e); if (e instanceof DistributionException) { throw (DistributionException) e; } else { throw new DistributionException(e); } } } @Override public Job retract(String channelId, MediaPackage mediapackage, String elementId) throws DistributionException { Set<String> elementIds = new HashSet<String>(); elementIds.add(elementId); return retract(channelId, mediapackage, elementIds); } @Override public Job retract(String channelId, MediaPackage mediapackage, Set<String> elementIds) throws DistributionException { notNull(mediapackage, "mediapackage"); notNull(elementIds, "elementIds"); notNull(channelId, "channelId"); try { return serviceRegistry.createJob(JOB_TYPE, Operation.Retract.toString(), Arrays.asList(channelId, MediaPackageParser.getAsXml(mediapackage), gson.toJson(elementIds))); } catch (ServiceRegistryException e) { throw new DistributionException("Unable to create a job", e); } } /** * Retracts the media package element with the given identifier from the distribution channel. * * @param channelId * the channel id * @param mediaPackage * the media package * @param element * the element * @return the retracted element or <code>null</code> if the element was not retracted */ protected MediaPackageElement retractElement(String channelId, MediaPackage mediaPackage, MediaPackageElement element) throws DistributionException { notNull(mediaPackage, "mediaPackage"); notNull(element, "element"); try { String objectName = getDistributedObjectName(element); if (objectName != null) { s3.deleteObject(bucketName, objectName); logger.info("Retracted element {}, object {}", element.getIdentifier(), objectName); } return element; } catch (AmazonClientException e) { throw new DistributionException("AWS error: " + e.getMessage(), e); } } /** * Retract a media package element from the distribution channel. The retracted element must not necessarily be the * one given as parameter <code>elementId</code>. Instead, the element's distribution URI will be calculated. This way * you are able to retract elements by providing the "original" element here. * * @param channelId * the channel id * @param mediapackage * the mediapackage * @param elementIds * the element identifiers * @return the retracted element or <code>null</code> if the element was not retracted * @throws org.opencastproject.distribution.api.DistributionException * in case of an error */ protected MediaPackageElement[] retractElements(String channelId, MediaPackage mediapackage, Set<String> elementIds) throws DistributionException { notNull(mediapackage, "mediapackage"); notNull(elementIds, "elementIds"); notNull(channelId, "channelId"); Set<MediaPackageElement> elements = getElements(mediapackage, elementIds); List<MediaPackageElement> retractedElements = new ArrayList<MediaPackageElement>(); for (MediaPackageElement element : elements) { MediaPackageElement retractedElement = retractElement(channelId, mediapackage, element); retractedElements.add(retractedElement); } return retractedElements.toArray(new MediaPackageElement[retractedElements.size()]); } public Job restore(String channelId, MediaPackage mediaPackage, String elementId) throws DistributionException { if (mediaPackage == null) throw new IllegalArgumentException("Media package must be specified"); if (elementId == null) throw new IllegalArgumentException("Element ID must be specified"); if (channelId == null) throw new IllegalArgumentException("Channel ID must be specified"); try { return serviceRegistry.createJob(JOB_TYPE, Operation.Restore.toString(), Arrays.asList(channelId, MediaPackageParser.getAsXml(mediaPackage), elementId)); } catch (ServiceRegistryException e) { throw new DistributionException("Unable to create a job", e); } } public Job restore(String channelId, MediaPackage mediaPackage, String elementId, String fileName) throws DistributionException { if (mediaPackage == null) throw new IllegalArgumentException("Media package must be specified"); if (elementId == null) throw new IllegalArgumentException("Element ID must be specified"); if (channelId == null) throw new IllegalArgumentException("Channel ID must be specified"); if (fileName == null) throw new IllegalArgumentException("Filename must be specified"); try { return serviceRegistry.createJob(JOB_TYPE, Operation.Restore.toString(), Arrays.asList(channelId, MediaPackageParser.getAsXml(mediaPackage), elementId, fileName)); } catch (ServiceRegistryException e) { throw new DistributionException("Unable to create a job", e); } } protected MediaPackageElement restoreElement(String channelId, MediaPackage mediaPackage, String elementId, String fileName) throws DistributionException { String objectName = null; if (StringUtils.isNotBlank(fileName)) { objectName = buildObjectName(channelId, mediaPackage.getIdentifier().toString(), elementId, fileName); } else { objectName = buildObjectName(channelId, mediaPackage.getIdentifier().toString(), mediaPackage.getElementById(elementId)); } //Get the latest version of the file //Note that this should be the delete marker for the file. We'll check, but if there is more than one delete marker we'll have probs ListVersionsRequest lv = new ListVersionsRequest().withBucketName(bucketName).withPrefix(objectName).withMaxResults(1); VersionListing listing = s3.listVersions(lv); if (listing.getVersionSummaries().size() < 1) { throw new DistributionException("Object not found: " + objectName); } String versionId = listing.getVersionSummaries().get(0).getVersionId(); //Verify that this is in fact a delete marker GetObjectMetadataRequest metadata = new GetObjectMetadataRequest(bucketName, objectName, versionId); //Ok, so there's no way of asking AWS directly if the object is deleted in this version of the SDK //So instead, we ask for its metadata //If it's deleted, then there *isn't* any metadata and we get a 404, which throws the exception //This, imo, is an incredibly boneheaded omission from the AWS SDK, and implies we should look for something which sucks less //FIXME: This section should be refactored with a simple s3.doesObjectExist(bucketName, objectName) once we update the AWS SDK boolean isDeleted = false; try { s3.getObjectMetadata(metadata); } catch (AmazonServiceException e) { //Note: This exception is actually a 405, not a 404. //This is expected, but very confusing if you're thinking it should be a 'file not found', rather than a 'method not allowed on stuff that's deleted' //It's unclear what the expected behaviour is for things which have never existed... isDeleted = true; } if (isDeleted) { //Delete the delete marker DeleteVersionRequest delete = new DeleteVersionRequest(bucketName, objectName, versionId); s3.deleteVersion(delete); } return mediaPackage.getElementById(elementId); } /** * Builds the aws s3 object name. * * @param channelId * @param mpId * @param element * @return */ protected String buildObjectName(String channelId, String mpId, MediaPackageElement element) { // Something like CHANNEL_ID/MP_ID/ELEMENT_ID/FILE_NAME.EXTENSION String uriString = element.getURI().toString(); String fileName = FilenameUtils.getName(uriString); return buildObjectName(channelId, mpId, element.getIdentifier(), fileName); } /** * Builds the aws s3 object name using the raw elementID and filename * * @param channelId * @param mpId * @param elementId * @param fileName * @return */ protected String buildObjectName(String channelId, String mpId, String elementId, String fileName) { return StringUtils.join(new String[] { channelId, mpId, elementId, fileName }, "/"); } /** * Gets the URI for the element to be distributed. * * @return The resulting URI after distribution * @throws URISyntaxException * if the concrete implementation tries to create a malformed uri */ protected URI getDistributionUri(String objectName) throws URISyntaxException { // Something like https://OPENCAST_DOWNLOAD_URL/CHANNEL_ID/MP_ID/ELEMENT_ID/FILE_NAME.EXTENSION return new URI(opencastDistributionUrl + objectName); } /** * Gets the distributed object's name. * * @return The distributed object name */ protected String getDistributedObjectName(MediaPackageElement element) { // Something like https://OPENCAST_DOWNLOAD_URL/CHANNEL_ID/MP_ID/ORIGINAL_ELEMENT_ID/FILE_NAME.EXTENSION String uriString = element.getURI().toString(); // String directoryName = distributionDirectory.getAbsolutePath(); if (uriString.startsWith(opencastDistributionUrl) && uriString.length() > opencastDistributionUrl.length()) { return uriString.substring(opencastDistributionUrl.length()); } else { // Cannot retract logger.warn( "Cannot retract {}. Uri must be in the format https://host/bucketName/channelId/mpId/originalElementId/fileName.extension", uriString); return null; } } /** * {@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)); Set<String> elementIds = gson.fromJson(arguments.get(2), new TypeToken<Set<String>>() { }.getType()); switch (op) { case Distribute: Boolean checkAvailability = Boolean.parseBoolean(arguments.get(3)); MediaPackageElement[] distributedElements = distributeElements(channelId, mediaPackage, elementIds, checkAvailability); return (distributedElements != null) ? MediaPackageElementParser.getArrayAsXml(Arrays.asList(distributedElements)) : null; case Retract: MediaPackageElement[] retractedElements = retractElements(channelId, mediaPackage, elementIds); return (retractedElements != null) ? MediaPackageElementParser.getArrayAsXml(Arrays.asList(retractedElements)) : null; /* Commented out due to changes in the way the element IDs are passed (ie, a list rather than individual ones per job). This code is still useful long term, but I don't have time to write the necessary wrapper code around it right now. case Restore: String fileName = arguments.get(3); MediaPackageElement restoredElement = null; if (StringUtils.isNotBlank(fileName)) { restoredElement = restoreElement(channelId, mediaPackage, elementIds, fileName); } else { restoredElement = restoreElement(channelId, mediaPackage, elementIds, null); } return (restoredElement != null) ? MediaPackageElementParser.getAsXml(restoredElement) : 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); } } /** * Creates the AWS S3 bucket if it doesn't exist yet. */ protected void createAWSBucket() { // Does bucket exist? try { s3.listObjects(bucketName); } catch (AmazonServiceException e) { if (e.getStatusCode() == 404) { // Create the bucket try { s3.createBucket(bucketName); // Allow public read Statement allowPublicReadStatement = new Statement(Effect.Allow).withPrincipals(Principal.AllUsers) .withActions(S3Actions.GetObject).withResources(new S3ObjectResource(bucketName, "*")); Policy policy = new Policy().withStatements(allowPublicReadStatement); s3.setBucketPolicy(bucketName, policy.toJson()); logger.info("AWS S3 bucket {} created", bucketName); } catch (Exception e2) { throw new ConfigurationException("Bucket " + bucketName + " cannot be created: " + e2.getMessage(), e2); } } else { throw new ConfigurationException("Bucket " + bucketName + " exists, but we can't access it: " + e.getMessage(), e); } } } /** The methods below are used by the test class */ protected void setS3(AmazonS3 s3) { this.s3 = s3; } protected void setS3TransferManager(TransferManager s3TransferManager) { this.s3TransferManager = s3TransferManager; } protected void setBucketName(String bucketName) { this.bucketName = bucketName; } protected void setOpencastDistributionUrl(String distributionUrl) { this.opencastDistributionUrl = distributionUrl; } }