Warning: chmod(): Operation not permitted in /var/www/hopeinstoughton_www/wp-content/plugins/simple-universal-google-analytics/main.php on line 8
[0] in function chmod in /var/www/hopeinstoughton_www/wp-content/plugins/simple-universal-google-analytics/main.php on line 8
[1] in function include_once in /var/www/hopeinstoughton_www/wp-settings.php on line 560
[2] in function require_once in /var/www/hopeinstoughton_www/wp-config.php on line 85
[3] in function require_once in /var/www/hopeinstoughton_www/wp-load.php on line 50
[4] in function require_once in /var/www/hopeinstoughton_www/wp-blog-header.php on line 13
[5] in function require in /var/www/hopeinstoughton_www/index.php on line 17
403WebShell
403Webshell
Server IP : 94.177.8.99  /  Your IP : 216.73.217.165
Web Server : Apache/2.4.58 (Ubuntu)
System : Linux aries 6.8.0-134-generic #134-Ubuntu SMP PREEMPT_DYNAMIC Fri Jun 26 18:43:11 UTC 2026 x86_64
User : www-data ( 33)
PHP Version : 8.1.34
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : OFF
Directory :  /var/www/hopeinstoughton_www/wp-content/plugins/media-manager/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/hopeinstoughton_www/wp-content/plugins/media-manager/media-manager.php
<?php

/*
Plugin Name: Media Manager
Plugin URI: http://hostz.org/
Description: Manage Audio and Video files
Version: 1.0.0
Author: Phil Young
Author URI: http://hostz.org/
License: Whatever
*/

// REQUIRES:  ffmpeg to be installed

// to do: upload xml
// to do: upload unmarked


require_once __DIR__ . '/cdn/include/class.media-source.php';
require_once __DIR__ . '/cdn/include/class.mp3id.php';
require_once __DIR__ . '/youtube_model.php';

// get source and video id from a url
function ParseVideoUrl($url) {
	$result = new \StdClass;
	$result->source = 'unknown';
	$result->id = FALSE;
	if ($url != '') {
		$elements = parse_url($url);
		$host = strtolower($elements['host'] ?? '');
		parse_str($elements['query'] ?? '', $query);
		switch($host) {
			case 'youtube.com':
			case 'www.youtube.com':
				$result->source = 'youtube';
				if (isset($query['v'])) {
					$result->id = $query['v'];
				} elseif (preg_match('~/live/(.*?)$~', $elements['path'], $match)) {
					$result->id = $match[1];
				}
				break;
			
			case 'youtu.be':
				$result->source = 'youtube';
				$result->id = substr($elements['path'], 1);
				break;
		}
	}
	return $result;
}


class media_manager_class {

    const ARIES  = 1;
    const HOSTZ  = 2;	// now JUPITER - decommissioned - using GoogleDrive instead
    const CHURCH = 3;
    const HOME   = 4;
    const GOOGLE = 5;
    const LISTINGPAGE = '/members/audio';

    const SERVICEPOST = 143;        // most recent service
    const EXHORT = 364;

    const FFMPEG  = '/usr/bin/ffmpeg';
    const FFMPEG_MAC = '/Users/video/videos/ffmpeg';

    const TYPES = [
        'ac'      => 'Adult Class',
        'bc'      => 'Bible Class',
        'school'  => 'Bible School',
        'bapt'    => 'Baptism',
        'cyc'     => 'CYC',
        'ex'      => 'Exhort',
        'fg'      => 'Fraternal Gathering',
        'mt'      => 'Moorestown Audio',
		'cbt'	  => 'ChristadelphianBibleTalks.com',
        'se'      => 'Special Event',
        'study'   => 'Study Day',
        'other'   => 'Other',
        'unknown' => 'Unknown',
    ];

    private $isPost;
    private $source;

	public function __construct() {
		global $wpdb;
		date_default_timezone_set('America/New_York');
        $this->ajaxurl =  admin_url( 'admin-ajax.php' );
        $this->URL = plugin_dir_url(__FILE__);
		add_action( 'admin_menu', [$this, 'main_menu'], 1 );
		add_action('wp_enqueue_scripts', [$this, 'add_css']);
		add_action('admin_enqueue_scripts', [$this, 'add_css']);
        
        add_shortcode('audio-archive', [$this, '_shortcode_archive' ]);     // categories
		add_shortcode('audio-table', [$this, '_shortcode_table' ]);         // items within category
		add_shortcode('moorestown-extra', [$this, '_shortcode_extras' ]);   // new items to go to Moorestown
        add_action( 'wp_loaded', [$this, 'custom_urls'] );
        add_action( 'wp', [$this, '__wp_page']);

        $this->isPost = $_SERVER['REQUEST_METHOD'] == 'POST';
        $this->source = new MediaSource;
        $this->source->Db($wpdb);
        add_filter( 'mm_generate_link', [$this, '__generate_link'], 80, 4 );
        add_filter( 'mm_get_contents', [$this, '__get_contents'], 10, 4 );           // get the contents of a file
        add_filter( 'sp_wpcp_image_carousel', [$this, '__carousel'], 50, 2 );   // populate the LatestVideo carousel. version 2.3.2
        add_action( 'admin_notices', [$this, '__notices'] );
		add_action( 'admin_bar_menu', [$this, 'admin_toolbar'], 900);
        add_action( 'wp_ajax_youtubeupdate', [$this, '__youtubeupdate'] );      // admin
    }

	public function admin_toolbar ($wp_admin_bar) {
		if (current_user_can('manage_options')) {
			
			$title = __('Talks', 'talks');
			$title = "<span class=\"ab-icon\" aria-hidden=\"true\"></span><span class=\"ab-label\">$title</span>";
			$wp_admin_bar->add_node( [
				'id'    => 'talks_root',
				'title' => $title,
				'href'  => admin_url('admin.php?page=talks-single')
			] );

			$wp_admin_bar->add_node( array(
				'id'    => 'talks_speaker',
				'title' => __("Speakers", 'talks'),
				'parent' => 'talks_root',
				'href'  => admin_url('admin.php?page=speakers')
			) );
				
			$wp_admin_bar->add_node( array(
				'id'    => 'talks_event',
				'title' => __("Events", 'talks'),
				'parent' => 'talks_root',
				'href'  => admin_url('admin.php?page=talks-events')
			) );

			$wp_admin_bar->add_node( array(
				'id'    => 'talks_exhort',
				'title' => __("Exhorts", 'talks'),
				'parent' => 'talks_root',
				'href'  => admin_url('admin.php?page=talks-exhort')
			) );

			$wp_admin_bar->add_node( array(
				'id'    => 'talks_clean',
				'title' => __("Cleanup", 'talks'),
				'parent' => 'talks_root',
				'href'  => admin_url('admin.php?page=talks-archive')
			) );

			$wp_admin_bar->add_node( array(
				'id'    => 'talks_vtt',
				'title' => __("Upload VTT", 'talks'),
				'parent' => 'talks_root',
				'href'  => admin_url('admin.php?page=talks-vtt')
			) );

		}
	}

    protected function CORS() {
        if (isset($_SERVER['HTTP_ORIGIN'])) {           // Allow from any origin
            header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}");
        }

        // Access-Control headers are received during OPTIONS requests
        if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
            if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']))
                // may also be using PUT, PATCH, HEAD etc
                header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
            if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']))
                header("Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}");
        
