436 lines
18 KiB
PHP
436 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\User;
|
|
use App\Models\Video;
|
|
use App\Models\VideoSlide;
|
|
use App\Services\NasSyncService;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class NasRepairLocalFiles extends Command
|
|
{
|
|
protected $signature = 'nas:repair
|
|
{--dry-run : Preview what would be pushed without making changes}
|
|
{--force : Actually upload to NAS and remove local copies}
|
|
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
|
|
|
|
protected $description = 'Upload stuck local files to NAS, fix DB paths, and delete local copies';
|
|
|
|
private NasSyncService $nas;
|
|
|
|
public function handle(NasSyncService $nas): int
|
|
{
|
|
if (! $nas->isEnabled()) {
|
|
$this->error('NAS sync is not enabled. Enable it in Admin → Settings first.');
|
|
return 1;
|
|
}
|
|
|
|
$this->nas = $nas;
|
|
|
|
$dryRun = $this->option('dry-run');
|
|
$force = $this->option('force');
|
|
|
|
if (! $dryRun && ! $force) {
|
|
$this->warn('Pass --dry-run to preview, or --force to repair.');
|
|
return 1;
|
|
}
|
|
|
|
$this->info($dryRun ? 'DRY RUN — nothing will be changed' : 'FORCE — uploading to NAS and removing local copies');
|
|
$this->newLine();
|
|
|
|
$totalRepaired = 0;
|
|
$totalFailed = 0;
|
|
|
|
// ── NAS-format videos / slides / thumbnails ───────────────────────────
|
|
$stuckVideos = $this->findStuckVideos();
|
|
if ($stuckVideos->isNotEmpty()) {
|
|
$this->info('Video / slide files stuck locally:');
|
|
$rows = [];
|
|
foreach ($stuckVideos as $item) {
|
|
foreach ($item['files'] as $label) {
|
|
$rows[] = [$item['video']->id, substr($item['video']->title, 0, 40), $label];
|
|
}
|
|
}
|
|
$this->table(['Video ID', 'Title', 'File'], $rows);
|
|
$this->newLine();
|
|
|
|
if ($force) {
|
|
foreach ($stuckVideos as $item) {
|
|
$video = $item['video'];
|
|
$this->line(" Repairing video #{$video->id}: {$video->title}…");
|
|
try {
|
|
$nas->syncVideo($video);
|
|
$nas->deleteLocalAssets($video);
|
|
if ($video->hls_path || $video->type === 'music') {
|
|
$nas->deleteLocalVideo($video);
|
|
}
|
|
$nas->pruneLocalVideoDir($video);
|
|
$totalRepaired++;
|
|
$this->line(' <info>✓ Done</info>');
|
|
Log::info("nas:repair: fixed video #{$video->id}");
|
|
} catch (\Throwable $e) {
|
|
$totalFailed++;
|
|
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
|
Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage());
|
|
}
|
|
}
|
|
$this->newLine();
|
|
}
|
|
}
|
|
|
|
// ── Avatars ───────────────────────────────────────────────────────────
|
|
$stuckAvatars = $this->findStuckAvatars();
|
|
if ($stuckAvatars->isNotEmpty()) {
|
|
$this->info('Avatar files stuck locally:');
|
|
$this->table(['User ID', 'Username', 'File'], $stuckAvatars->map(fn ($r) => [
|
|
$r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'],
|
|
])->all());
|
|
$this->newLine();
|
|
|
|
if ($force) {
|
|
foreach ($stuckAvatars as $item) {
|
|
$user = $item['user'];
|
|
$path = $item['path'];
|
|
$this->line(" Repairing avatar for {$user->username}…");
|
|
try {
|
|
$nas->syncAvatar($user, $path);
|
|
$nas->deleteLocalAvatar($user);
|
|
$totalRepaired++;
|
|
$this->line(' <info>✓ Done</info>');
|
|
} catch (\Throwable $e) {
|
|
$totalFailed++;
|
|
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
|
Log::error("nas:repair: failed avatar user#{$user->id}: " . $e->getMessage());
|
|
}
|
|
}
|
|
$this->newLine();
|
|
}
|
|
}
|
|
|
|
// ── Banners ───────────────────────────────────────────────────────────
|
|
$stuckBanners = $this->findStuckBanners();
|
|
if ($stuckBanners->isNotEmpty()) {
|
|
$this->info('Banner files stuck locally:');
|
|
$this->table(['User ID', 'Username', 'File'], $stuckBanners->map(fn ($r) => [
|
|
$r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'],
|
|
])->all());
|
|
$this->newLine();
|
|
|
|
if ($force) {
|
|
foreach ($stuckBanners as $item) {
|
|
$user = $item['user'];
|
|
$path = $item['path'];
|
|
$this->line(" Repairing banner for {$user->username}…");
|
|
try {
|
|
$nas->syncCover($user, $path);
|
|
$nas->deleteLocalBanner($user);
|
|
$totalRepaired++;
|
|
$this->line(' <info>✓ Done</info>');
|
|
} catch (\Throwable $e) {
|
|
$totalFailed++;
|
|
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
|
Log::error("nas:repair: failed banner user#{$user->id}: " . $e->getMessage());
|
|
}
|
|
}
|
|
$this->newLine();
|
|
}
|
|
}
|
|
|
|
// ── Legacy flat thumbnails (public/thumbnails/) ───────────────────────
|
|
$stuckThumbs = $this->findStuckLegacyThumbnails();
|
|
if ($stuckThumbs->isNotEmpty()) {
|
|
$this->info('Legacy thumbnail/slide files stuck locally:');
|
|
$this->table(['Type', 'File', 'Video ID'], $stuckThumbs->map(fn ($r) => [
|
|
$r['type'], $r['file'], $r['video_id'],
|
|
])->all());
|
|
$this->newLine();
|
|
|
|
if ($force) {
|
|
foreach ($stuckThumbs as $item) {
|
|
$this->line(" Repairing {$item['type']}: {$item['file']}…");
|
|
try {
|
|
if ($item['type'] === 'thumbnail' && $item['video']) {
|
|
$nas->syncVideo($item['video']);
|
|
} elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) {
|
|
// Upload slide directly to NAS
|
|
$video = $item['video'];
|
|
$slide = $item['slide'];
|
|
$dir = $nas->resolveVideoDir($video);
|
|
$ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg';
|
|
$nas->mkdirp("{$dir}/slides");
|
|
$nas->putFile($item['path'], "{$dir}/slides/{$slide->position}.{$ext}");
|
|
}
|
|
@unlink($item['path']);
|
|
$totalRepaired++;
|
|
$this->line(' <info>✓ Done</info>');
|
|
} catch (\Throwable $e) {
|
|
$totalFailed++;
|
|
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
|
Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage());
|
|
}
|
|
}
|
|
$this->newLine();
|
|
}
|
|
}
|
|
|
|
$anyStuck = $stuckVideos->isNotEmpty() || $stuckAvatars->isNotEmpty()
|
|
|| $stuckBanners->isNotEmpty() || $stuckThumbs->isNotEmpty();
|
|
|
|
// ── NAS orphaned video folders ─────────────────────────────────────────
|
|
$this->newLine();
|
|
$this->info('Scanning NAS for orphaned video folders (no matching DB record)…');
|
|
$nasOrphans = $nas->scanNasOrphans();
|
|
|
|
if (! empty($nasOrphans)) {
|
|
$this->table(
|
|
['NAS Directory', 'meta.json video_id'],
|
|
array_map(fn ($o) => [$o['dir'], $o['video_id'] ?? '(none)'], $nasOrphans)
|
|
);
|
|
$this->newLine();
|
|
|
|
if ($force) {
|
|
$deletedOrphans = 0;
|
|
$failedOrphans = 0;
|
|
foreach ($nasOrphans as $orphan) {
|
|
$this->line(" Deleting NAS orphan: {$orphan['dir']}…");
|
|
try {
|
|
$nas->deleteNasTree($orphan['dir']);
|
|
$deletedOrphans++;
|
|
$this->line(' <info>✓ Deleted</info>');
|
|
Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]);
|
|
} catch (\Throwable $e) {
|
|
$failedOrphans++;
|
|
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
|
Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
$totalRepaired += $deletedOrphans;
|
|
$totalFailed += $failedOrphans;
|
|
$this->newLine();
|
|
}
|
|
} else {
|
|
$this->line(' No orphaned NAS folders found.');
|
|
}
|
|
|
|
$anyIssues = $anyStuck || ! empty($nasOrphans);
|
|
|
|
// ── NAS stream cache ──────────────────────────────────────────────────
|
|
$cacheBytes = $nas->nasCacheSize();
|
|
if ($cacheBytes > 0) {
|
|
$ttl = (int) $this->option('cache-ttl');
|
|
$ttlLabel = $ttl === 0 ? 'all files' : "files older than {$ttl}h";
|
|
$this->info(sprintf(
|
|
'NAS stream cache: <comment>%s</comment> occupying <comment>%s</comment> — will be evicted on --force (%s).',
|
|
count(glob(storage_path('app/nas_cache/videos/*')) ?: []) . ' file(s)',
|
|
$this->humanBytes($cacheBytes),
|
|
$ttlLabel
|
|
));
|
|
$this->newLine();
|
|
}
|
|
|
|
if (! $anyIssues && $cacheBytes === 0) {
|
|
$this->info('Nothing to repair — no stuck local files, no NAS orphans, and no stream cache found.');
|
|
} elseif (! $anyIssues) {
|
|
$this->info('No issues found (stream cache will be evicted on --force).');
|
|
}
|
|
|
|
if ($dryRun && ($anyIssues || $cacheBytes > 0)) {
|
|
$this->warn('Run with --force to repair.');
|
|
return 0;
|
|
}
|
|
|
|
if ($force) {
|
|
$this->pruneAllLocalDirs();
|
|
|
|
$ttl = (int) $this->option('cache-ttl');
|
|
$evicted = $nas->clearNasCache($ttl);
|
|
if ($evicted > 0) {
|
|
$this->line("Evicted <comment>{$evicted}</comment> NAS stream-cache file(s).");
|
|
}
|
|
}
|
|
|
|
if ($totalRepaired > 0 || $force) {
|
|
$this->newLine();
|
|
if ($totalFailed === 0) {
|
|
$this->info("Repaired {$totalRepaired} item(s). All local directories cleaned up.");
|
|
} else {
|
|
$this->warn("Repaired: {$totalRepaired} Failed: {$totalFailed} — check logs for details.");
|
|
}
|
|
}
|
|
|
|
return $totalFailed > 0 ? 1 : 0;
|
|
}
|
|
|
|
// ── Scanners ──────────────────────────────────────────────────────────────
|
|
|
|
private function findStuckVideos(): \Illuminate\Support\Collection
|
|
{
|
|
return Video::with(['user', 'slides'])->get()
|
|
->filter(fn (Video $v) => str_starts_with($v->path, 'users/'))
|
|
->map(function (Video $video) {
|
|
$files = [];
|
|
if (file_exists(storage_path('app/' . $video->path)))
|
|
$files[] = basename($video->path) . ' (video)';
|
|
if ($video->thumbnail && str_contains($video->thumbnail, '/') &&
|
|
file_exists(storage_path('app/' . $video->thumbnail)))
|
|
$files[] = basename($video->thumbnail) . ' (thumbnail)';
|
|
foreach ($video->slides as $slide) {
|
|
if (file_exists($slide->localPath()))
|
|
$files[] = basename($slide->filename) . " (slide #{$slide->position})";
|
|
}
|
|
return $files ? ['video' => $video, 'files' => $files] : null;
|
|
})
|
|
->filter();
|
|
}
|
|
|
|
private function findStuckAvatars(): \Illuminate\Support\Collection
|
|
{
|
|
$results = collect();
|
|
|
|
// Legacy flat dir: public/avatars/
|
|
$dir = storage_path('app/public/avatars');
|
|
if (is_dir($dir)) {
|
|
$flat = collect(scandir($dir) ?: [])
|
|
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
|
|
->map(function ($filename) use ($dir) {
|
|
$user = User::where('avatar', $filename)->first();
|
|
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
|
|
})
|
|
->filter();
|
|
$results = $results->merge($flat);
|
|
}
|
|
|
|
// New structured dir: users/{slug}/profile/avatar.*
|
|
$usersBase = storage_path('app/users');
|
|
if (is_dir($usersBase)) {
|
|
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $path) {
|
|
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
|
|
// relPath = "users/{slug}/profile/avatar.{ext}"
|
|
$user = User::where('avatar', $relPath)->first();
|
|
if ($user) {
|
|
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
private function findStuckBanners(): \Illuminate\Support\Collection
|
|
{
|
|
$results = collect();
|
|
|
|
// Legacy flat dir: public/banners/
|
|
$dir = storage_path('app/public/banners');
|
|
if (is_dir($dir)) {
|
|
$flat = collect(scandir($dir) ?: [])
|
|
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
|
|
->map(function ($filename) use ($dir) {
|
|
$user = User::where('banner', $filename)->first();
|
|
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
|
|
})
|
|
->filter();
|
|
$results = $results->merge($flat);
|
|
}
|
|
|
|
// New structured dir: users/{slug}/profile/cover.*
|
|
$usersBase = storage_path('app/users');
|
|
if (is_dir($usersBase)) {
|
|
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $path) {
|
|
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
|
|
$user = User::where('banner', $relPath)->first();
|
|
if ($user) {
|
|
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
private function findStuckLegacyThumbnails(): \Illuminate\Support\Collection
|
|
{
|
|
$dir = storage_path('app/public/thumbnails');
|
|
if (! is_dir($dir)) return collect();
|
|
|
|
$results = [];
|
|
foreach (scandir($dir) ?: [] as $filename) {
|
|
if ($filename === '.' || $filename === '..') continue;
|
|
$path = "{$dir}/{$filename}";
|
|
if (! is_file($path)) continue;
|
|
|
|
// Check if it's a video thumbnail
|
|
$video = Video::where('thumbnail', $filename)->first();
|
|
if ($video) {
|
|
$results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null];
|
|
continue;
|
|
}
|
|
|
|
// Check if it's an old-format slide
|
|
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
|
if ($slide && $slide->video) {
|
|
$results[] = ['type' => 'slide', 'file' => $filename, 'path' => $path, 'video_id' => $slide->video_id, 'video' => $slide->video, 'slide' => $slide];
|
|
}
|
|
}
|
|
|
|
return collect($results);
|
|
}
|
|
|
|
// ── Directory pruning ─────────────────────────────────────────────────────
|
|
|
|
private function pruneAllLocalDirs(): void
|
|
{
|
|
// NAS-mirrored dirs
|
|
$this->pruneEmptyDirTree(storage_path('app/users'));
|
|
|
|
// Flat asset dirs — remove if empty
|
|
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
|
|
$path = storage_path("app/{$rel}");
|
|
if (is_dir($path) && $this->isDirEmpty($path)) {
|
|
@rmdir($path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bottom-up prune: remove dirs that contain only meta.json or nothing.
|
|
*/
|
|
private function pruneEmptyDirTree(string $root): void
|
|
{
|
|
if (! is_dir($root)) return;
|
|
|
|
$iter = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::CHILD_FIRST
|
|
);
|
|
|
|
foreach ($iter as $item) {
|
|
if (! $item->isDir()) continue;
|
|
$path = $item->getPathname();
|
|
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
|
|
$nonMeta = array_diff($contents, ['meta.json']);
|
|
if (empty($contents)) {
|
|
@rmdir($path);
|
|
} elseif (empty($nonMeta)) {
|
|
@unlink("{$path}/meta.json");
|
|
@rmdir($path);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function isDirEmpty(string $dir): bool
|
|
{
|
|
return empty(array_diff(scandir($dir) ?: [], ['.', '..']));
|
|
}
|
|
|
|
private function humanBytes(int $bytes): string
|
|
{
|
|
if ($bytes >= 1_073_741_824) return round($bytes / 1_073_741_824, 2) . ' GB';
|
|
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 2) . ' MB';
|
|
if ($bytes >= 1_024) return round($bytes / 1_024, 2) . ' KB';
|
|
return $bytes . ' B';
|
|
}
|
|
}
|