#!/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);