            exit;
        }
    }
    
    // inline edit of youtube link
    public function __youtubeupdate() {
        global $wpdb;
        $value = trim($_POST['value']);
        list($filler, $id) = explode('_', $_POST['id']);
        $ok = strpos($value, '://') === FALSE ? 0 : 1;
        if ($ok) {
            $data = static::Load($id);
            if (is_null($data)) $ok = 0;
        }
        if ($ok) {
            $data->_other->youtube = $value;
            $did = '"' . esc_sql($data->mi_id) . '"';
            $safeother = '"' . esc_sql(json_encode($data->_other, JSON_UNESCAPED_SLASHES)) . '"';
            $wpdb->Query("update mm_item set mi_other={$safeother} where mi_id=$did");
        }
        $result = [
            'result' => $value,
            'ok'     => $ok,
        ];
        header('Content-type: application/json');
        die(json_encode($result, JSON_UNESCAPED_SLASHES));
    }

    public function custom_urls() {
		$cache = $_SERVER['REQUEST_URI'];
        if (($p = strpos($cache, '?')) !== FALSE) {
            $cache = substr($cache, 0, $p);
        }
        $admin = current_user_can('editor') || current_user_can('administrator');
		if (preg_match('#/~/moorestown/(.*)$#', $cache, $matches)) {
            $dtparam = $this->_dataTables_params();
            return $this->_command_datatables_categorylist($dtparam);

        } elseif (preg_match('#/~/media-events(.*)$#', $cache, $matches)) {
            return $this->_command_datatables_eventlist();

        } elseif (preg_match('#/~/media-speaker$#', $cache, $matches)) {
            return $this->_command_datatables_speakerlist();

        } elseif (preg_match('#/~/media-talks(.*)$#', $cache, $matches)) {
            $exhortonly = strpos($matches[1], 'ex') !== FALSE;
            $archive = strpos($matches[1], 'archive') !== FALSE;
			if ($archive) {
				$rows = $this->_command_datatables_archive();			// top 50 large files to be moved off cdn1
			} else {
				$rows = $this->_command_datatables_classeslist(0, TRUE, $exhortonly);
			}
			header('Content-type: application/json');
			$result = ['data' => $rows];
			echo json_encode($result, JSON_UNESCAPED_SLASHES);
			exit;
			
        } elseif (preg_match('#/~/media-classes/(\d+)$#', $cache, $matches)) {
            $me_id = (int) $matches[1];
            $rows = $this->_command_datatables_classeslist($me_id, FALSE);
			header('Content-type: application/json');
			$result = ['data' => $rows];
			echo json_encode($result, JSON_UNESCAPED_SLASHES);
			exit;

        } elseif (preg_match('#/~/media-autocomplete#', $cache, $matches)) {
            $q = isset($_GET['term']) ? trim($_GET['term']) : '';
            return $this->_command_autocomplete($q, FALSE);      // surname first
        } elseif (preg_match('#/~/media-existing#', $cache, $matches)) {
            $q = isset($_GET['term']) ? trim($_GET['term']) : '';
            return $this->_command_autocomplete_id($q);
        } elseif (preg_match('#/~/autocomplete-speaker#', $cache, $matches)) {
            $this->CORS();
            $q = isset($_GET['q']) ? trim($_GET['q']) : '';
            return $this->_command_autocomplete($q, FALSE);
        } elseif (preg_match('#/~/media-items/(.*)$#', $cache, $matches)) {
            return $this->_command_datatables_medialist($matches[1]);
        } elseif (preg_match('#/~/media-stats$#', $cache, $matches)) {
			header('Content-type: text/plain');
            $this->_command_generate_stats();
			die('statistics have been recalculated');
        } elseif (preg_match('#/~/media-clean$#', $cache, $matches)) {
			header('Content-type: text/plain');
            $this->_command_media_clean();
			die('titles cleaned');
        } elseif (preg_match('#/~/media-fileupload/(.*)$#', $cache, $matches)) {
            $file = $_FILES['file'];
            return $this->_command_upload_item($matches[1], $file);
        } elseif (preg_match('#/~/media-add-cygnus$#', $cache)) {
			return $this->_command_add_from_cygnus();
        } elseif (preg_match('#/~/media-reindex$#', $cache, $matches)) {
			while (ob_get_level()) 
				ob_end_clean();
			header('Content-type: text/plain');
			$this->reindex_all();
			exit;
        } elseif ($admin && preg_match('#/~/media-repair$#', $cache, $matches)) {
			while (ob_get_level()) 
				ob_end_clean();
			header('Content-type: text/plain');
			die("\nFunction disabled\n");
//			$this->repairVttDistribution();
//			$this->command_repair_mp3();
			die("\nComplete\n");
			exit;

        } elseif ($admin && preg_match('#/~/media-captions/(.*)$#', $cache, $matches)) {
            return $this->_command_download_youtube_caption($matches[1]);   // mi_id
        } elseif ($admin && preg_match('#/~/media-import/(.*)$#', $cache, $matches)) {
            return $this->_command_download_youtube_video($matches[1]);   // mi_id
        } elseif ($admin && preg_match('#/~/media-title/(.*)$#', $cache, $matches)) {
            return $this->_command_download_youtube_title($matches[1]);   // mi_id
        } elseif ($admin && preg_match('#/~/media-thumbnail/(.*)$#', $cache, $matches)) {
            return $this->_command_download_youtube_thumbnail($matches[1]);   // mi_id

        } elseif (preg_match('#/~/media-asyoutube/(.*)$#', $cache, $matches)) {
            return $this->_command_download_for_youtube($matches[1]);
//        } elseif (preg_match('#/~/media-xapian/(.*)$#', $cache, $matches)) {
//            return $this->_command_reindex($matches[1]);
        } elseif (preg_match('#/~/media-class-update/(.*?)/(.*?)$#', $cache, $matches)) {
            // returned from cdn2 - update the entry and redirect to class
            return $this->action_class_update($matches[1], $matches[2]);
        } elseif (preg_match('#/~/media-generate-thumb/(.*?)$#', $cache, $matches)) {
            return $this->_command_generate_thumb($matches[1]);
//        } elseif (preg_match('#/~/media-youtube-cc/(.*)$#', $cache, $matches)) {
//            return $this->_command_download_youtube_caption($matches[1]);   // mi_id

        } elseif (preg_match('#/~/media-trash-item/(.*?)/(\d+)/(.*?)(/|)$#', $cache, $matches)) {
            $this->_command_trash_item($matches[1], $matches[2], $matches[3]);
			die('Done');
        } elseif (preg_match('#/~/media-refresh-fileinfo/(.*)/(\d+)$#', $cache, $matches)) {
            return $this->_command_refresh_fileinfo($matches[1], $matches[2]);   // auth, server
        } elseif (preg_match('#/~/media-new-talk/(\d+)/(\d+)/(.*?)$#', $cache, $matches)) {
            return $this->_command_new_talk($matches[1], $matches[2], $matches[3]);
        } elseif (preg_match('#/~/media-validate/(.*?)$#', $cache, $matches)) {
            if ($admin) {
                return $this->_command_validate_slug($matches[1]);
            }
        } elseif (preg_match('#/~/media-calendar/(.*?)$#', $cache, $matches)) {
            if ($admin) {
                $error = $this->_command_calendar_link($matches[1]);	// update calendar link
				if ($error) {
					header('Content-type: text/plain');
					die($error);
				}
				$data = [
					'id' => 'cal_' . $matches[1],
				];
				header('Content-type: application/json');
				die(json_encode($data, JSON_UNESCAPED_SLASHES));
            }
        } elseif (preg_match('#/~/media-archive/(\d+)$#', $cache, $matches)) {
			while (ob_get_level()) 
				ob_end_clean();
			header('Content-type: text/plain');
			$opt = $_GET['opt'] ?? '';
			$event = intval($matches[1]);
			if (!$admin) {
				echo "No permission\n";
			} else if ($event == static::EXHORT) {
				echo "Not for this event\n";
			} elseif (!$event && $opt != '') {
				$content = explode('.', $opt);
				set_time_limit(0);
				foreach($content as $item) {
					$this->_command_archive_content2($item);
				}
				echo "Done\n";
			} elseif (!$event) {
				echo "No event specified\n";
			} else {
				return $this->_command_archive_content($matches[1]);
			}
			exit;
        } elseif (preg_match('#/~/media-cron#', $cache)) {
			while (ob_get_level()) 
				ob_end_clean();
			header('Content-type: text/plain');
			return $this->_command_cron();							// scan for caption updates, if needed; run every 1/2 hour
        } elseif (preg_match('#/~/moorestown-audio\.json$#', $cache, $matches)) {
            $info = $this->_extra_json();
            header('Content-type: application/json');
            die(json_encode($info, JSON_UNESCAPED_SLASHES));
        } elseif (preg_match('#/~/cdn-google$#', $cache, $matches)) {		// import google info
			while (ob_get_level()) 
				ob_end_clean();
			header('Content-type: text/plain');
			return $this->_command_google();
			
        } elseif (preg_match('#/~/cdn-debug$#', $cache, $matches)) {
            header('Content-type: text/plain');
			$this->cdn_debug();
			die("\nDone\n");
        } elseif (preg_match('#/~/cdn-sync$#', $cache, $matches)) {
            $cdn = isset($_GET['cdn']) ? (int) $_GET['cdn'] : 0;
            $oldfiles = isset($_GET['oldfiles']) ? (int) $_GET['oldfiles'] : 0;
			if ($oldfiles) {
				$info = $this->Synchronize_OldFiles($cdn, $oldfiles);
            } elseif ($this->isPost) {
                $auth = trim($_POST['auth']);
                $type = trim($_POST['type']);
                $info = $this->Synchronize_POST($cdn, $auth, $type);
            } else {
                $info = $this->Synchronize($cdn);
            }
            header('Content-type: application/json');
            die(str_replace('\\/', '/', json_encode($info, JSON_UNESCAPED_SLASHES)));
            
        } elseif (preg_match('#/~/media-trash/(.*?)$#', $cache, $matches) && current_user_can('administrator')) {    // auth
            $eid = isset($_GET['eid']) ? (int) $_GET['eid'] : 0;
            $param = $this->_command_delete_talk($matches[1]);
            // reload event page, if arrived via event page
            if ($eid) {
                header("Location: /wp-admin/admin.php?page=talks-events&action=event&eid=" . $eid);
            }
            die("Item {$param->mi_id} has been deleted.");
        }

        // save forms
        if ($this->isPost) {
			
			if ($this->isPost && isset($_FILES['vtt_file'])) {
				$res = [];
				foreach($_FILES['vtt_file']['tmp_name'] as $index => $file) {
					if (is_uploaded_file($file)) {
						$name = $_FILES['vtt_file']['name'][$index];
						$ext = strtolower(pathinfo($name,  PATHINFO_EXTENSION));
						if ($ext == 'vtt') {
							$content = file_get_contents($file);
							$this->ProcessVTTfile($name, $content);
							
							$res[] = $name;
						}
					}
				}
				header('Content-type: application/json');
				die(json_encode($res, JSON_UNESCAPED_SLASHES));
			}
			
            $action = isset($_POST['action']) ? $_POST['action'] : '';
            $url = '';
            if ($action == 'media_manager_class_event') {
                $url = $this->SaveRecord_Event();
            } elseif ($action == 'media_manager_class_classes') {
                $url = $this->SaveRecord_Classes();
            } elseif ($action == 'media_manager_class_youtube') {
                $youtubies = trim($_POST['youtube']);
                $url = $this->retrieveYoutube($youtubies);
            }
            if ($url != '') {
                wp_redirect($url);
                exit;
            }
        }
    }

	public function main_menu() {
		add_menu_page( 'Theme page title', 'Talks', 'publish_pages', 'talks-single', 
					[$this, 'mmc_single'], 'dashicons-businessperson', 39); 
		add_submenu_page('talks-single', __('Talks - Talk'), __('Talks'), 
					'publish_pages', 'talks-single',  [$this, 'mmc_single']);      // a single talk 
		add_submenu_page('talks-single', __('Talks - Speakers'), __('Speakers'), 
					'publish_pages', 'speakers',  [$this, 'mmc_speaker']); 
		add_submenu_page('talks-single', __('Talks - Events'), __('Events'),     // a collection of talks
					'publish_pages', 'talks-events',  [$this, 'mmc_index']);
		add_submenu_page('talks-single', __('Talks - Exhortations'), __('Exhorts'), 
					'publish_pages', 'talks-exhort',  [$this, 'mmc_exhort']);
		add_submenu_page('talks-single', __('Cleanup'), __('Cleanup'), 
					'publish_pages', 'talks-archive',  [$this, 'mmc_archive']);
		add_submenu_page('talks-single', __('Upload VTT'), __('Upload VTT'), 		// filename must be the 8 character Item Id
					'publish_pages', 'talks-vtt',  [$this, 'mmc_vtt']);
/*
		add_submenu_page(NULL, __('Form Wizard - Submission'), __('xxxx'), 	// non-menu page
					'manage_options', 'form-wizard-item',  array($this, '_anitem'));
*/
    }
	
	public function ProcessVTTfile($filename, $content) {
        global $wpdb, $MEDIASEARCH;

		// get item_id from filename
		$item_id = preg_match('~[0-9a-f]{8}~i', $filename, $match) ? $match[0] : '';
		if ($item_id == '') {
			die('No Media Id found in filename');
		}
		
		// look up entry in media_item table - error if missing
		$data = static::Load($item_id);
		if (is_null($data)) {
			die('Unknown Media Id');
		}
		$this->source   ->MmItem($data);

		$this->_ProcessVTTfile_upload(static::ARIES, $data, $content);		// copy to Aries 
		$this->_ProcessVTTfile_upload(static::CHURCH, $data, $content);		// copy to Stoughton
		$this->_ProcessVTTfile_upload(static::HOME, $data, $content);		// copy to Home
		$this->_ProcessVTTfile_upload(static::GOOGLE, $data, '');			// copy to GoogleDrive - must be done after Aries copy
		
		// reindex
		set_time_limit(0);
		if (is_object($MEDIASEARCH)) {
            $MEDIASEARCH->command_reindex($item_id);
		}

		// if the file is in the whisper database, update the status to 10
		$url = 'https://hub.pjy.us/~/whisper/api/complete/' . $item_id;
		$curl = new WP_Http_Curl;
		$args = [
			'redirection' => 5,
			'user-agent' => '',
			'stream' => FALSE,
			'filename' => '',
			'decompress' => TRUE,
			'_redirection' => 5,
		];
		$response = $curl->request($url, $args);
		if (is_a($response, 'WP_Error')) {
			header('Content-type: text/plain');
			var_dump($url);
			var_dump($response);
			exit;
		}
		// update webpage
	}
	
	protected function _ProcessVTTfile_upload($target, $data, $content) {
		global $wpdb;
		if ($target == static::GOOGLE) {
			$source = $this->source->getDestination(static::ARIES, $data->mi_id . '.vtt');
			$this->Uploadfile($source, 'hash');			// upload to google
		} else {
			$dest = $this->source->getDestination($target, $data->mi_id . '.vtt');
			// copy file
			file_put_contents($dest, $content);
		}
		
		// update database - set vtt bitmap, update filesize
		$ok2 = 1 << $target;
		$did = '"' . esc_sql($data->mi_id) . '"';
		$wpdb->Query("update mm_item set present_vtt = present_vtt | $ok2 where mi_id=$did");
		$this->UpdateMediaTable($data->mi_id, $target, 'vtt');
	}
    
    public function __generate_link($link, $id, $download_name, $source) {
        $src = $this->source->determineBestServer($source, TRUE);
        if (is_null($src)) return $link;
        $data = static::Load($id);
        $this->source   ->MmItem($data);
        $data = $this->source->generateDownloadLink($src, $download_name);
        if (!empty($data)) $link = $data;
        return $link;
    }
    
    public function __notices() {
        $item = isset($_GET['info']) ? trim(wp_unslash($_GET['info'])) : '';
        $safemsg = htmlspecialchars($item);
        $safemsg = preg_replace_callback('~([0-9a-f]{8})~', function($m) {
            global $MEDIASEARCH;
            if (is_object($MEDIASEARCH)) {
                $link = "/wp-admin/admin.php?page=talks-single&amp;action=class&amp;cid={$m[0]}";
                return '<a href="' . $link . '" target="_blank">' . $m[0] . '</a>';
            } else {
                return $m[0];
            }
        }, $safemsg);
        
        if (strpos($item, 'SUCCESS') !== FALSE) {
            echo "<div class=\"updated notice\"><p>$safemsg</p></div>";
        } elseif ($item != '') {
            echo "<div class=\"error notice\"><p>$safemsg</p></div>";
        }
    }

    public function __get_contents($data, $id, $extension, $source) {
        // get alcor copy, if present. otherwise get a remote version
        $src = $this->source->determineBestServer($source, FALSE);
        if (is_null($src)) return $data;
        $dest = $this->source->getDestination($src, $id . '.' . $extension);
        $data = file_get_contents($dest);
        return $data;
    }
    
    public function _shortcode_archive($atts) {
        if (isset($atts['list'])) {
            $type = isset($atts['type']) ? explode(',', $atts['type']) : ['mt', 'cbt'];
            return $this->generateListing($atts['list'], $type);
        }
        return nl2br(htmlspecialchars(var_export($atts, 1)));
    }
    
    private function generateListing($listtype, $mediatype) {
		global $wpdb;
		if (is_array($mediatype)) {
			foreach($mediatype as &$item) {
				$item = '"' . esc_sql($item) . '"';
			}
			$where = 'i.mi_type in (' . join(', ', $mediatype) . ')';
		} else {
			$where = 'i.mi_type = "' . esc_sql($mediatype) . '"' ;
		}
		$column_count = 3;
        switch($listtype) {
            case 'speaker': // only include speakers that have spoken at the schools
                $sql = <<<BLOCK
select distinct ms_id as `key`, ms_firstname,ms_lastname,ms_suffix 
  from mm_speaker as s
     inner join mm_item as i on i.mi_speaker = s.ms_id and $where and (i.present_mp3<>0 or i.present_mp4<>0)
BLOCK;
				$column_count = 4;
                break;

            case 'subject':
                $sql = <<<BLOCK
select distinct me_topic as `key`
  from mm_item_x_event as x
     inner join mm_event as e on x.mixe_event = e.me_id
     inner join mm_item as i on x.mixe_item = i.mi_id and $where and (i.present_mp3<>0 or i.present_mp4<>0)
BLOCK;
				$column_count = 2;
                break;

            case 'location':
                $sql = <<<BLOCK
select distinct me_location as `key`
  from mm_item_x_event as x
     inner join mm_event as e on x.mixe_event = e.me_id
     inner join mm_item as i on x.mixe_item = i.mi_id and $where and (i.present_mp3<>0 or i.present_mp4<>0)
BLOCK;
				$column_count = 2;
                break;

            case 'myear':
                $sql = <<<BLOCK
select distinct me_year as `key`
  from mm_item_x_event as x
     inner join mm_event as e on x.mixe_event = e.me_id
     inner join mm_item as i on x.mixe_item = i.mi_id and $where and (i.present_mp3<>0 or i.present_mp4<>0)
BLOCK;
				$column_count = 6;
                break;

            default:
                return nl2br(htmlspecialchars(var_export($listtype, 1)));
        }
		$items = $wpdb->get_results( $sql );
        return $this->AsTable($items, $listtype, $column_count);
    }

    public function __sortByKey($a, $b) {
        $value_a = isset($a->value) ? $a->value : $a->key;
        $value_b = isset($b->value) ? $b->value : $b->key;
        return strcasecmp($value_a, $value_b);
    }
    
    private function _asTable_speaker(&$items) {
        foreach($items as &$item) {
            $item->value = static::AsSpeaker($item);
        }
    }

    private function _normalizeTitle($name) {
        $lname = strtolower($name);
        if (substr($lname, 0, 2) == 'a ') {
            $name = trim(substr($name, 2)) . ', A';
        } elseif (substr($lname, 0, 4) == 'the ') {
            $name = trim(substr($name, 4)) . ', The';
        }
        return $name;
    }

    private function _asTable_subject(&$items) {
        foreach($items as &$item) {
            $name = $item->key;
            $item->value = $this->_normalizeTitle($name);
        }
    }
    
    private function AsTable($items, $listtype, $columncount) {
        switch($listtype) {
            case 'speaker': // display surname first, sort by displayed value
                $this->_asTable_speaker($items);
                break;
            case 'subject': // remove 'a', 'the', sort by displayed value
                $this->_asTable_subject($items);
                break;
        }
        usort($items, [$this, '__sortByKey']);
        if ($listtype == 'myear') $items = array_reverse($items);
        $rowcount = ceil(count($items) / $columncount);
        $output = '';
        for($i = 0; $i < $rowcount; $i++) {
            $line = '';
            for($j = 0; $j < $columncount; $j++) {
                $index = $j * $rowcount + $i;
                if (isset($items[$index])) {
                    $item = $items[$index];
                    $safe_key = rawurlencode($item->key);
                    $value = isset($item->value) ? $item->value : $item->key;
                    $safe_name = htmlspecialchars($value);
                    $listingpage = self::LISTINGPAGE;
                    $line .= "<td><a href=\"{$listingpage}?{$listtype}={$safe_key}\">{$safe_name}</a></td>";
                }
            }
            $output .= "<tr>" . $line . "</tr>";
        }
        return $output;
    }
    
    // datatable with pagination - backend data
    public function _shortcode_table($att) {
        $params = '';
        foreach($_GET as $key => $value) {
            switch($key) {
                case 'speaker':
                case 'subject':
                case 'location':
                case 'myear':
                    $params .= ($params != '') ? '&' : '?';
                    $params .= rawurlencode($key) . '=' . rawurlencode($value);
            }
        }
        $data = [
            'pageLength' => 25,
            'dom' => 'lfrtip',
            'order' => [[ 1, 'desc' ]],
            'processing' => true,
            'serverSide' => true,
            'autoWidth' => false,
            'ajax' => '/~/moorestown/' . $params,
            'columns' => [
                [ 'data' => 'col1', 'width' => 450 ],
                [ 'data' => 'col2', 'width' => 150 ],
                [ 'data' => 'col3', 'width' => 294 ],
                [ 'data' => 'col4', 'width' => 56 , 'orderable' => FALSE],
            ],
        ];

        $jsontext = json_encode($data);
        return <<<BLOCK
<table class="datatable-audio">
<thead>
    <tr>
        <th>Title<br>Location</th>
        <th>Speaker<br>Date</th>
        <th>Series<br>Class</th>
        <th valign="top">Media</th>
    </tr>
</thead>
<tbody>
</tbody>
</table>
<script type="text/javascript">
var \$table = jQuery('table.datatable-audio');
mm_initDataTable(\$table, $jsontext);
</script>

BLOCK;

        return nl2br(htmlspecialchars(var_export($_GET, 1)));       
    }
    
	function add_css() {
		wp_enqueue_script('jquery');
        // https://code-boxx.com/upload-large-files-php/#sec-chunk
        wp_enqueue_style('plupload_css', plugins_url('plupload/jquery.ui.plupload.css', __FILE__), [], '3.1.3' );
		wp_enqueue_script('plupload_js', plugins_url('plupload/plupload.full.min.js', __FILE__), [], '3.1.3' );
		wp_enqueue_script('plupload_js2', plugins_url('plupload/jquery.ui.plupload.js', __FILE__), [], '3.1.3' );

		wp_enqueue_style('moorestown', plugins_url('moorestown.css', __FILE__), [], '0.8'); 
		wp_enqueue_script('moorestown_js', plugins_url('moorestown.js', __FILE__), [], '0.11' );
        wp_localize_script('moorestown_js', 'mediaManager', [
			'ajaxurl' => $this->ajaxurl,
		]);

		wp_enqueue_style('adult_class', plugins_url('jquery.dataTables.min.css', __FILE__), [], '1.10.21'); 
		wp_enqueue_script('adult_class_1', plugins_url('jquery.dataTables.min.js', __FILE__), [], '1.10.21' );

		wp_enqueue_style('moorestown_2', plugins_url('buttons.dataTables.min.css', __FILE__), [], '1.5.6'); 
		wp_enqueue_script('moorestown_2_js', plugins_url('dataTables.buttons.min.js', __FILE__), [], '1.5.6' );
		wp_enqueue_style('moorestown_3', plugins_url('dropzone/dropzone.min.css', __FILE__), [], '1.5.6'); 
		wp_enqueue_script('moorestown_3_js', plugins_url('dropzone/dropzone.min.js', __FILE__), [], '1.5.6' );

        # https://cdnjs.cloudflare.com/ajax/libs/jeditable.js/2.0.19/jquery.jeditable.js
        wp_enqueue_script('moorestown_4_js', 'https://cdnjs.cloudflare.com/ajax/libs/jeditable.js/2.0.19/jquery.jeditable.min.js', [], '2.0.19' );
        
    }

    protected function SaveRecord_Event() {
        global $wpdb;
        $me_id = (int) $_POST['me_id'];
        $posted = $_POST['_wpnonce'];
        if (!wp_verify_nonce($posted, 'media-manager:event:' . $me_id)) {
            return '';
        }
		if ($me_id) {
			// get the old event
			$did = "'" . esc_sql($me_id) . "'";
            $data = $wpdb->get_row("select me_playlist from mm_event where me_id=$did");
            $old_playlist = (int) $data->me_playlist;
		} else {
			$old_playlist = 0;
		}
        
		$payload = new \StdClass;
		$payload->event_id = $me_id;
        $payload->types = ['%d', '%s', '%s', '%s', '%s', '%d'];
        $payload->values = [
            'me_id' => NULL,
            'me_topic' => trim(wp_unslash($_POST['me_topic'])),
            'me_slug' => trim(wp_unslash($_POST['me_slug'])),
            'me_location' => trim(wp_unslash($_POST['me_location'])),
            'me_year' => trim(wp_unslash($_POST['me_year'])),
            'me_aslisting' => empty($_POST['me_aslisting']) ? 0 : 1,
            'me_showlink' => empty($_POST['me_showlink']) ? 0 : 1,
        ];
		do_action('event_save_before', $payload);

        if (!$me_id) {                    // create new record
            $result = $wpdb->insert('mm_event', $payload->values, $payload->types);
            $me_id = $wpdb->insert_id;
            $payload->event_id = $me_id;
        } else {                          // update
            unset($payload->values['me_id']);
            array_shift($payload->types);
            $result = $wpdb->update('mm_event', $payload->values, ['me_id' => $me_id], $payload->types);
        }

		do_action('event_save_after', $payload);

        return "/wp-admin/admin.php?page=talks-events&action=event&eid={$me_id}";
    }
    
    private function reindex_all() {
        global $wpdb, $MEDIASEARCH;

        if (is_object($MEDIASEARCH)) {
            set_time_limit(0);
			$sql = <<<BLOCK
select mi_id 
    from mm_item
where (present_mp3<>0 or present_mp4<>0) 
    and present_vtt<>0
BLOCK;
            $items = $wpdb->get_results($sql);
            foreach($items as $item) {
                echo "{$item->mi_id}...<br>\n";
				flush();
                $MEDIASEARCH->command_reindex($item->mi_id);
            }
        }
        echo "<br><br>Done\n";
    }
    
    // time-based hashes
    private function generateMediaHash() {
        global $wpdb;
        do {
            $data = random_bytes(20);
            $identifier = substr(md5($data), 10, 8);
            $data = $wpdb->get_row("select mi_id from mm_item where mi_id='$identifier'");
            $ok = is_null($data);
        } while(!$ok);
        return $identifier;
    }
    
    // convert a name to a number, adding it if needed
    private function retrieveSpeakerId($name) {
        global $wpdb;
		$suffix = NULL;
		if (($p = strpos($name, ',')) !== FALSE) {
			$suffix = substr($name, $p + 1);
			$name = substr($name, 0, $p);
		}
        if ($name == '') return NULL;
        $elements = explode(' ', $name, 3);
        $firstname = trim($elements[0]);
        $lastname = trim($elements[1]);
        $dfirst  = '"' . esc_sql($firstname) . '"';
        $dlast   = '"' . esc_sql($lastname) . '"';
        $dsuffix = is_null($suffix) ? ' is null' : ' = "' . $suffix . '"';
        $sql = <<<BLOCK
select ms_id
  from mm_speaker 
where ms_firstname=$dfirst and ms_lastname=$dlast and ms_suffix $dsuffix
BLOCK;
        $row = $wpdb->get_row($sql);
        if (!is_null($row)) {
            return $row->ms_id;
        }
        
        $wpdb->insert('mm_speaker', [
            'ms_id'         => NULL,
            'ms_firstname'  => $firstname,
            'ms_lastname'   => $lastname,
            'ms_suffix'     => $suffix,
            'ms_active'     => 1,
        ]);
        return $wpdb->insert_id;
    }
    
    protected function SaveRecord_Classes() {
        global $wpdb;
        $me_id = (int) $_POST['me_id'];         // required for new records
        $mi_id = trim($_POST['mi_id']);
        $isNew = $mi_id == '';
        $payload = new StdClass;
        if ($isNew) { // generate a new identifier
            if (empty($me_id)) {
                die('me_id is required for new records');
            }
            $mi_id = $this->generateMediaHash();
        } else {
            $temp = static::Load($mi_id);
            if (!is_null($temp) && isset($temp->_other)) {
                $payload = $temp->_other;
            }
        }
        $payload->youtube = trim(wp_unslash($_POST['_youtube']));

        $types = ['%s', '%d', '%s', '%s', '%s', '%s'];
        $values = [
            'mi_id'       => $mi_id,
            'mi_speaker'  => $this->retrieveSpeakerId(wp_unslash($_POST['_speaker'])),
            'mi_type'     => trim(wp_unslash($_POST['mi_type'])),
            'mi_title'    => trim(wp_unslash($_POST['mi_title'])),
            'mi_date'     => empty($_POST['mi_date']) ? NULL : trim(wp_unslash($_POST['mi_date'])),
            'mi_other'    => json_encode($payload, JSON_UNESCAPED_SLASHES),        // not for web
        ];
        if ($isNew) {                    // create new record
            $result = $wpdb->insert('mm_item', $values, $types);
            $seq = (int) $_POST['seq'];
            $this->addMixie($me_id, $seq, $mi_id);  // add mm_item_x_event record
        } else {                         // update
            unset($values['mi_id']);
            array_shift($types);
            $result = $wpdb->update('mm_item', $values, ['mi_id' => $mi_id], $types);
        }

        return "/wp-admin/admin.php?page=talks-single&action=class&cid={$mi_id}";
    }
    
    public function mmc_index() {
        global $wpdb;
        // select a/v item from datatable, or add new
        // select an event -> then select/add new.  Move item to event
        $action = empty($_GET['action']) ? '' : trim($_GET['action']);
        switch($action) {
            case 'import':
                //$this->import_wednesday();
                //$this->import_sermon();
                //$this->_import_speakers();
                echo "Imported.";
                return;
        
            case 'copy':
                $target = empty($_GET['target']) ? 4 : (int) $_GET['target'];
                $this->copyfiles($target);
                echo "<br>\nDone. Rerun until no files are copied";
                return;
        
            case 'event':
                $me_id = empty($_GET['eid']) ? 0 : (int) $_GET['eid'];
                return $this->action_event($me_id);

            case 'check-mt':
                return $this->report_missingitems();

            case 'repair':
//                return $this->repairdatabase();
                break;

            case 'seriesh':
//                return $this->import_seriesh();
                break;
                
            case 'fileinfo':
                $source = empty($_GET['source']) ? 0 : (int) $_GET['source'];
                if ($source > 0 && $source < 4) {
                    return $this->action_fileinfo($source);
                }
                break;
        }

        $data = [
            'pageLength' => 25,
            'dom' => 'lfrtipB',
            'order' => [[ 3, 'desc' ]],
            'autoWidth' => false,
            'ajax' => '/~/media-events',
            'columns' => [
				[ 'data' => 'me_id', 	     'width' => 80 ],
                [ 'data' => 'me_topic',      'className' => 'me-topic'],
                [ 'data' => 'me_slug',       'className' => 'me-slug' ],
                [ 'data' => 'me_year',       'width' => 90 ],
                [ 'data' => 'me_location',   'width' => 394 ],
                [ 'data' => 'me_playlistid', 'width' => 60 ],
                [ 'data' => 'c',             'width' => 60, 'defaultContent' => '.' ],
            ],
			'buttons' => [
				[
					'href' => '?page=talks-events&action=event&eid=0',
					'text' => '+',
					'hint' => 'Create a new event...',
					'tag'  => 'a',
				]
			],
        ];
        $jsontext = json_encode($data);
		
		if ($_SESSION['playlist_valid'] ?? 0) {
			$sidenote = "Since you've logged in as a YouTube admin, only selected <span title=\"Two or more talks on the same theme\">Series Events</span> are listed";
		} else {
			$sidenote = '';
		}
        
        echo <<<BLOCK
<h3>Events</h3>
<p>Please select an event from the list below, or <a href="?page=talks-events&amp;action=event&amp;eid=0">create a new one</a>. $sidenote</p>
<table class="datatable-event">
<thead>
    <tr>
	    <th>ID#</th>
        <th>Topic</th>
        <th>Slug</th>
        <th>Year</th>
        <th>Location</th>
        <th>Playlist</th>
		<th>#</th>
    </tr>
</thead>
<tbody>
</tbody>
</table>

<!-- <div id="upload_area" style="max-width:1200px">
    <div id="dzfile_1" class="mm-dropzone dropzone-frame dz-clickable" href="/~/media-fileupload/">
        <div class="dz-message needsclick">
            Drop file here or click to upload.
        </div>
    </div>
</div>
<p>Archive: <a href="?page=talks-events&amp;action=check-mt">Items missing audio and or captions</a></p>   -->

<script type="text/javascript">
var \$table = jQuery('table.datatable-event');
mm_initDataTable(\$table, $jsontext, 'event-wrapper');
</script>

BLOCK;
/*
<form method="post">
<input type="hidden" name="action" value="media_manager_class_youtube">
<br>Closed Captions<br>
<input type="text" class="regular-text ltr" name="youtube" placeholder="https://www.youtube.com/watch?v=xxxxxxxxxxx" value=""> <input type="submit" value=" upload ">
</form>
// drag-and-drop
<p><a href="?page=talks-main-page&amp;action=copy&target=4">Copy some files to cdn4</a></p>
<p><a href="?page=talks-main-page&amp;action=import">Import Stoughton Speakers</a></p>
<p><a href="?page=talks-main-page&amp;action=repair">Repair Durations</a></p>
<p><a href="?page=talks-main-page&amp;action=seriesh">Import cd_seriesh/cd_seriest</a></p>
<p><a href="?page=talks-main-page&amp;action=import">Import Sermons</a></p>
<p>FileInfo: <a href="?page=talks-main-page&amp;action=fileinfo&amp;source=1">alcor</a></p>
<p><a href="?page=talks-main-page&amp;action=import">Import Bible Classes</a></p>
<p><a href="?page=talks-main-page&amp;action=reindex">xapian: Reindex Everything</a></p>
<p><a href="?page=talks-main-page&amp;action=repair">Repair lookup table</a></p>
*/


    }

    public function mmc_exhort() {
        $data = [
            'pageLength' => 25,
            'dom' => 'lfrtipB',
            'order' => [[ 1, 'desc' ]],
            'autoWidth' => false,
            'ajax' => '/~/media-talks/ex',
            'columns' => [
                [ 'data' => 'mi_id',       'className' => 'mi-id'],
                [ 'data' => 'mi_date',     'className' => 'mi-date' ],
                [ 'data' => 'mi_speaker',  'className' => 'mi-speaker' ],
                [ 'data' => '_youtube',    'className' => 'mi-title'],
                [ 'data' => 'mi_flags',    'className' => 'mi-action' ],
            ],
            'buttons' => [
                [
                    'href' => '?page=talks-single&action=cygnus&eid='. static::EXHORT,
                    'text' => 'Cygnus',
                    'hint' => 'Import from Cygnus',
                    'tag'  => 'a',
                ], [
                    'href' => '?page=talks-single&action=class&eid='. static::EXHORT . '&cid=',
                    'text' => '+',
                    'hint' => 'Add exhort...',
                    'tag'  => 'a',
                ]
            ],
            
        ];
        $jsontext = json_encode($data);
		$safelink = htmlspecialchars('?page=talks-single&action=cygnus&eid='. static::EXHORT);

        echo <<<BLOCK
<h3>Exhorts</h3>
<p>Please select an exhort from the list below, import latest from <a href="$safelink">cygnus</a>, or create a new one.</p>
<table class="datatable-exhort">
<thead>
    <tr>
        <th>Id</th>
        <th>Date</th>
        <th>Speaker</th>
        <th>Original Url</th>
        <th>Actions</th>
    </tr>
</thead>
<tbody>
</tbody>
</table>

<script type="text/javascript">
var \$table = jQuery('table.datatable-exhort');
mm_initDataTable(\$table, $jsontext, 'exhort-wrapper');
</script>

BLOCK;

    }
	
	public function mmc_archive() {

        $data = [
            'pageLength' => 25,
            'dom' => 'lfrtipB',
            'order' => [[ 2, 'asc' ]],
            'autoWidth' => false,
            'ajax' => '/~/media-talks/archive',
            'columns' => [
                [ 'data' => '_size',       'className' => 'mi-size'],
                [ 'data' => 'mi_id',       'className' => 'mi-id'],
                [ 'data' => 'mi_date',     'className' => 'mi-date' ],
                [ 'data' => 'mi_type',     'className' => 'mi-type' ],
                [ 'data' => 'mi_title',    'className' => 'mi-title'],
                [ 'data' => 'mi_speaker',  'className' => 'mi-speaker' ],
            ],
            'buttons' => [
                [
                    'href' => '#',
                    'text' => 'Delete',
                    'hint' => 'Delete selected talks',
                    'tag'  => 'a',
					'className' => 'action-archive',
                ],
			]
        ];
        $jsontext = json_encode($data);

        echo <<<BLOCK
<h3>Cleanup</h3>
<p>The main server has limited storage. To free up space, please select the items to be removed from just the main server.</p>
<table class="datatable-archive">
<thead>
    <tr>
        <th>Size</th>
        <th>Id</th>
        <th>Date</th>
        <th>Type</th>
        <th>Title</th>
        <th>Speaker</th>
    </tr>
</thead>
<tbody>
</tbody>
</table>
<p>To appear in this list, each item must be uploaded to the Google Drive Shared Folder</p>

<script type="text/javascript">
var \$table = jQuery('table.datatable-archive');
mm_initDataTable(\$table, $jsontext, 'single-wrapper');
</script>

BLOCK;
	}

	protected function _getSpeakerDetails($ms_id) {
        global $wpdb;
		$did = "'" . esc_sql($ms_id) . "'";
		$sql = <<<BLOCK
select ms_id,ms_firstname,ms_lastname,ms_suffix,ms_active,ms_ecclesia,mb_name
	from mm_speaker
		left join mm_branch on ms_ecclesia=mb_id
where ms_id=$did
BLOCK;
        return  $wpdb->get_row($sql);
	}

	private function _speaker_detail($ms_id, $data) {
        global $wpdb;
		
		$rows = $wpdb->get_results('select mb_id,mb_name from mm_branch order by mb_name', ARRAY_A);
		$select = [];
		foreach($rows as $row) {
			$select[$row['mb_id']] = $row['mb_name'];
		}
		
		$fields = $this->FormField($data, 'ms_lastname',	'Last Name') .
				  $this->FormField($data, 'ms_firstname',	'First Name') .
				  $this->FormField($data, 'ms_suffix',		'Suffix') .
				  $this->FormField($data, 'ms_ecclesia',	'Ecclesia', [ 'select' => $select ]) .
                  $this->FormField($data, 'ms_active',		'Active/Living?', ['checkbox' => TRUE] );

		$payload = new \StdClass;
		$payload->fields = $fields;
		$payload->buttons = $this->submit_button('Save Changes', 'primary', 'submit', FALSE);
		$payload->data = $data;
        $nonce = wp_nonce_field( 'media-manager:speaker:' . $ms_id, '_wpnonce', FALSE, FALSE);
		$url = admin_url( 'admin.php' ) . '?page=speakers&action=' . $ms_id;

        echo <<<BLOCK
<h2 class="title">Speaker Details</h2>
<form method="post" action="$url">
<input type="hidden" name="action" value="speaker">
<input type="hidden" name="ms_id" value="{$ms_id}">
$nonce
<table class="form-table speaker-form" role="presentation">
<tbody>{$payload->fields}</tbody>
</table>
<p class="submit">
{$payload->buttons}
</p>
</form>
BLOCK;
		
	}

    public function mmc_speaker() {
        global $wpdb;
		// display list of speakers
        $action = $_GET['action'] ?? $_POST['action'] ?? '';
		$action = trim($action);
		if ($action != '') {
			$ms_id = (int) $action;
			if ($this->isPost && $ms_id) { 
				// update the record
				$types = [ '%s', '%s', '%s', '%d', '%d' ];
				$values = [
					'ms_lastname'	=> trim($_POST['ms_lastname'] ?? ''),
					'ms_firstname'	=> trim($_POST['ms_firstname'] ?? ''),
					'ms_suffix'		=> trim($_POST['ms_suffix'] ?? ''),
					'ms_ecclesia'	=> trim($_POST['ms_ecclesia'] ?? ''),
					'ms_active'		=> intval($_POST['ms_active'] ?? '0', 10),
				];
				$result = $wpdb->update('mm_speaker', $values, ['ms_id' => $ms_id], $types);
			}
			$info = $this->_getSpeakerDetails($ms_id);
			return $this->_speaker_detail($ms_id, $info);
		}

        $data = [
            'pageLength' => 25,
            'dom' => 'lfrtip',
            'order' => [[ 1, 'asc' ]],
            'autoWidth' => false,
            'ajax' => '/~/media-speaker',			// _command_datatables_speakerlist
            'columns' => [
                [ 'data' => 'ms_id',        'width' => 60,  'render' => ['_' => 'disp', 'sort' => 'val'],  'className' => 'textright' ],
                [ 'data' => 'ms_lastname',  'width' => 120 ],
                [ 'data' => 'ms_firstname', 'width' => 120 ],
                [ 'data' => 'ms_suffix',    'width' => 60 ],
                [ 'data' => 'mb_name',      'width' => 350],
                [ 'data' => 'ms_active',    'width' => 50],
            ],
        ];
        $jsontext = json_encode($data);
		
        echo <<<BLOCK
<h3>Talks</h3>
<p>Please select a speaker from the list below. New speakers are created when creating a Talk</p>
<div style="width: 800px">
<table class="datatable-single speaker-list" style="margin:0">
<thead>
    <tr>
        <th>ID</th>
        <th>Last Name</th>
        <th>First Name</th>
        <th>Suffix</th>
        <th>Ecclesia</th>
        <th>Active?</th>
    </tr>
</thead>
<tbody>
</tbody>
</table>
</div>

<script type="text/javascript">
var \$table = jQuery('table.datatable-single');
mm_initDataTable(\$table, $jsontext, 'single-wrapper');
</script>

BLOCK;
	}
    
    public function mmc_single() {
        global $wpdb;

        $action = empty($_GET['action']) ? '' : trim($_GET['action']);
        switch($action) {

            case 'class':
                $me_id = empty($_GET['eid']) ? 0 : (int) $_GET['eid'];
                $mi_id = empty($_GET['cid']) ? '' : trim($_GET['cid']);
                $seq = empty($_GET['seq']) ? '' : trim($_GET['seq']);
                return $this->action_class($mi_id, $me_id, $seq);
        
            case 'cygnus':			// if you mess up, you can re-import without causing any problems
                $me_id = empty($_GET['eid']) ? 0 : (int) $_GET['eid'];
                return $this->action_import_cygnus($me_id);

            case 'cygnus2':
                $me_id = empty($_GET['eid']) ? 0 : (int) $_GET['eid'];
                $this->action_import_cygnus2($me_id);
				echo "Done\n";
				return;
		
			case 'googledrive':
                $mi_id = empty($_GET['cid']) ? '' : trim($_GET['cid']);
                return $this->action_googledrive($mi_id);
				
			case 'whisper':
                $mi_id = empty($_GET['cid']) ? '' : trim($_GET['cid']);
                return $this->action_whisper($mi_id);
        }

        $data = [
            'pageLength' => 25,
            'dom' => 'lfrtip',
            'order' => [[ 1, 'desc' ]],
            'autoWidth' => false,
            'ajax' => '/~/media-talks',
            'columns' => [
                [ 'data' => 'mi_id',       'className' => 'mi-id'],
                [ 'data' => 'mi_date',     'className' => 'mi-date' ],
                [ 'data' => 'mi_type',     'className' => 'mi-type' ],
                [ 'data' => 'me_topic',    'className' => 'me-topic'],
                [ 'data' => 'mi_title',    'className' => 'mi-title'],
                [ 'data' => 'mi_speaker',  'className' => 'mi-speaker' ],
                [ 'data' => 'mi_flags',    'className' => 'mi-action' ],
            ],
        ];
        $jsontext = json_encode($data);

		$safelink = htmlspecialchars('?page=talks-single&action=cygnus&eid='. static::EXHORT);
        echo <<<BLOCK
<h3>Talks</h3>
<p>Please select a talk from the list below. New talks are created from the <a href="?page=talks-events">Event page</a>. Exhortations have their own page; import the latest from <a href="$safelink">cygnus</a>.</p>
<table class="datatable-single">
<thead>
    <tr>
        <th>Id</th>
        <th>Date</th>
        <th>Type</th>
        <th>Topic</th>
        <th>Title</th>
        <th>Speaker</th>
        <th>Actions</th>
    </tr>
</thead>
<tbody>
</tbody>
</table>

<script type="text/javascript">
var \$table = jQuery('table.datatable-single');
mm_initDataTable(\$table, $jsontext, 'single-wrapper');
</script>

BLOCK;

    }
	
	// upload a vtt file
	public function mmc_vtt() {
		$name = 'vtt_file';
		$href = '?#';
		echo <<<BLOCK
<h3>Upload a VTT file</h3>
<p>This page is used to update the filesystem and the database whenever a captioning file is recreated. The name of the .vtt file to be uploaded must contain the 8-character Item Id.</p>
<div id="vtt_upload_area" style="margin-bottom: 30px">
	<div style="width:800px;height:50px;" class="mm-dropzone dropzone-frame multiple" name="{$name}" href="{$href}" oncomplete="vtt_complete">
		<div class="dz-message needsclick">
			Drop vtt file(s) here or click to upload.
		</div>
	</div>
</div>
<div class="vtt-status">
</div>
<script type="text/javascript">
function vtt_complete(file, response) {
	let \$div = jQuery('.vtt-status').first();
	\$div.append(`<div>\${file.name} - \${file.status}</div>`);
};
</script>

BLOCK;
		// files are processed at 
	}

    private function _breakName($name) {
        $result = new StdClass;
        $result->suffix = FALSE;
        if (($p = strpos($name, ',')) !== FALSE) {
            $result->suffix = strtolower(trim(substr($name, $p + 1), '. '));
            $name = substr($name, 0, $p);
        }
        $p = strpos($name, ' ');
        $result->first = trim(substr($name, 0, $p));
        $result->last = trim(substr($name, $p + 1));
        return $result;
    }

    private function _dataTables_params() {
        $result = new StdClass;
        $result->draw = isset($_GET['draw']) ? $_GET['draw'] : 1;
        $result->start = isset($_GET['start']) ? (int) $_GET['start'] : 0;
        $result->length = isset($_GET['length']) ? (int) $_GET['length'] : 0;
        if (!$result->length) $result->length = 25;
        $result->search = isset($_GET['search']) ? trim($_GET['search']['value']) : '';
        $result->sorting = $this->getSorting($_GET['columns'], $_GET['order']);
        return $result;
    }

    private function _command_datatables_categorylist($dtparam) {
        $params = [];
        foreach($_GET as $key => $value) {
            switch($key) {
                case 'speaker':
                case 'subject':
                case 'location':
                case 'myear':
                    $params[$key] = $value;
            }
        }
        $result = $this->getAudioItems($params, $dtparam);
        $result['draw'] = (int) $dtparam->draw;

        header('Content-type: application/json');
        die(json_encode($result, JSON_UNESCAPED_SLASHES));
    }
    
	private function _command_datatables_speakerlist() {
        global $wpdb;
		$sql = <<<'BLOCK'
select ms_id,ms_firstname,ms_lastname,ms_suffix,ms_active,mb_name 
	from mm_speaker
		left join mm_branch on ms_ecclesia=mb_id
BLOCK;
        $rows = $wpdb->get_results($sql, ARRAY_A);
		$url = admin_url( 'admin.php' ) . '?page=speakers&amp;action=';
        foreach($rows as &$row) {
			$row['ms_id'] = [
				'disp' => "<a href=\"{$url}{$row['ms_id']}\" target=\"_speaker\">{$row['ms_id']}</a>",
				'val'  => $row['ms_id'],
			];
        }
		$result = ['data' => $rows];
        header('Content-type: application/json');
        die(json_encode($result, JSON_UNESCAPED_SLASHES));
	}
	
    private function _command_datatables_eventlist() {
        global $wpdb;
		if ($_SESSION['playlist_valid'] ?? 0) {
			$sql = <<<BLOCK
select me_id,me_location,me_slug,me_topic,me_year,me_playlistid, 
		count(mixe_item) as c 
  from `mm_event` 
    right join mm_item_x_event on mixe_event=me_id
	left  join mm_item on mixe_item=mi_id
where (mi_type="ac" or mi_type="bc") 
group by me_id
having c > 1
order by me_year desc,me_id desc
BLOCK;
		} else {
			$sql = <<<BLOCK
select me_id,me_location,me_slug,me_topic,me_year,me_playlistid, 
		count(mixe_item) as c 
  from `mm_event` 
    right join mm_item_x_event on mixe_event=me_id
group by me_id
order by me_year desc,me_id desc

BLOCK;
		}
		// and (mi_other like "%youtube.com%" or mi_other like "%youtu.be%")
		
        $rows = $wpdb->get_results($sql, ARRAY_A);
        foreach($rows as &$row) {
			$j = empty($row['mi_other']) ? [] : json_decode($row['mi_other'], TRUE);
			unset($row['mi_other']);
            $row['me_topic'] = $this->_normalizeTitle($row['me_topic']);
			if (empty($row['me_playlistid'])) $row['me_playlistid'] = '';
        }
        $result = ['data' => $rows];
        header('Content-type: application/json');
        die(json_encode($result, JSON_UNESCAPED_SLASHES));
    }

	// large files to be moved from cdn1 to cdn2
	private function _command_datatables_archive() {
		$source = 1 << static::ARIES;		// found on this
//		$nsource = static::ARIES;
		$target = 1 << static::GOOGLE;		// and found on this

		global $wpdb;
		// get list of all talks having video on cdn1 but not on cdn3
		// sort by size, return top 100
        $sql = <<<BLOCK
select mi_id,mi_type,mi_date,mi_title,ms_firstname,ms_lastname,ms_suffix,mi_other,
		mm_filesize as _size
from    mm_media as m     
  left join mm_item as i on m.mm_hash = i.mi_id 
  left join mm_speaker as s on s.ms_id = i.mi_speaker
where (((present_mp4 & $source) <> 0  and (present_mp4 & $target) <> 0 )
	or	((present_mp3 & $source) <> 0  and (present_mp3 & $target) <> 0 ))
  and  mm_source = 1 
  and (m.mm_type = 'mp4' or m.mm_type = 'mp3')
order by mi_date desc
limit 400
BLOCK;

        $rows = $wpdb->get_results($sql);
        foreach($rows as &$row) {
            $row->mi_speaker = static::AsSpeaker($row);
		}
		return $rows;
	}

    // list all classes for an event - in appropriate order
    //   or list all classes
    private function _command_datatables_classeslist($me_id, $allowzero = FALSE, $exhortonly = FALSE) {
        global $wpdb, $MEDIASEARCH;
		
		// get all calendar events
		$calendar = [];
		if (defined('WP_SPIFFYCAL_TABLE')) {
			$table = $wpdb->get_blog_prefix().WP_SPIFFYCAL_TABLE;
			$date = gmdate('Y-m-d', time() + 2 * 86400);
			$sql = "select event_begin,event_category,event_link from $table where event_category<>1 and event_begin <= '$date'";
			$rows = $wpdb->get_results($sql);
			foreach($rows as $row) {
				$category = $row->event_category;
				$calendar["{$row->event_begin}:{$row->event_category}"] = $row->event_link;
			}
		}
		
        $isAdmin = current_user_can('administrator');
        $did = "'" . esc_sql($me_id) . "'";
        if ($allowzero) {
//			$cutoff = "'" . esc_sql(date('Y-m-d', time() - 4 * 86400 * 366)) . "'";
			$cutoff = "'" . esc_sql(date('Y-m-d', time() - 20 * 86400 * 366)) . "'";
            $where = "mi_date > $cutoff";
            $extra = ',mixe_event,me_topic';
        } else {
            $where = "mixe_event=$did";
            $extra = '';
        }
        if ($exhortonly) {
            $where .= ' and mi_type="ex" and x.mixe_event=' . static::EXHORT;
        } else {
//            $where .= ' and mi_type<>"ex"';
        }
		// remove cutoff from the sql to list everything
        $sql = <<<BLOCK
select mi_id,mi_type,mi_date,mi_title,ms_firstname,ms_lastname,ms_suffix,mi_other,mixe_sequence,
        length(mi_thumbnail) as ltn, mg.mm_type as mg_type, mv.mm_type as mv_type,
        present_mp4,present_mp3,present_vtt$extra
  from mm_item_x_event as x
    left join mm_item as i    on x.mixe_item = i.mi_id
    left join mm_speaker as s on s.ms_id = i.mi_speaker
    left join mm_event as e   on e.me_id = x.mixe_event
	
	left join mm_media as mg  on mg.mm_hash = x.mixe_item and mg.mm_source=5 and (mg.mm_type="mp3" or mg.mm_type="mp4")
	left join mm_media as mv  on mv.mm_hash = x.mixe_item and mv.mm_source=5 and (mv.mm_type="vtt")
where $where
order by mixe_event, mixe_sequence
BLOCK;

        $rows = $wpdb->get_results($sql);
        foreach($rows as &$row) {
            $row->mi_speaker = static::AsSpeaker($row);
            $other = json_decode($row->mi_other);
            if (is_object($MEDIASEARCH)) {
                $row->media = $MEDIASEARCH->generateAuth($row->mi_type, $row->mi_id, TRUE) . '#' . $row->mi_type . '_' .rawurlencode($row->mi_id);
            }
            if ($isAdmin) {
                $this->source   ->MmItem($row);
                $row->auth = $this->source->generateAuth('***');        // being deleted
            }
            $row = (array) $row;
            $row['mi_flags'] = [
                'vtt' => $row['present_vtt'],
                'ltn' => (int) $row['ltn'],
            ];
            $row['_youtube'] = empty($other->youtube) ? '' : $other->youtube;
			$temp = ParseVideoUrl($row['_youtube']);
			if ($temp->source == 'youtube' && $temp->id !== FALSE) {
				$row['_edit'] = "https://studio.youtube.com/video/{$temp->id}/edit";
			}
            unset($row['present_vtt'], $row['ltn'], $row['mi_other'], $row['ms_firstname'], $row['ms_lastname'], $row['ms_suffix']);
			if ($row['mi_date'] > '2022-01-01') {		// this is when we started
				if ($row['mi_type'] == 'ex') {
					$key = "{$row['mi_date']}:2";
				} elseif ($row['mi_type'] == 'ac') {
					$key = "{$row['mi_date']}:3";
				} elseif ($row['mi_type'] == 'bc') {
					$key = "{$row['mi_date']}:4";
				} else {
					$key = '';
				}
				if ($key != '' && isset($calendar[$key])) {
					$incalendar = substr($calendar[$key], -6) == '/video' ? 1 : 2;
					$row['calendar'] = $incalendar;
				}
			}
			if (!empty($row['mg_type']) && !empty($row['mg_type'])) {
				$row['google'] = 2;
			} elseif (empty($row['mg_type']) && empty($row['mg_type'])) {
				$row['google'] = 0;
			} else {
				$row['google'] = 1;
			}
			$row['mi_date'] = str_replace('-', '.', $row['mi_date']);
        }
		
		return $rows;
    }
    
    private function _command_datatables_medialist($mi_id) {
        $rows = [];
        if ($mi_id != '') {
            $data = static::Load($mi_id, ['media' => TRUE]);
            $media = $data->_media;
            
            $basename = isset($data->_other->original) ? $data->_other->original : '';
            if (($p = strrpos($basename, '.')) !== FALSE) {
                $basename = substr($basename, 0, $p);
            }

            // which servers have which files?
            $rows = $this->source
                         ->BaseFilename($basename)
                         ->MmItem($data)
                         ->Media($media)
                         ->getListing();
        }
        
        header('Content-type: application/json');
        $result = ['data' => $rows];
        echo json_encode($result, JSON_UNESCAPED_SLASHES);
        exit;
    }

    private function _escape($text) {
        global $wpdb;
        return '"' . '' . '"';
    }

    private function _command_autocomplete($q, $surname_first = FALSE) {
        global $wpdb;
//        $dquery = '"' . $wpdb->remove_placeholder_escape(esc_sql("%$q%")) . '"';
        $dquery = esc_sql($q);
        $namefield = $surname_first 
                    ? 'concat(ms_lastname, " ", ms_firstname)'
                    : 'concat(ms_firstname, " ", ms_lastname)';
		if (strlen($q) == 2 && !$surname_first) {
			$fi = esc_sql($q[0]);
			$li = esc_sql($q[1]);
			$sql = <<<BLOCK
select *,
	$namefield as spk
  from mm_speaker
where ms_firstname like "{$fi}%" and ms_lastname like "{$li}%"
BLOCK;

		} else {
			$sql = <<<BLOCK
select *,
    $namefield as spk
  from mm_speaker
where $namefield like "%{$dquery}%"
BLOCK;
		}
        if (!$surname_first) $sql .= ' and ms_active=1';

        $rows = $wpdb->get_results($sql);
        $data = [];
        foreach($rows as $row) {
            $speaker = $surname_first 
                        ? static::AsSpeaker($row)
                        : trim($row->spk);
            $node = [
                'id' => $row->ms_id,
                'value' => $speaker,
				'home' => $row->ms_ecclesia,
            ];
			if ($row->ms_ecclesia == 1) $node['class'] = 'hilite';
			$data[] = $node;
        }
		usort($data, function($a, $b) {
			$x = $a['home'] - $b['home'];
			if ($x) return $x;
			return strcasecmp($a['value'], $b['value']);
		});
        header('Content-type: application/json');
        echo json_encode($data, JSON_UNESCAPED_SLASHES);
        exit;
    }

    private function _command_autocomplete_id($q) {
        global $wpdb;
        $dquery = '"' . esc_sql("$q%") . '"';
        $sql = <<<BLOCK
select mi_id, mi_date, ms_firstname,ms_lastname,ms_suffix
  from mm_item
    left join mm_speaker on mi_speaker=ms_id
where mi_id like $dquery
BLOCK;
        $rows = $wpdb->get_results($sql);
        $data = [];
        foreach($rows as $row) {
            $speaker = static::AsSpeaker($row);
            if (!empty($row->mi_date)) $speaker .= " ({$row->mi_date})";
            $data[] = [
                'id' => $row->mi_id,
                'value' => "{$row->mi_id} - $speaker",
            ];
        }
        header('Content-type: application/json');
        echo json_encode($data, JSON_UNESCAPED_SLASHES);
        exit;
    }
    
    // if mi_id is specified (and exists), add entry to mm_item_x_event and return to page
    // otherwise continue to add-new-talk page
    private function _command_new_talk($me_id, $sequence, $mi_id) {
        global $wpdb;
        if ($mi_id != '') {
            $data = static::Load($mi_id);
            if (!is_null($data)) {  // it's good
                $this->addMixie($me_id, $sequence, $mi_id);
                header("Location: /wp-admin/admin.php?page=talks-events&action=event&eid={$me_id}");
                exit;
            }
        }
        $dseq = rawurlencode($sequence);
        header("Location: /wp-admin/admin.php?page=talks-single&action=class&eid={$me_id}&cid=&seq={$dseq}");
        exit;
    }

    private function _form_option($option) {
        return esc_attr( get_option( $option ) );
    }
    
    private function _e( $text, $domain = 'default' ) {
        return translate( $text, $domain );
    }
    
    private function _generateEventList($events, $option, $class, $type, $id) {
        global $MEDIASEARCH;
        $result = '';
        foreach($events as $index => $event) {
            $value = $event->me_topic . ' - ' . $event->me_location . ' ('  . $event->me_year . ')';
            $dvalue = esc_html($value);

            $external = '#';
            if ($event->me_id == static::EXHORT) {
                $link2 = '/wp-admin/admin.php?page=talks-exhort';
                if (is_object($MEDIASEARCH)) {
                    $external = $MEDIASEARCH->generateAuth($type, $id, TRUE) . '#' . $type . '_' .rawurlencode($id);
                }
            } else {
                $link2 = '?page=talks-events&action=event&eid=' . $event->me_id;
                // this is not right - it should show all items in the specified event
                if (is_object($MEDIASEARCH)) {
                    $external = $MEDIASEARCH->generateAuth($event->me_id, $id, TRUE) . '#' . 'ev' . '_' .rawurlencode($id);
                }
            }
            
            $result .= <<<BLOCK
        <div>
            <a href="$link2" id="{$option}_{$index}" class="readonly regular-text ltr $class">$dvalue</a>[{$event->mixe_sequence}] <a 
               href="$external" target="clientversion"><i class="fas fa-link fa-lg" title="client view"></i></a>
        </div>
BLOCK;
        }

        return $result;
    }

    public function FormField($data, $option, $caption, $param = []) {
        $val = $data->{$option};
        $txt = $this->_e($caption);
        $class = str_replace('_', '-', $option);

        $inputtype = isset($param['type']) ? $param['type'] : '';
        if ($inputtype == '') $inputtype = 'text';
        $extra = '';
        if (isset($param['autocomplete'])) {
            $class .= ' mm-autocomplete';
            $extra = ' href="' . $param['autocomplete'] . '"';
        }
        if (isset($param['validate'])) {
            $class .= ' mm-validate';
            $extra = ' href="' . $param['validate'] . '"';
            if (!isset($param['suffix'])) $param['suffix'] = '';
            $param['suffix'] .= ' <i class="state1 far fa-check-circle fa-lg" style="color:#090;display:none;"></i><i class="state0 far fa-times-circle fa-lg" style="color:#900;display:none;"></i>';
        }
        if (isset($param['className'])) {
            $class .= ' ' . $param['className'];
        }

        $dval = is_array($val) ? '' : esc_attr($val);
        $field = <<<BLOCK
<input name="$option" type="$inputtype" id="$option" value="$dval" class="regular-text ltr $class"$extra />
BLOCK;
        if (!empty($param['checkbox'])) {
            $sel = empty($val) ? '' : ' checked';
			$suffix = isset($param['suffix']) ? ' &nbsp; &nbsp; ' . $param['suffix'] : '';
            return <<<BLOCK
<tr>
    <th scope="row"></th>
    <td><label><input type="checkbox" name="$option" value="1"$sel> $txt</label>$suffix</td>
</tr>
BLOCK;

        } elseif (isset($param['select'])) {
            $output = '';
            foreach($param['select'] as $key => $value) {
                $dkey = esc_attr($key);
                $dvalue = esc_html($value);
                $sel = $key == $val ? ' selected' : '';
                $output .= "<option value=\"$dkey\"$sel>$dvalue</option>";
            }
            $field = <<<BLOCK
<select name="$option" class="regular-text ltr $class">
$output
</select>
BLOCK;
        } else if (!empty($param['events']) && is_array($param['events'])) {
            $field = $this->_generateEventList($val, $option, $class, $param['events']['type'], $param['events']['id']);
        } else if (!empty($param['readonly'])) {
            $dvalue = esc_html($val);
            if (is_string($param['readonly'])) {
                $durl = esc_html($param['readonly']);
                $field = <<<BLOCK
            <a href="$durl" id="$option" class="readonly regular-text ltr $class">$dvalue</a>
BLOCK;
            } else {
                $field = <<<BLOCK
            <span id="$option" class="readonly regular-text ltr $class">$dvalue</span>
BLOCK;
            }
        }
        if (isset($param['suffix'])) {
            $field .= $param['suffix'];
        }
        
        return <<<BLOCK
<tr>
    <th scope="row"><label for="$option">$txt</label></th>
    <td>$field</td>
</tr>
BLOCK;

    }

    function submit_button( $text = null, $type = 'primary', $name = 'submit', $wrap = true, $other_attributes = null ) {
        return get_submit_button( $text, $type, $name, $wrap, $other_attributes );
    }

    private function action_event($me_id) {
        global $wpdb;
        if ($me_id) {
            $did = "'" . esc_sql($me_id) . "'";
            $data = $wpdb->get_row("select * from mm_event where me_id=$did");
            $me_id = (int) $me_id;
        } else {
            $data = NULL;
        }
        if (is_null($data)) {
            $data = new StdClass;
            $data->me_topic = '';
            $data->me_slug = '';
            $data->me_location = 'Stoughton';
            $data->me_year = date('Y');
            $data->me_aslisting = 0;
            $data->me_showlink = 1;
            $data->__existing = FALSE;
        } else {
            $data->__existing = TRUE;
        }
        
        if ($data->me_slug != '') {
            $suffix = '<a href="/' . $data->me_slug . '" target="extra" style="margin-left:12px"><i class="fas fa-link fa-lg"></i></a>';
        } else {
            $suffix = '';
        }
        $sql = "select max(mixe_sequence) as mx from `mm_item_x_event` where mixe_event={$me_id}";
        $temp = $wpdb->get_row($sql);
        $next_sequence = $temp->mx + 1;     // not for exhorts, which should be based on todays date
        
        // wp-admin/options-writing
        $fields = $this->FormField($data, 'me_topic',    'Topic') .
                  $this->FormField($data, 'me_slug',     'Slug',      ['suffix' => $suffix, 'validate' => '/~/media-validate/' . $me_id]) .
                  $this->FormField($data, 'me_location', 'Location') .       // combobox allowing paste of existing locations
                  $this->FormField($data, 'me_year',     'Year') .           // default to current year on new record
                  $this->FormField($data, 'me_aslisting','Display as a Listing', ['checkbox' => TRUE] ) .
                  $this->FormField($data, 'me_showlink', 'Show Original URL', ['checkbox' => TRUE] );
				  
		$payload = new \StdClass;
		$payload->fields = $fields;
		$payload->buttons = $this->submit_button('Save Changes', 'primary', 'submit', FALSE);
		$payload->data = $data;
		do_action('event_fields', $payload, $this);

        $nonce = wp_nonce_field( 'media-manager:event:' . $me_id, '_wpnonce', FALSE, FALSE);
        echo <<<BLOCK
<h2 class="title">Event Details</h2>
<form method="post" action="options-general.php?page=talks-events&amp;action=event">
<input type="hidden" name="action" value="media_manager_class_event">
<input type="hidden" name="me_id" value="{$me_id}">
$nonce
<table class="form-table media-event-form" role="presentation">
<tbody>{$payload->fields}</tbody>
</table>
<p class="submit">
{$payload->buttons}
</p>
</form>
BLOCK;

        if ($data->__existing) {
            $data = [
                'x_me_id' => $me_id,
                'x_admin' => current_user_can('administrator') ? 1 : 0,
                'x_sequence' => $next_sequence,
                'pageLength' => 25,
                'dom' => 'lfrtipB<"newstuff">',
                'autoWidth' => false,
                'ajax' => '/~/media-classes/' . $me_id,
                'columns' => [
//                    [ 'data' => 'mi_thumb',    'className' => 'me-topic'],
                    [ 'data' => 'mixe_sequence', 'className' => 'mi-sequence'],
                    [ 'data' => 'mi_date',       'className' => 'mi-date' ],
                    [ 'data' => 'mi_title',      'className' => 'mi-title'],
                    [ 'data' => 'mi_speaker',    'className' => 'mi-speaker' ],
                    [ 'data' => '_youtube',      'className' => 'mi-title'],
                    [ 'data' => 'mi_flags',      'className' => 'mi-action', 'width' => '210px' ],
                ],
                'buttons' => [
                    [
                        //'src'  => '?page=talks-single&action=class&eid='. $me_id . '&cid=',
                        'href' => '#',
                        'text' => '+',
                        'hint' => 'Create new talk...',
                        'tag'  => 'a',
                        'className' => 'newtalk',
                    ], [
                        'href' => '/~/media-archive/' . $me_id,
                        'text' => '<i class="fa fa-archive" aria-hidden="true"></i>',
                        'hint' => 'archive all',
                        'tag'  => 'a',
                        'className' => 'confirm-archive',
					]
                ],
            ];
            $jsontext = json_encode($data);
            // actions - edit, hasCC, link to watch/listen page
//        <th>Thumb?</th>

            echo <<<BLOCK

<table class="datatable-classes">
<thead>
    <tr>
        <th>#</th>
        <th>Date</th>
        <th>Title</th>
        <th>Speaker</th>
        <th>Original Url</th>
        <th>Actions</th>
    </tr>
</thead>
<tbody>
</tbody>
</table>
<script type="text/javascript">
var \$table = jQuery('table.datatable-classes');
mm_initDataTable(\$table, $jsontext, 'classes-wrapper');
</script>

BLOCK;
        }
    }

    static
    private function AsSpeaker($item) {
//        $value = trim($item->ms_lastname . ', ' . $item->ms_firstname);
        $value = trim($item->ms_firstname . ' ' . $item->ms_lastname);
        if (!empty($item->ms_suffix)) $value .= ', ' . $item->ms_suffix;
        return $value == ',' ? '-' : $value;
    }

    static
    public function Load($mi_id, $options = []) {
        global $wpdb;
        $include_media = array_key_exists('media', $options) ? $options['media'] : FALSE;
        $first         = array_key_exists('first', $options) ? $options['first'] : TRUE;
        $thumb         = array_key_exists('thumb', $options) ? $options['thumb'] : $first;      // thumbnail wanted?
        $order         = array_key_exists('order', $options) ? $options['order'] : '';
        $group         = array_key_exists('group', $options) ? $options['group'] : '';
        $limit         = array_key_exists('limit', $options) ? (int) $options['limit'] : 0;
        $debug         = array_key_exists('debug', $options) ? (int) $options['debug'] : 0;

        if (is_string($mi_id) || is_numeric($mi_id)) {
            $where = 'mixe_item="' . esc_sql($mi_id) . '"';
        } elseif (is_array($mi_id)) {
            $where = join(' and ', $mi_id);
        } else {
            die('Unable to WHERE ' . var_export($mi_id, 1));
        }
        $thumb = $thumb 
            ? '*'
            : 'mixe_event,mixe_sequence,mixe_item,'.
              'mi_id,mi_type,mi_date,mi_speaker,mi_title,mi_other,present_mp3,present_mp4,present_vtt,present_jpg,present_xml,'.
              'ms_firstname,ms_lastname,ms_suffix,'.
              'me_year,me_location,me_topic,me_slug,me_aslisting,me_showlink';
        $sql = <<<BLOCK
select $thumb, 
       length(mi_thumbnail) as ltn,
       max(mm_duration) as `duration`
  from mm_item_x_event as x
    inner join mm_item as i on i.mi_id = x.mixe_item
    left join mm_speaker as s on s.ms_id = i.mi_speaker
    left join mm_event as e on e.me_id = x.mixe_event
    left join mm_media as m on m.mm_hash = x.mixe_item and (m.mm_type="mp3" or m.mm_type="mp4")
where $where
BLOCK;
        // max(mm_duration) as duration
        if ($group != '') $group = ", $group";
        if (is_string($mi_id)) {
            $sql .= ' group by x.mixe_event, m.mm_hash '. $group;
        } else {
            $sql .= ' group by m.mm_hash '. $group;
        }
        if ($order != '') $sql .= ' order by '. $order;
        if ($first) {
            $sql .= ' limit 3';     // talk may be in upto 3 events
        } elseif ($limit) {
            $sql .= ' limit ' . $limit;
        }

        if ($debug) {
            echo $sql . "\n";
        }
        $rows = $wpdb->get_results($sql);
        if (is_null($rows) || !count($rows)) {
            return NULL;
        }
        
        $ids = [];
        foreach($rows as $index => $row) {
            $event = new StdClass;
            $event->me_id         = $row->me_id ?? 0;
            $event->me_year       = $row->me_year;
            $event->me_location   = $row->me_location;
            $event->me_topic      = $row->me_topic;
            $event->me_slug       = $row->me_slug;
            $event->me_aslisting  = $row->me_aslisting;
            $event->me_showlink   = $row->me_showlink;
            $event->mixe_sequence = $row->mixe_sequence;
            if (isset($ids[$row->mi_id])) {     // this talk is part of another event
                unset($rows[$index]);
                $row = $rows[$ids[$row->mi_id]];
                $row->_event[] = $event;
            } else {
                $ids[$row->mi_id] = $index;
				if (empty($row->mi_other)) {
					$row->_other = new stdClass;
				} else {
					$row->_other = json_decode($row->mi_other);
				}
                $row->_speaker = static::AsSpeaker($row);
                $row->_media = [];
                $row->_event = [$event];
            }
        }

        if ($include_media) {
            $list = array_map(function($x) {
                return '"' . esc_sql($x) . '"';
            }, array_keys($ids));
            $list = join(',', $list);
            $medias = $wpdb->get_results("select * from mm_media where mm_hash in ($list) order by mm_hash,mm_type,mm_source");
            foreach($medias as $item) {
                $hash = $item->mm_hash;
                $item->mm_other = json_decode($item->mm_other);
                $index = $rows[$ids[$hash]]->_media[] = $item;
            }
        }
        return ($first) ? $rows[0] : $rows;
    }

    private function action_class_update($mi_id, $sig) {
        global $wpdb;
        header('Content-type: text/plain');
        $data = static::Load($mi_id);
        
        $tar = $data->mi_id . '.mp4';
        $expected_sig = substr(md5('its-ok:' . $tar), 12, 8);
        if ($expected_sig == $sig && !is_null($data)) {
            $data->present_mp4 |= 1 << static::HOSTZ;
            if ($data->mi_type == 'ac') {
                $data->_other->original = str_replace('-', '.', $data->mi_date) . ' ac.mp4';
            }
            
            $did = '"' . esc_sql($data->mi_id) . '"';
            $safeother = '"' . esc_sql(json_encode($data->_other, JSON_UNESCAPED_SLASHES)) . '"';
            $wpdb->Query("update mm_item set present_mp4={$data->present_mp4},mi_other={$safeother} where mi_id=$did");

            // redirect back again
            header('Location: /wp-admin/admin.php?page=talks-single&action=class&cid=' . $mi_id);
            exit;
        }
        die('Unauthorized');
    }

    private function action_class($mi_id, $me_id, $seq) {
        global $wpdb, $MEDIASEARCH;
        $data = static::Load($mi_id);
        if (is_null($data)) {       // Create a default record
            $devent = '"' . esc_sql($me_id) . '"';
            $sql = <<<BLOCK
select e.*,
    max(i.mi_type)    as mi_type,
    max(i.mi_speaker) as mi_speaker
  from mm_event as e
    left join mm_item_x_event as x  on x.mixe_event = e.me_id
    left join mm_item as i          on x.mixe_item = i.mi_id
where e.me_id=$devent
BLOCK;
            $data = $wpdb->get_row($sql);
            if (is_null($data)) {
                $data = new StdClass;
                $data->_event = [];
            } else {
                $event = new StdClass;
                $event->me_id       = $me_id;
                $event->me_year     = $data->me_year;
                $event->me_location = $data->me_location;
                $event->me_topic    = $data->me_topic;
                $event->me_slug     = $data->me_slug;
                $event->mixe_sequence = $seq;
                $data->_event = [$event];
            }

            $data->mi_id = FALSE;
            $data->mi_title = '';
            $data->mi_date = date('Y-m-d');
            $data->_speaker = '';
            // default type depends on last item in this event
            if ($data->mi_type != 'ex' && $data->mi_type != 'bc' && $data->mi_type != '') {
                $def = (int) $data->mi_speaker;
                if ($def) {                    // select default speaker
                    $spkr = $wpdb->get_row("select * from mm_speaker where ms_id = $def");
                    $data->_speaker = static::AsSpeaker($spkr);
                }
            } else {
                $data->_speaker = '';
            }
            
            $data->_other = [];
            $data->__existing = FALSE;
        } else {
            $data->_speaker = static::AsSpeaker($data);
            $data->__existing = TRUE;
            $data->_other = json_decode($data->mi_other);
        }
        $data->_youtube = empty($data->_other->youtube) ? '' : $data->_other->youtube;
        
        if (empty($data->mi_id)) {
            $data->_id = '(new)';
            $link = TRUE;
        } else {
            $data->_id = $data->mi_id;
            if (is_object($MEDIASEARCH)) {
                $link = $MEDIASEARCH->generateAuth($data->mi_type, $data->mi_id, TRUE) . '#' . $data->mi_type . '_' .rawurlencode($data->mi_id);
            } else {
                $link = TRUE;
            }
        }
        $suffix = $this->hasOldVersion($data);   // old version present?
        if ($data->_speaker == '-' || $data->_speaker == '-,' ) $data->_speaker = '';
        
        $youtubies = '';
        if (!empty($data->_youtube)) {
            $safetext = htmlspecialchars($data->_youtube);
			$res = ParseVideoUrl($data->_youtube);
// youtube-dl does not format this correctly
//<a href="/~/media-youtube-cc/{$data->mi_id}" style="margin-left:15px"><i class="far fa-closed-captioning fa-lg" title="download youtube automatic closed captions"></i></a>
            $youtubies = <<<BLOCK
<a href="{$safetext}" target="_blank" style="margin-left:15px"><i class="fas fa-external-link-alt fa-lg" title="open external link in a new tab"></i></a>

BLOCK;
			// only for OTHER types (one source)
			if ($res->source == 'youtube' && $data->mi_type == 'other') {
				$youtube = new Youtube_Model;
				$youtube->Url($data->_youtube);
				$present = $youtube->CaptionPresent();
				if ($present) {
					$youtubies .= <<<BLOCK
<a href="/~/media-captions/{$data->mi_id}" style="margin-left:15px"><i class="far fa-closed-captioning fa-lg" title="import youtube captions"></i></a>

BLOCK;
				}
				if ($youtube->title && empty($data->mi_title)) {
					$youtubies .= <<<BLOCK
<a href="/~/media-title/{$data->mi_id}" style="margin-left:15px"><i class="fas fa-quote-left fa-lg" title="import youtube title"></i></a>

BLOCK;
				}
				if (count($youtube->poster)) {
					$youtubies .= <<<BLOCK
<a href="/~/media-thumbnail/{$data->mi_id}" style="margin-left:15px"><i class="far fa-file-image fa-lg" title="import youtube thumbnail"></i></a>

BLOCK;
				}

				$youtubies .= <<<BLOCK
<a href="/~/media-import/{$data->mi_id}" style="margin-left:15px"><i class="fas fa-file-import fa-lg" title="import youtube video"></i></a>

BLOCK;
				
			}
        }
		if ($data->mi_type == 'cbt') {
			$youtubies .= " <a href=\"https://cygnus.hopeinstoughton.org/videoimport/chbibletalks?ident={$data->mi_moorestown}\">Import</a>";
		}

        $primary_event = $data->_event[0];
        if (count($data->_event) > 1) {
            foreach($data->_event as $e) {
                if ($e->me_id != static::EXHORT) {
                    $primary_event = $e;
                    break;
                }
            }
        }
		if ($primary_event->me_aslisting && is_object($MEDIASEARCH)) {
			$link = $MEDIASEARCH->generateAuth('in', $data->mi_id, TRUE);
		}

        $fields = $this->FormField($data, '_id',         'Identifier',    ['readonly' => $link ] ).
                  $this->FormField($data, '_event',      'Event(s)',      ['events' => ['type' => $data->mi_type, 'id' => $data->mi_id ] ] ) .
                  $this->FormField($data, 'mi_type',     'Type',          ['select' => self::TYPES] ) .
                  $this->FormField($data, '_speaker',    'Speaker',       ['autocomplete' => '/~/media-autocomplete'] ) .
                  $this->FormField($data, 'mi_title',    'Title') .
                  $this->FormField($data, 'mi_date',     'Date',          ['type' => 'date', 'suffix' => $suffix] ) .        // put in calendar picker
                  $this->FormField($data, '_youtube',    'Original URL',  ['suffix' => $youtubies]  ) ;       // to do: put in link icon
        // mp3 - show current tags/thumbnail 
        //      - audio uploaded to church
        // add thumbnail to mp3?
        // determine filesize and duration
        
        $dmi_id = esc_attr($mi_id);
        $button = $this->submit_button();
        $thumb = '';
        if (!empty($data->mi_thumbnail)) {
            $thumb = $this->_scalethumb($data->mi_thumbnail);
            $thumb = "<div class=\"mm-thumbnail\">{$thumb}</div>";
        }
        // $me_id & $seq only used when creating a new event:
        $dseq = esc_attr($seq);
        echo <<<BLOCK
<h2 class="title">Talk Details</h2>
<form method="post" action="admin.php?page=talks-single&amp;action=class" style="position:relative;max-width:1200px">
$thumb
<input type="hidden" name="action" value="media_manager_class_classes">
<input type="hidden" name="seq" value="{$dseq}">
<input type="hidden" name="me_id" value="{$me_id}">
<input type="hidden" name="mi_id" value="{$dmi_id}">
<table class="form-table media-class-form" role="presentation">
<tbody>$fields</tbody>
</table>
$button
</form>

BLOCK;

        // table of files associated with this item, 
        //  server, filetype, filesize, download links
        // drag-and-drop xml - vtt - mp4 - mp3 - nomark
        //      max filesize?
        // show the datatable - of files and sources - drag and drop
        if ($data->__existing) {
            $name = trim($data->ms_firstname . ' ' . $data->ms_lastname);
            if ($data->ms_suffix) $name .= ' '. ucfirst($data->ms_suffix);
            $payload = [
                'pageLength' => 25,
                'dom' => 'lfrtipB',
                'autoWidth' => false,
                'ajax' => '/~/media-items/' . $mi_id,
                'columns' => [
                    [ 'data' => 'i_type',        'className' => 'i-type'],
                    [ 'data' => 'i_filename',    'className' => 'i-filename' ],
                    [ 'data' => 'i_duration',    'className' => 'i-duration'],
                    [ 'data' => 'i_dimension',   'className' => 'i-dimension'],
                    [ 'data' => 'i_filesize',    'className' => 'i-filesize'],
                    [ 'data' => 'i_location',    'className' => 'i-location'],
                    [ 'data' => 'i_flags',       'className' => 'i-action' ],
                ],
				'buttons' => [
					[
						'href' => '?page=talks-single&action=whisper&cid=' . $mi_id,
						'text' => '<i class="far fa-closed-captioning fa-lg"></i>',
						'hint' => 'Add to Whisper Queue',
						'tag'  => 'a',
					], [
						'href' => '?page=talks-single&action=googledrive&cid=' . $mi_id,
						'text' => '<i class="fab fa-google-drive fa-lg"></i>',
						'hint' => 'Export to Google Drive',
						'tag'  => 'a',
					]
				],
                'x_mi_id'       => $mi_id,
                'x_mi_type'     => $data->mi_type,
                'x_present_vtt' => $data->present_vtt,
                'x_defaults'    => [
                    'album'   => $primary_event->me_topic,
                    'title'   => $data->mi_title,
                    'artist'  => $name,
                    'year'    => $primary_event->me_year,
                    'track'   => $primary_event->mixe_sequence,
                ],
            ];
            $jsontext = json_encode($payload);
			$auth = '';
			$this->source   ->MmItem($data);
			$auth = $this->source->generateAuth('***');        // being deleted
			
            echo <<<BLOCK

<table class="datatable-media">
<thead>
    <tr>
        <th>Type</th>
        <th>Filename</th>
        <th>Duration</th>
        <th>Dimensions</th>
        <th>Filesize</th>
        <th>Location</th>
        <th>Actions</th>
    </tr>
</thead>
<tbody>
</tbody>
</table>
<br>
<div class="mm-plupload reload" href="/~/media-fileupload/{$mi_id}" style="max-width: 1200px">
    <p>Your browser doesn't support HTML5.</p>
</div>
<p>Upload .mp4, .mp3 and .vtt/.srt files, or .png/.jpg thumbnail for this talk. Attachments should be zipped and uploaded as a single .zip. Use .html for Inline html.</p>
<script type="text/javascript">
var \$table = jQuery('table.datatable-media');
mm_initDataTable(\$table, $jsontext, 'media-wrapper');
</script>
<p><a href="/~/media-trash/{$auth}" onclick="javascript:return confirm('Are you sure you wish to completely zap this talk?') == 1;">Completely remove this talk</a></p>

BLOCK;
                // show free space on each server
        }
    }
    
    // this is a very low quality image
    private function _scalethumb($text) {
        $text = preg_replace_callback('~(width|height)="(.*?)"~', function($m) {
            $s = 2.4 * (int) $m[2];
			if ($m[1] == 'width') {
				return "{$m[1]}=\"$s\"";
			}
			return '';
        }, $text);
        return $text;
    }
    
    // do we have the old version around - allow it to be copied instead of uploading
    private function hasOldVersion($data) {
        global $wpdb;
        $mi_id = $data->mi_id;
        $type = $data->mi_type; 
        $slug = $data->me_slug; 
        $date = $data->mi_date;

        if ($mi_id === FALSE) {
            $data = NULL;
        } else {
            $data = static::Load($mi_id);
        }

        if ($type == 'ac' && $slug != '') {
            // find it in the old system
            $xdate = '"' . esc_sql($date) . '"';
            $xslug = '"' . esc_sql($slug) . '"';
            $sql = <<<BLOCK
select * 
  from `cd_seriest` as t
    inner join `cd_seriesh` as h on cst_series = csh_id
where cst_date=$xdate and csh_slug=$xslug and csh_type="$type"
BLOCK;
            $row = $wpdb->get_row($sql);
            if (is_null($row)) return NULL;        // not recognized
            
            // does the video file exist?
            $basename = str_replace('-', '.', $date) . ' ac';
            if (file_exists("/mnt/media/current/{$basename}.mp4")) {
                $this->source   ->MmItem($data);
                $link = $this->source->generateDownloadLink(static::HOSTZ, '.mp4', 'info');
                $dname = rawurlencode($basename);
                return '<a href="' . $link . '/import?name=' . $dname . '&amp;type=ac" style="margin-left:20px" title="import this item from the old system"><i class="fas fa-file-import fa-lg"></i></a>';
            }
        }
        if ($type == 'ex' && !empty($data->_other->original)) {
            $filename = $data->_other->original;
            if (file_exists('/mnt/media/exhort/video/' . $filename)) {  // does the file exist?
                $this->source   ->MmItem($data);
                $link = $this->source->generateDownloadLink(static::HOSTZ, '.mp4', 'info');
                $dname = rawurlencode(str_replace('.mp4', '', $filename));
                return '<a href="' . $link . '/import?name=' . $dname . '&amp;type=ex" style="margin-left:20px" title="import this item from the old system"><i class="fas fa-file-import fa-lg"></i></a>';
            }
        } 
        
        return NULL;
    }

    private function getAudioItems($params, $dtparam) {
		global $wpdb, $MEDIASEARCH;
        $where = ['(present_mp3<>0 or present_mp4<>0)', '(mi_type="mt" or mi_type="cbt")'];
        if (isset($params['speaker'])) {
            $where[] = "mi_speaker='" . esc_sql($params['speaker']) . "'";
        }
        if (isset($params['subject'])) {
            $where[] = "me_topic='" . esc_sql($params['subject']) . "'";
        }
        if (isset($params['location'])) {
            $where[] = "me_location='" . esc_sql($params['location']) . "'";
        }
        if (isset($params['myear'])) {
            $where[] = "me_year='" . esc_sql($params['myear']) . "'";
        }
        
        // get total records, total filtered records
        $w = join(' and ', $where);
        $sql = <<<BLOCK
select count(x.mixe_item) as ttl 
  from mm_item_x_event as x
    inner join mm_item as i  on i.mi_id=x.mixe_item
    inner join mm_event as e on e.me_id = x.mixe_event
    left join mm_speaker as s on s.ms_id = i.mi_speaker
where $w
BLOCK;
  
		$total = $wpdb->get_row( $sql );
        $total->filtered = $total->ttl;
        // filtered records
        if ($dtparam->search != '') {
            $where[] = $this->generateSearch($dtparam->search);
            $w = join(' and ', $where);
            $sql = <<<BLOCK
select count(x.mixe_item) as ttl 
  from mm_item_x_event as x
    inner join mm_item as i  on i.mi_id=x.mixe_item
    inner join mm_event as e on e.me_id = x.mixe_event
    left join mm_speaker as s on s.ms_id = i.mi_speaker
where $w
BLOCK;
            $filtered = $wpdb->get_row( $sql );
            $total->filtered = $filtered->ttl;
        }
        
        // get records just in specified range, after applying sort order
        $orderby = $this->generateSorting($dtparam->sorting);
        // $where also includes text filter
        $w = join(' and ', $where);
        $sql = <<<BLOCK
select mi_id,mi_title,ms_firstname,ms_lastname,ms_suffix,mi_other,
        me_year,me_location,me_topic,present_mp3,present_mp4,
        mixe_sequence,
        max(mm_duration) as `mm_duration`
  from mm_item_x_event as x
    inner join mm_item as i  on i.mi_id=x.mixe_item
    inner join mm_event as e on e.me_id = x.mixe_event
    left join mm_speaker as s on s.ms_id = i.mi_speaker
    left join mm_media as m on mm_hash=mi_id and mm_type="mp3"
where $w 
group by mixe_item
$orderby 
limit {$dtparam->start}, {$dtparam->length}
BLOCK;

		$items = $wpdb->get_results( $sql );
        $data = [];
        foreach($items as $item) {
            $other = json_decode($item->mi_other);
            $duration = static::asHMS($item->mm_duration);
            $speaker = static::AsSpeaker($item);
            $node = [
                'col1' => [ $item->mi_title, $item->me_location ],
                'col2' => [ $speaker, $item->me_year ],
                'col3' => [ $item->me_topic, $item->mixe_sequence ],
                'col4' => [ $item->mi_id, $other->original, 0, $duration],       // size & duration
            ];
            if (is_object($MEDIASEARCH)) {
                $cdn = apply_filters('mm_generate_link', '', $item->mi_id, '.mp3', $item->present_mp3);
				$type = $item->mi_type ?? 'o';
                $node['media'] = $MEDIASEARCH->generateAuth($type, $item->mi_id, TRUE) . '#' . $type . '_' .rawurlencode($item->mi_id);
                if (!empty($cdn)) $node['cdn'] = $cdn;
            }

            $data[] = $node;
        }
        return [
            'recordsTotal' => (int) $total->ttl,
            'recordsFiltered' => (int) $total->filtered,
            'data' => $data,
        ];
    }

    static
    private function asHMS($duration) {
        if ($duration > 3599) {
            $hours = floor($duration / 3600);
            $duration %= 3600;
            $duration = sprintf('%d:%02d:%02d', $hours, floor($duration / 60), $duration % 60);
        } else {
            $duration = sprintf('%02d:%02d', floor($duration / 60), $duration % 60);
        }
        return $duration;
    }

    private function getSorting($columns, $order) {
        $cols = [];
        foreach($columns as $idx => $col) {
            $cols[$idx] = $col['data'];
        }
        foreach($order as &$item) {
            $item['column'] = $cols[$item['column']];
        }
        return $order;
    }
    
    private function generateSorting($sorting) {
        $result = '';
        foreach($sorting as $entry) {
            $direction = ($entry['dir'] == 'desc') ? 'desc' : 'asc';
            switch($entry['column']) {
                case 'col1':
                    if ($result != '') $result .= ',';
                    $result .= "mi_title $direction";
                    break;
                case 'col2':
                    if ($result != '') $result .= ',';
                    $result .= "me_year $direction, ms_lastname $direction, ms_firstname $direction, me_topic $direction, mixe_sequence";
                    break;
                case 'col3':
                    if ($result != '') $result .= ',';
                    $result .= "me_topic $direction, mixe_sequence $direction";
                    break;
            }
            break;
        }
        return ($result != '') ? "order by $result" : '';
    }
    
    private function generateSearch($search) {
        $safe_search = "'%" . esc_sql($search) . "%'";
        $output = '';
        foreach(['mi_title', 'ms_lastname', 'ms_firstname', 'me_topic', 'me_year', 'me_location'] as $field) {
            if ($output != '') $output .= ',';
            $output .= "ifnull(`$field`, ''), ' '";
        }
        return "concat($output) like $safe_search";
    }
    
    // grab some files that are not present on the target, and copy them
    private function copyfiles($target) {
        $k = 1 << $target;
        set_time_limit(0);
        global $wpdb;
        $sql = <<<BLOCK
select mi_id,mi_type,mi_other,present_mp3,present_vtt 
  from `mm_item` 
where mi_type="mt" 
    and present_mp3 <> 0 
    and (present_mp3 & $k = 0)
limit 32
BLOCK;
        echo "<h3>Copying to Server #$target</h3>";
        $rows = $wpdb->get_results($sql);
        foreach($rows as $data) {
            $data->_other = json_decode($data->mi_other);
            $basename = isset($data->_other->original) ? $data->_other->original : '';
            if (($p = strrpos($basename, '.')) !== FALSE) {
                $basename = substr($basename, 0, $p);
            }
            $dt = date('Y-m-d H:i:s');
            echo "$dt: Copying {$data->mi_id} $basename<br>\n";
            $ok1 = $this->source->CopyFile($data->mi_id . '.mp3', $data->present_mp3, $target, $data->mi_type);    // mi_type is deprecated
            $ok2 = $this->source->CopyFile($data->mi_id . '.vtt', $data->present_vtt, $target, $data->mi_type);
            // update `present` fields
            $did = '"'. esc_sql($data->mi_id) . '"';
            $ok1 = $ok1 ? $k : 0;
            $ok2 = $ok2 ? $k : 0;
            $wpdb->Query("update mm_item set present_mp3 = present_mp3 | $ok1, present_vtt = present_vtt | $ok2 where mi_id=$did");
            flush();
        }
    }

	private function _getExternalMedia($external_url) {
		$res = ParseVideoUrl($external_url);
		if ($res->source == 'youtube' && $res->id !== FALSE) {
			$result = new Youtube_Model;
			return $result->Url("https://youtu.be/" . $res->id);
		}
        return FALSE;		// unrecognized url
	}
	private function _getExternalMediaURL($mi_id) {
        $data = static::Load($mi_id);
		if (is_null($data)) 			   return NULL;			// item not found
        if (empty($data->_other->youtube)) return FALSE;		// Original Url has not been specified

		return $data->_other->youtube;
    }
    
	// this has been updated to use yt-dlp
    private function _command_download_youtube_caption($mi_id) {
		$url = $this->_getExternalMediaURL($mi_id);

        header('Content-type: text/plain');
        if (is_null($url)) {
            echo "unknown talk identifier";
            exit;
        }
		$youtube = $this->_getExternalMedia($url);
        if ($youtube === FALSE) {
            echo "Original Url is missing or unrecognized";
            exit;
        }

		$filename = $youtube->DownloadVTT();
		if (!file_exists($filename)) {
			die("Unable to download captions:n\n{$youtube->debug_output}\n\n");
		}

		$temp = new \StdClass;
		$temp->content = file_get_contents($filename);
        $result = $this->_youtube_saveVtt($temp, $mi_id);         // save the captions
		unlink($filename);

        header('Location: /wp-admin/admin.php?page=talks-single&action=class&cid=' . $mi_id . '&info=' . rawurlencode($result));
        exit;
    }
	
    private function _command_download_youtube_video($mi_id) {
		$url = $this->_getExternalMediaURL($mi_id);
        header('Content-type: text/plain');
        if (is_null($url)) {
            echo "unknown talk identifier";
            exit;
        }
		$youtube = $this->_getExternalMedia($url);
        if ($youtube === FALSE) {
            echo "Original Url is missing or unrecognized";
            exit;
        }
		$filename = $youtube->DownloadVideo();
		if (!file_exists($filename)) {
			die("Unable to download video \"$filename\":n\n{$youtube->debug_output}\n\n");
		} elseif (!filesize($filename)) {
			die("No video downloaded \"$filename\":n\n{$youtube->debug_output}\n\n");
		}

		$originalname = $mi_id . '.mp4';
		$file = [];
		$file['tmp_name'] = $filename;
		$file['name'] = wp_unslash($originalname);
		$file['size'] = filesize($filename);
		$res = $this->_uploadComplete($mi_id, $file);
		unlink($file['tmp_name']);
        header('Location: /wp-admin/admin.php?page=talks-single&action=class&cid=' . $mi_id );
        exit;
	}
	
	private function _command_download_youtube_title($mi_id) {
        global $wpdb;
		$url = $this->_getExternalMediaURL($mi_id);
		$youtube = $this->_getExternalMedia($url);
		if (is_object($youtube)) {
			$did = '"' . esc_sql($mi_id) . '"';
			$dtitle = '"' . esc_sql($youtube->title) . '"';
			$wpdb->Query("update mm_item set mi_title = $dtitle where mi_id=$did");
		}
        header('Location: /wp-admin/admin.php?page=talks-single&action=class&cid=' . $mi_id);
        exit;
	}

	private function _command_download_youtube_thumbnail($mi_id) {
		$data = static::Load($mi_id);
		$url = $data->_other->youtube ?? FALSE;
		$youtube = $this->_getExternalMedia($url);
		if (is_object($youtube)) {
			list($ht, $imagedata) = $youtube->GetThumbnail(-1);	// get poster
			list($ht2, $imagedata2) = $youtube->GetThumbnail(110);	// thumb: smallest thumbnail that is at least this height
			$imagedata2 = $ht2 != 110 ? NULL : $this->_ImageTothumbnail($imagedata2);	// no suitable image - create it from the thumb
			$this->savePoster($data, $imagedata, $imagedata2);
		}
        header('Location: /wp-admin/admin.php?page=talks-single&action=class&cid=' . $mi_id);
        exit;
	}

    private function _youtube_getCaptions($youtube_url) {
		
        $result = new StdClass;
        $result->error = FALSE;
		$youtube = $this->_getExternalMedia($youtube_url);
		if ($youtube === FALSE) {
			$result->error = 'Unrecognized URL';
		} else {
			$result->content = $youtube->Caption();
			if ($result->content == '') {
				$result->error = 'Captions are not yet available';
			}
		}
        return $result;
    }
    
    private function retrieveYoutube($youtubies) {
        $youtube = $this->_youtube_getCaptions($youtubies);
        if ($youtube->error === FALSE) {
            _retrieveYoutube_processFile($youtube);
        } else {
            $result = $youtube->error;
        }
        return '/wp-admin/admin.php?page=talks-events&info=' . rawurlencode($result);
    }
    
    private function _youtube_saveVtt($result, $mi_id) {
        global $wpdb;
        $data = static::Load($mi_id);
        if (!is_null($data)) {
            $filename  = $this->source
                            ->MmItem($data)
                            ->getDestination(static::ARIES, "{$mi_id}.vtt");
            file_put_contents($filename, $result->content);        // add to alcor cdn
            // update database
            $ok2 = 1 << static::ARIES;
            $did = '"' . esc_sql($mi_id) . '"';
            $wpdb->Query("update mm_item set present_vtt = present_vtt | $ok2 where mi_id=$did");
            $this->UpdateMediaTable($mi_id, static::ARIES, 'vtt');
            return "SUCCESS - Item '$mi_id' was updated";
        } else {
            return "Item '$mi_id' not found in database ($safeid)";
        }
    }
    
    private function _retrieveYoutube_processFile($youtube) {
        global $wpdb;
        $content = $youtube->content;
        $title = $youtube->title;

        // work out what entry it is for
        if (preg_match('~^(\d{4})\s(\d{2})\s(\d{2})\sac~i', $title, $match)) {
            $date = "{$match[1]}-{$match[2]}-{$match[3]}";
            $ddate = '"' . esc_sql($date) . '"';
            $dtype = '"' . esc_sql('ac') . '"';
            $row = $wpdb->get_row("select mi_id from mm_item where mi_date=$ddate and mi_type=$dtype");
            if (is_null($row)) {
                return "Event '$date-ac' was not found in the database";
            }
            return $this->_youtube_saveVtt($result, $row->mi_id);

        } else if (preg_match('~^([0-9a-f]{1,10})$~i', $title)) {     // hex string

            return $this->_youtube_saveVtt($result, $title);

        } else {
            return "unable to locate suitable entry for '$title' ($safeid)";
        }
        
        // a background task will make sure all sites have the files
        //   and that each has enough free space
    }

	protected function AsTime($time) {
		$seconds = (int) $time;
		$millisec =  intval(($time - $seconds) * 1000);
		while (strlen($millisec) < 3) $millisec = '0' . $millisec;
		$ss = intval($seconds % 60);
		$seconds = intval($seconds / 60);
		$mm = intval($seconds % 60);
		$hh = intval($seconds / 60);
		return sprintf('%02d:%02d:%02d', $hh, $mm, $ss) . '.' . $millisec ;
	}

	protected function AsVTT($words, $nexttime) {		// nexttime is the start time of the next phrase
		$output = '';
		while (count($words)) {
			$line = '';
			$last = 0;
			// extract a phrase
			foreach($words as $index => $word) {
				if (strlen($line) + strlen($word['word']) < 55) {
					$last = $index;
					$line .= $word['word'];
				}
			}
			if ($last == count($words) - 2) $last = count($words) - 1;	// only one word remains - include it
			
			$phrase = array_splice($words, 0, $last + 1);
			$words = array_values($words);
			$next = isset($words[0]) ? $words[0]['start'] : $nexttime;
			$output .= $this->_asvtt_processPhrase($phrase, $next);
			// drop the phase and repeat
			// process the phrase
		}
		return $output;
	}

	protected function _asvtt_processPhrase($phrase, $nexttime) {
		$c = count($phrase) - 1;
		$start = $phrase[0]['start'];
		$end   = $phrase[$c]['end'];
		if ($end + 0.5 < $nexttime) $end += 0.5; 		// keep visible for a fraction of a second
	//	$text = trim(join('', array_column($phrase, 'word')));
		$text = '';
		foreach($phrase as $index => $entry) {
			if (!$index) {
				$text .= trim($entry['word']);
			} else {
				$text .= '<' . $this->AsTime($entry['start']) . '><c>' . $entry['word'] . '</c>';
			}
		}
		
		$output = $this->AsTime($start) . ' --> ' . $this->AsTime($end) . "\n";
		$output .= $text . "\n";
		$output .= "\n";
		return $output;
	}

    private function _uploadComplete($mi_id, $file) {
        global $wpdb;
        $payload = [
            'result'   => 'err',
            'message'  => 'unknown file extension',
            'reload'   => 0,
        ];
        $data = static::Load($mi_id);
        if (is_null($data)) {
            $payload['message'] = 'invalid item id';
            return $payload;
        }

        $ext = ($p = strrpos($file['name'], '.')) !== FALSE ? substr($file['name'], $p + 1) : '';
		if ($ext == 'srt') {		// convert srt to vtt
			$content = str_replace("\r\n", "\n", file_get_contents($file['tmp_name']));
			$result = "WEBVTT\nKind: captions\nLanguage: en\n\n";
			$content = explode("\n", $content);
			foreach($content as $index => $line) {
				$next = $content[$index+1] ?? '';
				if (preg_match('~^(\d+)$~', $line) !== FALSE && strpos($next, '-->') !== FALSE) {
					continue;
				}
				if (strpos($line, '-->') !== FALSE) {
					$line = str_replace(',', '.', $line);
				}
				$result .= "$line\n";
			}
			
			file_put_contents($file['tmp_name'], $result);
			$ext = 'vtt';
		
		} elseif ($ext == 'json') {		// this is a caption file generated by Whisper
			$json = json_decode(file_get_contents($file['tmp_name']), TRUE);
			$segments = $json['segments'];
			$result = "WEBVTT\nKind: captions\nLanguage: en\n\n";

			// convert to VTT
			foreach($segments as $index => $segment) {
				$timestamp = isset($segments[$index + 1]) ? $segments[$index + 1]['start'] : 86400;
				$result .= $this->AsVTT($segment['words'], $timestamp);
			}
			file_put_contents($file['tmp_name'], $result);
			$ext = 'vtt';
		}
		
        $payload['ext'] = $ext;
        switch($ext) {
            case 'mp3':                     // save to Stoughton
                if ($file['size'] > 100000) {
                    
                    // save to church site
                    $filename  = $this->source
                                      ->MmItem($data)
                                      ->getDestination(self::CHURCH, "{$mi_id}.mp3");
                    @unlink($filename);
                    copy($file['tmp_name'], $filename);
                    @chmod($filename, 0666);
                    
                    // update database
                    $data->_other->original = $file['name'];
                    $data->present_mp3 = $data->present_mp3 | (1 << static::CHURCH);
                    
                    $did = '"' . esc_sql($mi_id) . '"';
                    $safeother = '"' . esc_sql(json_encode($data->_other, JSON_UNESCAPED_SLASHES)) . '"';
                    $wpdb->Query("update mm_item set present_mp3={$data->present_mp3},mi_other={$safeother} where mi_id=$did");
                    $this->UpdateMediaTable($mi_id, static::CHURCH, 'mp3');
                    // return status: reload page
                    $payload = [
                        'result'   => 'ok',
                        'id'       => $mi_id,
                        'reload'   => 1,
                    ];
                } else {
                    $payload = [
                        'result'   => 'err',
                        'message'  => 'file too small',
                        'reload'   => 0,
                    ];
                }
                break;

            case 'mp4':                     // save to ARIES. Creating poster/thumbnail is a separate step
                $filename  = $this->source
                                  ->MmItem($data)
                                  ->getDestination(static::ARIES, "{$mi_id}.mp4");
                @unlink($filename);
                copy($file['tmp_name'], $filename);
                @chmod($filename, 0666);

                $data->_other->original = str_replace('.nomark.', '.', $file['name']);
                $data->present_mp4 = $data->present_mp4 | (1 << static::ARIES);
                $did = '"' . esc_sql($mi_id) . '"';
                $safeother = '"' . esc_sql(json_encode($data->_other, JSON_UNESCAPED_SLASHES)) . '"';
                $wpdb->Query("update mm_item set present_mp4={$data->present_mp4},mi_other={$safeother} where mi_id=$did");
                $this->UpdateMediaTable($mi_id, static::ARIES, 'mp4');
				$this->_command_calendar_link($mi_id);				// change the calendar link, if present
                $payload = [
                    'result'   => 'ok',
                    'id'       => $mi_id,
                    'reload'   => 1,
                ];
                break;
            
            case 'vtt':               // save to aries
                $filename  = $this->source
                                  ->MmItem($data)
                                  ->getDestination(static::ARIES, "{$mi_id}.vtt");
                @unlink($filename);
                copy($file['tmp_name'], $filename);
                @chmod($filename, 0666);

                $data->present_vtt = $data->present_vtt | (1 << static::ARIES);
                $did = '"' . esc_sql($mi_id) . '"';
                $wpdb->Query("update mm_item set present_vtt={$data->present_vtt} where mi_id=$did");
                $this->UpdateMediaTable($mi_id, static::ARIES, 'vtt');

				// save to church
                $filename  = $this->source
                                  ->MmItem($data)
                                  ->getDestination(static::CHURCH, "{$mi_id}.vtt");
                @unlink($filename);
                copy($file['tmp_name'], $filename);
                @chmod($filename, 0666);

                $data->present_vtt = $data->present_vtt | (1 << static::CHURCH);
                $did = '"' . esc_sql($mi_id) . '"';
                $wpdb->Query("update mm_item set present_vtt={$data->present_vtt} where mi_id=$did");
                $this->UpdateMediaTable($mi_id, static::CHURCH, 'vtt');

                // return status: reload page
                $payload = [
                    'result'   => 'ok',
                    'id'       => $mi_id,
                    'reload'   => 1,
                ];
                break;
				
			case 'htm':				// inline html (eg links to other sites)
			case 'html':			// "
			case 'zip':				// attachments
				if ($ext == 'htm') $ext = 'html';
                $filename  = $this->source
                                  ->MmItem($data)
                                  ->getDestination(static::ARIES, "{$mi_id}.$ext");
                @unlink($filename);
                copy($file['tmp_name'], $filename);
                @chmod($filename, 0666);
				$this->UpdateMediaTable($mi_id, static::ARIES, $ext);

                // return status: reload page
                $payload = [
                    'result'   => 'ok',
                    'id'       => $mi_id,
                    'reload'   => 1,
					'target'   => $filename,
                ];
				break;
            
            case 'png':
                $image = imagecreatefrompng($file['tmp_name']);
                $bg = imagecreatetruecolor(imagesx($image), imagesy($image));
                imagefill($bg, 0, 0, imagecolorallocate($bg, 255, 255, 255));       // this is the background color used by transparent areas
                imagealphablending($bg, TRUE);
                imagecopy($bg, $image, 0, 0, 0, 0, imagesx($image), imagesy($image));
                imagedestroy($image);
                $quality = 80; // 0 = worst / smaller file, 100 = better / bigger file 
                imagejpeg($bg, $file['tmp_name'], $quality);
                imagedestroy($bg);

                $this->savePoster($data, file_get_contents($file['tmp_name']));
                $payload = [
                    'result'   => 'ok',
                    'id'       => $mi_id,
                    'reload'   => 1,
                ];
                break;
            
            case 'jpg':             // poster is saved to alcor
                $this->savePoster($data, file_get_contents($file['tmp_name']));
                $payload = [
                    'result'   => 'ok',
                    'id'       => $mi_id,
                    'reload'   => 1,
                ];
                break;

        }
        return $payload;
    }
    
    
    protected function UpdateMediaTable($mi_id, $source, $ext) {
        $body = $this->_getFileInfo($source, [$mi_id]);
        if ($ext == 'm4a') {
			$ext = 'mp3';
			$iteminfo = ['ext' => 'm4a'];
        } elseif (isset($body[$mi_id][$ext])) {
            $iteminfo = $body[$mi_id][$ext];
        } elseif ($ext == 'mp3' && isset($body[$mi_id]['m4a'])) {
            $iteminfo = $body[$mi_id]['m4a'];
			$iteminfo['ext'] = 'm4a';
		} else {
            $iteminfo = [];     // unable to get file information - certificate, [or intermediate cert] has expired
        }
        $this->_action_fileinfo_update_mm_media($source, $mi_id, $ext, $iteminfo);
        return [
            'body'     => $body,
            'iteminfo' => $iteminfo,
        ];
    }

    protected function WriteMP3tags($mi_id, $source, $post) {
       
        $param = '';
        foreach($post as $key => $value) {
            if ($param != '') $param .= '&';
            $param .= rawurlencode($key) . '=' . rawurlencode($value);
        }
       
        $data = static::Load($mi_id);
        $this->source   ->MmItem($data)->Load();
        $auth = $this->source->generateAuth('tag');

        $baseurl = $this->source->getHost($source);
        $url = "$baseurl/info/$auth/mp3tags?" . $param;
        $response = wp_remote_get($url, ['sslverify' => FALSE]);
        $body     = wp_remote_retrieve_body( $response );
        $body = json_decode($body, TRUE);
    }

    function exitXMLerror($xml_parser, $data) {
        $original = explode("\n", $data);
        $linenum = xml_get_current_line_number($xml_parser);
        $st = $linenum - 10; $fin = $linenum + 10;
        if ($st < 1) $st = 1;
        if ($fin > count($original)) $fin = count($original);
        echo '<hr>';
        for ($i = $st; $i <= $fin; $i++) {
            $res = htmlspecialchars($original[$i-1]);
            if ($i == $linenum) $res = "<font color=\"#cc0000\">$res</font>";
            echo "<strong>$i.</strong> $res<br>\n";
        }
        echo "<br>\n";
        echo sprintf("XML error: %s at line %d, col %d",
            xml_error_string(xml_get_error_code($xml_parser)),
            $linenum, xml_get_current_column_number($xml_parser));
        echo '<hr>';
        echo nl2br( htmlentities( var_export($data, 1) ) );
        exit;
    }

    private $currentnode;
    private $result;
    private $index;
    private $nodenum;
    private $cnode;
    private $characterText;

    function __startElement($parser, $name, $attrs) {
        $this->nodenum++; $parent = $this->cnode; $this->cnode = $this->nodenum;
        $this->index[$this->cnode] = [];
        $this->index["p{$this->cnode}"] = $parent;
        foreach ($attrs as $k => $v) {
            $this->index[$this->cnode][$k] = $v;
        }

        if (!isset($this->index[$parent][$name])) {
            $this->index[$parent][$name] = array();
        }
        $this->index[$parent][$name][] = &$this->index[$this->cnode];
        $this->currentnode = &$this->index[$this->cnode];
        $this->characterText = '';
    }

    function __characterData($parser, $data) {
        $this->characterText .= $data;
    }

    function __endElement($parser, $name) {
        $this->characterText = trim($this->characterText);
        if ($this->characterText != '') { 
            $this->index[$this->cnode]['@'] = $this->characterText;
            $this->characterText = '';
        }
        $this->cnode = $this->index["p{$this->cnode}"];
        if ($this->cnode > -1) {
            $this->currentnode = &$this->index[$this->cnode];
        } else {
            $this->currentnode = NULL;
        }
    }

    function XMLparser($data, $dieonerror = 1) {
        $data = str_replace('&', '&amp;', $data);
        $xml_parser = xml_parser_create();
        xml_set_element_handler($xml_parser, [$this, '__startElement'], [$this, '__endElement']);
        xml_set_character_data_handler($xml_parser, [$this, '__characterData']);

        $this->characterText = '';
        $this->index = [];
        $this->cnode = $this->nodenum = 0;
        $this->index[$this->cnode] = [];
        $index["p{$this->cnode}"] = -1;
        $currentnode = &$this->index[$this->cnode];
        $this->result = &$this->index[$this->cnode];
        
        if (!xml_parse($xml_parser, $data, 1)) {
            if ($dieonerror) exitXMLerror($xml_parser, $data);
        }
        xml_parser_free($xml_parser);
        return $this->result;
    }

    private function savePoster($item, $imagedata, $thumb = NULL) {
        global $wpdb;
        $filename_final = $this->source->getDestination(static::ARIES, $item->mi_id . '.jpg');
        file_put_contents($filename_final, $imagedata);
		if (is_null($thumb)) {
			$thumb = $this->_generateThumbnail($filename_final);
		}
        $item->present_jpg |= 1 << static::ARIES;
        $did = '"' . esc_sql($item->mi_id) . '"';
        $dthumb = '"' . esc_sql($thumb) . '"';
        $sql = "update mm_item set present_jpg={$item->present_jpg},mi_thumbnail={$dthumb} where mi_id=$did";
        $wpdb->Query($sql);
        $this->UpdateMediaTable($item->mi_id, static::ARIES, 'jpg');

    }
    
    private function _preparev($sequence, $item, $date, $eventtype) {
        global $wpdb;
        // load by sequence
        $data =  static::Load(["mixe_sequence=$sequence", 'mixe_event=' . static::EXHORT]);
        $isNew = is_null($data);
        if ($isNew) {
            $data = new StdClass;
            $data->mi_id       = $this->generateMediaHash();
            $data->mi_type     = 'ex';
            $data->mi_date     = str_replace('.', '-', $date);
            $data->present_jpg = 0;
        }
        // update speaker, creating new entry if needed
        $data->mi_speaker = $this->retrieveSpeakerId($item['SPEAKER']);
        
        $types = ['%s', '%s', '%s', '%d'];
        $values = [
            'mi_id'      => $data->mi_id,
            'mi_type'    => $data->mi_type,
            'mi_date'    => $data->mi_date,
            'mi_speaker' => $data->mi_speaker,
        ];
        
        // save mm_item
        if ($isNew) {
            $wpdb->insert('mm_item', $values, $types);
            $this->addMixie(static::EXHORT, $sequence, $data->mi_id);   // to fix: B/C also uses preparev
        } else {
            unset($values['mi_id']);
            array_shift($types);
            $result = $wpdb->update('mm_item', $values, ['mi_id' => $data->mi_id], $types);
        }

        if (isset($item['THUMBNAIL'])) {
            $this->savePoster($data, base64_decode($item['THUMBNAIL']));
        }
		return $data->mi_id;
    }

    private function processPrepareV($name, $filename) {
        // $name contains the date!
        if (!preg_match('~(\d{4}).(\d{2}).(\d{2})~', $name, $matches)) {
            die('uploaded filename does not contain a date');
        }
        $yy = (int) $matches[1];
        $mm = intval($matches[2], 10);
        $dd = intval($matches[3], 10);
        $data = file_get_contents($filename);
        $xml = $this->XMLparser($data);
        if (isset($xml['XML'][0]['EVENT'])) {
            foreach($xml['XML'][0]['EVENT'] as $item) {
                if ($item['EVENT_TYPE'] == 60 || $item['EVENT_TYPE'] == 100) {
                    $sequence = $yy * 10000 + $mm * 100 + $dd;
                    if ($item['EVENT_TYPE'] == 100) $sequence++;
                    $this->_preparev($sequence, $item, $matches[0], $item['EVENT_TYPE']); // eventtype will be deprecated
                }
            }
        }
        $payload = [
            'result'   => 'ok',
            'message'  => 'uploaded',
            'reload'   => 1,
        ];
        header('Content-type: application/json');
        die(json_encode($payload, JSON_UNESCAPED_SLASHES));
    }

    private function _command_upload_item($mi_id, $file) {
        global $wpdb;
        if (!is_uploaded_file($file['tmp_name'])) {
            die('something is wrong');
        }

        if (empty($mi_id)) {
            $ext = ($p = strrpos($file['name'], '.')) !== FALSE ? substr($file['name'], $p + 1) : '';
            switch($ext) {
                case 'xml':             // don't save, but extract the info
                    $this->processPrepareV($file['name'], $file['tmp_name']);
                    exit;

                default:
                    die('item cannot be empty');
            }
        }

        $mi_id = str_replace(['..', '/', '\\'], '', $mi_id);
        
        if (array_key_exists('chunks', $_POST)) {
            // upload this chunk to the wordpress upload folder
            // the LAST chunk will take a long time to load - the entire file is copied to Hostz.
            $targetname = realpath(__DIR__ . '/../../uploads/plupload') . '/' . $mi_id;
            $this->_uploadChunk($file, $targetname, function($uploadedfile) use ($file, $mi_id) {
                // when complete, move it to HOSTZ
                $file['tmp_name'] = $uploadedfile;
                $file['name'] = wp_unslash($_POST['name']);
                $file['size'] = filesize($uploadedfile);
                $res = $this->_uploadComplete($mi_id, $file);
                unlink($file['tmp_name']);
                return $res;
            });
        }

        $payload = $this->_uploadComplete($mi_id, $file);

        header('Content-type: application/json');
        die(json_encode($payload, JSON_UNESCAPED_SLASHES));

    }
    
    // this is too slow!  Must be run on cdn server
    private function _command_download_for_youtube($mi_id) {
        global $wpdb;
        $data = static::Load($mi_id);
        if (is_null($data)) {
            die('invalid item id');
        }
        // locate mp3
        $svr  = $this->source
                      ->MmItem($data)
                      ->determineBestServer($data->present_mp3);
        if (is_null($svr)) {
            die('Unable to locate source file');
        }
        
        $filename_source = $this->source->getDestination($svr, "{$mi_id}.mp3");
        
        // use ffmpeg + thumbnail to create video
        $filename_temp = tempnam(sys_get_temp_dir(), 'mp4') . '.mp4';
        // download video - use content-disposition  to download/filename
        // delete temp file
        if ($data->mi_type == 'mt') {
            $background = 'background-blu.jpg';
        } else {
            $background = 'background-grn.jpg';
        }
        $safe_bg = escapeshellarg(__DIR__ . '/cdn/include/' . $background);
        $safe_in = escapeshellarg($filename_source);
        $command = self::FFMPEG . " -loop 1 -i $safe_bg -i $safe_in -c:a copy -c:v libx264 -shortest $filename_temp";
        header('Content-type: text/plain');
        var_dump($mi_id);
        echo "$command\n";
        echo `$command`;
        
        exit;
    }
    
    private function report_missingitems() {
        global $wpdb;
        $sql = <<<BLOCK
select mi_id,mi_title,ms_firstname,ms_lastname,ms_suffix,me_year,me_location,
        me_topic,mixe_sequence,present_mp3,present_mp4,present_vtt
  from mm_item_x_event as x
    inner join mm_item as i  on x.mixe_item = i.mi_id
    left join mm_speaker as s on s.ms_id = i.mi_speaker
    left join mm_event as e on e.me_id = mixe_event
where  ((present_mp3 = 0 and present_mp4 = 0) or present_vtt=0)
    order by me_year,me_location,me_topic,mixe_sequence
BLOCK;

		$items = $wpdb->get_results( $sql );
        $output = '';
        foreach($items as $item) {
            if (empty($item->present_mp3) && empty($item->present_mp4)) {
                $status = 'missing audio or video';
            } elseif (empty($item->present_vtt)) {
                $status = 'missing caption';
            } else {
                $status = '';
            }
            $output .= <<<BLOCK
<tr>
    <td><a href="?page=talks-single&amp;action=class&amp;cid={$item->mi_id}" target="newpage">{$item->mi_id}</a></td>
    <td>{$item->ms_firstname} {$item->ms_lastname}</td>
    <td>{$item->mi_title}</td>
    <td>{$item->me_topic} ({$item->mixe_sequence})</td>
    <td>{$item->me_location} ({$item->me_year})</td>
    <td>$status</td>
</tr>
BLOCK;
        }
        
        echo <<<BLOCK
<h3>Missing Items</h3>
<table>
$output
</table>
BLOCK;
    }

