- Revisit later
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user