WIP: storage-fix-local-nas work before playlist controls feature
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
6b3ab5b65e
commit
c160242dbc
46 files changed
+5365
-815
No files matched your search
@@ -12,7 +12,8 @@ class NasFreeLocalStorage extends Command
|
||||
{
|
||||
protected $signature = 'nas:free-local
|
||||
{--dry-run : Preview what would be deleted without deleting}
|
||||
{--force : Actually delete local files confirmed on NAS}';
|
||||
{--force : Actually delete local files confirmed on NAS}
|
||||
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
|
||||
|
||||
protected $description = 'Delete local files (videos, thumbnails, avatars, banners) already stored on the NAS';
|
||||
|
||||
@@ -140,6 +141,7 @@ class NasFreeLocalStorage extends Command
|
||||
$this->newLine();
|
||||
$this->info('Scanning avatar files…');
|
||||
|
||||
// Legacy flat dir
|
||||
$avatarDir = storage_path('app/public/avatars');
|
||||
if (is_dir($avatarDir)) {
|
||||
foreach (glob($avatarDir . '/*') as $file) {
|
||||
@@ -148,23 +150,42 @@ class NasFreeLocalStorage extends Command
|
||||
$user = User::where('avatar', $filename)->first();
|
||||
if (! $user) continue;
|
||||
|
||||
// Confirm avatar.webp is on NAS
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file);
|
||||
$totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
$this->line(' Done scanning avatars.');
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/avatar.*
|
||||
$usersBase = storage_path('app/users');
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
|
||||
$user = User::where('avatar', $relPath)->first();
|
||||
if (! $user) continue;
|
||||
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'avatar:' . basename($file), 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(' Done scanning avatars.');
|
||||
|
||||
// ── Banners ───────────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info('Scanning banner files…');
|
||||
|
||||
// Legacy flat dir
|
||||
$bannerDir = storage_path('app/public/banners');
|
||||
if (is_dir($bannerDir)) {
|
||||
foreach (glob($bannerDir . '/*') as $file) {
|
||||
@@ -177,14 +198,32 @@ class NasFreeLocalStorage extends Command
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file);
|
||||
$totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
$this->line(' Done scanning banners.');
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/cover.*
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
|
||||
$user = User::where('banner', $relPath)->first();
|
||||
if (! $user) continue;
|
||||
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'banner:' . basename($file), 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(' Done scanning banners.');
|
||||
|
||||
// ── Slideshow cache directories ───────────────────────────────────────
|
||||
// The slideshow/ directory is a render cache that is always regenerated on
|
||||
// demand, so its contents are safe to delete unconditionally.
|
||||
@@ -205,6 +244,27 @@ class NasFreeLocalStorage extends Command
|
||||
$this->line(' Done scanning slideshow cache.');
|
||||
}
|
||||
|
||||
// ── NAS stream cache (nas_cache/videos/) ──────────────────────────────
|
||||
// These are on-demand local copies of NAS videos used for HTTP streaming.
|
||||
// Always safe to delete — they are re-downloaded from NAS on next play.
|
||||
$this->newLine();
|
||||
$this->info('Scanning NAS stream cache…');
|
||||
|
||||
$nasCacheDir = storage_path('app/nas_cache/videos');
|
||||
$ttl = (int) $this->option('cache-ttl');
|
||||
$cutoff = time() - ($ttl * 3600);
|
||||
|
||||
if (is_dir($nasCacheDir)) {
|
||||
foreach (glob("{$nasCacheDir}/*") as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
if ($ttl > 0 && filemtime($file) >= $cutoff) continue;
|
||||
$bytes = filesize($file);
|
||||
$totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'nas-cache', 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
$this->line(' Done scanning NAS stream cache.');
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
|
||||
@@ -249,6 +309,15 @@ class NasFreeLocalStorage extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// Prune empty directories left behind under storage/app/users/ and flat asset dirs
|
||||
$this->pruneEmptyDirs(storage_path('app/users'));
|
||||
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
|
||||
$path = storage_path("app/{$rel}");
|
||||
if (is_dir($path) && empty(array_diff(scandir($path) ?: [], ['.', '..']))) {
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
|
||||
|
||||
@@ -259,6 +328,32 @@ class NasFreeLocalStorage extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-up prune: remove dirs that are empty or contain only meta.json.
|
||||
*/
|
||||
private function pruneEmptyDirs(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function humanBytes(int $bytes): string
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user