View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.ingest.impl;
23  
24  import static org.apache.commons.lang3.StringUtils.isBlank;
25  import static org.opencastproject.metadata.dublincore.DublinCore.PROPERTY_IDENTIFIER;
26  import static org.opencastproject.metadata.dublincore.DublinCore.PROPERTY_TITLE;
27  import static org.opencastproject.security.api.SecurityConstants.GLOBAL_ADMIN_ROLE;
28  import static org.opencastproject.security.api.SecurityConstants.GLOBAL_CAPTURE_AGENT_ROLE;
29  import static org.opencastproject.util.JobUtil.waitForJob;
30  
31  import org.opencastproject.authorization.xacml.XACMLParsingException;
32  import org.opencastproject.authorization.xacml.XACMLUtils;
33  import org.opencastproject.capture.CaptureParameters;
34  import org.opencastproject.ingest.api.IngestException;
35  import org.opencastproject.ingest.api.IngestService;
36  import org.opencastproject.inspection.api.MediaInspectionService;
37  import org.opencastproject.job.api.AbstractJobProducer;
38  import org.opencastproject.job.api.Job;
39  import org.opencastproject.job.api.Job.Status;
40  import org.opencastproject.mediapackage.Attachment;
41  import org.opencastproject.mediapackage.Catalog;
42  import org.opencastproject.mediapackage.EName;
43  import org.opencastproject.mediapackage.MediaPackage;
44  import org.opencastproject.mediapackage.MediaPackageBuilderFactory;
45  import org.opencastproject.mediapackage.MediaPackageElement;
46  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
47  import org.opencastproject.mediapackage.MediaPackageElementParser;
48  import org.opencastproject.mediapackage.MediaPackageElements;
49  import org.opencastproject.mediapackage.MediaPackageException;
50  import org.opencastproject.mediapackage.MediaPackageParser;
51  import org.opencastproject.mediapackage.MediaPackageSupport;
52  import org.opencastproject.mediapackage.Track;
53  import org.opencastproject.mediapackage.identifier.IdImpl;
54  import org.opencastproject.metadata.dublincore.DCMIPeriod;
55  import org.opencastproject.metadata.dublincore.DublinCore;
56  import org.opencastproject.metadata.dublincore.DublinCoreCatalog;
57  import org.opencastproject.metadata.dublincore.DublinCoreCatalogService;
58  import org.opencastproject.metadata.dublincore.DublinCoreValue;
59  import org.opencastproject.metadata.dublincore.DublinCores;
60  import org.opencastproject.metadata.dublincore.EncodingSchemeUtils;
61  import org.opencastproject.metadata.dublincore.Precision;
62  import org.opencastproject.scheduler.api.SchedulerException;
63  import org.opencastproject.scheduler.api.SchedulerService;
64  import org.opencastproject.security.api.AccessControlEntry;
65  import org.opencastproject.security.api.AccessControlList;
66  import org.opencastproject.security.api.OrganizationDirectoryService;
67  import org.opencastproject.security.api.Permissions;
68  import org.opencastproject.security.api.SecurityService;
69  import org.opencastproject.security.api.TrustedHttpClient;
70  import org.opencastproject.security.api.UnauthorizedException;
71  import org.opencastproject.security.api.User;
72  import org.opencastproject.security.api.UserDirectoryService;
73  import org.opencastproject.security.util.SecurityUtil;
74  import org.opencastproject.series.api.SeriesException;
75  import org.opencastproject.series.api.SeriesService;
76  import org.opencastproject.serviceregistry.api.ServiceRegistry;
77  import org.opencastproject.serviceregistry.api.ServiceRegistryException;
78  import org.opencastproject.smil.api.util.SmilUtil;
79  import org.opencastproject.userdirectory.UserIdRoleProvider;
80  import org.opencastproject.util.ConfigurationException;
81  import org.opencastproject.util.IoSupport;
82  import org.opencastproject.util.LoadUtil;
83  import org.opencastproject.util.MimeTypes;
84  import org.opencastproject.util.NotFoundException;
85  import org.opencastproject.util.ProgressInputStream;
86  import org.opencastproject.util.XmlSafeParser;
87  import org.opencastproject.util.XmlUtil;
88  import org.opencastproject.util.data.functions.Misc;
89  import org.opencastproject.workflow.api.WorkflowDatabaseException;
90  import org.opencastproject.workflow.api.WorkflowDefinition;
91  import org.opencastproject.workflow.api.WorkflowException;
92  import org.opencastproject.workflow.api.WorkflowInstance;
93  import org.opencastproject.workflow.api.WorkflowService;
94  import org.opencastproject.workingfilerepository.api.WorkingFileRepository;
95  
96  import com.google.common.cache.Cache;
97  import com.google.common.cache.CacheBuilder;
98  
99  import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
100 import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
101 import org.apache.commons.io.FilenameUtils;
102 import org.apache.commons.io.IOUtils;
103 import org.apache.commons.lang3.BooleanUtils;
104 import org.apache.commons.lang3.StringUtils;
105 import org.apache.cxf.jaxrs.ext.multipart.ContentDisposition;
106 import org.apache.http.Header;
107 import org.apache.http.HttpHeaders;
108 import org.apache.http.HttpResponse;
109 import org.apache.http.auth.AuthScope;
110 import org.apache.http.auth.UsernamePasswordCredentials;
111 import org.apache.http.client.CredentialsProvider;
112 import org.apache.http.client.config.AuthSchemes;
113 import org.apache.http.client.methods.HttpGet;
114 import org.apache.http.impl.client.BasicCredentialsProvider;
115 import org.apache.http.impl.client.CloseableHttpClient;
116 import org.apache.http.impl.client.HttpClientBuilder;
117 import org.osgi.service.cm.ManagedService;
118 import org.osgi.service.component.ComponentContext;
119 import org.osgi.service.component.annotations.Activate;
120 import org.osgi.service.component.annotations.Component;
121 import org.osgi.service.component.annotations.Deactivate;
122 import org.osgi.service.component.annotations.Reference;
123 import org.osgi.service.component.annotations.ReferenceCardinality;
124 import org.osgi.service.component.annotations.ReferencePolicy;
125 import org.slf4j.Logger;
126 import org.slf4j.LoggerFactory;
127 import org.xml.sax.SAXException;
128 
129 import java.io.BufferedReader;
130 import java.io.IOException;
131 import java.io.InputStream;
132 import java.io.InputStreamReader;
133 import java.net.URI;
134 import java.nio.charset.StandardCharsets;
135 import java.util.ArrayList;
136 import java.util.Arrays;
137 import java.util.Base64;
138 import java.util.Date;
139 import java.util.Dictionary;
140 import java.util.HashMap;
141 import java.util.HashSet;
142 import java.util.List;
143 import java.util.Map;
144 import java.util.Map.Entry;
145 import java.util.Objects;
146 import java.util.Optional;
147 import java.util.Set;
148 import java.util.UUID;
149 import java.util.concurrent.TimeUnit;
150 import java.util.stream.Collectors;
151 
152 /**
153  * Creates and augments Opencast MediaPackages. Stores media into the Working File Repository.
154  */
155 @Component(
156     immediate = true,
157     service = {
158         IngestService.class,
159         ManagedService.class
160     },
161     property = {
162         "service.description=Ingest Service",
163         "service.pid=org.opencastproject.ingest.impl.IngestServiceImpl"
164     }
165 )
166 public class IngestServiceImpl extends AbstractJobProducer implements IngestService, ManagedService {
167 
168   /** The logger */
169   private static final Logger logger = LoggerFactory.getLogger(IngestServiceImpl.class);
170 
171   /** The source SMIL name */
172   private static final String PARTIAL_SMIL_NAME = "source_partial.smil";
173 
174   /** The configuration key that defines the default workflow definition */
175   protected static final String WORKFLOW_DEFINITION_DEFAULT = "org.opencastproject.workflow.default.definition";
176 
177   /** The workflow configuration property prefix **/
178   protected static final String WORKFLOW_CONFIGURATION_PREFIX = "org.opencastproject.workflow.config.";
179 
180   /** The key for the legacy mediapackage identifier */
181   public static final String LEGACY_MEDIAPACKAGE_ID_KEY = "org.opencastproject.ingest.legacy.mediapackage.id";
182 
183   public static final String JOB_TYPE = "org.opencastproject.ingest";
184 
185   /** Methods that ingest zips create jobs with this operation type */
186   public static final String INGEST_ZIP = "zip";
187 
188   /** Methods that ingest tracks directly create jobs with this operation type */
189   public static final String INGEST_TRACK = "track";
190 
191   /** Methods that ingest tracks from a URI create jobs with this operation type */
192   public static final String INGEST_TRACK_FROM_URI = "uri-track";
193 
194   /** Methods that ingest attachments directly create jobs with this operation type */
195   public static final String INGEST_ATTACHMENT = "attachment";
196 
197   /** Methods that ingest attachments from a URI create jobs with this operation type */
198   public static final String INGEST_ATTACHMENT_FROM_URI = "uri-attachment";
199 
200   /** Methods that ingest catalogs directly create jobs with this operation type */
201   public static final String INGEST_CATALOG = "catalog";
202 
203   /** Methods that ingest catalogs from a URI create jobs with this operation type */
204   public static final String INGEST_CATALOG_FROM_URI = "uri-catalog";
205 
206   /** The approximate load placed on the system by ingesting a file */
207   public static final float DEFAULT_INGEST_FILE_JOB_LOAD = 0.2f;
208 
209   /** The approximate load placed on the system by ingesting a zip file */
210   public static final float DEFAULT_INGEST_ZIP_JOB_LOAD = 0.2f;
211 
212   /** The key to look for in the service configuration file to override the {@link DEFAULT_INGEST_FILE_JOB_LOAD} */
213   public static final String FILE_JOB_LOAD_KEY = "job.load.ingest.file";
214 
215   /** The key to look for in the service configuration file to override the {@link DEFAULT_INGEST_ZIP_JOB_LOAD} */
216   public static final String ZIP_JOB_LOAD_KEY = "job.load.ingest.zip";
217 
218   /** The source to download from  */
219   public static final String DOWNLOAD_SOURCE = "org.opencastproject.download.source";
220 
221   /** The user for download from external sources */
222   public static final String DOWNLOAD_USER = "org.opencastproject.download.user";
223 
224   /** The password for download from external sources */
225   public static final String DOWNLOAD_PASSWORD = "org.opencastproject.download.password";
226 
227   /** The authentication method for download from external sources */
228   public static final String DOWNLOAD_AUTH_METHOD = "org.opencastproject.download.auth.method";
229 
230   /** Force basic authentication even if download host does not ask for it */
231   public static final String DOWNLOAD_AUTH_FORCE_BASIC = "org.opencastproject.download.auth.force_basic";
232 
233   /** By default, do not allow event ingest to modify existing series metadata */
234   public static final boolean DEFAULT_ALLOW_SERIES_MODIFICATIONS = false;
235 
236   /** The default is to preserve existing Opencast flavors during ingest. */
237   public static final boolean DEFAULT_ALLOW_ONLY_NEW_FLAVORS = true;
238 
239   /** The default is not to automatically skip attachments and catalogs from capture agent */
240   public static final boolean DEFAULT_SKIP = false;
241 
242   /** The default for force basic authentication even if download host does not ask for it */
243   public static final boolean DEFAULT_DOWNLOAD_AUTH_FORCE_BASIC = false;
244 
245   /** The maximum length of filenames ingested by Opencast */
246   public static final int FILENAME_LENGTH_MAX = 75;
247 
248   /** Managed Property key to allow Opencast series modification during ingest
249    * Deprecated, the param potentially causes an update chain reaction for all
250    * events associated to that series, for each ingest */
251   @Deprecated
252   public static final String MODIFY_OPENCAST_SERIES_KEY = "org.opencastproject.series.overwrite";
253 
254   /** Managed Property key to allow new flavors of ingested attachments and catalogs
255    * to be added to the existing Opencast mediapackage. But, not catalogs and attachments
256    * that would overwrite existing ones in Opencast.
257    */
258   public static final String ADD_ONLY_NEW_FLAVORS_KEY = "add.only.new.catalogs.attachments.for.existing.events";
259 
260   /** Control if catalogs sent by capture agents for scheduled events are skipped. */
261   public static final String SKIP_CATALOGS_KEY = "skip.catalogs.for.existing.events";
262 
263   /** Control if attachments sent by capture agents for scheduled events are skipped. */
264   public static final String SKIP_ATTACHMENTS_KEY = "skip.attachments.for.existing.events";
265 
266   /** The appendix added to autogenerated CA series. CA series creation deactivated if not configured */
267   private static final String SERIES_APPENDIX = "add.series.to.event.appendix";
268 
269   /** The approximate load placed on the system by ingesting a file */
270   private float ingestFileJobLoad = DEFAULT_INGEST_FILE_JOB_LOAD;
271 
272   /** The approximate load placed on the system by ingesting a zip file */
273   private float ingestZipJobLoad = DEFAULT_INGEST_ZIP_JOB_LOAD;
274 
275   /** The user for download from external sources */
276   private static String downloadUser = DOWNLOAD_USER;
277 
278   /** The password for download from external sources */
279   private static String downloadPassword = DOWNLOAD_PASSWORD;
280 
281   /** The authentication method for download from external sources */
282   private static String downloadAuthMethod = DOWNLOAD_AUTH_METHOD;
283 
284   /** Force basic authentication even if download host does not ask for it */
285   private static boolean downloadAuthForceBasic = DEFAULT_DOWNLOAD_AUTH_FORCE_BASIC;
286 
287   /** The external source dns name */
288   private static String downloadSource = DOWNLOAD_SOURCE;
289 
290   /** The workflow service */
291   private WorkflowService workflowService;
292 
293   /** The working file repository */
294   private WorkingFileRepository workingFileRepository;
295 
296   /** The http client */
297   private TrustedHttpClient httpClient;
298 
299   /** The series service */
300   private SeriesService seriesService;
301 
302   /** The dublin core service */
303   private DublinCoreCatalogService dublinCoreService;
304 
305   /** The opencast service registry */
306   private ServiceRegistry serviceRegistry;
307 
308   /** The security service */
309   protected SecurityService securityService = null;
310 
311   /** The user directory service */
312   protected UserDirectoryService userDirectoryService = null;
313 
314   /** The organization directory service */
315   protected OrganizationDirectoryService organizationDirectoryService = null;
316 
317   /** The scheduler service */
318   private SchedulerService schedulerService = null;
319 
320   /** The media inspection service */
321   private MediaInspectionService mediaInspectionService = null;
322 
323   /** The search index. */
324 
325   /** The default workflow identifier, if one is configured */
326   protected String defaultWorkflowDefinionId;
327 
328   /** The partial track start time map */
329   private Cache<String, Long> partialTrackStartTimes = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.DAYS)
330           .build();
331 
332   /** Option to overwrite matching flavors (e.g. series and episode metadata) on ingest,
333    *  tracks are always taken on ingest */
334   protected boolean isAddOnlyNew = DEFAULT_ALLOW_ONLY_NEW_FLAVORS;
335   protected boolean isAllowModifySeries = DEFAULT_ALLOW_SERIES_MODIFICATIONS;
336 
337   private boolean skipCatalogs = DEFAULT_SKIP;
338   private boolean skipAttachments = DEFAULT_SKIP;
339 
340   /** Create name appendix for autogenerated CA series */
341   private String createSeriesAppendix = null;
342 
343   protected boolean testMode = false;
344 
345   /**
346    * Creates a new ingest service instance.
347    */
348   public IngestServiceImpl() {
349     super(JOB_TYPE);
350   }
351 
352   /**
353    * OSGI callback for activating this component
354    *
355    * @param cc
356    *          the osgi component context
357    */
358   @Override
359   @Activate
360   public void activate(ComponentContext cc) {
361     super.activate(cc);
362     logger.info("Ingest Service started.");
363     defaultWorkflowDefinionId = StringUtils.trimToNull(cc.getBundleContext().getProperty(WORKFLOW_DEFINITION_DEFAULT));
364     if (defaultWorkflowDefinionId == null) {
365       defaultWorkflowDefinionId = "schedule-and-upload";
366     }
367   }
368 
369   /**
370    * Callback from OSGi on service deactivation.
371    */
372   @Deactivate
373   public void deactivate() {
374 
375   }
376 
377   /**
378    * {@inheritDoc}
379    *
380    * @see org.osgi.service.cm.ManagedService#updated(java.util.Dictionary)
381    *      Retrieve ManagedService configuration, including option to overwrite series
382    */
383   @Override
384   public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
385 
386     if (properties == null) {
387       logger.info("No configuration available, using defaults");
388       return;
389     }
390 
391     downloadAuthMethod = StringUtils.trimToEmpty((String)properties.get(DOWNLOAD_AUTH_METHOD));
392     if (!"Digest".equals(downloadAuthMethod) && !"Basic".equals(downloadAuthMethod)) {
393       logger.warn("Download authentication method is neither Digest nor Basic; setting to Digest");
394       downloadAuthMethod = "Digest";
395     }
396     downloadAuthForceBasic = BooleanUtils.toBoolean(Objects.toString(properties.get(DOWNLOAD_AUTH_FORCE_BASIC),
397         BooleanUtils.toStringTrueFalse(DEFAULT_DOWNLOAD_AUTH_FORCE_BASIC)));
398     downloadPassword = StringUtils.trimToEmpty((String)properties.get(DOWNLOAD_PASSWORD));
399     downloadUser = StringUtils.trimToEmpty(((String) properties.get(DOWNLOAD_USER)));
400     downloadSource = StringUtils.trimToEmpty(((String) properties.get(DOWNLOAD_SOURCE)));
401     if (!isBlank(downloadSource) && (isBlank(downloadUser) || isBlank(downloadPassword))) {
402       logger.warn("Configured ingest download source has no configured user or password; deactivating authenticated"
403           + "download");
404       downloadSource = "";
405     }
406 
407     skipAttachments = BooleanUtils.toBoolean(Objects.toString(properties.get(SKIP_ATTACHMENTS_KEY),
408             BooleanUtils.toStringTrueFalse(DEFAULT_SKIP)));
409     skipCatalogs = BooleanUtils.toBoolean(Objects.toString(properties.get(SKIP_CATALOGS_KEY),
410             BooleanUtils.toStringTrueFalse(DEFAULT_SKIP)));
411     logger.debug("Skip attachments sent by agents for scheduled events: {}", skipAttachments);
412     logger.debug("Skip metadata catalogs sent by agents for scheduled events: {}", skipCatalogs);
413 
414     ingestFileJobLoad = LoadUtil.getConfiguredLoadValue(properties, FILE_JOB_LOAD_KEY, DEFAULT_INGEST_FILE_JOB_LOAD,
415             serviceRegistry);
416     ingestZipJobLoad = LoadUtil.getConfiguredLoadValue(properties, ZIP_JOB_LOAD_KEY, DEFAULT_INGEST_ZIP_JOB_LOAD,
417             serviceRegistry);
418 
419     isAllowModifySeries = BooleanUtils.toBoolean(Objects.toString(properties.get(MODIFY_OPENCAST_SERIES_KEY),
420               BooleanUtils.toStringTrueFalse(DEFAULT_ALLOW_SERIES_MODIFICATIONS)));
421     isAddOnlyNew = BooleanUtils.toBoolean(Objects.toString(properties.get(ADD_ONLY_NEW_FLAVORS_KEY),
422             BooleanUtils.toStringTrueFalse(DEFAULT_ALLOW_ONLY_NEW_FLAVORS)));
423     logger.info("Only allow new flavored catalogs and attachments on ingest:'{}'", isAddOnlyNew);
424     logger.info("Allowing series modification:'{}'", isAllowModifySeries);
425     createSeriesAppendix = StringUtils.trimToNull(((String) properties.get(SERIES_APPENDIX)));
426   }
427 
428   /**
429    * Sets the trusted http client
430    *
431    * @param httpClient
432    *          the http client
433    */
434   @Reference
435   public void setHttpClient(TrustedHttpClient httpClient) {
436     this.httpClient = httpClient;
437   }
438 
439   /**
440    * Sets the service registry
441    *
442    * @param serviceRegistry
443    *          the serviceRegistry to set
444    */
445   @Reference
446   public void setServiceRegistry(ServiceRegistry serviceRegistry) {
447     this.serviceRegistry = serviceRegistry;
448   }
449 
450   /**
451    * Sets the media inspection service
452    *
453    * @param mediaInspectionService
454    *          the media inspection service to set
455    */
456   @Reference
457   public void setMediaInspectionService(MediaInspectionService mediaInspectionService) {
458     this.mediaInspectionService = mediaInspectionService;
459   }
460 
461   public WorkflowInstance addZippedMediaPackage(InputStream zipStream)
462           throws IngestException, IOException, MediaPackageException {
463     try {
464       return addZippedMediaPackage(zipStream, null, null);
465     } catch (NotFoundException e) {
466       throw new IllegalStateException("A not found exception was thrown without a lookup");
467     }
468   }
469 
470   @Override
471   public WorkflowInstance addZippedMediaPackage(InputStream zipStream, String wd, Map<String, String> workflowConfig)
472           throws MediaPackageException, IOException, IngestException, NotFoundException {
473     try {
474       return addZippedMediaPackage(zipStream, wd, workflowConfig, null);
475     } catch (UnauthorizedException e) {
476       throw new IllegalStateException(e);
477     }
478   }
479 
480   /**
481    * {@inheritDoc}
482    *
483    * @see org.opencastproject.ingest.api.IngestService#addZippedMediaPackage(java.io.InputStream, java.lang.String,
484    *      java.util.Map, java.lang.Long)
485    */
486   @Override
487   public WorkflowInstance addZippedMediaPackage(InputStream zipStream, String workflowDefinitionId,
488           Map<String, String> workflowConfig, Long workflowInstanceId)
489           throws MediaPackageException, IOException, IngestException, NotFoundException, UnauthorizedException {
490     // Start a job synchronously. We can't keep the open input stream waiting around.
491     Job job = null;
492 
493     if (StringUtils.isNotBlank(workflowDefinitionId)) {
494       try {
495         workflowService.getWorkflowDefinitionById(workflowDefinitionId);
496       } catch (WorkflowDatabaseException e) {
497         throw new IngestException(e);
498       } catch (NotFoundException nfe) {
499         logger.warn("Workflow definition {} not found, using default workflow {} instead", workflowDefinitionId,
500                 defaultWorkflowDefinionId);
501         workflowDefinitionId = defaultWorkflowDefinionId;
502       }
503     }
504 
505     if (workflowInstanceId != null) {
506       logger.warn("Deprecated method! Ingesting zipped mediapackage with workflow {}", workflowInstanceId);
507     } else {
508       logger.info("Ingesting zipped mediapackage");
509     }
510 
511     ZipArchiveInputStream zis = null;
512     Set<String> collectionFilenames = new HashSet<>();
513     try {
514       // We don't need anybody to do the dispatching for us. Therefore we need to make sure that the job is never in
515       // QUEUED state but set it to INSTANTIATED in the beginning and then manually switch it to RUNNING.
516       job = serviceRegistry.createJob(JOB_TYPE, INGEST_ZIP, null, null, false, ingestZipJobLoad);
517       job.setStatus(Status.RUNNING);
518       job = serviceRegistry.updateJob(job);
519 
520       // Create the working file target collection for this ingest operation
521       String wfrCollectionId = Long.toString(job.getId());
522 
523       zis = new ZipArchiveInputStream(zipStream);
524       ZipArchiveEntry entry;
525       MediaPackage mp = null;
526       Map<String, URI> uris = new HashMap<>();
527       // Sequential number to append to file names so that, if two files have the same
528       // name, one does not overwrite the other (see MH-9688)
529       int seq = 1;
530       // Folder name to compare with next one to figure out if there's a root folder
531       String folderName = null;
532       // Indicates if zip has a root folder or not, initialized as true
533       boolean hasRootFolder = true;
534       // While there are entries write them to a collection
535       while ((entry = zis.getNextZipEntry()) != null) {
536         try {
537           if (entry.isDirectory() || entry.getName().contains("__MACOSX")) {
538             continue;
539           }
540 
541           if (entry.getName().endsWith("manifest.xml") || entry.getName().endsWith("index.xml")) {
542             // Build the media package
543             final InputStream is = new ZipEntryInputStream(zis, entry.getSize());
544             mp = MediaPackageParser.getFromXml(IOUtils.toString(is, StandardCharsets.UTF_8));
545           } else {
546             logger.info("Storing zip entry {}/{} in working file repository collection '{}'", job.getId(),
547                     entry.getName(), wfrCollectionId);
548             // Since the directory structure is not being mirrored, makes sure the file
549             // name is different than the previous one(s) by adding a sequential number
550             String fileName = FilenameUtils.getBaseName(entry.getName()) + "_" + seq++ + "."
551                     + FilenameUtils.getExtension(entry.getName());
552             URI contentUri = workingFileRepository.putInCollection(wfrCollectionId, fileName,
553                     new ZipEntryInputStream(zis, entry.getSize()));
554             collectionFilenames.add(fileName);
555             // Key is the zip entry name as it is
556             String key = entry.getName();
557             uris.put(key, contentUri);
558             logger.info("Zip entry {}/{} stored at {}", job.getId(), entry.getName(), contentUri);
559             // Figures out if there's a root folder. Does entry name starts with a folder?
560             int pos = entry.getName().indexOf('/');
561             if (pos == -1) {
562               // No, we can conclude there's no root folder
563               hasRootFolder = false;
564             } else if (hasRootFolder && folderName != null && !folderName.equals(entry.getName().substring(0, pos))) {
565               // Folder name different from previous so there's no root folder
566               hasRootFolder = false;
567             } else if (folderName == null) {
568               // Just initialize folder name
569               folderName = entry.getName().substring(0, pos);
570             }
571           }
572         } catch (IOException e) {
573           logger.warn("Unable to process zip entry {}", entry.getName(), e);
574           throw e;
575         }
576       }
577 
578       if (mp == null) {
579         throw new MediaPackageException("No manifest found in this zip");
580       }
581 
582       // Determine the mediapackage identifier
583       if (mp.getIdentifier() == null || isBlank(mp.getIdentifier().toString())) {
584         mp.setIdentifier(IdImpl.fromUUID());
585       }
586 
587       String mediaPackageId = mp.getIdentifier().toString();
588 
589       logger.info("Ingesting mediapackage {} is named '{}'", mediaPackageId, mp.getTitle());
590 
591       // Make sure there are tracks in the mediapackage
592       if (mp.getTracks().length == 0) {
593         logger.warn("Mediapackage {} has no media tracks", mediaPackageId);
594       }
595 
596       // Update the element uris to point to their working file repository location
597       for (MediaPackageElement element : mp.elements()) {
598         // Key has root folder name if there is one
599         URI uri = uris.get((hasRootFolder ? folderName + "/" : "") + element.getURI().toString());
600 
601         if (uri == null) {
602           throw new MediaPackageException("Unable to map element name '" + element.getURI() + "' to workspace uri");
603         }
604         logger.info("Ingested mediapackage element {}/{} located at {}", mediaPackageId, element.getIdentifier(), uri);
605         URI dest = workingFileRepository.moveTo(wfrCollectionId, FilenameUtils.getName(uri.toString()), mediaPackageId,
606                 element.getIdentifier(), FilenameUtils.getName(element.getURI().toString()));
607         element.setURI(dest);
608       }
609 
610       // Now that all elements are in place, start with ingest
611       logger.info("Initiating processing of ingested mediapackage {}", mediaPackageId);
612       WorkflowInstance workflowInstance = ingest(mp, workflowDefinitionId, workflowConfig, workflowInstanceId);
613       logger.info("Ingest of mediapackage {} done", mediaPackageId);
614       job.setStatus(Job.Status.FINISHED);
615       return workflowInstance;
616     } catch (ServiceRegistryException e) {
617       throw new IngestException(e);
618     } catch (MediaPackageException e) {
619       job.setStatus(Job.Status.FAILED, Job.FailureReason.DATA);
620       throw e;
621     } catch (Exception e) {
622       if (e instanceof IngestException) {
623         throw (IngestException) e;
624       }
625       throw new IngestException(e);
626     } finally {
627       IOUtils.closeQuietly(zis);
628       finallyUpdateJob(job);
629       for (String filename : collectionFilenames) {
630         workingFileRepository.deleteFromCollection(Long.toString(job.getId()), filename, true);
631       }
632     }
633   }
634 
635   /**
636    * {@inheritDoc}
637    *
638    * @see org.opencastproject.ingest.api.IngestService#createMediaPackage()
639    */
640   @Override
641   public MediaPackage createMediaPackage() throws MediaPackageException, ConfigurationException {
642     MediaPackage mediaPackage;
643     try {
644       mediaPackage = MediaPackageBuilderFactory.newInstance().newMediaPackageBuilder().createNew();
645     } catch (MediaPackageException e) {
646       logger.error("INGEST:Failed to create media package " + e.getLocalizedMessage());
647       throw e;
648     }
649     mediaPackage.setDate(new Date());
650     logger.info("Created mediapackage {}", mediaPackage);
651     return mediaPackage;
652   }
653 
654   /**
655    * {@inheritDoc}
656    *
657    * @see org.opencastproject.ingest.api.IngestService#createMediaPackage()
658    */
659   @Override
660   public MediaPackage createMediaPackage(String mediaPackageId)
661           throws MediaPackageException, ConfigurationException {
662     MediaPackage mediaPackage;
663     try {
664       mediaPackage = MediaPackageBuilderFactory.newInstance().newMediaPackageBuilder()
665               .createNew(new IdImpl(mediaPackageId));
666     } catch (MediaPackageException e) {
667       logger.error("INGEST:Failed to create media package " + e.getLocalizedMessage());
668       throw e;
669     }
670     mediaPackage.setDate(new Date());
671     logger.info("Created mediapackage {}", mediaPackage);
672     return mediaPackage;
673   }
674 
675   /**
676    * {@inheritDoc}
677    *
678    * @see org.opencastproject.ingest.api.IngestService#addTrack(java.net.URI,
679    *      org.opencastproject.mediapackage.MediaPackageElementFlavor, String[] ,
680    *      org.opencastproject.mediapackage.MediaPackage)
681    */
682   @Override
683   public MediaPackage addTrack(URI uri, MediaPackageElementFlavor flavor, String[] tags, MediaPackage mediaPackage)
684           throws IOException, IngestException {
685     Job job = null;
686     try {
687       job = serviceRegistry
688               .createJob(
689                       JOB_TYPE, INGEST_TRACK_FROM_URI, Arrays.asList(uri.toString(),
690                               flavor == null ? null : flavor.toString(), MediaPackageParser.getAsXml(mediaPackage)),
691                       null, false, ingestFileJobLoad);
692       job.setStatus(Status.RUNNING);
693       job = serviceRegistry.updateJob(job);
694       String elementId = UUID.randomUUID().toString();
695       logger.info("Start adding track {} from URL {} on mediapackage {}", elementId, uri, mediaPackage);
696       URI newUrl = addContentToRepo(mediaPackage, elementId, uri);
697       MediaPackage mp = addContentToMediaPackage(mediaPackage, elementId, newUrl, MediaPackageElement.Type.Track,
698               flavor);
699       if (tags != null && tags.length > 0) {
700         MediaPackageElement trackElement = mp.getTrack(elementId);
701         for (String tag : tags) {
702           logger.info("Adding tag: " + tag + " to Element: " + elementId);
703           trackElement.addTag(tag);
704         }
705       }
706 
707       job.setStatus(Job.Status.FINISHED);
708       logger.info("Successful added track {} on mediapackage {} at URL {}", elementId, mediaPackage, newUrl);
709       return mp;
710     } catch (IOException e) {
711       throw e;
712     } catch (ServiceRegistryException e) {
713       throw new IngestException(e);
714     } catch (NotFoundException e) {
715       throw new IngestException("Unable to update ingest job", e);
716     } finally {
717       finallyUpdateJob(job);
718     }
719   }
720 
721   /**
722    * {@inheritDoc}
723    *
724    * @see org.opencastproject.ingest.api.IngestService#addTrack(java.io.InputStream, java.lang.String,
725    *      org.opencastproject.mediapackage.MediaPackageElementFlavor, org.opencastproject.mediapackage.MediaPackage)
726    */
727   @Override
728   public MediaPackage addTrack(InputStream in, String fileName, MediaPackageElementFlavor flavor,
729           MediaPackage mediaPackage) throws IOException, IngestException {
730     String[] tags = null;
731     return this.addTrack(in, fileName, flavor, tags, mediaPackage);
732   }
733 
734   /**
735    * {@inheritDoc}
736    *
737    * @see org.opencastproject.ingest.api.IngestService#addTrack(java.io.InputStream, java.lang.String,
738    *      org.opencastproject.mediapackage.MediaPackageElementFlavor, org.opencastproject.mediapackage.MediaPackage)
739    */
740   @Override
741   public MediaPackage addTrack(InputStream in, String fileName, MediaPackageElementFlavor flavor, String[] tags,
742           MediaPackage mediaPackage) throws IOException, IngestException {
743     Job job = null;
744     try {
745       job = serviceRegistry.createJob(JOB_TYPE, INGEST_TRACK, null, null, false, ingestFileJobLoad);
746       job.setStatus(Status.RUNNING);
747       job = serviceRegistry.updateJob(job);
748       String elementId = UUID.randomUUID().toString();
749       logger.info("Start adding track {} from input stream on mediapackage {}", elementId, mediaPackage);
750       if (fileName.length() > FILENAME_LENGTH_MAX) {
751         final String extension = "." + FilenameUtils.getExtension(fileName);
752         final int length = Math.max(0, FILENAME_LENGTH_MAX - extension.length());
753         fileName = fileName.substring(0, length) + extension;
754       }
755       URI newUrl = addContentToRepo(mediaPackage, elementId, fileName, in);
756       MediaPackage mp = addContentToMediaPackage(mediaPackage, elementId, newUrl, MediaPackageElement.Type.Track,
757               flavor);
758       if (tags != null && tags.length > 0) {
759         MediaPackageElement trackElement = mp.getTrack(elementId);
760         for (String tag : tags) {
761           logger.debug("Adding tag `{}` to element {}", tag, elementId);
762           trackElement.addTag(tag);
763         }
764       }
765 
766       job.setStatus(Job.Status.FINISHED);
767       logger.info("Successful added track {} on mediapackage {} at URL {}", elementId, mediaPackage, newUrl);
768       return mp;
769     } catch (IOException e) {
770       throw e;
771     } catch (ServiceRegistryException e) {
772       throw new IngestException(e);
773     } catch (NotFoundException e) {
774       throw new IngestException("Unable to update ingest job", e);
775     } finally {
776       finallyUpdateJob(job);
777     }
778   }
779 
780   @Override
781   public MediaPackage addPartialTrack(URI uri, MediaPackageElementFlavor flavor, long startTime,
782           MediaPackage mediaPackage) throws IOException, IngestException {
783     Job job = null;
784     try {
785       job = serviceRegistry.createJob(
786               JOB_TYPE,
787               INGEST_TRACK_FROM_URI,
788               Arrays.asList(uri.toString(), flavor == null ? null : flavor.toString(),
789                       MediaPackageParser.getAsXml(mediaPackage)), null, false);
790       job.setStatus(Status.RUNNING);
791       job = serviceRegistry.updateJob(job);
792       String elementId = UUID.randomUUID().toString();
793       logger.info("Start adding partial track {} from URL {} on mediapackage {}", elementId, uri, mediaPackage);
794       URI newUrl = addContentToRepo(mediaPackage, elementId, uri);
795       MediaPackage mp = addContentToMediaPackage(mediaPackage, elementId, newUrl, MediaPackageElement.Type.Track,
796               flavor);
797       job.setStatus(Job.Status.FINISHED);
798       // store startTime
799       partialTrackStartTimes.put(elementId, startTime);
800       logger.debug("Added start time {} for track {}", startTime, elementId);
801       logger.info("Successful added partial track {} on mediapackage {} at URL {}", elementId, mediaPackage, newUrl);
802       return mp;
803     } catch (ServiceRegistryException e) {
804       throw new IngestException(e);
805     } catch (NotFoundException e) {
806       throw new IngestException("Unable to update ingest job", e);
807     } finally {
808       finallyUpdateJob(job);
809     }
810   }
811 
812   @Override
813   public MediaPackage addPartialTrack(InputStream in, String fileName, MediaPackageElementFlavor flavor, long startTime,
814           MediaPackage mediaPackage) throws IOException, IngestException {
815     Job job = null;
816     try {
817       job = serviceRegistry.createJob(JOB_TYPE, INGEST_TRACK, null, null, false);
818       job.setStatus(Status.RUNNING);
819       job = serviceRegistry.updateJob(job);
820       String elementId = UUID.randomUUID().toString();
821       logger.info("Start adding partial track {} from input stream on mediapackage {}", elementId, mediaPackage);
822       URI newUrl = addContentToRepo(mediaPackage, elementId, fileName, in);
823       MediaPackage mp = addContentToMediaPackage(mediaPackage, elementId, newUrl, MediaPackageElement.Type.Track,
824               flavor);
825       job.setStatus(Job.Status.FINISHED);
826       // store startTime
827       partialTrackStartTimes.put(elementId, startTime);
828       logger.debug("Added start time {} for track {}", startTime, elementId);
829       logger.info("Successful added partial track {} on mediapackage {} at URL {}", elementId, mediaPackage, newUrl);
830       return mp;
831     } catch (ServiceRegistryException e) {
832       throw new IngestException(e);
833     } catch (NotFoundException e) {
834       throw new IngestException("Unable to update ingest job", e);
835     } finally {
836       finallyUpdateJob(job);
837     }
838   }
839 
840   /**
841    * {@inheritDoc}
842    *
843    * @see org.opencastproject.ingest.api.IngestService#addCatalog(java.net.URI,
844    *      org.opencastproject.mediapackage.MediaPackageElementFlavor, String[],
845    *      org.opencastproject.mediapackage.MediaPackage)
846    */
847   @Override
848   public MediaPackage addCatalog(URI uri, MediaPackageElementFlavor flavor, String[] tags, MediaPackage mediaPackage)
849           throws IOException, IngestException {
850     Job job = null;
851     try {
852       job = serviceRegistry.createJob(JOB_TYPE, INGEST_CATALOG_FROM_URI,
853               Arrays.asList(uri.toString(), flavor.toString(), MediaPackageParser.getAsXml(mediaPackage)), null, false,
854               ingestFileJobLoad);
855       job.setStatus(Status.RUNNING);
856       job = serviceRegistry.updateJob(job);
857       String elementId = UUID.randomUUID().toString();
858       logger.info("Start adding catalog {} from URL {} on mediapackage {}", elementId, uri, mediaPackage);
859       URI newUrl = addContentToRepo(mediaPackage, elementId, uri);
860       MediaPackage mp = addContentToMediaPackage(mediaPackage, elementId, newUrl, MediaPackageElement.Type.Catalog,
861               flavor);
862       if (tags != null && tags.length > 0) {
863         MediaPackageElement catalogElement = mp.getCatalog(elementId);
864         for (String tag : tags) {
865           logger.info("Adding tag: " + tag + " to Element: " + elementId);
866           catalogElement.addTag(tag);
867         }
868       }
869       job.setStatus(Job.Status.FINISHED);
870       logger.info("Successful added catalog {} on mediapackage {} at URL {}", elementId, mediaPackage, newUrl);
871       return mp;
872     } catch (ServiceRegistryException e) {
873       throw new IngestException(e);
874     } catch (NotFoundException e) {
875       throw new IngestException("Unable to update ingest job", e);
876     } finally {
877       finallyUpdateJob(job);
878     }
879   }
880 
881   /**
882    * Updates the persistent representation of a series based on a potentially modified dublin core document.
883    *
884    * @param mediaPackage
885    *         the media package containing series metadata and ACLs.
886    * @return
887    *         true, if the series is created or overwritten, false if the existing series remains intact.
888    * @throws IOException if the series catalog was not found
889    * @throws IngestException if any other exception was encountered
890    */
891   protected boolean updateSeries(MediaPackage mediaPackage) throws IOException, IngestException {
892     Catalog[] seriesCatalogs = mediaPackage.getCatalogs(MediaPackageElements.SERIES);
893     if (seriesCatalogs.length == 0) {
894       return false;
895     } else if (seriesCatalogs.length > 1) {
896       logger.warn("Mediapackage {} has more than one series dublincore catalogs. Using catalog {} with ID {}.",
897           mediaPackage.getIdentifier(), seriesCatalogs[0].getURI(), seriesCatalogs[0].getIdentifier());
898     }
899     // Parse series dublincore
900     HttpResponse response = null;
901     InputStream in = null;
902     boolean isUpdated = false;
903     boolean isNew = false;
904     String seriesId = null;
905     try {
906       HttpGet getDc = new HttpGet(seriesCatalogs[0].getURI());
907       response = httpClient.execute(getDc);
908       in = response.getEntity().getContent();
909       DublinCoreCatalog dc = dublinCoreService.load(in);
910       seriesId = dc.getFirst(DublinCore.PROPERTY_IDENTIFIER);
911       if (seriesId == null) {
912         logger.warn("Series dublin core document contains no identifier, "
913             + "rejecting ingested series catalog for mediapackage {}.", mediaPackage.getIdentifier());
914       } else {
915         try {
916           try {
917             seriesService.getSeries(seriesId);
918             if (isAllowModifySeries) {
919               // Update existing series
920               seriesService.updateSeries(dc);
921               isUpdated = true;
922               logger.debug("Ingest is overwriting the existing series {} with the ingested series", seriesId);
923             } else {
924               logger.debug("Series {} already exists. Ignoring series catalog from ingest.", seriesId);
925             }
926           } catch (NotFoundException e) {
927             logger.info("Creating new series {} with default ACL.", seriesId);
928             seriesService.updateSeries(dc);
929             isUpdated = true;
930             isNew = true;
931           }
932 
933         } catch (Exception e) {
934           throw new IngestException(e);
935         }
936       }
937       in.close();
938     } catch (IOException e) {
939       logger.error("Error updating series from DublinCoreCatalog.}", e);
940     } finally {
941       IOUtils.closeQuietly(in);
942       httpClient.close(response);
943     }
944     if (!isUpdated) {
945       return isUpdated;
946     }
947     // Apply series extended metadata
948     for (MediaPackageElement seriesElement : mediaPackage.getElementsByFlavor(
949         MediaPackageElementFlavor.parseFlavor("*/series"))) {
950       if (MediaPackageElement.Type.Catalog == seriesElement.getElementType()
951           && !MediaPackageElements.SERIES.equals(seriesElement.getFlavor())) {
952         String catalogType = seriesElement.getFlavor().getType();
953         logger.info("Apply series {} metadata catalog from mediapackage {} to newly created series {}.",
954             catalogType, mediaPackage.getIdentifier(), seriesId);
955         byte[] data;
956         try {
957           HttpGet getExtendedMetadata = new HttpGet(seriesElement.getURI());
958           response = httpClient.execute(getExtendedMetadata);
959           in = response.getEntity().getContent();
960           data = IOUtils.readFully(in, (int) response.getEntity().getContentLength());
961         } catch (Exception e) {
962           throw new IngestException("Unable to read series " + catalogType + " metadata catalog for series "
963               + seriesId + ".", e);
964         } finally {
965           IOUtils.closeQuietly(in);
966           httpClient.close(response);
967         }
968         try {
969           seriesService.updateSeriesElement(seriesId, catalogType, data);
970         } catch (SeriesException e) {
971           throw new IngestException(
972               "Unable to update series " + catalogType + " catalog on newly created series " + seriesId + ".", e);
973         }
974       }
975     }
976     if (isNew) {
977       logger.info("Apply series ACL from mediapackage {} to newly created series {}.",
978           mediaPackage.getIdentifier(), seriesId);
979       Attachment[] seriesXacmls = mediaPackage.getAttachments(MediaPackageElements.XACML_POLICY_SERIES);
980       if (seriesXacmls.length > 0) {
981         if (seriesXacmls.length > 1) {
982           logger.warn("Mediapackage {} has more than one series xacml attachments. Using {}.",
983               mediaPackage.getIdentifier(), seriesXacmls[0].getURI());
984         }
985         AccessControlList seriesAcl = null;
986         try {
987           HttpGet getXacml = new HttpGet(seriesXacmls[0].getURI());
988           response = httpClient.execute(getXacml);
989           in = response.getEntity().getContent();
990           seriesAcl = XACMLUtils.parseXacml(in);
991         } catch (XACMLParsingException ex) {
992           throw new IngestException("Unable to parse series xacml from mediapackage "
993               + mediaPackage.getIdentifier() + ".", ex);
994         } catch (IOException e) {
995           logger.error("Error updating series {} ACL from mediapackage {}.",
996               seriesId, mediaPackage.getIdentifier(), e);
997           throw e;
998         } finally {
999           IOUtils.closeQuietly(in);
1000           httpClient.close(response);
1001         }
1002         try {
1003           seriesService.updateAccessControl(seriesId, seriesAcl);
1004         } catch (Exception e) {
1005           throw new IngestException("Unable to update series ACL on newly created series " + seriesId + ".", e);
1006         }
1007       }
1008     }
1009     return isUpdated;
1010   }
1011 
1012   /**
1013    * {@inheritDoc}
1014    *
1015    * @see org.opencastproject.ingest.api.IngestService#addCatalog(java.io.InputStream, java.lang.String,
1016    *      org.opencastproject.mediapackage.MediaPackageElementFlavor, org.opencastproject.mediapackage.MediaPackage)
1017    */
1018   @Override
1019   public MediaPackage addCatalog(InputStream in, String fileName, MediaPackageElementFlavor flavor,
1020           MediaPackage mediaPackage) throws IOException, IngestException {
1021     return addCatalog(in, fileName, flavor, null, mediaPackage);
1022   }
1023 
1024   /**
1025    * {@inheritDoc}
1026    *
1027    * @see org.opencastproject.ingest.api.IngestService#addCatalog(java.io.InputStream, java.lang.String,
1028    *      org.opencastproject.mediapackage.MediaPackageElementFlavor, org.opencastproject.mediapackage.MediaPackage)
1029    */
1030   @Override
1031   public MediaPackage addCatalog(InputStream in, String fileName, MediaPackageElementFlavor flavor, String[] tags,
1032           MediaPackage mediaPackage) throws IOException, IngestException, IllegalArgumentException {
1033     Job job = null;
1034     try {
1035       job = serviceRegistry.createJob(JOB_TYPE, INGEST_CATALOG, null, null, false, ingestFileJobLoad);
1036       job.setStatus(Status.RUNNING);
1037       job = serviceRegistry.updateJob(job);
1038       final String elementId = UUID.randomUUID().toString();
1039       final String mediaPackageId = mediaPackage.getIdentifier().toString();
1040       logger.info("Start adding catalog {} from input stream on mediapackage {}", elementId, mediaPackageId);
1041       final URI newUrl = addContentToRepo(mediaPackage, elementId, fileName, in);
1042 
1043       final boolean isJSON;
1044       try (InputStream inputStream = workingFileRepository.get(mediaPackageId, elementId)) {
1045         try (BufferedReader reader  = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
1046           // Exception for current BBB integration and Extron SMP351 which is ingesting a JSON array/object as catalog
1047           int firstChar = reader.read();
1048           isJSON = firstChar == '[' || firstChar == '{';
1049         }
1050       }
1051 
1052       if (isJSON) {
1053         logger.warn("Input catalog seems to be JSON. This is a mistake and will fail in future Opencast versions."
1054             + "You will likely want to ingest this as a media package attachment instead.");
1055       } else {
1056         // Verify XML is not corrupted
1057         try {
1058           XmlSafeParser.parse(workingFileRepository.get(mediaPackageId, elementId));
1059         } catch (SAXException e) {
1060           workingFileRepository.delete(mediaPackageId, elementId);
1061           throw new IllegalArgumentException("Catalog XML is invalid", e);
1062         }
1063       }
1064       MediaPackage mp = addContentToMediaPackage(mediaPackage, elementId, newUrl, MediaPackageElement.Type.Catalog,
1065               flavor);
1066       if (tags != null && tags.length > 0) {
1067         MediaPackageElement trackElement = mp.getCatalog(elementId);
1068         for (String tag : tags) {
1069           logger.info("Adding tag {} to element {}", tag, elementId);
1070           trackElement.addTag(tag);
1071         }
1072       }
1073 
1074       job.setStatus(Job.Status.FINISHED);
1075       logger.info("Successful added catalog {} on mediapackage {} at URL {}", elementId, mediaPackage, newUrl);
1076       return mp;
1077     } catch (ServiceRegistryException e) {
1078       throw new IngestException(e);
1079     } catch (NotFoundException e) {
1080       throw new IngestException("Unable to update ingest job", e);
1081     } finally {
1082       finallyUpdateJob(job);
1083     }
1084   }
1085 
1086   /**
1087    * {@inheritDoc}
1088    *
1089    * @see org.opencastproject.ingest.api.IngestService#addAttachment(java.net.URI,
1090    *      org.opencastproject.mediapackage.MediaPackageElementFlavor, String[],
1091    *      org.opencastproject.mediapackage.MediaPackage)
1092    */
1093   @Override
1094   public MediaPackage addAttachment(URI uri, MediaPackageElementFlavor flavor, String[] tags, MediaPackage mediaPackage)
1095           throws IOException, IngestException {
1096     Job job = null;
1097     try {
1098       job = serviceRegistry.createJob(JOB_TYPE, INGEST_ATTACHMENT_FROM_URI,
1099               Arrays.asList(uri.toString(), flavor.toString(), MediaPackageParser.getAsXml(mediaPackage)), null, false,
1100               ingestFileJobLoad);
1101       job.setStatus(Status.RUNNING);
1102       job = serviceRegistry.updateJob(job);
1103       String elementId = UUID.randomUUID().toString();
1104       logger.info("Start adding attachment {} from URL {} on mediapackage {}", elementId, uri, mediaPackage);
1105       URI newUrl = addContentToRepo(mediaPackage, elementId, uri);
1106       MediaPackage mp = addContentToMediaPackage(mediaPackage, elementId, newUrl, MediaPackageElement.Type.Attachment,
1107               flavor);
1108       if (tags != null && tags.length > 0) {
1109         MediaPackageElement attachmentElement = mp.getAttachment(elementId);
1110         for (String tag : tags) {
1111           logger.debug("Adding tag: " + tag + " to Element: " + elementId);
1112           attachmentElement.addTag(tag);
1113         }
1114       }
1115       job.setStatus(Job.Status.FINISHED);
1116       logger.info("Successful added attachment {} on mediapackage {} at URL {}", elementId, mediaPackage, newUrl);
1117       return mp;
1118     } catch (ServiceRegistryException e) {
1119       throw new IngestException(e);
1120     } catch (NotFoundException e) {
1121       throw new IngestException("Unable to update ingest job", e);
1122     } finally {
1123       finallyUpdateJob(job);
1124     }
1125   }
1126 
1127   /**
1128    * {@inheritDoc}
1129    *
1130    * @see org.opencastproject.ingest.api.IngestService#addAttachment(java.io.InputStream, java.lang.String,
1131    *      org.opencastproject.mediapackage.MediaPackageElementFlavor, org.opencastproject.mediapackage.MediaPackage)
1132    */
1133   @Override
1134   public MediaPackage addAttachment(InputStream in, String fileName, MediaPackageElementFlavor flavor, String[] tags,
1135           MediaPackage mediaPackage) throws IOException, IngestException {
1136     Job job = null;
1137     try {
1138       job = serviceRegistry.createJob(JOB_TYPE, INGEST_ATTACHMENT, null, null, false, ingestFileJobLoad);
1139       job.setStatus(Status.RUNNING);
1140       job = serviceRegistry.updateJob(job);
1141       String elementId = UUID.randomUUID().toString();
1142       logger.info("Start adding attachment {} from input stream on mediapackage {}", elementId, mediaPackage);
1143       URI newUrl = addContentToRepo(mediaPackage, elementId, fileName, in);
1144       MediaPackage mp = addContentToMediaPackage(mediaPackage, elementId, newUrl, MediaPackageElement.Type.Attachment,
1145               flavor);
1146       if (tags != null && tags.length > 0) {
1147         MediaPackageElement trackElement = mp.getAttachment(elementId);
1148         for (String tag : tags) {
1149           logger.info("Adding tag: " + tag + " to Element: " + elementId);
1150           trackElement.addTag(tag);
1151         }
1152       }
1153       job.setStatus(Job.Status.FINISHED);
1154       logger.info("Successful added attachment {} on mediapackage {} at URL {}", elementId, mediaPackage, newUrl);
1155       return mp;
1156     } catch (ServiceRegistryException e) {
1157       throw new IngestException(e);
1158     } catch (NotFoundException e) {
1159       throw new IngestException("Unable to update ingest job", e);
1160     } finally {
1161       finallyUpdateJob(job);
1162     }
1163 
1164   }
1165 
1166   /**
1167    * {@inheritDoc}
1168    *
1169    * @see org.opencastproject.ingest.api.IngestService#addAttachment(java.io.InputStream, java.lang.String,
1170    *      org.opencastproject.mediapackage.MediaPackageElementFlavor, org.opencastproject.mediapackage.MediaPackage)
1171    */
1172   @Override
1173   public MediaPackage addAttachment(InputStream in, String fileName, MediaPackageElementFlavor flavor,
1174           MediaPackage mediaPackage) throws IOException, IngestException {
1175     String[] tags = null;
1176     return addAttachment(in, fileName, flavor, tags, mediaPackage);
1177   }
1178 
1179   /**
1180    *
1181    * {@inheritDoc}
1182    *
1183    * @see org.opencastproject.ingest.api.IngestService#ingest(org.opencastproject.mediapackage.MediaPackage)
1184    */
1185   @Override
1186   public WorkflowInstance ingest(MediaPackage mp) throws IngestException {
1187     try {
1188       return ingest(mp, null, null, null);
1189     } catch (NotFoundException e) {
1190       throw new IngestException(e);
1191     } catch (UnauthorizedException e) {
1192       throw new IllegalStateException(e);
1193     }
1194   }
1195 
1196   /**
1197    * {@inheritDoc}
1198    *
1199    * @see org.opencastproject.ingest.api.IngestService#ingest(org.opencastproject.mediapackage.MediaPackage,
1200    *      java.lang.String, java.util.Map)
1201    */
1202   @Override
1203   public WorkflowInstance ingest(MediaPackage mp, String wd, Map<String, String> properties)
1204           throws IngestException, NotFoundException {
1205     try {
1206       return ingest(mp, wd, properties, null);
1207     } catch (UnauthorizedException e) {
1208       throw new IllegalStateException(e);
1209     }
1210   }
1211 
1212   /**
1213    * {@inheritDoc}
1214    *
1215    * @see org.opencastproject.ingest.api.IngestService#ingest(org.opencastproject.mediapackage.MediaPackage,
1216    *      java.lang.String, java.util.Map, java.lang.Long)
1217    */
1218   @Override
1219   public WorkflowInstance ingest(MediaPackage mp, String workflowDefinitionId, Map<String, String> properties,
1220           Long workflowInstanceId) throws IngestException, NotFoundException, UnauthorizedException {
1221     // Check for legacy media package id
1222     mp = checkForLegacyMediaPackageId(mp, properties);
1223 
1224     try {
1225       mp = createSmil(mp);
1226     } catch (IOException e) {
1227       throw new IngestException("Unable to add SMIL Catalog", e);
1228     }
1229 
1230     try {
1231       updateSeries(mp);
1232     } catch (IOException e) {
1233       throw new IngestException("Unable to create or update series from mediapackage " + mp.getIdentifier() + ".", e);
1234     }
1235 
1236     // Done, update the job status and return the created workflow instance
1237     if (workflowInstanceId != null) {
1238       logger.warn(
1239               "Resuming workflow {} with ingested mediapackage {} is deprecated, skip resuming and start new workflow",
1240               workflowInstanceId, mp);
1241     }
1242 
1243     if (workflowDefinitionId == null) {
1244       logger.info("Starting a new workflow with ingested mediapackage {} based on the default workflow definition '{}'",
1245               mp, defaultWorkflowDefinionId);
1246     } else {
1247       logger.info("Starting a new workflow with ingested mediapackage {} based on workflow definition '{}'", mp,
1248               workflowDefinitionId);
1249     }
1250 
1251     try {
1252       // Determine the workflow definition
1253       WorkflowDefinition workflowDef = getWorkflowDefinition(workflowDefinitionId, mp);
1254 
1255       // Get the final set of workflow properties
1256       properties = mergeWorkflowConfiguration(properties, mp.getIdentifier().toString());
1257 
1258       // Remove potential workflow configuration prefixes from the workflow properties
1259       properties = removePrefixFromProperties(properties);
1260 
1261       // Merge scheduled mediapackage with ingested
1262       mp = mergeScheduledMediaPackage(mp);
1263       if (mp.getSeries() == null) {
1264         mp = checkForCASeries(mp, createSeriesAppendix);
1265       }
1266 
1267 
1268       if (workflowDef != null) {
1269         logger.info("Starting new workflow with ingested mediapackage '{}' using the specified template '{}'",
1270                 mp.getIdentifier().toString(), workflowDefinitionId);
1271       } else {
1272         logger.info("Starting new workflow with ingested mediapackage '{}' using the default template '{}'",
1273                 mp.getIdentifier().toString(), defaultWorkflowDefinionId);
1274       }
1275       return workflowService.start(workflowDef, mp, properties);
1276     } catch (WorkflowException e) {
1277       throw new IngestException(e);
1278     }
1279   }
1280 
1281   @Override
1282   public void schedule(MediaPackage mediaPackage, String workflowDefinitionID, Map<String, String> properties)
1283           throws IllegalStateException, IngestException, NotFoundException, UnauthorizedException, SchedulerException {
1284     MediaPackageElement[] mediaPackageElements = mediaPackage.getElementsByFlavor(MediaPackageElements.EPISODE);
1285     if (mediaPackageElements.length != 1) {
1286       logger.debug("There can be only one (and exactly one) episode dublin core catalog: https://youtu.be/_J3VeogFUOs");
1287       throw new IngestException("There can be only one (and exactly one) episode dublin core catalog");
1288     }
1289     InputStream inputStream;
1290     DublinCoreCatalog dublinCoreCatalog;
1291     try {
1292       inputStream = workingFileRepository.get(mediaPackage.getIdentifier().toString(),
1293               mediaPackageElements[0].getIdentifier());
1294       dublinCoreCatalog = dublinCoreService.load(inputStream);
1295     } catch (IOException e) {
1296       throw new IngestException(e);
1297     }
1298 
1299     EName temporal = new EName(DublinCore.TERMS_NS_URI, "temporal");
1300     List<DublinCoreValue> periods = dublinCoreCatalog.get(temporal);
1301     if (periods.size() != 1) {
1302       logger.debug("There can be only one (and exactly one) period");
1303       throw new IngestException("There can be only one (and exactly one) period");
1304     }
1305     DCMIPeriod period = EncodingSchemeUtils.decodeMandatoryPeriod(periods.get(0));
1306     if (!period.hasStart() || !period.hasEnd()) {
1307       logger.debug("A scheduled recording needs to have a start and end.");
1308       throw new IngestException("A scheduled recording needs to have a start and end.");
1309     }
1310     EName createdEName = new EName(DublinCore.TERMS_NS_URI, "created");
1311     List<DublinCoreValue> created = dublinCoreCatalog.get(createdEName);
1312     if (created.size() == 0) {
1313       logger.debug("Created not set");
1314     } else if (created.size() == 1) {
1315       Date date = EncodingSchemeUtils.decodeMandatoryDate(created.get(0));
1316       if (date.getTime() != period.getStart().getTime()) {
1317         logger.debug("start and created date differ ({} vs {})", date.getTime(), period.getStart().getTime());
1318         throw new IngestException("Temporal start and created date differ");
1319       }
1320     } else {
1321       logger.debug("There can be only one created date");
1322       throw new IngestException("There can be only one created date");
1323     }
1324     String captureAgent = getCaptureAgent(dublinCoreCatalog);
1325 
1326     // Go through properties
1327     Map<String, String> agentProperties = new HashMap<>();
1328     Map<String, String> workflowProperties = new HashMap<>();
1329     for (String key : properties.keySet()) {
1330       if (key.startsWith("org.opencastproject.workflow.config.")) {
1331         workflowProperties.put(key, properties.get(key));
1332       } else {
1333         agentProperties.put(key, properties.get(key));
1334       }
1335     }
1336 
1337     // Remove workflow configuration prefixes from the workflow properties
1338     workflowProperties = removePrefixFromProperties(workflowProperties);
1339 
1340     try {
1341       schedulerService.addEvent(period.getStart(), period.getEnd(), captureAgent, new HashSet<>(), mediaPackage,
1342               workflowProperties, agentProperties, Optional.empty());
1343     } finally {
1344       for (MediaPackageElement mediaPackageElement : mediaPackage.getElements()) {
1345         try {
1346           workingFileRepository.delete(mediaPackage.getIdentifier().toString(), mediaPackageElement.getIdentifier());
1347         } catch (IOException e) {
1348           logger.warn("Failed to delete media package element", e);
1349         }
1350       }
1351     }
1352   }
1353 
1354   private String getCaptureAgent(DublinCoreCatalog dublinCoreCatalog) throws IngestException {
1355     // spatial
1356     EName spatial = new EName(DublinCore.TERMS_NS_URI, "spatial");
1357     List<DublinCoreValue> captureAgents = dublinCoreCatalog.get(spatial);
1358     if (captureAgents.size() != 1) {
1359       logger.debug("Exactly one capture agent needs to be set");
1360       throw new IngestException("Exactly one capture agent needs to be set");
1361     }
1362     return captureAgents.get(0).getValue();
1363   }
1364 
1365   /**
1366    * Check whether the mediapackage id is set via the legacy workflow identifier and change the id if existing.
1367    *
1368    * @param mp
1369    *          the mediapackage
1370    * @param properties
1371    *          the workflow properties
1372    * @return the mediapackage
1373    */
1374   private MediaPackage checkForLegacyMediaPackageId(MediaPackage mp, Map<String, String> properties)
1375           throws IngestException {
1376     if (properties == null || properties.isEmpty()) {
1377       return mp;
1378     }
1379 
1380     try {
1381       String mediaPackageId = properties.get(LEGACY_MEDIAPACKAGE_ID_KEY);
1382       if (StringUtils.isNotBlank(mediaPackageId) && schedulerService != null) {
1383         logger.debug("Check ingested mediapackage {} for legacy mediapackage identifier {}",
1384                 mp.getIdentifier().toString(), mediaPackageId);
1385         try {
1386           schedulerService.getMediaPackage(mp.getIdentifier().toString());
1387           return mp;
1388         } catch (NotFoundException e) {
1389           logger.info("No scheduler mediapackage found with ingested id {}, try legacy mediapackage id {}",
1390                   mp.getIdentifier().toString(), mediaPackageId);
1391           try {
1392             schedulerService.getMediaPackage(mediaPackageId);
1393             logger.info("Legacy mediapackage id {} exists, change ingested mediapackage id {} to legacy id",
1394                     mediaPackageId, mp.getIdentifier().toString());
1395             mp.setIdentifier(new IdImpl(mediaPackageId));
1396             return mp;
1397           } catch (NotFoundException e1) {
1398             logger.info("No scheduler mediapackage found with legacy mediapackage id {}, skip merging", mediaPackageId);
1399           } catch (Exception e1) {
1400             logger.error("Unable to get event mediapackage from scheduler event {}", mediaPackageId, e);
1401             throw new IngestException(e);
1402           }
1403         } catch (Exception e) {
1404           logger.error("Unable to get event mediapackage from scheduler event {}", mp.getIdentifier().toString(), e);
1405           throw new IngestException(e);
1406         }
1407       }
1408       return mp;
1409     } finally {
1410       properties.remove(LEGACY_MEDIAPACKAGE_ID_KEY);
1411     }
1412   }
1413 
1414   private Map<String, String> mergeWorkflowConfiguration(Map<String, String> properties, String mediaPackageId) {
1415     if (isBlank(mediaPackageId) || schedulerService == null) {
1416       return properties;
1417     }
1418 
1419     HashMap<String, String> mergedProperties = new HashMap<>();
1420 
1421     try {
1422       Map<String, String> recordingProperties = schedulerService.getCaptureAgentConfiguration(mediaPackageId);
1423       logger.debug("Restoring workflow properties from scheduler event {}", mediaPackageId);
1424       mergedProperties.putAll(recordingProperties);
1425     } catch (SchedulerException e) {
1426       logger.warn("Unable to get workflow properties from scheduler event {}", mediaPackageId, e);
1427     } catch (NotFoundException e) {
1428       logger.info("No capture event found for id {}", mediaPackageId);
1429     } catch (UnauthorizedException e) {
1430       throw new IllegalStateException(e);
1431     }
1432 
1433     if (properties != null) {
1434       // Merge the properties, this must be after adding the recording properties
1435       logger.debug("Merge workflow properties with the one from the scheduler event {}", mediaPackageId);
1436       mergedProperties.putAll(properties);
1437     }
1438 
1439     return mergedProperties;
1440   }
1441 
1442   /**
1443    * Merges the ingested mediapackage with the scheduled mediapackage. The ingested mediapackage takes precedence over
1444    * the scheduled mediapackage.
1445    *
1446    * @param mp
1447    *          the ingested mediapackage
1448    * @return the merged mediapackage
1449    */
1450   private MediaPackage mergeScheduledMediaPackage(MediaPackage mp) throws IngestException {
1451     if (schedulerService == null) {
1452       logger.warn("No scheduler service available to merge mediapackage!");
1453       return mp;
1454     }
1455 
1456     try {
1457       MediaPackage scheduledMp = schedulerService.getMediaPackage(mp.getIdentifier().toString());
1458       logger.info("Found matching scheduled event for id '{}', merging mediapackage...", mp.getIdentifier().toString());
1459       mergeMediaPackageElements(mp, scheduledMp);
1460       mergeMediaPackageMetadata(mp, scheduledMp);
1461       return mp;
1462     } catch (NotFoundException e) {
1463       logger.debug("No scheduler mediapackage found with id {}, skip merging", mp.getIdentifier());
1464       return mp;
1465     } catch (Exception e) {
1466       throw new IngestException(String.format("Unable to get event media package from scheduler event %s",
1467               mp.getIdentifier()), e);
1468     }
1469   }
1470 
1471   /**
1472    * Merge different elements from capture agent ingesting mp and Asset manager. Overwrite or replace same flavored
1473    * elements depending on the Ingest Service overwrite configuration. Ignore publications (i.e. live publication
1474    * channel from Asset Manager) Always keep tracks from the capture agent.
1475    *
1476    * @param mp
1477    *          the medipackage being ingested from the Capture Agent
1478    * @param scheduledMp
1479    *          the mediapckage that was schedule and managed by the Asset Manager
1480    */
1481   private void mergeMediaPackageElements(MediaPackage mp, MediaPackage scheduledMp) {
1482     // drop catalogs sent by the capture agent in favor of Opencast's own metadata
1483     if (skipCatalogs) {
1484       for (MediaPackageElement element : mp.getCatalogs()) {
1485         if (!element.getFlavor().equals(MediaPackageElements.SMIL)) {
1486           mp.remove(element);
1487         }
1488       }
1489     }
1490 
1491     // drop attachments the capture agent sent us in favor of Opencast's attachments
1492     // e.g. prevent capture agents from modifying security rules of schedules events
1493     if (skipAttachments) {
1494       for (MediaPackageElement element : mp.getAttachments()) {
1495         mp.remove(element);
1496       }
1497     }
1498 
1499     for (MediaPackageElement element : scheduledMp.getElements()) {
1500       if (MediaPackageElement.Type.Publication.equals(element.getElementType())) {
1501         // The Asset managed media package may have a publication element for a live event, if retract live has not
1502         // run yet.
1503         // Publications do not have flavors and are never part of the mediapackage from the capture agent.
1504         // Therefore, ignore publication element because it is removed when the recorded media is published and causes
1505         // complications (on short media) if added.
1506         logger.debug("Ignoring {}, not adding to ingested mediapackage {}", MediaPackageElement.Type.Publication, mp);
1507         continue;
1508       } else if (mp.getElementsByFlavor(element.getFlavor()).length > 0) {
1509         // The default is to overwrite matching flavored elements in the Asset managed mediapackage (e.g. catalogs)
1510         // If isOverwrite is true, changes made from the CA overwrite (update/revert) changes made from the Admin UI.
1511         // If isOverwrite is false, changes made from the CA do not overwrite (update/revert) changes made from the
1512         // Admin UI.
1513         // regardless of overwrite, always keep new ingested tracks.
1514         if (!isAddOnlyNew || MediaPackageElement.Type.Track.equals(element.getElementType())) {
1515           // Allow updates made from the Capture Agent to overwrite existing metadata in Opencast
1516           logger.info(
1517                   "Omitting Opencast (Asset Managed) element '{}', replacing with ingested element of same flavor '{}'",
1518                   element,
1519                   element.getFlavor());
1520           continue;
1521         }
1522         // Remove flavored element from ingested mp and replaced it with maching element from Asset Managed
1523         // mediapackage.
1524         // This protects updates made from the admin UI during an event capture from being reverted by artifacts from
1525         // the ingested CA.
1526         for (MediaPackageElement el : mp.getElementsByFlavor(element.getFlavor())) {
1527           logger.info("Omitting ingested element '{}' {}, keeping existing (Asset Managed) element of same flavor '{}'",
1528               el, el.getURI(), element.getFlavor());
1529           mp.remove(el);
1530         }
1531       }
1532       logger.info("Adding element {} from scheduled (Asset Managed) event '{}' into ingested mediapackage",
1533           element, mp);
1534       mp.add(element);
1535     }
1536   }
1537 
1538   /**
1539    *
1540    * The previous OC behaviour is for metadata in the ingested mediapackage to be updated by the
1541    * Asset Managed metadata *only* when the field is blank on the ingested mediapackage.
1542    * However, that field may have been intentionally emptied by
1543    * removing its value from the Capture Agent UI (e.g. Galicaster)
1544    *
1545    * If isOverwrite is true, metadata values in the ingest mediapackage overwrite Asset Managed metadata.
1546    * If isOverwrite is false, Asset Managed metadata is preserved.
1547    *
1548    * @param mp,
1549    *          the inbound ingested mp
1550    * @param scheduledMp,
1551    *          the existing scheduled mp
1552    */
1553   private void mergeMediaPackageMetadata(MediaPackage mp, MediaPackage scheduledMp) {
1554     // Merge media package fields depending on overwrite setting
1555     boolean noOverwrite = (isAddOnlyNew && !skipCatalogs) || skipCatalogs;
1556     if ((mp.getDate() == null) || noOverwrite) {
1557       mp.setDate(scheduledMp.getDate());
1558     }
1559     if (isBlank(mp.getLicense()) || noOverwrite) {
1560       mp.setLicense(scheduledMp.getLicense());
1561     }
1562     if (isBlank(mp.getSeries()) || noOverwrite) {
1563       mp.setSeries(scheduledMp.getSeries());
1564     }
1565     if (isBlank(mp.getSeriesTitle()) || noOverwrite) {
1566       mp.setSeriesTitle(scheduledMp.getSeriesTitle());
1567     }
1568     if (isBlank(mp.getTitle()) || noOverwrite) {
1569       mp.setTitle(scheduledMp.getTitle());
1570     }
1571 
1572     if (mp.getSubjects().length <= 0 || noOverwrite) {
1573       Arrays.stream(mp.getSubjects()).forEach(mp::removeSubject);
1574       for (String subject : scheduledMp.getSubjects()) {
1575         mp.addSubject(subject);
1576       }
1577     }
1578     if (noOverwrite || mp.getContributors().length == 0) {
1579       Arrays.stream(mp.getContributors()).forEach(mp::removeContributor);
1580       for (String contributor : scheduledMp.getContributors()) {
1581         mp.addContributor(contributor);
1582       }
1583     }
1584     if (noOverwrite || mp.getCreators().length == 0) {
1585       Arrays.stream(mp.getCreators()).forEach(mp::removeCreator);
1586       for (String creator : scheduledMp.getCreators()) {
1587         mp.addCreator(creator);
1588       }
1589     }
1590   }
1591 
1592   /**
1593    * Removes the workflow configuration file prefix from all properties in a map.
1594    *
1595    * @param properties
1596    *          The properties to remove the prefixes from
1597    * @return A Map with the same collection of properties without the prefix
1598    */
1599   private Map<String, String> removePrefixFromProperties(Map<String, String> properties) {
1600     Map<String, String> fixedProperties = new HashMap<>();
1601     if (properties != null) {
1602       for (Entry<String, String> entry : properties.entrySet()) {
1603         if (entry.getKey().startsWith(WORKFLOW_CONFIGURATION_PREFIX)) {
1604           logger.debug("Removing prefix from key '" + entry.getKey() + " with value '" + entry.getValue() + "'");
1605           fixedProperties.put(entry.getKey().replace(WORKFLOW_CONFIGURATION_PREFIX, ""), entry.getValue());
1606         } else {
1607           fixedProperties.put(entry.getKey(), entry.getValue());
1608         }
1609       }
1610     }
1611     return fixedProperties;
1612   }
1613 
1614   private WorkflowDefinition getWorkflowDefinition(String workflowDefinitionID, MediaPackage mediapackage)
1615           throws NotFoundException, WorkflowDatabaseException, IngestException {
1616     // If the workflow definition and instance ID are null, use the default, or throw if there is none
1617     if (isBlank(workflowDefinitionID)) {
1618       String mediaPackageId = mediapackage.getIdentifier().toString();
1619       if (schedulerService != null) {
1620         logger.info("Determining workflow template for ingested mediapckage {} from capture event {}", mediapackage,
1621                 mediaPackageId);
1622         try {
1623           Map<String, String> recordingProperties = schedulerService.getCaptureAgentConfiguration(mediaPackageId);
1624           workflowDefinitionID = recordingProperties.get(CaptureParameters.INGEST_WORKFLOW_DEFINITION);
1625           if (isBlank(workflowDefinitionID)) {
1626             workflowDefinitionID = defaultWorkflowDefinionId;
1627             logger.debug("No workflow set. Falling back to default.");
1628           }
1629           if (isBlank(workflowDefinitionID)) {
1630             throw new IngestException("No value found for key '" + CaptureParameters.INGEST_WORKFLOW_DEFINITION
1631                     + "' from capture event configuration of scheduler event '" + mediaPackageId + "'");
1632           }
1633           logger.info("Ingested event {} will be processed using workflow '{}'", mediapackage, workflowDefinitionID);
1634         } catch (NotFoundException e) {
1635           logger.warn("Specified capture event {} was not found", mediaPackageId);
1636         } catch (UnauthorizedException e) {
1637           throw new IllegalStateException(e);
1638         } catch (SchedulerException e) {
1639           logger.warn("Unable to get the workflow definition id from scheduler event {}", mediaPackageId, e);
1640           throw new IngestException(e);
1641         }
1642       } else {
1643         logger.warn(
1644                 "Scheduler service not bound, unable to determine the workflow template to use for ingested "
1645                     + "mediapackage {}", mediapackage);
1646       }
1647 
1648     } else {
1649       logger.info("Ingested mediapackage {} is processed using workflow template '{}', specified during ingest",
1650               mediapackage, workflowDefinitionID);
1651     }
1652 
1653     // Use the default workflow definition if nothing was determined
1654     if (isBlank(workflowDefinitionID) && defaultWorkflowDefinionId != null) {
1655       logger.info("Using default workflow definition '{}' to process ingested mediapackage {}",
1656               defaultWorkflowDefinionId, mediapackage);
1657       workflowDefinitionID = defaultWorkflowDefinionId;
1658     }
1659 
1660     // Check if the workflow definition is valid
1661     if (StringUtils.isNotBlank(workflowDefinitionID) && StringUtils.isNotBlank(defaultWorkflowDefinionId)) {
1662       try {
1663         workflowService.getWorkflowDefinitionById(workflowDefinitionID);
1664       } catch (WorkflowDatabaseException e) {
1665         throw new IngestException(e);
1666       } catch (NotFoundException nfe) {
1667         logger.warn("Workflow definition {} not found, using default workflow {} instead", workflowDefinitionID,
1668                 defaultWorkflowDefinionId);
1669         workflowDefinitionID = defaultWorkflowDefinionId;
1670       }
1671     }
1672 
1673     // Have we been able to find a workflow definition id?
1674     if (isBlank(workflowDefinitionID)) {
1675       throw new IllegalStateException("Can not ingest a workflow without a workflow definition or an existing "
1676           + "instance. No default definition is specified");
1677     }
1678 
1679     // Let's make sure the workflow definition exists
1680     return workflowService.getWorkflowDefinitionById(workflowDefinitionID);
1681   }
1682 
1683   /**
1684    *
1685    * {@inheritDoc}
1686    *
1687    * @see org.opencastproject.ingest.api.IngestService#discardMediaPackage(
1688    *      org.opencastproject.mediapackage.MediaPackage)
1689    */
1690   @Override
1691   public void discardMediaPackage(MediaPackage mp) throws IOException {
1692     String mediaPackageId = mp.getIdentifier().toString();
1693     for (MediaPackageElement element : mp.getElements()) {
1694       if (!workingFileRepository.delete(mediaPackageId, element.getIdentifier())) {
1695         logger.warn("Unable to find (and hence, delete), this mediapackage element");
1696       }
1697     }
1698     logger.info("Successfully discarded media package {}", mp);
1699   }
1700 
1701   protected URI addContentToRepo(MediaPackage mp, String elementId, URI uri) throws IOException {
1702     InputStream in = null;
1703     HttpResponse response = null;
1704     CloseableHttpClient externalHttpClient = null;
1705     try {
1706       if (uri.toString().startsWith("http")) {
1707         HttpGet get = new HttpGet(uri);
1708 
1709         if (!isBlank(downloadSource) && uri.toString().matches(downloadSource)) {
1710           // NB: We're creating a new client here with *different* auth than the system auth creds
1711           externalHttpClient = getAuthedHttpClient();
1712           get.setHeader("X-Requested-Auth", downloadAuthMethod);
1713           if ("Basic".equals(downloadAuthMethod) && downloadAuthForceBasic) {
1714             String auth = downloadUser + ":" + downloadPassword;
1715             byte[] encodedAuth = Base64.getEncoder().encode(auth.getBytes(StandardCharsets.ISO_8859_1));
1716             String authHeader = "Basic " + new String(encodedAuth);
1717             get.setHeader(HttpHeaders.AUTHORIZATION, authHeader);
1718           }
1719           response = externalHttpClient.execute(get);
1720         } else {
1721           // httpClient checks internally to see if it should be sending the default auth, or not.
1722           response = httpClient.execute(get);
1723         }
1724 
1725         if (null == response) {
1726           // If you get here then chances are you're using a mock httpClient which does not have appropriate
1727           // mocking to respond to the URL you are feeding it.  Try adding that URL to the mock and see if that works.
1728           throw new IOException("Null response object from the http client, refer to code for explanation");
1729         }
1730 
1731         int httpStatusCode = response.getStatusLine().getStatusCode();
1732         if (httpStatusCode != 200) {
1733           throw new IOException(uri + " returns http " + httpStatusCode);
1734         }
1735         in = response.getEntity().getContent();
1736         //If it does not start with file, or we're in test mode (ie, to allow arbitrary file:// access)
1737       } else if (!uri.toString().startsWith("file") || testMode) {
1738         in = uri.toURL().openStream();
1739       } else {
1740         throw new IOException("Refusing to fetch files from the local filesystem");
1741       }
1742       String fileName = FilenameUtils.getName(uri.getPath());
1743       if (isBlank(FilenameUtils.getExtension(fileName))) {
1744         fileName = getContentDispositionFileName(response);
1745       }
1746 
1747       if (isBlank(FilenameUtils.getExtension(fileName))) {
1748         throw new IOException("No filename extension found: " + fileName);
1749       }
1750       return addContentToRepo(mp, elementId, fileName, in);
1751     } finally {
1752       if (in != null) {
1753         in.close();
1754       }
1755       if (externalHttpClient != null) {
1756         externalHttpClient.close();
1757       }
1758       httpClient.close(response);
1759     }
1760   }
1761 
1762   private String getContentDispositionFileName(HttpResponse response) {
1763     if (response == null) {
1764       return null;
1765     }
1766 
1767     Header header = response.getFirstHeader("Content-Disposition");
1768     ContentDisposition contentDisposition = new ContentDisposition(header.getValue());
1769     return contentDisposition.getParameter("filename");
1770   }
1771 
1772   private URI addContentToRepo(MediaPackage mp, String elementId, String filename, InputStream file)
1773           throws IOException {
1774     ProgressInputStream progressInputStream = new ProgressInputStream(file);
1775     return workingFileRepository.put(mp.getIdentifier().toString(), elementId, filename, progressInputStream);
1776   }
1777 
1778   private MediaPackage addContentToMediaPackage(MediaPackage mp, String elementId, URI uri,
1779           MediaPackageElement.Type type, MediaPackageElementFlavor flavor) {
1780     logger.info("Adding element of type {} to mediapackage {}", type, mp);
1781     MediaPackageElement mpe = mp.add(uri, type, flavor);
1782     mpe.setIdentifier(elementId);
1783     return mp;
1784   }
1785 
1786   // ---------------------------------------------
1787   // --------- bind and unbind bundles ---------
1788   // ---------------------------------------------
1789   @Reference
1790   public void setWorkflowService(WorkflowService workflowService) {
1791     this.workflowService = workflowService;
1792   }
1793 
1794   @Reference
1795   public void setWorkingFileRepository(WorkingFileRepository workingFileRepository) {
1796     this.workingFileRepository = workingFileRepository;
1797   }
1798 
1799   @Reference
1800   public void setSeriesService(SeriesService seriesService) {
1801     this.seriesService = seriesService;
1802   }
1803 
1804   @Reference
1805   public void setDublinCoreService(DublinCoreCatalogService dublinCoreService) {
1806     this.dublinCoreService = dublinCoreService;
1807   }
1808 
1809   /**
1810    * {@inheritDoc}
1811    *
1812    * @see org.opencastproject.job.api.AbstractJobProducer#getServiceRegistry()
1813    */
1814   @Override
1815   protected ServiceRegistry getServiceRegistry() {
1816     return serviceRegistry;
1817   }
1818 
1819   /**
1820    * {@inheritDoc}
1821    *
1822    * @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job)
1823    */
1824   @Override
1825   protected String process(Job job) throws Exception {
1826     throw new IllegalStateException("Ingest jobs are not expected to be dispatched");
1827   }
1828 
1829   /**
1830    * Callback for setting the security service.
1831    *
1832    * @param securityService
1833    *          the securityService to set
1834    */
1835   @Reference
1836   public void setSecurityService(SecurityService securityService) {
1837     this.securityService = securityService;
1838   }
1839 
1840   /**
1841    * Callback for setting the user directory service.
1842    *
1843    * @param userDirectoryService
1844    *          the userDirectoryService to set
1845    */
1846   @Reference
1847   public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
1848     this.userDirectoryService = userDirectoryService;
1849   }
1850 
1851   /**
1852    * Callback for setting the scheduler service.
1853    *
1854    * @param schedulerService
1855    *          the scheduler service to set
1856    */
1857   @Reference(
1858       policy = ReferencePolicy.DYNAMIC,
1859       cardinality = ReferenceCardinality.OPTIONAL,
1860       unbind = "unsetSchedulerService"
1861   )
1862   public void setSchedulerService(SchedulerService schedulerService) {
1863     this.schedulerService = schedulerService;
1864   }
1865 
1866   public void unsetSchedulerService(SchedulerService schedulerService) {
1867     if (this.schedulerService == schedulerService) {
1868       this.schedulerService = null;
1869     }
1870   }
1871 
1872   /**
1873    * Sets a reference to the organization directory service.
1874    *
1875    * @param organizationDirectory
1876    *          the organization directory
1877    */
1878   @Reference
1879   public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectory) {
1880     organizationDirectoryService = organizationDirectory;
1881   }
1882 
1883   /**
1884    * {@inheritDoc}
1885    *
1886    * @see org.opencastproject.job.api.AbstractJobProducer#getSecurityService()
1887    */
1888   @Override
1889   protected SecurityService getSecurityService() {
1890     return securityService;
1891   }
1892 
1893   /**
1894    * {@inheritDoc}
1895    *
1896    * @see org.opencastproject.job.api.AbstractJobProducer#getUserDirectoryService()
1897    */
1898   @Override
1899   protected UserDirectoryService getUserDirectoryService() {
1900     return userDirectoryService;
1901   }
1902 
1903   /**
1904    * {@inheritDoc}
1905    *
1906    * @see org.opencastproject.job.api.AbstractJobProducer#getOrganizationDirectoryService()
1907    */
1908   @Override
1909   protected OrganizationDirectoryService getOrganizationDirectoryService() {
1910     return organizationDirectoryService;
1911   }
1912 
1913   protected CloseableHttpClient getAuthedHttpClient() {
1914     HttpClientBuilder cb = HttpClientBuilder.create();
1915     CredentialsProvider provider = new BasicCredentialsProvider();
1916     String schema = AuthSchemes.DIGEST;
1917     if ("Basic".equals(downloadAuthMethod)) {
1918       schema = AuthSchemes.BASIC;
1919     }
1920     provider.setCredentials(
1921         new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT, AuthScope.ANY_REALM, schema),
1922         new UsernamePasswordCredentials(downloadUser, downloadPassword));
1923     return cb.setDefaultCredentialsProvider(provider).build();
1924   }
1925 
1926   private MediaPackage createSmil(MediaPackage mediaPackage) throws IOException, IngestException {
1927     List<Track> partialTracks = new ArrayList<>();
1928     for (Track track : mediaPackage.getTracks()) {
1929       Long startTime = partialTrackStartTimes.getIfPresent(track.getIdentifier());
1930       if (startTime != null) {
1931         partialTracks.add(track);
1932       }
1933     }
1934 
1935     // No partial track available return without adding SMIL catalog
1936     if (partialTracks.isEmpty()) {
1937       return mediaPackage;
1938     }
1939 
1940     // Inspect the partial tracks
1941     List<Track> tracks = partialTracks.stream()
1942         .map(track -> {
1943           try {
1944             // Create a media inspection job for a mediapackage element.
1945             return mediaInspectionService.enrich(track, true);
1946           } catch (Exception e) {
1947             throw new RuntimeException("Error enriching track", e);
1948           }
1949         })
1950         .map(job -> {
1951           try {
1952             // Interpret the payload of a completed Job as a MediaPackageElement.
1953             // Wait for the job to complete if necessary
1954             waitForJob(getServiceRegistry(), Optional.empty(), job);
1955             return (Track) MediaPackageElementParser.getFromXml(job.getPayload());
1956           } catch (Exception e) {
1957             throw new RuntimeException("Error parsing job payload as track", e);
1958           }
1959         })
1960         .peek(MediaPackageSupport.updateElement(mediaPackage))
1961         .collect(Collectors.toList());
1962 
1963     // Create the SMIL document
1964     org.w3c.dom.Document smilDocument = SmilUtil.createSmil();
1965     for (Track track : tracks) {
1966       Long startTime = partialTrackStartTimes.getIfPresent(track.getIdentifier());
1967       if (startTime == null) {
1968         logger.error("No start time found for track {}", track);
1969         throw new IngestException("No start time found for track " + track.getIdentifier());
1970       }
1971       smilDocument = addSmilTrack(smilDocument, track, startTime);
1972       partialTrackStartTimes.invalidate(track.getIdentifier());
1973     }
1974 
1975     // Store the SMIL document in the mediapackage
1976     return addSmilCatalog(smilDocument, mediaPackage);
1977   }
1978 
1979   /**
1980    * Adds a SMIL catalog to a mediapackage if it's not already existing.
1981    *
1982    * @param smilDocument
1983    *          the smil document
1984    * @param mediaPackage
1985    *          the mediapackage to extend with the SMIL catalog
1986    * @return the augmented mediapcakge
1987    * @throws IOException
1988    *           if reading or writing of the SMIL catalog fails
1989    * @throws IngestException
1990    *           if the SMIL catalog already exists
1991    */
1992   private MediaPackage addSmilCatalog(org.w3c.dom.Document smilDocument, MediaPackage mediaPackage)
1993           throws IOException, IngestException {
1994     Optional<org.w3c.dom.Document> optSmilDocument = loadSmilDocument(workingFileRepository, mediaPackage);
1995     if (optSmilDocument.isPresent()) {
1996       throw new IngestException("SMIL already exists!");
1997     }
1998 
1999     InputStream in = null;
2000     try {
2001       in = XmlUtil.serializeDocument(smilDocument);
2002       String elementId = UUID.randomUUID().toString();
2003       URI uri = workingFileRepository.put(mediaPackage.getIdentifier().toString(), elementId, PARTIAL_SMIL_NAME, in);
2004       MediaPackageElement mpe = mediaPackage.add(uri, MediaPackageElement.Type.Catalog, MediaPackageElements.SMIL);
2005       mpe.setIdentifier(elementId);
2006       // Reset the checksum since it changed
2007       mpe.setChecksum(null);
2008       mpe.setMimeType(MimeTypes.SMIL);
2009       return mediaPackage;
2010     } finally {
2011       IoSupport.closeQuietly(in);
2012     }
2013   }
2014 
2015   /**
2016    * Load a SMIL document of a media package.
2017    *
2018    * @return the document or none if no media package element found.
2019    */
2020   private Optional<org.w3c.dom.Document> loadSmilDocument(final WorkingFileRepository workingFileRepository,
2021           MediaPackage mp) {
2022     return Arrays.stream(mp.getElements())
2023         .filter(MediaPackageSupport.Filters::isSmilCatalog)
2024         .findFirst()
2025         .map(mpe -> {
2026           try (InputStream in = workingFileRepository.get(
2027               mpe.getMediaPackage().getIdentifier().toString(),
2028               mpe.getIdentifier())) {
2029             return SmilUtil.loadSmilDocument(in, mpe);
2030           } catch (Exception e) {
2031             logger.warn("Unable to load smil document from catalog '{}'", mpe, e);
2032             return Misc.chuck(e);
2033           }
2034         });
2035   }
2036 
2037   /**
2038    * Adds a SMIL track by a mediapackage track to a SMIL document
2039    *
2040    * @param smilDocument
2041    *          the SMIL document to extend
2042    * @param track
2043    *          the mediapackage track
2044    * @param startTime
2045    *          the start time
2046    * @return the augmented SMIL document
2047    * @throws IngestException
2048    *           if the partial flavor type is not valid
2049    */
2050   private org.w3c.dom.Document addSmilTrack(org.w3c.dom.Document smilDocument, Track track, long startTime)
2051           throws IngestException {
2052     if (MediaPackageElements.PRESENTER_SOURCE.getType().equals(track.getFlavor().getType())) {
2053       return SmilUtil.addTrack(smilDocument, SmilUtil.TrackType.PRESENTER, track.hasVideo(), startTime,
2054               track.getDuration(), track.getURI(), track.getIdentifier());
2055     } else if (MediaPackageElements.PRESENTATION_SOURCE.getType().equals(track.getFlavor().getType())) {
2056       return SmilUtil.addTrack(smilDocument, SmilUtil.TrackType.PRESENTATION, track.hasVideo(), startTime,
2057               track.getDuration(), track.getURI(), track.getIdentifier());
2058     } else {
2059       logger.warn("Invalid partial flavor type {} of track {}", track.getFlavor(), track);
2060       throw new IngestException(
2061               "Invalid partial flavor type " + track.getFlavor().getType() + " of track " + track.getURI().toString());
2062     }
2063   }
2064 
2065   private MediaPackage checkForCASeries(MediaPackage mp, String seriesAppendName) {
2066     //Check for media package id and CA series appendix set
2067     if (mp == null || seriesAppendName == null) {
2068       logger.debug("No series name provided");
2069       return mp;
2070     }
2071 
2072     // Verify user is a CA by checking roles and captureAgentId
2073     User user = securityService.getUser();
2074     if (!user.hasRole(GLOBAL_ADMIN_ROLE) && !user.hasRole(GLOBAL_CAPTURE_AGENT_ROLE)) {
2075       logger.info("User '{}' is missing capture agent roles, won't apply CASeries", user.getUsername());
2076       return mp;
2077     }
2078     //Get capture agent name
2079     String captureAgentId = null;
2080     Catalog[] catalog = mp.getCatalogs(MediaPackageElementFlavor.flavor("dublincore", "episode"));
2081     if (catalog.length == 1) {
2082       try (InputStream catalogInputStream = workingFileRepository.get(mp.getIdentifier().toString(),
2083           catalog[0].getIdentifier())) {
2084         DublinCoreCatalog dc = dublinCoreService.load(catalogInputStream);
2085         captureAgentId = getCaptureAgent(dc);
2086       } catch (Exception e) {
2087         logger.info("Unable to determine capture agent name");
2088       }
2089     }
2090     if (captureAgentId == null) {
2091       logger.info("No Capture Agent ID defined for MediaPackage {}, won't apply CASeries", mp.getIdentifier());
2092       return mp;
2093     }
2094     logger.info("Applying CASeries to MediaPackage {} for capture agent '{}'", mp.getIdentifier(), captureAgentId);
2095 
2096     // Find or create CA series
2097     String seriesId = captureAgentId.replaceAll("[^\\w-_.:;()]+", "_");
2098     String seriesName = captureAgentId + seriesAppendName;
2099 
2100     try {
2101       seriesService.getSeries(seriesId);
2102     } catch (NotFoundException nfe) {
2103       try {
2104         List<String> roleNames = new ArrayList<>();
2105         String roleName = SecurityUtil.getCaptureAgentRole(captureAgentId);
2106         roleNames.add(roleName);
2107         logger.debug("Capture agent role name: {}", roleName);
2108 
2109         String username = user.getUsername();
2110         roleNames.add(UserIdRoleProvider.getUserIdRole(username));
2111 
2112         logger.info("Creating new series for capture agent '{}' and user '{}'", captureAgentId, username);
2113         createSeries(seriesId, seriesName, roleNames);
2114       } catch (Exception e) {
2115         logger.error("Unable to create series {} for event {}", seriesName, mp, e);
2116         return mp;
2117       }
2118     } catch (SeriesException | UnauthorizedException e) {
2119       logger.error("Exception while searching for series {}", seriesName, e);
2120       return mp;
2121     }
2122 
2123     // Add the event to CA series
2124     mp.setSeries(seriesId);
2125     mp.setSeriesTitle(seriesName);
2126 
2127     return mp;
2128   }
2129 
2130   private DublinCoreCatalog createSeries(String seriesId, String seriesName, List<String> roleNames)
2131           throws SeriesException, UnauthorizedException, NotFoundException {
2132     DublinCoreCatalog dc = DublinCores.mkOpencastSeries().getCatalog();
2133     dc.set(PROPERTY_IDENTIFIER, seriesId);
2134     dc.set(PROPERTY_TITLE, seriesName);
2135     dc.set(DublinCore.PROPERTY_CREATED, EncodingSchemeUtils.encodeDate(new Date(), Precision.Second));
2136     // set series name
2137     DublinCoreCatalog createdSeries = seriesService.updateSeries(dc);
2138 
2139     // fill acl
2140     List<AccessControlEntry> aces = new ArrayList();
2141     for (String roleName : roleNames) {
2142       AccessControlEntry aceRead = new AccessControlEntry(roleName, Permissions.Action.READ.toString(), true);
2143       AccessControlEntry aceWrite = new AccessControlEntry(roleName, Permissions.Action.WRITE.toString(), true);
2144       aces.add(aceRead);
2145       aces.add(aceWrite);
2146     }
2147     AccessControlList acl = new AccessControlList(aces);
2148     seriesService.updateAccessControl(seriesId, acl);
2149     logger.info("Created capture agent series with name {} and id {}", seriesName, seriesId);
2150 
2151     return dc;
2152   }
2153 }