- Revisit later

This commit is contained in:
Ty Clifford
2026-06-05 10:27:17 -04:00
parent 22628cfacc
commit c22e525fb7
-486
View File
@@ -1,486 +0,0 @@
#!/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);