WIP: storage-fix-local-nas work before playlist controls feature

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-16 11:15:20 +03:00
1 parent 6b3ab5b65e
commit c160242dbc
46 files changed
+5365 -815

No files matched your search

+105 -10
View File
@@ -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