From cc03ea530b43f0a5615c122a6efe8e7c67fda661 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Thu, 4 Jun 2026 02:11:34 -0400 Subject: [PATCH] - import.php --- player/import.php | 486 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 player/import.php diff --git a/player/import.php b/player/import.php new file mode 100644 index 0000000..7f21a49 --- /dev/null +++ b/player/import.php @@ -0,0 +1,486 @@ +#!/usr/bin/env php + [options] + * + * Options: + * --published=0|1 Set published state (default: 1) + * --move Move files instead of copying (default: copy) + * --dry-run Show what would happen without making changes + * --overwrite Re-import files whose slug already exists + * --help Show this help text + * + * Examples: + * php import.php /home/user/videos + * php import.php /tmp/uploads --move --published=0 + * php import.php /srv/media --dry-run + * + * Title is derived from the filename: + * "My Awesome Video (2024).mp4" → "My Awesome Video (2024)" + * + * Multiple files with the same base name but different extensions are grouped + * into a single video entry as alternate sources (e.g. video.mp4 + video.webm). + * + * ffprobe is used automatically when available to detect duration, resolution, + * codec, and MIME type. Falls back to extension-based detection gracefully. + */ + +// ── Bootstrap ──────────────────────────────────────────────────────────────── + +define('SCRIPT_DIR', __DIR__); + +// Must be run from CLI +if (PHP_SAPI !== 'cli') { + fwrite(STDERR, "This script must be run from the command line.\n"); + exit(1); +} + +require_once SCRIPT_DIR . '/includes/db.php'; + +// ── ANSI colour helpers ─────────────────────────────────────────────────────── + +function ansi(string $code, string $text): string { + return "\033[{$code}m{$text}\033[0m"; +} +function c_green(string $s): string { return ansi('32', $s); } +function c_yellow(string $s): string { return ansi('33', $s); } +function c_red(string $s): string { return ansi('31', $s); } +function c_cyan(string $s): string { return ansi('36', $s); } +function c_bold(string $s): string { return ansi('1', $s); } +function c_dim(string $s): string { return ansi('2', $s); } + +function out(string $line): void { echo $line . "\n"; } +function err(string $line): void { fwrite(STDERR, $line . "\n"); } +function info(string $s): void { out(' ' . c_cyan('→') . ' ' . $s); } +function ok(string $s): void { out(' ' . c_green('✔') . ' ' . $s); } +function warn(string $s): void { out(' ' . c_yellow('⚠') . ' ' . $s); } +function fail(string $s): void { out(' ' . c_red('✘') . ' ' . $s); } +function skip(string $s): void { out(' ' . c_dim('–') . ' ' . $s); } +function section(string $s): void { out("\n" . c_bold($s)); } + +// ── Help ───────────────────────────────────────────────────────────────────── + +function show_help(): void { + $script = basename(__FILE__); + echo << [options] + + \033[36mOptions:\033[0m + --published=0|1 Published state for new videos (default: 1) + --move Move files to media/videos/ instead of copying + --dry-run Preview actions without writing anything + --overwrite Re-import slugs that already exist in the database + --help Show this help text + + \033[36mExamples:\033[0m + php {$script} /home/ty/videos + php {$script} /tmp/uploads --move --published=0 + php {$script} /srv/incoming --dry-run + php {$script} /srv/incoming --overwrite + + \033[36mNotes:\033[0m + • Supported formats: mp4, webm, ogv, ogg, mov, mkv, avi + • Files with the same base name (different extensions) are grouped + as multiple sources for one video entry. + • Title is derived from the filename (extension and trailing/leading + whitespace stripped; underscores and hyphens replaced with spaces). + • ffprobe is used when available for duration/quality auto-detection. + +HELP; +} + +// ── Argument parsing ────────────────────────────────────────────────────────── + +$args = array_slice($argv, 1); +$source_dir = null; +$opt_published = 1; +$opt_move = false; +$opt_dry_run = false; +$opt_overwrite = false; + +foreach ($args as $arg) { + if ($arg === '--help' || $arg === '-h') { show_help(); exit(0); } + if ($arg === '--move') { $opt_move = true; continue; } + if ($arg === '--dry-run') { $opt_dry_run = true; continue; } + if ($arg === '--overwrite') { $opt_overwrite = true; continue; } + if (preg_match('/^--published=([01])$/', $arg, $m)) { $opt_published = (int)$m[1]; continue; } + if ($arg[0] !== '-') { $source_dir = rtrim($arg, '/\\'); continue; } + err(c_red("Unknown option: $arg")); + show_help(); + exit(1); +} + +if (!$source_dir) { + err(c_red("Error: source directory is required.")); + show_help(); + exit(1); +} + +if (!is_dir($source_dir)) { + err(c_red("Error: '$source_dir' is not a directory or does not exist.")); + exit(1); +} + +// ── Constants & paths ───────────────────────────────────────────────────────── + +const ALLOWED_EXTS = ['mp4', 'webm', 'ogv', 'ogg', 'mov', 'mkv', 'avi']; + +$dest_dir = MEDIA_DIR . 'videos/'; +if (!$opt_dry_run && !is_dir($dest_dir)) { + mkdir($dest_dir, 0755, true); +} + +// ── ffprobe detection ───────────────────────────────────────────────────────── + +function find_ffprobe(): ?string { + $candidates = ['/usr/bin/ffprobe', '/usr/local/bin/ffprobe']; + foreach ($candidates as $c) { + if (is_executable($c)) return $c; + } + $which = trim(shell_exec('which ffprobe 2>/dev/null') ?? ''); + return ($which && is_executable($which)) ? $which : null; +} + +$FFPROBE = find_ffprobe(); + +function probe_file(string $path): array { + global $FFPROBE; + + $result = [ + 'duration' => 0, + 'width' => 0, + 'height' => 0, + 'quality' => '', + 'fps' => 0.0, + 'video_codec' => '', + 'audio_codec' => '', + 'bitrate_kbps' => 0, + 'format_name' => '', + 'mime_type' => '', + ]; + + if (!$FFPROBE || !is_readable($path)) return $result; + + $cmd = $FFPROBE . ' -v quiet -print_format json -show_streams -show_format ' . escapeshellarg($path) . ' 2>/dev/null'; + $raw = shell_exec($cmd); + $data = $raw ? json_decode($raw, true) : null; + if (!is_array($data)) return $result; + + // Format / container + $fmt = $data['format'] ?? []; + if (!empty($fmt['duration'])) $result['duration'] = (int)round((float)$fmt['duration']); + if (!empty($fmt['bit_rate'])) $result['bitrate_kbps'] = (int)round((int)$fmt['bit_rate'] / 1000); + if (!empty($fmt['format_name'])) { + $fmts = explode(',', $fmt['format_name']); + $result['format_name'] = trim($fmts[0]); + } + + // Streams + foreach (($data['streams'] ?? []) as $stream) { + $type = $stream['codec_type'] ?? ''; + if ($type === 'video' && empty($result['video_codec'])) { + $result['video_codec'] = $stream['codec_name'] ?? ''; + $w = (int)($stream['width'] ?? $stream['coded_width'] ?? 0); + $h = (int)($stream['height'] ?? $stream['coded_height'] ?? 0); + $result['width'] = $w; + $result['height'] = $h; + $result['quality'] = height_to_quality($h); + foreach (['r_frame_rate', 'avg_frame_rate'] as $k) { + if (!empty($stream[$k])) { + $parts = explode('/', $stream[$k]); + if (count($parts) === 2 && (float)$parts[1] > 0) { + $result['fps'] = round((float)$parts[0] / (float)$parts[1], 2); + break; + } + } + } + if (empty($result['duration']) && !empty($stream['duration'])) { + $result['duration'] = (int)round((float)$stream['duration']); + } + } + if ($type === 'audio' && empty($result['audio_codec'])) { + $result['audio_codec'] = $stream['codec_name'] ?? ''; + } + } + + // MIME type (codec-aware) + $result['mime_type'] = smart_mime( + $result['format_name'], + $result['video_codec'], + $path + ); + + return $result; +} + +function height_to_quality(int $h): string { + if ($h <= 0) return ''; + if ($h >= 2160) return '4K'; + if ($h >= 1440) return '1440p'; + if ($h >= 1080) return '1080p'; + if ($h >= 720) return '720p'; + if ($h >= 480) return '480p'; + if ($h >= 360) return '360p'; + if ($h >= 240) return '240p'; + return $h . 'p'; +} + +function smart_mime(string $fmt, string $codec, string $path): string { + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if ($fmt === 'webm' || in_array($codec, ['vp8','vp9','av1']) || $ext === 'webm') return 'video/webm'; + if (in_array($fmt, ['ogg','ogv']) || $codec === 'theora' || in_array($ext, ['ogv','ogg'])) return 'video/ogg'; + if ($fmt === 'mov' || $ext === 'mov') return 'video/quicktime'; + if (in_array($fmt, ['matroska','matroska,webm']) || $ext === 'mkv') { + return in_array($codec, ['vp8','vp9','av1']) ? 'video/webm' : 'video/x-matroska'; + } + if ($fmt === 'avi' || $ext === 'avi') return 'video/x-msvideo'; + return 'video/mp4'; +} + +// ── Title derivation ────────────────────────────────────────────────────────── + +/** + * Turn a filename into a human-readable title. + * "my_awesome_video--2024.mp4" → "My Awesome Video 2024" + * "Episode.01.Pilot.webm" → "Episode 01 Pilot" + */ +function filename_to_title(string $filename): string { + // Strip extension + $name = pathinfo($filename, PATHINFO_FILENAME); + // Replace common separators with spaces + $name = preg_replace('/[_\-\.]+/', ' ', $name); + // Collapse multiple spaces + $name = preg_replace('/\s{2,}/', ' ', $name); + // Trim and title-case + $name = trim($name); + // ucwords is good enough for titles; respect existing caps for acronyms + return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8'); +} + +// ── Scan directory ──────────────────────────────────────────────────────────── + +/** + * Scan $source_dir for video files, group files with the same base name + * (different extensions) together as a single video with multiple sources. + * + * Returns: [ 'base_name' => [ 'title'=>..., 'files'=>[...] ], ... ] + */ +function scan_videos(string $dir): array { + $groups = []; + $items = scandir($dir); + if (!$items) return $groups; + + sort($items); // deterministic order + + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + $full = $dir . DIRECTORY_SEPARATOR . $item; + if (!is_file($full)) continue; + + $ext = strtolower(pathinfo($item, PATHINFO_EXTENSION)); + if (!in_array($ext, ALLOWED_EXTS)) continue; + + $base = pathinfo($item, PATHINFO_FILENAME); + $title = filename_to_title($item); + + if (!isset($groups[$base])) { + $groups[$base] = ['title' => $title, 'files' => []]; + } + $groups[$base]['files'][] = $full; + } + + return $groups; +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +$db = get_db(); + +// Banner +out(''); +out(c_bold(' ╔══════════════════════════════════════╗')); +out(c_bold(' ║ Media Player — Bulk Video Importer ║')); +out(c_bold(' ╚══════════════════════════════════════╝')); +out(''); +info('Source dir : ' . c_cyan($source_dir)); +info('Dest dir : ' . c_cyan($dest_dir)); +info('Published : ' . ($opt_published ? c_green('yes') : c_yellow('no (draft)'))); +info('Mode : ' . ($opt_move ? c_yellow('move') : 'copy')); +if ($opt_dry_run) info(c_yellow('DRY RUN — no changes will be made')); +if ($opt_overwrite) info(c_yellow('OVERWRITE — existing slugs will be re-imported')); +info('ffprobe : ' . ($FFPROBE ? c_green($FFPROBE) : c_yellow('not found — using extension-based detection'))); + +// Scan +section('Scanning directory…'); +$groups = scan_videos($source_dir); + +if (empty($groups)) { + warn('No supported video files found in ' . $source_dir); + out(' Supported extensions: ' . implode(', ', ALLOWED_EXTS)); + exit(0); +} + +out(' Found ' . c_bold((string)count($groups)) . ' video group(s) across ' . + array_sum(array_map(fn($g) => count($g['files']), $groups)) . ' file(s).'); + +// Process +section('Importing videos…'); + +$count_added = 0; +$count_skipped = 0; +$count_failed = 0; + +foreach ($groups as $base => $group) { + $title = $group['title']; + $files = $group['files']; + + out(''); + out(' ' . c_bold($title) . c_dim(' (' . implode(', ', array_map('basename', $files)) . ')')); + + // Check for existing slug + $slug_candidate = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', $title), '-')) ?: 'video-' . time(); + $existing = $db->prepare("SELECT id FROM videos WHERE slug=?"); + $existing->execute([$slug_candidate]); + $existing_row = $existing->fetch(); + + if ($existing_row && !$opt_overwrite) { + skip('Already in database (slug: ' . $slug_candidate . ') — skipping. Use --overwrite to re-import.'); + $count_skipped++; + continue; + } + + // Probe all files and pick best duration/quality + $probed = []; + $best_dur = 0; + + foreach ($files as $fpath) { + $info = probe_file($fpath); + if (!$info['mime_type']) { + $info['mime_type'] = mime_from_ext($fpath); + } + if (!$info['quality']) { + $info['quality'] = '720p'; // fallback label + } + $probed[$fpath] = $info; + + if ($info['duration'] > $best_dur) $best_dur = $info['duration']; + + $summary_parts = array_filter([ + $info['quality'], + $info['width'] && $info['height'] ? $info['width'] . '×' . $info['height'] : '', + $info['video_codec'] ? strtoupper($info['video_codec']) : '', + $info['fps'] > 0 ? $info['fps'] . ' fps' : '', + $info['duration'] ? format_duration($info['duration']) : '', + ]); + info(basename($fpath) . ' ' . c_dim(implode(' · ', $summary_parts))); + } + + if ($opt_dry_run) { + ok(c_yellow('[dry-run] Would add: ') . $title . c_dim(' (' . format_duration($best_dur) . ')')); + $count_added++; + continue; + } + + // ── Insert video record ──────────────────────────────────────────────── + try { + // If overwriting, delete old record first (CASCADE removes sources) + if ($existing_row && $opt_overwrite) { + $db->prepare("DELETE FROM videos WHERE slug=?")->execute([$slug_candidate]); + warn('Deleted existing entry for re-import.'); + } + + $slug = make_slug($title); + + $db->prepare(" + INSERT INTO videos (title, description, slug, thumbnail, duration, sort_order, published) + VALUES (?, '', ?, '', ?, 0, ?) + ")->execute([$title, $slug, $best_dur, $opt_published]); + + $video_id = (int)$db->lastInsertId(); + } catch (PDOException $e) { + fail('Database error inserting video: ' . $e->getMessage()); + $count_failed++; + continue; + } + + // ── Move / copy each source file ─────────────────────────────────────── + $source_ok = 0; + foreach ($files as $fpath) { + $info = $probed[$fpath]; + $ext = strtolower(pathinfo($fpath, PATHINFO_EXTENSION)); + $fname = $slug . '_' . ($source_ok + 1) . '_' . time() . '.' . $ext; + $dest = $dest_dir . $fname; + + // Move or copy + if ($opt_move) { + $transferred = rename($fpath, $dest); + $verb = 'moved'; + } else { + $transferred = copy($fpath, $dest); + $verb = 'copied'; + } + + if (!$transferred) { + fail('Could not ' . ($opt_move ? 'move' : 'copy') . ' ' . basename($fpath) . ' → ' . $dest); + // Roll back this source — video record stays but we note the failure + $count_failed++; + continue; + } + + // Set permissions consistent with web uploads + chmod($dest, 0644); + + // Insert source record + try { + $db->prepare(" + INSERT INTO video_sources (video_id, label, file_path, mime_type, quality, sort_order) + VALUES (?, ?, ?, ?, ?, ?) + ")->execute([ + $video_id, + $info['quality'], // label = quality string + 'videos/' . $fname, + $info['mime_type'], + $info['quality'], + $source_ok, + ]); + } catch (PDOException $e) { + fail('Database error inserting source: ' . $e->getMessage()); + // Clean up the file we just moved/copied + if (file_exists($dest)) unlink($dest); + continue; + } + + ok(c_green($verb) . ' → media/videos/' . $fname); + $source_ok++; + } + + if ($source_ok > 0) { + ok(c_bold('Added: ') . $title . c_dim(' [id=' . $video_id . ', slug=' . $slug . ', sources=' . $source_ok . ']')); + $count_added++; + } else { + // No sources successfully transferred — remove the orphan video record + $db->prepare("DELETE FROM videos WHERE id=?")->execute([$video_id]); + fail('No sources could be transferred — video entry rolled back.'); + $count_failed++; + } +} + +// ── Summary ─────────────────────────────────────────────────────────────────── + +section('Done.'); +out(''); +out(' ' . c_green('✔ Added : ') . c_bold((string)$count_added)); +out(' ' . c_dim('– Skipped: ') . $count_skipped); +if ($count_failed > 0) { + out(' ' . c_red('✘ Failed : ') . c_bold((string)$count_failed)); +} +out(''); + +exit($count_failed > 0 ? 1 : 0);