Just a Ruby Script

Revised: 2008-01-25 richard

The "podcast" command-line tool is a ruby script, so, opening it up and reviewing it will expose you to other possible implementations & customization for Podcast Producer.

#!/usr/bin/env ruby

#  
#  Copyright (c) 2006-2007 Apple Inc.  All Rights Reserved.
#
#  IMPORTANT NOTE:  This file is licensed only for use on Apple-labeled computers
#  and is subject to the terms and conditions of the Apple Software License Agreement
#  accompanying the package this file is a part of.  You may not port this file to
#  another platform without Apple's written consent.
#

require 'rexml/document'
require 'uri'
require '/usr/lib/podcastproducer/agentcommon'
require 'socket'
require 'ftools'
require 'fileutils'
require 'net/ftp'
require 'net/https'

ASLLogger.enable_console_logging(true)

ENV['PATH'] = "/usr/bin"

DEFAULT_TRANSFER_BUFFER_SIZE = 128 * 1024 # 128K
AGENT_UPLOADER_SLEEP_INTERVAL = 15 #seconds

class RemoteCommand
  def execute_get(uri_path)
    execute("GET", uri_path)
  end
 
  def execute_post(uri_path, parameters = nil)
    execute("POST", uri_path, parameters)
  end
 
  def execute(request_type, uri_path, parameters = nil)
    if (!$server_context)
      raise PcastException.new, "Server context not initialized for #{uri_path}"
    end

    full_uri = URI.parse($server_context.server_url + uri_path)

    begin      
      http = Net::HTTP.new(full_uri.host, full_uri.port)
      http.use_ssl = (full_uri.scheme == "https")      
      if (http.use_ssl && $check_ssl_cert)
        http.verify_mode = OpenSSL::SSL::VERIFY_PEER
      elsif (http.use_ssl)
        http.verify_mode = OpenSSL::SSL::VERIFY_NONE
      end
      http.open_timeout = $timeout
      http.read_timeout = $timeout

      if (request_type == "GET")
        if (full_uri.query)
          req = Net::HTTP::Get.new(full_uri.path + "?" + full_uri.query)
        else
          req = Net::HTTP::Get.new(full_uri.path)
        end
      elsif (request_type == "POST")
        req = Net::HTTP::Post.new(full_uri.path)
      else
        raise PcastException.new, "Unexpected request_type: #{request_type}"
      end
        
      req.add_field 'User-Agent', "#{SCRIPT_NAME}/0.1"
      req.add_field 'Accept', 'application/xml'

      if (parameters && request_type == "POST")
        parameters.apply_as_form_data(req)
      end

      response = http.start do |http|
        http.request(req)               # This should generate a 401
      end

      case response
      when Net::HTTPSuccess then
        results = response.body
      when Net::HTTPUnauthorized then
        auth_type = response['www-authenticate']
        if (auth_type.startsWith("Basic"))
          req.basic_auth $server_context.username, $server_context.password
        else
          digester = DigestAuthentication.new(auth_type)
          auth_header_value = digester.generate_authorization_header_value(request_type, full_uri.path, "00000001", $server_context.username, $server_context.password)
          req.add_field 'Authorization', auth_header_value
        end

        response = http.start do |http|
          http.request(req)             # This should generate the proper response
        end

        results = response.body
      else
        raise PcastException.new, "Unhandled HTTP response: #{response}"
      end
    rescue Timeout::Error => timedout
      raise PcastCmdException.new(ERR_SERVER_TIMEOUT, timedout),
      "Podcast Producer server '#{$server_context.server_url}' did not respond within #{$timeout} seconds"  
    rescue Errno::ETIMEDOUT => timedout
      raise PcastCmdException.new(ERR_SERVER_TIMEOUT, timedout),
      "Podcast Producer server '#{$server_context.server_url}' did not respond within #{$timeout} seconds"  
    rescue Errno::EHOSTDOWN => hostdown
      raise PcastCmdException.new(ERR_SERVER_UNAVAILABLE, hostdown), "Podcast Producer server '#{$server_context.server_url}' not responding"
    rescue Errno::ECONNREFUSED => refusal
      raise PcastCmdException.new(ERR_SERVER_UNAVAILABLE, refusal), "Podcast Producer server '#{$server_context.server_url}' not responding"
    rescue SocketError => error
      if (error.to_s == "getaddrinfo: nodename nor servname provided, or not known")
        raise PcastCmdException.new(ERR_SERVER_HOSTNAME, error), "Unknown host '#{$server_context.server_url}'"
      else
        ASLLogger.error error.class
        ASLLogger.error error.backtrace
        raise PcastException.new, "Unexpected SocketError: #{error}"
      end
    rescue OpenSSL::SSL::SSLError => sslerror
      if (sslerror.to_s == "certificate verify failed")
        raise PcastCmdException.new(ERR_SERVER_CERT_VALIDATION, sslerror), "Podcast Producer server '#{$server_context.server_url}' certificate did not pass validation"
      elsif (sslerror.to_s == "unknown protocol")
        raise PcastCmdException.new(ERR_SERVER_HTTPS_REQUIRED, sslerror),
        "Podcast Producer server '#{$server_context.server_url}' is not HTTPS- enabled"
      else
        ASLLogger.error sslerror.class
        ASLLogger.error sslerror.backtrace
        raise PcastException.new, "Unexpected SSLError: #{sslerror}"
      end
    rescue Exception => boom
      ASLLogger.error boom.class
      ASLLogger.error boom.backtrace
      raise PcastCmdException.new(ERR_SERVER_UNKNOWN_FAILURE, boom),
      "Remote command '#{full_uri}' failed: #{boom}"
    end

    begin
      if (!results)
        raise PcastServerException.new(ERR_SERVER_BAD_RESPONSE), "No results from server"
      end

      # Check for apache responses within the body
      if (results.index("401 Authorization Required"))
        raise PcastServerException.new(ERR_SERVER_AUTH_FAILURE), "Not authorized"
      elsif (results.index("503 Service Temporarily Unavailable"))
        raise PcastServerException.new(ERR_SERVER_UNAVAILABLE), "Server unavailable"
      end

      # validate podcast producer xml
      response_xml = REXML::Document.new(results)
      if (!response_xml)
        raise PcastServerException.new(ERR_SERVER_BAD_RESPONSE), "No XML response from server"
      end    

      if (RemoteCommand.first_xml_value_for_key(response_xml, "podcast_producer_result"))
        return response_xml
      else
        raise PcastServerException.new(ERR_SERVER_BAD_RESPONSE), "Invalid XML response from server (possibly not a Podcast Producer server)"
      end
    rescue REXML::ParseException => xml_exception
      raise PcastServerException.new(ERR_SERVER_BAD_RESPONSE), "Invalid XML response from server (possibly not a Podcast Producer server)"
    end
  end

  def RemoteCommand.first_xml_value_for_key(xml, key)
    value = RemoteCommand.xml_value_for_key(xml, key)
    if (value)
      value[0]
    else
      return nil
    end
  end

  def RemoteCommand.xml_value_for_key(xml, key)
    if (xml)
      query = xml.elements["//#{key}"]
      if (query)
        return query
      else
        ASLLogger.error("Key '#{key}' not found in XML response")
      end
    end

    return nil
  end
end

