View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.scheduler.endpoint;
23  
24  import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
25  import static javax.servlet.http.HttpServletResponse.SC_OK;
26  import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
27  import static org.apache.commons.lang3.exception.ExceptionUtils.getMessage;
28  import static org.opencastproject.capture.CaptureParameters.AGENT_REGISTRATION_TYPE;
29  import static org.opencastproject.capture.CaptureParameters.AGENT_REGISTRATION_TYPE_ADHOC;
30  import static org.opencastproject.metadata.dublincore.DublinCore.PROPERTY_CREATED;
31  import static org.opencastproject.metadata.dublincore.DublinCore.PROPERTY_SPATIAL;
32  import static org.opencastproject.metadata.dublincore.DublinCore.PROPERTY_TEMPORAL;
33  import static org.opencastproject.metadata.dublincore.DublinCore.PROPERTY_TITLE;
34  import static org.opencastproject.util.Jsons.arr;
35  import static org.opencastproject.util.Jsons.obj;
36  import static org.opencastproject.util.Jsons.p;
37  import static org.opencastproject.util.Jsons.v;
38  import static org.opencastproject.util.RestUtil.generateErrorResponse;
39  
40  import org.opencastproject.capture.admin.api.Agent;
41  import org.opencastproject.capture.admin.api.AgentState;
42  import org.opencastproject.capture.admin.api.CaptureAgentStateService;
43  import org.opencastproject.mediapackage.Catalog;
44  import org.opencastproject.mediapackage.MediaPackage;
45  import org.opencastproject.mediapackage.MediaPackageBuilderFactory;
46  import org.opencastproject.mediapackage.MediaPackageElement;
47  import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
48  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
49  import org.opencastproject.mediapackage.MediaPackageElements;
50  import org.opencastproject.mediapackage.MediaPackageException;
51  import org.opencastproject.mediapackage.MediaPackageParser;
52  import org.opencastproject.metadata.dublincore.DCMIPeriod;
53  import org.opencastproject.metadata.dublincore.DublinCore;
54  import org.opencastproject.metadata.dublincore.DublinCoreCatalog;
55  import org.opencastproject.metadata.dublincore.DublinCoreUtil;
56  import org.opencastproject.metadata.dublincore.DublinCores;
57  import org.opencastproject.metadata.dublincore.EncodingSchemeUtils;
58  import org.opencastproject.metadata.dublincore.Precision;
59  import org.opencastproject.rest.RestConstants;
60  import org.opencastproject.scheduler.api.Recording;
61  import org.opencastproject.scheduler.api.SchedulerConflictException;
62  import org.opencastproject.scheduler.api.SchedulerException;
63  import org.opencastproject.scheduler.api.SchedulerService;
64  import org.opencastproject.scheduler.api.TechnicalMetadata;
65  import org.opencastproject.scheduler.impl.CaptureNowProlongingService;
66  import org.opencastproject.security.api.UnauthorizedException;
67  import org.opencastproject.systems.OpencastConstants;
68  import org.opencastproject.util.DateTimeSupport;
69  import org.opencastproject.util.Jsons;
70  import org.opencastproject.util.Jsons.Arr;
71  import org.opencastproject.util.Jsons.Prop;
72  import org.opencastproject.util.Jsons.Val;
73  import org.opencastproject.util.NotFoundException;
74  import org.opencastproject.util.RestUtil;
75  import org.opencastproject.util.UrlSupport;
76  import org.opencastproject.util.doc.rest.RestParameter;
77  import org.opencastproject.util.doc.rest.RestParameter.Type;
78  import org.opencastproject.util.doc.rest.RestQuery;
79  import org.opencastproject.util.doc.rest.RestResponse;
80  import org.opencastproject.util.doc.rest.RestService;
81  import org.opencastproject.workspace.api.Workspace;
82  
83  import com.google.gson.Gson;
84  import com.google.gson.GsonBuilder;
85  import com.google.gson.JsonPrimitive;
86  import com.google.gson.JsonSerializer;
87  
88  import net.fortuna.ical4j.model.property.RRule;
89  
90  import org.apache.commons.io.IOUtils;
91  import org.apache.commons.lang3.StringUtils;
92  import org.joda.time.DateTime;
93  import org.joda.time.DateTimeZone;
94  import org.json.simple.JSONArray;
95  import org.json.simple.JSONObject;
96  import org.json.simple.parser.JSONParser;
97  import org.osgi.service.component.ComponentContext;
98  import org.osgi.service.component.annotations.Activate;
99  import org.osgi.service.component.annotations.Component;
100 import org.osgi.service.component.annotations.Reference;
101 import org.osgi.service.component.annotations.ReferenceCardinality;
102 import org.osgi.service.component.annotations.ReferencePolicy;
103 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
104 import org.slf4j.Logger;
105 import org.slf4j.LoggerFactory;
106 
107 import java.io.IOException;
108 import java.io.InputStream;
109 import java.io.StringReader;
110 import java.net.URI;
111 import java.text.ParseException;
112 import java.util.ArrayList;
113 import java.util.Arrays;
114 import java.util.Collections;
115 import java.util.Date;
116 import java.util.HashMap;
117 import java.util.HashSet;
118 import java.util.List;
119 import java.util.Map;
120 import java.util.Map.Entry;
121 import java.util.Objects;
122 import java.util.Optional;
123 import java.util.Properties;
124 import java.util.Set;
125 import java.util.TimeZone;
126 import java.util.stream.Collectors;
127 
128 import javax.servlet.http.HttpServletRequest;
129 import javax.servlet.http.HttpServletResponse;
130 import javax.ws.rs.DELETE;
131 import javax.ws.rs.FormParam;
132 import javax.ws.rs.GET;
133 import javax.ws.rs.POST;
134 import javax.ws.rs.PUT;
135 import javax.ws.rs.Path;
136 import javax.ws.rs.PathParam;
137 import javax.ws.rs.Produces;
138 import javax.ws.rs.QueryParam;
139 import javax.ws.rs.WebApplicationException;
140 import javax.ws.rs.core.Context;
141 import javax.ws.rs.core.HttpHeaders;
142 import javax.ws.rs.core.MediaType;
143 import javax.ws.rs.core.Response;
144 import javax.ws.rs.core.Response.ResponseBuilder;
145 import javax.ws.rs.core.Response.Status;
146 
147 /**
148  * REST Endpoint for Scheduler Service
149  */
150 @Path("/recordings")
151 @RestService(name = "schedulerservice",
152     title = "Scheduler Service",
153     abstractText = "This service creates, edits and retrieves and helps managing scheduled capture events.",
154     notes = {
155         "All paths above are relative to the REST endpoint base (something like http://your.server/files)",
156         "If the service is down or not working it will return a status 503, this means the the underlying service is "
157                 + "not working and is either restarting or has failed",
158         "A status code 500 means a general failure has occurred which is not recoverable and was not anticipated. In "
159                 + "other words, there is a bug! You should file an error report with your server logs from the time "
160                 + "when the error occurred: "
161                 + "<a href=\"https://github.com/opencast/opencast/issues\">Opencast Issue Tracker</a>"
162     }
163 )
164 @Component(
165     immediate = true,
166     service = SchedulerRestService.class,
167     property = {
168         "service.description=Scheduler REST Endpoint",
169         "opencast.service.type=org.opencastproject.scheduler",
170         "opencast.service.path=/recordings"
171     }
172 )
173 @JaxrsResource
174 public class SchedulerRestService {
175 
176   private static final Logger logger = LoggerFactory.getLogger(SchedulerRestService.class);
177 
178   /** Key for the default workflow definition in config.properties */
179   private static final String DEFAULT_WORKFLOW_DEFINITION = "org.opencastproject.workflow.default.definition";
180 
181   private SchedulerService service;
182   private CaptureAgentStateService agentService;
183   private CaptureNowProlongingService prolongingService;
184   private Workspace workspace;
185 
186   private final Gson gson = new Gson();
187   private final Gson gsonTimestamp = new GsonBuilder()
188       .registerTypeAdapter(
189           Date.class,
190           (JsonSerializer<Date>) (date, type, jsonSerializationContext) -> new JsonPrimitive(date.getTime()))
191       .create();
192 
193   private String defaultWorkflowDefinitionId;
194 
195   protected String serverUrl = UrlSupport.DEFAULT_BASE_URL;
196   protected String serviceUrl = null;
197 
198   /**
199    * Method to set the service this REST endpoint uses
200    *
201    * @param service
202    */
203   @Reference(
204       policy = ReferencePolicy.DYNAMIC,
205       unbind = "unsetService"
206   )
207   public void setService(SchedulerService service) {
208     this.service = service;
209   }
210 
211   /**
212    * Method to unset the service this REST endpoint uses
213    *
214    * @param service
215    */
216   public void unsetService(SchedulerService service) {
217     if (this.service == service) {
218       this.service = null;
219     }
220   }
221 
222   /**
223    * Method to set the prolonging service this REST endpoint uses
224    *
225    * @param prolongingService
226    */
227   @Reference(
228       policy = ReferencePolicy.DYNAMIC,
229       unbind = "unsetProlongingService"
230   )
231   public void setProlongingService(CaptureNowProlongingService prolongingService) {
232     this.prolongingService = prolongingService;
233   }
234 
235   /**
236    * Method to unset the prolonging service this REST endpoint uses
237    *
238    * @param prolongingService
239    */
240   public void unsetProlongingService(CaptureNowProlongingService prolongingService) {
241     if (this.prolongingService == prolongingService) {
242       this.prolongingService = null;
243     }
244   }
245 
246   /**
247    * Method to set the capture agent state service this REST endpoint uses
248    *
249    * @param agentService
250    */
251   @Reference(
252       cardinality = ReferenceCardinality.OPTIONAL,
253       policy = ReferencePolicy.DYNAMIC,
254       unbind = "unsetCaptureAgentStateService"
255   )
256   public void setCaptureAgentStateService(CaptureAgentStateService agentService) {
257     this.agentService = agentService;
258   }
259 
260   /**
261    * Method to unset the capture agent state service this REST endpoint uses
262    *
263    * @param agentService
264    */
265   public void unsetCaptureAgentStateService(CaptureAgentStateService agentService) {
266     if (this.agentService == agentService) {
267       this.agentService = null;
268     }
269   }
270 
271   /**
272    * Method to set the workspace this REST endpoint uses
273    *
274    * @param workspace
275    */
276   @Reference
277   public void setWorkspace(Workspace workspace) {
278     this.workspace = workspace;
279   }
280 
281   /**
282    * The method that will be called, if the service will be activated
283    *
284    * @param cc
285    *          The ComponentContext of this service
286    */
287   @Activate
288   public void activate(ComponentContext cc) {
289     // Get the configured server URL
290     if (cc != null) {
291       String ccServerUrl = cc.getBundleContext().getProperty(OpencastConstants.SERVER_URL_PROPERTY);
292       logger.debug("configured server url is {}", ccServerUrl);
293       if (ccServerUrl == null) {
294         serverUrl = UrlSupport.DEFAULT_BASE_URL;
295       } else {
296         serverUrl = ccServerUrl;
297       }
298       serviceUrl = (String) cc.getProperties().get(RestConstants.SERVICE_PATH_PROPERTY);
299       defaultWorkflowDefinitionId = StringUtils
300               .trimToNull(cc.getBundleContext().getProperty(DEFAULT_WORKFLOW_DEFINITION));
301       if (defaultWorkflowDefinitionId == null) {
302         defaultWorkflowDefinitionId = "schedule-and-upload";
303       }
304     }
305   }
306 
307   /**
308    * Gets a XML with the media package for the specified event.
309    *
310    * @param eventId
311    *          The unique ID of the event.
312    * @return media package XML for the event
313    */
314   @GET
315   @Produces(MediaType.TEXT_XML)
316   @Path("{id:.+}/mediapackage.xml")
317   @RestQuery(name = "getmediapackagexml",
318       description = "Retrieves media package for specified event",
319       returnDescription = "media package in XML",
320       pathParameters = {
321           @RestParameter(name = "id", isRequired = true, description = "ID of event for which media package will be "
322               + "retrieved", type = Type.STRING)
323       },
324       responses = {
325           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "DublinCore of event is in the body "
326               + "of response"),
327           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "Event with specified ID does "
328               + "not exist"),
329           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "You do not have permission "
330               + "to remove the event. Maybe you need to authenticate.")
331       }
332   )
333   public Response getMediaPackageXml(@PathParam("id") String eventId) throws UnauthorizedException {
334     try {
335       MediaPackage result = service.getMediaPackage(eventId);
336       return Response.ok(MediaPackageParser.getAsXml(result)).build();
337     } catch (NotFoundException e) {
338       logger.info("Event with id '{}' does not exist.", eventId);
339       return Response.status(Status.NOT_FOUND).build();
340     } catch (SchedulerException e) {
341       logger.error("Unable to retrieve event with id '{}': {}", eventId, getMessage(e));
342       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
343     }
344   }
345 
346   /**
347    * Gets a XML with the Dublin Core metadata for the specified event.
348    *
349    * @param eventId
350    *          The unique ID of the event.
351    * @return Dublin Core XML for the event
352    */
353   @GET
354   @Produces(MediaType.TEXT_XML)
355   @Path("{id:.+}/dublincore.xml")
356   @RestQuery(name = "recordingsasxml",
357       description = "Retrieves DublinCore for specified event",
358       returnDescription = "DublinCore in XML",
359       pathParameters = {
360           @RestParameter(name = "id", isRequired = true, description = "ID of event for which DublinCore will be "
361               + "retrieved", type = Type.STRING)
362       },
363       responses = {
364           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "DublinCore of event is in the body "
365               + "of response"),
366           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "Event with specified ID does "
367               + "not exist"),
368           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "You do not have permission "
369               + "to remove the event. Maybe you need to authenticate.")
370       }
371   )
372   public Response getDublinCoreMetadataXml(@PathParam("id") String eventId) throws UnauthorizedException {
373     try {
374       DublinCoreCatalog result = service.getDublinCore(eventId);
375       return Response.ok(result.toXmlString()).build();
376     } catch (NotFoundException e) {
377       logger.info("Event with id '{}' does not exist.", eventId);
378       return Response.status(Status.NOT_FOUND).build();
379     } catch (UnauthorizedException e) {
380       throw e;
381     } catch (Exception e) {
382       logger.error("Unable to retrieve event with id '{}': {}", eventId, getMessage(e));
383       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
384     }
385   }
386 
387   /**
388    * Gets a Dublin Core metadata for the specified event as JSON.
389    *
390    * @param eventId
391    *          The unique ID of the event.
392    * @return Dublin Core JSON for the event
393    */
394   @GET
395   @Produces(MediaType.APPLICATION_JSON)
396   @Path("{id:.+}/dublincore.json")
397   @RestQuery(name = "recordingsasjson",
398       description = "Retrieves DublinCore for specified event",
399       returnDescription = "DublinCore in JSON",
400       pathParameters = {
401           @RestParameter(name = "id", isRequired = true, description = "ID of event for which DublinCore will be "
402               + "retrieved", type = Type.STRING)
403       },
404       responses = {
405           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "DublinCore of event is in the body "
406               + "of response"),
407           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "Event with specified ID does "
408               + "not exist"),
409           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "You do not have permission "
410               + "to remove the event. Maybe you need to authenticate.")
411       }
412   )
413   public Response getDublinCoreMetadataJSON(@PathParam("id") String eventId) throws UnauthorizedException {
414     try {
415       DublinCoreCatalog result = service.getDublinCore(eventId);
416       return Response.ok(result.toJson()).build();
417     } catch (NotFoundException e) {
418       logger.info("Event with id '{}' does not exist.", eventId);
419       return Response.status(Status.NOT_FOUND).build();
420     } catch (UnauthorizedException e) {
421       throw e;
422     } catch (Exception e) {
423       logger.error("Unable to retrieve event with id '{}': {}", eventId, getMessage(e));
424       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
425     }
426   }
427 
428   /**
429    * Gets a XML with the media package for the specified event.
430    *
431    * @param eventId
432    *          The unique ID of the event.
433    * @return media package XML for the event
434    */
435   @GET
436   @Produces(MediaType.TEXT_XML)
437   @Path("{id:.+}/technical.json")
438   @RestQuery(name = "gettechnicalmetadatajson",
439       description = "Retrieves the technical metadata for specified event",
440       returnDescription = "technical metadata as JSON",
441       pathParameters = {
442           @RestParameter(name = "id", isRequired = true, description = "ID of event for which the technical metadata "
443               + "will be retrieved", type = Type.STRING)
444       },
445       responses = {
446           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "technical metadata of event is in "
447               + "the body of response"),
448           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "Event with specified ID does "
449               + "not exist"),
450           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "You do not have permission "
451               + "to remove the event. Maybe you need to authenticate.")
452       }
453   )
454   public Response getTechnicalMetadataJSON(@PathParam("id") String eventId) throws UnauthorizedException {
455     try {
456       TechnicalMetadata metadata = service.getTechnicalMetadata(eventId);
457 
458       Val state = v("");
459       Val lastHeard = v("");
460       if (metadata.getRecording().isPresent()) {
461         state = v(metadata.getRecording().get().getState());
462         lastHeard = v(DateTimeSupport.toUTC(metadata.getRecording().get().getLastCheckinTime()));
463       }
464 
465       List<Val> presenterVals = metadata.getPresenters().stream()
466           .map(Jsons::stringVal)
467           .collect(Collectors.toList());
468       Arr presenters = arr(presenterVals);
469       List<Prop> wfProperties = new ArrayList<>();
470       for (Entry<String, String> entry : metadata.getWorkflowProperties().entrySet()) {
471         wfProperties.add(p(entry.getKey(), entry.getValue()));
472       }
473       List<Prop> agentConfig = new ArrayList<>();
474       for (Entry<String, String> entry : metadata.getCaptureAgentConfiguration().entrySet()) {
475         agentConfig.add(p(entry.getKey(), entry.getValue()));
476       }
477       return RestUtil.R.ok(obj(p("id", metadata.getEventId()), p("location", metadata.getAgentId()),
478               p("start", DateTimeSupport.toUTC(metadata.getStartDate().getTime())),
479               p("end", DateTimeSupport.toUTC(metadata.getEndDate().getTime())),
480               p("presenters", presenters), p("wfProperties", obj(wfProperties.toArray(new Prop[wfProperties.size()]))),
481               p("agentConfig", obj(agentConfig.toArray(new Prop[agentConfig.size()]))), p("state", state),
482               p("lastHeardFrom", lastHeard)));
483     } catch (NotFoundException e) {
484       logger.info("Event with id '{}' does not exist.", eventId);
485       return Response.status(Status.NOT_FOUND).build();
486     } catch (SchedulerException e) {
487       logger.error("Unable to retrieve event with id '{}': {}", eventId, getMessage(e));
488       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
489     }
490   }
491 
492   /**
493    * Gets the workflow configuration for the specified event.
494    *
495    * @param eventId
496    *          The unique ID of the event.
497    * @return the workflow configuration
498    */
499   @GET
500   @Produces(MediaType.TEXT_PLAIN)
501   @Path("{id:.+}/workflow.properties")
502   @RestQuery(name = "recordingsagentproperties",
503       description = "Retrieves workflow configuration for specified event",
504       returnDescription = "workflow configuration in the form of key, value pairs",
505       pathParameters = {
506           @RestParameter(name = "id", isRequired = true, description = "ID of event for which workflow configuration "
507               + "will be retrieved", type = Type.STRING)
508       },
509       responses = {
510           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "workflow configuration of event is "
511               + "in the body of response"),
512           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "Event with specified ID does "
513               + "not exist"),
514           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "You do not have permission "
515               + "to remove the event. Maybe you need to authenticate.")
516       }
517   )
518   public Response getWorkflowConfiguration(@PathParam("id") String eventId) throws UnauthorizedException {
519     try {
520       Map<String, String> result = service.getWorkflowConfig(eventId);
521       String serializedProperties = serializeProperties(result);
522       return Response.ok(serializedProperties).build();
523     } catch (NotFoundException e) {
524       logger.info("Event with id '{}' does not exist.", eventId);
525       return Response.status(Status.NOT_FOUND).build();
526     } catch (SchedulerException e) {
527       logger.error("Unable to retrieve workflow configuration for event with id '{}': {}", eventId, getMessage(e));
528       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
529     }
530   }
531 
532   /**
533    * Gets java Properties file with technical metadata for the specified event.
534    *
535    * @param eventId
536    *          The unique ID of the event.
537    * @return Java Properties File with the metadata for the event
538    */
539   @GET
540   @Produces(MediaType.TEXT_PLAIN)
541   @Path("{id:.+}/agent.properties")
542   @RestQuery(name = "recordingsagentproperties",
543       description = "Retrieves Capture Agent properties for specified event",
544       returnDescription = "Capture Agent properties in the form of key, value pairs",
545       pathParameters = {
546           @RestParameter(name = "id", isRequired = true, description = "ID of event for which agent properties will "
547               + "be retrieved", type = Type.STRING)
548       },
549       responses = {
550                   @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "Capture Agent properties of "
551                       + "event is in the body of response"),
552                   @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "Event with "
553                       + "specified ID does not exist"),
554                   @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "You do not have "
555                       + "permission to remove the event. Maybe you need to authenticate.")
556       }
557   )
558   public Response getCaptureAgentMetadata(@PathParam("id") String eventId) throws UnauthorizedException {
559     try {
560       Map<String, String> result = service.getCaptureAgentConfiguration(eventId);
561       String serializedProperties = serializeProperties(result);
562       return Response.ok(serializedProperties).build();
563     } catch (NotFoundException e) {
564       logger.info("Event with id '{}' does not exist.", eventId);
565       return Response.status(Status.NOT_FOUND).build();
566     } catch (SchedulerException e) {
567       logger.error("Unable to retrieve event with id '{}': {}", eventId, getMessage(e));
568       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
569     }
570   }
571 
572   /**
573    *
574    * Removes the specified event. Returns true if the event was found and could be removed.
575    *
576    * @param eventId
577    *          The unique ID of the event.
578    * @return true if the event was found and could be deleted.
579    */
580   @DELETE
581   @Path("{id:.+}")
582   @Produces(MediaType.TEXT_PLAIN)
583   @RestQuery(name = "deleterecordings",
584       description = "Removes scheduled event with specified ID.",
585       returnDescription = "OK if event were successfully removed or NOT FOUND if event with specified ID does not "
586           + "exist",
587       pathParameters = {
588           @RestParameter(name = "id", isRequired = true, description = "Event ID", type = Type.STRING)
589       },
590       responses = {
591           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "Event was successfully removed"),
592           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "Event with specified ID does "
593               + "not exist"),
594           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "You do not have permission "
595               + "to remove the event. Maybe you need to authenticate."),
596           @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT, description = "Event with specified ID is "
597               + "locked by a transaction, unable to delete event.")
598       }
599   )
600   public Response deleteEvent(@PathParam("id") String eventId) throws UnauthorizedException {
601     try {
602       service.removeEvent(eventId);
603       return Response.status(Response.Status.OK).build();
604     } catch (NotFoundException e) {
605       logger.info("Event with id '{}' does not exist.", eventId);
606       return Response.status(Status.NOT_FOUND).build();
607     } catch (UnauthorizedException e) {
608       throw e;
609     } catch (Exception e) {
610       logger.error("Unable to delete event with id '{}': {}", eventId, getMessage(e));
611       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
612     }
613   }
614 
615   /**
616    * Gets the iCalendar with all (even old) events for the specified filter.
617    *
618    * @param captureAgentId
619    *          The ID that specifies the capture agent.
620    * @param seriesId
621    *          The ID that specifies series.
622    *
623    * @return an iCalendar
624    */
625   @GET
626   @Produces("text/calendar")
627   // NOTE: charset not supported by current jaxrs impl (is ignored), set explicitly in response
628   @Path("calendars")
629   @RestQuery(name = "getcalendar",
630       description = "Returns iCalendar for specified set of events",
631       returnDescription = "ICalendar for events",
632       restParameters = {
633           @RestParameter(name = "agentid", description = "Filter events by capture agent", isRequired = false,
634               type = Type.STRING),
635           @RestParameter(name = "seriesid", description = "Filter events by series", isRequired = false,
636               type = Type.STRING),
637           @RestParameter(name = "cutoff", description = "A cutoff date in UNIX milliseconds to limit the number of "
638               + "events returned in the calendar.", isRequired = false, type = Type.INTEGER)
639       },
640       responses = {
641           @RestResponse(responseCode = HttpServletResponse.SC_NOT_MODIFIED, description = "Events were not modified "
642               + "since last request"),
643           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "Events were modified, new calendar "
644               + "is in the body")
645       }
646   )
647   public Response getCalendar(@QueryParam("agentid") String captureAgentId, @QueryParam("seriesid") String seriesId,
648           @QueryParam("cutoff") Long cutoff, @Context HttpServletRequest request) {
649     Date endDate = null;
650     if (cutoff != null) {
651       try {
652         endDate = new Date(cutoff);
653       } catch (NumberFormatException e) {
654         return Response.status(Status.BAD_REQUEST).build();
655       }
656     }
657 
658     try {
659       String lastModified = null;
660       // If the etag matches the if-not-modified header,return a 304
661       if (StringUtils.isNotBlank(captureAgentId)) {
662         lastModified = service.getScheduleLastModified(captureAgentId);
663         String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH);
664         if (StringUtils.isNotBlank(ifNoneMatch) && ifNoneMatch.equals(lastModified)) {
665           return Response.notModified(lastModified).expires(null).build();
666         }
667       }
668 
669       String result = service.getCalendar(Optional.ofNullable(StringUtils.trimToNull(captureAgentId)),
670               Optional.ofNullable(StringUtils.trimToNull(seriesId)), Optional.ofNullable(endDate));
671 
672       ResponseBuilder response = Response.ok(result).header(HttpHeaders.CONTENT_TYPE, "text/calendar; charset=UTF-8");
673       if (StringUtils.isNotBlank(lastModified)) {
674         response.header(HttpHeaders.ETAG, lastModified);
675       }
676       return response.build();
677     } catch (Exception e) {
678       logger.error("Unable to get calendar for capture agent '{}':", captureAgentId, e);
679       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
680     }
681   }
682 
683   @GET
684   @Produces("application/json")
685   @Path("calendar.json")
686   @RestQuery(name = "getCalendarJSON",
687       description = "Returns a calendar in JSON format for specified events.",
688       returnDescription = "Calendar for events in JSON format",
689       restParameters = {
690           @RestParameter(name = "agentid", description = "Filter events by capture agent", isRequired = false,
691               type = Type.STRING),
692           @RestParameter(name = "cutoff", description = "A cutoff date in UNIX milliseconds to limit the number of "
693               + "events returned in the calendar.", isRequired = false, type = Type.INTEGER),
694           @RestParameter(name = "timestamp", description = "Return dates as UNIX timestamp in milliseconds instead "
695               + "of a date string.", isRequired = false, type = Type.BOOLEAN)
696       }, responses = {
697           @RestResponse(responseCode = HttpServletResponse.SC_NOT_MODIFIED, description = "Events were not modified "
698               + "since last request"),
699           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "Events were modified, new calendar "
700               + "is in the body")
701       }
702   )
703   public Response getCalendarJson(
704           @QueryParam("agentid") String captureAgentId,
705           @QueryParam("cutoff") Long cutoff,
706           @QueryParam("timestamp") Boolean timestamp,
707           @Context HttpServletRequest request) {
708     try {
709       var endDate = Optional.ofNullable(cutoff)
710               .map(Date::new);
711       var agent = Optional.ofNullable(captureAgentId)
712               .map(String::trim)
713               .filter(id -> !id.isEmpty());
714       timestamp = !Objects.isNull(timestamp) && timestamp;
715 
716       String lastModified = null;
717       // If the `etag` matches the if-not-modified header,return a 304
718       if (agent.isPresent()) {
719         lastModified = service.getScheduleLastModified(agent.get());
720         String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH);
721         if (StringUtils.isNotBlank(ifNoneMatch) && ifNoneMatch.equals(lastModified)) {
722           return Response.notModified(lastModified).expires(null).build();
723         }
724       }
725 
726       var result = new ArrayList<Map<String, Object>>();
727       for (var event: service.search(agent, Optional.empty(), Optional.empty(), Optional.of(new Date()), endDate)) {
728         var id = event.getIdentifier().toString();
729         result.add(Map.of(
730                 "data", service.getTechnicalMetadata(id),
731                 "episode-dublincore", service.getDublinCore(id).toXmlString()
732                 ));
733       }
734 
735       final ResponseBuilder response = Response.ok((timestamp ? gsonTimestamp : gson).toJson(result));
736       if (StringUtils.isNotBlank(lastModified)) {
737         response.header(HttpHeaders.ETAG, lastModified);
738       }
739       return response.build();
740     } catch (Exception e) {
741       throw new WebApplicationException(
742               String.format("Unable to get calendar for capture agent %s", captureAgentId),
743               e, Response.Status.INTERNAL_SERVER_ERROR);
744     }
745   }
746 
747 
748   @GET
749   @Produces(MediaType.TEXT_PLAIN)
750   @Path("{id}/lastmodified")
751   @RestQuery(name = "agentlastmodified",
752       description = "Retrieves the last modified hash for specified agent",
753       returnDescription = "The last modified hash",
754       pathParameters = {
755           @RestParameter(name = "id", isRequired = true, description = "ID of capture agent for which the last "
756               + "modified hash will be retrieved", type = Type.STRING)
757       },
758       responses = {
759           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "The last modified hash of agent "
760               + "is in the body of response")
761       }
762   )
763   public Response getLastModified(@PathParam("id") String agentId) {
764     try {
765       String lastModified = service.getScheduleLastModified(agentId);
766       return Response.ok(lastModified).build();
767     } catch (Exception e) {
768       logger.error("Unable to retrieve agent last modified hash of agent id '{}': {}", agentId, getMessage(e));
769       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
770     }
771   }
772 
773   @POST
774   @Path("/removeOldScheduledRecordings")
775   @RestQuery(name = "removeOldScheduledRecordings",
776       description = "This will find and remove any scheduled events before the buffer time to keep performance in the "
777           + "scheduler optimum.",
778       returnDescription = "No return value",
779       responses = {
780           @RestResponse(responseCode = SC_OK, description = "Removed old scheduled recordings."),
781           @RestResponse(responseCode = SC_BAD_REQUEST, description = "Unable to parse buffer."),
782           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "You do not have permission to remove old "
783               + "schedulings. Maybe you need to authenticate.")
784       },
785       restParameters = {
786           @RestParameter(name = "buffer", type = RestParameter.Type.INTEGER, defaultValue = "604800", isRequired = true,
787               description = "The amount of seconds before now that a capture has to have stopped capturing. "
788                   + "It must be 0 or greater.")
789       }
790   )
791   public Response removeOldScheduledRecordings(@FormParam("buffer") long buffer) throws UnauthorizedException {
792     if (buffer < 0) {
793       return Response.status(SC_BAD_REQUEST).build();
794     }
795 
796     try {
797       service.removeScheduledRecordingsBeforeBuffer(buffer);
798     } catch (SchedulerException e) {
799       logger.error("Error while trying to remove old scheduled recordings", e);
800       throw new WebApplicationException(e);
801     }
802     return Response.ok().build();
803   }
804 
805 
806   /**
807    * Creates new event based on parameters. All times and dates are in milliseconds.
808    */
809   @POST
810   @Path("/")
811   @RestQuery(name = "newrecording",
812       description = "Creates new event with specified parameters",
813       returnDescription = "If an event was successfully created",
814       restParameters = {
815           @RestParameter(name = "start", isRequired = true, type = Type.INTEGER,
816               description = "The start date of the event in milliseconds from 1970-01-01T00:00:00Z"),
817           @RestParameter(name = "end", isRequired = true, type = Type.INTEGER,
818               description = "The end date of the event in milliseconds from 1970-01-01T00:00:00Z"),
819           @RestParameter(name = "agent", isRequired = true, type = Type.STRING, description = "The agent of the event"),
820           @RestParameter(name = "users", isRequired = false, type = Type.STRING,
821               description = "Comma separated list of user ids (speakers/lecturers) for the event"),
822           @RestParameter(name = "mediaPackage", isRequired = true, type = Type.TEXT,
823               description = "The media package of the event"),
824           @RestParameter(name = "wfproperties", isRequired = false, type = Type.TEXT, description = "Workflow "
825                   + "configuration keys for the event. Each key will be prefixed by 'org.opencastproject.workflow"
826                   + ".config.' and added to the capture agent parameters."),
827           @RestParameter(name = "agentparameters", isRequired = false, type = Type.TEXT,
828               description = "The capture agent properties for the event"),
829           @RestParameter(name = "source", isRequired = false, type = Type.STRING,
830               description = "The scheduling source of the event"),
831       },
832       responses = {
833           @RestResponse(responseCode = HttpServletResponse.SC_CREATED, description = "Event is successfully created"),
834           @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT,
835               description = "Unable to create event, conflicting events found (ConflicsFound)"),
836           @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT,
837               description = "Unable to create event, event locked by a transaction  (TransactionLock)"),
838           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED,
839               description = "You do not have permission to create the event. Maybe you need to authenticate."),
840           @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST,
841               description = "Missing or invalid information for this request")
842       }
843   )
844   public Response addEvent(@FormParam("start") long startTime, @FormParam("end") long endTime,
845           @FormParam("agent") String agentId, @FormParam("users") String users,
846           @FormParam("mediaPackage") String mediaPackageXml, @FormParam("wfproperties") String workflowProperties,
847           @FormParam("agentparameters") String agentParameters,
848           @FormParam("source") String schedulingSource)
849                   throws UnauthorizedException {
850     if (endTime <= startTime || startTime < 0) {
851       logger.debug("Cannot add event without proper start and end time");
852       return RestUtil.R.badRequest("Cannot add event without proper start and end time");
853     }
854 
855     if (StringUtils.isBlank(agentId)) {
856       logger.debug("Cannot add event without agent identifier");
857       return RestUtil.R.badRequest("Cannot add event without agent identifier");
858     }
859 
860     if (StringUtils.isBlank(mediaPackageXml)) {
861       logger.debug("Cannot add event without media package");
862       return RestUtil.R.badRequest("Cannot add event without media package");
863     }
864 
865     MediaPackage mediaPackage;
866     try {
867       mediaPackage = MediaPackageParser.getFromXml(mediaPackageXml);
868     } catch (MediaPackageException e) {
869       logger.debug("Could not parse media package", e);
870       return RestUtil.R.badRequest("Could not parse media package");
871     }
872 
873     String eventId = mediaPackage.getIdentifier().toString();
874 
875     Map<String, String> caProperties = new HashMap<>();
876     if (StringUtils.isNotBlank(agentParameters)) {
877       try {
878         Properties prop = parseProperties(agentParameters);
879         caProperties.putAll((Map) prop);
880       } catch (Exception e) {
881         logger.info("Could not parse capture agent properties: {}", agentParameters);
882         return RestUtil.R.badRequest("Could not parse capture agent properties");
883       }
884     }
885 
886     Map<String, String> wfProperties = new HashMap<>();
887     if (StringUtils.isNotBlank(workflowProperties)) {
888       try {
889         Properties prop = parseProperties(workflowProperties);
890         wfProperties.putAll((Map) prop);
891       } catch (IOException e) {
892         logger.info("Could not parse workflow configuration properties: {}", workflowProperties);
893         return RestUtil.R.badRequest("Could not parse workflow configuration properties");
894       }
895     }
896     Set<String> userIds = new HashSet<>();
897     String[] ids = StringUtils.split(users, ",");
898     if (ids != null) {
899       userIds.addAll(Arrays.asList(ids));
900     }
901 
902     DateTime startDate = new DateTime(startTime).toDateTime(DateTimeZone.UTC);
903     DateTime endDate = new DateTime(endTime).toDateTime(DateTimeZone.UTC);
904 
905     try {
906       service.addEvent(startDate.toDate(), endDate.toDate(), agentId, userIds, mediaPackage, wfProperties, caProperties,
907               Optional.ofNullable(schedulingSource));
908       return Response.status(Status.CREATED)
909               .header("Location", serverUrl + serviceUrl + '/' + eventId + "/mediapackage.xml").build();
910     } catch (UnauthorizedException e) {
911       throw e;
912     } catch (SchedulerConflictException e) {
913       return Response.status(Status.CONFLICT).entity(generateErrorResponse(e)).type(MediaType.APPLICATION_JSON).build();
914     } catch (Exception e) {
915       logger.error("Unable to create new event with id '{}'", eventId, e);
916       return Response.serverError().build();
917     }
918   }
919 
920   /**
921    * Creates new event based on parameters. All times and dates are in milliseconds.
922    */
923   @POST
924   @Path("/multiple")
925   @RestQuery(name = "newrecordings",
926       description = "Creates new event with specified parameters",
927       returnDescription = "If an event was successfully created",
928       restParameters = {
929           @RestParameter(name = "rrule", isRequired = true, type = Type.STRING,
930               description = "The recurrence rule for the events"),
931           @RestParameter(name = "start", isRequired = true, type = Type.INTEGER,
932               description = "The start date of the event in milliseconds from 1970-01-01T00:00:00Z"),
933           @RestParameter(name = "end", isRequired = true, type = Type.INTEGER,
934               description = "The end date of the event in milliseconds from 1970-01-01T00:00:00Z"),
935           @RestParameter(name = "duration", isRequired = true, type = Type.INTEGER,
936               description = "The duration of the events in milliseconds"),
937           @RestParameter(name = "tz", isRequired = true, type = Type.INTEGER,
938               description = "The timezone of the events"),
939           @RestParameter(name = "agent", isRequired = true, type = Type.STRING,
940               description = "The agent of the event"),
941           @RestParameter(name = "users", isRequired = false, type = Type.STRING,
942               description = "Comma separated list of user ids (speakers/lecturers) for the event"),
943           @RestParameter(name = "templateMp", isRequired = true, type = Type.TEXT,
944               description = "The template mediapackage for the events"),
945           @RestParameter(name = "wfproperties", isRequired = false, type = Type.TEXT, description = "Workflow "
946                   + "configuration keys for the event. Each key will be prefixed by 'org.opencastproject.workflow"
947                   + ".config.' and added to the capture agent parameters."),
948           @RestParameter(name = "agentparameters", isRequired = false, type = Type.TEXT,
949               description = "The capture agent properties for the event"),
950           @RestParameter(name = "source", isRequired = false, type = Type.STRING,
951               description = "The scheduling source of the event"),
952       },
953       responses = {
954           @RestResponse(responseCode = HttpServletResponse.SC_CREATED, description = "Event is successfully created"),
955           @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT,
956               description = "Unable to create event, conflicting events found (ConflicsFound)"),
957           @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT,
958               description = "Unable to create event, event locked by a transaction  (TransactionLock)"),
959           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED,
960               description = "You do not have permission to create the event. Maybe you need to authenticate."),
961           @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST,
962               description = "Missing or invalid information for this request")
963       }
964   )
965   public Response addMultipleEvents(@FormParam("rrule") String rruleString, @FormParam("start") long startTime,
966           @FormParam("end") long endTime, @FormParam("duration") long duration, @FormParam("tz") String tzString,
967           @FormParam("agent") String agentId, @FormParam("users") String users,
968           @FormParam("templateMp") MediaPackage templateMp, @FormParam("wfproperties") String workflowProperties,
969           @FormParam("agentparameters") String agentParameters,
970           @FormParam("source") String schedulingSource)
971           throws UnauthorizedException {
972     if (endTime <= startTime || startTime < 0) {
973       logger.debug("Cannot add event without proper start and end time");
974       return RestUtil.R.badRequest("Cannot add event without proper start and end time");
975     }
976 
977     RRule rrule;
978     try {
979       rrule = new RRule(rruleString);
980     } catch (ParseException e) {
981       logger.debug("Could not parse recurrence rule");
982       return RestUtil.R.badRequest("Could not parse recurrence rule");
983     }
984 
985     if (duration < 1) {
986       logger.debug("Cannot schedule events with durations less than 1");
987       return RestUtil.R.badRequest("Cannot schedule events with durations less than 1");
988     }
989 
990     if (StringUtils.isBlank(tzString)) {
991       logger.debug("Cannot schedule events with blank timezone");
992       return RestUtil.R.badRequest("Cannot schedule events with blank timezone");
993     }
994     TimeZone tz = TimeZone.getTimeZone(tzString);
995 
996     if (StringUtils.isBlank(agentId)) {
997       logger.debug("Cannot add event without agent identifier");
998       return RestUtil.R.badRequest("Cannot add event without agent identifier");
999     }
1000 
1001     Map<String, String> caProperties = new HashMap<>();
1002     if (StringUtils.isNotBlank(agentParameters)) {
1003       try {
1004         Properties prop = parseProperties(agentParameters);
1005         caProperties.putAll((Map) prop);
1006       } catch (Exception e) {
1007         logger.info("Could not parse capture agent properties: {}", agentParameters);
1008         return RestUtil.R.badRequest("Could not parse capture agent properties");
1009       }
1010     }
1011 
1012     Map<String, String> wfProperties = new HashMap<>();
1013     if (StringUtils.isNotBlank(workflowProperties)) {
1014       try {
1015         Properties prop = parseProperties(workflowProperties);
1016         wfProperties.putAll((Map) prop);
1017       } catch (IOException e) {
1018         logger.info("Could not parse workflow configuration properties: {}", workflowProperties);
1019         return RestUtil.R.badRequest("Could not parse workflow configuration properties");
1020       }
1021     }
1022     Set<String> userIds = new HashSet<>();
1023     String[] ids = StringUtils.split(users, ",");
1024     if (ids != null) {
1025       userIds.addAll(Arrays.asList(ids));
1026     }
1027 
1028     // ical4j expects start and end dates to be in TimeZone to be schedule to (not UTC)
1029     DateTime startDate = new DateTime(startTime).toDateTime(DateTimeZone.forTimeZone(tz));
1030     DateTime endDate = new DateTime(endTime).toDateTime(DateTimeZone.forTimeZone(tz));
1031 
1032     try {
1033       service.addMultipleEvents(rrule, startDate.toDate(), endDate.toDate(), duration, tz, agentId, userIds, templateMp,
1034               wfProperties, caProperties, Optional.ofNullable(schedulingSource));
1035       return Response.status(Status.CREATED).build();
1036     } catch (UnauthorizedException e) {
1037       throw e;
1038     } catch (SchedulerConflictException e) {
1039       return Response.status(Status.CONFLICT).entity(generateErrorResponse(e)).type(MediaType.APPLICATION_JSON).build();
1040     } catch (Exception e) {
1041       logger.error("Unable to create new events", e);
1042       return Response.serverError().build();
1043     }
1044   }
1045 
1046   @PUT
1047   @Path("{id}")
1048   @RestQuery(name = "updaterecordings",
1049       description = "Updates specified event",
1050       returnDescription = "Status OK is returned if event was successfully updated, NOT FOUND if specified event does "
1051           + "not exist or BAD REQUEST if data is missing or invalid",
1052       pathParameters = {
1053           @RestParameter(name = "id", description = "ID of event to be updated", isRequired = true, type = Type.STRING)
1054       },
1055       restParameters = {
1056           @RestParameter(name = "start", isRequired = false, type = Type.INTEGER,
1057               description = "Updated start date for event"),
1058           @RestParameter(name = "end", isRequired = false, type = Type.INTEGER,
1059               description = "Updated end date for event"),
1060           @RestParameter(name = "agent", isRequired = false, type = Type.STRING,
1061               description = "Updated agent for event"),
1062           @RestParameter(name = "users", isRequired = false, type = Type.STRING,
1063               description = "Updated comma separated list of user ids (speakers/lecturers) for the event"),
1064           @RestParameter(name = "mediaPackage", isRequired = false, type = Type.TEXT,
1065               description = "Updated media package for event"),
1066           @RestParameter(name = "wfproperties", isRequired = false, type = Type.TEXT,
1067               description = "Workflow configuration properties"),
1068           @RestParameter(name = "agentparameters", isRequired = false, type = Type.TEXT,
1069               description = "Updated Capture Agent properties")
1070       },
1071       responses = {
1072           @RestResponse(responseCode = HttpServletResponse.SC_OK,
1073               description = "Event was successfully updated"),
1074           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND,
1075               description = "Event with specified ID does not exist"),
1076           @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT,
1077               description = "Unable to update event, conflicting events found (ConflicsFound)"),
1078           @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT,
1079               description = "Unable to update event, event locked by a transaction (TransactionLock)"),
1080           @RestResponse(responseCode = HttpServletResponse.SC_FORBIDDEN,
1081               description = "Event with specified ID cannot be updated"),
1082           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED,
1083               description = "You do not have permission to update the event. Maybe you need to authenticate."),
1084           @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST,
1085               description = "Data is missing or invalid")
1086       }
1087   )
1088   public Response updateEvent(@PathParam("id") String eventID, @FormParam("start") Long startTime,
1089           @FormParam("end") Long endTime, @FormParam("agent") String agentId, @FormParam("users") String users,
1090           @FormParam("mediaPackage") String mediaPackageXml, @FormParam("wfproperties") String workflowProperties,
1091           @FormParam("agentparameters") String agentParameters) throws UnauthorizedException {
1092     if (startTime != null) {
1093       if (startTime < 0) {
1094         logger.debug("Cannot add event with negative start time ({} < 0)", startTime);
1095         return RestUtil.R.badRequest("Cannot add event with negative start time");
1096       }
1097       if (endTime != null && endTime <= startTime) {
1098         logger.debug("Cannot add event without proper end time ({} <= {})", startTime, endTime);
1099         return RestUtil.R.badRequest("Cannot add event without proper end time");
1100       }
1101     }
1102 
1103     MediaPackage mediaPackage = null;
1104     if (StringUtils.isNotBlank(mediaPackageXml)) {
1105       try {
1106         mediaPackage = MediaPackageParser.getFromXml(mediaPackageXml);
1107       } catch (Exception e) {
1108         logger.debug("Could not parse media packagey", e);
1109         return Response.status(Status.BAD_REQUEST).build();
1110       }
1111     }
1112 
1113     Map<String, String> caProperties = null;
1114     if (StringUtils.isNotBlank(agentParameters)) {
1115       try {
1116         Properties prop = parseProperties(agentParameters);
1117         caProperties = new HashMap<>();
1118         caProperties.putAll((Map) prop);
1119       } catch (Exception e) {
1120         logger.debug("Could not parse capture agent properties: {}", agentParameters, e);
1121         return Response.status(Status.BAD_REQUEST).build();
1122       }
1123     }
1124 
1125     Map<String, String> wfProperties = null;
1126     if (StringUtils.isNotBlank(workflowProperties)) {
1127       try {
1128         Properties prop = parseProperties(workflowProperties);
1129         wfProperties = new HashMap<>();
1130         wfProperties.putAll((Map) prop);
1131       } catch (IOException e) {
1132         logger.debug("Could not parse workflow configuration properties: {}", workflowProperties, e);
1133         return Response.status(Status.BAD_REQUEST).build();
1134       }
1135     }
1136 
1137     Set<String> userIds = null;
1138     String[] ids = StringUtils.split(StringUtils.trimToNull(users), ",");
1139     if (ids != null) {
1140       userIds = new HashSet<>(Arrays.asList(ids));
1141     }
1142 
1143     Date startDate = null;
1144     if (startTime != null) {
1145       startDate = new DateTime(startTime).toDateTime(DateTimeZone.UTC).toDate();
1146     }
1147 
1148     Date endDate = null;
1149     if (endTime != null) {
1150       endDate = new DateTime(endTime).toDateTime(DateTimeZone.UTC).toDate();
1151     }
1152 
1153     try {
1154       service.updateEvent(eventID, Optional.ofNullable(startDate), Optional.ofNullable(endDate),
1155               Optional.ofNullable(StringUtils.trimToNull(agentId)), Optional.ofNullable(userIds),
1156               Optional.ofNullable(mediaPackage), Optional.ofNullable(wfProperties), Optional.ofNullable(caProperties));
1157       return Response.ok().build();
1158     } catch (SchedulerConflictException e) {
1159       return Response.status(Status.CONFLICT).entity(generateErrorResponse(e)).type(MediaType.APPLICATION_JSON).build();
1160     } catch (SchedulerException e) {
1161       logger.warn("Error updating event with id '{}'", eventID, e);
1162       return Response.status(Status.FORBIDDEN).build();
1163     } catch (NotFoundException e) {
1164       logger.info("Event with id '{}' does not exist.", eventID);
1165       return Response.status(Status.NOT_FOUND).build();
1166     } catch (UnauthorizedException e) {
1167       throw e;
1168     } catch (Exception e) {
1169       logger.error("Unable to update event with id '{}'", eventID, e);
1170       return Response.serverError().build();
1171     }
1172   }
1173 
1174   @GET
1175   @Path("currentRecording/{agent}")
1176   @Produces(MediaType.TEXT_XML)
1177   @RestQuery(name = "currentrecording",
1178       description = "Get the current capture event as XML",
1179       returnDescription = "The current capture event as XML",
1180       pathParameters = {
1181           @RestParameter(name = "agent", isRequired = true, type = Type.STRING, description = "The agent identifier")
1182       },
1183       responses = {
1184           @RestResponse(responseCode = HttpServletResponse.SC_OK,
1185               description = "current event is in the body of response"),
1186           @RestResponse(responseCode = HttpServletResponse.SC_NO_CONTENT, description = "There is no current recording")
1187       }
1188   )
1189   public Response currentRecording(@PathParam("agent") String agentId) throws UnauthorizedException {
1190     try {
1191       Optional<MediaPackage> current = service.getCurrentRecording(agentId);
1192       if (current.isEmpty()) {
1193         return Response.noContent().build();
1194       } else {
1195         return Response.ok(MediaPackageParser.getAsXml(current.get())).build();
1196       }
1197     } catch (UnauthorizedException e) {
1198       throw e;
1199     } catch (Exception e) {
1200       logger.error("Unable to get the current recording for agent '{}'", agentId, e);
1201       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1202     }
1203   }
1204 
1205   @GET
1206   @Path("upcomingRecording/{agent}")
1207   @Produces(MediaType.TEXT_XML)
1208   @RestQuery(name = "upcomingrecording",
1209       description = "Get the upcoming capture event as XML",
1210       returnDescription = "The upcoming capture event as XML",
1211       pathParameters = {
1212           @RestParameter(name = "agent", isRequired = true, type = Type.STRING, description = "The agent identifier")
1213       },
1214       responses = {
1215           @RestResponse(responseCode = HttpServletResponse.SC_OK,
1216               description = "upcoming event is in the body of response"),
1217           @RestResponse(responseCode = HttpServletResponse.SC_NO_CONTENT,
1218               description = "There is no upcoming recording")
1219       }
1220   )
1221   public Response upcomingRecording(@PathParam("agent") String agentId) throws UnauthorizedException {
1222     try {
1223       Optional<MediaPackage> upcoming = service.getUpcomingRecording(agentId);
1224       if (upcoming.isEmpty()) {
1225         return Response.noContent().build();
1226       } else {
1227         return Response.ok(MediaPackageParser.getAsXml(upcoming.get())).build();
1228       }
1229     } catch (UnauthorizedException e) {
1230       throw e;
1231     } catch (Exception e) {
1232       logger.error("Unable to get the upcoming recording for agent '{}'", agentId, e);
1233       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1234     }
1235   }
1236 
1237   @GET
1238   @Path("eventCount")
1239   @Produces(MediaType.TEXT_PLAIN)
1240   @RestQuery(name = "eventcount",
1241       description = "Get the number of scheduled events",
1242       returnDescription = "The number of scheduled events",
1243       responses = {
1244           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "The event count")
1245       }
1246   )
1247   public Response eventCount() throws UnauthorizedException {
1248     try {
1249       return Response.ok("" + service.getEventCount()).build();
1250     } catch (UnauthorizedException e) {
1251       throw e;
1252     } catch (Exception e) {
1253       logger.error("Unable to get the event count", e);
1254       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1255     }
1256   }
1257 
1258   @GET
1259   @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON })
1260   @Path("recordings.{type:xml|json}")
1261   @RestQuery(name = "recordingsaslist",
1262       description = "Searches recordings and returns result as XML or JSON",
1263       returnDescription = "XML or JSON formated results",
1264        pathParameters = {
1265           @RestParameter(name = "type", isRequired = true, description = "The media type of the response [xml|json]",
1266               type = Type.STRING)
1267        },
1268        restParameters = {
1269           @RestParameter(name = "agent", description = "Search by device", isRequired = false, type = Type.STRING),
1270           @RestParameter(name = "startsfrom", description = "Search by when does event start", isRequired = false,
1271               type = Type.INTEGER),
1272           @RestParameter(name = "startsto", description = "Search by when does event start", isRequired = false,
1273               type = Type.INTEGER),
1274           @RestParameter(name = "endsfrom", description = "Search by when does event finish", isRequired = false,
1275               type = Type.INTEGER),
1276           @RestParameter(name = "endsto", description = "Search by when does event finish", isRequired = false,
1277               type = Type.INTEGER)
1278        },
1279        responses = {
1280           @RestResponse(responseCode = HttpServletResponse.SC_OK,
1281               description = "Search completed, results returned in body")
1282       }
1283   )
1284   public Response getEventsAsList(@PathParam("type") final String type, @QueryParam("agent") String device,
1285           @QueryParam("startsfrom") Long startsFromTime,
1286           @QueryParam("startsto") Long startsToTime, @QueryParam("endsfrom") Long endsFromTime,
1287           @QueryParam("endsto") Long endsToTime) throws UnauthorizedException {
1288     Date startsfrom = null;
1289     Date startsTo = null;
1290     Date endsFrom = null;
1291     Date endsTo = null;
1292     if (startsFromTime != null) {
1293       startsfrom = new DateTime(startsFromTime).toDateTime(DateTimeZone.UTC).toDate();
1294     }
1295     if (startsToTime != null) {
1296       startsTo = new DateTime(startsToTime).toDateTime(DateTimeZone.UTC).toDate();
1297     }
1298     if (endsFromTime != null) {
1299       endsFrom = new DateTime(endsFromTime).toDateTime(DateTimeZone.UTC).toDate();
1300     }
1301     if (endsToTime != null) {
1302       endsTo = new DateTime(endsToTime).toDateTime(DateTimeZone.UTC).toDate();
1303     }
1304 
1305     try {
1306       List<MediaPackage> events = service.search(Optional.ofNullable(StringUtils.trimToNull(device)),
1307               Optional.ofNullable(startsfrom), Optional.ofNullable(startsTo), Optional.ofNullable(endsFrom),
1308               Optional.ofNullable(endsTo));
1309       if ("json".equalsIgnoreCase(type)) {
1310         return Response.ok(getEventListAsJsonString(events)).build();
1311       } else {
1312         return Response.ok(MediaPackageParser.getArrayAsXml(events)).build();
1313       }
1314     } catch (UnauthorizedException e) {
1315       throw e;
1316     } catch (Exception e) {
1317       logger.error("Unable to perform search: {}", getMessage(e));
1318       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1319     }
1320   }
1321   @GET
1322   @Produces(MediaType.APPLICATION_JSON)
1323   @Path("conflicts.json")
1324   @RestQuery(name = "conflictingrecordingsasjson",
1325       description = "Searches for conflicting recordings based on parameters",
1326       returnDescription = "Returns NO CONTENT if no recordings are in conflict within specified period or list of "
1327           + "conflicting recordings in JSON",
1328       restParameters = {
1329           @RestParameter(name = "agent", description = "Device identifier for which conflicts will be searched",
1330               isRequired = true, type = Type.STRING),
1331           @RestParameter(name = "start", description = "Start time of conflicting period, in milliseconds",
1332               isRequired = true, type = Type.INTEGER),
1333           @RestParameter(name = "end", description = "End time of conflicting period, in milliseconds",
1334               isRequired = true, type = Type.INTEGER),
1335           @RestParameter(name = "rrule", description = "Rule for recurrent conflicting, "
1336               + "specified as: \"FREQ=WEEKLY;BYDAY=day(s);BYHOUR=hour;BYMINUTE=minute\". FREQ is required. "
1337               + "BYDAY may include one or more (separated by commas) of the following: SU,MO,TU,WE,TH,FR,SA.",
1338               isRequired = false, type = Type.STRING),
1339           @RestParameter(name = "duration", description = "If recurrence rule is specified duration of each "
1340               + "conflicting period, in milliseconds", isRequired = false, type = Type.INTEGER),
1341           @RestParameter(name = "timezone", description = "The timezone of the capture device",
1342               isRequired = false, type = Type.STRING)
1343       },
1344       responses = {
1345           @RestResponse(responseCode = HttpServletResponse.SC_NO_CONTENT, description = "No conflicting events found"),
1346           @RestResponse(responseCode = HttpServletResponse.SC_OK,
1347               description = "Found conflicting events, returned in body of response"),
1348           @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST,
1349               description = "Missing or invalid parameters"),
1350           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED,
1351               description = "Not authorized to make this request"),
1352           @RestResponse(responseCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
1353               description = "A detailed stack track of the internal issue.")
1354       }
1355   )
1356   public Response getConflictingEventsJson(@QueryParam("agent") String device, @QueryParam("rrule") String rrule,
1357           @QueryParam("start") Long startDate, @QueryParam("end") Long endDate, @QueryParam("duration") Long duration,
1358           @QueryParam("timezone") String timezone) throws UnauthorizedException {
1359     try {
1360       List<MediaPackage> events = getConflictingEvents(device, rrule, startDate, endDate, duration, timezone);
1361       if (!events.isEmpty()) {
1362         String eventsJsonString = getEventListAsJsonString(events);
1363         return Response.ok(eventsJsonString).build();
1364       } else {
1365         return Response.noContent().build();
1366       }
1367     } catch (IllegalArgumentException e) {
1368       return Response.status(Status.BAD_REQUEST).build();
1369     } catch (UnauthorizedException e) {
1370       throw e;
1371     } catch (Exception e) {
1372       logger.error("Unable to find conflicting events for {}, {}, {}, {}, {}:",
1373               device, rrule, startDate, endDate, duration, e);
1374       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1375     }
1376   }
1377 
1378   @GET
1379   @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON })
1380   @Path("conflicts.{type:xml|json}")
1381   @RestQuery(name = "conflictingrecordings",
1382       description = "Searches for conflicting recordings based on parameters and returns result as XML or JSON",
1383       returnDescription = "Returns NO CONTENT if no recordings are in conflict within specified period or list of "
1384           + "conflicting recordings in XML or JSON",
1385         pathParameters = {
1386            @RestParameter(name = "type", isRequired = true, description = "The media type of the response [xml|json]",
1387                type = Type.STRING)
1388         },
1389         restParameters = {
1390            @RestParameter(name = "agent", description = "Device identifier for which conflicts will be searched",
1391                isRequired = true, type = Type.STRING),
1392            @RestParameter(name = "start", description = "Start time of conflicting period, in milliseconds",
1393                isRequired = true, type = Type.INTEGER),
1394            @RestParameter(name = "end", description = "End time of conflicting period, in milliseconds",
1395                isRequired = true, type = Type.INTEGER),
1396            @RestParameter(name = "rrule", description = "Rule for recurrent conflicting, "
1397                + "specified as: \"FREQ=WEEKLY;BYDAY=day(s);BYHOUR=hour;BYMINUTE=minute\". FREQ is required. "
1398                + "BYDAY may include one or more (separated by commas) of the following: SU,MO,TU,WE,TH,FR,SA.",
1399                isRequired = false, type = Type.STRING),
1400            @RestParameter(name = "duration", description = "If recurrence rule is specified duration of each "
1401                + "conflicting period, in milliseconds", isRequired = false, type = Type.INTEGER),
1402            @RestParameter(name = "timezone", description = "The timezone of the capture device",
1403                isRequired = false, type = Type.STRING)
1404         },
1405         responses = {
1406            @RestResponse(responseCode = HttpServletResponse.SC_NO_CONTENT, description = "No conflicting events found"),
1407            @RestResponse(responseCode = HttpServletResponse.SC_OK,
1408                description = "Found conflicting events, returned in body of response"),
1409            @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST,
1410                description = "Missing or invalid parameters"),
1411            @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED,
1412                description = "Not authorized to make this request"),
1413            @RestResponse(responseCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
1414                description = "A detailed stack track of the internal issue.")
1415         }
1416   )
1417   public Response getConflicts(@PathParam("type") final String type, @QueryParam("agent") String device,
1418           @QueryParam("rrule") String rrule, @QueryParam("start") Long startDate, @QueryParam("end") Long endDate,
1419           @QueryParam("duration") Long duration, @QueryParam("timezone") String timezone) throws UnauthorizedException {
1420     // Pass dates in the TZ to be schedule to (not UTC)
1421     // If no timezone passed, use the local timezone of the system
1422     if (StringUtils.isBlank(timezone)) {
1423       timezone = DateTimeZone.getDefault().toString();
1424     }
1425 
1426     try {
1427       List<MediaPackage> events = getConflictingEvents(device, rrule, startDate, endDate, duration, timezone);
1428       if (!events.isEmpty()) {
1429         if ("json".equalsIgnoreCase(type)) {
1430           return Response.ok(getEventListAsJsonString(events)).build();
1431         } else {
1432           return Response.ok(MediaPackageParser.getArrayAsXml(events)).build();
1433         }
1434       } else {
1435         return Response.noContent().build();
1436       }
1437     } catch (IllegalArgumentException e) {
1438       return Response.status(Status.BAD_REQUEST).build();
1439     } catch (UnauthorizedException e) {
1440       throw e;
1441     } catch (Exception e) {
1442       logger.error("Unable to find conflicting events for {}, {}, {}, {}, {}",
1443               device, rrule, startDate, endDate, duration, e);
1444       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1445     }
1446   }
1447 
1448   @PUT
1449   @Path("{id}/recordingStatus")
1450   @RestQuery(name = "updateRecordingState",
1451       description = "Set the status of a given recording, registering it if it is new",
1452       pathParameters = {
1453           @RestParameter(description = "The ID of a given recording", isRequired = true, name = "id",
1454               type = Type.STRING)
1455       },
1456       restParameters = {
1457           @RestParameter(description = "The state of the recording. Must be one of the following: unknown, capturing, "
1458               + "capture_finished, capture_error, manifest, manifest_error, manifest_finished, compressing, "
1459               + "compressing_error, uploading, upload_finished, upload_error.",
1460               isRequired = true, name = "state", type = Type.STRING)
1461       },
1462       responses = {
1463           @RestResponse(description = "{id} set to {state}", responseCode = HttpServletResponse.SC_OK),
1464           @RestResponse(description = "{id} or state {state} is empty or the {state} is not known",
1465               responseCode = HttpServletResponse.SC_BAD_REQUEST),
1466           @RestResponse(description = "Recording with {id} could not be found",
1467               responseCode = HttpServletResponse.SC_NOT_FOUND)
1468       },
1469       returnDescription = ""
1470   )
1471   public Response updateRecordingState(@PathParam("id") String id, @FormParam("state") String state)
1472           throws NotFoundException {
1473     if (StringUtils.isEmpty(id) || StringUtils.isEmpty(state)) {
1474       return Response.serverError().status(Response.Status.BAD_REQUEST).build();
1475     }
1476 
1477     try {
1478       if (service.updateRecordingState(id, state)) {
1479         return Response.ok(id + " set to " + state).build();
1480       } else {
1481         return Response.status(Response.Status.BAD_REQUEST).build();
1482       }
1483     } catch (SchedulerException e) {
1484       logger.debug("Unable to set recording state of {}:", id, e);
1485       return Response.serverError().build();
1486     }
1487   }
1488 
1489   @GET
1490   @Produces(MediaType.APPLICATION_JSON)
1491   @Path("{id}/recordingStatus")
1492   @RestQuery(name = "getRecordingState",
1493       description = "Return the state of a given recording",
1494       pathParameters = {
1495           @RestParameter(description = "The ID of a given recording", isRequired = true, name = "id",
1496               type = Type.STRING)
1497       },
1498       restParameters = {},
1499       responses = {
1500           @RestResponse(description = "Returns the state of the recording with the correct id",
1501               responseCode = HttpServletResponse.SC_OK),
1502           @RestResponse(description = "The recording with the specified ID does not exist",
1503               responseCode = HttpServletResponse.SC_NOT_FOUND)
1504       },
1505       returnDescription = ""
1506   )
1507   public Response getRecordingState(@PathParam("id") String id) throws NotFoundException {
1508     try {
1509       Recording rec = service.getRecordingState(id);
1510       return RestUtil.R
1511               .ok(obj(p("id", rec.getID()), p("state", rec.getState()), p("lastHeardFrom", rec.getLastCheckinTime())));
1512     } catch (SchedulerException e) {
1513       logger.debug("Unable to get recording state of {}:", id, e);
1514       return Response.serverError().build();
1515     }
1516   }
1517 
1518   @DELETE
1519   @Path("{id}/recordingStatus")
1520   @RestQuery(name = "removeRecording",
1521       description = "Remove record of a given recording",
1522       pathParameters = {
1523           @RestParameter(description = "The ID of a given recording", isRequired = true, name = "id",
1524               type = Type.STRING)
1525       },
1526       restParameters = {},
1527       responses = {
1528           @RestResponse(description = "{id} removed", responseCode = HttpServletResponse.SC_OK),
1529           @RestResponse(description = "{id} is empty", responseCode = HttpServletResponse.SC_BAD_REQUEST),
1530           @RestResponse(description = "Recording with {id} could not be found",
1531               responseCode = HttpServletResponse.SC_NOT_FOUND)
1532       },
1533       returnDescription = ""
1534   )
1535   public Response removeRecording(@PathParam("id") String id) throws NotFoundException {
1536     if (StringUtils.isEmpty(id)) {
1537       return Response.serverError().status(Response.Status.BAD_REQUEST).build();
1538     }
1539 
1540     try {
1541       service.removeRecording(id);
1542       return Response.ok(id + " removed").build();
1543     } catch (SchedulerException e) {
1544       logger.debug("Unable to remove recording with id '{}':", id, e);
1545       return Response.serverError().build();
1546     }
1547   }
1548 
1549   @GET
1550   @Produces(MediaType.APPLICATION_JSON)
1551   @Path("recordingStatus")
1552   @RestQuery(name = "getAllRecordings",
1553       description = "Return all registered recordings and their state",
1554       pathParameters = {},
1555       restParameters = {},
1556       responses = {
1557           @RestResponse(description = "Returns all known recordings.", responseCode = HttpServletResponse.SC_OK)
1558       },
1559       returnDescription = ""
1560   )
1561   public Response getAllRecordings() {
1562     try {
1563       List<Val> update = new ArrayList<>();
1564       for (Entry<String, Recording> e : service.getKnownRecordings().entrySet()) {
1565         update.add(obj(p("id", e.getValue().getID()), p("state", e.getValue().getState()),
1566                 p("lastHeardFrom", e.getValue().getLastCheckinTime())));
1567       }
1568       return RestUtil.R.ok(arr(update).toJson());
1569     } catch (SchedulerException e) {
1570       logger.debug("Unable to get all recordings:", e);
1571       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1572     }
1573   }
1574 
1575   /**
1576    *
1577    *
1578    *
1579    * Prolonging service
1580    *
1581    *
1582    *
1583    *
1584    */
1585 
1586   @GET
1587   @Path("capture/{agent}")
1588   @Produces(MediaType.APPLICATION_JSON)
1589   @RestQuery(name = "currentcapture",
1590       description = "Get the current capture event catalog as JSON",
1591       returnDescription = "The current capture event catalog as JSON",
1592       pathParameters = {
1593           @RestParameter(name = "agent", isRequired = true, type = Type.STRING, description = "The agent identifier")
1594       },
1595       responses = {
1596           @RestResponse(responseCode = HttpServletResponse.SC_OK,
1597               description = "DublinCore of current capture event is in the body of response"),
1598           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "There is no ongoing recording"),
1599           @RestResponse(responseCode = HttpServletResponse.SC_SERVICE_UNAVAILABLE,
1600               description = "The agent is not ready to communicate")
1601       }
1602   )
1603   public Response currentCapture(@PathParam("agent") String agentId) throws NotFoundException {
1604     if (service == null || agentService == null) {
1605       return Response.serverError().status(Response.Status.SERVICE_UNAVAILABLE)
1606           .entity("Scheduler service is unavailable, please wait...").build();
1607     }
1608 
1609     try {
1610       Optional<MediaPackage> current = service.getCurrentRecording(agentId);
1611       if (current.isEmpty()) {
1612         logger.info("No recording to stop found for agent '{}'!", agentId);
1613         throw new NotFoundException("No recording to stop found for agent: " + agentId);
1614       } else {
1615         DublinCoreCatalog catalog = DublinCoreUtil.loadEpisodeDublinCore(workspace, current.get()).get();
1616         return Response.ok(catalog.toJson()).build();
1617       }
1618     } catch (NotFoundException e) {
1619       throw e;
1620     } catch (Exception e) {
1621       logger.error("Unable to get the immediate recording for agent '{}'", agentId, e);
1622       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1623     }
1624   }
1625 
1626   @GET
1627   @Path("capture/{agent}/upcoming")
1628   @Produces(MediaType.APPLICATION_JSON)
1629   @RestQuery(name = "upcomingcapture",
1630       description = "Get the upcoming capture event catalog as JSON",
1631       returnDescription = "The upcoming capture event catalog as JSON",
1632       pathParameters = {
1633           @RestParameter(name = "agent", isRequired = true, type = Type.STRING, description = "The agent identifier")
1634       },
1635       responses = {
1636           @RestResponse(responseCode = HttpServletResponse.SC_OK,
1637               description = "DublinCore of the upcomfing capture event is in the body of response"),
1638           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND,
1639               description = "There is no upcoming recording"),
1640           @RestResponse(responseCode = HttpServletResponse.SC_SERVICE_UNAVAILABLE,
1641               description = "The agent is not ready to communicate")
1642       }
1643   )
1644   public Response upcomingCapture(@PathParam("agent") String agentId) throws NotFoundException {
1645     if (service == null || agentService == null) {
1646       return Response.serverError().status(Response.Status.SERVICE_UNAVAILABLE)
1647           .entity("Scheduler service is unavailable, please wait...").build();
1648     }
1649 
1650     try {
1651       Optional<MediaPackage> upcoming = service.getUpcomingRecording(agentId);
1652       if (upcoming.isEmpty()) {
1653         logger.info("No recording to stop found for agent '{}'!", agentId);
1654         throw new NotFoundException("No recording to stop found for agent: " + agentId);
1655       } else {
1656         DublinCoreCatalog catalog = DublinCoreUtil.loadEpisodeDublinCore(workspace, upcoming.get()).get();
1657         return Response.ok(catalog.toJson()).build();
1658       }
1659     } catch (NotFoundException e) {
1660       throw e;
1661     } catch (Exception e) {
1662       logger.error("Unable to get the immediate recording for agent '{}'", agentId, e);
1663       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1664     }
1665   }
1666 
1667   @POST
1668   @Path("capture/{agent}")
1669   @RestQuery(name = "startcapture",
1670       description = "Create an immediate event",
1671       returnDescription = "If events were successfully generated, status CREATED is returned",
1672       pathParameters = {
1673           @RestParameter(name = "agent", isRequired = true, type = Type.STRING, description = "The agent identifier")
1674       },
1675       restParameters = {
1676           @RestParameter(name = "workflowDefinitionId", isRequired = false, type = Type.STRING,
1677               description = "The workflow definition id to use")
1678       },
1679       responses = {
1680           @RestResponse(responseCode = HttpServletResponse.SC_CREATED, description = "Recording started"),
1681           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "There is no such agent"),
1682           @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT, description = "The agent is already recording"),
1683           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "You do not have permission "
1684               + "to start this immediate capture. Maybe you need to authenticate."),
1685           @RestResponse(responseCode = HttpServletResponse.SC_SERVICE_UNAVAILABLE,
1686               description = "The agent is not ready to communicate")
1687       }
1688   )
1689   public Response startCapture(@PathParam("agent") String agentId, @FormParam("workflowDefinitionId") String wfId)
1690           throws NotFoundException, UnauthorizedException {
1691     if (service == null || agentService == null || prolongingService == null) {
1692       return Response.serverError().status(Response.Status.SERVICE_UNAVAILABLE)
1693           .entity("Scheduler service is unavailable, please wait...").build();
1694     }
1695 
1696     // Lookup the agent. If it doesn't exist, add a temporary registration
1697     boolean adHocRegistration = false;
1698     try {
1699       agentService.getAgent(agentId);
1700     } catch (NotFoundException e) {
1701       Properties adHocProperties = new Properties();
1702       adHocProperties.put(AGENT_REGISTRATION_TYPE, AGENT_REGISTRATION_TYPE_ADHOC);
1703       agentService.setAgentConfiguration(agentId, adHocProperties);
1704       agentService.setAgentState(agentId, AgentState.CAPTURING);
1705       adHocRegistration = true;
1706       logger.info("Temporarily registered agent '{}' for ad-hoc recording", agentId);
1707     }
1708 
1709     try {
1710       Date now = new Date();
1711       Date temporaryEndDate = DateTime.now().plus(prolongingService.getInitialTime()).toDate();
1712       try {
1713         List<MediaPackage> events = service.findConflictingEvents(agentId, now, temporaryEndDate);
1714         if (!events.isEmpty()) {
1715           logger.info("An already existing event is in a conflict with the the one to be created on the agent {}!",
1716                   agentId);
1717           return Response.status(Status.CONFLICT).build();
1718         }
1719       } catch (SchedulerException e) {
1720         logger.error("Unable to create immediate event on agent {}", agentId, e);
1721         throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1722       }
1723 
1724       String workflowId = defaultWorkflowDefinitionId;
1725       if (StringUtils.isNotBlank(wfId)) {
1726         workflowId = wfId;
1727       }
1728 
1729       Map<String, String> caProperties = new HashMap<>();
1730       caProperties.put("org.opencastproject.workflow.definition", workflowId);
1731       caProperties.put("event.location", agentId);
1732       caProperties.put("event.title", "Capture now event");
1733       // caProperties.put("org.opencastproject.workflow.config.captionHold", "false");
1734       // caProperties.put("org.opencastproject.workflow.config.archiveOp", "true");
1735       // caProperties.put("org.opencastproject.workflow.config.trimHold", "false");
1736 
1737       // TODO default metadata? configurable?
1738       // A temporal with start and end period is needed! As well PROPERTY_SPATIAL is needed
1739       DublinCoreCatalog eventCatalog = DublinCores.mkOpencastEpisode().getCatalog();
1740       eventCatalog.set(PROPERTY_TITLE, "Capture now event");
1741       eventCatalog.set(PROPERTY_TEMPORAL,
1742               EncodingSchemeUtils.encodePeriod(new DCMIPeriod(now, temporaryEndDate), Precision.Second));
1743       eventCatalog.set(PROPERTY_SPATIAL, agentId);
1744       eventCatalog.set(PROPERTY_CREATED, EncodingSchemeUtils.encodeDate(new Date(), Precision.Minute));
1745       // eventCatalog.set(PROPERTY_CREATOR, "demo");
1746       // eventCatalog.set(PROPERTY_SUBJECT, "demo");
1747       // eventCatalog.set(PROPERTY_LANGUAGE, "demo");
1748       // eventCatalog.set(PROPERTY_CONTRIBUTOR, "demo");
1749       // eventCatalog.set(PROPERTY_DESCRIPTION, "demo");
1750 
1751       // TODO workflow properties
1752       Map<String, String> wfProperties = new HashMap<>();
1753 
1754       MediaPackage mediaPackage = null;
1755       try {
1756         mediaPackage = MediaPackageBuilderFactory.newInstance().newMediaPackageBuilder().createNew();
1757         mediaPackage = addCatalog(workspace, IOUtils.toInputStream(eventCatalog.toXmlString(), "UTF-8"),
1758                 "dublincore.xml", MediaPackageElements.EPISODE, mediaPackage);
1759 
1760         prolongingService.schedule(agentId);
1761         service.addEvent(now, temporaryEndDate, agentId, Collections.<String> emptySet(), mediaPackage, wfProperties,
1762                 caProperties, Optional.empty());
1763         return Response.status(Status.CREATED)
1764                 .header("Location", serverUrl + serviceUrl + '/' + mediaPackage.getIdentifier().toString() + ".xml")
1765                 .build();
1766       } catch (Exception e) {
1767         prolongingService.stop(agentId);
1768         if (e instanceof UnauthorizedException) {
1769           throw (UnauthorizedException) e;
1770         }
1771         logger.error("Unable to create immediate event on agent {}", agentId, e);
1772         throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1773       } finally {
1774         if (mediaPackage != null) {
1775           for (MediaPackageElement elem : mediaPackage.getElements()) {
1776             if (MediaPackageElements.EPISODE.matches(elem.getFlavor())) {
1777               try {
1778                 workspace.delete(elem.getURI());
1779               } catch (NotFoundException e) {
1780                 logger.warn("Unable to find (and hence, delete), this mediapackage '{}' element '{}'",
1781                     mediaPackage.getIdentifier(), elem.getIdentifier());
1782               } catch (IOException e) {
1783                 throw new RuntimeException(e);
1784               }
1785             }
1786           }
1787         }
1788       }
1789     } catch (Throwable t) {
1790       throw t;
1791     } finally {
1792       if (adHocRegistration) {
1793         agentService.removeAgent(agentId);
1794         logger.info("Removed temporary registration for agent '{}'", agentId);
1795       }
1796     }
1797   }
1798 
1799   @DELETE
1800   @Path("capture/{agent}")
1801   @Produces(MediaType.TEXT_PLAIN)
1802   @RestQuery(name = "stopcapture",
1803       description = "Stops an immediate capture.",
1804       returnDescription = "OK if event were successfully stopped",
1805       pathParameters = {
1806           @RestParameter(name = "agent", isRequired = true, description = "The agent identifier", type = Type.STRING)
1807       },
1808       responses = {
1809           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "Recording stopped"),
1810           @RestResponse(responseCode = HttpServletResponse.SC_NOT_MODIFIED,
1811               description = "The recording was already stopped"),
1812           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "There is no such agent"),
1813           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "You do not have permission "
1814               + "to stop this immediate capture. Maybe you need to authenticate."),
1815           @RestResponse(responseCode = HttpServletResponse.SC_SERVICE_UNAVAILABLE,
1816               description = "The agent is not ready to communicate")
1817       }
1818   )
1819   public Response stopCapture(@PathParam("agent") String agentId) throws NotFoundException, UnauthorizedException {
1820     if (service == null || agentService == null || prolongingService == null) {
1821       return Response.serverError().status(Response.Status.SERVICE_UNAVAILABLE)
1822           .entity("Scheduler service is unavailable, please wait...").build();
1823     }
1824 
1825     boolean isAdHoc = false;
1826     try {
1827       Agent agent = agentService.getAgent(agentId);
1828       String registrationType = (String) agent.getConfiguration().get(AGENT_REGISTRATION_TYPE);
1829       isAdHoc = AGENT_REGISTRATION_TYPE_ADHOC.equals(registrationType);
1830     } catch (NotFoundException e) {
1831       logger.debug("Temporarily registered agent '{}' for ad-hoc recording already removed", agentId);
1832     }
1833 
1834     try {
1835       String eventId;
1836       MediaPackage mp;
1837       DublinCoreCatalog eventCatalog;
1838       try {
1839         Optional<MediaPackage> current = service.getCurrentRecording(agentId);
1840         if (current.isEmpty()) {
1841           logger.info("No recording to stop found for agent '{}'!", agentId);
1842           return Response.notModified().build();
1843         } else {
1844           mp = current.get();
1845           eventCatalog = DublinCoreUtil.loadEpisodeDublinCore(workspace, mp).get();
1846           eventId = mp.getIdentifier().toString();
1847         }
1848       } catch (Exception e) {
1849         logger.error("Unable to get the immediate recording for agent '{}'", agentId, e);
1850         throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1851       }
1852 
1853       try {
1854         DCMIPeriod period = EncodingSchemeUtils
1855                 .decodeMandatoryPeriod(eventCatalog.getFirst(DublinCore.PROPERTY_TEMPORAL));
1856         eventCatalog.set(PROPERTY_TEMPORAL,
1857                 EncodingSchemeUtils.encodePeriod(new DCMIPeriod(period.getStart(), new Date()), Precision.Second));
1858 
1859         mp = addCatalog(workspace, IOUtils.toInputStream(eventCatalog.toXmlString(), "UTF-8"), "dublincore.xml",
1860                 MediaPackageElements.EPISODE, mp);
1861 
1862         service.updateEvent(eventId, Optional.empty(), Optional.empty(), Optional.empty(),
1863                 Optional.empty(), Optional.of(mp), Optional.empty(),
1864                 Optional.empty());
1865         prolongingService.stop(agentId);
1866         return Response.ok().build();
1867       } catch (UnauthorizedException e) {
1868         throw e;
1869       } catch (Exception e) {
1870         logger.error("Unable to update the temporal of event '{}'", eventId, e);
1871         throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1872       }
1873     } catch (Throwable t) {
1874       throw t;
1875     } finally {
1876       if (isAdHoc) {
1877         agentService.removeAgent(agentId);
1878         logger.info("Removed temporary agent registration '{}'", agentId);
1879       }
1880     }
1881   }
1882 
1883   @PUT
1884   @Path("capture/{agent}/prolong")
1885   @Produces(MediaType.TEXT_PLAIN)
1886   @RestQuery(name = "prolongcapture",
1887       description = "Prolong an immediate capture.",
1888       returnDescription = "OK if event were successfully prolonged",
1889       pathParameters = {
1890           @RestParameter(name = "agent", isRequired = true, description = "The agent identifier", type = Type.STRING)
1891       }, responses = {
1892           @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "Recording prolonged"),
1893           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND,
1894               description = "No recording found for prolonging"),
1895           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "You do not have permission "
1896               + "to prolong this immediate capture. Maybe you need to authenticate."),
1897           @RestResponse(responseCode = HttpServletResponse.SC_SERVICE_UNAVAILABLE,
1898               description = "The agent is not ready to communicate")
1899       }
1900   )
1901   public Response prolongCapture(@PathParam("agent") String agentId) throws NotFoundException, UnauthorizedException {
1902     if (service == null || agentService == null || prolongingService == null) {
1903       return Response.serverError().status(Response.Status.SERVICE_UNAVAILABLE)
1904           .entity("Scheduler service is unavailable, please wait...").build();
1905     }
1906     try {
1907       MediaPackage event = prolongingService.getCurrentRecording(agentId);
1908       DublinCoreCatalog dc = DublinCoreUtil.loadEpisodeDublinCore(workspace, event).get();
1909       prolongingService.prolongEvent(event, dc, agentId);
1910       return Response.ok().build();
1911     } catch (NotFoundException | UnauthorizedException e) {
1912       throw e;
1913     } catch (Exception e) {
1914       logger.error("Unable to prolong the immediate recording for agent '{}'", agentId, e);
1915       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1916     }
1917   }
1918 
1919   private List<MediaPackage> getConflictingEvents(String device, String rrule,
1920           Long startDate, Long endDate, Long duration, String timezone)
1921                   throws IllegalArgumentException, UnauthorizedException, SchedulerException {
1922 
1923     List<MediaPackage> events = null;
1924 
1925     if (StringUtils.isBlank(device) || startDate == null || endDate == null) {
1926       logger.info("Either agent, start date or end date were not specified");
1927       throw new IllegalArgumentException();
1928     }
1929 
1930     RRule rule = null;
1931     if (StringUtils.isNotBlank(rrule)) {
1932       if (duration == null || StringUtils.isBlank(timezone)) {
1933         logger.info("Either duration or timezone were not specified");
1934         throw new IllegalArgumentException();
1935       }
1936 
1937       try {
1938         rule = new RRule(rrule);
1939         rule.validate();
1940       } catch (Exception e) {
1941         logger.info("Unable to parse rrule {}: {}", rrule, getMessage(e));
1942         throw new IllegalArgumentException();
1943       }
1944 
1945       if (!Arrays.asList(TimeZone.getAvailableIDs()).contains(timezone)) {
1946         logger.info("Unable to parse timezone: {}", timezone);
1947         throw new IllegalArgumentException();
1948       }
1949     }
1950 
1951     Date start = new DateTime(startDate).toDateTime(DateTimeZone.UTC).toDate();
1952 
1953     Date end = new DateTime(endDate).toDateTime(DateTimeZone.UTC).toDate();
1954 
1955     if (StringUtils.isNotBlank(rrule)) {
1956       events = service.findConflictingEvents(device, rule, start, end, duration, TimeZone.getTimeZone(timezone));
1957     } else {
1958       events = service.findConflictingEvents(device, start, end);
1959     }
1960     return events;
1961   }
1962 
1963   private MediaPackage addCatalog(Workspace workspace, InputStream in, String fileName,
1964           MediaPackageElementFlavor flavor, MediaPackage mediaPackage) throws IOException {
1965     Catalog[] catalogs = mediaPackage.getCatalogs(flavor);
1966     Catalog c = null;
1967     if (catalogs.length == 1) {
1968       c = catalogs[0];
1969     }
1970 
1971     // If catalog found, create a new one
1972     if (c == null) {
1973       c = (Catalog) MediaPackageElementBuilderFactory.newInstance().newElementBuilder()
1974               .newElement(MediaPackageElement.Type.Catalog, flavor);
1975       c.generateIdentifier();
1976       logger.info("Adding catalog with flavor {} to mediapackage {}", flavor, mediaPackage);
1977       mediaPackage.add(c);
1978     }
1979 
1980     // Update comments catalog
1981     try {
1982       URI catalogUrl = workspace.put(mediaPackage.getIdentifier().toString(), c.getIdentifier(), fileName, in);
1983       c.setURI(catalogUrl);
1984       // setting the URI to a new source so the checksum will most like be invalid
1985       c.setChecksum(null);
1986     } finally {
1987       IOUtils.closeQuietly(in);
1988     }
1989     return mediaPackage;
1990   }
1991 
1992   private String serializeProperties(Map<String, String> properties) {
1993     StringBuilder wfPropertiesString = new StringBuilder();
1994     for (Map.Entry<String, String> entry : properties.entrySet()) {
1995       wfPropertiesString.append(entry.getKey() + "=" + entry.getValue() + "\n");
1996     }
1997     return wfPropertiesString.toString();
1998   }
1999 
2000   /**
2001    * Parses Properties represented as String.
2002    *
2003    * @param serializedProperties
2004    *          properties to be parsed.
2005    * @return parsed properties
2006    * @throws IOException
2007    *           if parsing fails
2008    */
2009   private Properties parseProperties(String serializedProperties) throws IOException {
2010     Properties caProperties = new Properties();
2011     logger.debug("properties: {}", serializedProperties);
2012     caProperties.load(new StringReader(serializedProperties));
2013     return caProperties;
2014   }
2015 
2016   /**
2017    * Serializes mediapackage schedule metadata into JSON array string.
2018    *
2019    * @return serialized array as json array string
2020    * @throws SchedulerException
2021    *           if parsing list into JSON format fails
2022    */
2023   public String getEventListAsJsonString(List<MediaPackage> mpList) throws SchedulerException {
2024     JSONParser parser = new JSONParser();
2025     JSONObject jsonObj = new JSONObject();
2026     JSONArray jsonArray = new JSONArray();
2027     for (MediaPackage mp: mpList) {
2028       JSONObject mpJson;
2029       try {
2030         mpJson = (JSONObject) parser.parse(MediaPackageParser.getAsJSON(mp));
2031         mpJson = (JSONObject) mpJson.get("mediapackage");
2032         jsonArray.add(mpJson);
2033       } catch (org.json.simple.parser.ParseException e) {
2034         logger.warn("Unexpected JSON parse exception for getAsJSON on mp {}", mp.getIdentifier().toString(), e);
2035         throw new SchedulerException(e);
2036       }
2037     }
2038     jsonObj.put("totalCount", String.valueOf(mpList.size()));
2039     jsonObj.put("events", jsonArray);
2040     return jsonObj.toJSONString();
2041   }
2042 }
2043