/*    public function repairdatabase() {
        global $wpdb;
        $sql = "select mi_id,mi_duration,mi_other from mm_item where mi_duration is null";
        $items = $wpdb->get_results( $sql );
        foreach($items as $item) {
            $item->other = json_decode($item->mi_other);
            echo "{$item->mi_id}...<br>\n";
//            echo '<pre>' . htmlspecialchars(var_export($item, 1)) . '</pre>';
            if (!empty($item->other->duration)) {
                $did = '"' . esc_sql($item->mi_id) . '"';
                $wpdb->Query("update `mm_item` set mi_duration={$item->other->duration} where mi_id=$did");
            }
        }
        die('Done');
    } */

    private function addMixie($event, $sequence, $item) {
        global $wpdb;
        $devent = '"' . esc_sql($event) . '"';
        $dsequence = '"' . esc_sql($sequence) . '"';
        $ditem = '"' . esc_sql($item) . '"';
        $sql = "insert into `mm_item_x_event` (mixe_event, mixe_sequence, mixe_item) VALUES ($devent, $dsequence, $ditem) ON DUPLICATE KEY UPDATE mixe_item=$ditem";
        $wpdb->Query($sql);
    }

    // Returns a file size limit in bytes based on the PHP upload_max_filesize
    // and post_max_size
    public function file_upload_max_size() {
      static $max_size = -1;

      if ($max_size < 0) {
        // Start with post_max_size.
        $post_max_size = $this->parse_size(ini_get('post_max_size'));
        if ($post_max_size > 0) {
          $max_size = $post_max_size;
        }

        // If upload_max_size is less, then reduce. Except if upload_max_size is
        // zero, which indicates no limit.
        $upload_max = $this->parse_size(ini_get('upload_max_filesize'));
        if ($upload_max > 0 && $upload_max < $max_size) {
          $max_size = $upload_max;
        }
      }
      return $max_size;
    }

    private function parse_size($size) {
      $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); // Remove the non-unit characters from the size.
      $size = preg_replace('/[^0-9\.]/', '', $size); // Remove the non-numeric characters from the size.
      if ($unit) {
        // Find the position of the unit in the ordered string which is the power of magnitude to multiply a kilobyte by.
        return round($size * pow(1024, stripos('bkmgtpezy', $unit[0])));
      }
      else {
        return round($size);
      }
    }
    
    protected function verbose($ok=1,$info='', $param = NULL){
        if (!$ok) { 
            http_response_code(400); 
        }
        $payload = [
            'ok'   => $ok, 
            'info' => $info,
        ];
        if ($param) $payload['param'] = $param;
        die(json_encode($payload, JSON_UNESCAPED_SLASHES));
    }

    protected function _uploadChunk($FILE, $targetname, $callback) { 

        if ($FILE['error']) {
          $this->verbose(0, "Failed to move uploaded file.");
        }

        $chunk = isset($_POST['chunk']) ? intval($_POST['chunk']) : 0;
        $chunks = isset($_POST['chunks']) ? intval($_POST['chunks']) : 0;
        $out = @fopen("{$targetname}.part", $chunk ? 'ab' : 'wb');
        if ($out) {
            $buff = file_get_contents($FILE['tmp_name']);
            fwrite($out, $buff);
            @fclose($out);
            @unlink($FILE['tmp_name']);
        } else {
            $this->verbose(0, "Failed to open output stream", $targetname);
        }

        $res = NULL;
        if (!$chunks || $chunk == $chunks - 1) {
            rename("{$targetname}.part", $targetname);
            if (is_callable($callback)) {
                $res = $callback($targetname);
            }
        }
        $this->verbose(1, "Upload OK", $res);
    }

    private function _generateThumbnail($filename_source) {
        if (file_exists($filename_source)) {
            $image_info = getimagesize($filename_source);
            $wd = $image_info[0];
            $ht = $image_info[1];
            
            $new_height = 110;
            $new_width  = intval($wd * $new_height / $ht);
            $original = ImageCreateFromJPEG($filename_source);
            $image = imagecreatetruecolor($new_width, $new_height);
            imagecopyresized($image, $original, 0, 0, 0, 0, $new_width, $new_height, $wd, $ht);
            imagedestroy($original);
            // create the image tag, with size, class, and data
            
            ob_start();
            imagejpeg($image, NULL, 90);
            $rawImageBytes = base64_encode(ob_get_clean());
            imagedestroy($image);

            return "<img width=\"$new_width\" height=\"$new_height\" src=\"data:image/jpeg;base64,$rawImageBytes\">";
        }
        return '';
    }
	
	private function _ImageTothumbnail($imagedata) {
		$rawImageBytes = base64_encode($imagedata);
		$image = ImageCreateFromString($imagedata);
		$new_width = imagesx($image);
		$new_height = imagesy($image);
		imagedestroy($image);
		return "<img width=\"$new_width\" height=\"$new_height\" src=\"data:image/jpeg;base64,$rawImageBytes\">";
	}
    
    private function _hasoldThumbFile($data) {
/*        $basefile = str_replace('-', '.', $data->mi_date);
        
        list($yy,$mm,$dd) = explode('.', $basefile);
        $yy = intval($yy);
        $mm = intval($mm, 10);
        $dd = intval($dd, 10);
        $sequence = $yy * 10000 + $mm * 100 + $dd;
        $event = ($data->mixe_sequence == $sequence) ? 60 : 100;
        $filename = "/var/www/hopeinstoughton_www/file_up/media/{$basefile}_{$event}.jpg";
        if (file_exists($filename)) {
            return $filename;
        } */
        return FALSE;
    }
    
	private function _command_trash_item($auth, $source, $type) {
        global $wpdb, $MEDIASEARCH;
        header('Content-type: text/plain');
        $param = $this->source->decodeAuth($auth);
        if (!$param->valid) {
            die("Invalid Link");
        }
        if ($param->expired) {
            die("Expired Link");
        }
        
        $data = static::Load($param->id, ['media' => TRUE]);
		$found = NULL;
		// is it in mm_media? exit if not
		foreach($data->_media as $media_item) {
			if ($media_item->mm_source == $source && $media_item->mm_type == $type) {
				$found = $media_item;
				break;
			}
		}
		if (is_null($found)) {
			die('media item not found');
		}
		
		// delete the file
		$target = $this->source->getDestination($found->mm_source, "{$data->mi_id}.{$found->mm_type}");    // delete from filesystem
		@unlink($target);

		// remove from mm_media and update flags
		$did = '"' . esc_sql($data->mi_id) . '"';
		$dsource = '"' . esc_sql($source) . '"';
		$dtype = '"' . esc_sql($type) . '"';
        $wpdb->Query("delete from mm_media where mm_hash=$did and mm_source=$dsource and mm_type=$dtype");
		
		// update flags
		$mask = 1 << $source;
		switch($type) {
			case 'mp4':
				$wpdb->Query("update mm_item set present_mp4 = present_mp4 & ~ $mask where mi_id=$did");
				break;
			case 'jpg':
				$wpdb->Query("update mm_item set present_jpg = present_jpg & ~ $mask where mi_id=$did");
				break;
			case 'vtt':
				$wpdb->Query("update mm_item set present_vtt = present_vtt & ~ $mask where mi_id=$did");
				break;
			case 'mp3':
				$wpdb->Query("update mm_item set present_mp3 = present_mp3 & ~ $mask where mi_id=$did");
				break;
		}
		header("Location: /wp-admin/admin.php?page=talks-single&action=class&cid={$data->mi_id}");
		exit;
	}
	
    // this will not remove the entry from mm_event
    private function _command_delete_talk($auth) {
        global $wpdb, $MEDIASEARCH;
        header('Content-type: text/plain');
        $param = $this->source->decodeAuth($auth);
        if (!$param->valid) {
            die("Invalid Link");
        }
        if ($param->expired) {
            die("Expired Link");
        }
        
        $data = static::Load($param->id, ['media' => TRUE]);
        if (is_null($data)) {
            die("item \"{$param->id}\" could not be found");
        }

        if (is_object($MEDIASEARCH)) {
            $MEDIASEARCH->remove_from_xapian($data->mi_id);     // this does not remove
        }

        // remove from each cdn - mm_media
        $dhash = '"' . $data->mi_id . '"';
        foreach($data->_media as $entry) {
            $target = $this->source->getDestination($entry->mm_source, "{$data->mi_id}.{$entry->mm_type}");    // delete from filesystem
            @unlink($target);
            $dsource = '"' . $entry->mm_source . '"';
            $dtype = '"' . $entry->mm_type . '"';
            $wpdb->Query("delete from mm_media where mm_hash=$dhash and mm_source=$dsource and mm_type=$dtype");
        }
        
        $wpdb->Query("delete from mm_item where mi_id=$dhash"); // remove from database - mm_item
        $wpdb->Query("delete from mm_item_x_event where mixe_item=$dhash"); // remove from database - mm_item_x_event
        return $data;
    }
    
    private function _command_refresh_fileinfo($auth, $server) {
        header('Content-type: text/plain');
        $param = $this->source->decodeAuth($auth);
        if (!$param->valid) {
            die("Invalid Link");
        }
        if ($param->expired) {
            die("Expired Link");
        }
		$ext = $param->ext;
        
        $data = static::Load($param->id);
        if (is_null($data)) {
            die("Entry has been deleted");
        }
        
        if ($this->isPost) {
            $post = [
                'artist'    => trim($_POST['artist']),
                'album'     => trim($_POST['album']),
                'title'     => trim($_POST['title']),
                'year'      => trim($_POST['year']),
                'track'     => (int) trim($_POST['track']),
            ];
            switch($server) {
                case 3:
					if ($ext == 'mp3')
						$this->WriteMP3tags($param->id, static::CHURCH, $post);
                    break;
            }
        }
		        
        switch($server) {
            case 1:
                $this->UpdateMediaTable($param->id, static::ARIES, $ext);
                break;
            case 3:
                $this->UpdateMediaTable($param->id, static::CHURCH, $ext);
                break;
            case 4:
                $this->UpdateMediaTable($param->id, static::HOME, $ext);
                break;
        }
        if ($this->isPost) {
            $output = [
                'success' => 'unknown',
            ];
            header('Content-type: application/json');
            die(json_encode($output, JSON_UNESCAPED_SLASHES));

        }
        header("Location: /wp-admin/admin.php?page=talks-single&action=class&cid=" . $param->id);
        exit;
    }
    
    private function _command_generate_thumb($auth) {
        global $wpdb;
        header('Content-type: text/plain');
        $param = $this->source->decodeAuth($auth);
        if (!$param->valid) {
            die("Invalid Link");
        }
        if ($param->expired) {
            die("Expired Link");
        }
        
        $data = static::Load($param->id);
        if ($data->mi_type == 'ex') {       // use the xml file if it is present
            $filename_src = $this->_hasoldThumbFile($data);
            if ($filename_src === FALSE) {
                header('Location: /wp-admin/admin.php?page=talks-single&action=class&cid=' . $param->id . '&info=thumbnail+missing');
                exit;
            }
            // save the jpeg to ALCOR
            $filename_final = $this->source->getDestination(static::ARIES, $param->id . '.jpg');
            @unlink($filename_final);
            copy($filename_src, $filename_final);
            @chmod($filename_final, 0666);

            $thumb = $this->_generateThumbnail($filename_src);
        } else {
            $svr = $this->source->determineBestServer($data->present_mp4);
            $filename_src = $this->source->getDestination($svr, $param->id . '.mp4');
            
            $temp = tempnam(sys_get_temp_dir(), 'jpg');
            $filename_temp = $temp . '.jpg';
            @unlink($filename_temp);

            $dsource = escapeshellarg($filename_src);
            $dtarget = escapeshellarg($filename_temp);
            // run ffmpeg to extract very first frame
            $command = static::FFMPEG . "  -i $dsource -vframes 1 $dtarget";
            $output = `$command 2>&1`;
            file_put_contents('/phil/ffmpeg.txt', $command . "\n\n" . $output);

            // save the jpeg to ALCOR
            $filename_final = $this->source->getDestination(static::ARIES, $param->id . '.jpg');
            @unlink($filename_final);
            copy($filename_temp, $filename_final);
            @chmod($filename_final, 0666);
            
            // create a thumb
            $thumb = $this->_generateThumbnail($filename_temp);
            
            @unlink($filename_temp);
            @unlink($temp);
        }

        // update database
        $data = static::Load($param->id);
        $data->present_jpg |= 1 << static::ARIES;
        $did = '"' . esc_sql($param->id) . '"';
        $dthumb = '"' . esc_sql($thumb) . '"';
        $sql = "update mm_item set present_jpg={$data->present_jpg},mi_thumbnail={$dthumb} where mi_id=$did";
        $wpdb->Query($sql);
        
        // get file info
        $this->UpdateMediaTable($data->mi_id, static::ARIES, 'jpg');

        // redirect back to page
        header('Location: /wp-admin/admin.php?page=talks-single&action=class&cid=' . $param->id);
        exit;
    }
    
    private function _command_validate_slug($me_id) {
        global $wpdb;
        $slug = $_GET['slug'];
        $did = '"' . esc_sql($me_id) . '"';
        $dslug = '"' . esc_sql(trim($_GET['slug'])) . '"';
        $row = $wpdb->get_row("select me_slug from mm_event where me_slug=$dslug and me_id<>$did");
        $result = [
            'status' => $row === NULL ? 1 : 0,
        ];
        header('Content-type: application/json');
        die(json_encode($result, JSON_UNESCAPED_SLASHES));
    }

    private function _getFileInfo($source, $ids) {
        $baseurl = $this->source->getHost($source);
        $url = "$baseurl/info?id=" . join('.', $ids);
        $response = wp_remote_get($url, ['sslverify' => FALSE]);
        $body     = wp_remote_retrieve_body( $response );
        $body = json_decode($body, TRUE);
        return $body;
    }

    private function action_fileinfo($source) {
        global $wpdb;
        // get a list of all resources that are on the specified server
        $sql = <<<BLOCK
select mi_id,mi_other,present_mp3,present_vtt,present_mp4,present_jpg
  from mm_item as i
where (present_mp3 <> 0 or present_vtt <> 0 or present_mp4 <> 0 or present_jpg <> 0)
BLOCK;

        $resource = [];
		$items = $wpdb->get_results( $sql );
        $current = 1 << $source;
        set_time_limit(0);
        
        foreach($items as $item) {
            $servers = (int) $item->present_mp3 | (int) $item->present_mp4 | (int) $item->present_jpg | (int) $item->present_vtt;
            if ($servers & $current) {
                $resource[] = $item->mi_id;
            }
        }
        
        // then, in blocks of 16, get the info
        while (count($resource)) {
            $slice = array_splice($resource, 0, 16);
            echo join(', ', $slice) . "<br>\n";
            flush();
            
            $body = $this->_getFileInfo($source, $slice);

            // update the database
            foreach($body as $id => $id_info) {
                foreach($id_info as $ext => $iteminfo) {
                    $this->_action_fileinfo_update_mm_media($source, $id, $ext, $iteminfo);
                }
            }
        
        }
        // finally remove old entries
    }

    private function _action_fileinfo_update_mm_media($source, $id, $ext, $iteminfo) {
        global $wpdb;
        $dsource = (int) $source;
        $dhash   =  '"' . esc_sql($id) . '"';
        $dtype   =  '"' . esc_sql($ext) . '"';
        
		// fields to keep:
        $other = [];
        foreach(['bitrate', 'frequency', 'topic', 'title', 'speaker', 'year', 'track', 'ext'] as $fieldname) {
            if (isset($iteminfo[$fieldname])) {
                $other[$fieldname] = $iteminfo[$fieldname];
            }
        }

        $types = ['%s', '%d', '%s', '%d', '%s', '%d', '%s'];
        $values = [
            'mm_hash'      => $id,
            'mm_source'    => $source,
            'mm_type'      => $ext,
            'mm_filesize'  => $iteminfo['filesize'] ?? 0,
            'mm_framesize' => isset($iteminfo['framesize']) ? $iteminfo['framesize'] : NULL,
            'mm_duration'  => isset($iteminfo['duration'])  ? $iteminfo['duration'] : NULL,
            'mm_other'     => json_encode($other, JSON_UNESCAPED_SLASHES),
        ];

        $sql = "select mm_filesize,mm_duration,mm_framesize,mm_other from mm_media where mm_hash=$dhash and mm_source=$dsource and mm_type=$dtype";
        $data = $wpdb->get_row($sql);
        if (is_null($data)) { 
            $wpdb->insert('mm_media', $values, $types);
            return;
        }
        
        // has something changed?
        if ($data->mm_filesize != $values['mm_filesize'] || $data->mm_duration != $values['mm_duration'] || $data->mm_other != $values['mm_other']) {
            unset($values['mm_hash'], $values['mm_source'], $values['mm_type']);
            array_shift($types);
            array_shift($types);
            array_shift($types);
            $wpdb->update('mm_media', $values, ['mm_hash' => $id, 'mm_source' => $source, 'mm_type' => $ext], $types);
        }
    }

	public function __wp_page() {
		global $post, $wpdb;
        // this plug intercepts the text '[videoplayer action="latest-class"]' within a post/page - the page can only contain one such entry

        $hasPlayer = preg_match('~\[videoplayer(.*?)\]~ims', $post->post_content, $match) ? $match[0] : '';
        if (strpos($hasPlayer, 'action="latest-class"') !== FALSE) {
            $transcript = preg_match('~transcript="(.*?)"~', $hasPlayer, $match) ? $match[1] : FALSE;

            $sql = <<<BLOCK
select mi_id, mi_date, mi_title,
        ms_firstname, ms_lastname, ms_suffix, 
        me_slug, me_topic
  from mm_item_x_event as x
    inner join mm_item as i  on i.mi_id=x.mixe_item
    inner join mm_event as e on e.me_id = x.mixe_event
    left join mm_speaker as s on s.ms_id = i.mi_speaker
    inner join mm_media as m on m.mm_hash = x.mixe_item and m.mm_type="mp4"
where mi_type="ac" 
order by mi_date desc
  limit 1
BLOCK;
            $data = $wpdb->get_row($sql);
//            $j = '<pre>' . htmlspecialchars(var_export($data, 1)) . '</pre>';
            $j = "[videoplayer archive=\"{$data->mi_id}\" transcript=\"1\"]";
            $post->post_content = str_replace($hasPlayer, $j, $post->post_content);
        }
    }

    // https://hopeinstoughton.org/moorestown-audio/
    private function _extra_json() {
        global $wpdb, $MEDIASEARCH;
        $events = range(366, 396);
        $result = [];
        foreach($events as $event) {
            $data = static::Load(["me_id=$event"], ['first' => FALSE, 'media' => TRUE, 'order' => 'me_id, mixe_sequence']);
            if (is_null($data)) continue;
            $first = $data[0];
            $node = [
                'topic'    => $first->me_topic,
                'speaker'  => static::AsSpeaker($first),
                'location' => $first->me_location,
                'year'     => $first->me_year,
            ];
            if (is_object($MEDIASEARCH)) {
                $node['media'] = $MEDIASEARCH->generateAuth($first->mi_type, $first->mi_id, TRUE) . '#' . $first->mi_type . '_' .rawurlencode($first->mi_id);
            }

//                echo "<pre>" . htmlspecialchars(var_export($media, 1)) . "</pre>";
            $max = 0;
            foreach($data as $item) {
                if ($item->mixe_sequence > $max) $max = $item->mixe_sequence;
            }
            foreach($data as $item) {
                $other = json_decode($item->mi_other);
                list($duration, $filesize) = $this->_extra_json_deets($item->_media);
                $node['talks'][] = [
                    'title'     => $item->mi_title,
                    'class'     => "{$item->mixe_sequence} of $max",
                    'duration'  => $duration,
                    'filesize'  => $filesize,
                    'download'  => apply_filters('mm_generate_link', '', $item->mi_id, $other->original, $item->present_mp3),
                    // filesize, duration
                ];
            }
            $result[] = $node;
        }
        return $result;
    }
    
    private function _extra_json_deets($media) {
        foreach($media as $item) {
            if ($item->mm_type == 'mp3' || $item->mm_type == 'mp4') {
                $filesize = (int) $item->mm_filesize;
                $duration = (int) $item->mm_duration;
                return [$duration, $filesize];
            }
        }
        return [0, 0];
    }
    
    public function _shortcode_extras() {
        $result = $this->_extra_json();
        $output = '';
        foreach($result as $entry) {
            $ms = htmlspecialchars($entry['topic']);
            if (isset($entry['media'])) {
                $view = "\n<tr><td>Online:</td><td><a href=\"{$entry['media']}\" target=\"listennow\">Listen Now</a></td></tr>";
            } else {
                $view = '';
            }
            $output .= <<<BLOCK
<tr><td colspan="5">
<table style="width:100%;border-radius:5px;border:1px solid #999;padding:10px;background-color:#eee">
<tr><td width="120">Location:</td><td>{$entry['location']}</td></tr>
<tr><td>Topic:</td><td>$ms</td></tr>
<tr><td>Speaker:</td><td>{$entry['speaker']}</td></tr>
<tr><td>Year:</td><td>{$entry['year']}</td></tr>$view
</table>
</td></tr>
BLOCK;
            foreach($entry['talks'] as $talk) {
                $title = htmlspecialchars($talk['title']);
                $duration = static::asHMS($talk['duration']);
                $size = MediaSource::formatBytes($talk['filesize']);

                $output .= <<<BLOCK
<tr>
    <td width="370">$title</td>
    <td width="70">{$talk['class']}</td>
    <td width="70" style="text-align:right;padding-right:10px">{$duration}</td>
    <td width="90" style="text-align:right;padding-right:10px">{$size}</td>
    <td><a href="{$talk['download']}">download</a></td>
</tr>
BLOCK;
            }
            $output .= '<tr><td colspan="3">&nbsp;</td></tr>';
        }
//        $x = '<pre>' . htmlspecialchars(var_export($result, 1)) . '</pre>';
        return <<<BLOCK
<table style="width:700px">
$output
</table>

BLOCK;
    }

    // get a list of $qty files that are not on the requested $cdn.
    // returns a list containing download links for mp3, mp4, jpg or vtt files
    // run once per hour
    private function Synchronize($cdn, $qty = 50) {
        global $wpdb;
        $bitmap = 1 << $cdn;
        $where = "((present_mp3 <> 0) and (present_mp3 & $bitmap = 0)) or ((present_mp4 <> 0) and (present_mp4 & $bitmap = 0)) or ((present_vtt <> 0) and (present_vtt & $bitmap = 0)) or ((present_jpg <> 0) and (present_jpg & $bitmap = 0))";
        
        $list = static::Load(
            [$where],
            ['first' => FALSE, 'media' => TRUE, 'limit' => $qty, 'order' => 'rand()']
        );
        header('Content-type: text/plain');

        $fieldnames = ['present_mp3', 'present_mp4', 'present_vtt', 'present_jpg'];		// mp3 present may also be true for m4a
        $results = [];
        foreach($list as $item) {
            $this->source->MmItem($item);
            $filename = pathinfo($item->_other->original, PATHINFO_FILENAME);
            foreach($fieldnames as $fieldname) {
                $bmp = (int) $item->{$fieldname};
                if ($bmp && !($bmp & $bitmap)) {
                    list($filler, $type) = explode('_', $fieldname);
					// get the real extension, if one is present
					$existing = NULL;
					$ext = $type;
					foreach($item->_media as $mediaitem) {
						if ($mediaitem->mm_type == $type) {
							$existing = $mediaitem->mm_other;
							break;
						}
					}
					if (!empty($existing) && !empty($existing->ext)) {
						$ext = $existing->ext;
					}
			
                    $itemfilename = $type == 'vtt' ? $item->mi_id . '.vtt' : $filename . '.' . $ext;
                    $server = $this->source->determineBestServer($bmp);
                    $link = $this->source->generateDownloadLink($server, $itemfilename);
                    $filesize = $this->_synchronize_getFilesize($item->_media, $type);
                    $results[] = [
                        'type' => $ext,
                        'id'   => $item->mi_id,
                        'fs'   => $filesize,
                        'link' => $link,
                    ];
                }
            }
        }
        if (0) {
            // when a certificate has expired. curl will be unable to download the source, resulting in a zero-length file.  This will update them
            // get all the affected files
            $hashes = $wpdb->get_col("select distinct mm_hash from mm_media where `mm_source`={$cdn} and `mm_filesize` IS NULL");
            if (count($hashes)) {
                $hashes = join(',', array_map( function($x) {
                    return '"' . esc_sql($x) . '"';
                }, $hashes ));
                $list = static::Load(
                    [ 1, "mixe_item in ($hashes)" ],
                    [ 'first' => FALSE, 'media' => TRUE, 'limit' => 999 ]
                );
                if (is_array($list)) {
                    foreach($list as $item) {
                        $this->source->MmItem($item);
                        $filename = pathinfo($item->_other->original, PATHINFO_FILENAME);
                        foreach($fieldnames as $fieldname) {
                            $bmp = (int) $item->{$fieldname};
                            if ($bmp) {
                                list($filler, $type) = explode('_', $fieldname);
                                $itemfilename = $type == 'vtt' ? $item->mi_id . '.vtt' : $filename . '.' . $type;
                                $server = $this->source->determineBestServer($bmp & ~(1 << $cdn));      // make sure we are downloading the missing version
                                if ($server !== NULL) {
                                    $link = $this->source->generateDownloadLink($server, $itemfilename);
                                    $filesize = $this->_synchronize_getFilesize($item->_media, $type);
                                    $results[] = [
                                        'type' => $type,
                                        'id'   => $item->mi_id,
                                        'fs'   => $filesize,
                                        'link' => $link,
                                    ];
                                }
                            }
                        }
                    }
                }
            }
        }
        return $results;
    }
    
    private function _synchronize_getFilesize($media, $type) {
        foreach($media as $item) {
            if ($item->mm_type == $type) {
                return (int) $item->mm_filesize;
            }
        }
        return -1;
    }

    private function Synchronize_POST($cdn, $auth, $type) {
        global $wpdb;
        $res = $this->source->decodeAuth($auth);
        if (!$res->valid) {
            return [
                'success' => 0,
                'message' => 'Invalid authentication',
            ];
        }
        if ($type != $res->ext) {
            return [
                'success' => 0,
                'message' => 'Invalid type specified',
            ];
        }
        // mark it as valid!
        $ok2 = 1 << $cdn;
        $did = '"' . esc_sql($res->id) . '"';
        switch($res->ext) {
            case 'vtt':
                $wpdb->Query("update mm_item set present_vtt = present_vtt | $ok2 where mi_id=$did");
                break;
            case 'jpg':
                $wpdb->Query("update mm_item set present_jpg = present_jpg | $ok2 where mi_id=$did");
                break;
            case 'mp4':
                $wpdb->Query("update mm_item set present_mp4 = present_mp4 | $ok2 where mi_id=$did");
                break;
            case 'm4a':
            case 'mp3':
                $wpdb->Query("update mm_item set present_mp3 = present_mp3 | $ok2 where mi_id=$did");
                break;
        }
        $this->UpdateMediaTable($res->id, $cdn, $res->ext);
        return [
            'success' => 1,
            'message' => 'OK',
            'cdn'     => $cdn,
            'item'    => $res->id,
        ];
    }
	
	private function Synchronize_OldFiles($cdn, $oldfiles) {
        global $wpdb;
		$sql = <<<BLOCK
select mi_id,mi_type,mm_type,mm_filesize 
  from mm_media
    inner join mm_item on mm_hash=mi_id
where mm_source = $cdn
  and mm_type in ('mp3', 'mp4')
  and mi_type="other"
order by mm_filesize desc  
limit 10
BLOCK;
		$items = $wpdb->get_results( $sql );
		foreach($items as $item) {
			$item->mm_filesize = intval($item->mm_filesize / 1024 / 1024);
		}
		return $items;
	}

	// this hook does not exist in newer versions of the plugin - so only the placeholders are shown
    public function __carousel($html, $post_id) {
        $videos = [];       // array: of datetime / title / url / thumbnail
        
        // assemble list of videos to be displayed
        $this->_carousel_lastservice($videos); // current full service
        $this->_carousel_liveevent($videos);
        $this->_carousel_lastadultclass($videos);
        $this->_carousel_lastexhort($videos);
        $this->_carousel_lastbibleclass($videos);
        $this->_carousel_lastonesource($videos);    // Richard

        // sort by newest to oldest
        usort($videos, function($a, $b) {
            return $b['datetime'] - $a['datetime'];
        });
        
        // generate the html
        $html = $this->_carousel_generatehtml($html, $videos, $post_id);
        return $html;
    }

    private function _carousel_lastservice(&$videos) {
        global $wpdb;
		// get sunday
		$info = video_archive_class::getLastSundayService(TRUE);
		if ($info !== FALSE) {
			$dt = str_replace('.', '-', $info['date']);
			$date = $dt . ' 06:00';
			$videos[] = [
				'datetime' => strtotime($date),
				'title'    => $info['speaker'],
				'event'    => 'Memorial Service',
				'url'      => '/members/current',
				'thumb'    => $info['thumb'],
			];
		}
    }

    //      live video - only if password level is correct
    private function _carousel_liveevent(&$videos) {
        // are we live - is the youtube link present
        $info = apply_filters('youtubeschedule-active', ['active' => 0]);
        if ($info['active']) {
            $videos[] = [
                'datetime' => time(),
                'title'    => $info['desc'],
                'event'    => 'Live Now',
                'url'      => $info['link'],
                'thumb'    => $this->URL . '/logo_youtube.jpg',
            ];
        }
        
        // youtubeschedule-zoom - only public events, a different account is used for business meetings
        $info = apply_filters('youtubeschedule-zoom', ['active' => 0]);
        if ($info['active']) {
            $videos[] = [
                'datetime' => time() - 1800,
                'title'    => $info['desc'],
                'event'    => 'Live Now',
                'url'      => $info['link'],
                'thumb'    => $this->URL . '/logo_zoom.jpg',
            ];
        }
        
    }

    private function _carousel_lastadultclass(&$videos) {
        global $MEDIASEARCH;
		$cutoff = date('Y-m-d', time() - 13 * 86400);
        $row =  static::Load(['((mi_type="ac") or (mi_type="bapt"))', 'present_mp4 <> 0', "mi_date >= \"$cutoff\""],
                              ['order' => 'mi_date desc'] );
        if ($row !== NULL) {
            $date = $row->mi_date;
            if (is_object($MEDIASEARCH)) {
                $link = $MEDIASEARCH->generateAuth($row->mi_type, $row->mi_id, TRUE) . '#' . $row->mi_type . '_' . $row->mi_id;
            } else {
                $link = '#';
            }
			$tp = $row->mi_type;
            $videos[] = [
                'datetime' => strtotime("$date 09:00"),
                'title'    => $this->_carousel_speaker($row),
                'url'      => $link,
                'event'    => $tp == 'ac' ? 'Adult Class' : 'Baptism',
                'thumb'    => $this->_carousel_clean($row->mi_thumbnail),
            ];
        }
    }
    
    private function _carousel_speaker($row) {
        $name = trim($row->ms_firstname . ' ' . $row->ms_lastname);
        if ($row->ms_suffix) $name .= ' ' . ucfirst($row->ms_suffix);
        return $name;
    }

    private function _carousel_lastexhort(&$videos) {   // or two
        global $MEDIASEARCH;
        list($yy, $mm, $dd) = explode(',', date('Y,n,j', time() + 86400));
        $today = $yy * 10000 + $mm * 100 + $dd;
        
        $rows =  static::Load(['mixe_event=' . static::EXHORT, "mixe_sequence<$today"],
                              ['first' => FALSE, 'thumb' => TRUE, 'limit' => 5, 'order' => 'mixe_sequence desc'] );
        $date = FALSE;
        
        foreach($rows as $row) {
            if ($date === FALSE) $date = $row->mi_date;
            
            if ($row->mi_date != $date) 
                break;

            list($yy, $mm, $dd) = explode('-', $row->mi_date);
            $thisdate = $yy * 10000 + intval($mm, 10) * 100 + intval($dd, 10);

            // don't care about actual time, only that third exhort comes after the second.
            if ($thisdate == $row->mixe_sequence) {
                $time = '11:00';
                $event = 'Exhort';
            } else {
                $time = '10:00';
                $event = 'Devotion';
            }
            if (is_object($MEDIASEARCH)) {
                $link = $MEDIASEARCH->generateAuth($row->mi_type, $row->mi_id, TRUE);
            } else {
                $link = '#';
            }
            $videos[] = [
                'datetime' => strtotime("$date $time"),
                'title'    => $this->_carousel_speaker($row),
                'url'      => $link,
                'event'    => $event,
                'thumb'    => $this->_carousel_clean($row->mi_thumbnail),
            ];
        }
        return NULL;
    }

    private function _carousel_lastonesource(&$videos) {
        global $MEDIASEARCH;
        $row =  static::Load(['present_mp4 <> 0', 'mixe_event = 588'],
                              ['order' => 'mi_date desc'] );
        if ($row !== NULL) {
            $date = $row->mi_date;
            $dt = strtotime("$date 19:30");
            if ($dt > time() - 86400 * 10) {
                if (is_object($MEDIASEARCH)) {
                    $link = $MEDIASEARCH->generateAuth('in', $row->mi_id, TRUE);
                } else {
                    $link = '#';
                }
                $videos[] = [
                    'datetime' => $dt,
                    'title'    => $this->_carousel_speaker($row),
                    'event'    => 'The One Source',
                    'url'      => $link,
                    'thumb'    => $this->_carousel_clean($row->mi_thumbnail),
                ];
            }
        }
    }

    private function _carousel_lastbibleclass(&$videos) {
        global $MEDIASEARCH;
        $row =  static::Load(['mi_type="bc"', 'present_mp4 <> 0'],
                              ['order' => 'mi_date desc'] );
        if ($row !== NULL) {
            $date = $row->mi_date;
            if (is_object($MEDIASEARCH)) {
                $link = $MEDIASEARCH->generateAuth($row->mi_type, $row->mi_id, TRUE);
            } else {
                $link = '#';
            }
            $videos[] = [
                'datetime' => strtotime("$date 19:30"),
                'title'    => $this->_carousel_speaker($row),
                'event'    => 'Bible Class',
                'url'      => $link,
                'thumb'    => $this->_carousel_clean($row->mi_thumbnail),
            ];
        }
    }
    
    private function _carousel_clean($data) {
        return preg_match('~src="(.*?)"~', $data, $match) ? $match[1] : '';
    }

    private function _carousel_generatehtml($html, $videos, $post_id) {
        $slickoptions = preg_match("~data-slick='(.*?)'~", $html, $match) ? $match[1] : '';
//        echo "<pre>" . htmlspecialchars(var_export($videos, 1)) . '</pre>';
        $shortcode_data = get_post_meta( $post_id, 'sp_wpcp_shortcode_options', true );
        $carousel_direction = isset( $shortcode_data['wpcp_carousel_direction'] ) ? $shortcode_data['wpcp_carousel_direction'] : '';
        
        $xpostid = esc_attr($post_id);
        $the_rtl = ( 'ltr' === $carousel_direction ) ? 'dir="rtl"' : 'dir="ltr"';
        
        $slides = '';
        foreach($videos as $video) {
            $safe_title = esc_html($video['title']);
            $safe_link = esc_html($video['url']);
            if ($video['event'] != '') $safe_title .= '<br /><br />';
            $slides .= <<<BLOCK
<div class="wpcp-single-item detail-with-overlay">
    <div class="wpcp-slide-image">
        <a href="{$safe_link}" target="_blank"><img class="skip-lazy" src="{$video['thumb']}" alt="{$video['title']}"></a>
    </div>
    <div class="wpcp-all-captions" style="pointer-events: none;">
        <div class="wpcp-image-caption">
            {$safe_title}
        </div>
        <div class="wpcp-image-event">
            {$video['event']}
        </div>
    </div>
</div>
BLOCK;
        }

        return <<<BLOCK
<div class="wpcp-carousel-wrapper wpcp-wrapper-{$xpostid}">
    <div id="sp-wp-carousel-free-id-$xpostid" class="wpcp-carousel-section sp-wpcp-$xpostid nav-vertical-center wpcp-image-carousel detail-with-overlay overlay-on-hover wpcp-standard" data-slick='$slickoptions' {$the_rtl}>
{$slides}
    </div>
</div>
BLOCK;
    }
    
    protected function action_import_cygnus($me_id) {
        // get data from cygnus  - speaker, thumbnail, sequence#
        $curl = new WP_Http_Curl;
        $response = $curl->request('https://cygnus.hopeinstoughton.org/~/videoEdit/current');
        $response = json_decode($response['body'], TRUE);
        // show date. thumb and speaker and duration
        // if ok to continue, create entries, copy image 
        // copy image
        
        // type":"60","in":1913.117053,"out":4070.544448,"speaker":"Jim Sullivan","date":"2022.01.23","thumb":"/
        foreach($response as $item) {
			if ($item['type'] == 40) continue;	// ignore announcements at this time
            $sequence = $this->_extractSequenceFromEntry($item);
            $duration = static::asHMS($item['out'] - $item['in']);
            echo <<<BLOCK
<div>
<img src="data:image/jpeg;base64,{$item['thumb']}">
<p>{$sequence}. {$item['data']} {$item['speaker']} [{$duration}]</p>
</div>
BLOCK;
        }
        $ex = static::EXHORT;
//<div><a href="?page=talks-single&amp;action=cygnus2&amp;eid=$ex">Continue and add to HopeInStoughton</a></div>
        echo <<<BLOCK
<div><a href="/~/media-add-cygnus">Continue and add to HopeInStoughton</a></div>
BLOCK;
    }
	
	private function _command_add_from_cygnus() {
		$hash = $this->action_import_cygnus2(static::EXHORT);
		// redirect to newly added entry
		wp_redirect( admin_url( 'admin.php?page=talks-single&action=class&cid=' . $hash ) );
		exit;
	}
    
    private function _extractSequenceFromEntry($item) {
        list($yy, $mm, $dd) = explode('.', $item['date']);
        $yy = intval($yy, 10); $mm = intval($mm, 10); $dd = intval($dd, 10);
        $dt = gmmktime(12, 0, 0, $mm, $dd, $yy);
        if (isset($item['type']) && $item['type'] == 100) {
            $dt += 86400;
        }
        list($yy, $mm, $dd) = explode('.', gmdate('Y.n.j', $dt));
        return $yy * 10000 + $mm * 100 + $dd;
    }

    protected function action_import_cygnus2($me_id) {
        $curl = new WP_Http_Curl;
		$args = [
			'user-agent' => '',
			'stream' => FALSE,
			'filename' => '',
		];

        $response = $curl->request('https://cygnus.hopeinstoughton.org/~/videoEdit/current', $args);
        $response = json_decode($response['body'], TRUE);
		$hash = '';
        foreach($response as $item) {
			if ($item['type'] == 40) continue;	// ignore announcements at this time
            $sequence = $this->_extractSequenceFromEntry($item);
            $speaker = $item['speaker'];                                                // firstname lastname
            $vitem = [
                'SPEAKER'   => $speaker,
                'THUMBNAIL' => $item['thumb']
            ];
            $thishash = $this->_preparev($sequence, $vitem, $item['date'], $item['type']);
			if ($hash == '') $hash = $thishash;
        }
		return $hash;
    }
	
	private function _command_calendar_link($mi_id) {
		global $wpdb, $MEDIASEARCH;
		// determine correct url
		$data = static::Load($mi_id, ['media' => FALSE]);
		$link = '/video';
		switch($data->mi_type) {
			case 'ac':
				$key = $data->mi_date;
				$category = 3;
				$link = '/' . $data->me_slug;
				if ($data->mixe_sequence > 1) $link .= '/' . $data->mixe_sequence;
				break;
			case 'ex':	// not for third exhort
				$key = $data->mi_date;
				$category = 2;
				$item = ['date' => str_replace('-', '.', $key)];
				$seq = $this->_extractSequenceFromEntry($item);
				if ($seq != $data->mixe_sequence) {
					return "Link generation not for devotional\n";
				}
				// password needed to access this content
				$link = '/members/video-archive/?video-id=' . $data->mi_id;
				break;
			case 'bc':
				$key = $data->mi_date;
				$category = 4;
				$link = '/' . $data->me_slug;
				if ($data->mixe_sequence > 1) $link .= '/' . $data->mixe_sequence;
				break;
			default:
				return "Link generation method unknown for type \"{$data->mi_type}\"\n";
		}
		// update calendar entry, if it exists
		$tablename = $wpdb->get_blog_prefix().WP_SPIFFYCAL_TABLE;
		$sql = $wpdb->prepare("update $tablename set event_link='%s' where event_begin='%s' and event_category=%d", $link, $key, $category);
		$wpdb->get_results($sql);
		return FALSE;
	}
	
	private function _command_generate_stats() {
		
		$result = [];
		$this->_generate_stats($result, 'Stoughton Exhortations', 'ex');
		$this->_generate_stats($result, 'Stoughton Adult Classes', 'ac');
		$this->_generate_stats($result, 'Stoughton Bible Classes', 'bc');
		$this->_generate_stats($result, 'Moorestown', 'mt');
		$this->_generate_stats($result, 'ChristadelphianBibleTalks', 'cbt');
		$jsontext = json_encode($result, JSON_UNESCAPED_SLASHES);
		var_export($jsontext);
		update_option('media-manager-stats', $result, FALSE);
	}
	
	private function _generate_stats(&$result, $caption, $type) {
		global $wpdb, $MEDIASEARCH;
		$speakers = 0;
		$talks = 0;
		$duration = 0;
		
		$sql = <<<'BLOCK'
select mi_speaker, count(mi_id) as talks, sum(m1.mm_duration) as duration 
  from `mm_item` 
  inner join mm_media as m1 on m1.mm_hash=mi_id and not m1.mm_duration is null
  left join mm_media as m2 on m2.mm_hash=mi_id and not m2.mm_duration is null and m2.mm_source > m1.mm_source
where mi_type='%s' and m2.mm_hash is null
group by mi_speaker
BLOCK;
		$sql = $wpdb->prepare($sql, $type);
		$items = $wpdb->get_results($sql);
		foreach($items as $item) {
			$speakers ++;
			$talks    += (int)$item->talks;
			$duration += (int)$item->duration;
		}
		
		$result[] = [
			'type'	   => $type,
//			'source'   => $caption,
			'speakers' => $speakers,
			'talks'    => $talks,
			'duration' => $duration,
			
		];
	}
	