class PListOutput
  def PListOutput.output_plist(plist)
    puts %q{<?xml version="1.0" encoding="UTF-8"?>}
    puts %q{<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">}
    puts plist.to_s
  end
end

class Query < RemoteCommand
  def Query.list_cameras
    Query.new.execute_query("/cameras")
  end

  def Query.list_workflows
    # Request the user's primary language for names and descriptions of workflows
    user_defaults = OSX::NSUserDefaults.standardUserDefaults
    languages = user_defaults.objectForKey("AppleLanguages")
    default_language = languages[0]

    Query.new.execute_query("/workflows?language=#{default_language.to_s}")
  end

  def execute_query(uri_path)
    response_xml = execute_get(uri_path)
    if (response_xml)
      status = RemoteCommand.first_xml_value_for_key(response_xml, "status")
      if (status != "success")
        reason = RemoteCommand.first_xml_value_for_key(response_xml, "reason")
        raise PcastServerException.new(ERR_SERVER_AUTH_FAILURE), "#{reason}"
      else
        plist = RemoteCommand.xml_value_for_key(response_xml, "plist")
        if (plist)
          PListOutput.output_plist(plist)
        end
      end
    end
  end
end

class FileReader
  def initialize(file_path, upload)
    @file_path = file_path
    @upload = upload
  end

  def length
    File.size(@file_path)
  end

  def open
    @file_io = File.open(@file_path, "r")
  end

  def read(length)
    # We ignore the dinky 1024 read length sent to us and substitute our value @upload.upload_buffer_size
    buffer = @file_io.read(@upload.upload_buffer_size)
    if (buffer)
      @upload.update_progress(buffer.length)
    end

    return buffer
  end

  def close
    @file_io.close
  end
end

class Upload < RemoteCommand
  def Upload.set_progress(control_plist, percentage, estimated_time_left, status, save_immediate=true)
    if (status == "pending")
      control_plist.set("created_at", Time.now, save_immediate)
    end

    # We're going from pending to uploading
    if (control_plist.get("status") == "pending" && status == "uploading")
      control_plist.set("started_at", Time.now, save_immediate)
    end

    # We're going from uploading to completed or error
    if (control_plist.get("status") == "uploading" && (status == "completed" || status == "error"))
      control_plist.set("finished_at", Time.now, save_immediate)
    end

    control_plist.set("status", status, save_immediate)
    control_plist.set("percent_done", percentage.to_s, save_immediate)
    control_plist.set("estimated_time_left", estimated_time_left, save_immediate)  
  end

  def update_progress(bytes_written)
    @uploaded_size += bytes_written

    percent_done = @uploaded_size * 100 / @total_upload_size

    now = Time.now
    elapsed = now - @started_at

    time_each_percent = elapsed / percent_done    
    remaining_time = time_each_percent * (100 - percent_done)

    ASLLogger.notice "Uploaded: #{@uploaded_size}/#{@total_upload_size} bytes (#{percent_done} %)"
    ASLLogger.notice "Time elapsed: #{elapsed} and remaining: #{remaining_time} (seconds)"

    Upload.set_progress(@control_plist, percent_done, remaining_time, "uploading")
  end

  def initialize(control_plist_path)
    @control_plist_path = control_plist_path
    @started_at = nil
    @total_upload_size = 0
    @uploaded_size = 0
  end

  def upload_type
    @control_plist.get("upload_type")
  end

  def upload_uuid
    @control_plist.get("upload_uuid")
  end

  def delete_after_upload
    @control_plist.get("delete_after_upload") == "true"
  end

  def submission_uuid
    @control_plist.get("submission_uuid")
  end

  def recording_uuid
    @control_plist.get("recording_uuid")
  end

  def file_copy_path
    @control_plist.get("file_copy_path")
  end

  def https_upload_url
    @control_plist.get("https_upload_url")
  end

  def ftp_upload_url
    @control_plist.get("ftp_upload_url")
  end

  def content_paths
    @control_plist.get_array("content_paths")
  end

  def metadata_file
    @control_plist.get("cached_metadata_file")    
  end

  def original_metadata_file
    @control_plist.get("original_metadata_file")    
  end

  def upload_buffer_size
    @control_plist.get("upload_buffer_size").to_i
  end

  def paths_to_submit
    paths_to_submit = Array.new
    if (metadata_file && !metadata_file.empty?)
      paths_to_submit << metadata_file
    end
    paths_to_submit = paths_to_submit | content_paths
  end
 
  def paths_to_cleanup
    paths_to_cleanup = Array.new
    if (original_metadata_file && !original_metadata_file.empty?)
      paths_to_cleanup << original_metadata_file
    end
    paths_to_cleanup = paths_to_cleanup | content_paths
  end

  def primary_content_path
    if (content_paths)  
      content_paths[0]
    else
      nil
    end
  end

  def run
    begin
      ASLLogger.notice "Processing: #{@control_plist_path}"

      begin
        @control_plist = PList.new(@control_plist_path)
      rescue Exception => boom
        # Likely, the file is not complete yet, skip it and try again later
        ASLLogger.error "Invalid upload configuration file: #{@control_plist_path}."
        ASLLogger.error boom
        return
      end

      if (@control_plist.get("status") == "completed")
        ASLLogger.notice "Skipping completed upload."
        return
      end

      # This is the last thing written to the plist, if it's not there, it's not ready
      submission_uuid = @control_plist.get("submission_uuid")
      if (!submission_uuid || submission_uuid.empty?)
        if (@control_plist.get("upload_type") == "recording")
          ASLLogger.notice "Missing submission_uuid.  Initiating submission."
          Submission.initiate_recording_submission(@control_plist)
        else
          ASLLogger.notice "Missing submission_uuid for #{@control_plist.get("upload_type")} upload. Skipping."
          return
        end
      end

      if (!upload_and_complete_submission)
        ASLLogger.error "Failed to upload and complete #{upload_type} submission: #{upload_uuid}"
        return
      end

      # Mark the upload as completed
      Upload.set_progress(@control_plist, 100, "0", "completed")

      # If the original upload requested deletion of content files and original metadata file,
      # remove them after the upload completes.  The cached metadata file still exists
      if (delete_after_upload)
        cleanup_paths(paths_to_cleanup)
      end

      # Move the completed control file into the completed area
      completed_path = File.join(Uploader.completed_control_files_area, File.basename(@control_plist_path))
      FileUtils.move(@control_plist_path, completed_path)

      ASLLogger.notice "Successfully completed #{upload_type} upload: #{upload_uuid}"
    rescue Exception => boom
      # Once we've gotten an error, we need to mark the control plist as error and not pick it up anymore
      Upload.set_progress(@control_plist, 0, "0", "error", true)
      raise boom    # Re-raise
    end
  end

  def upload_cmd(cmd, cmd_args, file_path)
    cmd_result, results = system_cmd(cmd, cmd_args)

    if (cmd_result != 0)  
      raise PcastServerException.new(ERR_SERVER_FILE_UPLOAD_FAILED), "Failed to upload: #{file_path} error_code: #{$?}"
    end

    results
  end

  def cleanup_paths(paths_array)
    paths_array.each do |path|
      if (File.exist?(path))
        if (FileUtils.rm_r(path, :force => true, :secure => true))
          ASLLogger.notice("Cleaned up #{path}")
        end
      end
    end
  end

  def copy_upload(file_copy_path, file_path, dest_file_name, recording_uuid)
    begin
      if (!file_copy_path || file_copy_path.to_s.empty?)
        raise "No file copy path"
      end

      copy_dir = File.join(file_copy_path.to_s, recording_uuid)

      # We never create directories, the server does.  If it doesn't exist, we can't copy
      if (!File.exist?(copy_dir))
        raise "Destination directory: '#{copy_dir}' is not mounted on client machine.  Skipping."
      end

      # Where are we going?
      destpath = File.join(copy_dir, dest_file_name)

      ASLLogger.notice("Trying direct copy to '#{copy_dir}'...")

      buffer_length = self.upload_buffer_size

      begin        
        file_size = File.size(file_path)
        
        File.open(file_path, "r") do |read_file|
          File.open(destpath, "w+") do |write_file|
            # Preallocate here (assume it's an Xsan)
            PcastPreallocate.preallocate(write_file.fileno, file_size)

            while (true)
              buffer = read_file.read(buffer_length)
              if (!buffer)
                break
              end

              bytes_written = write_file.write(buffer)
              update_progress(bytes_written)
            end
          end  
        end
      rescue Exception => boom
        #puts boom.backtrace
        raise "#{copy_dir} is not writable.  Skipping."
      end

      ASLLogger.notice "Local copy succeeded for '#{file_path}'"
      true
    rescue Exception => details
      ASLLogger.notice(details)
      false
    end
  end

  def https_upload(https_upload_url, file_path, dest_file_name, recording_uuid)
    begin
      if (!https_upload_url || https_upload_url.to_s.empty?)
        raise "No HTTPS upload URL"
      end

      https_uri = URI.parse(https_upload_url.to_s)

      ASLLogger.notice "Trying HTTPS POST of '#{File.basename(file_path)}' to '#{https_uri.to_s}'"

      http = Net::HTTP.new(https_uri.host, https_uri.port)
      http.use_ssl = (https_uri.scheme == "https")
      if (http.use_ssl && $check_ssl_cert)
        http.verify_mode = OpenSSL::SSL::VERIFY_PEER
      elsif (http.use_ssl)
        http.verify_mode = OpenSSL::SSL::VERIFY_NONE
      end

      results = ""

      fr = FileReader.new(file_path, self)
      begin
        fr.open
        http.start do |http|
          request = Net::HTTP::Post.new(https_uri.path)
          request.add_field 'File-Name', dest_file_name
          request.add_field 'Recording-UUID', recording_uuid
          request.add_field 'Content-Length', fr.length
          request.add_field 'Content-Type', 'application/x-www-form-urlencoded'
          request.body_stream = fr          # Stream the files
          response = http.request(request)
          response.value    # Check for 2xx response code
          results = response.body   # Read the response body
        end
      ensure
        fr.close
      end

      if (results.match(/<status>success<\/status>/))
        ASLLogger.notice "HTTPS POST upload succeeded for '#{file_path}'"
        true
      else
        raise PcastServerException.new(ERR_SERVER_FILE_UPLOAD_FAILED), "Failed to upload: #{file_path}"
      end
    rescue Exception => details
      ASLLogger.notice(details)
      false      
    end
  end

  def ftp_upload(ftp_upload_url, file_path, dest_file_name, recording_uuid)
    begin
      if (!ftp_upload_url || ftp_upload_url.to_s.empty?)
        raise "No FTP upload URL"
      end

      ftp_uri = URI.parse(ftp_upload_url.to_s)

      if (ftp_uri.user == "username" && ftp_uri.password == "password")
        raise "FTP upload not configured"
      end

      # Within the FTP server, what's the destination path?
      path = File.join(recording_uuid, dest_file_name)

      ASLLogger.notice "Trying FTP upload of '#{File.basename(file_path)}' as '#{ftp_uri.user}@#{ftp_uri.host}' to: '#{path}'"

      Net::FTP.open(ftp_uri.host) do |ftp|
        ftp.login(ftp_uri.user, ftp_uri.password)
        ftp.chdir(recording_uuid)   # Below <PodcastProducerSharedFS>/Submissions
        ftp.putbinaryfile(file_path, dest_file_name, self.upload_buffer_size) do |data|
          update_progress(data.length)
        end
      end

      ASLLogger.notice "FTP upload succeeded for '#{file_path}'"
      true
    rescue Exception => details
      ASLLogger.notice(details)
      false      
    end
  end

  def upload_and_complete_submission
    ASLLogger.notice "Starting #{upload_type} upload for #{upload_uuid}"

    # Set the start time
    @started_at = Time.now

    # Evaluate each path and if it's a directory, tar it up
    archive_paths = Array.new
    paths_to_upload = Array.new
    
    paths_to_submit.each do |path|
      if (File.directory?(path))
        basename = File.basename(path)
        archive_path = File.join(Uploader.archives_area, "FOLDER_#{basename}.cpgz")

        cmd = "/usr/bin/ditto"
        cmd_args = Array.new
        cmd_args << "-c"
        cmd_args << "-z"
        cmd_args << "--keepParent"
        cmd_args << path
        cmd_args << archive_path
        
        # Run the ditto operation
        upload_cmd(cmd, cmd_args, path)
        
        archive_paths << archive_path
        paths_to_upload << archive_path
      else
        # Just a file
        paths_to_upload << path
      end
    end

    # Calculate the total size to upload
    paths_to_upload.each do |path|
      @total_upload_size += File.size(path)
    end

    ASLLogger.notice "Total Upload Size: #{@total_upload_size}"

    # We attempt to upload each of the files in the submission
    expected_files = Array.new

    paths_to_upload.each do |path|
      # Keep track of the original basename of the file to maintain it
      extname = File.extname(path)
      basename = File.basename(path)

      # Keep track of the file size
      file_size = File.size(path)

      # Currently, the metadata is submitted in two different formats, plist and xml
      if (upload_type == "file")
        if (path == metadata_file)
          dest_file_name = "user_metadata.plist"
        else
          dest_file_name = basename
        end
      else
        # This is a recording
        if (extname == ".xml")
          dest_file_name = "recording_metadata.xml"
        else
          dest_file_name = basename
        end
      end

      # Keep track of expected files
      expected_files << "#{dest_file_name}:#{file_size.to_s}"

      # We favor File copy over HTTPS over FTP
      upload_succeeded = copy_upload(file_copy_path, path, dest_file_name, recording_uuid.to_s)

      if (!upload_succeeded)
        $check_ssl_cert = @control_plist.get("check_ssl_cert") == "true"
        upload_succeeded = https_upload(https_upload_url, path, dest_file_name, recording_uuid.to_s)
      end  

      if (!upload_succeeded)
        upload_succeeded = ftp_upload(ftp_upload_url, path, dest_file_name, recording_uuid.to_s)
      end  

      if (!upload_succeeded)
        raise PcastServerException.new(ERR_SERVER_FILE_UPLOAD_FAILED), "Unable to upload file: #{path}"
      end
    end

    # Before completing the submission, remove all the archives
    cleanup_paths(archive_paths)

    # Contact the server and complete the submission
    params = CmdParameters.new
    params.add("recording_uuid", recording_uuid)
    params.add("submission_uuid", submission_uuid)
    params.add("submitted_files", expected_files.join(";"))
    params.add("primary_content_file", File.basename(primary_content_path))

    # We need to return to the original server to do the completion, it does not require a username/password
    $server_url = @control_plist.get("server_url")  
    $check_ssl_cert = @control_plist.get("check_ssl_cert") == "true"  
    $server_context = ServerContext.get(false)

    complete_xml = execute_post("/submissions/complete", params)

    # Evaluate the response
    status = RemoteCommand.first_xml_value_for_key(complete_xml, "status")
    if (status.to_s == "success")
      pcast_job_id = RemoteCommand.first_xml_value_for_key(complete_xml, "pcast_job_id")
      ASLLogger.notice "Podcast Producer Job ID: #{pcast_job_id}"
      true
    else
      false
    end
  end
end

class Uploader  
  def terminate
    ASLLogger.notice "pcastuploader terminated."
    @running = false
  end

  def Uploader.start(control_plist_path = nil)
    ASLLogger.enable_timestamps(true)
    
    if (!control_plist_path)
      ASLLogger.notice("Starting pcastuploader as pid: #{Process.pid}...")
    end

    uploader = Uploader.new

    trap("TERM") do
      ASLLogger.notice("pcastuploader got TERM signal...")
      uploader.terminate
    end

    uploader.run(control_plist_path)

    ASLLogger.notice("pcastuploader quit")
  end

  def run(control_plist_path = nil)
    if (control_plist_path)
      begin
        # For each control plist we find, do an upload
        upload = Upload.new(control_plist_path)
        upload.run
      rescue Exception => e
        ASLLogger.error e
      end
    else
      ASLLogger.notice("Using uploads area: #{Uploader.control_files_area}")

      @running = true
      while (@running)
        # For every file we find in the directory, do an upload run
        # Always sort this by earliest first
        control_files = Dir["#{Uploader.control_files_area}/*"].sort_by {|f| test(?M, f)}
        control_files.each do |control_plist_path|
          begin
            # For each control plist we find, do an upload
            upload = Upload.new(control_plist_path)
            upload.run
          rescue Exception => e
            ASLLogger.error e
          end
        end
        
        # Sleep at the bottom of the loop for 15 seconds
        sleep AGENT_UPLOADER_SLEEP_INTERVAL      
      end
    end
  end

  def Uploader.area(area_name)
    if (Process.uid == 0)
      # This is below /var/spool/pcastuploader/<area_name>
      area = File.join(AGENT_UPLOAD_AREA, area_name)
    else
      # This is below ~/Library/Application Support/pcastuploader/<area_name>
      area = File.join(File.expand_path(ADHOC_UPLOAD_AREA), area_name)
    end

    FileUtils.mkdir_p area
    File::chmod(0750, area)

    area
  end

  def Uploader.control_files_area
    Uploader.area("control")
  end

  def Uploader.metadata_area
    Uploader.area("metadata")
  end
 
  def Uploader.archives_area
    Uploader.area("archives")
  end
 
  def Uploader.completed_control_files_area
    Uploader.area("completed")
  end

  def Uploader.list_uploads
    all_uploads = OSX::NSMutableDictionary.dictionary

    # This lists all pending, errored, and in-progress control files in the library area
    control_files = Dir["#{Uploader.control_files_area}/*"]
    control_files.each do |control_plist_path|
      upload_uuid = File.basename(control_plist_path, ".plist")

      upload = PList.new(control_plist_path)
      if (upload && upload.dict)
        all_uploads.setObject_forKey(upload.dict, upload_uuid)
      end
    end

    # This lists all completed control files in the library area
    control_files = Dir["#{Uploader.completed_control_files_area}/*"]
    control_files.each do |control_plist_path|
      upload_uuid = File.basename(control_plist_path, ".plist")

      upload = PList.new(control_plist_path)
      if (upload && upload.dict)
        all_uploads.setObject_forKey(upload.dict, upload_uuid)
      end
    end

    data = OSX::NSPropertyListSerialization.dataFromPropertyList_format_errorDescription(all_uploads, OSX::NSPropertyListXMLFormat_v1_0, nil)
    puts OSX::NSString.alloc.initWithData_encoding(data, OSX::NSUTF8StringEncoding)
    #puts all_uploads.description.to_s
  end

  def Uploader.clear_completed_uploads
    # We iterate through all the control plists and remove the completed ones
    control_files = Dir["#{Uploader.completed_control_files_area}/*"]
    control_files.each do |control_plist_path|
      upload_uuid = File.basename(control_plist_path, ".plist")

      upload = PList.new(control_plist_path)
      if (upload.get("status") == "completed")
        File.delete(control_plist_path)
        
        # We also delete the metadata file associated with this uploader if it exists
        metadata_path = upload.get("cached_metadata_file")
        if (metadata_path && File.exist?(metadata_path))
          File.delete(metadata_path)
        end
      end
    end    
  end

  def Uploader.clear_errored_uploads
    # We iterate through all the control plists and remove the errored ones
    control_files = Dir["#{Uploader.control_files_area}/*"]
    control_files.each do |control_plist_path|
      upload_uuid = File.basename(control_plist_path, ".plist")

      upload = PList.new(control_plist_path)
      if (upload.get("status") == "error")
        File.delete(control_plist_path)
        
        # We also delete the metadata file associated with this uploader if it exists
        metadata_path = upload.get("cached_metadata_file")
        if (metadata_path && File.exist?(metadata_path))
          File.delete(metadata_path)
        end
      end
    end    
  end
end

class Submission < RemoteCommand
  def Submission.read_create_response(create_xml, control_plist)
    if (!create_xml)
      raise PcastServerException.new(ERR_SERVER_SUBMISSION_CREATE_FAILED), "Submission create failed"
    end

    # Extract the information from the response and store it in the control_plist
    status = RemoteCommand.first_xml_value_for_key(create_xml, "status")
    if (status != "success")
      raise PcastServerException.new(ERR_SERVER_SUBMISSION_CREATE_FAILED), "Submission create failed"
    end

    # Extract our important keys for this submission
    submission_uuid = RemoteCommand.first_xml_value_for_key(create_xml, "uuid")
    recording_uuid = RemoteCommand.first_xml_value_for_key(create_xml, "recording_uuid")
    file_copy_path = RemoteCommand.first_xml_value_for_key(create_xml, "file_copy_path")
    https_upload_url = RemoteCommand.first_xml_value_for_key(create_xml, "https_upload_url")
    ftp_upload_url = RemoteCommand.first_xml_value_for_key(create_xml, "ftp_upload_url")    

    control_plist.set("recording_uuid", recording_uuid, false)
    control_plist.set("file_copy_path", file_copy_path, false)
    control_plist.set("https_upload_url", https_upload_url, false)
    control_plist.set("ftp_upload_url", ftp_upload_url, false)      # TODO: make sure the user:pass is secured
    control_plist.set("submission_uuid", submission_uuid, false)
  end

  def Submission.create_upload_for_recording(recording_uuid, delete_after_upload)
    # Create a new upload_uuid
    upload_uuid = UUID.generate

    # Make sure the control files area exists
    Uploader.control_files_area
    
    # Make sure logs area exists
    FileUtils.mkdir_p AGENT_UPLOAD_LOGS_AREA

    # If the launchd plist does not exist, create and load it
    if (!File.exist?(AGENT_LAUNCHD_PLIST))
      FileUtils.cp(AGENT_LAUNCHD_TEMPLATE, AGENT_LAUNCHD_PLIST)
      if (File.exist?(AGENT_LAUNCHD_PLIST))
        system("/bin/launchctl load #{AGENT_LAUNCHD_PLIST}")
        if ($? != 0)
          ASLLogger.error "Failed to load #{AGENT_LAUNCHD_PLIST}"
        end
      end
    end

    # Where will the upload configuration go?  
    control_plist_path = File.join(Uploader.control_files_area, "#{upload_uuid}.plist")

    # Within the recording repository, find the completed recording...
    recording_dir = AgentPreferences.get("RecordingRepositoryPath")
    content_file = File.join(recording_dir, recording_uuid + ".mov")
    recording_metadata_file = File.join(recording_dir, recording_uuid + ".xml")

    # Copy the metadata file into the metadata files area
    cached_metadata_path = File.join(Uploader.metadata_area, "#{upload_uuid}.xml")
    FileUtils.cp(recording_metadata_file, cached_metadata_path)

    content_paths = Array.new
    content_paths << content_file

    # Create properties that this upload will need when it is run
    control_plist = PList.new(control_plist_path)
    control_plist.set("upload_type", "recording", false)
    control_plist.set("upload_uuid", upload_uuid, false)
    control_plist.set("recording_uuid", recording_uuid, false)
    control_plist.set("delete_after_upload", delete_after_upload ? "true" : "false", false)
    control_plist.set("original_metadata_file", recording_metadata_file, false)
    control_plist.set_array("content_paths", content_paths, false)
    control_plist.set("cached_metadata_file", cached_metadata_path, false)  
    control_plist.set("upload_buffer_size", $upload_buffer_size, false)  
    control_plist.set("camera_uuid", AgentPreferences.get("CameraUUID"), false)  

    # Lookup our agent shared secret for the server and encrypt the recording_uuid with it
    server_uuid = AgentPreferences.get("ServerUUID")
    secret = Base64.decode64(PcastSecret.agent_shared_secret_for_server(server_uuid))
    enc_recording_uuid = Crypto.encrypt(secret, recording_uuid)
    control_plist.set("enc_recording_uuid", enc_recording_uuid, false)  

    # Make sure we know what server we'll be using
    control_plist.set("server_url", $server_url, false)  
    control_plist.set("check_ssl_cert", $check_ssl_cert, false)  

    # Initialize the progress in the control file
    Upload.set_progress(control_plist, 0, "N/A", "pending", false)

    # We save out our upload config file and the uploader will be started by launchd
    if (!control_plist.save)
      raise PcastException.new(ERR_PLIST_FAILURE), "Unable to save #{control_plist_path}"
    end

    # Lock it down to root
    File::chmod(0640, control_plist_path)

    ASLLogger.notice("Upload #{upload_uuid} for recording: #{$recording_uuid} initiated successfully.")
  end

  def Submission.initiate_recording_submission(control_plist)
    # Contact the server and create a new submission for this recording
    params = CmdParameters.new
    params.add("submission_type", "recording")
    params.add("camera_uuid", control_plist.get("camera_uuid"))
    params.add("enc_recording_uuid", control_plist.get("enc_recording_uuid"))

    # We contact the server to do start the submission, it does not require a username/password
    $server_url = control_plist.get("server_url")  
    $check_ssl_cert = control_plist.get("check_ssl_cert") == "true"  
    $server_context = ServerContext.get(false)

    # Here, we contact the server to create a submission container for this recording
    sub = Submission.new
    create_xml = sub.execute_post("/submissions/create_for_recording", params)
    Submission.read_create_response(create_xml, control_plist)
  end

  def Submission.create_upload_for_files(paths_to_submit, metadata_file, workflow_name, delete_after_upload)
    # Create a new upload_uuid
    upload_uuid = UUID.generate

    # Make sure the control files area exists
    Uploader.control_files_area
    
    # Make sure logs area exists
    FileUtils.mkdir_p File.expand_path(ADHOC_UPLOAD_LOGS_AREA)
    
    # Where will the upload configuration go?
    control_plist_path = File.join(Uploader.control_files_area, "#{upload_uuid}.plist")

    # If passed, validate that the identified metadata file is a valid dictionary plist
    if ($metadata_file_path)
      begin
        md = OSX::NSDictionary.dictionaryWithContentsOfFile($metadata_file_path)
      rescue
        raise PcastException.new(ERR_CMDLINE_PARAMETERS), "#{$metadata_file_path} is not a valid dictionary plist"
      end

      # Copy the metadata file into the metadata files area
      metadata_plist_path = File.join(Uploader.metadata_area, "#{upload_uuid}.plist")
      FileUtils.cp($metadata_file_path, metadata_plist_path)
    end

    # Check that all the files exist and are readable
    paths_to_submit.each do |path|
      if (!File.exist?(path))
        raise PcastException.new(ERR_CMDLINE_PARAMETERS), "#{path} does not exist"
      end
      if (!File.readable?(path))
        raise PcastException.new(ERR_CMDLINE_PARAMETERS), "#{path} is not readable"
      end
    end

    # Create properties that this upload will need when it is run
    control_plist = PList.new(control_plist_path)
    control_plist.set("upload_type", "file", false)
    control_plist.set("upload_uuid", upload_uuid, false)
    control_plist.set("user", $username, false)
    if ($metadata_file_path)
      control_plist.set("original_metadata_file", $metadata_file_path, false)
    end
    control_plist.set_array("content_paths", paths_to_submit, false)
    control_plist.set("delete_after_upload", delete_after_upload ? "true" : "false", false)
    if (metadata_plist_path)
      control_plist.set("cached_metadata_file", metadata_plist_path, false)
    end
    control_plist.set("upload_buffer_size", $upload_buffer_size, false)  
    control_plist.set("workflow_name", workflow_name, false)  

    # Initialize the progress in the control file
    Upload.set_progress(control_plist, 0, "N/A", "pending")

    # Initiate a file submission on the server
    Submission.initiate_file_submission(control_plist)

    # We save out our upload config file and the uploader will be started by launchd
    if (!control_plist.save)
      raise PcastException.new(ERR_PLIST_FAILURE), "Unable to save #{control_plist_path}"
    end

    # Lock it down to this user
    File::chmod(0640, control_plist_path)

    ASLLogger.enable_timestamps(true)
    ASLLogger.notice("Upload UUID: #{upload_uuid}")

    # For adhoc file submissions, we run the upload synchronously
    Uploader.start control_plist_path
  end

  def Submission.initiate_file_submission(control_plist)
    # Contact the server and create a new submission
    params = CmdParameters.new
    params.add("submission_type", "file")
    params.add("workflow_name", control_plist.get("workflow_name"))

    sub = Submission.new
    create_xml = sub.execute_post("/submissions/create_for_file", params)
    Submission.read_create_response(create_xml, control_plist)

    control_plist.set("server_url", $server_url, false)     
    control_plist.set("check_ssl_cert", $check_ssl_cert, false)     
  end
end

class Registration < RemoteCommand
  def Registration.is_bound
    if (File.exist?(AGENT_PREFS_FILE))
      camera_name = AgentPreferences.get("CameraName")
      return camera_name && !camera_name.empty?
    else
      return false
    end    
  end

  def Registration.bind
    Registration.new.bind
  end

  def Registration.unbind
    Registration.new.unbind
  end

  def bind
    if (Registration.is_bound)
      ASLLogger.notice "WARNING: Agent was previously bound as: #{AgentPreferences.get("CameraName")}"
    end

    ASLLogger.notice "Registering camera: #{$camera_agent_name} on #{$server_context.server_url}"

    # First, we need a new shared secret
    shared_secret = GoodRandom.bytes64(16)

    params = CmdParameters.new
    params.add("camera_name", $camera_agent_name)
    params.add("shared_secret", shared_secret)

    bind_xml = execute_post("/cameras/bind", params)
    status = RemoteCommand.first_xml_value_for_key(bind_xml, "status")
    if (status == "success")
      camera_uuid = RemoteCommand.first_xml_value_for_key(bind_xml, "camera_uuid")
      server_uuid = RemoteCommand.first_xml_value_for_key(bind_xml, "server_uuid")
      tunnel_host = RemoteCommand.first_xml_value_for_key(bind_xml, "tunnel_host")
      tunnel_port = RemoteCommand.first_xml_value_for_key(bind_xml, "tunnel_port")

      ASLLogger.notice "Binding local camera: #{$camera_agent_name}"

      # Now we need to save it all out to prefs
      if (PcastSecret.set_agent_shared_secret_for_server(shared_secret, server_uuid) &&
        AgentConfig.bind($camera_agent_name, camera_uuid, server_uuid,
        $server_context.server_url, tunnel_host, tunnel_port))
        ASLLogger.notice "Successfully bound #{camera_uuid} to server #{server_uuid}"
      else
        AgentConfig.unbind
        raise PcastException.new(ERR_PLIST_FAILURE), "Failed to bind camera: #{$camera_agent_name}"
      end
    else
      reason = RemoteCommand.first_xml_value_for_key(bind_xml, "reason")
      AgentConfig.unbind
      raise PcastServerException.new(ERR_SERVER_BIND_FAILED), "#{reason}"
    end
  end

  def unbind
    ASLLogger.notice "Unregistering camera: #{$camera_agent_name} from #{$server_context.server_url}"
    params = CmdParameters.new
    params.add("camera_name", $camera_agent_name)

    unbind_xml = execute_post("/cameras/unbind", params)
    status = RemoteCommand.first_xml_value_for_key(unbind_xml, "status")
    if (status != "success")
      reason = RemoteCommand.first_xml_value_for_key(unbind_xml, "reason")
      ASLLogger.error "Failure reason: #{reason}"
      # We continue on here to unbind locally, even if the server operation failed
    end

    # Remove all traces from this agent
    local_camera_name = AgentPreferences.get("CameraName")

    if (Registration.is_bound)
      if ($camera_agent_name == local_camera_name)        
        ASLLogger.notice "Unbinding local camera: #{local_camera_name}"

        camera_uuid = AgentPreferences.get("CameraUUID")
        server_uuid = AgentPreferences.get("ServerUUID")

        # remove the shared secret for this server
        PcastSecret.remove_agent_shared_secret_for_server(server_uuid)

        if (AgentConfig.unbind)
          ASLLogger.notice "Successfully unbound #{camera_uuid} from server #{server_uuid}"
        else
          raise PcastException.new(ERR_PLIST_FAILURE), "Failed to unbind camera: #{local_camera_name}"
        end
      else
        ASLLogger.notice "WARNING: Local camera is not #{$camera_agent_name}, skipping local unbind."
      end
    else
      ASLLogger.notice "WARNING: Local camera is not bound"
    end
  end
end

class CameraCmd < RemoteCommand
  def CameraCmd.start(audio_only)
    ASLLogger.notice "Starting recording on '#{$camera_agent_name}' on #{$server_context.server_url}"
    params = nil
    if (audio_only)
      params = CmdParameters.new
      params.add("audio_only", "true")
    end

    CameraCmd.new.send_cmd("start", params)
    ASLLogger.notice("'#{$camera_agent_name}' recording started")
  end

  def CameraCmd.stop(workflow_name, metadata_file_path)
    ASLLogger.notice "Stopping recording on '#{$camera_agent_name}' on #{$server_context.server_url}"
    params = CmdParameters.new
    params.add("workflow_name", workflow_name)

    # Read md_file_path as plist, then add key/value pairs as parameters
    metadata_plist = PList.new(metadata_file_path)

    # Add all the keys to the params
    keys = metadata_plist.keys
    if (keys)
      keys.each do |key|
        if (key.startsWith("UserMetadata_"))
          params.add("#{key}", metadata_plist.get(key))
        else
          params.add("UserMetadata_#{key}", metadata_plist.get(key))
        end
      end
    else
      raise PcastServerException.new(ERR_CMDLINE_PARAMETERS), "Invalid property list file: #{metadata_file_path}"
    end

    CameraCmd.new.send_cmd("stop", params)
    ASLLogger.notice("'#{$camera_agent_name}' recording stopped")
  end

  def CameraCmd.pause
    ASLLogger.notice "Pausing recording on '#{$camera_agent_name}' on #{$server_context.server_url}"
    CameraCmd.new.send_cmd("pause")
    ASLLogger.notice("'#{$camera_agent_name}' recording paused")
  end

  def CameraCmd.resume
    ASLLogger.notice "Resuming recording on '#{$camera_agent_name}' on #{$server_context.server_url}"
    CameraCmd.new.send_cmd("resume")
    ASLLogger.notice("'#{$camera_agent_name}' recording resumed")
  end

  def CameraCmd.cancel
    ASLLogger.notice "Canceling recording on '#{$camera_agent_name}' on #{$server_context.server_url}"
    CameraCmd.new.send_cmd("cancel")
    ASLLogger.notice("'#{$camera_agent_name}' recording canceled")
  end

  def CameraCmd.status(update_preview)  
    params = CmdParameters.new
    if (update_preview)
      params.add("update_preview", "true")
    end

    response_xml = CameraCmd.new.send_cmd("status", params)
    plist = RemoteCommand.xml_value_for_key(response_xml, "plist")
    if (plist)
      PListOutput.output_plist(plist)
    end
  end

  def send_cmd(operation, params=nil)    
    params = CmdParameters.new unless params
    params.add("camera_name", $camera_agent_name)
    response_xml = execute_post("/cameras/#{operation}", params)
    status = RemoteCommand.first_xml_value_for_key(response_xml, "status")
    if (status == "success")
      results = RemoteCommand.first_xml_value_for_key(response_xml, "results")
      if (results == "OK")
        return response_xml
      else
        raise PcastServerException.new(ERR_SERVER_CAMERA_CMD_FAILED), "Unexpected camera agent results: #{results}"
      end
    else
      reason = RemoteCommand.first_xml_value_for_key(response_xml, "reason")
      raise PcastServerException.new(ERR_SERVER_CAMERA_CMD_FAILED), "Failed: '#{reason}'"
    end
  end
end

class ServerContext
  def ServerContext.get(requires_user_pass=true)
    # Choose an intelligent default if no -s is passed
    if (!$server_url)
      $server_url = "https://#{Socket.gethostname}:#{SERVER_DEFAULT_PORT}/podcastproducer"
    end

    # Provide compatibility with -s <hostname> (not a URL)
    if (!$server_url.startsWith("http"))
      $server_url = "https://#{$server_url}:#{SERVER_DEFAULT_PORT}/podcastproducer"
    end

    begin
      server_uri = URI.parse($server_url)
    rescue URI::InvalidURIError => boom
      raise PcastException.new(ERR_CMDLINE_PARAMETERS), boom.to_s
    end

    if (server_uri.scheme != "https")
      raise PcastException.new(ERR_CMDLINE_PARAMETERS), "Invalid --server URL: Must be https://"
    end

    # Use the environment if no -u is passed
    if (requires_user_pass && !$username)
      $username = ENV['USER']
    end

    # If the -p was not passed on the command line, prompt for it
    if (requires_user_pass && !$password)
      gp = PcastPassword.new
      begin
        $password = gp.get_password("Enter password for #{$username}: ")
      rescue Interrupt => boom
        raise PcastException.new(ERR_CMD_CANCELED), "Command canceled"
      end
    end

    if (requires_user_pass)
      require_argument($username, "user name")
      require_argument($password, "password")
    end

    ServerContext.new($server_url, $username, $password, $check_ssl_cert)
  end

  def initialize(server_url, user, pass, check_ssl_cert)
    @server_url = server_url
    @username = user
    @password = pass
    @check_ssl_cert = check_ssl_cert
  end

  def username
    @username
  end

  def password
    @password
  end

  def server_url
    @server_url
  end

  def check_ssl_cert
    @check_ssl_cert
  end
end

class AgentConfig
  def AgentConfig.create_if_missing
    if (Process.uid != 0)
      adhoc_path = File.expand_path(ADHOC_PREFS_FILE)
      if (!File.exist?(adhoc_path))
        # Make sure the agent settings file exists
        File.copy("#{ETC_BASEDIR}/pcastagentd.adhoc.plist-default", adhoc_path)
        File::chmod(0640, adhoc_path)
        AgentPreferences.reload
      end
    else
      if (!File.exist?(AGENT_PREFS_FILE))
        # Make sure the agent settings file exists
        File.copy("#{ETC_BASEDIR}/pcastagentd.agent.plist-default", AGENT_PREFS_FILE)
        File::chmod(0644, AGENT_PREFS_FILE)
        AgentPreferences.reload
      end
    end
  end

  def AgentConfig.bind(camera_name, camera_uuid, server_uuid, server_url, tunnel_host, tunnel_port)
    AgentConfig.create_if_missing
    AgentPreferences.set("CameraName", camera_name) &&
    AgentPreferences.set("CameraUUID", camera_uuid) &&
    AgentPreferences.set("ServerUUID", server_uuid) &&
    AgentPreferences.set("PodcastProducerURL", server_url) &&
    AgentPreferences.set("TunnelHost", tunnel_host) &&
    AgentPreferences.set("TunnelPort", tunnel_port)
  end

  def AgentConfig.unbind
    AgentConfig.create_if_missing
    AgentPreferences.remove("CameraName")
    AgentPreferences.remove("CameraUUID")
    AgentPreferences.remove("ServerUUID")
    AgentPreferences.remove("PodcastProducerURL")
    AgentPreferences.remove("TunnelHost")
    AgentPreferences.remove("TunnelPort")
    true
  end

  def AgentConfig.set_config(settings_list)
    AgentConfig.create_if_missing

    # $config_settings_list looks like 'a=b;c=d;e=f'
    settings = settings_list.split(";")
    settings.each do |setting|
      parts = setting.split("=")
      if (parts.length == 2)
        key = parts[0]
        value = parts[1]
        case key
        when "Capture"
          # Capture = <CaptureType>:<CaptureQuality>
          #     CaptureType = "Audio|Video|Screen"
          #     CaptureQuality = "Good|Better|Best"
          capture_parts = value.split(":")
          if (capture_parts.length != 2)
            raise PcastException.new(ERR_PLIST_FAILURE), "Invalid capture setting #{value}"
          end

          type = capture_parts[0]
          quality = capture_parts[1]

          # Get the presets dictionary
          presets = AgentConfig.presets

          # Find the preset type first
          preset_type = presets.objectForKey(type)
          if (!preset_type)
            raise PcastException.new(ERR_PLIST_FAILURE), "Invalid capture type #{type}"
          end

          # Find the quality next    
          preset_dict = preset_type.objectForKey(quality)
          if (!preset_dict)
            raise PcastException.new(ERR_PLIST_FAILURE), "Invalid capture quality #{quality}"
          end

          # Slam all the keys in the preset_dict into the agent plist
          preset_dict.keys.each do |preset_key|
            preset_value = preset_dict.objectForKey(preset_key)
            if (!AgentPreferences.set(preset_key.to_s, preset_value.to_s))
              raise PcastException.new(ERR_PLIST_FAILURE), "Could not set #{preset_key} to #{preset_value}"
            end
          end

          ASLLogger.notice "Set Capture to #{value}"
        when "AudioDevice"
          # AudioDevice = Automatic|<Name>
          if (AgentPreferences.set("RecordingAudioInput", value))
            ASLLogger.notice "Set Audio Device to #{value}"
          else
            raise PcastException.new(ERR_PLIST_FAILURE), "Could not set Audio Device to #{value}"
          end
        when "VideoDevice"
          # VideoDevice = Automatic|<Name>
          if (AgentPreferences.set("RecordingVideoInput", value))
            ASLLogger.notice "Set Video Device to #{value}"
          else
            raise PcastException.new(ERR_PLIST_FAILURE), "Could not set Video Device to #{value}"
          end
        else
          raise PcastException.new(ERR_PLIST_FAILURE), "Unknown configuration key #{key}"
        end
      else
        raise PcastException.new(ERR_CMDLINE_PARAMETERS), "Invalid key=value pair: #{setting}"
      end
    end
  end

  def AgentConfig.presets
    # Get the presets dictionary
    capture_presets = "/etc/podcastproducer/pcastagentd.capture-presets.plist"
    presets = OSX::NSDictionary.dictionaryWithContentsOfFile(capture_presets)
    if (!presets)
      raise PcastException.new(ERR_PLIST_FAILURE), "Unable to load presets: #{capture_presets}"
    end
    presets    
  end

  def AgentConfig.list_presets
    presets = AgentConfig.presets

    ASLLogger.notice("Capture preset choices: ")

    presets.keys.each do |type|
      # Find the dict for the type
      preset_type = presets.objectForKey(type)
      if (!preset_type)
        raise PcastException.new(ERR_PLIST_FAILURE), "Invalid capture type #{type}"
      end

      preset_type.keys.each do |quality|
        dict = preset_type.objectForKey(quality)
        description = dict.objectForKey("RecordingCaptureDescription")
        ASLLogger.notice("\t'Capture=#{type}:#{quality}' => #{description}")
      end
    end    
  end

  def AgentConfig.list_devices
    system("/usr/libexec/podcastproducer/pcastagentd -i")
  end

  def AgentConfig.get_config
    AgentConfig.create_if_missing
    ASLLogger.notice("Capture=#{AgentPreferences.get('RecordingCapturePreset')}")
    ASLLogger.notice("AudioDevice=#{AgentPreferences.get('RecordingAudioInput')}")
    ASLLogger.notice("VideoDevice=#{AgentPreferences.get('RecordingVideoInput')}")
  end
end

# Debug
#ASLLogger.notice "ARGS: " + ARGV.join(" ")

opts = GetoptLong.new(
# Server Connection
["--server", "-s", GetoptLong::REQUIRED_ARGUMENT],
["--user", "-u", GetoptLong::REQUIRED_ARGUMENT],
["--pass", "-p", GetoptLong::REQUIRED_ARGUMENT],
["--checksslcert", GetoptLong::NO_ARGUMENT],

# Query Operations
["--listcameras", GetoptLong::NO_ARGUMENT],
["--listworkflows", GetoptLong::NO_ARGUMENT],

# Submission Operations
["--submit", GetoptLong::NO_ARGUMENT],
["--recording", "-r", GetoptLong::REQUIRED_ARGUMENT],
["--file", "-f", GetoptLong::REQUIRED_ARGUMENT],
["--workflow", "-w", GetoptLong::REQUIRED_ARGUMENT],
["--metadata", "-m", GetoptLong::REQUIRED_ARGUMENT],
["--delete", GetoptLong::NO_ARGUMENT],  

# Uploader
["--run_uploader", GetoptLong::NO_ARGUMENT],
["--list_uploads", GetoptLong::NO_ARGUMENT],
["--clear_completed_uploads", GetoptLong::NO_ARGUMENT],

# Registration Operations
["--isbound", GetoptLong::NO_ARGUMENT],
["--bind", GetoptLong::REQUIRED_ARGUMENT],
["--unbind", GetoptLong::REQUIRED_ARGUMENT],

# AgentPreferences Operations
["--setconfig", GetoptLong::REQUIRED_ARGUMENT],
["--getconfig", GetoptLong::NO_ARGUMENT],
["--presets", GetoptLong::NO_ARGUMENT],
["--devices", GetoptLong::NO_ARGUMENT],

# Camera Command Operations
["--status", GetoptLong::REQUIRED_ARGUMENT],
["--start", GetoptLong::REQUIRED_ARGUMENT],
["--stop", GetoptLong::REQUIRED_ARGUMENT],
["--pause", GetoptLong::REQUIRED_ARGUMENT],
["--resume", GetoptLong::REQUIRED_ARGUMENT],
["--cancel", GetoptLong::REQUIRED_ARGUMENT],
["--update_preview", GetoptLong::NO_ARGUMENT],
["--audio_only", GetoptLong::NO_ARGUMENT],

# Networking
["--timeout", "-t", GetoptLong::REQUIRED_ARGUMENT],
["--upload_buffer_size", GetoptLong::REQUIRED_ARGUMENT],

# Help
[ '--help', '-h', GetoptLong::NO_ARGUMENT ],
[ '--debug', '-d', GetoptLong::NO_ARGUMENT ]
)

$server_url = nil
$username = nil
$password = nil
$cmd = nil
$camera_agent_name = nil
$recording_uuid = nil
$paths_to_submit = Array.new
$workflow_name = nil
$metadata_file_path = nil
$camera_agent_name = nil
$config_settings_list = nil
$check_ssl_cert = false
$update_preview = false
$audio_only = false
$timeout = DEFAULT_CMD_TIMEOUT
$delete_after_upload = false
$upload_buffer_size = DEFAULT_TRANSFER_BUFFER_SIZE

# Extract options and arguments
begin
  opts.each do |opt, arg|
    case opt
    when '--help', '-h'
      system("/usr/bin/man #{SCRIPT_NAME}")
      exit 0
    when '--debug', '-d'
      ASLLogger.enable_debug_logging(true)
    when '--server', '-s'
      $server_url = arg
    when '--user', '-u'
      $username = arg
    when '--pass', '-p'
      $password = arg
    when '--checksslcert'
      $check_ssl_cert = true
    when '--listcameras'
      $cmd = "listcameras"
    when '--listworkflows'
      $cmd = "listworkflows"
    when '--status'
      $cmd = "status"
      $camera_agent_name = arg
    when '--submit'
      $cmd = "submit"
    when '--run_uploader'
      $cmd = "run_uploader"
    when '--list_uploads'
      $cmd = "list_uploads"
    when '--clear_completed_uploads'
      $cmd = "clear_completed_uploads"
    when '--recording', '-r'
      $recording_uuid = arg
    when '--file', '-f'
      $paths_to_submit << File.expand_path(arg)
    when '--workflow', '-w'
      $workflow_name = arg
    when '--metadata', '-m'
      if ($metadata_file_path)
        # We already have an identified metadata file
        raise GetoptLong::InvalidOption.new, "Only a single --metadata file may be used"
      end

      $metadata_file_path = File.expand_path(arg)
    when '--delete'
      $delete_after_upload = true
    when '--isbound'
      $cmd = "isbound"
    when '--bind'
      $cmd = "bind"
      $camera_agent_name = arg
    when '--unbind'
      $cmd = "unbind"
      $camera_agent_name = arg
    when '--setconfig'
      $cmd = "setconfig"
      $config_settings_list = arg
    when '--getconfig'
      $cmd = "getconfig"
    when '--presets'
      $cmd = "presets"
    when '--devices'
      $cmd = "devices"
    when '--start'
      $cmd = "start"
      $camera_agent_name = arg
    when '--stop'
      $cmd = "stop"
      $camera_agent_name = arg
    when '--pause'
      $cmd = "pause"
      $camera_agent_name = arg
    when '--resume'
      $cmd = "resume"
      $camera_agent_name = arg
    when '--cancel'
      $cmd = "cancel"
      $camera_agent_name = arg
    when '--update_preview'
      $update_preview = true
    when '--audio_only'
      $audio_only = true
    when '--timeout', '-t'
      $timeout = arg.to_i
    when '--upload_buffer_size'
      $upload_buffer_size = arg
    else
      print "Illegal argument: #{opt}"
    end
  end
rescue GetoptLong::InvalidOption => boom
  ASLLogger.crit_and_exit(boom, ERR_CMDLINE_PARAMETERS)
rescue GetoptLong::MissingArgument => boom
  ASLLogger.crit_and_exit(boom, ERR_CMDLINE_PARAMETERS)
end

def require_argument(argument, display_name)
  if (!argument || argument.empty?)
    raise PcastException.new(ERR_CMDLINE_PARAMETERS), "--#{$cmd} requires a #{display_name}"
  end
end

class PcastServerException < PcastException
  def initialize(return_code)
    super(return_code)
  end
end

# Check for required arguments and context and dispatch commmands
begin
  case $cmd
  when nil
    raise PcastException.new(ERR_CMDLINE_PARAMETERS), "No command specified.  Use #{SCRIPT_NAME} --help for assistance."
  when "listcameras"
    $server_context = ServerContext.get
    Query.list_cameras
  when "listworkflows"
    $server_context = ServerContext.get
    Query.list_workflows
  when "submit"
    if ($recording_uuid)
      require_argument($server_url, "server url")
      require_argument($recording_uuid, "recording uuid")

      check_for_root("--#{$cmd} with --recording")

      if (!Registration.is_bound)
        raise PcastException.new(ERR_INVALID_AGENT_STATE), "Agent is not bound"
      end

      $server_context = ServerContext.get(false)

      Submission.create_upload_for_recording($recording_uuid, $delete_after_upload)
    elsif ($paths_to_submit.length > 0)
      require_argument($workflow_name, "workflow name")

      $server_context = ServerContext.get

      Submission.create_upload_for_files($paths_to_submit, $metadata_file_path, $workflow_name, $delete_after_upload)
    else
      raise PcastException.new(ERR_CMDLINE_PARAMETERS), "--#{$cmd} requires either --recording or --file"
    end
  when "run_uploader"
    Uploader.start
  when "list_uploads"
    Uploader.list_uploads
  when "clear_completed_uploads"
    Uploader.clear_completed_uploads
    Uploader.clear_errored_uploads
  when "isbound"
    if (Registration.is_bound)
      puts "BOUND"
      exit 1
    else
      puts "UNBOUND"
      exit 0
    end
  when "bind"
    require_argument($camera_agent_name, "camera agent name")
    check_for_root($cmd)
    $server_context = ServerContext.get
    Registration.bind
  when "unbind"
    require_argument($camera_agent_name, "camera agent name")
    check_for_root($cmd)
    $server_context = ServerContext.get
    Registration.unbind
  when "setconfig"
    require_argument($config_settings_list, "settings list")
    AgentConfig.set_config($config_settings_list)
  when "getconfig"
    AgentConfig.get_config
  when "presets"
    AgentConfig.list_presets
  when "devices"
    AgentConfig.list_devices
  when "start"
    require_argument($camera_agent_name, "camera agent name")
    $server_context = ServerContext.get
    CameraCmd.start($audio_only)
  when "stop"
    require_argument($camera_agent_name, "camera agent name")
    require_argument($workflow_name, "workflow name")
    require_argument($metadata_file_path, "metadata file path")
    $server_context = ServerContext.get
    CameraCmd.stop($workflow_name, $metadata_file_path)
  when "pause"
    require_argument($camera_agent_name, "camera agent name")
    $server_context = ServerContext.get
    CameraCmd.pause
  when "resume"
    require_argument($camera_agent_name, "camera agent name")
    $server_context = ServerContext.get
    CameraCmd.resume
  when "cancel"
    require_argument($camera_agent_name, "camera agent name")
    $server_context = ServerContext.get
    CameraCmd.cancel
  when "status"
    require_argument($camera_agent_name, "camera agent name")
    $server_context = ServerContext.get
    CameraCmd.status($update_preview)
  else
    raise PcastException.new(ERR_CMDLINE_PARAMETERS), "Unknown command: #{$cmd}"
  end
rescue PcastException => detail
  ASLLogger.crit_and_exit(detail, detail.return_code)
rescue SystemExit => detail
  raise detail
rescue Exception => detail
  ASLLogger.crit_and_exit("Unexpected exception: #{detail} #{detail.backtrace}")
else
  exit 0
end