/**
* 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.composer.impl.episode;
import org.opencastproject.composer.api.EncoderException;
import org.opencastproject.composer.api.EncodingProfile;
import org.opencastproject.composer.impl.episode.XmlRpcJob.XmlRpcJobState;
import org.opencastproject.composer.impl.episode.XmlRpcJob.XmlRpcReason;
import org.opencastproject.util.ConfigurationException;
import org.opencastproject.util.PathSupport;
import org.opencastproject.util.UrlSupport;
import org.apache.xmlrpc.XmlRpcException;
import org.apache.xmlrpc.client.XmlRpcClient;
import org.apache.xmlrpc.client.XmlRpcClientConfigImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Vector;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Class used to monitor the various jobs, based on xmlrpc communication with the Telestream Episode encoder engine.
*/
public class XmlRpcEngineController implements Runnable {
/** Timeout for retry in case of a communication error */
private static final int COMM_RETRY_TIMEOUT = 30;
/** The engine to notify */
private EpisodeEncoderEngine engine = null;
/** xmlrpc hostname */
private String xmlrpcHostname = null;
/** xmlrpc port */
private int xmlrpcPort = EpisodeEncoderEngine.DEFAULT_XMLRPC_PORT;
/** True if a warning about failing communication has been issued */
private boolean commWarningIssued = false;
/** xmlrpc path on the server */
private String xmlrpcPath = null;
/** xmlrpc password on the server */
// private String xmlrpcPassword = null;
/** The running flag */
private boolean keepRunning = true;
/** The communication client */
private XmlRpcClient xmlrpcClient = null;
/** The list of jobs to watch */
private final List<XmlRpcJob> joblist = new CopyOnWriteArrayList<XmlRpcJob>();
/** The timeout */
private long timeout = EpisodeEncoderEngine.DEFAULT_MONITOR_FREQUENCY * 1000L;
/** the logging facility provided by log4j */
private static final Logger logger = LoggerFactory.getLogger(XmlRpcEngineController.class.getName());
/**
* Creates a new monitor for the given engine.
*
* @param engine
* the compression engine instance
* @param host
* the xmlrpc hostname
* @param port
* the xmlrpc port number
* @param path
* the xmlrpc path
* @param password
* the xmlrpc password
* @param timeout
* the timeout in milliseconds
*/
XmlRpcEngineController(EpisodeEncoderEngine engine, String host, int port, String path, String password, long timeout) {
this.engine = engine;
this.xmlrpcHostname = host;
this.xmlrpcPort = port;
this.xmlrpcPath = path;
// this.xmlrpcPassword = password;
this.timeout = timeout;
}
/**
* Stops the encoder controller.
*/
protected void stop() {
stop("(unkown reason)");
}
/**
* Stopts the encoder controller.
*
* @param reason
* the reason for stopping
*/
void stop(String reason) {
keepRunning = false;
// Notify listeners
for (XmlRpcJob job : joblist) {
engine.fileEncodingFailed(job.getSourceFile(), job.getEncodingProfile(), reason);
}
synchronized (joblist) { // FIXME CopyOnWriteArrayList should never be externally synchronized
joblist.clear();
}
// Disconnect from the engine
disconnect();
}
/**
* Sets the monitor frequency.
*
* @param timeout
* the timeout
*/
void setFrequency(long timeout) {
this.timeout = timeout;
}
/**
* Submits a job to the watchfolder monitor. The monitor will then notify the engine and registered listeners about
* the appearance of <code>outfile</code>, once it shows up after processing.
*
* @param track
* the track that is being encoded
* @param profiles
* the encoding profile
*/
void submitJob(File track, EncodingProfile format) throws EncoderException {
List<EpisodeSettings> settings = getSettings(track, format);
if (settings == null || settings.size() == 0)
throw new EncoderException(engine, "No settings found for profile '" + format.getIdentifier() + "' and track "
+ track);
// Prepare the metadata
Map<String, String> metadata = new Hashtable<String, String>();
// The job priority
int priority = EpisodeEncoderEngine.PRIORITY_MEDIUM;
// Submit a job for every setting in the settings group
for (EpisodeSettings setting : settings) {
StringBuffer desc = new StringBuffer();
desc.append(track);
desc.append(" to ");
desc.append(format);
desc.append(" ");
desc.append(settings);
Vector<Object> arguments = new Vector<Object>(5);
arguments.add(track.getAbsolutePath());
arguments.add(setting.getPath());
arguments.add(desc.toString());
arguments.add(priority);
arguments.add(metadata);
Object result = execute("engine.submitJob", arguments);
if (result instanceof Integer) {
int jobId = ((Integer) result).intValue();
synchronized (joblist) { // FIXME CopyOnWriteArrayList should never be externally synchronized
joblist.add(new XmlRpcJob(jobId, track, format, setting));
}
logger.trace("Submitted track " + track + " to episode engine with settings " + setting.getName());
} else {
throw new EncoderException(engine, "Unexpected reply from episode engine at " + xmlrpcHostname
+ ": expected job identifier, got " + result);
}
}
}
/**
* Connects to the engine's xmlrpc server and returns the corresponding client.
*/
private XmlRpcClient connect() throws ConfigurationException {
if (xmlrpcClient != null)
return xmlrpcClient;
try {
URL url = new URL(UrlSupport.concat("http://" + xmlrpcHostname + ":" + xmlrpcPort, xmlrpcPath));
xmlrpcClient = new XmlRpcClient();
XmlRpcClientConfigImpl config = new XmlRpcClientConfigImpl();
config.setServerURL(url);
xmlrpcClient.setConfig(config);
// TODO: Authentication is not working. Must have the engine set
// to "anonymous" password.
// xmlrpc.setBasicAuthentication("anonymous", xmlrpcPassword);
} catch (MalformedURLException e) {
throw new ConfigurationException("Error connecting to episode engine: " + e.getMessage());
}
return xmlrpcClient;
}
/**
* Reconnects to the engine's xmlrpc server and returns the corresponding client.
*/
private XmlRpcClient reconnect() throws ConfigurationException {
xmlrpcClient = null;
return connect();
}
/**
* Disconnects from the engine's xmlrpc server and sets the xmlrpc client to <code>null</code>.
*/
private void disconnect() {
xmlrpcClient = null;
}
/**
* Returns a list of settings for the specified track and profile. The settings are being looked up in episode's
* settings folder (usually <tt>/Users/Shared/Episode Engine/Settings</tt>) and inside this folder at location
* <tt>Opencast/<profile></tt>
*
* @param sourceFile
* the file to be encoded
* @param profile
* the profile name
* @return the settings that are available for this combination
* @throws EncoderException
* if loading the settings failed
*/
@SuppressWarnings("unchecked")
private List<EpisodeSettings> getSettings(File sourceFile, EncodingProfile profile) throws EncoderException {
String settingsPath = PathSupport.concat(new String[] { "Opencast", profile.getIdentifier() });
logger.trace("Looking for episode settings at " + settingsPath);
// Prepare the call
Vector<Object> arguments = new Vector<Object>(1);
arguments.add(settingsPath);
// Ask for the settings
List<EpisodeSettings> settings = null;
Object result = execute("engine.getSettingsInGroupAtPath", arguments);
if (!(result instanceof Object[]))
throw new EncoderException(engine, "Episode engine returned unknown result when asked for settings at "
+ settingsPath);
Object[] settingsGroup = (Object[]) result;
settings = new ArrayList<EpisodeSettings>(settingsGroup.length);
for (Object settingsEntry : settingsGroup) {
Map<String, Object> s = (Map<String, Object>) settingsEntry;
if (s.size() != 4)
throw new EncoderException(engine, "Episode engine returned unknown result when asked for settings at "
+ settingsPath);
EpisodeSettings es = new EpisodeSettings(s);
settings.add(es);
}
return settings;
}
/**
* Returns <code>true</code> if the engine is processing the specified track for the same media format and profile,
* using at least one setting.
*
* TODO do we need this?
*
* @param file
* the file
* @param profile
* the profile
* @return <code>true</code> if the track is still processed
*/
private boolean fileIsProcessed(File file, EncodingProfile profile) {
List<XmlRpcJob> jobs = getJobs(file, profile);
if (jobs.size() > 0)
logger.trace("File " + file + " is still being processed");
return jobs.size() > 0;
}
/**
* Returns the jobs that are currently in the system processing the given file with the specified profile.
*
* @param sourceFile
* the file to be encoded
* @param profile
* the profile
* @return the list of jobs
*/
private List<XmlRpcJob> getJobs(File sourceFile, EncodingProfile profile) {
List<XmlRpcJob> jobs = new ArrayList<XmlRpcJob>();
synchronized (joblist) { // FIXME CopyOnWriteArrayList should never be externally synchronized
for (XmlRpcJob job : joblist) {
if (job.getSourceFile().equals(sourceFile) && job.getEncodingProfile().equals(profile))
jobs.add(job);
}
}
return jobs;
}
/**
* @see java.lang.Runnable#run()
*/
@SuppressWarnings("unchecked")
public void run() {
XmlRpcJobState newState = null;
while (keepRunning) {
try {
// Go through all jobs and see if the status has changed
for (XmlRpcJob job : joblist) {
Vector<Object> arguments = new Vector<Object>(1);
arguments.add(job.getIdentifier());
Object result = execute("engine.getJobForID", arguments);
if (!(result instanceof Map))
throw new EncoderException(engine, "Episode engine returned an illegal state value");
Map<String, Object> response = (Map<String, Object>) result;
Map<String, Object> status = (Map<String, Object>) response.get("currentStatus");
if (status == null)
throw new EncoderException(engine, "Episode enginge does not return a status");
newState = XmlRpcJobState.parseResult(status);
File track = job.getSourceFile();
EncodingProfile encodingProfile = job.getEncodingProfile();
EpisodeSettings settings = job.getSettings();
if (!job.getState().equals(newState)) {
if (newState == null) {
logger.error("Lost job state for " + job);
continue;
}
else if (newState.equals(XmlRpcJobState.Created)) {
// TODO: Process state change
logger.trace("Episode job " + job + " was created");
}
else if (newState.equals(XmlRpcJobState.Queued)) {
// TODO: Process state change
logger.debug("Enqueued encoding of " + track + " to " + encodingProfile + " " + settings);
}
else if (newState.equals(XmlRpcJobState.Running)) {
// TODO: Process state change
logger.debug("Started encoding of " + track + " to " + encodingProfile + " " + settings);
}
else if (newState.equals(XmlRpcJobState.Finished)) {
// Remove job and see if track is still processed with
// another setting from the same settings group
boolean trackIsProcessed = false;
synchronized (joblist) {
joblist.remove(job);
trackIsProcessed = fileIsProcessed(track, encodingProfile);
}
// Tell engine
logger.debug("Finished encoding of " + track + " to " + encodingProfile + " " + settings);
if (!trackIsProcessed) {
engine.fileEncoded(track, encodingProfile);
}
}
else if (newState.equals(XmlRpcJobState.Stopped)) {
// Remove job and notify observers regardless of
// other settings that are applied to this track
List<XmlRpcJob> associatedJobs = null;
synchronized (joblist) { // FIXME CopyOnWriteArrayList should never be externally synchronized
joblist.remove(job);
associatedJobs = getJobs(track, encodingProfile);
joblist.removeAll(associatedJobs);
}
// Tell engine
logger.warn("Encoding of " + track + " to " + encodingProfile + " " + settings + " was stopped");
if (associatedJobs.size() > 0)
logger.warn(associatedJobs.size() + " associated jobs have been canceled");
engine.fileEncodingFailed(track, encodingProfile, "Canceled");
}
else if (newState.equals(XmlRpcJobState.Failed)) {
XmlRpcReason reason = XmlRpcReason.parseResult(result);
// Remove job and notify observers regardless of
// other settings that are applied to this track
List<XmlRpcJob> associatedJobs = null;
synchronized (joblist) { // FIXME CopyOnWriteArrayList should never be externally synchronized
joblist.remove(job);
associatedJobs = getJobs(track, encodingProfile);
joblist.removeAll(associatedJobs);
}
// Tell engine
logger.debug("Encoding of " + track + " to " + encodingProfile + " " + settings + " failed: " + reason);
if (associatedJobs.size() > 0)
logger.trace(associatedJobs.size() + " associated jobs have been canceled");
engine.fileEncodingFailed(track, encodingProfile, reason.toString());
} else {
logger.error("Episode engine discovered job with unkown state '" + newState + "'");
}
// Remember the new state
job.setState(newState);
}
// Monitor progress of running jobs
else if (job.getState().equals(XmlRpcJobState.Running)) {
int progress = ((Integer) status.get("progress")).intValue();
if (progress - job.getProgress() >= 10) {
job.setProgress((progress / 10) * 10);
engine.fileEncodingProgressed(track, encodingProfile, job.getProgress());
logger.trace("Encoding of " + track + " to " + encodingProfile + " progressed to " + job.getProgress()
+ "%");
}
}
}
} catch (Throwable t) {
// TODO: Think about what to do here.
logger.error("Episode encoder monitor encountered an exception: " + t.getMessage(), t);
}
// Sleep for a few seconds
try {
Thread.sleep(timeout);
} catch (InterruptedException e) {
// TODO: Think about what to do here.
logger.trace("Episode encoder monitor interrupted");
}
}
}
/**
* Encapsulates communication with episode engine. Since the connection can sometimes be instable, a bit of retry
* logic has been added here.
*
* @param command
* the command to execute
* @param arguments
* arguments to the command
* @return the response or <code>null</code> if the request failed
*/
private synchronized Object execute(String command, Vector<Object> arguments) throws EncoderException {
XmlRpcClient client = connect();
while (keepRunning) {
String msg = null;
try {
Object result = client.execute(command, arguments);
commWarningIssued = false;
return result;
} catch (XmlRpcException e) {
msg = "Communication error with episode engine: " + e.getMessage();
}
// Log this incident
if (!commWarningIssued) {
logger.warn(msg);
commWarningIssued = true;
}
// Take a break
try {
Thread.sleep(COMM_RETRY_TIMEOUT * 1000L);
} catch (InterruptedException e) {
}
// Get a new connection
reconnect();
}
// The controller is about to be shut down
throw new EncoderException(engine, "Error communicating with episode engine at " + xmlrpcHostname);
}
}