Files
comv3/player/import.php
T
Ty Clifford cc03ea530b - import.php
2026-06-04 02:11:34 -04:00

487 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env php
<?php
/**
* import.php — Bulk-add videos from a directory into the media player database.
*
* Usage:
* php import.php <directory> [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 <<<HELP
\033[1mGitea Media — Bulk Video Importer\033[0m
\033[36mUsage:\033[0m
php {$script} <directory> [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);