/* crontab entry for user stoughton
	15,45 * * * * curl -k -s -o /dev/null -v https://hopeinstoughton.org/~/media-cron > /dev/null 2>&1
*/
	
	private function _command_cron() {
		global $wpdb;
		set_time_limit(0);
		// get talks with YouTube url that does not have captions
	$sql = <<<BLOCK
select mi_id, mi_other
  from mm_item
where (present_mp3<>0 or present_mp4<>0) 
  and present_vtt=0
  and mi_type <> "other"
  and (mi_other like "%youtube.%" or mi_other like "%youtu.be%")
order by rand()
limit 5  
BLOCK;


		$items = $wpdb->get_results($sql);
		foreach($items as $item) {
			$date = date('Y-m-d H:i:s');
			$payload = json_decode($item->mi_other, TRUE);
			echo "$date: Processing {$payload['youtube']} [$item->mi_id]\n";
			flush();
			echo $this->_command_cron_youtube($item->mi_id, $payload['youtube']);
			flush();
		}
		die("Done\n");
	}
	
	// do we have youtube closedcaptions for this talk?
	private function _command_cron_youtube($mi_id, $youtube) {
        global $wpdb, $MEDIASEARCH;

		$url = $this->_getExternalMediaURL($mi_id);
        if (is_null($url)) {
			return "unknown talk identifier";
        }
		$youtube = $this->_getExternalMedia($url);
        if ($youtube === FALSE) {
            return "Original Url is missing or unrecognized";
        }
		if (!$youtube->hasCaption()) {
			return "Captioning not yet present";
		}

		// download and save the captions, update database
		$temp = new \StdClass;
		$temp->content = $youtube->Caption();
		$result = $this->_youtube_saveVtt($temp, $mi_id);
		if (strpos($result, 'SUCCESS') !== FALSE) {
			if (is_object($MEDIASEARCH)) {
				$MEDIASEARCH->command_reindex($mi_id);
			} else {
				return "Unable to index captioning file";
			}
		} else {
			return $result;
		}
		return '';
	}
	
	private function _command_archive_content($event_id) {
		// get all the videos not on HOSTZ
		$rows = $this->_command_datatables_classeslist($event_id, FALSE);
		$target = 1 << static::HOSTZ;
		foreach($rows as $row) {
			$bitmap = (int) $row['present_mp4'];
			$dt = date('Y-m-d h:i:s');
			if (!$bitmap) {
				echo "$dt Item {$row['mixe_sequence']} [{$row['mi_id']}] does not have video\n";
				flush();
			} elseif (!($bitmap & $target)) {		// not on CDN2
				echo "$dt Processing {$row['mixe_sequence']} [{$row['mi_id']}]\n";
				flush();
				// HOSTZ no longer exists so no need to copy the file
//				$this->_archive_content_copyfile($row['mi_id'], $bitmap, static::HOSTZ);
				if ($bitmap && (1 << static::ARIES)) {
					$this->_archive_content_remove($row['mi_id'], static::ARIES);			// remove from aries
				}
			} else {
				echo "$dt Item {$row['mixe_sequence']} [{$row['mi_id']}] has been archived\n";
				flush();
			}
		}
		echo "\nDone\n";
		exit;
	}
	
	private function _command_archive_content2($mi_id) {
		global $wpdb;
		// load the id
		$did = '"' . esc_sql($mi_id) . '"';
		$item = $wpdb->get_row("select mi_id,present_mp4,present_mp3 from mm_item where mi_id = $did");
		$bitmap = (int) $item->present_mp4;
		$bitmap3 = (int) $item->present_mp3;

		$dt = date('Y-m-d h:i:s');
		echo "$dt Processing {$item->mi_id}...\n";
		flush();

		if ($bitmap && (1 << static::ARIES)) {
			$this->_archive_content_remove($item->mi_id, static::ARIES);			// remove from aries
		}
	}

	
	private function _command_media_clean() {
		global $wpdb;
/*		
		$sql = "SELECT mi_id, mi_title FROM `mm_item` WHERE `mi_title` LIKE '%.mp3'";
		$items = $wpdb->get_results($sql);
		foreach($items as $item) {
			$title = str_replace(['.mp3', '.MP3'], '', $item->mi_title);
			echo "{$item->mi_id}. $title\n";
			
            $did = '"' . esc_sql($item->mi_id) . '"';
            $safetitle = '"' . esc_sql($title) . '"';
//			echo "update mm_item set mi_title={$safetitle} where mi_id=$did\n";
            $wpdb->Query("update mm_item set mi_title={$safetitle} where mi_id=$did");
			
		}

		$sql = "SELECT * FROM `mm_media` WHERE `mm_other` LIKE '%.mp3%'";
		$items = $wpdb->get_results($sql);
		foreach($items as $item) {
			$source = $item->mm_source;
			$hash = $item->mm_hash;
			$payload = json_decode($item->mm_other, TRUE);
			$payload['title'] = str_replace(['.mp3', '.MP3'], '', $payload['title']);
			
			$safesource = '"' . esc_sql($source) . '"';
			$safehash = '"' . esc_sql($hash) . '"';
			$safepayload = '"' . esc_sql(str_replace('\\/', '/', json_encode($payload))) . '"';
			$query = "update `mm_media` set mm_other={$safepayload} where mm_source={$safesource} and mm_hash={$safehash}";
			$wpdb->Query($query);
		}

		$sql = "SELECT mi_id,mi_other FROM `mm_item` WHERE `mi_other` LIKE '%.mp3.mp3%'";
		$items = $wpdb->get_results($sql);
		foreach($items as $item) {
			$hash = $item->mi_id;
			$payload = json_decode($item->mi_other, TRUE);
			$payload['original'] = str_replace(['.mp3.mp3', '.MP3.mp3', '.MP3.MP3'], '.mp3', $payload['original']);
			$safehash = '"' . esc_sql($hash) . '"';
			$safepayload = '"' . esc_sql(str_replace('\\/', '/', json_encode($payload))) . '"';
			$query = "update `mm_item` set mi_other={$safepayload} where mi_id={$safehash}";
			//echo "$query\n";
			$wpdb->Query($query);
			
		}
*/
	}
	
	private function _archive_content_copyfile($mi_id, $sources, $target) {
		global $wpdb;
		// get source folder/file
		$src = $this->source->determineBestServer($sources, FALSE);
		$filename_src = $this->source->getDestination($src, $mi_id . '.mp4');
		$filename_target = $this->source->getDestination($target, $mi_id . '.mp4');
		// copy the file - may take a while
		copy($filename_src, $filename_target);
		
		// add media record
		$this->UpdateMediaTable($mi_id, $target, 'mp4');
		
		// update flags
		$mask = 1 << $target;
		$did = '"' . esc_sql($mi_id) . '"';
		
		$wpdb->Query("update mm_item set present_mp4 = present_mp4 | $mask where mi_id=$did");
	}
	
	private function _archive_content_remove($mi_id, $target) {
		global $wpdb;
		$filename_target = $this->source->getDestination($target, $mi_id . '.mp4');
		@unlink($filename_target);

		// remove media record
		$did = '"' . esc_sql($mi_id) . '"';
		$dsource = '"' . esc_sql($target) . '"';
        $wpdb->Query("delete from mm_media where mm_hash=$did and mm_source=$dsource and mm_type=\"mp4\"");
		
		// update flags
		$mask = 1 << $target;
		$wpdb->Query("update mm_item set present_mp4 = present_mp4 & ~ $mask where mi_id=$did");

	}

	// generate sql to resolve various problems
	private function cdn_debug() {
		
/*		// generate an authkey for test 
		$date = '2025.01.19';
		$key = MediaSource::GenerateServiceAuth($date);
		var_dump($key); */
		
		global $wpdb;
		
		
		$target = static::GOOGLE;
		$mask = 1 << $target;
		$sql = <<<BLOCK
select mi_id,present_mp4,mm_source from mm_item
  left join mm_media on mm_hash=mi_id and mm_source = $target and mm_type='mp4'
where (present_mp4 <> 0) and (present_mp4 & $mask = 0) and not mm_type is null
BLOCK;
		$sql = <<<BLOCK
select mi_id,present_mp3,mm_source from mm_item
  left join mm_media on mm_hash=mi_id and mm_source = $target and mm_type='mp3'
where (present_mp3 <> 0) and (present_mp3 & $mask = 0) and not mm_type is null
BLOCK;
		$items = $wpdb->get_results($sql);
		
		echo "$sql\n\n";
		var_dump($items); 
		return;
		
/*		foreach($items as $item) {
			$did = '"' . esc_sql($item->mi_id) . '"';
			$wpdb->Query("update mm_item set present_mp4 = present_mp4 & ~ $mask where mi_id=$did");
		} */
		foreach($items as $item) {
			$did = '"' . esc_sql($item->mi_id) . '"';
			$wpdb->Query("update mm_item set present_mp3 = present_mp3 | $mask where mi_id=$did");
		}
		// everything that should be on 4.
	}
	
	// we have incorrect filesize stored in db
	// file present at home, but no at-home entry: (1726 entries)
	private function command_repair_mp3() {
		global $wpdb;
		$target = 4;
		$mask = 1 << $target;

		// mp3 present on target system - but missing in mm_media table
		$sql = <<<BLOCK
select mi_id,present_mp3,mi_other
  from mm_item as i
	left join mm_media on mi_id=mm_hash and mm_source=$target and (mm_type='mp3' or mm_type='wma')
where (present_mp3 & $mask <> 0) and mm_hash is null
BLOCK;
		$items = $wpdb->get_results($sql);
		foreach($items as $index => $item) {
			$item->mi_other = json_decode($item->mi_other, TRUE);
			$this->_command_repair_mp3_check($item);
			if ($index > 100) break;
		}

		$sql = <<<BLOCK
select mi_id,present_vtt,mi_other
  from mm_item as i
	left join mm_media on mi_id=mm_hash and mm_source=$target and (mm_type='vtt')
where (present_vtt & $mask <> 0) and mm_hash is null
BLOCK;
		$items = $wpdb->get_results($sql);
		foreach($items as $index => $item) {
			$item->mi_other = json_decode($item->mi_other, TRUE);
			$this->_command_repair_vtt($item);
			if ($index > 100) break;
		}

	}
	
	private function _command_repair_mp3_check($item) {
		global $wpdb;
		$id = $item->mi_id;
		// get original filesize from cdn3
		$dest = $this->source->getDestination(3, $id . '.mp3');
		$filesize_orginal = filesize($dest);
		if (!$filesize_orginal) {
			echo "$dest - empty file\n";
			flush();
			return;
		}
		$filesize_current = (int) $item->mi_other['filesize'];
		if  ($filesize_orginal != $filesize_current) {
			// update the filesize in the mm_item table
			$item->mi_other['filesize'] = $filesize_orginal . '';

			$did = '"' . esc_sql($id) . '"';
			$safeother = '"' . esc_sql(json_encode($item->mi_other, JSON_UNESCAPED_SLASHES)) . '"';
			$sql = "update mm_item set mi_other={$safeother} where mi_id=$did";
			$wpdb->Query($sql);
			
			echo "$dest - filesize adjusted from $filesize_current to $filesize_orginal\n";
			flush();
		}

		$args = [
			'user-agent' => '',
			'stream' => FALSE,
			'filename' => '',
		];
        $curl = new WP_Http_Curl;
        $response = $curl->request('https://cdn3.hopeinstoughton.org/info?id=' . $id, $args);
        $response = json_decode($response['body'], TRUE);

		if (isset($response[$id]['mp3'])) {
			//  add the correct entry into mm_media table
			// type, filesize duration, bitrate, mp3 tags
			$info = $response[$id]['mp3'];
			$filesize = $info['filesize'];
			$duration = $info['duration'];
			unset($info['filesize'], $info['duration'], $info['track']);

			$types = ['%s', '%d', '%s', '%d', '%d', '%s'];
			$values = [
				'mm_hash'		=> $id,
				'mm_source'		=> 4,
				'mm_type' 		=> 'mp3',
				'mm_filesize'	=> $filesize,
				'mm_duration'	=> $duration,
				'mm_other'		=> json_encode($info, JSON_UNESCAPED_SLASHES),
			];
			$result = $wpdb->insert('mm_media', $values, $types);
			if ($result) return 1;
			
			echo "Unable to add entry for $id\n";
			flush();
		}
		return 0;
	}
	
	private function _command_repair_vtt($item) {
		global $wpdb;
		$id = $item->mi_id;
		$args = [
			'user-agent' => '',
			'stream' => FALSE,
			'filename' => '',
		];
        $curl = new WP_Http_Curl;
        $response = $curl->request('https://cdn3.hopeinstoughton.org/info?id=' . $id, $args);
        $response = json_decode($response['body'], TRUE);

		if (isset($response[$id]['vtt'])) {
			$info = $response[$id]['vtt'];
			$filesize = $info['filesize'];

			$types = ['%s', '%d', '%s', '%d', '%d', '%s'];
			$values = [
				'mm_hash'		=> $id,
				'mm_source'		=> static::HOME,
				'mm_type' 		=> 'vtt',
				'mm_filesize'	=> $filesize,
				'mm_other'		=> '[]',
			];
			$result = $wpdb->insert('mm_media', $values, $types);
			if ($result) return 1;
			
			echo "Unable to add entry for $id\n";
			flush();

		}
	}
	
	private function _command_google() {
		global $wpdb;
        $curl = new WP_Http_Curl;
		$args = [
			'user-agent' => '',
			'stream' => FALSE,
			'filename' => '',
		];

        $response = $curl->request('https://media.hopeinstoughton.org/~/mediaHiS/cdn-summary', $args);
        $response = json_decode($response['body'], TRUE);
		$types = ['%s', '%d', '%s', '%d', '%s'];
		foreach($response as $hash => $ext) {
			$list = explode(',', $ext);
			foreach($list as $fileext) {
				$values = [
					'mm_hash'		=> $hash,
					'mm_source'		=> static::GOOGLE,
					'mm_type' 		=> $fileext,
					'mm_filesize'	=> 0,
					'mm_other'		=> '[]',
				];
				$result = $wpdb->insert('mm_media', $values, $types);
			}
		}
		
		die("Google Drive import done.\n");
		
	}
	
	public function UploadFile($filename, $type, $debug = FALSE) {
		$keyfile = __DIR__ . '/cdn/.ht-www@mars.pem';
		$safekey = escapeshellarg($keyfile);
		$safefile = escapeshellarg($filename);
		// if - Host key verification failed.   - run the command from bash as user www-data - add key to the file
		$command = "scp -i $safekey $safefile www-data@mars.hostz.org:/var/www/.incoming";
		$res = `$command 2>&1`;
		$xcommand = "scp $safefile www-data@mars.hostz.org:/var/www/.incoming";
		if ($debug) echo "<pre>" . htmlspecialchars("$xcommand\n$res\n") . "</pre>";
//		echo "<pre>" . htmlspecialchars("$command\n$res\n") . "</pre>";
		$base = pathinfo($filename, PATHINFO_BASENAME);
		$year = substr($base, 0, 4);
		$single = $base[0];
		switch($type) {
			case 'sun':
				$target = 'Memorial Service/' . $year;
				break;
			
			default:
				$target = 'CDN/' . $single;
				break;
		}

		$payload = [
			'base' 		=> $base,
			'target' 	=> $target,				// target folder
		];

		$safebase = rawurlencode($base);
		$safetarget = rawurlencode($target);
		$command = "/usr/bin/curl -X POST -H \"Content-Type: application/x-www-form-urlencoded\" -d \"base={$safebase}&target={$safetarget}\" https://media.hopeinstoughton.org/~/mediahis/upload";
		`$command > /dev/null 2&>1 &`;
	}
	
	// add the file to the whisper queue, as a priority event
	private function action_whisper($mi_id) {
		$url = 'https://hub.pjy.us/~/whisper/add-to-queue/' . $mi_id;
		$curl = new WP_Http_Curl;
		$args = [
			'redirection' => 5,
			'user-agent' => '',
			'stream' => FALSE,
			'filename' => '',
			'decompress' => TRUE,
			'_redirection' => 5,
		];
		$response = $curl->request($url, $args);
		if (is_a($response, 'WP_Error')) {
			header('Content-type: text/plain');
			var_dump($url);
			var_dump($response);
			exit;
		}
		//
	}
	
	// export the files to googledrive - everything must be on aries first
	private function action_googledrive($mi_id) {
		global $wpdb;
		
		// using scp, copy each of these to mars, and run curl to update the database.  
		// vtt, mp3, mp4
		$safeid = esc_sql($mi_id);
		// this is not great sql
		$sql = <<<BLOCK
select mm_type, mm_source as ms, ms_url as mu, ms_path as mp  
  from `mm_media` 
    left join mm_source on mm_source=ms_id
where `mm_hash` = '$safeid'
group by mm_type;
		
BLOCK;
//   and mm_type='vtt'
		echo "<br><br><br>";
		$items = $wpdb->get_results($sql);
		$types = ['%s', '%d', '%s', '%d', '%s'];
		foreach($items as $item) {
			$base = $mi_id;
			echo "Copying from {$item->mu}...<br>\n";
			flush();
			$filename = $item->mp . '/' . $base[0] . '/' . $base . '.' . $item->mm_type;
			$this->UploadFile($filename, 'hash', TRUE);			// copy to mars
			
			$values = [
				'mm_hash'		=> $base,
				'mm_source'		=> static::GOOGLE,
				'mm_type' 		=> $item->mm_type,
				'mm_filesize'	=> 0,
				'mm_other'		=> '[]',
			];
			$result = $wpdb->insert('mm_media', $values, $types);

			$did = '"' . esc_sql($mi_id) . '"';
			$mask = 1 << static::GOOGLE;
			if ($item->mm_type == 'mp3') {
				$wpdb->Query("update mm_item set present_mp3 = present_mp3 | $mask where mi_id=$did");
			} elseif ($item->mm_type == 'mp4') {
				$wpdb->Query("update mm_item set present_mp4 = present_mp4 | $mask where mi_id=$did");
			} elseif ($item->mm_type == 'vtt') {
				$wpdb->Query("update mm_item set present_vtt = present_vtt | $mask where mi_id=$did");
			} elseif ($item->mm_type == 'jpg') {
				$wpdb->Query("update mm_item set present_jpg = present_jpg | $mask where mi_id=$did");
			}
		}
		$deet = admin_url( 'admin.php?page=talks-single&action=class&cid=' . $mi_id );
		echo "<p><a href=\"{$deet}\">Return</a> to Talk Detail page</p>";  
		//echo "<br><pre>" . htmlspecialchars(var_export($items, 1)) . "</pre>";
	}
	
	private function _repairVttDistribution($row) {
		$media = static::Load($row['mi_id'], ['media' => TRUE]);
		// load the Aries vtt
		$source = $this->source->getDestination(static::ARIES, $row['mi_id'] . '.vtt');
		$content = file_get_contents($source);
		$data = new \StdClass;
		$data->mi_id = $row['mi_id'];

		foreach($media->_media as $row) {
			if ($row->mm_type == 'mp3' && ($row->mm_source == static::CHURCH || $row->mm_source == static::GOOGLE)) {
				switch($row->mm_source) {
					case static::CHURCH: 
						$this->_ProcessVTTfile_upload(static::CHURCH, $data, $content);						// save to stoughton
						break;

					case static::GOOGLE: 
						$this->_ProcessVTTfile_upload(static::GOOGLE, $data, '');			// copy to GoogleDrive - must be done after Aries copy (but aries version is up-to-date here
						break;
				}
			}
		}
	}
	
	public function repairVttDistribution() {
		set_time_limit(0);
		$mm_media = [
  ['mi_id' => 'f7f595d6','mc_filesize' => '368825','ma_filesize' => '232278'],
  ['mi_id' => 'f82620ac','mc_filesize' => '345877','ma_filesize' => '226681'],
  ['mi_id' => 'f85bb7fa','mc_filesize' => '284553','ma_filesize' => '181157'],
  ['mi_id' => 'f8e3b77f','mc_filesize' => '506964','ma_filesize' => '309198'],
  ['mi_id' => 'f98ab968','mc_filesize' => '468291','ma_filesize' => '306204'],
  ['mi_id' => 'f9e0adb5','mc_filesize' => '515587','ma_filesize' => '338458'],
  ['mi_id' => 'f9f2d70f','mc_filesize' => '354701','ma_filesize' => '231657'],
  ['mi_id' => 'fa1ca367','mc_filesize' => '296426','ma_filesize' => '184372'],
  ['mi_id' => 'fa724f8b','mc_filesize' => '515587','ma_filesize' => '338458'],
  ['mi_id' => 'fa88a7fc','mc_filesize' => '434148','ma_filesize' => '290179'],
  ['mi_id' => 'fb328507','mc_filesize' => '358821','ma_filesize' => '223725'],
  ['mi_id' => 'fb8ed4a7','mc_filesize' => '261611','ma_filesize' => '162362'],
  ['mi_id' => 'fe22c8a8','mc_filesize' => '319672','ma_filesize' => '202298'],
  ['mi_id' => 'fe6cae3a','mc_filesize' => '357636','ma_filesize' => '226096'],
  ['mi_id' => 'fea7111f','mc_filesize' => '435615','ma_filesize' => '282328'],
  ['mi_id' => 'ff8246fb','mc_filesize' => '484244','ma_filesize' => '322572'],
  ['mi_id' => 'fff9ee7f','mc_filesize' => '324585','ma_filesize' => '212326'],
  ['mi_id' => 'f9d87c29','mc_filesize' => '279009','ma_filesize' => '138887'],
  ['mi_id' => '4b448733','mc_filesize' => '220106','ma_filesize' => '110183'],
  ['mi_id' => 'd053f808','mc_filesize' => '240971','ma_filesize' => '122368'],
  ['mi_id' => '91668544','mc_filesize' => '221392','ma_filesize' => '111808'],
  ['mi_id' => 'c69e6bd5','mc_filesize' => '284771','ma_filesize' => '144195'],
  ['mi_id' => '50fa9b8f','mc_filesize' => '267209','ma_filesize' => '137071'],
  ['mi_id' => '73cde7ca','mc_filesize' => '259913','ma_filesize' => '159897'],
  ['mi_id' => '6b2e4b5c','mc_filesize' => '348170','ma_filesize' => '224987'],
  ['mi_id' => '84a96260','mc_filesize' => '295511','ma_filesize' => '185222'],
  ['mi_id' => 'b2f9150a','mc_filesize' => '0','ma_filesize' => '98689'],
  ['mi_id' => '7a29def1','mc_filesize' => '90284','ma_filesize' => '90333']

		];
		foreach($mm_media as $row) {
			if ($row['ma_filesize'] < $row['mc_filesize']) {
				echo "{$row['mi_id']}\n";
				flush();
				$this->_repairVttDistribution($row);
			}
		}
		die("\nDone\n");
	}
}

