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(
416       name = "getevent",
417       description = "Returns a single event. By setting the optional sign parameter to true, the method will pre-sign "
418           + "distribution urls if signing is turned on in Opencast. Remember to consider the maximum validity of "
419           + "signed URLs when caching this response.",
420       returnDescription = "",
421       pathParameters = {
422           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
423       },
424       restParameters = {
425           @RestParameter(name = "sign", isRequired = false, description = "Whether public distribution urls should be "
426               + "signed.", type = Type.BOOLEAN),
427           @RestParameter(name = "withacl", isRequired = false, description = "Whether the acl metadata should be "
428               + "included in the response.", type = Type.BOOLEAN),
429           @RestParameter(name = "withmetadata", isRequired = false, description = "Whether the metadata catalogs "
430               + "should be included in the response.", type = Type.BOOLEAN),
431           @RestParameter(name = "withscheduling", isRequired = false, description = "Whether the scheduling "
432               + "information should be included in the response.", type = Type.BOOLEAN),
433           @RestParameter(name = "withpublications", isRequired = false, description = "Whether the publication ids and "
434               + "urls should be included in the response.", type = Type.BOOLEAN),
435           @RestParameter(name = "includeInternalPublication", isRequired = false, description = "Whether internal "
436               + "publications should be included.", type = Type.BOOLEAN)
437       },
438       responses = {
439           @RestResponse(description = "The event is returned.", responseCode = HttpServletResponse.SC_OK),
440           @RestResponse(description = "The specified event does not exist.",
441               responseCode = HttpServletResponse.SC_NOT_FOUND)
442       })
443   @Operation(
444       summary = "Get a single event",
445       description = "Returns a single event. By setting the optional sign parameter to true, the method will pre-sign "
446           + "distribution urls if signing is turned on in Opencast. Remember to consider the maximum validity of "
447           + "signed URLs when caching this response."
448   )
449   public Response getEvent(
450       @HeaderParam("Accept") String acceptHeader,
451       @Parameter(description = "The event id", required = true)
452       @PathParam("eventId") String id,
453       @Parameter(description = "Whether public distribution urls should be signed.")
454       @QueryParam("sign") boolean sign,
455       @Parameter(description = "Whether the acl metadata should be included in the response.")
456       @QueryParam("withacl") Boolean withAcl,
457       @Parameter(description = "Whether the metadata catalogs should be included in the response.")
458       @QueryParam("withmetadata") Boolean withMetadata,
459       @Parameter(description = "Whether the scheduling information should be included in the response.")
460       @QueryParam("withscheduling") Boolean withScheduling,
461       @Parameter(description = "Whether the publication ids and urls should be included in the response.")
462       @QueryParam("withpublications") Boolean withPublications,
463       @Parameter(description = "Whether internal publications should be included.")
464       @QueryParam("includeInternalPublication") Boolean includeInternalPublication)
465           throws Exception {
466     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
467     if (requestedVersion.isSmallerThan(VERSION_1_1_0)) {
468       // withScheduling was added in version 1.1.0 and should be ignored for smaller versions
469       withScheduling = false;
470     }
471     Optional<Event> eventOpt = indexService.getEvent(id, elasticsearchIndex);
472     if (eventOpt.isPresent()) {
473       Event event = eventOpt.get();
474       event.updatePreview(previewSubtype);
475       return ApiResponseBuilder.Json.ok(
476           requestedVersion, eventToJSON(event, withAcl, withMetadata, withScheduling, withPublications,
477               includeInternalPublication, sign, requestedVersion));
478     }
479     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
480   }
481 
482   @GET
483   @Path("{eventId}/media")
484   @RestQuery(
485       name = "geteventmedia",
486       description = "Returns media tracks of specific single event.",
487       returnDescription = "",
488       pathParameters = {
489           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
490       },
491       responses = {
492           @RestResponse(description = "The event's media is returned.", responseCode = HttpServletResponse.SC_OK),
493           @RestResponse(description = "The specified event does not exist.",
494               responseCode = HttpServletResponse.SC_NOT_FOUND)
495       })
496   @Operation(
497       summary = "Get media tracks of a single event",
498       description = "Returns media tracks of specific single event."
499   )
500   @Parameters({
501       @Parameter(name = "eventId", description = "The event id", required = true, in = ParameterIn.PATH),
502       @Parameter(name = "Accept", description = "The accept header", required = true, in = ParameterIn.HEADER)
503   })
504   @ApiResponses(value = {
505       @ApiResponse(responseCode = "200", description = "The event's media is returned."),
506       @ApiResponse(responseCode = "404", description = "The specified event does not exist.")
507   })
508   public Response getEventMedia(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id)
509           throws Exception {
510     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
511     List<TrackImpl> tracks = new ArrayList<>();
512 
513     Optional<Event> eventOpt = indexService.getEvent(id, elasticsearchIndex);
514     if (eventOpt.isPresent()) {
515       final MediaPackage mp = indexService.getEventMediapackage(eventOpt.get());
516       for (Track track : mp.getTracks()) {
517         if (track instanceof TrackImpl) {
518           tracks.add((TrackImpl) track);
519         }
520       }
521 
522       JsonArray tracksJson = new JsonArray();
523       for (Track track : tracks) {
524         JsonObject trackJson = new JsonObject();
525         if (track.getChecksum() != null) {
526           trackJson.addProperty("checksum", track.getChecksum().toString());
527         }
528         if (track.getDescription() != null) {
529           trackJson.addProperty("description", track.getDescription());
530         }
531         if (track.getDuration() != null) {
532           trackJson.addProperty("duration", track.getDuration());
533         }
534         if (track.getElementDescription() != null) {
535           trackJson.addProperty("element-description", track.getElementDescription());
536         }
537         if (track.getFlavor() != null) {
538           trackJson.addProperty("flavor", track.getFlavor().toString());
539         }
540         if (track.getIdentifier() != null) {
541           trackJson.addProperty("identifier", track.getIdentifier());
542         }
543         if (track.getMimeType() != null) {
544           trackJson.addProperty("mimetype", track.getMimeType().toString());
545         }
546         trackJson.addProperty("size", track.getSize());
547 
548         if (!requestedVersion.isSmallerThan(VERSION_1_7_0)) {
549           trackJson.addProperty("has_video", track.hasVideo());
550           trackJson.addProperty("has_audio", track.hasAudio());
551           trackJson.addProperty("is_master_playlist", track.isMaster());
552           trackJson.addProperty("is_live", track.isLive());
553         }
554 
555         if (track.getStreams() != null) {
556           JsonObject streamsJson = new JsonObject();
557           for (Stream stream : track.getStreams()) {
558             streamsJson.add(stream.getIdentifier(), getJsonStream(stream));
559           }
560           trackJson.add("streams", streamsJson);
561         }
562 
563         trackJson.add("tags", arrayToJsonArray(track.getTags()));
564 
565         if (track.getURI() != null) {
566           trackJson.addProperty("uri", track.getURI().toString());
567         }
568 
569         tracksJson.add(trackJson);
570       }
571 
572       return ApiResponseBuilder.Json.ok(acceptHeader, tracksJson);
573     }
574 
575     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
576   }
577 
578   @DELETE
579   @Path("{eventId}")
580   @RestQuery(
581       name = "deleteevent",
582       description = "Deletes an event.",
583       returnDescription = "",
584       pathParameters = {
585           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
586       },
587       responses = {
588           @RestResponse(description = "The event has been deleted.", responseCode = HttpServletResponse.SC_NO_CONTENT),
589           @RestResponse(description = "The retraction of publications has started.",
590               responseCode = HttpServletResponse.SC_ACCEPTED),
591           @RestResponse(description = "The specified event does not exist.",
592               responseCode = HttpServletResponse.SC_NOT_FOUND)
593       })
594   public Response deleteEvent(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id)
595           throws SearchIndexException, UnauthorizedException {
596     final Optional<Event> event = indexService.getEvent(id, elasticsearchIndex);
597     if (event.isEmpty()) {
598       return RestUtil.R.notFound(id);
599     }
600     final IndexService.EventRemovalResult result;
601     try {
602       result = indexService.removeEvent(event.get(), retractWorkflowId);
603     } catch (WorkflowDatabaseException e) {
604       logger.error("Workflow database is not reachable. This may be a temporary problem.");
605       return RestUtil.R.serverError();
606     } catch (NotFoundException e) {
607       logger.error("Configured retract workflow not found. Check your configuration.");
608       return RestUtil.R.serverError();
609     }
610     switch (result) {
611       case SUCCESS:
612         return Response.noContent().build();
613       case RETRACTING:
614         return Response.accepted().build();
615       case GENERAL_FAILURE:
616         return Response.serverError().build();
617       case NOT_FOUND:
618         return RestUtil.R.notFound(id);
619       default:
620         throw new RuntimeException("Unknown EventRemovalResult type: " + result.name());
621     }
622   }
623 
624   @POST
625   @Path("{eventId}")
626   @RestQuery(
627       name = "updateeventmetadata",
628       description = "Updates an event.",
629       returnDescription = "",
630       pathParameters = {
631           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
632       },
633       restParameters = {
634           @RestParameter(name = "acl", isRequired = false, description = "A collection of roles with their possible "
635               + "action", type = Type.STRING),
636           @RestParameter(name = "metadata", isRequired = false, description = "Event metadata as Form param",
637               type = Type.STRING),
638           @RestParameter(name = "scheduling", isRequired = false, description = "Scheduling information as Form param",
639               type = Type.STRING),
640           @RestParameter(name = "presenter", isRequired = false, description = "Presenter movie track",
641               type = Type.FILE),
642           @RestParameter(name = "presentation", isRequired = false, description = "Presentation movie track",
643               type = Type.FILE),
644           @RestParameter(name = "audio", isRequired = false, description = "Audio track", type = Type.FILE),
645           @RestParameter(name = "processing", isRequired = false, description = "Processing instructions task "
646               + "configuration", type = Type.STRING),
647       },
648       responses = {
649           @RestResponse(description = "The event has been updated.", responseCode = HttpServletResponse.SC_NO_CONTENT),
650           @RestResponse(description = "The event could not be updated due to a scheduling conflict.",
651               responseCode = HttpServletResponse.SC_CONFLICT),
652           @RestResponse(description = "The specified event does not exist.",
653               responseCode = HttpServletResponse.SC_NOT_FOUND)
654       })
655   public Response updateEventMetadata(@HeaderParam("Accept") String acceptHeader, @Context HttpServletRequest request,
656           @PathParam("eventId") String eventId) {
657     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
658     try {
659       String startDatePattern = configuredMetadataFields.containsKey("startDate")
660           ? configuredMetadataFields.get("startDate").getPattern() : null;
661       String startTimePattern = configuredMetadataFields.containsKey("startTime")
662           ? configuredMetadataFields.get("startTime").getPattern() : null;
663       Optional<Event> eventOpt = indexService.getEvent(eventId, elasticsearchIndex);
664       if (eventOpt.isPresent()) {
665         Event event = eventOpt.get();
666         EventHttpServletRequest eventHttpServletRequest = EventHttpServletRequest.updateFromHttpServletRequest(event,
667                 request, getEventCatalogUIAdapters(), startDatePattern, startTimePattern);
668 
669         // FIXME: All of these update operations should be a part of a transaction to avoid a partially updated event.
670         if (eventHttpServletRequest.getMetadataList().isPresent()) {
671           indexService.updateEventMetadata(eventId, eventHttpServletRequest.getMetadataList().get(),
672               elasticsearchIndex);
673         }
674 
675         if (eventHttpServletRequest.getAcl().isPresent()) {
676           indexService.updateEventAcl(eventId, eventHttpServletRequest.getAcl().get(), elasticsearchIndex);
677         }
678 
679         if (eventHttpServletRequest.getProcessing().isPresent()) {
680 
681           if (!event.isScheduledEvent() || event.hasRecordingStarted()) {
682             return RestUtil.R.badRequest("Processing can't be updated for events that are already uploaded.");
683           }
684           JSONObject processing = eventHttpServletRequest.getProcessing().get();
685 
686           String workflowId = (String) processing.get("workflow");
687           if (workflowId == null) {
688             throw new IllegalArgumentException("No workflow template in metadata");
689           }
690 
691           Map<String, String> configuration = new HashMap<>();
692           if (eventHttpServletRequest.getProcessing().get().get("configuration") != null) {
693             configuration = new HashMap<>(
694                 (JSONObject) eventHttpServletRequest
695                     .getProcessing().get()
696                     .get("configuration"));
697           }
698 
699           Optional<Map<String, String>> caMetadataOpt = Optional.empty();
700           Optional<Map<String, String>> workflowConfigOpt = Optional.empty();
701 
702           Map<String, String> caMetadata = new HashMap<>(getSchedulerService().getCaptureAgentConfiguration(eventId));
703           if (!workflowId.equals(caMetadata.get(CaptureParameters.INGEST_WORKFLOW_DEFINITION))) {
704             caMetadata.put(CaptureParameters.INGEST_WORKFLOW_DEFINITION, workflowId);
705             caMetadataOpt = Optional.of(caMetadata);
706           }
707 
708           Map<String, String> oldWorkflowConfig = new HashMap<>(getSchedulerService().getWorkflowConfig(eventId));
709           if (!oldWorkflowConfig.equals(configuration)) {
710             workflowConfigOpt = Optional.of(configuration);
711           }
712 
713           if (!caMetadataOpt.isEmpty() || !workflowConfigOpt.isEmpty()) {
714             getSchedulerService().updateEvent(eventId, Optional.empty(), Optional.empty(), Optional.empty(),
715                     Optional.empty(), Optional.empty(), workflowConfigOpt, caMetadataOpt);
716           }
717         }
718 
719         if (eventHttpServletRequest.getScheduling().isPresent() && !requestedVersion.isSmallerThan(VERSION_1_1_0)) {
720           // Scheduling is only available for version 1.1.0 and above
721           Optional<Response> clientError = updateSchedulingInformation(
722               eventHttpServletRequest.getScheduling().get(), eventId, requestedVersion, false);
723           if (clientError.isPresent()) {
724             return clientError.get();
725           }
726         }
727 
728         return Response.noContent().build();
729       }
730       return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", eventId);
731     } catch (NotFoundException e) {
732       return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", eventId);
733     } catch (UnauthorizedException e) {
734       return Response.status(Status.UNAUTHORIZED).build();
735     } catch (IllegalArgumentException e) {
736       logger.debug("Unable to update event '{}'", eventId, e);
737       return RestUtil.R.badRequest(e.getMessage());
738     } catch (IndexServiceException e) {
739       logger.error("Unable to get multi part fields or file for event '{}'", eventId, e);
740       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
741     } catch (SearchIndexException e) {
742       logger.error("Unable to update event '{}'", eventId, e);
743       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
744     } catch (Exception e) {
745       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
746     }
747   }
748 
749   @POST
750   @Path("/")
751   @Consumes(MediaType.MULTIPART_FORM_DATA)
752   @RestQuery(
753       name = "createevent",
754       description = "Creates an event by sending metadata, access control list, processing instructions and files in a "
755           + "multipart request.",
756       returnDescription = "",
757       restParameters = {
758           @RestParameter(name = "acl", isRequired = false, description = "A collection of roles with their possible "
759               + "action", type = STRING),
760           @RestParameter(name = "metadata", description = "Event metadata as Form param", isRequired = false,
761               type = STRING),
762           @RestParameter(name = "scheduling", description = "Scheduling information as Form param", isRequired = false,
763               type = STRING),
764           @RestParameter(name = "presenter", description = "Presenter movie track", isRequired = false,
765               type = Type.FILE),
766           @RestParameter(name = "presentation", description = "Presentation movie track", isRequired = false,
767               type = Type.FILE),
768           @RestParameter(name = "audio", description = "Audio track", isRequired = false, type = Type.FILE),
769           @RestParameter(name = "processing", description = "Processing instructions task configuration",
770               isRequired = false, type = STRING)
771       },
772       responses = {
773           @RestResponse(description = "A new event is created and its identifier is returned in the Location header.",
774               responseCode = HttpServletResponse.SC_CREATED),
775           @RestResponse(description = "The event could not be created due to a scheduling conflict.",
776               responseCode = HttpServletResponse.SC_CONFLICT),
777           @RestResponse(description = "The request is invalid or inconsistent..",
778               responseCode = HttpServletResponse.SC_BAD_REQUEST)
779       })
780   public Response createNewEvent(@HeaderParam("Accept") String acceptHeader, @Context HttpServletRequest request) {
781     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
782     try {
783       String startDatePattern = configuredMetadataFields.containsKey("startDate")
784           ? configuredMetadataFields.get("startDate").getPattern() : null;
785       String startTimePattern = configuredMetadataFields.containsKey("startTime")
786           ? configuredMetadataFields.get("startTime").getPattern() : null;
787       EventHttpServletRequest eventHttpServletRequest = EventHttpServletRequest.createFromHttpServletRequest(request,
788           ingestService, getEventCatalogUIAdapters(), startDatePattern, startTimePattern);
789 
790       // If scheduling information is provided, the source has to be "SCHEDULE_SINGLE" or "SCHEDULE_MULTIPLE".
791       if (eventHttpServletRequest.getScheduling().isPresent() && !requestedVersion.isSmallerThan(VERSION_1_1_0)) {
792         // Scheduling is only available for version 1.1.0 and above
793         return scheduleNewEvent(eventHttpServletRequest, eventHttpServletRequest.getScheduling().get(),
794             requestedVersion);
795       }
796 
797       JSONObject source = new JSONObject();
798       source.put("type", "UPLOAD");
799       eventHttpServletRequest.setSource(source);
800       String eventId = indexService.createEvent(eventHttpServletRequest);
801       JsonObject json = new JsonObject();
802       json.addProperty("identifier", eventId);
803       return ApiResponseBuilder.Json.created(requestedVersion, URI.create(getEventUrl(eventId)), json);
804     } catch (IllegalArgumentException | DateTimeParseException e) {
805       logger.debug("Unable to create event", e);
806       return RestUtil.R.badRequest(e.getMessage());
807     } catch (SchedulerException | IndexServiceException e) {
808       if (e.getCause() != null && e.getCause() instanceof NotFoundException
809               || e.getCause() instanceof IllegalArgumentException) {
810         logger.debug("Unable to create event", e);
811         return RestUtil.R.badRequest(e.getCause().getMessage());
812       } else {
813         logger.error("Unable to create event", e);
814         throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
815       }
816     } catch (Exception e) {
817       logger.error("Unable to create event", e);
818       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
819     }
820   }
821 
822   private Response scheduleNewEvent(EventHttpServletRequest request, JSONObject scheduling, ApiVersion requestedVersion)
823           throws MediaPackageException, IOException, IngestException, SchedulerException,
824           NotFoundException, UnauthorizedException, SearchIndexException, java.text.ParseException {
825 
826     final SchedulingInfo schedulingInfo = SchedulingInfo.of(scheduling);
827     final JSONObject source = schedulingInfo.toSource();
828     request.setSource(source);
829 
830     try {
831       final String eventId = indexService.createEvent(request);
832 
833       if (StringUtils.isEmpty(eventId)) {
834         return RestUtil.R.badRequest("The date range provided did not include any events");
835       }
836 
837       if (eventId.contains(",")) {
838         // This the case when SCHEDULE_MULTIPLE is performed.
839         JsonArray eventArray = new JsonArray();
840         for (String id : eventId.split(",")) {
841           JsonObject eventObj = new JsonObject();
842           eventObj.addProperty("identifier", id);
843           eventArray.add(eventObj);
844         }
845         return ApiResponseBuilder.Json.ok(requestedVersion, eventArray);
846       }
847 
848       JsonObject eventJson = new JsonObject();
849       eventJson.addProperty("identifier", eventId);
850       return ApiResponseBuilder.Json.created(requestedVersion, URI.create(getEventUrl(eventId)), eventJson);
851     } catch (SchedulerConflictException e) {
852       List<MediaPackage> conflictingEvents =
853           getConflictingEvents(schedulingInfo, agentStateService, schedulerService);
854       logger.debug("Client tried to schedule conflicting event(s).");
855       JsonArray conflictArray = new JsonArray();
856       for (JsonObject conflict : convertConflictingEvents(
857           Optional.empty(), conflictingEvents, indexService, elasticsearchIndex)) {
858         conflictArray.add(conflict);
859       }
860       return ApiResponseBuilder.Json.conflict(requestedVersion, conflictArray);
861     }
862   }
863 
864   @GET
865   @Path("/")
866   @RestQuery(
867       name = "getevents",
868       description = "Returns a list of events. By setting the optional sign parameter to true, the method will "
869           + "pre-sign distribution urls if signing is turned on in Opencast. Remember to consider the maximum validity "
870           + "of signed URLs when caching this response.",
871       returnDescription = "",
872       restParameters = {
873           @RestParameter(name = "sign", isRequired = false, description = "Whether public distribution urls should be "
874               + "signed.", type = Type.BOOLEAN),
875           @RestParameter(name = "withacl", isRequired = false, description = "Whether the acl metadata should be "
876               + "included in the response.", type = Type.BOOLEAN),
877           @RestParameter(name = "withmetadata", isRequired = false, description = "Whether the metadata catalogs "
878               + "should be included in the response.", type = Type.BOOLEAN),
879           @RestParameter(name = "withscheduling", isRequired = false, description = "Whether the scheduling "
880               + "information should be included in the response.", type = Type.BOOLEAN),
881           @RestParameter(name = "withpublications", isRequired = false, description = "Whether the publication ids and "
882               + "urls should be included in the response.", type = Type.BOOLEAN),
883           @RestParameter(name = "includeInternalPublication", description = "Whether internal publications should be "
884               + "included.", isRequired = false, type = Type.BOOLEAN),
885           @RestParameter(name = "onlyWithWriteAccess", isRequired = false, description = "Whether only to get the "
886               + "events to which we have write access.", type = Type.BOOLEAN),
887           @RestParameter(name = "filter", isRequired = false, description = "Usage [Filter Name]:[Value to Filter With]"
888               + ". Multiple filters can be used by combining them with commas \",\". Available Filters: presenters, "
889               + "contributors, location, textFilter, series, subject. If API ver > 1.1.0 also: identifier, title, "
890               + "description, series_name, language, created, license, rightsholder, is_part_of, source, status, "
891               + "agent_id, start, technical_start.", type = STRING),
892           @RestParameter(name = "sort", description = "Sort the results based upon a list of comma seperated sorting "
893               + "criteria. In the comma seperated list each type of sorting is specified as a pair such as: "
894               + "<Sort Name>:ASC or <Sort Name>:DESC. Adding the suffix ASC or DESC sets the order as ascending or "
895               + "descending order and is mandatory.", isRequired = false, type = STRING),
896           @RestParameter(name = "limit", description = "The maximum number of results to return for a single request.",
897               isRequired = false, type = RestParameter.Type.INTEGER),
898           @RestParameter(name = "offset", description = "The index of the first result to return.", isRequired = false,
899               type = RestParameter.Type.INTEGER)
900       },
901       responses = {
902           @RestResponse(description = "A (potentially empty) list of events is returned.",
903               responseCode = HttpServletResponse.SC_OK)
904       })
905   public Response getEvents(@HeaderParam("Accept") String acceptHeader, @QueryParam("id") String id,
906           @QueryParam("commentReason") String reasonFilter, @QueryParam("commentResolution") String resolutionFilter,
907           @QueryParam("filter") List<String> filter, @QueryParam("sort") String sort,
908           @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit, @QueryParam("sign") boolean sign,
909           @QueryParam("withacl") Boolean withAcl, @QueryParam("withmetadata") Boolean withMetadata,
910           @QueryParam("withscheduling") Boolean withScheduling,
911           @QueryParam("onlyWithWriteAccess") Boolean onlyWithWriteAccess,
912           @QueryParam("withpublications") Boolean withPublications,
913           @QueryParam("includeInternalPublication") Boolean includeInternalPublication) {
914     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
915     if (requestedVersion.isSmallerThan(VERSION_1_1_0)) {
916       // withscheduling was added for version 1.1.0 and should be ignored for smaller versions.
917       withScheduling = false;
918     }
919 
920     Optional<Integer> optLimit = Optional.ofNullable(limit);
921     Optional<Integer> optOffset = Optional.ofNullable(offset);
922     Optional<String> optSort = Optional.ofNullable(trimToNull(sort));
923     EventSearchQuery query = new EventSearchQuery(getSecurityService().getOrganization().getId(),
924             getSecurityService().getUser());
925     // If the limit is set to 0, this is not taken into account
926     if (optLimit.isPresent() && limit == 0) {
927       optLimit = Optional.empty();
928     }
929 
930     //List of all events from the filters
931     List<IndexObject> allEvents = new ArrayList<>();
932 
933     if (!isNullOrEmpty(filter)) {
934       // API version 1.5.0: Additive filter
935       if (!requestedVersion.isSmallerThan(ApiVersion.VERSION_1_5_0)) {
936         filter = filter.subList(0,1);
937       }
938       for (String filterPart : filter) {
939         // Parse the filters
940 
941         for (String f : filterPart.split(",")) {
942           String[] filterTuple = f.split(":");
943           if (filterTuple.length < 2) {
944             logger.debug("No value for filter {} in filters list: {}", filterTuple[0], filter);
945             continue;
946           }
947 
948           String name = filterTuple[0];
949           String value;
950 
951           if (!requestedVersion.isSmallerThan(ApiVersion.VERSION_1_1_0)) {
952             // MH-13038 - 1.1.0 and higher support colons in values
953             value = f.substring(name.length() + 1);
954           } else {
955             value = filterTuple[1];
956           }
957 
958           if ("presenters".equals(name)) {
959             query.withPresenter(value);
960           } else if ("contributors".equals(name)) {
961             query.withContributor(value);
962           } else if ("location".equals(name)) {
963             query.withLocation(value);
964           } else if ("textFilter".equals(name)) {
965             query.withText(value);
966           } else if ("series".equals(name)) {
967             query.withSeriesId(value);
968           } else if ("subject".equals(name)) {
969             query.withSubject(value);
970           } else if (!requestedVersion.isSmallerThan(ApiVersion.VERSION_1_1_0)) {
971             // additional filters only available with Version 1.1.0 or higher
972             if ("identifier".equals(name)) {
973               query.withIdentifier(value);
974             } else if ("title".equals(name)) {
975               query.withTitle(value);
976             } else if ("description".equals(name)) {
977               query.withDescription(value);
978             } else if ("series_name".equals(name)) {
979               query.withSeriesName(value);
980             } else if ("language".equals(name)) {
981               query.withLanguage(value);
982             } else if ("created".equals(name)) {
983               query.withCreated(value);
984             } else if ("license".equals(name)) {
985               query.withLicense(value);
986             } else if ("rightsholder".equals(name)) {
987               query.withRights(value);
988             } else if ("is_part_of".equals(name)) {
989               query.withSeriesId(value);
990             } else if ("source".equals(name)) {
991               query.withSource(value);
992             } else if ("status".equals(name)) {
993               query.withEventStatus(value);
994             } else if ("agent_id".equals(name)) {
995               query.withAgentId(value);
996             } else if ("start".equals(name)) {
997               try {
998                 Tuple<Date, Date> fromAndToCreationRange = RestUtils.getFromAndToDateRange(value);
999                 query.withStartFrom(fromAndToCreationRange.getA());
1000                 query.withStartTo(fromAndToCreationRange.getB());
1001               } catch (Exception e) {
1002                 return RestUtil.R
1003                         .badRequest(String.format("Filter 'start' could not be parsed: %s", e.getMessage()));
1004 
1005               }
1006             } else if ("technical_start".equals(name)) {
1007               try {
1008                 Tuple<Date, Date> fromAndToCreationRange = RestUtils.getFromAndToDateRange(value);
1009                 query.withTechnicalStartFrom(fromAndToCreationRange.getA());
1010                 query.withTechnicalStartTo(fromAndToCreationRange.getB());
1011               } catch (Exception e) {
1012                 return RestUtil.R
1013                         .badRequest(String.format("Filter 'technical_start' could not be parsed: %s", e.getMessage()));
1014 
1015               }
1016             } else {
1017               logger.warn("Unknown filter criteria {}", name);
1018               return RestUtil.R.badRequest(String.format("Unknown filter criterion in request: %s", name));
1019 
1020             }
1021           }
1022         }
1023 
1024         if (optSort.isPresent()) {
1025           ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(optSort.get());
1026           for (SortCriterion criterion : sortCriteria) {
1027 
1028             switch (criterion.getFieldName()) {
1029               case EventIndexSchema.TITLE:
1030                 query.sortByTitle(criterion.getOrder());
1031                 break;
1032               case EventIndexSchema.PRESENTER:
1033                 query.sortByPresenter(criterion.getOrder());
1034                 break;
1035               case EventIndexSchema.TECHNICAL_START:
1036               case "technical_date":
1037                 query.sortByTechnicalStartDate(criterion.getOrder());
1038                 break;
1039               case EventIndexSchema.TECHNICAL_END:
1040                 query.sortByTechnicalEndDate(criterion.getOrder());
1041                 break;
1042               case EventIndexSchema.START_DATE:
1043               case "date":
1044                 query.sortByStartDate(criterion.getOrder());
1045                 break;
1046               case EventIndexSchema.END_DATE:
1047                 query.sortByEndDate(criterion.getOrder());
1048                 break;
1049               case EventIndexSchema.WORKFLOW_STATE:
1050                 query.sortByWorkflowState(criterion.getOrder());
1051                 break;
1052               case EventIndexSchema.SERIES_NAME:
1053                 query.sortBySeriesName(criterion.getOrder());
1054                 break;
1055               case EventIndexSchema.LOCATION:
1056                 query.sortByLocation(criterion.getOrder());
1057                 break;
1058               // For compatibility, we mimic to support the old review_status and scheduling_status sort criteria
1059               // (MH-13407)
1060               case "review_status":
1061               case "scheduling_status":
1062                 break;
1063               default:
1064                 return RestUtil.R.badRequest(String.format("Unknown sort criterion in request: %s",
1065                     criterion.getFieldName()));
1066             }
1067           }
1068         }
1069 
1070         // TODO: Add the comment resolution filter to the query
1071         if (StringUtils.isNotBlank(resolutionFilter)) {
1072           try {
1073             CommentResolution.valueOf(resolutionFilter);
1074           } catch (Exception e) {
1075             logger.debug("Unable to parse comment resolution filter {}", resolutionFilter);
1076             return Response.status(Status.BAD_REQUEST).build();
1077           }
1078         }
1079 
1080         if (optLimit.isPresent()) {
1081           query.withLimit(optLimit.get());
1082         }
1083         if (optOffset.isPresent()) {
1084           query.withOffset(offset);
1085         }
1086         // TODO: Add other filters to the query
1087 
1088         SearchResult<Event> results = null;
1089         try {
1090           results = elasticsearchIndex.getByQuery(query);
1091         } catch (SearchIndexException e) {
1092           logger.error("The External Search Index was not able to get the events list", e);
1093           throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1094         }
1095 
1096         SearchResultItem<Event>[] items = results.getItems();
1097         List<IndexObject> events = new ArrayList<>();
1098         for (SearchResultItem<Event> item : items) {
1099           Event source = item.getSource();
1100           source.updatePreview(previewSubtype);
1101           events.add(source);
1102         }
1103         //Append  filtered results to the list
1104         allEvents.addAll(events);
1105       }
1106     } else {
1107       if (optSort.isPresent()) {
1108         ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(optSort.get());
1109         for (SortCriterion criterion : sortCriteria) {
1110 
1111           switch (criterion.getFieldName()) {
1112             case EventIndexSchema.TITLE:
1113               query.sortByTitle(criterion.getOrder());
1114               break;
1115             case EventIndexSchema.PRESENTER:
1116               query.sortByPresenter(criterion.getOrder());
1117               break;
1118             case EventIndexSchema.TECHNICAL_START:
1119             case "technical_date":
1120               query.sortByTechnicalStartDate(criterion.getOrder());
1121               break;
1122             case EventIndexSchema.TECHNICAL_END:
1123               query.sortByTechnicalEndDate(criterion.getOrder());
1124               break;
1125             case EventIndexSchema.START_DATE:
1126             case "date":
1127               query.sortByStartDate(criterion.getOrder());
1128               break;
1129             case EventIndexSchema.END_DATE:
1130               query.sortByEndDate(criterion.getOrder());
1131               break;
1132             case EventIndexSchema.WORKFLOW_STATE:
1133               query.sortByWorkflowState(criterion.getOrder());
1134               break;
1135             case EventIndexSchema.SERIES_NAME:
1136               query.sortBySeriesName(criterion.getOrder());
1137               break;
1138             case EventIndexSchema.LOCATION:
1139               query.sortByLocation(criterion.getOrder());
1140               break;
1141             // For compatibility, we mimic to support the old review_status and scheduling_status sort criteria
1142             // (MH-13407)
1143             case "review_status":
1144             case "scheduling_status":
1145               break;
1146             default:
1147               return RestUtil.R.badRequest(String.format("Unknown sort criterion in request: %s",
1148                   criterion.getFieldName()));
1149           }
1150         }
1151       }
1152 
1153       // TODO: Add the comment resolution filter to the query
1154       if (StringUtils.isNotBlank(resolutionFilter)) {
1155         try {
1156           CommentResolution.valueOf(resolutionFilter);
1157         } catch (Exception e) {
1158           logger.debug("Unable to parse comment resolution filter {}", resolutionFilter);
1159           return Response.status(Status.BAD_REQUEST).build();
1160         }
1161       }
1162 
1163       if (optLimit.isPresent()) {
1164         query.withLimit(optLimit.get());
1165       }
1166       if (optOffset.isPresent()) {
1167         query.withOffset(offset);
1168       }
1169 
1170       if (onlyWithWriteAccess != null && onlyWithWriteAccess) {
1171         query.withoutActions();
1172         query.withAction(Permissions.Action.WRITE);
1173       }
1174       // TODO: Add other filters to the query
1175 
1176       SearchResult<Event> results = null;
1177       try {
1178         results = elasticsearchIndex.getByQuery(query);
1179       } catch (SearchIndexException e) {
1180         logger.error("The External Search Index was not able to get the events list", e);
1181         throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1182       }
1183 
1184       SearchResultItem<Event>[] items = results.getItems();
1185       List<IndexObject> events = new ArrayList<>();
1186       for (SearchResultItem<Event> item : items) {
1187         Event source = item.getSource();
1188         source.updatePreview(previewSubtype);
1189         events.add(source);
1190       }
1191       //Append  filtered results to the list
1192       allEvents.addAll(events);
1193     }
1194     try {
1195       return getJsonEvents(
1196           acceptHeader, allEvents, withAcl, withMetadata, withScheduling, withPublications, includeInternalPublication,
1197           sign, requestedVersion);
1198     } catch (Exception e) {
1199       logger.error("Unable to get events", e);
1200       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1201     }
1202   }
1203 
1204   /**
1205    * Render a collection of {@link Event}s into a json array.
1206    *
1207    * @param acceptHeader
1208    *          The accept header to return to the client.
1209    * @param events
1210    *          The {@link List} of {@link Event}s to render into json.
1211    * @param withAcl
1212    *          Whether to include the events' ACLs.
1213    * @param withMetadata
1214    *          Whether to include the events' metadata.
1215    * @param withScheduling
1216    *          Whether to include the events' scheduling information.
1217    * @param withPublications
1218    *          Whether to include the events' publications.
1219    * @param withSignedUrls
1220    *          Whether to sign the included urls.
1221    * @return A {@link Response} with the accept header and body as the Json array of {@link Event}s.
1222    * @throws IndexServiceException
1223    * @throws SchedulerException
1224    * @throws UnauthorizedException
1225    */
1226   protected Response getJsonEvents(String acceptHeader, List<IndexObject> events, Boolean withAcl, Boolean withMetadata,
1227       Boolean withScheduling, Boolean withPublications, Boolean includeInternalPublication, Boolean withSignedUrls,
1228       ApiVersion requestedVersion)
1229           throws IndexServiceException, UnauthorizedException, SchedulerException {
1230     JsonArray eventsArray = new JsonArray();
1231     for (IndexObject item : events) {
1232       JsonObject jsonEvent = eventToJSON((Event) item, withAcl, withMetadata, withScheduling, withPublications,
1233           includeInternalPublication, withSignedUrls, requestedVersion);
1234       eventsArray.add(jsonEvent);
1235     }
1236 
1237     return ApiResponseBuilder.Json.ok(requestedVersion, eventsArray);
1238   }
1239 
1240   /**
1241    * Transform an {@link Event} to Json
1242    *
1243    * @param event
1244    *          The event to transform into json
1245    * @param withAcl
1246    *          Whether to add the acl information for the event
1247    * @param withMetadata
1248    *          Whether to add all the metadata for the event
1249    * @param withScheduling
1250    *          Whether to add the scheduling information for the event
1251    * @param withPublications
1252    *          Whether to add the publications
1253    * @param withSignedUrls
1254    *          Whether to sign the urls if they are protected by stream security.
1255    * @return The event in json format.
1256    * @throws IndexServiceException
1257    *           Thrown if unable to get the metadata for the event.
1258    * @throws SchedulerException
1259    * @throws UnauthorizedException
1260    */
1261   protected JsonObject eventToJSON(Event event, Boolean withAcl, Boolean withMetadata, Boolean withScheduling,
1262       Boolean withPublications, Boolean includeInternalPublication, Boolean withSignedUrls,
1263       ApiVersion requestedVersion) throws IndexServiceException, SchedulerException, UnauthorizedException {
1264     JsonObject json = new JsonObject();
1265 
1266     if (event.getArchiveVersion() != null) {
1267       json.addProperty("archive_version", event.getArchiveVersion());
1268     }
1269     json.addProperty("created", safeString(event.getCreated()));
1270     json.addProperty("creator", safeString(event.getCreator()));
1271     json.add("contributor", collectionToJsonArray(event.getContributors()));
1272     json.addProperty("description", safeString(event.getDescription()));
1273     json.addProperty("has_previews", event.hasPreview());
1274     json.addProperty("identifier", safeString(event.getIdentifier()));
1275     json.addProperty("location", safeString(event.getLocation()));
1276     json.add("presenter", collectionToJsonArray(event.getPresenters()));
1277 
1278     if (!requestedVersion.isSmallerThan(VERSION_1_1_0)) {
1279       json.addProperty("language", safeString(event.getLanguage()));
1280       json.addProperty("rightsholder", safeString(event.getRights()));
1281       json.addProperty("license", safeString(event.getLicense()));
1282       json.addProperty("is_part_of", safeString(event.getSeriesId()));
1283       json.addProperty("series", safeString(event.getSeriesName()));
1284       json.addProperty("source", safeString(event.getSource()));
1285       json.addProperty("status", safeString(event.getEventStatus()));
1286     }
1287 
1288     JsonArray publicationIds = new JsonArray();
1289     if (event.getPublications() != null) {
1290       for (Publication publication : event.getPublications()) {
1291         publicationIds.add(new JsonPrimitive(publication.getChannel()));
1292       }
1293     }
1294     json.add("publication_status", publicationIds);
1295     json.addProperty("processing_state", safeString(event.getWorkflowState()));
1296 
1297     if (requestedVersion.isSmallerThan(VERSION_1_4_0)) {
1298       json.addProperty("start", safeString(event.getTechnicalStartTime()));
1299       if (event.getTechnicalEndTime() != null) {
1300         long duration = new DateTime(event.getTechnicalEndTime()).getMillis()
1301             - new DateTime(event.getTechnicalStartTime()).getMillis();
1302         json.addProperty("duration", duration);
1303       }
1304     } else {
1305       json.addProperty("start", safeString(event.getRecordingStartDate()));
1306       if (event.getDuration() != null) {
1307         json.addProperty("duration", event.getDuration());
1308       } else {
1309         json.add("duration", JsonNull.INSTANCE);
1310       }
1311     }
1312 
1313     if (StringUtils.trimToNull(event.getSubject()) != null) {
1314       json.add("subjects", splitSubjectIntoArray(event.getSubject()));
1315     } else {
1316       json.add("subjects", new JsonArray());
1317     }
1318 
1319     json.addProperty("title", safeString(event.getTitle()));
1320 
1321     if (withAcl != null && withAcl) {
1322       AccessControlList acl = getAclFromEvent(event);
1323       json.add("acl", AclUtils.serializeAclToJson(acl));
1324     }
1325 
1326     if (withMetadata != null && withMetadata) {
1327       try {
1328         Optional<MetadataList> metadata = getEventMetadata(event);
1329         if (metadata.isPresent()) {
1330           json.add("metadata", MetadataJson.listToJson(metadata.get(), true));
1331         }
1332       } catch (Exception e) {
1333         logger.error("Unable to get metadata for event '{}'", event.getIdentifier(), e);
1334         throw new IndexServiceException("Unable to add metadata to event", e);
1335       }
1336     }
1337 
1338     if (withScheduling != null && withScheduling) {
1339       json.add("scheduling", SchedulingInfo.of(event.getIdentifier(), schedulerService).toJson());
1340     }
1341 
1342     if (withPublications != null && withPublications) {
1343       List<JsonObject> publications = getPublications(event, withSignedUrls, includeInternalPublication,
1344           requestedVersion);
1345       JsonArray pubDetails = new JsonArray();
1346       for (JsonObject pub : publications) {
1347         pubDetails.add(pub);
1348       }
1349       json.add("publications", pubDetails);
1350     }
1351 
1352     return json;
1353   }
1354 
1355   private JsonArray splitSubjectIntoArray(final String subject) {
1356     JsonArray array = new JsonArray();
1357     if (subject != null && !subject.trim().isEmpty()) {
1358       for (String part : subject.split(",")) {
1359         array.add(new JsonPrimitive(part.trim()));
1360       }
1361     }
1362     return array;
1363   }
1364 
1365   @GET
1366   @Path("{eventId}/acl")
1367   @RestQuery(
1368       name = "geteventacl",
1369       description = "Returns an event's access policy.",
1370       returnDescription = "",
1371       pathParameters = {
1372           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
1373       },
1374       responses = {
1375           @RestResponse(description = "The access control list for the specified event is returned.",
1376               responseCode = HttpServletResponse.SC_OK),
1377           @RestResponse(description = "The specified event does not exist.",
1378               responseCode = HttpServletResponse.SC_NOT_FOUND)
1379       })
1380   public Response getEventAcl(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id)
1381           throws Exception {
1382     Optional<Event> eventOpt = indexService.getEvent(id, elasticsearchIndex);
1383     if (eventOpt.isPresent()) {
1384       AccessControlList acl = getAclFromEvent(eventOpt.get());
1385       return ApiResponseBuilder.Json.ok(acceptHeader, AclUtils.serializeAclToJson(acl));
1386     }
1387     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1388   }
1389 
1390   @PUT
1391   @Path("{eventId}/acl")
1392   @RestQuery(
1393       name = "updateeventacl",
1394       description = "Update an event's access policy.",
1395       returnDescription = "",
1396       pathParameters = {
1397           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
1398       },
1399       restParameters = {
1400           @RestParameter(name = "acl", isRequired = true, description = "Access policy", type = STRING)
1401       },
1402       responses = {
1403           @RestResponse(description = "The access control list for the specified event is updated.",
1404               responseCode = HttpServletResponse.SC_NO_CONTENT),
1405           @RestResponse(description = "The specified event does not exist.",
1406               responseCode = HttpServletResponse.SC_NOT_FOUND)
1407       })
1408   public Response updateEventAcl(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1409           @FormParam("acl") String acl) throws Exception {
1410     if (indexService.getEvent(id, elasticsearchIndex).isPresent()) {
1411       AccessControlList accessControlList;
1412       try {
1413         accessControlList = AclUtils.deserializeJsonToAcl(acl, false);
1414       } catch (ParseException e) {
1415         logger.debug("Unable to update event acl to '{}'", acl, e);
1416         return R.badRequest(String.format("Unable to parse acl '%s' because '%s'", acl, e.getMessage()));
1417       } catch (IllegalArgumentException e) {
1418         logger.debug("Unable to update event acl to '{}'", acl, e);
1419         return R.badRequest(e.getMessage());
1420       }
1421       try {
1422         accessControlList = indexService.updateEventAcl(id, accessControlList, elasticsearchIndex);
1423       } catch (IllegalArgumentException e) {
1424         logger.error("Unable to update event '{}' acl with '{}'", id, acl, e);
1425         return Response.status(Status.FORBIDDEN).build();
1426       }
1427       return Response.noContent().build();
1428     } else {
1429       return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1430     }
1431   }
1432 
1433   @POST
1434   @Path("{eventId}/acl/{action}")
1435   @RestQuery(
1436       name = "addeventace",
1437       description = "Grants permission to execute action on the specified event to any user with role role. Note that "
1438           + "this is a convenience method to avoid having to build and post a complete access control list.",
1439       returnDescription = "",
1440       pathParameters = {
1441           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING),
1442           @RestParameter(name = "action", description = "The action that is allowed to be executed", isRequired = true,
1443               type = STRING)
1444       },
1445       restParameters = {
1446           @RestParameter(name = "role", isRequired = true, description = "The role that is granted permission",
1447               type = STRING)
1448       },
1449       responses = {
1450           @RestResponse(description = "The permission has been created in the access control list of the specified "
1451               + "event.", responseCode = HttpServletResponse.SC_NO_CONTENT),
1452           @RestResponse(description = "The specified event does not exist.",
1453               responseCode = HttpServletResponse.SC_NOT_FOUND)
1454       })
1455   public Response addEventAce(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1456           @PathParam("action") String action, @FormParam("role") String role) throws Exception {
1457     List<AccessControlEntry> entries = new ArrayList<>();
1458     Optional<Event> eventOpt = indexService.getEvent(id, elasticsearchIndex);
1459     if (eventOpt.isPresent()) {
1460       AccessControlList accessControlList = getAclFromEvent(eventOpt.get());
1461       AccessControlEntry newAce = new AccessControlEntry(role, action, true);
1462       boolean alreadyInAcl = false;
1463       for (AccessControlEntry ace : accessControlList.getEntries()) {
1464         if (ace.equals(newAce)) {
1465           // We have found an identical access control entry so just return.
1466           entries = accessControlList.getEntries();
1467           alreadyInAcl = true;
1468           break;
1469         } else if (ace.getAction().equals(newAce.getAction()) && ace.getRole().equals(newAce.getRole())
1470                 && !ace.isAllow()) {
1471           entries.add(newAce);
1472           alreadyInAcl = true;
1473         } else {
1474           entries.add(ace);
1475         }
1476       }
1477 
1478       if (!alreadyInAcl) {
1479         entries.add(newAce);
1480       }
1481 
1482       AccessControlList withNewAce = new AccessControlList(entries);
1483       try {
1484         withNewAce = indexService.updateEventAcl(id, withNewAce, elasticsearchIndex);
1485       } catch (IllegalArgumentException e) {
1486         logger.error("Unable to update event '{}' acl entry with action '{}' and role '{}'", id, action, role, e);
1487         return Response.status(Status.FORBIDDEN).build();
1488       }
1489       return Response.noContent().build();
1490     }
1491     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1492   }
1493 
1494   @DELETE
1495   @Path("{eventId}/acl/{action}/{role}")
1496   @RestQuery(
1497       name = "deleteeventace",
1498       description = "Revokes permission to execute action on the specified event from any user with role role.",
1499       returnDescription = "",
1500       pathParameters = {
1501           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING),
1502           @RestParameter(name = "action", description = "The action that is no longer allowed to be executed",
1503               isRequired = true, type = STRING),
1504           @RestParameter(name = "role", description = "The role that is no longer granted permission",
1505               isRequired = true, type = STRING)
1506       },
1507       responses = {
1508           @RestResponse(description = "The permission has been revoked from the access control list of the specified "
1509               + "event.", responseCode = HttpServletResponse.SC_NO_CONTENT),
1510           @RestResponse(description = "The specified event does not exist.",
1511               responseCode = HttpServletResponse.SC_NOT_FOUND)
1512       })
1513   public Response deleteEventAce(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1514           @PathParam("action") String action, @PathParam("role") String role) throws Exception {
1515     List<AccessControlEntry> entries = new ArrayList<>();
1516     Optional<Event> eventOpt = indexService.getEvent(id, elasticsearchIndex);
1517     if (eventOpt.isPresent()) {
1518       AccessControlList accessControlList = getAclFromEvent(eventOpt.get());
1519       boolean foundDelete = false;
1520       for (AccessControlEntry ace : accessControlList.getEntries()) {
1521         if (ace.getAction().equals(action) && ace.getRole().equals(role)) {
1522           foundDelete = true;
1523         } else {
1524           entries.add(ace);
1525         }
1526       }
1527 
1528       if (!foundDelete) {
1529         return ApiResponseBuilder.notFound("Unable to find an access control entry with action '%s' and role '%s'",
1530             action, role);
1531       }
1532 
1533       AccessControlList withoutDeleted = new AccessControlList(entries);
1534       try {
1535         withoutDeleted = indexService.updateEventAcl(id, withoutDeleted, elasticsearchIndex);
1536       } catch (IllegalArgumentException e) {
1537         logger.error("Unable to delete event's '{}' acl entry with action '{}' and role '{}'", id, action, role, e);
1538         return Response.status(Status.FORBIDDEN).build();
1539       }
1540       return Response.noContent().build();
1541     }
1542     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1543   }
1544 
1545   @GET
1546   @Path("{eventId}/metadata")
1547   @RestQuery(
1548       name = "geteventmetadata",
1549       description = "Returns the event's metadata of the specified type. For a metadata catalog there is the flavor "
1550           + "such as 'dublincore/episode' and this is the unique type.",
1551       returnDescription = "",
1552       pathParameters = {
1553           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
1554       },
1555       restParameters = {
1556           @RestParameter(name = "type", isRequired = false, description = "The type of metadata to get", type = STRING)
1557       },
1558       responses = {
1559           @RestResponse(description = "The metadata collection is returned.", responseCode = HttpServletResponse.SC_OK),
1560           @RestResponse(description = "The specified event does not exist.",
1561               responseCode = HttpServletResponse.SC_NOT_FOUND)
1562       })
1563   public Response getAllEventMetadata(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1564           @QueryParam("type") String type) throws Exception {
1565     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
1566     if (StringUtils.trimToNull(type) == null) {
1567       Optional<MetadataList> metadataList = getEventMetadataById(id);
1568       if (metadataList.isPresent()) {
1569         MetadataList actualList = metadataList.get();
1570 
1571         // API v1 should return a two separate fields for start date and start time. Since those fields were merged in
1572         // index service, we have to split them up.
1573         final DublinCoreMetadataCollection collection = actualList.getMetadataByFlavor("dublincore/episode");
1574         final boolean withOrderedText = collection == null;
1575         if (collection != null) {
1576           convertStartDateTimeToApiV1(collection);
1577         }
1578 
1579         return ApiResponseBuilder.Json.ok(requestedVersion, MetadataJson.listToJson(actualList, withOrderedText));
1580       }
1581       else {
1582         return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1583       }
1584     } else {
1585       return getEventMetadataByType(id, type, requestedVersion);
1586     }
1587   }
1588 
1589   private void convertStartDateTimeToApiV1(DublinCoreMetadataCollection collection) throws java.text.ParseException {
1590 
1591     if (!collection.getOutputFields().containsKey("startDate")) {
1592       return;
1593     }
1594 
1595     MetadataField oldStartDateField = collection.getOutputFields().get("startDate");
1596     SimpleDateFormat sdf = MetadataField.getSimpleDateFormatter(oldStartDateField.getPattern());
1597     Date startDate = sdf.parse((String) oldStartDateField.getValue());
1598 
1599     if (configuredMetadataFields.containsKey("startDate")) {
1600       MetadataField startDateField = configuredMetadataFields.get("startDate");
1601       final String pattern = startDateField.getPattern() == null ? "yyyy-MM-dd" : startDateField.getPattern();
1602       startDateField = new MetadataField(startDateField);
1603       startDateField.setPattern(pattern);
1604       sdf.applyPattern(startDateField.getPattern());
1605       startDateField.setValue(sdf.format(startDate));
1606       collection.removeField(oldStartDateField);
1607       collection.addField(startDateField);
1608     }
1609 
1610     if (configuredMetadataFields.containsKey("startTime")) {
1611       MetadataField startTimeField = configuredMetadataFields.get("startTime");
1612       final String pattern = startTimeField.getPattern() == null ? "HH:mm" : startTimeField.getPattern();
1613       startTimeField = new MetadataField(startTimeField);
1614       startTimeField.setPattern(pattern);
1615       sdf.applyPattern(startTimeField.getPattern());
1616       startTimeField.setValue(sdf.format(startDate));
1617       collection.addField(startTimeField);
1618     }
1619   }
1620 
1621   protected Optional<MetadataList> getEventMetadataById(String id) throws IndexServiceException, Exception {
1622     Optional<Event> eventOpt = indexService.getEvent(id, elasticsearchIndex);
1623     if (eventOpt.isPresent()) {
1624       return getEventMetadata(eventOpt.get());
1625     }
1626     return Optional.<MetadataList> empty();
1627   }
1628 
1629   protected Optional<MetadataList> getEventMetadata(Event event) throws IndexServiceException, Exception {
1630     MetadataList metadataList = new MetadataList();
1631     List<EventCatalogUIAdapter> catalogUIAdapters = getEventCatalogUIAdapters();
1632     EventCatalogUIAdapter eventCatalogUIAdapter = indexService.getCommonEventCatalogUIAdapter();
1633     catalogUIAdapters.remove(eventCatalogUIAdapter);
1634     if (catalogUIAdapters.size() > 0) {
1635       MediaPackage mediaPackage = indexService.getEventMediapackage(event);
1636       for (EventCatalogUIAdapter catalogUIAdapter : catalogUIAdapters) {
1637         // TODO: This is very slow:
1638         DublinCoreMetadataCollection fields = catalogUIAdapter.getFields(mediaPackage);
1639         if (fields != null) {
1640           ExternalMetadataUtils.removeCollectionList(fields);
1641           metadataList.add(catalogUIAdapter, fields);
1642         }
1643       }
1644     }
1645     DublinCoreMetadataCollection collection = EventUtils.getEventMetadata(event, eventCatalogUIAdapter,
1646         new EmptyResourceListQuery());
1647     ExternalMetadataUtils.changeSubjectToSubjects(collection);
1648     ExternalMetadataUtils.removeCollectionList(collection);
1649     metadataList.add(eventCatalogUIAdapter, collection);
1650     if (WorkflowInstance.WorkflowState.RUNNING.toString().equals(event.getWorkflowState())) {
1651       metadataList.setLocked(Locked.WORKFLOW_RUNNING);
1652     }
1653     return Optional.of(metadataList);
1654   }
1655 
1656   private Optional<MediaPackageElementFlavor> getFlavor(String flavorString) {
1657     try {
1658       MediaPackageElementFlavor flavor = MediaPackageElementFlavor.parseFlavor(flavorString);
1659       return Optional.of(flavor);
1660     } catch (IllegalArgumentException e) {
1661       return Optional.empty();
1662     }
1663   }
1664 
1665   private Response getEventMetadataByType(String id, String type, ApiVersion requestedVersion) throws Exception {
1666     Optional<Event> eventOpt = indexService.getEvent(id, elasticsearchIndex);
1667     if (eventOpt.isPresent()) {
1668       Event event = eventOpt.get();
1669       Optional<MediaPackageElementFlavor> flavor = getFlavor(type);
1670       if (flavor.isEmpty()) {
1671         return R.badRequest(
1672                 String.format("Unable to parse type '%s' as a flavor so unable to find the matching catalog.", type));
1673       }
1674       // Try the main catalog first as we load it from the index.
1675       EventCatalogUIAdapter eventCatalogUIAdapter = indexService.getCommonEventCatalogUIAdapter();
1676       if (flavor.get().equals(eventCatalogUIAdapter.getFlavor())) {
1677         DublinCoreMetadataCollection collection = EventUtils.getEventMetadata(event, eventCatalogUIAdapter,
1678             new EmptyResourceListQuery());
1679         ExternalMetadataUtils.changeSubjectToSubjects(collection);
1680         ExternalMetadataUtils.removeCollectionList(collection);
1681         convertStartDateTimeToApiV1(collection);
1682         return ApiResponseBuilder.Json.ok(requestedVersion, MetadataJson.collectionToJson(collection, false));
1683       }
1684       // Try the other catalogs
1685       List<EventCatalogUIAdapter> catalogUIAdapters = getEventCatalogUIAdapters();
1686       catalogUIAdapters.remove(eventCatalogUIAdapter);
1687       if (catalogUIAdapters.size() > 0) {
1688         MediaPackage mediaPackage = indexService.getEventMediapackage(event);
1689         for (EventCatalogUIAdapter catalogUIAdapter : catalogUIAdapters) {
1690           if (flavor.get().equals(catalogUIAdapter.getFlavor())) {
1691             DublinCoreMetadataCollection fields = catalogUIAdapter.getFields(mediaPackage);
1692             ExternalMetadataUtils.removeCollectionList(fields);
1693             convertStartDateTimeToApiV1(fields);
1694             return ApiResponseBuilder.Json.ok(requestedVersion, MetadataJson.collectionToJson(fields, false));
1695           }
1696         }
1697       }
1698       return ApiResponseBuilder.notFound("Cannot find a catalog with type '%s' for event with id '%s'.", type, id);
1699     }
1700     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1701   }
1702 
1703   @PUT
1704   @Path("{eventId}/metadata")
1705   @RestQuery(
1706       name = "updateeventmetadata",
1707       description = "Update the metadata with the matching type of the specified event. For a metadata catalog there "
1708           + "is the flavor such as 'dublincore/episode' and this is the unique type.",
1709       returnDescription = "",
1710       pathParameters = {
1711           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
1712       },
1713       restParameters = {
1714           @RestParameter(name = "type", isRequired = true, description = "The type of metadata to update",
1715               type = STRING),
1716           @RestParameter(name = "metadata", description = "Metadata catalog in JSON format", isRequired = true,
1717               type = STRING)
1718       },
1719       responses = {
1720           @RestResponse(description = "The metadata of the given namespace has been updated.",
1721               responseCode = HttpServletResponse.SC_OK),
1722           @RestResponse(description = "The request is invalid or inconsistent.",
1723               responseCode = HttpServletResponse.SC_BAD_REQUEST),
1724           @RestResponse(description = "The specified event does not exist.",
1725               responseCode = HttpServletResponse.SC_NOT_FOUND)
1726       })
1727   public Response updateEventMetadataByType(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1728           @QueryParam("type") String type, @FormParam("metadata") String metadataJSON) throws Exception {
1729     Map<String, String> updatedFields;
1730     JSONParser parser = new JSONParser();
1731     try {
1732       updatedFields = RequestUtils.getKeyValueMap(metadataJSON);
1733     } catch (ParseException e) {
1734       logger.debug("Unable to update event '{}' with metadata type '{}' and content '{}'", id, type, metadataJSON, e);
1735       return RestUtil.R.badRequest(String.format("Unable to parse metadata fields as json from '%s'", metadataJSON));
1736     } catch (IllegalArgumentException e) {
1737       logger.debug("Unable to update event '{}' with metadata type '{}' and content '{}'", id, type, metadataJSON, e);
1738       return RestUtil.R.badRequest(e.getMessage());
1739     }
1740 
1741     if (updatedFields == null || updatedFields.size() == 0) {
1742       return RestUtil.R.badRequest(
1743               String.format("Unable to parse metadata fields as json from '%s' because there were no fields to update.",
1744                       metadataJSON));
1745     }
1746 
1747     Optional<MediaPackageElementFlavor> flavor = getFlavor(type);
1748     if (flavor.isEmpty()) {
1749       return R.badRequest(
1750               String.format("Unable to parse type '%s' as a flavor so unable to find the matching catalog.", type));
1751     }
1752 
1753     DublinCoreMetadataCollection collection = null;
1754     EventCatalogUIAdapter adapter = null;
1755     Optional<Event> eventOpt = indexService.getEvent(id, elasticsearchIndex);
1756     if (eventOpt.isPresent()) {
1757       Event event = eventOpt.get();
1758       MetadataList metadataList = new MetadataList();
1759       // Try the main catalog first as we load it from the index.
1760       EventCatalogUIAdapter eventCatalogUIAdapter = indexService.getCommonEventCatalogUIAdapter();
1761       if (flavor.get().equals(eventCatalogUIAdapter.getFlavor())) {
1762         collection = EventUtils.getEventMetadata(event, eventCatalogUIAdapter);
1763         adapter = eventCatalogUIAdapter;
1764       } else {
1765         metadataList.add(eventCatalogUIAdapter, EventUtils.getEventMetadata(event, eventCatalogUIAdapter));
1766       }
1767 
1768       // Try the other catalogs
1769       List<EventCatalogUIAdapter> catalogUIAdapters = getEventCatalogUIAdapters();
1770       catalogUIAdapters.remove(eventCatalogUIAdapter);
1771       if (catalogUIAdapters.size() > 0) {
1772         MediaPackage mediaPackage = indexService.getEventMediapackage(event);
1773         for (EventCatalogUIAdapter catalogUIAdapter : catalogUIAdapters) {
1774           if (flavor.get().equals(catalogUIAdapter.getFlavor())) {
1775             collection = catalogUIAdapter.getFields(mediaPackage);
1776             adapter = eventCatalogUIAdapter;
1777           } else {
1778             metadataList.add(catalogUIAdapter, catalogUIAdapter.getFields(mediaPackage));
1779           }
1780         }
1781       }
1782 
1783       if (collection == null) {
1784         return ApiResponseBuilder.notFound("Cannot find a catalog with type '%s' for event with id '%s'.", type, id);
1785       }
1786 
1787       for (String key : updatedFields.keySet()) {
1788         if ("subjects".equals(key)) {
1789           MetadataField field = collection.getOutputFields().get(DublinCore.PROPERTY_SUBJECT.getLocalName());
1790           Optional<Response> error = validateField(field, key, id, type, updatedFields);
1791           if (error.isPresent()) {
1792             return error.get();
1793           }
1794           collection.removeField(field);
1795           JSONArray subjectArray = (JSONArray) parser.parse(updatedFields.get(key));
1796           collection.addField(
1797                   MetadataJson.copyWithDifferentJsonValue(field, StringUtils.join(subjectArray.iterator(), ",")));
1798         } else if ("startDate".equals(key)) {
1799           // Special handling for start date since in API v1 we expect start date and start time to be separate fields.
1800           MetadataField field = collection.getOutputFields().get(key);
1801           Optional<Response> error = validateField(field, key, id, type, updatedFields);
1802           if (error.isPresent()) {
1803             return error.get();
1804           }
1805           String apiPattern = field.getPattern();
1806           if (configuredMetadataFields.containsKey("startDate")) {
1807             final String startDate = configuredMetadataFields.get("startDate").getPattern();
1808             apiPattern = startDate == null ? apiPattern : startDate;
1809           }
1810           SimpleDateFormat apiSdf = MetadataField.getSimpleDateFormatter(apiPattern);
1811           SimpleDateFormat sdf = MetadataField.getSimpleDateFormatter(field.getPattern());
1812           DateTime oldStartDate = new DateTime(sdf.parse((String) field.getValue()), DateTimeZone.UTC);
1813           DateTime newStartDate = new DateTime(apiSdf.parse(updatedFields.get(key)), DateTimeZone.UTC);
1814           DateTime updatedStartDate = oldStartDate.withDate(newStartDate.year().get(), newStartDate.monthOfYear().get(),
1815               newStartDate.dayOfMonth().get());
1816           collection.removeField(field);
1817           collection.addField(
1818                   MetadataJson.copyWithDifferentJsonValue(field, sdf.format(updatedStartDate.toDate())));
1819         } else if ("startTime".equals(key)) {
1820           // Special handling for start time since in API v1 we expect start date and start time to be separate fields.
1821           MetadataField field = collection.getOutputFields().get("startDate");
1822           Optional<Response> error = validateField(field, "startDate", id, type, updatedFields);
1823           if (error.isPresent()) {
1824             return error.get();
1825           }
1826           String apiPattern = "HH:mm";
1827           if (configuredMetadataFields.containsKey("startTime")) {
1828             final String startTime = configuredMetadataFields.get("startTime").getPattern();
1829             apiPattern = startTime == null ? apiPattern : startTime;
1830           }
1831           SimpleDateFormat apiSdf = MetadataField.getSimpleDateFormatter(apiPattern);
1832           SimpleDateFormat sdf = MetadataField.getSimpleDateFormatter(field.getPattern());
1833           DateTime oldStartDate = new DateTime(sdf.parse((String) field.getValue()), DateTimeZone.UTC);
1834           DateTime newStartDate = new DateTime(apiSdf.parse(updatedFields.get(key)), DateTimeZone.UTC);
1835           DateTime updatedStartDate = oldStartDate.withTime(
1836                   newStartDate.hourOfDay().get(),
1837                   newStartDate.minuteOfHour().get(),
1838                   newStartDate.secondOfMinute().get(),
1839                   newStartDate.millisOfSecond().get());
1840           collection.removeField(field);
1841           collection.addField(
1842                   MetadataJson.copyWithDifferentJsonValue(field, sdf.format(updatedStartDate.toDate())));
1843         } else {
1844           MetadataField field = collection.getOutputFields().get(key);
1845           Optional<Response> error = validateField(field, key, id, type, updatedFields);
1846           if (error.isPresent()) {
1847             return error.get();
1848           }
1849           collection.removeField(field);
1850           collection.addField(
1851                   MetadataJson.copyWithDifferentJsonValue(field, updatedFields.get(key)));
1852         }
1853       }
1854 
1855       metadataList.add(adapter, collection);
1856       indexService.updateEventMetadata(id, metadataList, elasticsearchIndex);
1857       return Response.noContent().build();
1858     }
1859     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1860   }
1861 
1862   private Optional<Response> validateField(MetadataField field, String key, String id, String type,
1863       Map<String, String> updatedFields) {
1864     if (field == null) {
1865       return Optional.of(ApiResponseBuilder.notFound(
1866               "Cannot find a metadata field with id '%s' from event with id '%s' and the metadata type '%s'.",
1867               key, id, type));
1868     } else if (field.isRequired() && StringUtils.isBlank(updatedFields.get(key))) {
1869       return Optional.of(R.badRequest(String.format(
1870               "The event metadata field with id '%s' and the metadata type '%s' is required and can not be empty!.",
1871               key, type)));
1872     }
1873     return Optional.empty();
1874   }
1875 
1876   @DELETE
1877   @Path("{eventId}/metadata")
1878   @RestQuery(
1879       name = "deleteeventmetadata",
1880       description = "Delete the metadata namespace catalog of the specified event. This will remove all fields and "
1881           + "values of the catalog.",
1882       returnDescription = "",
1883       pathParameters = {
1884           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
1885       },
1886       restParameters = {
1887           @RestParameter(name = "type", isRequired = true, description = "The type of metadata to delete",
1888               type = STRING)
1889       },
1890       responses = {
1891           @RestResponse(description = "The metadata of the given namespace has been updated.",
1892               responseCode = HttpServletResponse.SC_NO_CONTENT),
1893           @RestResponse(description = "The main metadata catalog dublincore/episode cannot be deleted as it has "
1894               + "mandatory fields.", responseCode = HttpServletResponse.SC_FORBIDDEN),
1895           @RestResponse(description = "The specified event does not exist.",
1896               responseCode = HttpServletResponse.SC_NOT_FOUND)
1897       })
1898   public Response deleteEventMetadataByType(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1899           @QueryParam("type") String type) throws SearchIndexException {
1900     Optional<Event> eventOpt = indexService.getEvent(id, elasticsearchIndex);
1901     if (eventOpt.isPresent()) {
1902       Optional<MediaPackageElementFlavor> flavor = getFlavor(type);
1903       if (flavor.isEmpty()) {
1904         return R.badRequest(
1905                 String.format("Unable to parse type '%s' as a flavor so unable to find the matching catalog.", type));
1906       }
1907       EventCatalogUIAdapter eventCatalogUIAdapter = indexService.getCommonEventCatalogUIAdapter();
1908       if (flavor.get().equals(eventCatalogUIAdapter.getFlavor())) {
1909         return Response
1910                 .status(Status.FORBIDDEN).entity(String
1911                         .format("Unable to delete mandatory metadata catalog with type '%s' for event '%s'", type, id))
1912                 .build();
1913       }
1914       try {
1915         indexService.removeCatalogByFlavor(eventOpt.get(), flavor.get());
1916       } catch (NotFoundException e) {
1917         return ApiResponseBuilder.notFound(e.getMessage());
1918       } catch (IndexServiceException e) {
1919         logger.error("Unable to remove metadata catalog with type '{}' from event '{}'", type, id, e);
1920         throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1921       } catch (IllegalStateException e) {
1922         logger.debug("Unable to remove metadata catalog with type '{}' from event '{}'", type, id, e);
1923         throw new WebApplicationException(e, Status.BAD_REQUEST);
1924       } catch (UnauthorizedException e) {
1925         return Response.status(Status.UNAUTHORIZED).build();
1926       }
1927       return Response.noContent().build();
1928     }
1929     return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", id);
1930   }
1931 
1932   @GET
1933   @Path("{eventId}/publications")
1934   @RestQuery(
1935       name = "geteventpublications",
1936       description = "Returns an event's list of publications.",
1937       returnDescription = "",
1938       pathParameters = {
1939           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
1940       },
1941       restParameters = {
1942           @RestParameter(name = "sign", description = "Whether public distribution urls should be signed.",
1943               isRequired = false, type = Type.BOOLEAN),
1944           @RestParameter(name = "includeInternalPublication", description = "Whether internal publications should be "
1945               + "included.", isRequired = false, type = Type.BOOLEAN)
1946       },
1947       responses = {
1948           @RestResponse(description = "The list of publications is returned.",
1949               responseCode = HttpServletResponse.SC_OK),
1950           @RestResponse(description = "The specified event does not exist.",
1951               responseCode = HttpServletResponse.SC_NOT_FOUND)
1952       })
1953 
1954     public Response getEventPublications(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
1955       @QueryParam("sign") boolean sign, @QueryParam("includeInternalPublication") boolean includeInternalPublication)
1956             throws Exception {
1957     try {
1958       final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
1959       final Optional<Event> event = indexService.getEvent(id, elasticsearchIndex);
1960       if (event.isPresent()) {
1961         JsonArray jsonArray = new JsonArray();
1962         for (JsonElement pub : getPublications(event.get(), sign, includeInternalPublication, requestedVersion)) {
1963           jsonArray.add(pub);
1964         }
1965         return ApiResponseBuilder.Json.ok(acceptHeader, jsonArray);
1966       } else {
1967         return ApiResponseBuilder.notFound(String.format("Unable to find event with id '%s'", id));
1968       }
1969     } catch (SearchIndexException e) {
1970       logger.error("Unable to get list of publications from event with id '{}'", id, e);
1971       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1972     }
1973   }
1974 
1975   private List<JsonObject> getPublications(Event event, Boolean withSignedUrls, Boolean includeInternalPublication,
1976       ApiVersion requestedVersion) {
1977     return event.getPublications().stream()
1978         .filter(publication -> {
1979           boolean isInternalAllowed = includeInternalPublication != null && includeInternalPublication
1980               && !requestedVersion.isSmallerThan(VERSION_1_11_0);
1981           return isInternalAllowed || EventUtils.internalChannelFilter.test(publication);
1982         })
1983         .map(p -> getPublication(p, withSignedUrls, requestedVersion))
1984         .collect(Collectors.toList());
1985   }
1986 
1987   public JsonObject getPublication(Publication publication, Boolean sign, ApiVersion requestedVersion) {
1988     // Signing URLs introduced in version 1.7.0
1989     URI publicationUrl = publication.getURI();
1990     if (!requestedVersion.isSmallerThan(VERSION_1_7_0)) {
1991       publicationUrl = getSignedUrl(publicationUrl, sign);
1992     }
1993 
1994     JsonObject json = new JsonObject();
1995     json.addProperty("id", publication.getIdentifier());
1996     json.addProperty("channel", publication.getChannel());
1997     json.addProperty("mediatype", safeString(publication.getMimeType()));
1998     json.addProperty("url", publicationUrl != null ? publicationUrl.toString() : "");
1999     JsonArray mediaArray = new JsonArray();
2000     for (JsonObject trackJson : getPublicationTracksJson(publication, sign, requestedVersion)) {
2001       mediaArray.add(trackJson);
2002     }
2003     json.add("media", mediaArray);
2004     JsonArray attachmentArray = new JsonArray();
2005     for (JsonObject attachmentJson : getPublicationAttachmentsJson(publication, sign)) {
2006       attachmentArray.add(attachmentJson);
2007     }
2008     json.add("attachments", attachmentArray);
2009     JsonArray metadataArray = new JsonArray();
2010     for (JsonObject catalogJson : getPublicationCatalogsJson(publication, sign)) {
2011       metadataArray.add(catalogJson);
2012     }
2013     json.add("metadata", metadataArray);
2014 
2015     return json;
2016   }
2017 
2018   private URI getSignedUrl(URI url, boolean sign) {
2019     if (url == null || !sign) {
2020       return url;
2021     }
2022 
2023     if (urlSigningService.accepts(url.toString())) {
2024       try {
2025         return URI.create(urlSigningService.sign(url.toString(), expireSeconds, null, null));
2026       } catch (UrlSigningException e) {
2027         logger.error("Unable to sign URI {}", url, e);
2028       }
2029     }
2030     return url;
2031   }
2032 
2033   private List<JsonObject> getPublicationTracksJson(Publication publication, Boolean sign,
2034       ApiVersion requestedVersion) {
2035     List<JsonObject> tracksJson = new ArrayList<>();
2036 
2037     for (Track track : publication.getTracks()) {
2038       JsonObject trackJson = new JsonObject();
2039 
2040       trackJson.addProperty("id", safeString(track.getIdentifier()));
2041       trackJson.addProperty("mediatype", safeString(track.getMimeType()));
2042       trackJson.addProperty("url", safeString(getSignedUrl(track.getURI(), sign)));
2043       trackJson.addProperty("flavor", safeString(track.getFlavor()));
2044       trackJson.addProperty("size", track.getSize());
2045       trackJson.addProperty("checksum", safeString(track.getChecksum()));
2046       trackJson.add("tags", arrayToJsonArray(track.getTags()));
2047       trackJson.addProperty("has_audio", track.hasAudio());
2048       trackJson.addProperty("has_video", track.hasVideo());
2049       trackJson.addProperty("duration", track.getDuration() != null ? track.getDuration() : null);
2050       trackJson.addProperty("description", safeString(track.getDescription()));
2051 
2052       VideoStream[] videoStreams = TrackSupport.byType(track.getStreams(), VideoStream.class);
2053       if (videoStreams.length > 0) {
2054         // Only supporting one stream, like in many other places...
2055         VideoStream videoStream = videoStreams[0];
2056         if (videoStream.getBitRate() != null) {
2057           trackJson.addProperty("bitrate", videoStream.getBitRate());
2058         }
2059         if (videoStream.getFrameRate() != null) {
2060           trackJson.addProperty("framerate", videoStream.getFrameRate());
2061         }
2062         if (videoStream.getFrameCount() != null) {
2063           trackJson.addProperty("framecount", videoStream.getFrameCount());
2064         }
2065         if (videoStream.getFrameWidth() != null) {
2066           trackJson.addProperty("width", videoStream.getFrameWidth());
2067         }
2068         if (videoStream.getFrameHeight() != null) {
2069           trackJson.addProperty("height", videoStream.getFrameHeight());
2070         }
2071       }
2072 
2073       if (!requestedVersion.isSmallerThan(VERSION_1_7_0)) {
2074         trackJson.addProperty("is_master_playlist", track.isMaster());
2075         trackJson.addProperty("is_live", track.isLive());
2076       }
2077 
2078       tracksJson.add(trackJson);
2079     }
2080 
2081     return tracksJson;
2082   }
2083 
2084   private List<JsonObject> getPublicationAttachmentsJson(Publication publication, Boolean sign) {
2085     List<JsonObject> attachmentsJson = new ArrayList<>();
2086 
2087     for (Attachment attachment : publication.getAttachments()) {
2088       JsonObject json = new JsonObject();
2089 
2090       json.addProperty("id", safeString(attachment.getIdentifier()));
2091       json.addProperty("mediatype", safeString(attachment.getMimeType()));
2092       json.addProperty("url", safeString(getSignedUrl(attachment.getURI(), sign)));
2093       json.addProperty("flavor", safeString(attachment.getFlavor()));
2094       json.addProperty("ref", safeString(attachment.getReference()));
2095       json.addProperty("size", attachment.getSize());
2096       json.addProperty("checksum", safeString(attachment.getChecksum()));
2097       json.add("tags", arrayToJsonArray(attachment.getTags()));
2098 
2099       attachmentsJson.add(json);
2100     }
2101 
2102     return attachmentsJson;
2103   }
2104 
2105   private List<JsonObject> getPublicationCatalogsJson(Publication publication, Boolean sign) {
2106     List<JsonObject> catalogsJson = new ArrayList<>();
2107 
2108     for (Catalog catalog : publication.getCatalogs()) {
2109       JsonObject json = new JsonObject();
2110 
2111       json.addProperty("id", safeString(catalog.getIdentifier()));
2112       json.addProperty("mediatype", safeString(catalog.getMimeType()));
2113       json.addProperty("url", safeString(getSignedUrl(catalog.getURI(), sign)));
2114       json.addProperty("flavor", safeString(catalog.getFlavor()));
2115       json.addProperty("size", catalog.getSize());
2116       json.addProperty("checksum", safeString(catalog.getChecksum()));
2117       json.add("tags", arrayToJsonArray(catalog.getTags()));
2118 
2119       catalogsJson.add(json);
2120     }
2121 
2122     return catalogsJson;
2123   }
2124 
2125   @GET
2126   @Path("{eventId}/publications/{publicationId}")
2127   @RestQuery(
2128       name = "geteventpublication",
2129       description = "Returns a single publication.",
2130       returnDescription = "",
2131       pathParameters = {
2132           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING),
2133           @RestParameter(name = "publicationId", description = "The publication id", isRequired = true, type = STRING)
2134       },
2135       restParameters = {
2136           @RestParameter(name = "sign", description = "Whether public distribution urls should be signed.",
2137               isRequired = false, type = Type.BOOLEAN)
2138       },
2139       responses = {
2140           @RestResponse(description = "The track details are returned.", responseCode = HttpServletResponse.SC_OK),
2141           @RestResponse(description = "The specified event or publication does not exist.",
2142               responseCode = HttpServletResponse.SC_NOT_FOUND)
2143       })
2144 
2145   public Response getEventPublication(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String eventId,
2146           @PathParam("publicationId") String publicationId, @QueryParam("sign") boolean sign) throws Exception {
2147     try {
2148       final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
2149       return ApiResponseBuilder.Json.ok(acceptHeader, getPublication(eventId, publicationId, sign, requestedVersion));
2150     } catch (NotFoundException e) {
2151       return ApiResponseBuilder.notFound(e.getMessage());
2152     } catch (SearchIndexException e) {
2153       logger.error("Unable to get list of publications from event with id '{}'", eventId, e);
2154       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
2155     }
2156   }
2157 
2158 
2159   private JsonObject getPublication(String eventId, String publicationId, Boolean withSignedUrls,
2160       ApiVersion requestedVersion)
2161           throws SearchIndexException, NotFoundException {
2162     Optional<Event> eventOpt = indexService.getEvent(eventId, elasticsearchIndex);
2163     if (eventOpt.isPresent()) {
2164       List<Publication> publications;
2165       publications = eventOpt.get().getPublications().stream()
2166           .filter(publication -> (!requestedVersion.isSmallerThan(VERSION_1_11_0)
2167               || EventUtils.internalChannelFilter.test(publication)))
2168           .collect(Collectors.toList());
2169       for (Publication publication : publications) {
2170         if (publicationId.equals(publication.getIdentifier())) {
2171           return getPublication(publication, withSignedUrls, requestedVersion);
2172         }
2173       }
2174       throw new NotFoundException(
2175               String.format("Unable to find publication with id '%s' in event with id '%s'", publicationId, eventId));
2176     }
2177     throw new NotFoundException(String.format("Unable to find event with id '%s'", eventId));
2178   }
2179 
2180   /**
2181    * Get an {@link AccessControlList} from an {@link Event}.
2182    *
2183    * @param event
2184    *          The {@link Event} to get the ACL from.
2185    * @return The {@link AccessControlList} stored in the {@link Event}
2186    */
2187   protected static AccessControlList getAclFromEvent(Event event) {
2188     AccessControlList activeAcl = new AccessControlList();
2189     try {
2190       if (event.getAccessPolicy() != null) {
2191         activeAcl = AccessControlParser.parseAcl(event.getAccessPolicy());
2192       }
2193     } catch (Exception e) {
2194       logger.error("Unable to parse access policy", e);
2195     }
2196     return activeAcl;
2197   }
2198 
2199   private JsonObject getJsonStream(Stream stream) {
2200     JsonObject json = new JsonObject();
2201 
2202     if (stream instanceof AudioStream) {
2203       AudioStream audio = (AudioStream) stream;
2204 
2205       if (audio.getBitDepth() != null) {
2206         json.addProperty("bitdepth", audio.getBitDepth());
2207       }
2208       if (audio.getBitRate() != null) {
2209         json.addProperty("bitrate", audio.getBitRate());
2210       }
2211       if (audio.getCaptureDevice() != null) {
2212         json.addProperty("capturedevice", audio.getCaptureDevice());
2213       }
2214       if (audio.getCaptureDeviceVendor() != null) {
2215         json.addProperty("capturedevicevendor", audio.getCaptureDeviceVendor());
2216       }
2217       if (audio.getCaptureDeviceVersion() != null) {
2218         json.addProperty("capturedeviceversion", audio.getCaptureDeviceVersion());
2219       }
2220       if (audio.getChannels() != null) {
2221         json.addProperty("channels", audio.getChannels());
2222       }
2223       if (audio.getEncoderLibraryVendor() != null) {
2224         json.addProperty("encoderlibraryvendor", audio.getEncoderLibraryVendor());
2225       }
2226       if (audio.getFormat() != null) {
2227         json.addProperty("format", audio.getFormat());
2228       }
2229       if (audio.getFormatVersion() != null) {
2230         json.addProperty("formatversion", audio.getFormatVersion());
2231       }
2232       if (audio.getFrameCount() != null) {
2233         json.addProperty("framecount", audio.getFrameCount());
2234       }
2235       if (audio.getIdentifier() != null) {
2236         json.addProperty("identifier", audio.getIdentifier());
2237       }
2238       if (audio.getPkLevDb() != null) {
2239         json.addProperty("pklevdb", audio.getPkLevDb());
2240       }
2241       if (audio.getRmsLevDb() != null) {
2242         json.addProperty("rmslevdb", audio.getRmsLevDb());
2243       }
2244       if (audio.getRmsPkDb() != null) {
2245         json.addProperty("rmspkdb", audio.getRmsPkDb());
2246       }
2247       if (audio.getSamplingRate() != null) {
2248         json.addProperty("samplingrate", audio.getSamplingRate());
2249       }
2250 
2251     } else if (stream instanceof VideoStream) {
2252       VideoStream video = (VideoStream) stream;
2253 
2254       if (video.getBitRate() != null) {
2255         json.addProperty("bitrate", video.getBitRate());
2256       }
2257       if (video.getCaptureDevice() != null) {
2258         json.addProperty("capturedevice", video.getCaptureDevice());
2259       }
2260       if (video.getCaptureDeviceVendor() != null) {
2261         json.addProperty("capturedevicevendor", video.getCaptureDeviceVendor());
2262       }
2263       if (video.getCaptureDeviceVersion() != null) {
2264         json.addProperty("capturedeviceversion", video.getCaptureDeviceVersion());
2265       }
2266       if (video.getEncoderLibraryVendor() != null) {
2267         json.addProperty("encoderlibraryvendor", video.getEncoderLibraryVendor());
2268       }
2269       if (video.getFormat() != null) {
2270         json.addProperty("format", video.getFormat());
2271       }
2272       if (video.getFormatVersion() != null) {
2273         json.addProperty("formatversion", video.getFormatVersion());
2274       }
2275       if (video.getFrameCount() != null) {
2276         json.addProperty("framecount", video.getFrameCount());
2277       }
2278       if (video.getFrameHeight() != null) {
2279         json.addProperty("frameheight", video.getFrameHeight());
2280       }
2281       if (video.getFrameRate() != null) {
2282         json.addProperty("framerate", video.getFrameRate());
2283       }
2284       if (video.getFrameWidth() != null) {
2285         json.addProperty("framewidth", video.getFrameWidth());
2286       }
2287       if (video.getIdentifier() != null) {
2288         json.addProperty("identifier", video.getIdentifier());
2289       }
2290       if (video.getScanOrder() != null) {
2291         json.addProperty("scanorder", video.getScanOrder().toString());
2292       }
2293       if (video.getScanType() != null) {
2294         json.addProperty("scantype", video.getScanType().toString());
2295       }
2296     }
2297 
2298     return json;
2299   }
2300 
2301   private String getEventUrl(String eventId) {
2302     return UrlSupport.concat(endpointBaseUrl, eventId);
2303   }
2304 
2305   @GET
2306   @Path("{eventId}/scheduling")
2307   @Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_1_0, ApiMediaType.VERSION_1_2_0, ApiMediaType.VERSION_1_3_0,
2308               ApiMediaType.VERSION_1_4_0, ApiMediaType.VERSION_1_5_0, ApiMediaType.VERSION_1_6_0,
2309               ApiMediaType.VERSION_1_7_0, ApiMediaType.VERSION_1_8_0, ApiMediaType.VERSION_1_9_0,
2310               ApiMediaType.VERSION_1_10_0, ApiMediaType.VERSION_1_11_0 })
2311   @RestQuery(
2312       name = "geteventscheduling",
2313       description = "Returns an event's scheduling information.",
2314       returnDescription = "",
2315       pathParameters = {
2316           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING)
2317       },
2318       responses = {
2319           @RestResponse(description = "The scheduling information for the specified event is returned.",
2320               responseCode = HttpServletResponse.SC_OK),
2321           @RestResponse(description = "The specified event has no scheduling information.",
2322               responseCode = HttpServletResponse.SC_NO_CONTENT),
2323           @RestResponse(description = "The specified event does not exist.",
2324               responseCode = HttpServletResponse.SC_NOT_FOUND)
2325       })
2326   public Response getEventScheduling(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id)
2327           throws Exception {
2328     try {
2329       final Optional<Event> event = indexService.getEvent(id, elasticsearchIndex);
2330 
2331       if (event.isEmpty()) {
2332         return ApiResponseBuilder.notFound(String.format("Unable to find event with id '%s'", id));
2333       }
2334 
2335       final JsonObject scheduling = SchedulingInfo.of(event.get().getIdentifier(), schedulerService).toJson();
2336       if (!scheduling.isEmpty()) {
2337         return ApiResponseBuilder.Json.ok(acceptHeader, scheduling);
2338       }
2339       return Response.noContent().build();
2340     } catch (SearchIndexException e) {
2341       logger.error("Unable to get list of publications from event with id '{}'", id, e);
2342       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
2343     }
2344   }
2345 
2346   @PUT
2347   @Path("{eventId}/scheduling")
2348   @Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_1_0, ApiMediaType.VERSION_1_2_0, ApiMediaType.VERSION_1_3_0,
2349               ApiMediaType.VERSION_1_4_0, ApiMediaType.VERSION_1_5_0, ApiMediaType.VERSION_1_6_0,
2350               ApiMediaType.VERSION_1_7_0, ApiMediaType.VERSION_1_8_0, ApiMediaType.VERSION_1_9_0,
2351               ApiMediaType.VERSION_1_10_0, ApiMediaType.VERSION_1_11_0 })
2352   @RestQuery(
2353       name = "updateeventscheduling",
2354       description = "Update an event's scheduling information.",
2355       returnDescription = "",
2356       pathParameters = {
2357           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = Type.STRING)
2358       },
2359       restParameters = {
2360           @RestParameter(name = "scheduling", isRequired = true, description = "Scheduling Information",
2361               type = Type.STRING),
2362           @RestParameter(name = "allowConflict", description = "Allow conflicts when updating scheduling",
2363               isRequired = false, type = Type.BOOLEAN)
2364       },
2365       responses = {
2366           @RestResponse(description = "The  scheduling information for the specified event is updated.",
2367               responseCode = HttpServletResponse.SC_NO_CONTENT),
2368           @RestResponse(description = "The specified event has no scheduling information to update.",
2369               responseCode = HttpServletResponse.SC_NOT_ACCEPTABLE),
2370           @RestResponse(description = "The scheduling information could not be updated due to a conflict.",
2371               responseCode = HttpServletResponse.SC_CONFLICT),
2372           @RestResponse(description = "The specified event does not exist.",
2373               responseCode = HttpServletResponse.SC_NOT_FOUND)
2374       })
2375   public Response updateEventScheduling(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
2376       @FormParam("scheduling") String scheduling,
2377       @FormParam("allowConflict") @DefaultValue("false") boolean allowConflict) throws Exception {
2378     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
2379     final Optional<Event> event = indexService.getEvent(id, elasticsearchIndex);
2380 
2381     if (requestedVersion.isSmallerThan(ApiVersion.VERSION_1_2_0)) {
2382       allowConflict = false;
2383     }
2384     if (event.isEmpty()) {
2385       return ApiResponseBuilder.notFound(String.format("Unable to find event with id '%s'", id));
2386     }
2387     final JSONParser parser = new JSONParser();
2388     JSONObject parsedJson;
2389     try {
2390       parsedJson = (JSONObject) parser.parse(scheduling);
2391     } catch (ParseException e) {
2392       logger.debug("Client sent unparsable scheduling information for event {}: {}", id, scheduling);
2393       return RestUtil.R.badRequest("Unparsable scheduling information");
2394     }
2395     Optional<Response> clientError = updateSchedulingInformation(parsedJson, id, requestedVersion, allowConflict);
2396     return clientError.orElse(Response.noContent().build());
2397   }
2398 
2399   private Optional<Response> updateSchedulingInformation(
2400       JSONObject parsedScheduling,
2401       String id,
2402       ApiVersion requestedVersion,
2403       boolean allowConflict) throws Exception {
2404 
2405     SchedulingInfo schedulingInfo;
2406     try {
2407       schedulingInfo = SchedulingInfo.of(parsedScheduling);
2408     } catch (DateTimeParseException e) {
2409       logger.debug("Client sent unparsable start or end date for event {}", id);
2410       return Optional.of(RestUtil.R.badRequest("Unparsable date in scheduling information"));
2411     }
2412     final TechnicalMetadata technicalMetadata = schedulerService.getTechnicalMetadata(id);
2413 
2414     // When "inputs" is updated, capture agent configuration needs to be merged
2415     Optional<Map<String, String>> caConfig = Optional.empty();
2416     if (schedulingInfo.getInputs().isPresent()) {
2417       final Map<String, String> configMap = new HashMap<>(technicalMetadata.getCaptureAgentConfiguration());
2418       configMap.put(CaptureParameters.CAPTURE_DEVICE_NAMES, schedulingInfo.getInputs().get());
2419       caConfig = Optional.of(configMap);
2420     }
2421 
2422     try {
2423       schedulerService.updateEvent(
2424           id,
2425           schedulingInfo.getStartDate(),
2426           schedulingInfo.getEndDate(),
2427           schedulingInfo.getAgentId(),
2428           Optional.empty(),
2429           Optional.empty(),
2430           Optional.empty(),
2431           caConfig,
2432           allowConflict);
2433     } catch (SchedulerConflictException e) {
2434       final List<MediaPackage> conflictingEvents = getConflictingEvents(
2435           schedulingInfo.merge(technicalMetadata), agentStateService, schedulerService);
2436       logger.debug("Client tried to change scheduling information causing a conflict for event {}.", id);
2437       List<JsonObject> conflicts = convertConflictingEvents(
2438           Optional.of(id), conflictingEvents, indexService, elasticsearchIndex
2439       );
2440 
2441       JsonArray conflictArray = new JsonArray();
2442       for (JsonObject conflict : conflicts) {
2443         conflictArray.add(conflict);
2444       }
2445 
2446       return Optional.of(ApiResponseBuilder.Json.conflict(requestedVersion, conflictArray));
2447     }
2448     return Optional.empty();
2449   }
2450 
2451   @POST
2452   @Path("{eventId}/track")
2453   @Consumes(MediaType.MULTIPART_FORM_DATA)
2454   @RestQuery(
2455       name = "updateFlavorWithTrack",
2456       description = "Update an events track for a given flavor",
2457       returnDescription = "",
2458       pathParameters = {
2459           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = STRING) },
2460       restParameters = {
2461           @RestParameter(description = "Flavor to add track to, e.g. captions/source",
2462               isRequired = true, name = "flavor", type = RestParameter.Type.STRING),
2463           @RestParameter(description = "Comma separated list of tags for the given track, e.g. archive,publish. "
2464               + "If a 'lang:LANG-CODE' tag exists and overwriteExisting=true "
2465               + "only tracks with same lang tag and flavor will be replaced. This behavior is used for captions.",
2466               isRequired = false, name = "tags", type = RestParameter.Type.STRING),
2467           @RestParameter(description = "If true, all other tracks in the specified flavor are REMOVED. "
2468               + "If tags argument contains a lang:LANG-CODE tag, only elements with same tag would be removed.",
2469               isRequired = true, name = "overwriteExisting", type = RestParameter.Type.BOOLEAN),
2470           @RestParameter(description = "The track file", isRequired = true, name = "track",
2471               type = RestParameter.Type.FILE),
2472       },
2473       responses = {
2474           @RestResponse(description = "The specified event does not exist.",
2475               responseCode = HttpServletResponse.SC_NOT_FOUND),
2476           @RestResponse(description = "The track has been added to the event.",
2477               responseCode = HttpServletResponse.SC_OK),
2478           @RestResponse(description = "The request is invalid or inconsistent.",
2479               responseCode = HttpServletResponse.SC_BAD_REQUEST),
2480       })
2481   public Response updateFlavorWithTrack(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String id,
2482           @Context HttpServletRequest request) {
2483     logger.debug("updateFlavorWithTrack called");
2484     try {
2485       boolean overwriteExisting = false;
2486       MediaPackageElementFlavor tmpFlavor = MediaPackageElementFlavor.parseFlavor("addTrack/temporary");
2487       MediaPackageElementFlavor newFlavor = null;
2488       Optional<Event> event;
2489       List<String> tags = null;
2490       String langTag = null;
2491 
2492       try {
2493         event = indexService.getEvent(id, elasticsearchIndex);
2494       } catch (SearchIndexException e) {
2495         return RestUtil.R.badRequest(String.format("Error while searching for event with id %s; %s",
2496             id, e.getMessage()));
2497       }
2498 
2499       if (event.isEmpty()) {
2500         return ApiResponseBuilder.notFound(String.format("Unable to find event with id '%s'", id));
2501       }
2502       MediaPackage mp = indexService.getEventMediapackage(event.get());
2503 
2504       try {
2505         if (workflowService.mediaPackageHasActiveWorkflows(mp.getIdentifier().toString())) {
2506           return RestUtil.R.conflict(String.format("Cannot update while a workflow is running on event '%s'", id));
2507         }
2508       } catch (WorkflowDatabaseException e) {
2509         return RestUtil.R.serverError();
2510       }
2511 
2512       if (!ServletFileUpload.isMultipartContent(request)) {
2513         throw new IllegalArgumentException("No multipart content");
2514       }
2515       for (FileItemIterator iter = new ServletFileUpload().getItemIterator(request); iter.hasNext();) {
2516         FileItemStream item = iter.next();
2517         String fieldName = item.getFieldName();
2518         if (item.isFormField()) {
2519           if ("flavor".equals(fieldName)) {
2520             String flavorString = Streams.asString(item.openStream());
2521             try {
2522               newFlavor = MediaPackageElementFlavor.parseFlavor(flavorString);
2523             } catch (IllegalArgumentException e) {
2524               return RestUtil.R.badRequest(String.format("Could not parse flavor %s; %s",
2525                   flavorString, e.getMessage()));
2526             }
2527           } else if ("tags".equals(fieldName)) {
2528             String tagsString = Streams.asString(item.openStream());
2529             if (StringUtils.isNotBlank(tagsString)) {
2530               tags = List.of(StringUtils.split(tagsString, ','));
2531               // find lang tag if exists
2532               for (String tag : tags) {
2533                 if (StringUtils.startsWith(StringUtils.trimToEmpty(tag), "lang:")) {
2534                   // lang tag is set
2535                   langTag = StringUtils.trimToEmpty(tag);
2536                   break;
2537                 }
2538               }
2539             }
2540           } else if ("overwriteExisting".equals(fieldName)) {
2541             overwriteExisting = Boolean.parseBoolean(Streams.asString(item.openStream()));
2542           }
2543         } else {
2544           // Add track with temporary flavor
2545           if ("track".equals(item.getFieldName())) {
2546             mp = ingestService.addTrack(item.openStream(), item.getName(), tmpFlavor, mp);
2547           }
2548         }
2549       }
2550 
2551       if (overwriteExisting) {
2552         // remove existing attachments of the new flavor
2553         Track[] existing = mp.getTracks(newFlavor);
2554         for (int i = 0; i < existing.length; i++) {
2555           // if lang tag is set, remove only matching elements
2556           if (null == langTag || existing[i].containsTag(langTag)) {
2557             mp.remove(existing[i]);
2558             logger.debug("Overwriting existing asset {} {}", tmpFlavor, newFlavor);
2559           }
2560         }
2561       }
2562       // correct the flavor of the new attachment
2563       for (Track track : mp.getTracks(tmpFlavor)) {
2564         track.setFlavor(newFlavor);
2565         if (null != tags) {
2566           for (String tag : tags) {
2567             track.addTag(tag);
2568           }
2569         }
2570       }
2571       logger.debug("Updated asset {} {}", tmpFlavor, newFlavor);
2572 
2573       try {
2574         assetManager.takeSnapshot(mp);
2575       } catch (AssetManagerException e) {
2576         logger.error("Error while adding the updated media package ({}) to the archive", mp.getIdentifier(), e);
2577         return RestUtil.R.badRequest(e.getMessage());
2578       }
2579 
2580       return Response.status(Status.OK).build();
2581     } catch (IllegalArgumentException | IOException | FileUploadException | IndexServiceException | IngestException
2582             | MediaPackageException e) {
2583       return RestUtil.R.badRequest(String.format("Could not add track: %s", e.getMessage()));
2584     }
2585   }
2586 }