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  package org.opencastproject.external.endpoint;
22  
23  import static com.entwinemedia.fn.Stream.$;
24  import static com.entwinemedia.fn.data.json.Jsons.BLANK;
25  import static com.entwinemedia.fn.data.json.Jsons.NULL;
26  import static com.entwinemedia.fn.data.json.Jsons.arr;
27  import static com.entwinemedia.fn.data.json.Jsons.f;
28  import static com.entwinemedia.fn.data.json.Jsons.obj;
29  import static com.entwinemedia.fn.data.json.Jsons.v;
30  import static org.apache.commons.lang3.StringUtils.trimToNull;
31  import static org.opencastproject.external.common.ApiVersion.VERSION_1_11_0;
32  import static org.opencastproject.external.common.ApiVersion.VERSION_1_1_0;
33  import static org.opencastproject.external.common.ApiVersion.VERSION_1_4_0;
34  import static org.opencastproject.external.common.ApiVersion.VERSION_1_7_0;
35  import static org.opencastproject.external.util.SchedulingUtils.SchedulingInfo;
36  import static org.opencastproject.external.util.SchedulingUtils.convertConflictingEvents;
37  import static org.opencastproject.external.util.SchedulingUtils.getConflictingEvents;
38  import static org.opencastproject.util.RestUtil.getEndpointUrl;
39  import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
40  
41  import org.opencastproject.assetmanager.api.AssetManager;
42  import org.opencastproject.assetmanager.api.AssetManagerException;
43  import org.opencastproject.capture.CaptureParameters;
44  import org.opencastproject.capture.admin.api.CaptureAgentStateService;
45  import org.opencastproject.elasticsearch.api.SearchIndexException;
46  import org.opencastproject.elasticsearch.api.SearchResult;
47  import org.opencastproject.elasticsearch.api.SearchResultItem;
48  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
49  import org.opencastproject.elasticsearch.index.QueryPreprocessor;
50  import org.opencastproject.elasticsearch.index.objects.IndexObject;
51  import org.opencastproject.elasticsearch.index.objects.event.Event;
52  import org.opencastproject.elasticsearch.index.objects.event.EventIndexSchema;
53  import org.opencastproject.elasticsearch.index.objects.event.EventSearchQuery;
54  import org.opencastproject.external.common.ApiMediaType;
55  import org.opencastproject.external.common.ApiResponseBuilder;
56  import org.opencastproject.external.common.ApiVersion;
57  import org.opencastproject.external.util.AclUtils;
58  import org.opencastproject.external.util.ExternalMetadataUtils;
59  import org.opencastproject.index.service.api.IndexService;
60  import org.opencastproject.index.service.catalog.adapter.DublinCoreMetadataUtil;
61  import org.opencastproject.index.service.exception.IndexServiceException;
62  import org.opencastproject.index.service.impl.util.EventHttpServletRequest;
63  import org.opencastproject.index.service.impl.util.EventUtils;
64  import org.opencastproject.index.service.util.RequestUtils;
65  import org.opencastproject.index.service.util.RestUtils;
66  import org.opencastproject.ingest.api.IngestException;
67  import org.opencastproject.ingest.api.IngestService;
68  import org.opencastproject.list.impl.EmptyResourceListQuery;
69  import org.opencastproject.mediapackage.Attachment;
70  import org.opencastproject.mediapackage.AudioStream;
71  import org.opencastproject.mediapackage.Catalog;
72  import org.opencastproject.mediapackage.MediaPackage;
73  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
74  import org.opencastproject.mediapackage.MediaPackageException;
75  import org.opencastproject.mediapackage.Publication;
76  import org.opencastproject.mediapackage.Stream;
77  import org.opencastproject.mediapackage.Track;
78  import org.opencastproject.mediapackage.TrackSupport;
79  import org.opencastproject.mediapackage.VideoStream;
80  import org.opencastproject.mediapackage.track.TrackImpl;
81  import org.opencastproject.metadata.dublincore.DublinCore;
82  import org.opencastproject.metadata.dublincore.DublinCoreMetadataCollection;
83  import org.opencastproject.metadata.dublincore.EventCatalogUIAdapter;
84  import org.opencastproject.metadata.dublincore.MetadataField;
85  import org.opencastproject.metadata.dublincore.MetadataJson;
86  import org.opencastproject.metadata.dublincore.MetadataList;
87  import org.opencastproject.metadata.dublincore.MetadataList.Locked;
88  import org.opencastproject.rest.RestConstants;
89  import org.opencastproject.scheduler.api.SchedulerConflictException;
90  import org.opencastproject.scheduler.api.SchedulerException;
91  import org.opencastproject.scheduler.api.SchedulerService;
92  import org.opencastproject.scheduler.api.TechnicalMetadata;
93  import org.opencastproject.security.api.AccessControlEntry;
94  import org.opencastproject.security.api.AccessControlList;
95  import org.opencastproject.security.api.AccessControlParser;
96  import org.opencastproject.security.api.Permissions;
97  import org.opencastproject.security.api.SecurityService;
98  import org.opencastproject.security.api.UnauthorizedException;
99  import org.opencastproject.security.urlsigning.exception.UrlSigningException;
100 import org.opencastproject.security.urlsigning.service.UrlSigningService;
101 import org.opencastproject.systems.OpencastConstants;
102 import org.opencastproject.util.DateTimeSupport;
103 import org.opencastproject.util.NotFoundException;
104 import org.opencastproject.util.RestUtil;
105 import org.opencastproject.util.RestUtil.R;
106 import org.opencastproject.util.UrlSupport;
107 import org.opencastproject.util.data.Option;
108 import org.opencastproject.util.data.Tuple;
109 import org.opencastproject.util.doc.rest.RestParameter;
110 import org.opencastproject.util.doc.rest.RestParameter.Type;
111 import org.opencastproject.util.doc.rest.RestQuery;
112 import org.opencastproject.util.doc.rest.RestResponse;
113 import org.opencastproject.util.doc.rest.RestService;
114 import org.opencastproject.util.requests.SortCriterion;
115 import org.opencastproject.workflow.api.WorkflowDatabaseException;
116 import org.opencastproject.workflow.api.WorkflowInstance;
117 import org.opencastproject.workflow.api.WorkflowService;
118 
119 import com.entwinemedia.fn.Fn;
120 import com.entwinemedia.fn.data.Opt;
121 import com.entwinemedia.fn.data.json.Field;
122 import com.entwinemedia.fn.data.json.JObject;
123 import com.entwinemedia.fn.data.json.JValue;
124 import com.entwinemedia.fn.data.json.Jsons;
125 import com.entwinemedia.fn.data.json.Jsons.Functions;
126 
127 import org.apache.commons.fileupload.FileItemIterator;
128 import org.apache.commons.fileupload.FileItemStream;
129 import org.apache.commons.fileupload.FileUploadException;
130 import org.apache.commons.fileupload.servlet.ServletFileUpload;
131 import org.apache.commons.fileupload.util.Streams;
132 import org.apache.commons.lang3.StringUtils;
133 import org.joda.time.DateTime;
134 import org.joda.time.DateTimeZone;
135 import org.json.simple.JSONArray;
136 import org.json.simple.JSONObject;
137 import org.json.simple.parser.JSONParser;
138 import org.json.simple.parser.ParseException;
139 import org.osgi.service.cm.ConfigurationException;
140 import org.osgi.service.cm.ManagedService;
141 import org.osgi.service.component.ComponentContext;
142 import org.osgi.service.component.annotations.Activate;
143 import org.osgi.service.component.annotations.Component;
144 import org.osgi.service.component.annotations.Reference;
145 import org.osgi.service.component.annotations.ReferenceCardinality;
146 import org.osgi.service.component.annotations.ReferencePolicy;
147 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
148 import org.slf4j.Logger;
149 import org.slf4j.LoggerFactory;
150 
151 import java.io.IOException;
152 import java.net.URI;
153 import java.text.SimpleDateFormat;
154 import java.time.format.DateTimeParseException;
155 import java.util.ArrayList;
156 import java.util.Arrays;
157 import java.util.Date;
158 import java.util.Dictionary;
159 import java.util.HashMap;
160 import java.util.Hashtable;
161 import java.util.List;
162 import java.util.Map;
163 import java.util.Objects;
164 import java.util.Optional;
165 import java.util.TreeMap;
166 import java.util.concurrent.ConcurrentHashMap;
167 import java.util.concurrent.CopyOnWriteArrayList;
168 import java.util.stream.Collectors;
169 
170 import javax.servlet.http.HttpServletRequest;
171 import javax.servlet.http.HttpServletResponse;
172 import javax.ws.rs.Consumes;
173 import javax.ws.rs.DELETE;
174 import javax.ws.rs.DefaultValue;
175 import javax.ws.rs.FormParam;
176 import javax.ws.rs.GET;
177 import javax.ws.rs.HeaderParam;
178 import javax.ws.rs.POST;
179 import javax.ws.rs.PUT;
180 import javax.ws.rs.Path;
181 import javax.ws.rs.PathParam;
182 import javax.ws.rs.Produces;
183 import javax.ws.rs.QueryParam;
184 import javax.ws.rs.WebApplicationException;
185 import javax.ws.rs.core.Context;
186 import javax.ws.rs.core.MediaType;
187 import javax.ws.rs.core.Response;
188 import javax.ws.rs.core.Response.Status;
189 
190 import io.swagger.v3.oas.annotations.Operation;
191 import io.swagger.v3.oas.annotations.Parameter;
192 import io.swagger.v3.oas.annotations.Parameters;
193 import io.swagger.v3.oas.annotations.enums.ParameterIn;
194 import io.swagger.v3.oas.annotations.responses.ApiResponse;
195 import io.swagger.v3.oas.annotations.responses.ApiResponses;
196 import io.swagger.v3.oas.annotations.tags.Tag;
197 
198 @Path("/api/events")
199 @Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_0_0, ApiMediaType.VERSION_1_1_0, ApiMediaType.VERSION_1_2_0,
200             ApiMediaType.VERSION_1_3_0, ApiMediaType.VERSION_1_4_0, ApiMediaType.VERSION_1_5_0,
201             ApiMediaType.VERSION_1_6_0, ApiMediaType.VERSION_1_7_0, ApiMediaType.VERSION_1_8_0,
202             ApiMediaType.VERSION_1_9_0, ApiMediaType.VERSION_1_10_0, ApiMediaType.VERSION_1_11_0 })
203 @RestService(name = "externalapievents", title = "External API Events Service", notes = {},
204              abstractText = "Provides resources and operations related to the events")
205 @Tag(name = "External API")
206 @Tag(name = "External API - Events",
207     description = "The events endpoint provides resources and operations related to the events")
208 @Component(
209     immediate = true,
210     service = { EventsEndpoint.class,ManagedService.class },
211     property = {
212         "service.description=External API - Events Endpoint",
213         "opencast.service.type=org.opencastproject.external.events",
214         "opencast.service.path=/api/events"
215     }
216 )
217 @JaxrsResource
218 public class EventsEndpoint implements ManagedService {
219 
220   protected static final String URL_SIGNING_EXPIRES_DURATION_SECONDS_KEY = "url.signing.expires.seconds";
221 
222   /** The default time before a piece of signed content expires. 2 Hours. */
223   protected static final Long DEFAULT_URL_SIGNING_EXPIRE_DURATION = 2 * 60 * 60L;
224 
225   /** Subtype of previews required by the video editor */
226   private static final String PREVIEW_SUBTYPE = "preview.subtype";
227 
228   /** Subtype of previews required by the video editor */
229   private static final String DEFAULT_PREVIEW_SUBTYPE = "preview";
230 
231   /** ID of the workflow used to retract published events */
232   private static final String RETRACT_WORKFLOW = "retract.workflow.id";
233 
234   /** Default ID of the workflow used to retract published events */
235   private static final String DEFAULT_RETRACT_WORKFLOW = "delete";
236 
237   /** The logging facility */
238   private static final Logger logger = LoggerFactory.getLogger(EventsEndpoint.class);
239 
240   /** Base URL of this endpoint */
241   protected String endpointBaseUrl;
242 
243   private static long expireSeconds = DEFAULT_URL_SIGNING_EXPIRE_DURATION;
244 
245   private String previewSubtype = DEFAULT_PREVIEW_SUBTYPE;
246 
247   private Map<String, MetadataField> configuredMetadataFields = new TreeMap<>();
248 
249   private String retractWorkflowId = DEFAULT_RETRACT_WORKFLOW;
250 
251   /** The resolutions */
252   private enum CommentResolution {
253     ALL, UNRESOLVED, RESOLVED;
254   };
255 
256   /* OSGi service references */
257   private AssetManager assetManager;
258   private ElasticsearchIndex elasticsearchIndex;
259   private IndexService indexService;
260   private IngestService ingestService;
261   private SecurityService securityService;
262   private final List<EventCatalogUIAdapter> catalogUIAdapters = new CopyOnWriteArrayList<>();
263   private final Map<String, List<EventCatalogUIAdapter>> orgCatalogUIAdaptersMap = new ConcurrentHashMap<>();
264   private UrlSigningService urlSigningService;
265   private SchedulerService schedulerService;
266   private CaptureAgentStateService agentStateService;
267   private WorkflowService workflowService;
268 
269   /** OSGi DI */
270   @Reference
271   public void setAssetManager(AssetManager assetManager) {
272     this.assetManager = assetManager;
273   }
274 
275   /** OSGi DI */
276   @Reference
277   void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
278     this.elasticsearchIndex = elasticsearchIndex;
279   }
280 
281   /** OSGi DI */
282   @Reference
283   public void setIndexService(IndexService indexService) {
284     this.indexService = indexService;
285   }
286 
287   /** OSGi DI */
288   @Reference
289   public void setIngestService(IngestService ingestService) {
290     this.ingestService = ingestService;
291   }
292 
293   /** OSGi DI */
294   @Reference
295   void setSecurityService(SecurityService securityService) {
296     this.securityService = securityService;
297   }
298 
299   /** OSGi DI */
300   @Reference
301   public void setUrlSigningService(UrlSigningService urlSigningService) {
302     this.urlSigningService = urlSigningService;
303   }
304 
305   public SecurityService getSecurityService() {
306     return securityService;
307   }
308 
309   public SchedulerService getSchedulerService() {
310     return schedulerService;
311   }
312 
313   @Reference
314   public void setSchedulerService(SchedulerService schedulerService) {
315     this.schedulerService = schedulerService;
316   }
317 
318   /** OSGi DI. */
319   @Reference(
320       cardinality = ReferenceCardinality.MULTIPLE,
321       policy = ReferencePolicy.DYNAMIC,
322       unbind = "removeCatalogUIAdapter"
323   )
324   public void addCatalogUIAdapter(EventCatalogUIAdapter catalogUIAdapter) {
325     catalogUIAdapters.add(catalogUIAdapter);
326     invalidateOrgCatalogUIAdaptersMapFor(catalogUIAdapter);
327   }
328 
329   /** OSGi DI. */
330   public void removeCatalogUIAdapter(EventCatalogUIAdapter catalogUIAdapter) {
331     catalogUIAdapters.remove(catalogUIAdapter);
332     invalidateOrgCatalogUIAdaptersMapFor(catalogUIAdapter);
333   }
334 
335   /**
336    * Invalidates caches for organizations that are handled by given catalog.
337    *
338    * @param catalogUIAdapter catalog used to identify affected organizations.
339    */
340   private void invalidateOrgCatalogUIAdaptersMapFor(EventCatalogUIAdapter catalogUIAdapter) {
341     // clean cached org to catalog map
342     for (String orgName : orgCatalogUIAdaptersMap.keySet()) {
343       if (catalogUIAdapter.handlesOrganization(orgName)) {
344         orgCatalogUIAdaptersMap.remove(orgName);
345       }
346     }
347   }
348 
349   /** OSGi DI */
350   public CaptureAgentStateService getAgentStateService() {
351     return agentStateService;
352   }
353 
354   /** OSGi DI */
355   @Reference
356   public void setAgentStateService(CaptureAgentStateService agentStateService) {
357     this.agentStateService = agentStateService;
358   }
359 
360   /** OSGi DI */
361   @Reference
362   public void setWorkflowService(WorkflowService workflowService) {
363     this.workflowService = workflowService;
364   }
365 
366 
367   private List<EventCatalogUIAdapter> getEventCatalogUIAdapters() {
368     return getEventCatalogUIAdapters(getSecurityService().getOrganization().getId());
369   }
370 
371   public List<EventCatalogUIAdapter> getEventCatalogUIAdapters(String organization) {
372     List<EventCatalogUIAdapter> cachedCatalogUIAdapters = orgCatalogUIAdaptersMap.computeIfAbsent(organization,
373         org -> new ArrayList<>(catalogUIAdapters.stream()
374             .filter(a -> a.handlesOrganization(org))
375             .collect(Collectors.toList())));
376     // create a shallow copy as callers may change it
377     return new ArrayList<>(cachedCatalogUIAdapters);
378   }
379 
380   /** OSGi activation method */
381   @Activate
382   void activate(ComponentContext cc) {
383     logger.info("Activating External API - Events Endpoint");
384 
385     final Tuple<String, String> endpointUrl = getEndpointUrl(cc, OpencastConstants.EXTERNAL_API_URL_ORG_PROPERTY,
386             RestConstants.SERVICE_PATH_PROPERTY);
387     endpointBaseUrl = UrlSupport.concat(endpointUrl.getA(), endpointUrl.getB());
388     logger.debug("Configured service endpoint is {}", endpointBaseUrl);
389   }
390 
391   /** OSGi callback if properties file is present */
392   @Override
393   public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
394     // Ensure properties is not null
395     if (properties == null) {
396       properties = new Hashtable<>();
397       logger.debug("No configuration set");
398     }
399 
400     // Read URL Signing Expiration duration
401     // Default to DEFAULT_URL_SIGNING_EXPIRE_DURATION.toString()));
402     expireSeconds = Long.parseLong(Objects.toString(
403         properties.get(URL_SIGNING_EXPIRES_DURATION_SECONDS_KEY),
404         DEFAULT_URL_SIGNING_EXPIRE_DURATION.toString()));
405     logger.debug("URLs signatures are configured to expire in {}.", DateTimeSupport.humanReadableTime(expireSeconds));
406 
407     // Read preview subtype configuration
408     // Default to DEFAULT_PREVIEW_SUBTYPE
409     previewSubtype = StringUtils.defaultString((String) properties.get(PREVIEW_SUBTYPE), DEFAULT_PREVIEW_SUBTYPE);
410     logger.debug("Preview subtype is '{}'", previewSubtype);
411 
412     configuredMetadataFields = DublinCoreMetadataUtil.getDublinCoreProperties(properties);
413 
414     retractWorkflowId = StringUtils.defaultString((String) properties.get(RETRACT_WORKFLOW), DEFAULT_RETRACT_WORKFLOW);
415     logger.debug("Retract Workflow is '{}'", retractWorkflowId);
416   }
417 
418   public static <T> boolean isNullOrEmpty(List<String> list) {
419     return list == null || list.isEmpty();
420   }
421 
422   @GET
423   @Path("{eventId}")
424   @RestQuery(name = "getevent", description = "Returns a single event. By setting the optional sign parameter to true, the method will pre-sign distribution urls if signing is turned on in Opencast. Remember to consider the maximum validity of signed URLs when caching this response.", returnDescription = "", pathParameters = {
425           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) }, restParameters = {
426                   @RestParameter(name = "sign", isRequired = false, description = "Whether public distribution urls should be signed.", type = Type.BOOLEAN),
427                   @RestParameter(name = "withacl", isRequired = false, description = "Whether the acl metadata should be included in the response.", type = Type.BOOLEAN),
428                   @RestParameter(name = "withmetadata", isRequired = false, description = "Whether the metadata catalogs should be included in the response.", type = Type.BOOLEAN),
429                   @RestParameter(name = "withscheduling", isRequired = false, description = "Whether the scheduling information should be included in the response.", type = Type.BOOLEAN),
430                   @RestParameter(name = "withpublications", isRequired = false, description = "Whether the publication ids and urls should be included in the response.", type = Type.BOOLEAN),
431                   @RestParameter(name = "includeInternalPublication", isRequired = false, description = "Whether internal publications should be included.", type = Type.BOOLEAN)}, responses = {
432                           @RestResponse(description = "The event is returned.", responseCode = HttpServletResponse.SC_OK),
433                           @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
434   @Operation(summary = "Get a single event", description = "Returns a single event. By setting the optional sign parameter to true, the method will pre-sign distribution urls if signing is turned on in Opencast. Remember to consider the maximum validity of signed URLs when caching this response.")
435   public Response getEvent(
436       @HeaderParam("Accept") String acceptHeader,
437       @Parameter(description = "The event id", required = true)
438       @PathParam("eventId") String id,
439       @Parameter(description = "Whether public distribution urls should be signed.")
440       @QueryParam("sign") boolean sign,
441       @Parameter(description = "Whether the acl metadata should be included in the response.")
442       @QueryParam("withacl") Boolean withAcl,
443       @Parameter(description = "Whether the metadata catalogs should be included in the response.")
444       @QueryParam("withmetadata") Boolean withMetadata,
445       @Parameter(description = "Whether the scheduling information should be included in the response.")
446       @QueryParam("withscheduling") Boolean withScheduling,
447       @Parameter(description = "Whether the publication ids and urls should be included in the response.")
448       @QueryParam("withpublications") Boolean withPublications,
449       @Parameter(description = "Whether internal publications should be included.")
450       @QueryParam("includeInternalPublication") Boolean includeInternalPublication)
451         throws Exception {
452     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
453     if (requestedVersion.isSmallerThan(VERSION_1_1_0)) {
454       // withScheduling was added in version 1.1.0 and should be ignored for smaller versions
455       withScheduling = false;
456     }
457     for (final Event event : indexService.getEvent(id, elasticsearchIndex)) {
458       event.updatePreview(previewSubtype);
459       return ApiResponseBuilder.Json.ok(
460           requestedVersion, eventToJSON(event, withAcl, withMetadata, withScheduling, withPublications, includeInternalPublication, sign, requestedVersion));
461     }
462     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
463   }
464 
465   @GET
466   @Path("{eventId}/media")
467   @RestQuery(name = "geteventmedia", description = "Returns media tracks of specific single event.", returnDescription = "", pathParameters = {
468       @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) }, responses = {
469       @RestResponse(description = "The event's media is returned.", responseCode = HttpServletResponse.SC_OK),
470       @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
471   @Operation(summary = "Get media tracks of a single event", description = "Returns media tracks of specific single event.")
472   @Parameters({
473       @Parameter(name = "eventId", description = "The event id", required = true, in = ParameterIn.PATH),
474       @Parameter(name = "Accept", description = "The accept header", required = true, in = ParameterIn.HEADER)
475   })
476   @ApiResponses(value = {
477       @ApiResponse(responseCode = "200", description = "The event's media is returned."),
478       @ApiResponse(responseCode = "404", description = "The specified event does not exist.")
479   })
480   public Response getEventMedia(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id)
481           throws Exception {
482     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
483     ArrayList<TrackImpl> tracks = new ArrayList<>();
484 
485     for (final Event event : indexService.getEvent(id, elasticsearchIndex)) {
486       final MediaPackage mp = indexService.getEventMediapackage(event);
487       for (Track track : mp.getTracks()) {
488         if (track instanceof TrackImpl) {
489           tracks.add((TrackImpl) track);
490         }
491       }
492 
493       List<JValue> tracksJson = new ArrayList<>();
494       for (Track track : tracks) {
495         List<Field> fields = new ArrayList<>();
496         if (track.getChecksum() != null)
497           fields.add(f("checksum", v(track.getChecksum().toString())));
498         if (track.getDescription() != null)
499           fields.add(f("description", v(track.getDescription())));
500         if (track.getDuration() != null)
501           fields.add(f("duration", v(track.getDuration())));
502         if (track.getElementDescription() != null)
503           fields.add(f("element-description", v(track.getElementDescription())));
504         if (track.getFlavor() != null)
505           fields.add(f("flavor", v(track.getFlavor().toString())));
506         if (track.getIdentifier() != null)
507           fields.add(f("identifier", v(track.getIdentifier())));
508         if (track.getMimeType() != null)
509           fields.add(f("mimetype", v(track.getMimeType().toString())));
510         fields.add(f("size", v(track.getSize())));
511         if (!requestedVersion.isSmallerThan(VERSION_1_7_0)) {
512           fields.add(f("has_video", v(track.hasVideo())));
513           fields.add(f("has_audio", v(track.hasAudio())));
514           fields.add(f("is_master_playlist", v(track.isMaster())));
515           fields.add(f("is_live", v(track.isLive())));
516         }
517         if (track.getStreams() != null) {
518           List<Field> streams = new ArrayList<>();
519           for (Stream stream : track.getStreams()) {
520             streams.add(f(stream.getIdentifier(), getJsonStream(stream)));
521           }
522           fields.add(f("streams", obj(streams)));
523         }
524         if (track.getTags() != null) {
525           List<JValue> tags = new ArrayList<>();
526           for (String tag : track.getTags()) {
527             tags.add(v(tag));
528           }
529           fields.add(f("tags", arr(tags)));
530         }
531         if (track.getURI() != null)
532           fields.add(f("uri", v(track.getURI().toString())));
533         tracksJson.add(obj(fields));
534       }
535       return ApiResponseBuilder.Json.ok(acceptHeader, arr(tracksJson));
536     }
537     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
538   }
539 
540   @DELETE
541   @Path("{eventId}")
542   @RestQuery(name = "deleteevent", description = "Deletes an event.", returnDescription = "", pathParameters = {
543           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) }, responses = {
544           @RestResponse(description = "The event has been deleted.", responseCode = HttpServletResponse.SC_NO_CONTENT),
545           @RestResponse(description = "The retraction of publications has started.", responseCode = HttpServletResponse.SC_ACCEPTED),
546           @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
547   public Response deleteEvent(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id)
548           throws SearchIndexException, UnauthorizedException {
549     final Opt<Event> event = indexService.getEvent(id, elasticsearchIndex);
550     if (event.isNone()) {
551       return RestUtil.R.notFound(id);
552     }
553     final IndexService.EventRemovalResult result;
554     try {
555       result = indexService.removeEvent(event.get(), retractWorkflowId);
556     } catch (WorkflowDatabaseException e) {
557       logger.error("Workflow database is not reachable. This may be a temporary problem.");
558       return RestUtil.R.serverError();
559     } catch (NotFoundException e) {
560       logger.error("Configured retract workflow not found. Check your configuration.");
561       return RestUtil.R.serverError();
562     }
563     switch (result) {
564       case SUCCESS:
565         return Response.noContent().build();
566       case RETRACTING:
567         return Response.accepted().build();
568       case GENERAL_FAILURE:
569         return Response.serverError().build();
570       case NOT_FOUND:
571         return RestUtil.R.notFound(id);
572       default:
573         throw new RuntimeException("Unknown EventRemovalResult type: " + result.name());
574     }
575   }
576 
577   @POST
578   @Path("{eventId}")
579   @RestQuery(name = "updateeventmetadata", description = "Updates an event.", returnDescription = "", pathParameters = {
580           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) }, restParameters = {
581                   @RestParameter(name = "acl", isRequired = false, description = "A collection of roles with their possible action", type = Type.STRING),
582                   @RestParameter(name = "metadata", isRequired = false, description = "Event metadata as Form param", type = Type.STRING),
583                   @RestParameter(name = "scheduling", isRequired = false, description = "Scheduling information as Form param", type = Type.STRING),
584                   @RestParameter(name = "presenter", isRequired = false, description = "Presenter movie track", type = Type.FILE),
585                   @RestParameter(name = "presentation", isRequired = false, description = "Presentation movie track", type = Type.FILE),
586                   @RestParameter(name = "audio", isRequired = false, description = "Audio track", type = Type.FILE),
587                   @RestParameter(name = "processing", isRequired = false, description = "Processing instructions task configuration", type = Type.STRING), }, responses = {
588                           @RestResponse(description = "The event has been updated.", responseCode = HttpServletResponse.SC_NO_CONTENT),
589                           @RestResponse(description = "The event could not be updated due to a scheduling conflict.", responseCode = HttpServletResponse.SC_CONFLICT),
590                           @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
591   public Response updateEventMetadata(@HeaderParam("Accept") String acceptHeader, @Context HttpServletRequest request,
592           @PathParam("eventId") String eventId) {
593     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
594     try {
595       String startDatePattern = configuredMetadataFields.containsKey("startDate") ? configuredMetadataFields.get("startDate").getPattern() : null;
596       String startTimePattern = configuredMetadataFields.containsKey("startTime") ? configuredMetadataFields.get("startTime").getPattern() : null;
597       for (final Event event : indexService.getEvent(eventId, elasticsearchIndex)) {
598         EventHttpServletRequest eventHttpServletRequest = EventHttpServletRequest.updateFromHttpServletRequest(event,
599                 request, getEventCatalogUIAdapters(), startDatePattern, startTimePattern);
600 
601         // FIXME: All of these update operations should be a part of a transaction to avoid a partially updated event.
602         if (eventHttpServletRequest.getMetadataList().isSome()) {
603           indexService.updateEventMetadata(eventId, eventHttpServletRequest.getMetadataList().get(), elasticsearchIndex);
604         }
605 
606         if (eventHttpServletRequest.getAcl().isSome()) {
607           indexService.updateEventAcl(eventId, eventHttpServletRequest.getAcl().get(), elasticsearchIndex);
608         }
609 
610         if (eventHttpServletRequest.getProcessing().isSome()) {
611 
612           if (!event.isScheduledEvent() || event.hasRecordingStarted()) {
613             return RestUtil.R.badRequest("Processing can't be updated for events that are already uploaded.");
614           }
615           JSONObject processing = eventHttpServletRequest.getProcessing().get();
616 
617           String workflowId = (String) processing.get("workflow");
618           if (workflowId == null)
619             throw new IllegalArgumentException("No workflow template in metadata");
620 
621           Map<String, String> configuration = new HashMap<>();
622           if (eventHttpServletRequest.getProcessing().get().get("configuration") != null) {
623             configuration = new HashMap<>((JSONObject) eventHttpServletRequest.getProcessing().get().get("configuration"));
624           }
625 
626           Opt<Map<String, String>> caMetadataOpt = Opt.none();
627           Opt<Map<String, String>> workflowConfigOpt = Opt.none();
628 
629           Map<String, String> caMetadata = new HashMap<>(getSchedulerService().getCaptureAgentConfiguration(eventId));
630           if (!workflowId.equals(caMetadata.get(CaptureParameters.INGEST_WORKFLOW_DEFINITION))) {
631             caMetadata.put(CaptureParameters.INGEST_WORKFLOW_DEFINITION, workflowId);
632             caMetadataOpt = Opt.some(caMetadata);
633           }
634 
635           Map<String, String> oldWorkflowConfig = new HashMap<>(getSchedulerService().getWorkflowConfig(eventId));
636           if (!oldWorkflowConfig.equals(configuration))
637             workflowConfigOpt = Opt.some(configuration);
638 
639           if (!caMetadataOpt.isNone() || !workflowConfigOpt.isNone()) {
640             getSchedulerService().updateEvent(eventId, Opt.none(), Opt.none(), Opt.none(),
641                     Opt.none(), Opt.none(), workflowConfigOpt, caMetadataOpt);
642           }
643         }
644 
645         if (eventHttpServletRequest.getScheduling().isSome() && !requestedVersion.isSmallerThan(VERSION_1_1_0)) {
646           // Scheduling is only available for version 1.1.0 and above
647           Optional<Response> clientError = updateSchedulingInformation(
648               eventHttpServletRequest.getScheduling().get(), eventId, requestedVersion, false);
649           if (clientError.isPresent()) {
650             return clientError.get();
651           }
652         }
653 
654         return Response.noContent().build();
655       }
656       return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", eventId);
657     } catch (NotFoundException e) {
658       return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", eventId);
659     } catch (UnauthorizedException e) {
660       return Response.status(Status.UNAUTHORIZED).build();
661     } catch (IllegalArgumentException e) {
662       logger.debug("Unable to update event '{}'", eventId, e);
663       return RestUtil.R.badRequest(e.getMessage());
664     } catch (IndexServiceException e) {
665       logger.error("Unable to get multi part fields or file for event '{}'", eventId, e);
666       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
667     } catch (SearchIndexException e) {
668       logger.error("Unable to update event '{}'", eventId, e);
669       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
670     } catch (Exception e) {
671       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
672     }
673   }
674 
675   @POST
676   @Path("/")
677   @Consumes(MediaType.MULTIPART_FORM_DATA)
678   @RestQuery(name = "createevent", description = "Creates an event by sending metadata, access control list, processing instructions and files in a multipart request.", returnDescription = "", restParameters = {
679           @RestParameter(name = "acl", isRequired = false, description = "A collection of roles with their possible action", type = STRING),
680           @RestParameter(name = "metadata", description = "Event metadata as Form param", isRequired = false, type = STRING),
681           @RestParameter(name = "scheduling", description = "Scheduling information as Form param", isRequired = false, type = STRING),
682           @RestParameter(name = "presenter", description = "Presenter movie track", isRequired = false, type = Type.FILE),
683           @RestParameter(name = "presentation", description = "Presentation movie track", isRequired = false, type = Type.FILE),
684           @RestParameter(name = "audio", description = "Audio track", isRequired = false, type = Type.FILE),
685           @RestParameter(name = "processing", description = "Processing instructions task configuration", isRequired = false, type = STRING) }, responses = {
686                   @RestResponse(description = "A new event is created and its identifier is returned in the Location header.", responseCode = HttpServletResponse.SC_CREATED),
687                   @RestResponse(description = "The event could not be created due to a scheduling conflict.", responseCode = HttpServletResponse.SC_CONFLICT),
688                   @RestResponse(description = "The request is invalid or inconsistent..", responseCode = HttpServletResponse.SC_BAD_REQUEST) })
689   public Response createNewEvent(@HeaderParam("Accept") String acceptHeader, @Context HttpServletRequest request) {
690     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
691     try {
692       String startDatePattern = configuredMetadataFields.containsKey("startDate") ? configuredMetadataFields.get("startDate").getPattern() : null;
693       String startTimePattern = configuredMetadataFields.containsKey("startTime") ? configuredMetadataFields.get("startTime").getPattern() : null;
694       EventHttpServletRequest eventHttpServletRequest = EventHttpServletRequest.createFromHttpServletRequest(request,
695           ingestService, getEventCatalogUIAdapters(), startDatePattern, startTimePattern);
696 
697       // If scheduling information is provided, the source has to be "SCHEDULE_SINGLE" or "SCHEDULE_MULTIPLE".
698       if (eventHttpServletRequest.getScheduling().isSome() && !requestedVersion.isSmallerThan(VERSION_1_1_0)) {
699         // Scheduling is only available for version 1.1.0 and above
700         return scheduleNewEvent(eventHttpServletRequest, eventHttpServletRequest.getScheduling().get(), requestedVersion);
701       }
702 
703       JSONObject source = new JSONObject();
704       source.put("type", "UPLOAD");
705       eventHttpServletRequest.setSource(source);
706       String eventId = indexService.createEvent(eventHttpServletRequest);
707       return ApiResponseBuilder.Json.created(requestedVersion, URI.create(getEventUrl(eventId)), obj(f("identifier", v(eventId))));
708     } catch (IllegalArgumentException | DateTimeParseException e) {
709       logger.debug("Unable to create event", e);
710       return RestUtil.R.badRequest(e.getMessage());
711     } catch (SchedulerException | IndexServiceException e) {
712       if (e.getCause() != null && e.getCause() instanceof NotFoundException
713               || e.getCause() instanceof IllegalArgumentException) {
714         logger.debug("Unable to create event", e);
715         return RestUtil.R.badRequest(e.getCause().getMessage());
716       } else {
717         logger.error("Unable to create event", e);
718         throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
719       }
720     } catch (Exception e) {
721       logger.error("Unable to create event", e);
722       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
723     }
724   }
725 
726   private Response scheduleNewEvent(EventHttpServletRequest request, JSONObject scheduling, ApiVersion requestedVersion) throws
727       MediaPackageException, IOException, IngestException, SchedulerException,
728       NotFoundException, UnauthorizedException, SearchIndexException, java.text.ParseException {
729 
730     final SchedulingInfo schedulingInfo = SchedulingInfo.of(scheduling);
731     final JSONObject source = schedulingInfo.toSource();
732     request.setSource(source);
733 
734     try {
735       final String eventId = indexService.createEvent(request);
736 
737       if (StringUtils.isEmpty(eventId)) {
738         return RestUtil.R.badRequest("The date range provided did not include any events");
739       }
740 
741       if (eventId.contains(",")) {
742         // This the case when SCHEDULE_MULTIPLE is performed.
743         return ApiResponseBuilder.Json.ok(requestedVersion, arr(
744             Arrays.stream(eventId.split(","))
745                 .map(s -> obj(f("identifier", v(s))))
746                 .collect(Collectors.toList()))
747         );
748       }
749 
750       return ApiResponseBuilder.Json.created(requestedVersion, URI.create(getEventUrl(eventId)), obj(f("identifier", v(eventId))));
751     } catch (SchedulerConflictException e) {
752       final List<MediaPackage> conflictingEvents =
753           getConflictingEvents(schedulingInfo, agentStateService, schedulerService);
754       logger.debug("Client tried to schedule conflicting event(s).");
755       return ApiResponseBuilder.Json.conflict(requestedVersion,
756           arr(convertConflictingEvents(Optional.empty(), conflictingEvents, indexService, elasticsearchIndex)));
757     }
758   }
759 
760   @GET
761   @Path("/")
762   @RestQuery(name = "getevents", description = "Returns a list of events. By setting the optional sign parameter to true, the method will pre-sign distribution urls if signing is turned on in Opencast. Remember to consider the maximum validity of signed URLs when caching this response.", returnDescription = "", restParameters = {
763           @RestParameter(name = "sign", isRequired = false, description = "Whether public distribution urls should be signed.", type = Type.BOOLEAN),
764           @RestParameter(name = "withacl", isRequired = false, description = "Whether the acl metadata should be included in the response.", type = Type.BOOLEAN),
765           @RestParameter(name = "withmetadata", isRequired = false, description = "Whether the metadata catalogs should be included in the response.", type = Type.BOOLEAN),
766           @RestParameter(name = "withscheduling", isRequired = false, description = "Whether the scheduling information should be included in the response.", type = Type.BOOLEAN),
767           @RestParameter(name = "withpublications", isRequired = false, description = "Whether the publication ids and urls should be included in the response.", type = Type.BOOLEAN),
768           @RestParameter(name = "includeInternalPublication", description = "Whether internal publications should be included.", isRequired = false, type = Type.BOOLEAN),
769           @RestParameter(name = "onlyWithWriteAccess", isRequired = false, description = "Whether only to get the events to which we have write access.", type = Type.BOOLEAN),
770           @RestParameter(name = "filter", isRequired = false, description = "Usage [Filter Name]:[Value to Filter With]. Multiple filters can be used by combining them with commas \",\". Available Filters: presenters, contributors, location, textFilter, series, subject. If API ver > 1.1.0 also: identifier, title, description, series_name, language, created, license, rightsholder, is_part_of, source, status, agent_id, start, technical_start.", type = STRING),
771           @RestParameter(name = "sort", description = "Sort the results based upon a list of comma seperated sorting criteria. In the comma seperated list each type of sorting is specified as a pair such as: <Sort Name>:ASC or <Sort Name>:DESC. Adding the suffix ASC or DESC sets the order as ascending or descending order and is mandatory.", isRequired = false, type = STRING),
772           @RestParameter(name = "limit", description = "The maximum number of results to return for a single request.", isRequired = false, type = RestParameter.Type.INTEGER),
773           @RestParameter(name = "offset", description = "The index of the first result to return.", isRequired = false, type = RestParameter.Type.INTEGER) }, responses = {
774                   @RestResponse(description = "A (potentially empty) list of events is returned.", responseCode = HttpServletResponse.SC_OK) })
775   public Response getEvents(@HeaderParam("Accept") String acceptHeader, @QueryParam("id") String id,
776           @QueryParam("commentReason") String reasonFilter, @QueryParam("commentResolution") String resolutionFilter,
777           @QueryParam("filter") List<String> filter, @QueryParam("sort") String sort, @QueryParam("offset") Integer offset,
778           @QueryParam("limit") Integer limit, @QueryParam("sign") boolean sign, @QueryParam("withacl") Boolean withAcl,
779           @QueryParam("withmetadata") Boolean withMetadata, @QueryParam("withscheduling") Boolean withScheduling,
780           @QueryParam("onlyWithWriteAccess") Boolean onlyWithWriteAccess, @QueryParam("withpublications") Boolean withPublications, @QueryParam("includeInternalPublication") Boolean includeInternalPublication) {
781     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
782     if (requestedVersion.isSmallerThan(VERSION_1_1_0)) {
783       // withscheduling was added for version 1.1.0 and should be ignored for smaller versions.
784       withScheduling = false;
785     }
786 
787     Option<Integer> optLimit = Option.option(limit);
788     Option<Integer> optOffset = Option.option(offset);
789     Option<String> optSort = Option.option(trimToNull(sort));
790     EventSearchQuery query = new EventSearchQuery(getSecurityService().getOrganization().getId(),
791             getSecurityService().getUser());
792     // If the limit is set to 0, this is not taken into account
793     if (optLimit.isSome() && limit == 0) {
794       optLimit = Option.none();
795     }
796 
797     //List of all events from the filters
798     List<IndexObject> allEvents = new ArrayList<>();
799 
800     if (!isNullOrEmpty(filter)) {
801       // API version 1.5.0: Additive filter
802       if (!requestedVersion.isSmallerThan(ApiVersion.VERSION_1_5_0)) {
803         filter = filter.subList(0,1);
804       }
805       for (String filterPart : filter) {
806         // Parse the filters
807 
808         for (String f : filterPart.split(",")) {
809           String[] filterTuple = f.split(":");
810           if (filterTuple.length < 2) {
811             logger.debug("No value for filter {} in filters list: {}", filterTuple[0], filter);
812             continue;
813           }
814 
815           String name = filterTuple[0];
816           String value;
817 
818           if (!requestedVersion.isSmallerThan(ApiVersion.VERSION_1_1_0)) {
819             // MH-13038 - 1.1.0 and higher support colons in values
820             value = f.substring(name.length() + 1);
821           } else {
822             value = filterTuple[1];
823           }
824 
825           if ("presenters".equals(name)) {
826             query.withPresenter(value);
827           } else if ("contributors".equals(name)) {
828             query.withContributor(value);
829           } else if ("location".equals(name)) {
830             query.withLocation(value);
831           } else if ("textFilter".equals(name)) {
832             query.withText(QueryPreprocessor.sanitize(value));
833           } else if ("series".equals(name)) {
834             query.withSeriesId(value);
835           } else if ("subject".equals(name)) {
836             query.withSubject(value);
837           } else if (!requestedVersion.isSmallerThan(ApiVersion.VERSION_1_1_0)) {
838             // additional filters only available with Version 1.1.0 or higher
839             if ("identifier".equals(name)) {
840               query.withIdentifier(value);
841             } else if ("title".equals(name)) {
842               query.withTitle(value);
843             } else if ("description".equals(name)) {
844               query.withDescription(value);
845             } else if ("series_name".equals(name)) {
846               query.withSeriesName(value);
847             } else if ("language".equals(name)) {
848               query.withLanguage(value);
849             } else if ("created".equals(name)) {
850               query.withCreated(value);
851             } else if ("license".equals(name)) {
852               query.withLicense(value);
853             } else if ("rightsholder".equals(name)) {
854               query.withRights(value);
855             } else if ("is_part_of".equals(name)) {
856               query.withSeriesId(value);
857             } else if ("source".equals(name)) {
858               query.withSource(value);
859             } else if ("status".equals(name)) {
860               query.withEventStatus(value);
861             } else if ("agent_id".equals(name)) {
862               query.withAgentId(value);
863             } else if ("start".equals(name)) {
864               try {
865                 Tuple<Date, Date> fromAndToCreationRange = RestUtils.getFromAndToDateRange(value);
866                 query.withStartFrom(fromAndToCreationRange.getA());
867                 query.withStartTo(fromAndToCreationRange.getB());
868               } catch (Exception e) {
869                 return RestUtil.R
870                         .badRequest(String.format("Filter 'start' could not be parsed: %s", e.getMessage()));
871 
872               }
873             } else if ("technical_start".equals(name)) {
874               try {
875                 Tuple<Date, Date> fromAndToCreationRange = RestUtils.getFromAndToDateRange(value);
876                 query.withTechnicalStartFrom(fromAndToCreationRange.getA());
877                 query.withTechnicalStartTo(fromAndToCreationRange.getB());
878               } catch (Exception e) {
879                 return RestUtil.R
880                         .badRequest(String.format("Filter 'technical_start' could not be parsed: %s", e.getMessage()));
881 
882               }
883             } else {
884               logger.warn("Unknown filter criteria {}", name);
885               return RestUtil.R.badRequest(String.format("Unknown filter criterion in request: %s", name));
886 
887             }
888           }
889         }
890 
891         if (optSort.isSome()) {
892           ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(optSort.get());
893           for (SortCriterion criterion : sortCriteria) {
894 
895             switch (criterion.getFieldName()) {
896               case EventIndexSchema.TITLE:
897                 query.sortByTitle(criterion.getOrder());
898                 break;
899               case EventIndexSchema.PRESENTER:
900                 query.sortByPresenter(criterion.getOrder());
901                 break;
902               case EventIndexSchema.TECHNICAL_START:
903               case "technical_date":
904                 query.sortByTechnicalStartDate(criterion.getOrder());
905                 break;
906               case EventIndexSchema.TECHNICAL_END:
907                 query.sortByTechnicalEndDate(criterion.getOrder());
908                 break;
909               case EventIndexSchema.START_DATE:
910               case "date":
911                 query.sortByStartDate(criterion.getOrder());
912                 break;
913               case EventIndexSchema.END_DATE:
914                 query.sortByEndDate(criterion.getOrder());
915                 break;
916               case EventIndexSchema.WORKFLOW_STATE:
917                 query.sortByWorkflowState(criterion.getOrder());
918                 break;
919               case EventIndexSchema.SERIES_NAME:
920                 query.sortBySeriesName(criterion.getOrder());
921                 break;
922               case EventIndexSchema.LOCATION:
923                 query.sortByLocation(criterion.getOrder());
924                 break;
925               // For compatibility, we mimic to support the old review_status and scheduling_status sort criteria (MH-13407)
926               case "review_status":
927               case "scheduling_status":
928                 break;
929               default:
930                 return RestUtil.R.badRequest(String.format("Unknown sort criterion in request: %s", criterion.getFieldName()));
931             }
932           }
933         }
934 
935         // TODO: Add the comment resolution filter to the query
936         if (StringUtils.isNotBlank(resolutionFilter)) {
937           try {
938             CommentResolution.valueOf(resolutionFilter);
939           } catch (Exception e) {
940             logger.debug("Unable to parse comment resolution filter {}", resolutionFilter);
941             return Response.status(Status.BAD_REQUEST).build();
942           }
943         }
944 
945         if (optLimit.isSome())
946           query.withLimit(optLimit.get());
947         if (optOffset.isSome())
948           query.withOffset(offset);
949         // TODO: Add other filters to the query
950 
951         SearchResult<Event> results = null;
952         try {
953           results = elasticsearchIndex.getByQuery(query);
954         } catch (SearchIndexException e) {
955           logger.error("The External Search Index was not able to get the events list", e);
956           throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
957         }
958 
959         SearchResultItem<Event>[] items = results.getItems();
960         List<IndexObject> events = new ArrayList<>();
961         for (SearchResultItem<Event> item : items) {
962           Event source = item.getSource();
963           source.updatePreview(previewSubtype);
964           events.add(source);
965         }
966         //Append  filtered results to the list
967         allEvents.addAll(events);
968       }
969     } else {
970       if (optSort.isSome()) {
971         ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(optSort.get());
972         for (SortCriterion criterion : sortCriteria) {
973 
974           switch (criterion.getFieldName()) {
975             case EventIndexSchema.TITLE:
976               query.sortByTitle(criterion.getOrder());
977               break;
978             case EventIndexSchema.PRESENTER:
979               query.sortByPresenter(criterion.getOrder());
980               break;
981             case EventIndexSchema.TECHNICAL_START:
982             case "technical_date":
983               query.sortByTechnicalStartDate(criterion.getOrder());
984               break;
985             case EventIndexSchema.TECHNICAL_END:
986               query.sortByTechnicalEndDate(criterion.getOrder());
987               break;
988             case EventIndexSchema.START_DATE:
989             case "date":
990               query.sortByStartDate(criterion.getOrder());
991               break;
992             case EventIndexSchema.END_DATE:
993               query.sortByEndDate(criterion.getOrder());
994               break;
995             case EventIndexSchema.WORKFLOW_STATE:
996               query.sortByWorkflowState(criterion.getOrder());
997               break;
998             case EventIndexSchema.SERIES_NAME:
999               query.sortBySeriesName(criterion.getOrder());
1000               break;
1001             case EventIndexSchema.LOCATION:
1002               query.sortByLocation(criterion.getOrder());
1003               break;
1004             // For compatibility, we mimic to support the old review_status and scheduling_status sort criteria (MH-13407)
1005             case "review_status":
1006             case "scheduling_status":
1007               break;
1008             default:
1009               return RestUtil.R.badRequest(String.format("Unknown sort criterion in request: %s", criterion.getFieldName()));
1010           }
1011         }
1012       }
1013 
1014       // TODO: Add the comment resolution filter to the query
1015       if (StringUtils.isNotBlank(resolutionFilter)) {
1016         try {
1017           CommentResolution.valueOf(resolutionFilter);
1018         } catch (Exception e) {
1019           logger.debug("Unable to parse comment resolution filter {}", resolutionFilter);
1020           return Response.status(Status.BAD_REQUEST).build();
1021         }
1022       }
1023 
1024       if (optLimit.isSome())
1025         query.withLimit(optLimit.get());
1026       if (optOffset.isSome())
1027         query.withOffset(offset);
1028 
1029       if (onlyWithWriteAccess != null && onlyWithWriteAccess) {
1030         query.withoutActions();
1031         query.withAction(Permissions.Action.WRITE);
1032       }
1033       // TODO: Add other filters to the query
1034 
1035       SearchResult<Event> results = null;
1036       try {
1037         results = elasticsearchIndex.getByQuery(query);
1038       } catch (SearchIndexException e) {
1039         logger.error("The External Search Index was not able to get the events list", e);
1040         throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1041       }
1042 
1043       SearchResultItem<Event>[] items = results.getItems();
1044       List<IndexObject> events = new ArrayList<>();
1045       for (SearchResultItem<Event> item : items) {
1046         Event source = item.getSource();
1047         source.updatePreview(previewSubtype);
1048         events.add(source);
1049       }
1050       //Append  filtered results to the list
1051       allEvents.addAll(events);
1052     }
1053     try {
1054       return getJsonEvents(
1055           acceptHeader, allEvents, withAcl, withMetadata, withScheduling, withPublications, includeInternalPublication, sign, requestedVersion);
1056     } catch (Exception e) {
1057       logger.error("Unable to get events", e);
1058       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1059     }
1060   }
1061 
1062   /**
1063    * Render a collection of {@link Event}s into a json array.
1064    *
1065    * @param acceptHeader
1066    *          The accept header to return to the client.
1067    * @param events
1068    *          The {@link List} of {@link Event}s to render into json.
1069    * @param withAcl
1070    *          Whether to include the events' ACLs.
1071    * @param withMetadata
1072    *          Whether to include the events' metadata.
1073    * @param withScheduling
1074    *          Whether to include the events' scheduling information.
1075    * @param withPublications
1076    *          Whether to include the events' publications.
1077    * @param withSignedUrls
1078    *          Whether to sign the included urls.
1079    * @return A {@link Response} with the accept header and body as the Json array of {@link Event}s.
1080    * @throws IndexServiceException
1081    * @throws SchedulerException
1082    * @throws UnauthorizedException
1083    */
1084   protected Response getJsonEvents(String acceptHeader, List<IndexObject> events, Boolean withAcl, Boolean withMetadata,
1085           Boolean withScheduling, Boolean withPublications,Boolean includeInternalPublication, Boolean withSignedUrls, ApiVersion requestedVersion)
1086       throws IndexServiceException, UnauthorizedException, SchedulerException {
1087     List<JValue> eventsList = new ArrayList<>();
1088     for (IndexObject item : events) {
1089       eventsList.add(eventToJSON((Event) item, withAcl, withMetadata, withScheduling, withPublications, includeInternalPublication, withSignedUrls,
1090               requestedVersion));
1091     }
1092     return ApiResponseBuilder.Json.ok(requestedVersion, arr(eventsList));
1093   }
1094 
1095   /**
1096    * Transform an {@link Event} to Json
1097    *
1098    * @param event
1099    *          The event to transform into json
1100    * @param withAcl
1101    *          Whether to add the acl information for the event
1102    * @param withMetadata
1103    *          Whether to add all the metadata for the event
1104    * @param withScheduling
1105    *          Whether to add the scheduling information for the event
1106    * @param withPublications
1107    *          Whether to add the publications
1108    * @param withSignedUrls
1109    *          Whether to sign the urls if they are protected by stream security.
1110    * @return The event in json format.
1111    * @throws IndexServiceException
1112    *           Thrown if unable to get the metadata for the event.
1113    * @throws SchedulerException
1114    * @throws UnauthorizedException
1115    */
1116   protected JValue eventToJSON(Event event, Boolean withAcl, Boolean withMetadata, Boolean withScheduling,
1117           Boolean withPublications, Boolean includeInternalPublication, Boolean withSignedUrls, ApiVersion requestedVersion) throws IndexServiceException, SchedulerException, UnauthorizedException {
1118     List<Field> fields = new ArrayList<>();
1119     if (event.getArchiveVersion() != null)
1120       fields.add(f("archive_version", v(event.getArchiveVersion())));
1121     fields.add(f("created", v(event.getCreated(), Jsons.BLANK)));
1122     fields.add(f("creator", v(event.getCreator(), Jsons.BLANK)));
1123     fields.add(f("contributor", arr($(event.getContributors()).map(Functions.stringToJValue))));
1124     fields.add(f("description", v(event.getDescription(), Jsons.BLANK)));
1125     fields.add(f("has_previews", v(event.hasPreview())));
1126     fields.add(f("identifier", v(event.getIdentifier(), BLANK)));
1127     fields.add(f("location", v(event.getLocation(), BLANK)));
1128     fields.add(f("presenter", arr($(event.getPresenters()).map(Functions.stringToJValue))));
1129     if (!requestedVersion.isSmallerThan(VERSION_1_1_0)) {
1130       fields.add(f("language", v(event.getLanguage(), BLANK)));
1131       fields.add(f("rightsholder", v(event.getRights(), BLANK)));
1132       fields.add(f("license", v(event.getLicense(), BLANK)));
1133       fields.add(f("is_part_of", v(event.getSeriesId(), BLANK)));
1134       fields.add(f("series", v(event.getSeriesName(),BLANK)));
1135       fields.add(f("source", v(event.getSource(), BLANK)));
1136       fields.add(f("status", v(event.getEventStatus(), BLANK)));
1137     }
1138     List<JValue> publicationIds = new ArrayList<>();
1139     if (event.getPublications() != null) {
1140       for (Publication publication : event.getPublications()) {
1141         publicationIds.add(v(publication.getChannel()));
1142       }
1143     }
1144     fields.add(f("publication_status", arr(publicationIds)));
1145     fields.add(f("processing_state", v(event.getWorkflowState(), BLANK)));
1146 
1147     if (requestedVersion.isSmallerThan(VERSION_1_4_0)) {
1148       fields.add(f("start", v(event.getTechnicalStartTime(), BLANK)));
1149       if (event.getTechnicalEndTime() != null) {
1150         long duration = new DateTime(event.getTechnicalEndTime()).getMillis()
1151                 - new DateTime(event.getTechnicalStartTime()).getMillis();
1152         fields.add(f("duration", v(duration)));
1153       }
1154     } else {
1155       fields.add(f("start", v(event.getRecordingStartDate(), BLANK)));
1156       fields.add(f("duration", v(event.getDuration(), NULL)));
1157     }
1158 
1159     if (StringUtils.trimToNull(event.getSubject()) != null) {
1160       fields.add(f("subjects", arr(splitSubjectIntoArray(event.getSubject()))));
1161     } else {
1162       fields.add(f("subjects", arr()));
1163     }
1164     fields.add(f("title", v(event.getTitle(), BLANK)));
1165     if (withAcl != null && withAcl) {
1166       AccessControlList acl = getAclFromEvent(event);
1167       fields.add(f("acl", arr(AclUtils.serializeAclToJson(acl))));
1168     }
1169     if (withMetadata != null && withMetadata) {
1170       try {
1171         Opt<MetadataList> metadata = getEventMetadata(event);
1172         if (metadata.isSome()) {
1173           fields.add(f("metadata", MetadataJson.listToJson(metadata.get(), true)));
1174         }
1175       } catch (Exception e) {
1176         logger.error("Unable to get metadata for event '{}'", event.getIdentifier(), e);
1177         throw new IndexServiceException("Unable to add metadata to event", e);
1178       }
1179     }
1180     if (withScheduling != null && withScheduling) {
1181       fields.add(f("scheduling", SchedulingInfo.of(event.getIdentifier(), schedulerService).toJson()));
1182     }
1183     if (withPublications != null && withPublications) {
1184       List<JValue> publications = getPublications(event, withSignedUrls, includeInternalPublication,requestedVersion);
1185       fields.add(f("publications", arr(publications)));
1186     }
1187     return obj(fields);
1188   }
1189 
1190   private List<JValue> splitSubjectIntoArray(final String subject) {
1191     return com.entwinemedia.fn.Stream.$(subject.split(",")).map(new Fn<String, JValue>() {
1192       @Override
1193       public JValue apply(String a) {
1194         return v(a.trim());
1195       }
1196     }).toList();
1197   }
1198 
1199   @GET
1200   @Path("{eventId}/acl")
1201   @RestQuery(name = "geteventacl", description = "Returns an event's access policy.", returnDescription = "", pathParameters = {
1202           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) }, responses = {
1203                   @RestResponse(description = "The access control list for the specified event is returned.", responseCode = HttpServletResponse.SC_OK),
1204                   @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1205   public Response getEventAcl(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id)
1206           throws Exception {
1207     for (final Event event : indexService.getEvent(id, elasticsearchIndex)) {
1208       AccessControlList acl = getAclFromEvent(event);
1209       return ApiResponseBuilder.Json.ok(acceptHeader, arr(AclUtils.serializeAclToJson(acl)));
1210     }
1211     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1212   }
1213 
1214   @PUT
1215   @Path("{eventId}/acl")
1216   @RestQuery(name = "updateeventacl", description = "Update an event's access policy.", returnDescription = "", pathParameters = {
1217           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) }, restParameters = {
1218                   @RestParameter(name = "acl", isRequired = true, description = "Access policy", type = STRING) }, responses = {
1219                           @RestResponse(description = "The access control list for the specified event is updated.", responseCode = HttpServletResponse.SC_NO_CONTENT),
1220                           @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1221   public Response updateEventAcl(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1222           @FormParam("acl") String acl) throws Exception {
1223     if (indexService.getEvent(id, elasticsearchIndex).isSome()) {
1224       AccessControlList accessControlList;
1225       try {
1226         accessControlList = AclUtils.deserializeJsonToAcl(acl, false);
1227       } catch (ParseException e) {
1228         logger.debug("Unable to update event acl to '{}'", acl, e);
1229         return R.badRequest(String.format("Unable to parse acl '%s' because '%s'", acl, e.getMessage()));
1230       } catch (IllegalArgumentException e) {
1231         logger.debug("Unable to update event acl to '{}'", acl, e);
1232         return R.badRequest(e.getMessage());
1233       }
1234       try {
1235         accessControlList = indexService.updateEventAcl(id, accessControlList, elasticsearchIndex);
1236       } catch (IllegalArgumentException e) {
1237         logger.error("Unable to update event '{}' acl with '{}'", id, acl, e);
1238         return Response.status(Status.FORBIDDEN).build();
1239       }
1240       return Response.noContent().build();
1241     } else {
1242       return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1243     }
1244   }
1245 
1246   @POST
1247   @Path("{eventId}/acl/{action}")
1248   @RestQuery(name = "addeventace", description = "Grants permission to execute action on the specified event to any user with role role. Note that this is a convenience method to avoid having to build and post a complete access control list.", returnDescription = "", pathParameters = {
1249           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING),
1250           @RestParameter(name = "action", description = "The action that is allowed to be executed", isRequired = true, type = STRING) }, restParameters = {
1251                   @RestParameter(name = "role", isRequired = true, description = "The role that is granted permission", type = STRING) }, responses = {
1252                           @RestResponse(description = "The permission has been created in the access control list of the specified event.", responseCode = HttpServletResponse.SC_NO_CONTENT),
1253                           @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1254   public Response addEventAce(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1255           @PathParam("action") String action, @FormParam("role") String role) throws Exception {
1256     List<AccessControlEntry> entries = new ArrayList<>();
1257     for (final Event event : indexService.getEvent(id, elasticsearchIndex)) {
1258       AccessControlList accessControlList = getAclFromEvent(event);
1259       AccessControlEntry newAce = new AccessControlEntry(role, action, true);
1260       boolean alreadyInAcl = false;
1261       for (AccessControlEntry ace : accessControlList.getEntries()) {
1262         if (ace.equals(newAce)) {
1263           // We have found an identical access control entry so just return.
1264           entries = accessControlList.getEntries();
1265           alreadyInAcl = true;
1266           break;
1267         } else if (ace.getAction().equals(newAce.getAction()) && ace.getRole().equals(newAce.getRole())
1268                 && !ace.isAllow()) {
1269           entries.add(newAce);
1270           alreadyInAcl = true;
1271         } else {
1272           entries.add(ace);
1273         }
1274       }
1275 
1276       if (!alreadyInAcl) {
1277         entries.add(newAce);
1278       }
1279 
1280       AccessControlList withNewAce = new AccessControlList(entries);
1281       try {
1282         withNewAce = indexService.updateEventAcl(id, withNewAce, elasticsearchIndex);
1283       } catch (IllegalArgumentException e) {
1284         logger.error("Unable to update event '{}' acl entry with action '{}' and role '{}'", id, action, role, e);
1285         return Response.status(Status.FORBIDDEN).build();
1286       }
1287       return Response.noContent().build();
1288     }
1289     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1290   }
1291 
1292   @DELETE
1293   @Path("{eventId}/acl/{action}/{role}")
1294   @RestQuery(name = "deleteeventace", description = "Revokes permission to execute action on the specified event from any user with role role.", returnDescription = "", pathParameters = {
1295           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING),
1296           @RestParameter(name = "action", description = "The action that is no longer allowed to be executed", isRequired = true, type = STRING),
1297           @RestParameter(name = "role", description = "The role that is no longer granted permission", isRequired = true, type = STRING) }, responses = {
1298                   @RestResponse(description = "The permission has been revoked from the access control list of the specified event.", responseCode = HttpServletResponse.SC_NO_CONTENT),
1299                   @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1300   public Response deleteEventAce(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1301           @PathParam("action") String action, @PathParam("role") String role) throws Exception {
1302     List<AccessControlEntry> entries = new ArrayList<>();
1303     for (final Event event : indexService.getEvent(id, elasticsearchIndex)) {
1304       AccessControlList accessControlList = getAclFromEvent(event);
1305       boolean foundDelete = false;
1306       for (AccessControlEntry ace : accessControlList.getEntries()) {
1307         if (ace.getAction().equals(action) && ace.getRole().equals(role)) {
1308           foundDelete = true;
1309         } else {
1310           entries.add(ace);
1311         }
1312       }
1313 
1314       if (!foundDelete) {
1315         return ApiResponseBuilder.notFound("Unable to find an access control entry with action '%s' and role '%s'", action,
1316                 role);
1317       }
1318 
1319       AccessControlList withoutDeleted = new AccessControlList(entries);
1320       try {
1321         withoutDeleted = indexService.updateEventAcl(id, withoutDeleted, elasticsearchIndex);
1322       } catch (IllegalArgumentException e) {
1323         logger.error("Unable to delete event's '{}' acl entry with action '{}' and role '{}'", id, action, role, e);
1324         return Response.status(Status.FORBIDDEN).build();
1325       }
1326       return Response.noContent().build();
1327     }
1328     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1329   }
1330 
1331   @GET
1332   @Path("{eventId}/metadata")
1333   @RestQuery(name = "geteventmetadata", description = "Returns the event's metadata of the specified type. For a metadata catalog there is the flavor such as 'dublincore/episode' and this is the unique type.", returnDescription = "", pathParameters = {
1334           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) }, restParameters = {
1335                   @RestParameter(name = "type", isRequired = false, description = "The type of metadata to get", type = STRING) }, responses = {
1336                           @RestResponse(description = "The metadata collection is returned.", responseCode = HttpServletResponse.SC_OK),
1337                           @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1338   public Response getAllEventMetadata(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1339           @QueryParam("type") String type) throws Exception {
1340     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
1341     if (StringUtils.trimToNull(type) == null) {
1342       Opt<MetadataList> metadataList = getEventMetadataById(id);
1343       if (metadataList.isSome()) {
1344         MetadataList actualList = metadataList.get();
1345 
1346         // API v1 should return a two separate fields for start date and start time. Since those fields were merged in index service, we have to split them up.
1347         final DublinCoreMetadataCollection collection = actualList.getMetadataByFlavor("dublincore/episode");
1348         final boolean withOrderedText = collection == null;
1349         if (collection != null) {
1350           convertStartDateTimeToApiV1(collection);
1351         }
1352 
1353         return ApiResponseBuilder.Json.ok(requestedVersion, MetadataJson.listToJson(actualList, withOrderedText));
1354       }
1355       else
1356         return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1357     } else {
1358       return getEventMetadataByType(id, type, requestedVersion);
1359     }
1360   }
1361 
1362   private void convertStartDateTimeToApiV1(DublinCoreMetadataCollection collection) throws java.text.ParseException {
1363 
1364     if (!collection.getOutputFields().containsKey("startDate")) return;
1365 
1366     MetadataField oldStartDateField = collection.getOutputFields().get("startDate");
1367     SimpleDateFormat sdf = MetadataField.getSimpleDateFormatter(oldStartDateField.getPattern());
1368     Date startDate = sdf.parse((String) oldStartDateField.getValue());
1369 
1370     if (configuredMetadataFields.containsKey("startDate")) {
1371       MetadataField startDateField = configuredMetadataFields.get("startDate");
1372       final String pattern = startDateField.getPattern() == null ? "yyyy-MM-dd" : startDateField.getPattern();
1373       startDateField = new MetadataField(startDateField);
1374       startDateField.setPattern(pattern);
1375       sdf.applyPattern(startDateField.getPattern());
1376       startDateField.setValue(sdf.format(startDate));
1377       collection.removeField(oldStartDateField);
1378       collection.addField(startDateField);
1379     }
1380 
1381     if (configuredMetadataFields.containsKey("startTime")) {
1382       MetadataField startTimeField = configuredMetadataFields.get("startTime");
1383       final String pattern = startTimeField.getPattern() == null ? "HH:mm" : startTimeField.getPattern();
1384       startTimeField = new MetadataField(startTimeField);
1385       startTimeField.setPattern(pattern);
1386       sdf.applyPattern(startTimeField.getPattern());
1387       startTimeField.setValue(sdf.format(startDate));
1388       collection.addField(startTimeField);
1389     }
1390   }
1391 
1392   protected Opt<MetadataList> getEventMetadataById(String id) throws IndexServiceException, Exception {
1393     for (final Event event : indexService.getEvent(id, elasticsearchIndex)) {
1394       return getEventMetadata(event);
1395     }
1396     return Opt.<MetadataList> none();
1397   }
1398 
1399   protected Opt<MetadataList> getEventMetadata(Event event) throws IndexServiceException, Exception {
1400     MetadataList metadataList = new MetadataList();
1401     List<EventCatalogUIAdapter> catalogUIAdapters = getEventCatalogUIAdapters();
1402     EventCatalogUIAdapter eventCatalogUIAdapter = indexService.getCommonEventCatalogUIAdapter();
1403     catalogUIAdapters.remove(eventCatalogUIAdapter);
1404     if (catalogUIAdapters.size() > 0) {
1405       MediaPackage mediaPackage = indexService.getEventMediapackage(event);
1406       for (EventCatalogUIAdapter catalogUIAdapter : catalogUIAdapters) {
1407         // TODO: This is very slow:
1408         DublinCoreMetadataCollection fields = catalogUIAdapter.getFields(mediaPackage);
1409         if (fields != null) {
1410           ExternalMetadataUtils.removeCollectionList(fields);
1411           metadataList.add(catalogUIAdapter, fields);
1412         }
1413       }
1414     }
1415     DublinCoreMetadataCollection collection = EventUtils.getEventMetadata(event, eventCatalogUIAdapter,
1416         new EmptyResourceListQuery());
1417     ExternalMetadataUtils.changeSubjectToSubjects(collection);
1418     ExternalMetadataUtils.removeCollectionList(collection);
1419     metadataList.add(eventCatalogUIAdapter, collection);
1420     if (WorkflowInstance.WorkflowState.RUNNING.toString().equals(event.getWorkflowState())) {
1421       metadataList.setLocked(Locked.WORKFLOW_RUNNING);
1422     }
1423     return Opt.some(metadataList);
1424   }
1425 
1426   private Opt<MediaPackageElementFlavor> getFlavor(String flavorString) {
1427     try {
1428       MediaPackageElementFlavor flavor = MediaPackageElementFlavor.parseFlavor(flavorString);
1429       return Opt.some(flavor);
1430     } catch (IllegalArgumentException e) {
1431       return Opt.none();
1432     }
1433   }
1434 
1435   private Response getEventMetadataByType(String id, String type, ApiVersion requestedVersion) throws Exception {
1436     for (final Event event : indexService.getEvent(id, elasticsearchIndex)) {
1437       Opt<MediaPackageElementFlavor> flavor = getFlavor(type);
1438       if (flavor.isNone()) {
1439         return R.badRequest(
1440                 String.format("Unable to parse type '%s' as a flavor so unable to find the matching catalog.", type));
1441       }
1442       // Try the main catalog first as we load it from the index.
1443       EventCatalogUIAdapter eventCatalogUIAdapter = indexService.getCommonEventCatalogUIAdapter();
1444       if (flavor.get().equals(eventCatalogUIAdapter.getFlavor())) {
1445         DublinCoreMetadataCollection collection = EventUtils.getEventMetadata(event, eventCatalogUIAdapter,
1446             new EmptyResourceListQuery());
1447         ExternalMetadataUtils.changeSubjectToSubjects(collection);
1448         ExternalMetadataUtils.removeCollectionList(collection);
1449         convertStartDateTimeToApiV1(collection);
1450         return ApiResponseBuilder.Json.ok(requestedVersion, MetadataJson.collectionToJson(collection, false));
1451       }
1452       // Try the other catalogs
1453       List<EventCatalogUIAdapter> catalogUIAdapters = getEventCatalogUIAdapters();
1454       catalogUIAdapters.remove(eventCatalogUIAdapter);
1455       if (catalogUIAdapters.size() > 0) {
1456         MediaPackage mediaPackage = indexService.getEventMediapackage(event);
1457         for (EventCatalogUIAdapter catalogUIAdapter : catalogUIAdapters) {
1458           if (flavor.get().equals(catalogUIAdapter.getFlavor())) {
1459             DublinCoreMetadataCollection fields = catalogUIAdapter.getFields(mediaPackage);
1460             ExternalMetadataUtils.removeCollectionList(fields);
1461             convertStartDateTimeToApiV1(fields);
1462             return ApiResponseBuilder.Json.ok(requestedVersion, MetadataJson.collectionToJson(fields, false));
1463           }
1464         }
1465       }
1466       return ApiResponseBuilder.notFound("Cannot find a catalog with type '%s' for event with id '%s'.", type, id);
1467     }
1468     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1469   }
1470 
1471   @PUT
1472   @Path("{eventId}/metadata")
1473   @RestQuery(name = "updateeventmetadata", description = "Update the metadata with the matching type of the specified event. For a metadata catalog there is the flavor such as 'dublincore/episode' and this is the unique type.", returnDescription = "", pathParameters = {
1474           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) }, restParameters = {
1475                   @RestParameter(name = "type", isRequired = true, description = "The type of metadata to update", type = STRING),
1476                   @RestParameter(name = "metadata", description = "Metadata catalog in JSON format", isRequired = true, type = STRING) }, responses = {
1477                           @RestResponse(description = "The metadata of the given namespace has been updated.", responseCode = HttpServletResponse.SC_OK),
1478                           @RestResponse(description = "The request is invalid or inconsistent.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
1479                           @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1480   public Response updateEventMetadataByType(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1481           @QueryParam("type") String type, @FormParam("metadata") String metadataJSON) throws Exception {
1482     Map<String, String> updatedFields;
1483     JSONParser parser = new JSONParser();
1484     try {
1485       updatedFields = RequestUtils.getKeyValueMap(metadataJSON);
1486     } catch (ParseException e) {
1487       logger.debug("Unable to update event '{}' with metadata type '{}' and content '{}'", id, type, metadataJSON, e);
1488       return RestUtil.R.badRequest(String.format("Unable to parse metadata fields as json from '%s'", metadataJSON));
1489     } catch (IllegalArgumentException e) {
1490       logger.debug("Unable to update event '{}' with metadata type '{}' and content '{}'", id, type, metadataJSON, e);
1491       return RestUtil.R.badRequest(e.getMessage());
1492     }
1493 
1494     if (updatedFields == null || updatedFields.size() == 0) {
1495       return RestUtil.R.badRequest(
1496               String.format("Unable to parse metadata fields as json from '%s' because there were no fields to update.",
1497                       metadataJSON));
1498     }
1499 
1500     Opt<MediaPackageElementFlavor> flavor = getFlavor(type);
1501     if (flavor.isNone()) {
1502       return R.badRequest(
1503               String.format("Unable to parse type '%s' as a flavor so unable to find the matching catalog.", type));
1504     }
1505 
1506     DublinCoreMetadataCollection collection = null;
1507     EventCatalogUIAdapter adapter = null;
1508     for (final Event event : indexService.getEvent(id, elasticsearchIndex)) {
1509       MetadataList metadataList = new MetadataList();
1510       // Try the main catalog first as we load it from the index.
1511       EventCatalogUIAdapter eventCatalogUIAdapter = indexService.getCommonEventCatalogUIAdapter();
1512       if (flavor.get().equals(eventCatalogUIAdapter.getFlavor())) {
1513         collection = EventUtils.getEventMetadata(event, eventCatalogUIAdapter);
1514         adapter = eventCatalogUIAdapter;
1515       } else {
1516         metadataList.add(eventCatalogUIAdapter, EventUtils.getEventMetadata(event, eventCatalogUIAdapter));
1517       }
1518 
1519       // Try the other catalogs
1520       List<EventCatalogUIAdapter> catalogUIAdapters = getEventCatalogUIAdapters();
1521       catalogUIAdapters.remove(eventCatalogUIAdapter);
1522       if (catalogUIAdapters.size() > 0) {
1523         MediaPackage mediaPackage = indexService.getEventMediapackage(event);
1524         for (EventCatalogUIAdapter catalogUIAdapter : catalogUIAdapters) {
1525           if (flavor.get().equals(catalogUIAdapter.getFlavor())) {
1526             collection = catalogUIAdapter.getFields(mediaPackage);
1527             adapter = eventCatalogUIAdapter;
1528           } else {
1529             metadataList.add(catalogUIAdapter, catalogUIAdapter.getFields(mediaPackage));
1530           }
1531         }
1532       }
1533 
1534       if (collection == null) {
1535         return ApiResponseBuilder.notFound("Cannot find a catalog with type '%s' for event with id '%s'.", type, id);
1536       }
1537 
1538       for (String key : updatedFields.keySet()) {
1539         if ("subjects".equals(key)) {
1540           MetadataField field = collection.getOutputFields().get(DublinCore.PROPERTY_SUBJECT.getLocalName());
1541           Opt<Response> error = validateField(field, key, id, type, updatedFields);
1542           if (error.isSome()) {
1543             return error.get();
1544           }
1545           collection.removeField(field);
1546           JSONArray subjectArray = (JSONArray) parser.parse(updatedFields.get(key));
1547           collection.addField(
1548                   MetadataJson.copyWithDifferentJsonValue(field, StringUtils.join(subjectArray.iterator(), ",")));
1549         } else if ("startDate".equals(key)) {
1550           // Special handling for start date since in API v1 we expect start date and start time to be separate fields.
1551           MetadataField field = collection.getOutputFields().get(key);
1552           Opt<Response> error = validateField(field, key, id, type, updatedFields);
1553           if (error.isSome()) {
1554             return error.get();
1555           }
1556           String apiPattern = field.getPattern();
1557           if (configuredMetadataFields.containsKey("startDate")) {
1558             final String startDate = configuredMetadataFields.get("startDate").getPattern();
1559             apiPattern = startDate == null ? apiPattern : startDate;
1560           }
1561           SimpleDateFormat apiSdf = MetadataField.getSimpleDateFormatter(apiPattern);
1562           SimpleDateFormat sdf = MetadataField.getSimpleDateFormatter(field.getPattern());
1563           DateTime oldStartDate = new DateTime(sdf.parse((String) field.getValue()), DateTimeZone.UTC);
1564           DateTime newStartDate = new DateTime(apiSdf.parse(updatedFields.get(key)), DateTimeZone.UTC);
1565           DateTime updatedStartDate = oldStartDate.withDate(newStartDate.year().get(), newStartDate.monthOfYear().get(), newStartDate.dayOfMonth().get());
1566           collection.removeField(field);
1567           collection.addField(
1568                   MetadataJson.copyWithDifferentJsonValue(field, sdf.format(updatedStartDate.toDate())));
1569         } else if ("startTime".equals(key)) {
1570           // Special handling for start time since in API v1 we expect start date and start time to be separate fields.
1571           MetadataField field = collection.getOutputFields().get("startDate");
1572           Opt<Response> error = validateField(field, "startDate", id, type, updatedFields);
1573           if (error.isSome()) {
1574             return error.get();
1575           }
1576           String apiPattern = "HH:mm";
1577           if (configuredMetadataFields.containsKey("startTime")) {
1578             final String startTime = configuredMetadataFields.get("startTime").getPattern();
1579             apiPattern = startTime == null ? apiPattern : startTime;
1580           }
1581           SimpleDateFormat apiSdf = MetadataField.getSimpleDateFormatter(apiPattern);
1582           SimpleDateFormat sdf = MetadataField.getSimpleDateFormatter(field.getPattern());
1583           DateTime oldStartDate = new DateTime(sdf.parse((String) field.getValue()), DateTimeZone.UTC);
1584           DateTime newStartDate = new DateTime(apiSdf.parse(updatedFields.get(key)), DateTimeZone.UTC);
1585           DateTime updatedStartDate = oldStartDate.withTime(
1586                   newStartDate.hourOfDay().get(),
1587                   newStartDate.minuteOfHour().get(),
1588                   newStartDate.secondOfMinute().get(),
1589                   newStartDate.millisOfSecond().get());
1590           collection.removeField(field);
1591           collection.addField(
1592                   MetadataJson.copyWithDifferentJsonValue(field, sdf.format(updatedStartDate.toDate())));
1593         } else {
1594           MetadataField field = collection.getOutputFields().get(key);
1595           Opt<Response> error = validateField(field, key, id, type, updatedFields);
1596           if (error.isSome()) {
1597             return error.get();
1598           }
1599           collection.removeField(field);
1600           collection.addField(
1601                   MetadataJson.copyWithDifferentJsonValue(field, updatedFields.get(key)));
1602         }
1603       }
1604 
1605       metadataList.add(adapter, collection);
1606       indexService.updateEventMetadata(id, metadataList, elasticsearchIndex);
1607       return Response.noContent().build();
1608     }
1609     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1610   }
1611 
1612   private Opt<Response> validateField(MetadataField field, String key, String id, String type, Map<String, String> updatedFields) {
1613     if (field == null) {
1614       return Opt.some(ApiResponseBuilder.notFound(
1615               "Cannot find a metadata field with id '%s' from event with id '%s' and the metadata type '%s'.",
1616               key, id, type));
1617     } else if (field.isRequired() && StringUtils.isBlank(updatedFields.get(key))) {
1618       return Opt.some(R.badRequest(String.format(
1619               "The event metadata field with id '%s' and the metadata type '%s' is required and can not be empty!.",
1620               key, type)));
1621     }
1622     return Opt.none();
1623   }
1624 
1625   @DELETE
1626   @Path("{eventId}/metadata")
1627   @RestQuery(name = "deleteeventmetadata", description = "Delete the metadata namespace catalog of the specified event. This will remove all fields and values of the catalog.", returnDescription = "", pathParameters = {
1628           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) }, restParameters = {
1629                   @RestParameter(name = "type", isRequired = true, description = "The type of metadata to delete", type = STRING) }, responses = {
1630                           @RestResponse(description = "The metadata of the given namespace has been updated.", responseCode = HttpServletResponse.SC_NO_CONTENT),
1631                           @RestResponse(description = "The main metadata catalog dublincore/episode cannot be deleted as it has mandatory fields.", responseCode = HttpServletResponse.SC_FORBIDDEN),
1632                           @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1633   public Response deleteEventMetadataByType(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1634           @QueryParam("type") String type) throws SearchIndexException {
1635     for (final Event event : indexService.getEvent(id, elasticsearchIndex)) {
1636       Opt<MediaPackageElementFlavor> flavor = getFlavor(type);
1637       if (flavor.isNone()) {
1638         return R.badRequest(
1639                 String.format("Unable to parse type '%s' as a flavor so unable to find the matching catalog.", type));
1640       }
1641       EventCatalogUIAdapter eventCatalogUIAdapter = indexService.getCommonEventCatalogUIAdapter();
1642       if (flavor.get().equals(eventCatalogUIAdapter.getFlavor())) {
1643         return Response
1644                 .status(Status.FORBIDDEN).entity(String
1645                         .format("Unable to delete mandatory metadata catalog with type '%s' for event '%s'", type, id))
1646                 .build();
1647       }
1648       try {
1649         indexService.removeCatalogByFlavor(event, flavor.get());
1650       } catch (NotFoundException e) {
1651         return ApiResponseBuilder.notFound(e.getMessage());
1652       } catch (IndexServiceException e) {
1653         logger.error("Unable to remove metadata catalog with type '{}' from event '{}'", type, id, e);
1654         throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1655       } catch (IllegalStateException e) {
1656         logger.debug("Unable to remove metadata catalog with type '{}' from event '{}'", type, id, e);
1657         throw new WebApplicationException(e, Status.BAD_REQUEST);
1658       } catch (UnauthorizedException e) {
1659         return Response.status(Status.UNAUTHORIZED).build();
1660       }
1661       return Response.noContent().build();
1662     }
1663     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1664   }
1665 
1666   @GET
1667   @Path("{eventId}/publications")
1668   @RestQuery(name = "geteventpublications", description = "Returns an event's list of publications.",
1669              returnDescription = "",
1670              pathParameters = {
1671                @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
1672              },
1673              restParameters = {
1674                @RestParameter(name = "sign", description = "Whether public distribution urls should be signed.",
1675                               isRequired = false, type = Type.BOOLEAN),
1676                @RestParameter(name = "includeInternalPublication", description = "Whether internal publications should be included.",
1677                               isRequired = false, type = Type.BOOLEAN)
1678              },
1679              responses = {
1680                   @RestResponse(description = "The list of publications is returned.", responseCode = HttpServletResponse.SC_OK),
1681                   @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1682 
1683     public Response getEventPublications(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1684           @QueryParam("sign") boolean sign, @QueryParam("includeInternalPublication") boolean includeInternalPublication) throws Exception {
1685     try {
1686       final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
1687       final Opt<Event> event = indexService.getEvent(id, elasticsearchIndex);
1688       if (event.isSome()) {
1689         return ApiResponseBuilder.Json.ok(acceptHeader, arr(getPublications(event.get(), sign, includeInternalPublication, requestedVersion)));
1690       } else {
1691         return ApiResponseBuilder.notFound(String.format("Unable to find event with id '%s'", id));
1692       }
1693     } catch (SearchIndexException e) {
1694       logger.error("Unable to get list of publications from event with id '{}'", id, e);
1695       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1696     }
1697   }
1698 
1699 
1700   private List<JValue> getPublications(Event event, Boolean withSignedUrls, Boolean includeInternalPublication, ApiVersion requestedVersion) {
1701         return event.getPublications().stream()
1702         .filter(publication -> ((includeInternalPublication != null && includeInternalPublication && !requestedVersion.isSmallerThan(VERSION_1_11_0)) || EventUtils.internalChannelFilter.apply(publication)))
1703         .map(p -> getPublication(p, withSignedUrls, requestedVersion))
1704         .collect(Collectors.toList());
1705   }
1706 
1707   public JObject getPublication(Publication publication, Boolean sign, ApiVersion requestedVersion) {
1708     // signing publication URLs was introduced in 1.7.0
1709     URI publicationUrl = publication.getURI();
1710     if (!requestedVersion.isSmallerThan(VERSION_1_7_0)) {
1711       publicationUrl = getSignedUrl(publicationUrl, sign);
1712     }
1713 
1714     return obj(f("id", v(publication.getIdentifier())), f("channel", v(publication.getChannel())),
1715             f("mediatype", v(publication.getMimeType(), BLANK)),
1716             f("url", v(publicationUrl, BLANK)),
1717             f("media", arr(getPublicationTracksJson(publication, sign, requestedVersion))),
1718             f("attachments", arr(getPublicationAttachmentsJson(publication, sign))),
1719             f("metadata", arr(getPublicationCatalogsJson(publication, sign))));
1720   }
1721 
1722   private URI getSignedUrl(URI url, boolean sign) {
1723     if (url == null || !sign) {
1724       return url;
1725     }
1726 
1727     if (urlSigningService.accepts(url.toString())) {
1728       try {
1729         return URI.create(urlSigningService.sign(url.toString(), expireSeconds, null, null));
1730       } catch (UrlSigningException e) {
1731         logger.error("Unable to sign URI {}", url, e);
1732       }
1733     }
1734     return url;
1735   }
1736 
1737   private List<JValue> getPublicationTracksJson(Publication publication, Boolean sign, ApiVersion requestedVersion) {
1738     List<JValue> tracks = new ArrayList<>();
1739     for (Track track : publication.getTracks()) {
1740 
1741       VideoStream[] videoStreams = TrackSupport.byType(track.getStreams(), VideoStream.class);
1742       List<Field> trackInfo = new ArrayList<>();
1743 
1744       if (videoStreams.length > 0) {
1745         // Only supporting one stream, like in many other places...
1746         final VideoStream videoStream = videoStreams[0];
1747         if (videoStream.getBitRate() != null)
1748           trackInfo.add(f("bitrate", v(videoStream.getBitRate())));
1749         if (videoStream.getFrameRate() != null)
1750           trackInfo.add(f("framerate", v(videoStream.getFrameRate())));
1751         if (videoStream.getFrameCount() != null)
1752           trackInfo.add(f("framecount", v(videoStream.getFrameCount())));
1753         if (videoStream.getFrameWidth() != null)
1754           trackInfo.add(f("width", v(videoStream.getFrameWidth())));
1755         if (videoStream.getFrameHeight() != null)
1756           trackInfo.add(f("height", v(videoStream.getFrameHeight())));
1757       }
1758 
1759       if (!requestedVersion.isSmallerThan(VERSION_1_7_0)) {
1760         trackInfo.add(f("is_master_playlist", v(track.isMaster())));
1761         trackInfo.add(f("is_live", v(track.isLive())));
1762       }
1763 
1764       tracks.add(obj(f("id", v(track.getIdentifier(), BLANK)), f("mediatype", v(track.getMimeType(), BLANK)),
1765               f("url", v(getSignedUrl(track.getURI(), sign), BLANK)), f("flavor", v(track.getFlavor(), BLANK)),
1766               f("size", v(track.getSize())), f("checksum", v(track.getChecksum(), BLANK)),
1767               f("tags", arr(track.getTags())), f("has_audio", v(track.hasAudio())),
1768               f("has_video", v(track.hasVideo())), f("duration", v(track.getDuration(), NULL)),
1769               f("description", v(track.getDescription(), BLANK))).merge(trackInfo));
1770     }
1771     return tracks;
1772   }
1773 
1774   private List<JValue> getPublicationAttachmentsJson(Publication publication, Boolean sign) {
1775     List<JValue> attachments = new ArrayList<>();
1776     for (Attachment attachment : publication.getAttachments()) {
1777       attachments.add(
1778               obj(f("id", v(attachment.getIdentifier(), BLANK)), f("mediatype", v(attachment.getMimeType(), BLANK)),
1779                       f("url", v(getSignedUrl(attachment.getURI(), sign), BLANK)),
1780                       f("flavor", v(attachment.getFlavor(), BLANK)), f("ref", v(attachment.getReference(), BLANK)),
1781                       f("size", v(attachment.getSize())), f("checksum", v(attachment.getChecksum(), BLANK)),
1782                       f("tags", arr(attachment.getTags()))));
1783     }
1784     return attachments;
1785   }
1786 
1787   private List<JValue> getPublicationCatalogsJson(Publication publication, Boolean sign) {
1788     List<JValue> catalogs = new ArrayList<>();
1789     for (Catalog catalog : publication.getCatalogs()) {
1790       catalogs.add(obj(f("id", v(catalog.getIdentifier(), BLANK)), f("mediatype", v(catalog.getMimeType(), BLANK)),
1791               f("url", v(getSignedUrl(catalog.getURI(), sign), BLANK)),
1792               f("flavor", v(catalog.getFlavor(), BLANK)), f("size", v(catalog.getSize())),
1793               f("checksum", v(catalog.getChecksum(), BLANK)), f("tags", arr(catalog.getTags()))));
1794     }
1795     return catalogs;
1796   }
1797 
1798   @GET
1799   @Path("{eventId}/publications/{publicationId}")
1800   @RestQuery(name = "geteventpublication", description = "Returns a single publication.", returnDescription = "",
1801              pathParameters = {
1802                @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING),
1803                @RestParameter(name = "publicationId", description = "The publication id", isRequired = true, type = STRING)
1804              },
1805              restParameters = {
1806                @RestParameter(name = "sign", description = "Whether public distribution urls should be signed.",
1807                               isRequired = false, type = Type.BOOLEAN)
1808              },
1809              responses = {
1810                   @RestResponse(description = "The track details are returned.", responseCode = HttpServletResponse.SC_OK),
1811                   @RestResponse(description = "The specified event or publication does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1812 
1813   public Response getEventPublication(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String eventId,
1814           @PathParam("publicationId") String publicationId, @QueryParam("sign") boolean sign) throws Exception {
1815     try {
1816       final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
1817       return ApiResponseBuilder.Json.ok(acceptHeader, getPublication(eventId, publicationId, sign, requestedVersion));
1818     } catch (NotFoundException e) {
1819       return ApiResponseBuilder.notFound(e.getMessage());
1820     } catch (SearchIndexException e) {
1821       logger.error("Unable to get list of publications from event with id '{}'", eventId, e);
1822       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1823     }
1824   }
1825 
1826 
1827   private JObject getPublication(String eventId, String publicationId, Boolean withSignedUrls, ApiVersion requestedVersion)
1828           throws SearchIndexException, NotFoundException {
1829     for (final Event event : indexService.getEvent(eventId, elasticsearchIndex)) {
1830       List<Publication> publications;
1831       publications = event.getPublications().stream().filter(publication -> (!requestedVersion.isSmallerThan(VERSION_1_11_0) || EventUtils.internalChannelFilter.apply(publication))).collect(Collectors.toList());
1832       for (Publication publication : publications) {
1833         if (publicationId.equals(publication.getIdentifier())) {
1834           return getPublication(publication, withSignedUrls, requestedVersion);
1835         }
1836       }
1837       throw new NotFoundException(
1838               String.format("Unable to find publication with id '%s' in event with id '%s'", publicationId, eventId));
1839     }
1840     throw new NotFoundException(String.format("Unable to find event with id '%s'", eventId));
1841   }
1842 
1843   /**
1844    * Get an {@link AccessControlList} from an {@link Event}.
1845    *
1846    * @param event
1847    *          The {@link Event} to get the ACL from.
1848    * @return The {@link AccessControlList} stored in the {@link Event}
1849    */
1850   protected static AccessControlList getAclFromEvent(Event event) {
1851     AccessControlList activeAcl = new AccessControlList();
1852     try {
1853       if (event.getAccessPolicy() != null)
1854         activeAcl = AccessControlParser.parseAcl(event.getAccessPolicy());
1855     } catch (Exception e) {
1856       logger.error("Unable to parse access policy", e);
1857     }
1858     return activeAcl;
1859   }
1860 
1861   private JValue getJsonStream(Stream stream) {
1862     List<Field> fields = new ArrayList<>();
1863     if (stream instanceof AudioStream) {
1864       AudioStream audioStream = (AudioStream) stream;
1865       if (audioStream.getBitDepth() != null)
1866         fields.add(f("bitdepth", v(audioStream.getBitDepth())));
1867       if (audioStream.getBitRate() != null)
1868         fields.add(f("bitrate", v(audioStream.getBitRate())));
1869       if (audioStream.getCaptureDevice() != null)
1870         fields.add(f("capturedevice", v(audioStream.getCaptureDevice())));
1871       if (audioStream.getCaptureDeviceVendor() != null)
1872         fields.add(f("capturedevicevendor", v(audioStream.getCaptureDeviceVendor())));
1873       if (audioStream.getCaptureDeviceVersion() != null)
1874         fields.add(f("capturedeviceversion", v(audioStream.getCaptureDeviceVersion())));
1875       if (audioStream.getChannels() != null)
1876         fields.add(f("channels", v(audioStream.getChannels())));
1877       if (audioStream.getEncoderLibraryVendor() != null)
1878         fields.add(f("encoderlibraryvendor", v(audioStream.getEncoderLibraryVendor())));
1879       if (audioStream.getFormat() != null)
1880         fields.add(f("format", v(audioStream.getFormat())));
1881       if (audioStream.getFormatVersion() != null)
1882         fields.add(f("formatversion", v(audioStream.getFormatVersion())));
1883       if (audioStream.getFrameCount() != null)
1884         fields.add(f("framecount", v(audioStream.getFrameCount())));
1885       if (audioStream.getIdentifier() != null)
1886         fields.add(f("identifier", v(audioStream.getIdentifier())));
1887       if (audioStream.getPkLevDb() != null)
1888         fields.add(f("pklevdb", v(audioStream.getPkLevDb())));
1889       if (audioStream.getRmsLevDb() != null)
1890         fields.add(f("rmslevdb", v(audioStream.getRmsLevDb())));
1891       if (audioStream.getRmsPkDb() != null)
1892         fields.add(f("rmspkdb", v(audioStream.getRmsPkDb())));
1893       if (audioStream.getSamplingRate() != null)
1894         fields.add(f("samplingrate", v(audioStream.getSamplingRate())));
1895     } else if (stream instanceof VideoStream) {
1896       VideoStream videoStream = (VideoStream) stream;
1897       if (videoStream.getBitRate() != null)
1898         fields.add(f("bitrate", v(videoStream.getBitRate())));
1899       if (videoStream.getCaptureDevice() != null)
1900         fields.add(f("capturedevice", v(videoStream.getCaptureDevice())));
1901       if (videoStream.getCaptureDeviceVendor() != null)
1902         fields.add(f("capturedevicevendor", v(videoStream.getCaptureDeviceVendor())));
1903       if (videoStream.getCaptureDeviceVersion() != null)
1904         fields.add(f("capturedeviceversion", v(videoStream.getCaptureDeviceVersion())));
1905       if (videoStream.getEncoderLibraryVendor() != null)
1906         fields.add(f("encoderlibraryvendor", v(videoStream.getEncoderLibraryVendor())));
1907       if (videoStream.getFormat() != null)
1908         fields.add(f("format", v(videoStream.getFormat())));
1909       if (videoStream.getFormatVersion() != null)
1910         fields.add(f("formatversion", v(videoStream.getFormatVersion())));
1911       if (videoStream.getFrameCount() != null)
1912         fields.add(f("framecount", v(videoStream.getFrameCount())));
1913       if (videoStream.getFrameHeight() != null)
1914         fields.add(f("frameheight", v(videoStream.getFrameHeight())));
1915       if (videoStream.getFrameRate() != null)
1916         fields.add(f("framerate", v(videoStream.getFrameRate())));
1917       if (videoStream.getFrameWidth() != null)
1918         fields.add(f("framewidth", v(videoStream.getFrameWidth())));
1919       if (videoStream.getIdentifier() != null)
1920         fields.add(f("identifier", v(videoStream.getIdentifier())));
1921       if (videoStream.getScanOrder() != null)
1922         fields.add(f("scanorder", v(videoStream.getScanOrder().toString())));
1923       if (videoStream.getScanType() != null)
1924         fields.add(f("scantype", v(videoStream.getScanType().toString())));
1925     }
1926     return obj(fields);
1927   }
1928 
1929   private String getEventUrl(String eventId) {
1930     return UrlSupport.concat(endpointBaseUrl, eventId);
1931   }
1932 
1933   @GET
1934   @Path("{eventId}/scheduling")
1935   @Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_1_0, ApiMediaType.VERSION_1_2_0, ApiMediaType.VERSION_1_3_0,
1936               ApiMediaType.VERSION_1_4_0, ApiMediaType.VERSION_1_5_0, ApiMediaType.VERSION_1_6_0,
1937               ApiMediaType.VERSION_1_7_0, ApiMediaType.VERSION_1_8_0, ApiMediaType.VERSION_1_9_0,
1938               ApiMediaType.VERSION_1_10_0, ApiMediaType.VERSION_1_11_0 })
1939   @RestQuery(name = "geteventscheduling", description = "Returns an event's scheduling information.", returnDescription = "", pathParameters = {
1940       @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) }, responses = {
1941       @RestResponse(description = "The scheduling information for the specified event is returned.", responseCode = HttpServletResponse.SC_OK),
1942       @RestResponse(description = "The specified event has no scheduling information.", responseCode = HttpServletResponse.SC_NO_CONTENT),
1943       @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1944   public Response getEventScheduling(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id)
1945       throws Exception {
1946     try {
1947       final Opt<Event> event = indexService.getEvent(id, elasticsearchIndex);
1948 
1949       if (event.isNone()) {
1950         return ApiResponseBuilder.notFound(String.format("Unable to find event with id '%s'", id));
1951       }
1952 
1953       final JObject scheduling = SchedulingInfo.of(event.get().getIdentifier(), schedulerService).toJson();
1954       if (!scheduling.isEmpty()) {
1955         return ApiResponseBuilder.Json.ok(acceptHeader, scheduling);
1956       }
1957       return Response.noContent().build();
1958     } catch (SearchIndexException e) {
1959       logger.error("Unable to get list of publications from event with id '{}'", id, e);
1960       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1961     }
1962   }
1963 
1964   @PUT
1965   @Path("{eventId}/scheduling")
1966   @Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_1_0, ApiMediaType.VERSION_1_2_0, ApiMediaType.VERSION_1_3_0,
1967               ApiMediaType.VERSION_1_4_0, ApiMediaType.VERSION_1_5_0, ApiMediaType.VERSION_1_6_0,
1968               ApiMediaType.VERSION_1_7_0, ApiMediaType.VERSION_1_8_0, ApiMediaType.VERSION_1_9_0,
1969               ApiMediaType.VERSION_1_10_0, ApiMediaType.VERSION_1_11_0 })
1970   @RestQuery(name = "updateeventscheduling", description = "Update an event's scheduling information.", returnDescription = "", pathParameters = {
1971       @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = Type.STRING) }, restParameters = {
1972       @RestParameter(name = "scheduling", isRequired = true, description = "Scheduling Information", type = Type.STRING),
1973       @RestParameter(name = "allowConflict", description = "Allow conflicts when updating scheduling", isRequired = false, type = Type.BOOLEAN) }, responses = {
1974       @RestResponse(description = "The  scheduling information for the specified event is updated.", responseCode = HttpServletResponse.SC_NO_CONTENT),
1975       @RestResponse(description = "The specified event has no scheduling information to update.", responseCode = HttpServletResponse.SC_NOT_ACCEPTABLE),
1976       @RestResponse(description = "The scheduling information could not be updated due to a conflict.", responseCode = HttpServletResponse.SC_CONFLICT),
1977       @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1978   public Response updateEventScheduling(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1979                                  @FormParam("scheduling") String scheduling,
1980                                  @FormParam("allowConflict") @DefaultValue("false") boolean allowConflict) throws Exception {
1981     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
1982     final Opt<Event> event = indexService.getEvent(id, elasticsearchIndex);
1983 
1984     if (requestedVersion.isSmallerThan(ApiVersion.VERSION_1_2_0)) {
1985         allowConflict = false;
1986     }
1987     if (event.isNone()) {
1988       return ApiResponseBuilder.notFound(String.format("Unable to find event with id '%s'", id));
1989     }
1990     final JSONParser parser = new JSONParser();
1991     JSONObject parsedJson;
1992     try {
1993        parsedJson = (JSONObject) parser.parse(scheduling);
1994     } catch (ParseException e) {
1995       logger.debug("Client sent unparsable scheduling information for event {}: {}", id, scheduling);
1996       return RestUtil.R.badRequest("Unparsable scheduling information");
1997     }
1998     Optional<Response> clientError = updateSchedulingInformation(parsedJson, id, requestedVersion, allowConflict);
1999     return clientError.orElse(Response.noContent().build());
2000   }
2001 
2002   private Optional<Response> updateSchedulingInformation(
2003       JSONObject parsedScheduling,
2004       String id,
2005       ApiVersion requestedVersion,
2006       boolean allowConflict) throws Exception {
2007 
2008     SchedulingInfo schedulingInfo;
2009     try {
2010       schedulingInfo = SchedulingInfo.of(parsedScheduling);
2011     } catch (DateTimeParseException e) {
2012       logger.debug("Client sent unparsable start or end date for event {}", id);
2013       return Optional.of(RestUtil.R.badRequest("Unparsable date in scheduling information"));
2014     }
2015     final TechnicalMetadata technicalMetadata = schedulerService.getTechnicalMetadata(id);
2016 
2017     // When "inputs" is updated, capture agent configuration needs to be merged
2018     Opt<Map<String, String>> caConfig = Opt.none();
2019     if (schedulingInfo.getInputs().isSome()) {
2020       final Map<String, String> configMap = new HashMap<>(technicalMetadata.getCaptureAgentConfiguration());
2021       configMap.put(CaptureParameters.CAPTURE_DEVICE_NAMES, schedulingInfo.getInputs().get());
2022       caConfig = Opt.some(configMap);
2023     }
2024 
2025     try {
2026       schedulerService.updateEvent(
2027           id,
2028           schedulingInfo.getStartDate(),
2029           schedulingInfo.getEndDate(),
2030           schedulingInfo.getAgentId(),
2031           Opt.none(),
2032           Opt.none(),
2033           Opt.none(),
2034           caConfig,
2035           allowConflict);
2036     } catch (SchedulerConflictException e) {
2037       final List<MediaPackage> conflictingEvents = getConflictingEvents(
2038           schedulingInfo.merge(technicalMetadata), agentStateService, schedulerService);
2039       logger.debug("Client tried to change scheduling information causing a conflict for event {}.", id);
2040       return Optional.of(ApiResponseBuilder.Json.conflict(requestedVersion,
2041           arr(convertConflictingEvents(Optional.of(id), conflictingEvents, indexService, elasticsearchIndex))));
2042     }
2043     return Optional.empty();
2044   }
2045 
2046   @POST
2047   @Path("{eventId}/track")
2048   @Consumes(MediaType.MULTIPART_FORM_DATA)
2049   @RestQuery(name = "updateFlavorWithTrack", description = "Update an events track for a given flavor", returnDescription = "",
2050           pathParameters = {
2051                   @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) },
2052           restParameters = {
2053                   @RestParameter(description = "Flavor to add track to, e.g. captions/source",
2054                       isRequired = true, name = "flavor", type = RestParameter.Type.STRING),
2055                   @RestParameter(description = "Comma separated list of tags for the given track, e.g. archive,publish. "
2056                       + "If a 'lang:LANG-CODE' tag exists and overwriteExisting=true "
2057                       + "only tracks with same lang tag and flavor will be replaced. This behavior is used for captions.",
2058                       isRequired = false, name = "tags", type = RestParameter.Type.STRING),
2059                   @RestParameter(description = "If true, all other tracks in the specified flavor are REMOVED. "
2060                       + "If tags argument contains a lang:LANG-CODE tag, only elements with same tag would be removed.",
2061                       isRequired = true, name = "overwriteExisting", type = RestParameter.Type.BOOLEAN),
2062                   @RestParameter(description = "The track file", isRequired = true, name = "track", type = RestParameter.Type.FILE),
2063           },
2064           responses = {
2065                   @RestResponse(description = "The specified event does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND),
2066                   @RestResponse(description = "The track has been added to the event.", responseCode = HttpServletResponse.SC_OK),
2067                   @RestResponse(description = "The request is invalid or inconsistent.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2068           })
2069   public Response updateFlavorWithTrack(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
2070           @Context HttpServletRequest request) {
2071     logger.debug("updateFlavorWithTrack called");
2072     try {
2073       boolean overwriteExisting = false;
2074       MediaPackageElementFlavor tmpFlavor = MediaPackageElementFlavor.parseFlavor("addTrack/temporary");
2075       MediaPackageElementFlavor newFlavor = null;
2076       Opt<Event> event;
2077       List<String> tags = null;
2078       String langTag = null;
2079 
2080       try {
2081         event = indexService.getEvent(id, elasticsearchIndex);
2082       } catch (SearchIndexException e) {
2083         return RestUtil.R.badRequest(String.format("Error while searching for event with id %s; %s", id, e.getMessage()));
2084       }
2085 
2086       if (event.isNone()) {
2087         return ApiResponseBuilder.notFound(String.format("Unable to find event with id '%s'", id));
2088       }
2089       MediaPackage mp = indexService.getEventMediapackage(event.get());
2090 
2091       try {
2092         if (workflowService.mediaPackageHasActiveWorkflows(mp.getIdentifier().toString())) {
2093           return RestUtil.R.conflict(String.format("Cannot update while a workflow is running on event '%s'", id));
2094         }
2095       } catch (WorkflowDatabaseException e) {
2096         return RestUtil.R.serverError();
2097       }
2098 
2099       if (!ServletFileUpload.isMultipartContent(request)) {
2100         throw new IllegalArgumentException("No multipart content");
2101       }
2102       for (FileItemIterator iter = new ServletFileUpload().getItemIterator(request); iter.hasNext();) {
2103         FileItemStream item = iter.next();
2104         String fieldName = item.getFieldName();
2105         if (item.isFormField()) {
2106           if ("flavor".equals(fieldName)) {
2107             String flavorString = Streams.asString(item.openStream());
2108             try {
2109               newFlavor = MediaPackageElementFlavor.parseFlavor(flavorString);
2110             } catch (IllegalArgumentException e) {
2111               return RestUtil.R.badRequest(String.format("Could not parse flavor %s; %s", flavorString, e.getMessage()));
2112             }
2113           } else if ("tags".equals(fieldName)) {
2114             String tagsString = Streams.asString(item.openStream());
2115             if (StringUtils.isNotBlank(tagsString)) {
2116               tags = List.of(StringUtils.split(tagsString, ','));
2117               // find lang tag if exists
2118               for (String tag : tags) {
2119                 if (StringUtils.startsWith(StringUtils.trimToEmpty(tag), "lang:")) {
2120                   // lang tag is set
2121                   langTag = StringUtils.trimToEmpty(tag);
2122                   break;
2123                 }
2124               }
2125             }
2126           } else if ("overwriteExisting".equals(fieldName)) {
2127             overwriteExisting = Boolean.parseBoolean(Streams.asString(item.openStream()));
2128           }
2129         } else {
2130           // Add track with temporary flavor
2131           if ("track".equals(item.getFieldName())) {
2132             mp = ingestService.addTrack(item.openStream(), item.getName(), tmpFlavor, mp);
2133           }
2134         }
2135       }
2136 
2137       if (overwriteExisting) {
2138         // remove existing attachments of the new flavor
2139         Track[] existing = mp.getTracks(newFlavor);
2140         for (int i = 0; i < existing.length; i++) {
2141           // if lang tag is set, remove only matching elements
2142           if (null == langTag || existing[i].containsTag(langTag)) {
2143             mp.remove(existing[i]);
2144             logger.debug("Overwriting existing asset {} {}", tmpFlavor, newFlavor);
2145           }
2146         }
2147       }
2148       // correct the flavor of the new attachment
2149       for (Track track : mp.getTracks(tmpFlavor)) {
2150         track.setFlavor(newFlavor);
2151         if (null != tags) {
2152           for (String tag : tags) {
2153             track.addTag(tag);
2154           }
2155         }
2156       }
2157       logger.debug("Updated asset {} {}", tmpFlavor, newFlavor);
2158 
2159       try {
2160         assetManager.takeSnapshot(mp);
2161       } catch (AssetManagerException e) {
2162         logger.error("Error while adding the updated media package ({}) to the archive", mp.getIdentifier(), e);
2163         return RestUtil.R.badRequest(e.getMessage());
2164       }
2165 
2166       return Response.status(Status.OK).build();
2167     } catch (IllegalArgumentException | IOException | FileUploadException | IndexServiceException | IngestException
2168             | MediaPackageException e) {
2169       return RestUtil.R.badRequest(String.format("Could not add track: %s", e.getMessage()));
2170     }
2171   }
2172 }