SchedulingUtils.java
/*
* Licensed to The Apereo Foundation under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
*
* The Apereo Foundation licenses this file to you under the Educational
* Community License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of the License
* at:
*
* http://opensource.org/licenses/ecl2.txt
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*
*/
package org.opencastproject.external.util;
import static java.time.ZoneOffset.UTC;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import org.opencastproject.capture.CaptureParameters;
import org.opencastproject.capture.admin.api.Agent;
import org.opencastproject.capture.admin.api.CaptureAgentStateService;
import org.opencastproject.elasticsearch.api.SearchIndexException;
import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
import org.opencastproject.elasticsearch.index.objects.event.Event;
import org.opencastproject.index.service.api.IndexService;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.scheduler.api.SchedulerException;
import org.opencastproject.scheduler.api.SchedulerService;
import org.opencastproject.scheduler.api.TechnicalMetadata;
import org.opencastproject.security.api.UnauthorizedException;
import org.opencastproject.util.NotFoundException;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import net.fortuna.ical4j.model.property.RRule;
import org.apache.commons.lang3.StringUtils;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.TimeZone;
public final class SchedulingUtils {
/** The logging facility */
private static final Logger logger = LoggerFactory.getLogger(SchedulingUtils.class);
private static final String JSON_KEY_AGENT_ID = "agent_id";
private static final String JSON_KEY_START_DATE = "start";
private static final String JSON_KEY_END_DATE = "end";
private static final String JSON_KEY_DURATION = "duration";
private static final String JSON_KEY_INPUTS = "inputs";
private static final String JSON_KEY_RRULE = "rrule";
private SchedulingUtils() {
}
public static class SchedulingInfo {
private Optional<Date> startDate = Optional.empty();
private Optional<Date> endDate = Optional.empty();
private Optional<Long> duration = Optional.empty();
private Optional<String> agentId = Optional.empty();
private Optional<String> inputs = Optional.empty();
private Optional<RRule> rrule = Optional.empty();
public SchedulingInfo() {
}
/**
* Copy the given SchedulingInfo object.
*
* @param other
* The SchedulingInfo object to copy.
*/
public SchedulingInfo(SchedulingInfo other) {
this.startDate = other.startDate;
this.endDate = other.endDate;
this.duration = other.duration;
this.agentId = other.agentId;
this.inputs = other.inputs;
this.rrule = other.rrule;
}
public Optional<Date> getStartDate() {
return startDate;
}
public void setStartDate(Optional<Date> startDate) {
this.startDate = startDate;
}
public Optional<Date> getEndDate() {
if (endDate.isPresent()) {
return endDate;
} else if (startDate.isPresent() && duration.isPresent()) {
return Optional.of(Date.from(startDate.get().toInstant().plusMillis(duration.get())));
} else {
return Optional.empty();
}
}
public void setEndDate(Optional<Date> endDate) {
this.endDate = endDate;
}
public Optional<Long> getDuration() {
if (duration.isPresent()) {
return duration;
} else if (startDate.isPresent() && endDate.isPresent()) {
return Optional.of(endDate.get().getTime() - startDate.get().getTime());
} else {
return Optional.empty();
}
}
public void setDuration(Optional<Long> duration) {
this.duration = duration;
}
public Optional<String> getAgentId() {
return agentId;
}
public void setAgentId(Optional<String> agentId) {
this.agentId = agentId;
}
public Optional<String> getInputs() {
return inputs;
}
public void setInputs(Optional<String> inputs) {
this.inputs = inputs;
}
public Optional<RRule> getRrule() {
return rrule;
}
public void setRrule(Optional<RRule> rrule) {
this.rrule = rrule;
}
/**
* @return A JSON representation of this SchedulingInfo object.
*/
public JsonObject toJson() {
JsonObject json = new JsonObject();
DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME;
if (startDate.isPresent()) {
json.addProperty(JSON_KEY_START_DATE, dateFormatter.format(startDate.get().toInstant().atZone(ZoneOffset.UTC)));
}
if (endDate.isPresent()) {
json.addProperty(JSON_KEY_END_DATE, dateFormatter.format(endDate.get().toInstant().atZone(ZoneOffset.UTC)));
}
if (agentId.isPresent()) {
json.addProperty(JSON_KEY_AGENT_ID, agentId.get());
}
if (inputs.isPresent()) {
JsonArray inputsArray = new JsonArray();
for (String input : inputs.get().split(",")) {
inputsArray.add(new JsonPrimitive(input.trim()));
}
json.add(JSON_KEY_INPUTS, inputsArray);
}
return json;
}
/**
* @return A JSON source representation of this SchedulingInfo as needed by the IndexService to create an event.
*/
@SuppressWarnings("unchecked")
public JSONObject toSource() {
final JSONObject source = new JSONObject();
if (rrule.isPresent()) {
source.put("type", "SCHEDULE_MULTIPLE");
} else {
source.put("type", "SCHEDULE_SINGLE");
}
final JSONObject sourceMetadata = new JSONObject();
final DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME;
if (startDate.isPresent()) {
sourceMetadata.put("start", dateFormatter.format(startDate.get().toInstant().atZone(UTC)));
}
if (endDate.isPresent()) {
sourceMetadata.put("end", dateFormatter.format(endDate.get().toInstant().atZone(UTC)));
}
if (agentId.isPresent()) {
sourceMetadata.put("device", agentId.get());
}
if (getDuration().isPresent()) {
sourceMetadata.put("duration", String.valueOf(getDuration().get()));
}
if (rrule.isPresent()) {
sourceMetadata.put("rrule", rrule.get().getValue());
}
sourceMetadata.put("inputs", inputs.orElse(""));
source.put("metadata", sourceMetadata);
return source;
}
/**
* Creates a new SchedulingInfo of this instance which uses start date, end date, and agent id form the given
* {@link TechnicalMetadata} if they are not present in this instance.
*
* @param metadata
* The {@link TechnicalMetadata} of which to use start date, end date, and agent id in case they are missing.
*
* @return The new SchedulingInfo with start date, end date, and agent id set.
*/
public SchedulingInfo merge(TechnicalMetadata metadata) {
SchedulingInfo result = new SchedulingInfo(this);
if (result.startDate.isEmpty()) {
result.startDate = Optional.of(metadata.getStartDate());
}
if (result.endDate.isEmpty()) {
result.endDate = Optional.of(metadata.getEndDate());
}
if (result.agentId.isEmpty()) {
result.agentId = Optional.of(metadata.getAgentId());
}
return result;
}
/**
* Parse the given json and create a new SchedulingInfo.
*
* @param json
* The JSONObject to parse.
*
* @return The SchedulingInfo instance represented by the given JSON.
*/
public static SchedulingInfo of(JSONObject json) {
final DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME;
final SchedulingInfo schedulingInfo = new SchedulingInfo();
final String startDate = (String) json.get(JSON_KEY_START_DATE);
final String endDate = (String) json.get(JSON_KEY_END_DATE);
final String agentId = (String) json.get(JSON_KEY_AGENT_ID);
final JSONArray inputs = (JSONArray) json.get(JSON_KEY_INPUTS);
final String rrule = (String) json.get(JSON_KEY_RRULE);
// Special handling because the original implementation required String but now we require long
final String durationString = Objects.toString(json.get(JSON_KEY_DURATION), null);
if (isNotBlank(startDate)) {
schedulingInfo.startDate = Optional.of(Date.from(Instant.from(dateFormatter.parse(startDate))));
}
if (isNotBlank(endDate)) {
schedulingInfo.endDate = Optional.of(Date.from(Instant.from(dateFormatter.parse(endDate))));
}
if (isNotBlank(agentId)) {
schedulingInfo.agentId = Optional.of(agentId);
}
if (isNotBlank(durationString)) {
try {
schedulingInfo.duration = Optional.of(Long.parseLong(durationString));
} catch (Exception e) {
throw new IllegalArgumentException("Invalid format of field 'duration'");
}
}
if (isBlank(endDate) && isBlank(durationString)) {
throw new IllegalArgumentException("Either 'end' or 'duration' must be specified");
}
if (inputs != null) {
schedulingInfo.inputs = Optional.of(String.join(",", inputs));
}
if (isNotBlank(rrule)) {
try {
RRule parsedRrule = new RRule(rrule);
parsedRrule.validate();
schedulingInfo.rrule = Optional.of(parsedRrule);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid RRule: " + rrule);
}
if (isBlank(durationString) || isBlank(startDate) || isBlank(endDate)) {
throw new IllegalArgumentException("'start', 'end' and 'duration' must be specified when 'rrule' is specified");
}
}
return schedulingInfo;
}
/**
* Get the SchedulingInfo for the given event id.
*
* @param eventId
* The id of the event to get the SchedulingInfo for.
* @param schedulerService
* The {@link SchedulerService} to query for the event id.
*
* @return The SchedulingInfo for the given event id.
*
* @throws UnauthorizedException
* If the {@link SchedulerService} cannot be queried due to missing authorization.
* @throws SchedulerException
* In case internal errors occur within the {@link SchedulerService}.
*/
public static SchedulingInfo of(String eventId, SchedulerService schedulerService)
throws UnauthorizedException, SchedulerException {
final SchedulingInfo result = new SchedulingInfo();
try {
final TechnicalMetadata technicalMetadata = schedulerService.getTechnicalMetadata(eventId);
result.startDate = Optional.of(technicalMetadata.getStartDate());
result.endDate = Optional.of(technicalMetadata.getEndDate());
result.agentId = Optional.of(technicalMetadata.getAgentId());
String inputs = technicalMetadata.getCaptureAgentConfiguration().get(CaptureParameters.CAPTURE_DEVICE_NAMES);
if (isNotBlank(inputs)) {
result.inputs = Optional.of(inputs);
}
return result;
} catch (NotFoundException e) {
return result;
}
}
}
/**
* Convert the given list of {@link MediaPackage} elements to a JSON used to tell which events are causing conflicts.
*
* @param checkedEventId
* The id of the event which was checked for conflicts. May be empty if an rrule was checked.
* @param mediaPackages
* The conflicting {@link MediaPackage}s.
* @param indexService
* The {@link IndexService} for getting the corresponding events for the conflicting {@link MediaPackage}s.
* @param elasticsearchIndex
* The index to use for getting the corresponding events for the conflicting MediaPackages.
*
* @return A List of conflicting events, represented as JSON objects.
*
* @throws SearchIndexException
* If an event cannot be found.
*/
public static List<JsonObject> convertConflictingEvents(
Optional<String> checkedEventId,
List<MediaPackage> mediaPackages,
IndexService indexService,
ElasticsearchIndex elasticsearchIndex
) throws SearchIndexException {
List<JsonObject> result = new ArrayList<>();
for (MediaPackage mediaPackage : mediaPackages) {
Optional<Event> eventOpt = indexService.getEvent(mediaPackage.getIdentifier().toString(), elasticsearchIndex);
if (eventOpt.isPresent()) {
final Event event = eventOpt.get();
if (checkedEventId.isPresent() && checkedEventId.get().equals(event.getIdentifier())) {
continue;
}
JsonObject eventJson = new JsonObject();
if (event.getTechnicalStartTime() != null)
eventJson.addProperty("start", event.getTechnicalStartTime().toString());
if (event.getTechnicalEndTime() != null)
eventJson.addProperty("end", event.getTechnicalEndTime().toString());
eventJson.addProperty("title", event.getTitle());
result.add(eventJson);
} else {
logger.warn("Index out of sync! Conflicting event catalog {} not found on event index!",
mediaPackage.getIdentifier().toString());
}
}
return result;
}
/**
* Get the conflicting events for the given SchedulingInfo.
*
* @param schedulingInfo
* The SchedulingInfo to check for conflicts.
* @param agentStateService
* The {@link CaptureAgentStateService} to use for retrieving capture agents.
* @param schedulerService
* The {@link SchedulerService} to use for conflict checking.
* @return
* A list of {@link MediaPackage} elements which cause conflicts with the given SchedulingInfo.
*
* @throws NotFoundException
* If the capture agent cannot be found.
* @throws UnauthorizedException
* If the {@link SchedulerService} cannot be queried due to missing authorization.
* @throws SchedulerException
* In case internal errors occur within the {@link SchedulerService}.
*/
public static List<MediaPackage> getConflictingEvents(
SchedulingInfo schedulingInfo,
CaptureAgentStateService agentStateService,
SchedulerService schedulerService
) throws NotFoundException, UnauthorizedException, SchedulerException {
if (schedulingInfo.getRrule().isPresent()) {
final Agent agent = agentStateService.getAgent(schedulingInfo.getAgentId().get());
String timezone = agent.getConfiguration().getProperty("capture.device.timezone");
if (StringUtils.isBlank(timezone)) {
timezone = TimeZone.getDefault().getID();
logger.warn("No 'capture.device.timezone' set on agent {}. The default server timezone {} will be used.",
schedulingInfo.getAgentId().get(), timezone);
}
return schedulerService.findConflictingEvents(
schedulingInfo.getAgentId().get(),
schedulingInfo.getRrule().get(),
schedulingInfo.getStartDate().get(),
schedulingInfo.getEndDate().get(),
schedulingInfo.getDuration().get(),
TimeZone.getTimeZone(timezone)
);
}
return schedulerService.findConflictingEvents(
schedulingInfo.getAgentId().get(),
schedulingInfo.getStartDate().get(),
schedulingInfo.getEndDate().get()
);
}
}