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