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