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