/*

CREATE TABLE `mm_source` (
  `ms_id` int(10) UNSIGNED NOT NULL,
  `ms_name` varchar(80) NOT NULL COMMENT 'name of location',
  `ms_url` varchar(255) DEFAULT NULL COMMENT 'external path',
  `ms_path` varchar(512) DEFAULT NULL COMMENT 'internal path',
  `ms_mountfile` varchar(32) DEFAULT NULL COMMENT 'the name of a file indicating mount established'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='description of server hosting source files';


INSERT INTO `mm_source` (`ms_id`, `ms_name`, `ms_url`, `ms_path`, `ms_mountfile`) VALUES
(1, 'Aries', 'https://cdn1.hopeinstoughton.org', '/var/www/hopeinstoughton_cdn1', '.htaccess'),
(2, 'Hostz', 'https://media.hopeinstoughton.org', '/mnt/media', 'index.html'),
(3, 'Church', 'https://cdn3.hopeinstoughton.org', '/mnt/stoughton', '.htaccess'),
(4, 'Home', 'https://cdn4.hopeinstoughton.org', '/mnt/home', '.mounted');


ALTER TABLE `mm_source`
  ADD PRIMARY KEY (`ms_id`);


ALTER TABLE `mm_source`
  MODIFY `ms_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5;
  
  
CREATE TABLE `mm_speaker` (
  `ms_id` int(10) UNSIGNED NOT NULL,
  `ms_firstname` varchar(100) DEFAULT NULL,
  `ms_lastname` varchar(100) DEFAULT NULL,
  `ms_suffix` varchar(10) DEFAULT NULL COMMENT 'jr or sr, etc',
  `ms_ecclesia` varchar(60) DEFAULT NULL,
  `ms_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'alive?'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;


ALTER TABLE `mm_speaker`
  ADD PRIMARY KEY (`ms_id`),
  ADD KEY `ms_lastname` (`ms_lastname`,`ms_firstname`,`ms_suffix`),
  ADD KEY `ms_ecclesia` (`ms_ecclesia`);


ALTER TABLE `mm_speaker`
  MODIFY `ms_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;


CREATE TABLE `mm_event` (
  `me_id` int(10) UNSIGNED NOT NULL,
  `me_location` varchar(128) DEFAULT NULL,
  `me_slug` varchar(128) DEFAULT NULL,
  `me_topic` varchar(128) DEFAULT NULL,
  `me_year` varchar(10) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='a collection of talks by a speaker';


ALTER TABLE `mm_event`
  ADD PRIMARY KEY (`me_id`),
  ADD KEY `me_location` (`me_location`(80),`me_topic`(80),`me_year`),
  ADD KEY `me_slug` (`me_slug`) USING BTREE,
  ADD KEY `me_topic` (`me_topic`) USING BTREE;


ALTER TABLE `mm_event`
  MODIFY `me_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;

SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";

CREATE TABLE `mm_item` (
  `mi_id` varchar(10) NOT NULL,
  `mi_date` date DEFAULT NULL COMMENT 'null if unknown',
  `mi_title` varchar(512) DEFAULT NULL,
  `mi_speaker` int(10) DEFAULT NULL,
  `mi_type` enum('unknown','ex','mt','ac','bc','bapt','study','cyc','cbt') DEFAULT NULL,
  `mi_thumbnail` mediumtext null default null,
  `mi_moorestown` int(11) DEFAULT NULL,
  `mi_duration` int(11) DEFAULT NULL,
  `mi_other` mediumtext COMMENT 'json: filename, filesize, filehash, framesize',
  `present_mp4` int(11) NOT NULL DEFAULT '0' COMMENT 'location of video file. bitmap',
  `present_mp3` int(11) NOT NULL DEFAULT '0' COMMENT 'location of audio file. bitmap',
  `present_jpg` int(11) NOT NULL DEFAULT '0' COMMENT 'location of poster',
  `present_vtt` int(11) NOT NULL DEFAULT '0' COMMENT 'location of captions',
  `present_xml` INT(11) NOT NULL DEFAULT '0' COMMENT 'location of preparev xml'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='media items';


ALTER TABLE `mm_item`
  ADD PRIMARY KEY (`mi_id`),
  ADD KEY `mi_date` (`mi_date`),
  ADD KEY `mi_moorestown` (`mi_moorestown`),
  ADD KEY `mi_type` (`mi_type`,`mi_date`);



CREATE TABLE `mm_item_x_event` (
  `mixe_event` int(10) UNSIGNED NOT NULL,
  `mixe_sequence` int(11) NOT NULL,
  `mixe_item` varchar(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='items included in an event';

ALTER TABLE `mm_item_x_event`
  ADD PRIMARY KEY (`mixe_event`,`mixe_sequence`,`mixe_item`),
  ADD KEY `mixe_item` (`mixe_item`);
COMMIT;

// http://archive.moorestownchristadelphians.org/index.php?option=com_biblestudy&view=landingpage&Itemid=9
// download:  /index.php?option=com_biblestudy&id=26&view=studieslist&controller=studieslist&task=download&Itemid=3
// http://archive.moorestownchristadelphians.org/downloads/

// chrome does not like media links to http:// from https pages and will automatically rewrite them
SELECT * FROM `mm_item` where present_mp3 <> 0 and present_vtt = 0 ORDER BY `mi_id` ASC  

At home, but no at-home entry: (1726 entries)
	select i.* 
	  from mm_item as i
		left join mm_media on mi_id=mm_hash and mm_source=4 and (mm_type='mp3' or mm_type='wma')
	where (present_mp3 & 16 <> 0) and mm_hash is null

*/

