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