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