/*
ffmpeg -loop 1 -i './include/background-blu.jpg' -i './5/5c9dc850.mp3' -c:a copy -c:v libx264 -shortest /tmp/mp4U5eTkB.mp4

<div id="upload_area" style="max-width:1200px">
    <div id="dzfile_2" class="mm-dropzone dropzone-frame dz-clickable" href="/~/media-fileupload/{$mi_id}">
        <div class="dz-message needsclick">
            Drop file here or click to upload.
        </div>
    </div>
</div>

// Get a list of all audio
select me_location, me_year, ms_firstname, ms_lastname, ms_suffix, me_topic, mixe_sequence, mi_id, mi_date, mi_title, mi_type
  from mm_item_x_event 
    left join mm_item on mixe_item=mi_id
    left join mm_speaker on mi_speaker=ms_id
    left join mm_event on me_id = mixe_event 
where present_mp3 <> 0 and mixe_event <> 364
  order by mixe_event, mixe_sequence
  
  
vtt reprocessing errors (not copied to stoughton or google):
select mi.mi_id, 
	mc.mm_filesize as mc_filesize,
	ma.mm_filesize as ma_filesize 
  from mm_item as mi
    inner join mm_media as mc on mc.mm_hash=mi_id and mc.mm_source=3 and mc.mm_type=3
    inner join mm_media as ma on ma.mm_hash=mi_id and ma.mm_source=1 and ma.mm_type=3
where mc.mm_filesize <> ma.mm_filesize   
  
*/

new media_manager_class();


Youez - 2016 - github.com/yon3zu
LinuXploit