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
@@ -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';
}
}
+8
View File
@@ -17,6 +17,14 @@ class Kernel extends ConsoleKernel
->cron("*/{$interval} * * * *")
->withoutOverlapping()
->runInBackground();
// Evict NAS stream-cache files older than 24 hours
$schedule->call(function () {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->clearNasCache(24);
}
})->daily()->name('nas-cache-evict')->withoutOverlapping();
}
/**
+71
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoSlide;
@@ -47,6 +48,11 @@ class MediaController extends Controller
}
}
// Might be a playlist thumbnail
if (! file_exists($local)) {
$nas->ensureLocalAsset($local, $filename);
}
if (! file_exists($local)) abort(404);
}
@@ -87,6 +93,27 @@ class MediaController extends Controller
public function avatar(string $filename, NasSyncService $nas): Response
{
// New format: "users/{slug}/profile/avatar.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$user = User::where('avatar', $filename)->first();
if ($user) {
$dir = "users/{$nas->userSlug($user)}/profile";
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
foreach (["avatar.webp", "avatar.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
}
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat format
$local = storage_path('app/public/avatars/' . $filename);
if (! file_exists($local)) {
@@ -104,6 +131,27 @@ class MediaController extends Controller
public function banner(string $filename, NasSyncService $nas): Response
{
// New format: "users/{slug}/profile/cover.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$user = User::where('banner', $filename)->first();
if ($user) {
$dir = "users/{$nas->userSlug($user)}/profile";
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
foreach (["cover.webp", "cover.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
}
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat format
$local = storage_path('app/public/banners/' . $filename);
if (! file_exists($local)) {
@@ -119,6 +167,29 @@ class MediaController extends Controller
return $this->fileResponse($local);
}
public function postImage(string $filename, NasSyncService $nas): Response
{
// New format: "users/{slug}/posts/{id}/{seq}.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
// NAS path is identical to the relative path stored in DB
$nas->ensureLocalAsset($local, $filename);
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat format
$local = storage_path('app/public/post_images/' . $filename);
if (! file_exists($local)) abort(404);
return $this->fileResponse($local);
}
// ── Helper ────────────────────────────────────────────────────────────────
private function fileResponse(string $path): Response
+39 -16
View File
@@ -159,9 +159,8 @@ class PlaylistController extends Controller
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$filename = self::generateFilename($file->getClientOriginalExtension());
$file->storeAs('public/thumbnails', $filename);
$playlist->update(['thumbnail' => $filename]);
$nasPath = self::pushPlaylistThumbnailToNas($file, $playlist);
$playlist->update(['thumbnail' => $nasPath]);
}
// Reload playlist with thumbnail
@@ -228,28 +227,19 @@ class PlaylistController extends Controller
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
// Delete old thumbnail if exists
// Delete old thumbnail from NAS if exists
if ($playlist->thumbnail) {
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
if (file_exists($oldPath)) {
unlink($oldPath);
}
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
}
// Upload new thumbnail
$file = $request->file('thumbnail');
$filename = self::generateFilename($file->getClientOriginalExtension());
$file->storeAs('public/thumbnails', $filename);
$updateData['thumbnail'] = $filename;
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
}
// Handle thumbnail removal
if ($request->input('remove_thumbnail') == '1') {
if ($playlist->thumbnail) {
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
if (file_exists($oldPath)) {
unlink($oldPath);
}
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
$updateData['thumbnail'] = null;
}
}
@@ -472,4 +462,37 @@ class PlaylistController extends Controller
return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token);
}
// ── NAS thumbnail helpers ─────────────────────────────────────────────────
private static function nasPlaylistThumbPath(Playlist $playlist, string $ext): string
{
$nas = app(\App\Services\NasSyncService::class);
$playlist->loadMissing('user');
$userSlug = $nas->userSlug($playlist->user);
return "users/{$userSlug}/playlists/{$playlist->id}/thumb.{$ext}";
}
private static function pushPlaylistThumbnailToNas(\Illuminate\Http\UploadedFile $file, Playlist $playlist): string
{
$nas = app(\App\Services\NasSyncService::class);
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$tmpName = self::generateFilename($ext);
$file->storeAs('public/thumbnails', $tmpName);
$tempAbs = storage_path('app/public/thumbnails/' . $tmpName);
$nasPath = self::nasPlaylistThumbPath($playlist, $ext);
$dir = dirname($nasPath);
$nas->mkdirp($dir);
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
return $nasPath;
}
private static function deletePlaylistThumbnailFromNas(?string $nasPath): void
{
if (! $nasPath || ! str_starts_with($nasPath, 'users/')) return;
try {
app(\App\Services\NasSyncService::class)->deleteFile($nasPath);
} catch (\Throwable) {}
}
}
+65 -29
View File
@@ -6,9 +6,9 @@ use App\Models\Post;
use App\Models\PostImage;
use App\Models\PostVideo;
use App\Models\User;
use App\Services\NasSyncService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class PostController extends Controller
{
@@ -29,7 +29,6 @@ class PostController extends Controller
'images.*' => 'image|mimes:jpg,jpeg,png,webp,gif|max:8192',
'video_ids' => 'nullable|array|max:10',
'video_ids.*' => 'exists:videos,id',
// Legacy fields
'video_id' => 'nullable|exists:videos,id',
'image' => 'nullable|image|mimes:jpg,jpeg,png,webp,gif|max:8192',
]);
@@ -43,35 +42,69 @@ class PostController extends Controller
return back()->withErrors(['body' => 'Post cannot be empty.']);
}
$data = [
// Create post first — we need the ID as the folder name
$post = Post::create([
'user_id' => $user->id,
'body' => $request->body,
'video_id' => $request->video_id ?? null,
];
]);
// Legacy single image (backward compat)
if ($hasLegacyImg) {
$filename = uniqid('post_', true) . '.' . $request->file('image')->getClientOriginalExtension();
$request->file('image')->storeAs('public/post_images', $filename);
$data['image'] = $filename;
}
$nas = app(NasSyncService::class);
$nasMode = $nas->isEnabled();
$postDir = $nas->resolvePostDir($post); // "users/{slug}/posts/{id}"
$post = Post::create($data);
if ($hasImages || $hasLegacyImg) {
if ($nasMode) {
// ── NAS primary: upload directly from PHP temp files ──────────
$nas->mkdirp($postDir);
// New multi-image
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$filename = uniqid('post_', true) . '.' . $file->getClientOriginalExtension();
$file->storeAs('public/post_images', $filename);
PostImage::create([
'post_id' => $post->id,
'filename' => $filename,
'sort_order' => $idx,
]);
if ($hasLegacyImg) {
$file = $request->file('image');
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$nasPath = "{$postDir}/0.{$ext}";
$nas->putFile($file->getRealPath(), $nasPath);
$post->update(['image' => $nasPath]);
}
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$nasPath = "{$postDir}/" . ($idx + 1) . ".{$ext}";
$nas->putFile($file->getRealPath(), $nasPath);
PostImage::create([
'post_id' => $post->id,
'filename' => $nasPath,
'sort_order' => $idx,
]);
}
}
} else {
// ── Local storage: save inside the user's posts directory ─────
$localDir = storage_path('app/' . $postDir);
@mkdir($localDir, 0755, true);
if ($hasLegacyImg) {
$ext = $request->file('image')->getClientOriginalExtension() ?: 'jpg';
$filename = "0.{$ext}";
$request->file('image')->move($localDir, $filename);
$post->update(['image' => "{$postDir}/{$filename}"]);
}
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$filename = ($idx + 1) . ".{$ext}";
$file->move($localDir, $filename);
PostImage::create([
'post_id' => $post->id,
'filename' => "{$postDir}/{$filename}",
'sort_order' => $idx,
]);
}
}
}
}
// New multi-video
if ($hasVideoIds) {
foreach ($request->input('video_ids') as $idx => $videoId) {
PostVideo::create([
@@ -91,14 +124,17 @@ class PostController extends Controller
abort(403);
}
if ($post->image) {
Storage::delete('public/post_images/' . $post->image);
$post->loadMissing('postImages');
$nas = app(NasSyncService::class);
if ($nas->isEnabled()) {
try {
$nas->deleteNasPost($post);
} catch (\Throwable) {}
}
// Delete multi-image files
foreach ($post->postImages as $postImage) {
Storage::delete('public/post_images/' . $postImage->filename);
}
// Always clean up local copies (handles both legacy flat and new structured format)
$nas->deleteLocalPostImages($post);
$post->delete();
@@ -107,7 +143,7 @@ class PostController extends Controller
public function react(Post $post)
{
$user = Auth::user();
$user = Auth::user();
$existing = $post->reactions()->where('user_id', $user->id)->first();
if ($existing) {
+491 -13
View File
@@ -313,12 +313,46 @@ class SuperAdminController extends Controller
return redirect()->route('admin.users')->with('success', 'User updated successfully!');
}
// Returns true if admin is already within the 30-min verified window
private function adminIsVerified(): bool
{
$admin = auth()->user();
if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) {
return true;
}
$verifiedAt = session('admin_2fa_verified_at');
return $verifiedAt && now()->timestamp - $verifiedAt < 1800;
}
// Validates OTP and stamps the session on success
private function verify2fa(Request $request): bool
{
$admin = auth()->user();
if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) {
return true;
}
if ($this->adminIsVerified()) {
return true;
}
$code = $request->input('otp_code', '');
$google2fa = app('pragmarx.google2fa');
if ($google2fa->verifyKey(decrypt($admin->two_factor_secret), (string) $code)) {
session(['admin_2fa_verified_at' => now()->timestamp]);
return true;
}
return false;
}
// Delete user
public function deleteUser(User $user)
public function deleteUser(Request $request, User $user)
{
// Prevent deleting yourself
if (auth()->id() === $user->id) {
return back()->with('error', 'You cannot delete your own account!');
return response()->json(['success' => false, 'message' => 'You cannot delete your own account!'], 422);
}
if (! $this->verify2fa($request)) {
return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422);
}
AuditLog::record('admin.user.deleted', [
@@ -343,7 +377,7 @@ class SuperAdminController extends Controller
$user->delete();
return redirect()->route('admin.users')->with('success', 'User deleted successfully!');
return response()->json(['success' => true, 'message' => 'User deleted successfully!']);
}
// List all videos
@@ -530,16 +564,33 @@ class SuperAdminController extends Controller
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
]);
$oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
$video->update($data);
if (($data['title'] ?? $oldTitle) !== $oldTitle) {
try {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->renameVideoDir($video->fresh());
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NAS video dir rename failed: ' . $e->getMessage());
}
}
return redirect()->route('admin.videos')->with('success', 'Video updated successfully!');
}
// Delete video
public function deleteVideo(Video $video)
public function deleteVideo(Request $request, Video $video)
{
if (! $this->verify2fa($request)) {
return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422);
}
$videoTitle = $video->title;
AuditLog::record('admin.video.deleted', [
@@ -561,7 +612,7 @@ class SuperAdminController extends Controller
$video->delete();
return redirect()->route('admin.videos')->with('success', 'Video "' . $videoTitle . '" deleted successfully!');
return response()->json(['success' => true, 'message' => 'Video "' . $videoTitle . '" deleted successfully!']);
}
/**
@@ -819,7 +870,6 @@ class SuperAdminController extends Controller
'gpu_hwaccel' => 'required|in:cuda,none',
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
'ffmpeg_binary' => 'required|string|max:255',
'nas_sync_enabled' => 'required|in:true,false',
]);
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
@@ -827,13 +877,12 @@ class SuperAdminController extends Controller
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
}
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
Setting::set('nas_sync_enabled', $request->nas_sync_enabled);
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
return back()->with('success', 'Settings saved.');
}
@@ -897,4 +946,433 @@ class SuperAdminController extends Controller
$nodes = config('nas-file-manager.schema', []);
return view('admin.nas-storage', compact('nodes'));
}
public function nasDelete(Request $request)
{
$path = trim($request->input('path', ''));
$type = $request->input('type', 'dir');
if ($path === '') {
return response()->json(['success' => false, 'message' => 'Path is required.'], 422);
}
$nas = app(\App\Services\NasSyncService::class);
if (! $nas->isEnabled()) {
return response()->json(['success' => false, 'message' => 'NAS not enabled.'], 422);
}
try {
if ($type === 'dir') {
$nas->deleteNasTree($path);
} else {
$nas->deleteFile($path);
}
return response()->json(['success' => true]);
} catch (\Throwable $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()]);
}
}
public function nasRepair(Request $request)
{
$nas = app(\App\Services\NasSyncService::class);
if (! $nas->isEnabled()) {
return response()->json(['success' => false, 'message' => 'NAS sync is not enabled.'], 422);
}
// ── Collect stuck items ───────────────────────────────────────────────
$stuckVideos = $this->collectStuckVideos();
$stuckAvatars = $this->collectStuckAvatars();
$stuckBanners = $this->collectStuckBanners();
$stuckThumbs = $this->collectStuckLegacyThumbnails();
$nasOrphans = $nas->scanNasOrphans();
$totalStuck = $stuckVideos->count() + $stuckAvatars->count()
+ $stuckBanners->count() + $stuckThumbs->count()
+ count($nasOrphans);
// Scan-only mode ───────────────────────────────────────────────────────
if ($request->boolean('scan_only')) {
$details = [];
foreach ($stuckVideos as $item) {
$details[] = "[video] #{$item['video']->id} {$item['video']->title}: " . implode(', ', $item['files']);
}
foreach ($stuckAvatars as $item) {
$details[] = "[avatar] {$item['user']->username}: {$item['file']}";
}
foreach ($stuckBanners as $item) {
$details[] = "[banner] {$item['user']->username}: {$item['file']}";
}
foreach ($stuckThumbs as $item) {
$details[] = "[{$item['type']}] {$item['file']} (video #{$item['video_id']})";
}
foreach ($nasOrphans as $orphan) {
$label = $orphan['video_id'] ? "video #{$orphan['video_id']}" : 'no meta.json';
$details[] = "[nas-orphan] {$orphan['dir']} ({$label} — not in DB)";
}
$cacheBytes = $nas->nasCacheSize();
if ($cacheBytes > 0) {
$cacheMb = round($cacheBytes / 1048576, 1);
$details[] = "[stream-cache] {$cacheMb} MB of on-demand video cache (safe to clear)";
$totalStuck++;
}
return response()->json(['stuck' => $totalStuck, 'details' => $details]);
}
// Repair mode ─────────────────────────────────────────────────────────
$repaired = 0;
$failed = 0;
$details = [];
foreach ($stuckVideos as $item) {
$video = $item['video'];
try {
$nas->syncVideo($video);
$nas->deleteLocalAssets($video);
if ($video->hls_path || $video->type === 'music') $nas->deleteLocalVideo($video);
$nas->pruneLocalVideoDir($video);
$repaired++;
$details[] = "✓ [video] #{$video->id}: {$video->title}";
\Log::info("nas:repair: fixed video #{$video->id}");
} catch (\Throwable $e) {
$failed++;
$details[] = "✗ [video] #{$video->id}: {$e->getMessage()}";
\Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage());
}
}
foreach ($stuckAvatars as $item) {
try {
$nas->syncAvatar($item['user'], $item['path']);
$nas->deleteLocalAvatar($item['user']);
$repaired++;
$details[] = "✓ [avatar] {$item['user']->username}";
} catch (\Throwable $e) {
$failed++;
$details[] = "✗ [avatar] {$item['user']->username}: {$e->getMessage()}";
\Log::error("nas:repair: failed avatar user#{$item['user']->id}: " . $e->getMessage());
}
}
foreach ($stuckBanners as $item) {
try {
$nas->syncCover($item['user'], $item['path']);
$nas->deleteLocalBanner($item['user']);
$repaired++;
$details[] = "✓ [banner] {$item['user']->username}";
} catch (\Throwable $e) {
$failed++;
$details[] = "✗ [banner] {$item['user']->username}: {$e->getMessage()}";
\Log::error("nas:repair: failed banner user#{$item['user']->id}: " . $e->getMessage());
}
}
foreach ($stuckThumbs as $item) {
try {
if ($item['type'] === 'thumbnail' && $item['video']) {
$nas->syncVideo($item['video']);
} elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) {
$dir = $nas->resolveVideoDir($item['video']);
$ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg';
$nas->mkdirp("{$dir}/slides");
$nas->putFile($item['path'], "{$dir}/slides/{$item['slide']->position}.{$ext}");
}
@unlink($item['path']);
$repaired++;
$details[] = "✓ [{$item['type']}] {$item['file']}";
} catch (\Throwable $e) {
$failed++;
$details[] = "✗ [{$item['type']}] {$item['file']}: {$e->getMessage()}";
\Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage());
}
}
// Delete NAS orphan folders
foreach ($nasOrphans as $orphan) {
try {
$nas->deleteNasTree($orphan['dir']);
$repaired++;
$details[] = "✓ [nas-orphan] deleted {$orphan['dir']}";
\Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]);
} catch (\Throwable $e) {
$failed++;
$details[] = "✗ [nas-orphan] {$orphan['dir']}: {$e->getMessage()}";
\Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]);
}
}
// Evict NAS stream cache (24h TTL by default)
$evicted = $nas->clearNasCache(24);
if ($evicted > 0) {
$details[] = "✓ [stream-cache] evicted {$evicted} cached file(s)";
$repaired += $evicted;
}
$this->pruneLocalStorageDirs();
if ($totalStuck === 0) {
return response()->json([
'success' => true,
'message' => 'Nothing to repair — no stuck local files and no NAS orphans found.',
'repaired' => 0, 'failed' => 0, 'details' => [],
]);
}
return response()->json([
'success' => $failed === 0,
'message' => $failed === 0
? "Repaired {$repaired} item(s) successfully."
: "Repaired {$repaired}, failed {$failed} — check logs.",
'repaired' => $repaired,
'failed' => $failed,
'details' => $details,
]);
}
private function collectStuckVideos(): \Illuminate\Support\Collection
{
return \App\Models\Video::with(['user', 'slides'])->get()
->filter(fn ($v) => str_starts_with($v->path, 'users/'))
->map(function ($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 collectStuckAvatars(): \Illuminate\Support\Collection
{
$results = collect();
// Legacy flat dir
$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 = \App\Models\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)), '/');
$user = \App\Models\User::where('avatar', $relPath)->first();
if ($user) {
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
}
}
}
return $results;
}
private function collectStuckBanners(): \Illuminate\Support\Collection
{
$results = collect();
// Legacy flat dir
$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 = \App\Models\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 = \App\Models\User::where('banner', $relPath)->first();
if ($user) {
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
}
}
}
return $results;
}
private function collectStuckLegacyThumbnails(): \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;
$video = \App\Models\Video::where('thumbnail', $filename)->first();
if ($video) {
$results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null];
continue;
}
$slide = \App\Models\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);
}
private function pruneLocalStorageDirs(): void
{
// NAS-mirrored tree
$nasRoot = storage_path('app/users');
if (is_dir($nasRoot)) {
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($nasRoot, \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);
}
}
}
// Flat asset dirs — remove if empty
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);
}
}
}
// ── NAS Disable Flow ──────────────────────────────────────────────────
public function nasDisable(Request $request)
{
$mode = $request->input('mode'); // 'migrate' or 'fresh'
if ($mode === 'migrate') {
// Reset progress cache, dispatch job
\Cache::put('nas_disable_progress', json_encode([
'current' => 0, 'total' => 0,
'phase' => 'Starting...', 'done' => false, 'error' => null,
]), 3600);
\App\Jobs\NasToLocalMigrationJob::dispatch()
->onQueue('video-processing')
->onConnection('database');
return response()->json(['ok' => true]);
}
if ($mode === 'fresh') {
// Truncate all media tables, reset user avatars/banners, disable NAS
$tables = [
'videos','video_slides','video_likes','video_views','video_shares',
'video_downloads','playlist_videos','playlists','comments','comment_likes',
'posts','post_images','post_reactions','post_videos',
'coach_reviews','match_rounds','match_points',
'share_accesses','playlist_share_accesses','notifications',
];
foreach ($tables as $t) {
\DB::table($t)->delete();
}
\DB::table('users')->update(['avatar' => null, 'banner' => null]);
Setting::set('nas_sync_enabled', 'false');
AuditLog::record('admin.nas_disabled_fresh');
return response()->json(['ok' => true]);
}
return response()->json(['ok' => false, 'message' => 'Invalid mode'], 422);
}
public function nasMigrateProgress()
{
$raw = \Cache::get('nas_disable_progress');
if (! $raw) return response()->json(['done' => false, 'current' => 0, 'total' => 0, 'phase' => 'Not started']);
return response()->json(json_decode($raw, true));
}
public function backupUsersSettings()
{
$users = \DB::table('users')->get()->map(function ($u) {
return (array) $u;
})->toArray();
$settings = \DB::table('settings')->get()->map(function ($s) {
return (array) $s;
})->toArray();
$payload = json_encode([
'version' => '1.0',
'exported_at' => now()->toIso8601String(),
'users' => $users,
'settings' => $settings,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return response($payload, 200, [
'Content-Type' => 'application/json',
'Content-Disposition' => 'attachment; filename="takeone-backup-' . now()->format('Ymd-His') . '.json"',
]);
}
public function restoreUsersSettings(Request $request)
{
$request->validate(['backup' => 'required|file|mimes:json|max:10240']);
$content = file_get_contents($request->file('backup')->getRealPath());
$data = json_decode($content, true);
if (! isset($data['users']) || ! isset($data['settings'])) {
return back()->with('toast_error', 'Invalid backup file.');
}
// Restore settings
foreach ($data['settings'] as $row) {
\DB::table('settings')->updateOrInsert(
['key' => $row['key']],
['key' => $row['key'], 'value' => $row['value']]
);
}
// Restore users (upsert by email)
$restored = 0;
foreach ($data['users'] as $row) {
unset($row['id']); // let DB assign new IDs to avoid PK conflicts
\DB::table('users')->updateOrInsert(
['email' => $row['email']],
$row
);
$restored++;
}
AuditLog::record('admin.backup_restored', ['users' => $restored]);
return back()->with('toast_success', "Backup restored: {$restored} users + settings.");
}
}
+81 -8
View File
@@ -68,17 +68,36 @@ class UserController extends Controller
'timezone' => $request->timezone ?: null,
];
$nas = app(\App\Services\NasSyncService::class);
if ($request->hasFile('avatar')) {
if ($user->avatar) {
Storage::delete('public/avatars/'.$user->avatar);
}
$filename = self::generateFilename($request->file('avatar')->getClientOriginalExtension());
$request->file('avatar')->storeAs('public/avatars', $filename);
$data['avatar'] = $filename;
// Delete old avatar (handles both flat and new relative-path formats)
$nas->deleteLocalAvatar($user);
$ext = $request->file('avatar')->getClientOriginalExtension() ?: 'webp';
$profileDir = $nas->localProfileDir($user);
$destFilename = "avatar.{$ext}";
$destPath = "{$profileDir}/{$destFilename}";
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
@mkdir($profileDir, 0755, true);
$request->file('avatar')->move($profileDir, $destFilename);
$data['avatar'] = $relPath;
}
$user->update($data);
// Push avatar to NAS and remove local copy when NAS is primary storage
if ($nas->isEnabled()) {
if ($request->hasFile('avatar')) {
$destPath = storage_path('app/' . $data['avatar']);
if (file_exists($destPath)) {
$nas->syncAvatar($user, $destPath);
$nas->deleteLocalAvatar($user);
}
}
}
// Sync social links
$user->socialLinks()->delete();
$order = 0;
@@ -418,14 +437,68 @@ class UserController extends Controller
public function updateAvatar(Request $request)
{
$request->validate(['path' => 'required|string|max:300']);
Auth::user()->update(['avatar' => basename($request->path)]);
$user = Auth::user();
$filename = basename($request->path);
$tempPath = storage_path('app/public/avatars/' . $filename);
$nas = app(\App\Services\NasSyncService::class);
// Move temp file into the user's profile directory
$profileDir = $nas->localProfileDir($user);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
$destFilename = "avatar.{$ext}";
$destPath = "{$profileDir}/{$destFilename}";
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
// Delete old avatar before moving new one in (handles both path formats)
$nas->deleteLocalAvatar($user);
@mkdir($profileDir, 0755, true);
if (file_exists($tempPath)) {
rename($tempPath, $destPath);
}
$user->update(['avatar' => $relPath]);
if ($nas->isEnabled() && file_exists($destPath)) {
$nas->syncAvatar($user, $destPath);
$nas->deleteLocalAvatar($user);
}
return response()->json(['ok' => true]);
}
public function updateBanner(Request $request)
{
$request->validate(['path' => 'required|string|max:300']);
Auth::user()->update(['banner' => basename($request->path)]);
$user = Auth::user();
$filename = basename($request->path);
$tempPath = storage_path('app/public/banners/' . $filename);
$nas = app(\App\Services\NasSyncService::class);
// Move temp file into the user's profile directory
$profileDir = $nas->localProfileDir($user);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
$destFilename = "cover.{$ext}";
$destPath = "{$profileDir}/{$destFilename}";
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
// Delete old banner before moving new one in (handles both path formats)
$nas->deleteLocalBanner($user);
@mkdir($profileDir, 0755, true);
if (file_exists($tempPath)) {
rename($tempPath, $destPath);
}
$user->update(['banner' => $relPath]);
if ($nas->isEnabled() && file_exists($destPath)) {
$nas->syncCover($user, $destPath);
$nas->deleteLocalBanner($user);
}
return response()->json(['ok' => true]);
}
}
+246 -16
View File
@@ -527,6 +527,8 @@ class VideoController extends Controller
'slides_add.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
]);
$oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type']);
$data['download_access'] = $request->input('download_access', 'disabled');
@@ -544,10 +546,17 @@ class VideoController extends Controller
$userSlug = $nas->userSlug($video->user);
$data['thumbnail'] = 'users/' . $userSlug . '/videos/' . basename($localDir) . "/thumb.{$ext}";
} else {
// Legacy video: keep in flat thumbnails dir
// Legacy video: push thumbnail directly to NAS
$nas = app(\App\Services\NasSyncService::class);
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
$data['thumbnail'] = basename($data['thumbnail']);
$request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
$tempAbs = storage_path('app/public/thumbnails/' . $thumbFilename);
$nasDir = $nas->resolveVideoDir($video);
$ext = pathinfo($thumbFilename, PATHINFO_EXTENSION);
$nas->mkdirp($nasDir);
$nas->putFile($tempAbs, "{$nasDir}/thumb.{$ext}");
@unlink($tempAbs);
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
}
}
@@ -579,11 +588,11 @@ class VideoController extends Controller
if ($request->hasFile('slides_add')) {
$nextPos = count($keptOrder);
$isNewFormat = str_starts_with($video->path, 'users/');
$nasForSlides = app(\App\Services\NasSyncService::class);
if ($isNewFormat) {
$nas = app(\App\Services\NasSyncService::class);
$localDir = $nas->localVideoDir($video);
$localDir = $nasForSlides->localVideoDir($video);
@mkdir("{$localDir}/slides", 0755, true);
$userSlug = $nas->userSlug($video->user);
$userSlug = $nasForSlides->userSlug($video->user);
$relDir = 'users/' . $userSlug . '/videos/' . basename($localDir);
}
foreach ($request->file('slides_add') as $file) {
@@ -594,9 +603,18 @@ class VideoController extends Controller
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
$slide->update(['filename' => "{$relDir}/slides/{$slide->id}.{$ext}"]);
} else {
$fname = self::generateFilename($file->getClientOriginalExtension());
// Legacy video: push slide directly to NAS
$fname = self::generateFilename($file->getClientOriginalExtension());
$file->storeAs('public/thumbnails', $fname);
VideoSlide::create(['video_id' => $video->id, 'filename' => $fname, 'position' => $nextPos]);
$tempAbs = storage_path('app/public/thumbnails/' . $fname);
$nasDir = $nasForSlides->resolveVideoDir($video);
$nasForSlides->mkdirp("{$nasDir}/slides");
$ext = pathinfo($fname, PATHINFO_EXTENSION);
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
$nasSlide = "{$nasDir}/slides/{$slide->id}.{$ext}";
$nasForSlides->putFile($tempAbs, $nasSlide);
@unlink($tempAbs);
$slide->update(['filename' => $nasSlide]);
}
$nextPos++;
$slidesChanged = true;
@@ -618,6 +636,19 @@ class VideoController extends Controller
$video->update($data);
// If the title changed, rename the NAS/local folder before syncing so
// the sync job writes to the correctly-named directory.
if (($data['title'] ?? $oldTitle) !== $oldTitle) {
try {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->renameVideoDir($video->fresh());
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NAS video dir rename failed: ' . $e->getMessage());
}
}
try {
NasSyncVideoJob::dispatch($video->fresh());
} catch (\Throwable $e) {
@@ -692,6 +723,182 @@ class VideoController extends Controller
return redirect()->route('videos.index')->with('success', 'Video deleted!');
}
// ─────────────────────────────────────────────────────────────────────────
// Replace media file (keeps all metadata, views, likes, comments intact)
// ─────────────────────────────────────────────────────────────────────────
// Replace media file (keeps all metadata, views, likes, comments intact)
// ─────────────────────────────────────────────────────────────────────────
public function replaceFile(Request $request, Video $video)
{
$user = Auth::user();
if ($user->id !== $video->user_id && ! $user->isSuperAdmin()) {
abort(403);
}
$request->validate([
'replacement_file' => [
'required',
'file',
'mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus',
'max:512000',
],
]);
$newFile = $request->file('replacement_file');
$mimeType = $newFile->getMimeType();
$isAudio = str_starts_with($mimeType, 'audio/');
$newSize = $newFile->getSize();
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
$nas = app(\App\Services\NasSyncService::class);
// ── 1. Clear old HLS ─────────────────────────────────────────────────
if ($video->has_hls && $video->hls_path) {
\Storage::deleteDirectory($video->hls_path);
}
// ── 2. Delete old media file ─────────────────────────────────────────
// NAS: only delete the video file; thumbnail/slides/meta.json stay
// Local: unlink the local copy
if ($nas->isEnabled() && str_starts_with($video->path, 'users/')) {
try { $nas->deleteFile($video->path); } catch (\Throwable) {}
} else {
$oldLocal = storage_path('app/' . $video->path);
if (file_exists($oldLocal)) @unlink($oldLocal);
}
// ── 3. Store new file to a temporary local path ──────────────────────
$tempFilename = \Str::uuid() . '.' . $newExt;
$tempRelPath = 'public/videos/' . $tempFilename;
$newFile->storeAs('public/videos', $tempFilename);
$tempAbsPath = storage_path('app/' . $tempRelPath);
if (! file_exists($tempAbsPath)) {
return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500);
}
// ── 4. Extract metadata via FFprobe ──────────────────────────────────
$width = $height = null;
$orientation = 'landscape';
$duration = 0;
try {
$ffprobeBin = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
$out = [];
exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($tempAbsPath), $out);
$duration = (int) round((float) ($out[0] ?? 0));
if (! $isAudio) {
$ffprobe = \FFMpeg\FFProbe::create();
$stream = $ffprobe->streams($tempAbsPath)->videos()->first();
if ($stream) {
$width = $stream->get('width');
$height = $stream->get('height');
if ($width && $height) {
if ($height > $width) $orientation = 'portrait';
elseif ($width > $height) $orientation = 'landscape';
else $orientation = 'square';
}
}
}
} catch (\Throwable $e) {
\Log::warning('replaceFile: FFprobe failed: ' . $e->getMessage());
}
// ── 5. Persist the file to its final location ─────────────────────────
//
// NAS path: push directly to NAS using uploadDirectToNas()
// This handles legacy paths correctly by computing the
// proper users/.../videos/... directory.
// uploadDirectToNas() updates path/filename in DB.
//
// Local path: update path/filename to temp location, then call
// organizeLocalFiles() which moves it to users/... layout.
// CompressVideoJob() sets status=ready and chains HLS.
if ($nas->isEnabled()) {
// Point filename at the new file (uploadDirectToNas uses this for ext)
$video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]);
try {
// Pass null for thumb — we don't want to overwrite the existing thumbnail
$nas->uploadDirectToNas($video, $tempAbsPath, null);
$video->refresh();
} catch (\Throwable $e) {
\Log::error('replaceFile: NAS upload failed: ' . $e->getMessage());
@unlink($tempAbsPath);
return response()->json(['success' => false, 'message' => 'NAS upload failed. Please try again.'], 500);
}
$metaUpdates = [
'size' => $newSize,
'mime_type'=> $mimeType,
'has_hls' => false,
'hls_path' => null,
// For NAS the upload is the "done" state — set ready so GenerateHlsJob runs
'status' => 'ready',
];
if (! $isAudio) {
$metaUpdates['duration'] = $duration ?: $video->duration;
$metaUpdates['width'] = $width ?: $video->width;
$metaUpdates['height'] = $height ?: $video->height;
$metaUpdates['orientation'] = $orientation;
$metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
}
$video->update($metaUpdates);
if (! $isAudio) {
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
->onQueue('video-processing')
->onConnection('database');
}
} else {
// Point the record at the temp file so organizeLocalFiles can move it
$video->update(['path' => $tempRelPath, 'filename' => $tempFilename]);
try {
$nas->organizeLocalFiles($video);
$video->refresh();
} catch (\Throwable $e) {
\Log::warning('replaceFile: organizeLocalFiles failed: ' . $e->getMessage());
}
$metaUpdates = [
'size' => $newSize,
'mime_type'=> $mimeType,
'has_hls' => false,
'hls_path' => null,
'status' => $isAudio ? 'ready' : 'processing',
];
if (! $isAudio) {
$metaUpdates['duration'] = $duration ?: $video->duration;
$metaUpdates['width'] = $width ?: $video->width;
$metaUpdates['height'] = $height ?: $video->height;
$metaUpdates['orientation'] = $orientation;
$metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
}
$video->update($metaUpdates);
if (! $isAudio) {
\App\Jobs\CompressVideoJob::dispatch($video->fresh())
->onQueue('video-processing')
->onConnection('database');
}
}
return response()->json([
'success' => true,
'message' => $isAudio
? 'Audio file replaced successfully.'
: 'File replaced — re-encoding has started. The video will be ready shortly.',
'status' => $video->fresh()->status,
'is_audio' => $isAudio,
]);
}
public function trending(Request $request)
{
$hours = $request->get('hours', 48);
@@ -1127,7 +1334,11 @@ class VideoController extends Controller
$path = $video->localVideoPath();
if (! file_exists($path)) {
abort(404, 'Video file not found.');
$nas = app(\App\Services\NasSyncService::class);
$path = $nas->ensureLocalCopy($video);
if (! $path) {
abort(404, 'Video file not found.');
}
}
$slug = $this->safeFilename($video->title, 'audio');
@@ -1568,7 +1779,7 @@ class VideoController extends Controller
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar
? asset('storage/avatars/' . $u->avatar)
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
@@ -1602,7 +1813,7 @@ class VideoController extends Controller
'user_name' => $r->user_name ?? 'Guest',
'user_avatar' => $r->user_id
? ($r->user_avatar
? asset('storage/avatars/' . $r->user_avatar)
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
]);
@@ -1631,7 +1842,7 @@ class VideoController extends Controller
'id' => $u->id,
'name' => $u->name,
'avatar' => $u->avatar
? asset('storage/avatars/' . $u->avatar)
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
@@ -1668,7 +1879,7 @@ class VideoController extends Controller
'user_name' => $r->user_name ?? 'Guest',
'user_avatar'=> $r->user_id
? ($r->user_avatar
? asset('storage/avatars/' . $r->user_avatar)
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
]);
@@ -1728,6 +1939,24 @@ class VideoController extends Controller
'pct' => $totalAge > 0 ? round($cnt / $totalAge * 100) : 0,
])->values();
// Who liked this video
$likers = \DB::table('video_likes')
->join('users', 'users.id', '=', 'video_likes.user_id')
->select('users.id', 'users.name', 'users.avatar', 'users.username', 'video_likes.created_at as liked_at')
->where('video_likes.video_id', $id)
->orderByDesc('video_likes.created_at')
->limit(50)
->get()
->map(fn ($u) => [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'liked_at' => $u->liked_at,
]);
// ── Share analytics ────────────────────────────────────────
$shareLinks = \DB::table('video_shares')->where('video_id', $id)->get();
$shareIds = $shareLinks->pluck('id');
@@ -1774,6 +2003,7 @@ class VideoController extends Controller
'daily' => $daily,
'peak_hour' => $peakHour,
'likes' => $video->like_count,
'likers' => $likers,
'genders' => $genders,
'age_groups' => $ageGroups,
]);
@@ -1805,7 +2035,7 @@ class VideoController extends Controller
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar ? asset('storage/avatars/' . $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
]);
@@ -1869,7 +2099,7 @@ class VideoController extends Controller
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar ? asset('storage/avatars/' . $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
]);
@@ -1940,7 +2170,7 @@ class VideoController extends Controller
'user' => [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->avatar ? asset('storage/avatars/' . $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
],
'total' => $records->count(),
'records' => $records,
+7 -2
View File
@@ -30,10 +30,15 @@ class NasSyncVideoJob implements ShouldQueue
// Video uploads must keep the local file until GenerateHlsJob finishes.
if ($this->video->type === 'music') {
$nas->deleteLocalVideo($this->video);
$nas->deleteLocalAssets($this->video);
}
$nas->deleteLocalAssets($this->video);
$nas->pruneLocalVideoDir($this->video);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NAS syncVideo failed for video ' . $this->video->id . ': ' . $e->getMessage());
\Illuminate\Support\Facades\Log::error(
'NasSyncVideoJob failed for video #' . $this->video->id .
' ("' . $this->video->title . '"): ' . $e->getMessage() .
' — local files kept. Run `php artisan nas:repair --force` to retry.'
);
}
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace App\Jobs;
use App\Models\Setting;
use App\Models\User;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class NasToLocalMigrationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 7200; // 2 hours
private function updateProgress(array $data): void
{
Cache::put('nas_disable_progress', json_encode($data), 3600);
}
public function handle(NasSyncService $nas): void
{
$progress = [
'current' => 0,
'total' => 0,
'phase' => 'Counting files...',
'done' => false,
'error' => null,
];
$this->updateProgress($progress);
try {
// ── Count all items ───────────────────────────────────────────────
$videos = Video::where('path', 'like', 'users/%')->get();
$slides = \DB::table('video_slides')
->where('filename', 'like', 'users/%')
->get();
$usersWithAvatar = User::where('avatar', 'like', 'users/%')->get();
$usersWithBanner = User::where('banner', 'like', 'users/%')->get();
$postImages = \DB::table('post_images')
->where('filename', 'like', 'users/%')
->get();
// Count thumbnails separately (they're additional per-video downloads)
$videoThumbs = $videos->filter(fn($v) => $v->thumbnail && str_starts_with($v->thumbnail, 'users/'));
$total = $videos->count()
+ $videoThumbs->count()
+ $slides->count()
+ $usersWithAvatar->count()
+ $usersWithBanner->count()
+ $postImages->count();
$progress['total'] = $total;
$progress['phase'] = 'Downloading files from NAS...';
$this->updateProgress($progress);
// ── Download videos ───────────────────────────────────────────────
foreach ($videos as $video) {
$nasPath = $video->path;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download video #' . $video->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading videos... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download thumbnails ───────────────────────────────────────────
foreach ($videoThumbs as $video) {
$nasPath = $video->thumbnail;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download thumbnail for video #' . $video->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading thumbnails... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download slides ───────────────────────────────────────────────
foreach ($slides as $slide) {
$nasPath = $slide->filename;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download slide id=' . $slide->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading audio slides... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download user avatars ─────────────────────────────────────────
foreach ($usersWithAvatar as $user) {
$nasPath = $user->avatar;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download avatar for user #' . $user->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading avatars... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download user banners ─────────────────────────────────────────
foreach ($usersWithBanner as $user) {
$nasPath = $user->banner;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download banner for user #' . $user->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading banners... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download post images ──────────────────────────────────────────
foreach ($postImages as $img) {
$nasPath = $img->filename;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download post image id=' . $img->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading post images... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Disable NAS ───────────────────────────────────────────────────
Setting::set('nas_sync_enabled', 'false');
$progress['done'] = true;
$progress['phase'] = 'Complete';
$this->updateProgress($progress);
Log::info('NasToLocalMigrationJob: completed. ' . $total . ' files migrated. NAS disabled.');
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: fatal error: ' . $e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
$progress['error'] = $e->getMessage();
$this->updateProgress($progress);
}
}
}
+2 -3
View File
@@ -43,13 +43,12 @@ class Playlist extends Model
}
// Accessors
public function getThumbnailUrlAttribute()
public function getThumbnailUrlAttribute(): string
{
if ($this->thumbnail) {
return asset('storage/thumbnails/'.$this->thumbnail);
return route('media.thumbnail', $this->thumbnail);
}
// Generate a placeholder based on playlist name
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
}
+5 -1
View File
@@ -46,6 +46,10 @@ class Post extends Model
public function getImageUrlAttribute(): ?string
{
return $this->image ? asset('storage/post_images/' . $this->image) : null;
if (! $this->image) return null;
if (str_starts_with($this->image, 'users/')) {
return route('media.post-image', $this->image);
}
return asset('storage/post_images/' . $this->image);
}
}
+3
View File
@@ -15,6 +15,9 @@ class PostImage extends Model
public function getImageUrlAttribute(): string
{
if (str_starts_with($this->filename, 'users/')) {
return route('media.post-image', $this->filename);
}
return asset('storage/post_images/' . $this->filename);
}
}
+3 -3
View File
@@ -123,10 +123,10 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(\App\Models\Post::class);
}
public function getAvatarUrlAttribute()
public function getAvatarUrlAttribute(): string
{
if ($this->avatar) {
return asset('storage/avatars/'.$this->avatar);
return route('media.avatar', $this->avatar);
}
return 'https://i.pravatar.cc/150?u='.$this->id;
@@ -135,7 +135,7 @@ class User extends Authenticatable implements MustVerifyEmail
public function getBannerUrlAttribute(): ?string
{
if ($this->banner) {
return asset('storage/banners/'.$this->banner);
return route('media.banner', $this->banner);
}
return null;
}
+22
View File
@@ -32,5 +32,27 @@ class AppServiceProvider extends ServiceProvider
// Universal pagination view — used everywhere by default
Paginator::defaultView('partials.pagination');
Paginator::defaultSimpleView('partials.pagination');
// Merge NAS credentials stored in the DB into the package config at runtime.
// This way the live browser and all NAS operations use DB values without needing .env.
$this->app->booted(function () {
try {
$host = \App\Models\Setting::get('nas_host', '');
if ($host) {
config([
'nas-file-manager.connection.protocol' => \App\Models\Setting::get('nas_protocol', 'smb'),
'nas-file-manager.connection.host' => $host,
'nas-file-manager.connection.port' => (int) \App\Models\Setting::get('nas_port', 445),
'nas-file-manager.connection.username' => \App\Models\Setting::get('nas_username', ''),
'nas-file-manager.connection.password' => \App\Models\Setting::get('nas_password', ''),
'nas-file-manager.connection.path' => \App\Models\Setting::get('nas_path', '/media'),
'nas-file-manager.connection.smb_share' => \App\Models\Setting::get('nas_smb_share', ''),
'nas-file-manager.connection.smb_domain' => \App\Models\Setting::get('nas_smb_domain', ''),
]);
}
} catch (\Throwable $e) {
// DB may not exist yet (fresh install / migrations not run) — silently skip
}
});
}
}
+449 -3
View File
@@ -26,9 +26,22 @@ class NasSyncService
public function titleSlug(string $title): string
{
$slug = mb_strtolower($title);
$slug = preg_replace('/[^a-z0-9]+/u', '-', $slug);
// NFC normalisation ensures consistent codepoints across sources
$title = \Normalizer::normalize($title, \Normalizer::FORM_C) ?: $title;
// Keep all Unicode letters and numbers; replace everything else with dashes.
// This preserves Chinese, Arabic, Japanese, Cyrillic, etc. as-is.
// Characters illegal in SMB/Windows filenames (\ / : * ? " < > |) are all
// excluded by \p{L}\p{N}, so the result is always filesystem-safe.
$slug = preg_replace('/[^\p{L}\p{N}]+/u', '-', $title);
$slug = mb_strtolower($slug);
$slug = trim($slug, '-');
// Cap at 100 chars to stay safely within any filesystem path limit
if (mb_strlen($slug) > 100) {
$slug = rtrim(mb_substr($slug, 0, 100), '-');
}
return $slug ?: 'video';
}
@@ -338,6 +351,59 @@ class NasSyncService
return true;
}
/**
* Delete cached NAS video files from nas_cache/videos/.
*
* @param int $olderThanHours Only delete files last-accessed more than N hours ago.
* Pass 0 to delete everything regardless of age.
* @return int Number of files deleted.
*/
public function clearNasCache(int $olderThanHours = 24): int
{
$cacheDir = storage_path('app/nas_cache/videos');
if (! is_dir($cacheDir)) return 0;
$cutoff = time() - ($olderThanHours * 3600);
$deleted = 0;
foreach (glob("{$cacheDir}/*") as $file) {
if (! is_file($file)) continue;
// Use mtime (last modified) as a proxy for last-used
if ($olderThanHours === 0 || filemtime($file) < $cutoff) {
if (@unlink($file)) {
$deleted++;
Log::info('NAS cache: evicted ' . basename($file));
}
}
}
// Remove the directory itself if now empty
if (is_dir($cacheDir) && empty(array_diff(scandir($cacheDir) ?: [], ['.', '..']))) {
@rmdir($cacheDir);
$parent = dirname($cacheDir); // nas_cache/
if (is_dir($parent) && empty(array_diff(scandir($parent) ?: [], ['.', '..']))) {
@rmdir($parent);
}
}
return $deleted;
}
/**
* Return the total size in bytes of all files in nas_cache/videos/.
*/
public function nasCacheSize(): int
{
$cacheDir = storage_path('app/nas_cache/videos');
if (! is_dir($cacheDir)) return 0;
$total = 0;
foreach (glob("{$cacheDir}/*") as $file) {
if (is_file($file)) $total += filesize($file);
}
return $total;
}
/**
* Delete the local video file after it has been successfully pushed to NAS.
*/
@@ -372,6 +438,49 @@ class NasSyncService
Log::info('NAS: local assets removed after NAS push', ['video_id' => $video->id]);
}
/**
* Remove the local video directory and all empty ancestor directories up to
* storage/app/users. Call this after deleteLocalVideo + deleteLocalAssets so
* no ghost folders are left behind.
*/
public function pruneLocalVideoDir(Video $video): void
{
$videoDir = $this->localVideoDir($video);
// Remove meta.json helper file
@unlink("{$videoDir}/meta.json");
// Remove slides/ subdirectory if empty
$slidesDir = "{$videoDir}/slides";
if (is_dir($slidesDir) && $this->isDirEmpty($slidesDir)) {
@rmdir($slidesDir);
}
// Remove the video directory itself if now empty
if (is_dir($videoDir) && $this->isDirEmpty($videoDir)) {
@rmdir($videoDir);
// Walk up: remove videos/ and users/{username}/ if they become empty
$parent = dirname($videoDir); // …/users/{username}/videos
if (is_dir($parent) && $this->isDirEmpty($parent)) {
@rmdir($parent);
$grandparent = dirname($parent); // …/users/{username}
if (is_dir($grandparent) && $this->isDirEmpty($grandparent)) {
@rmdir($grandparent);
}
}
}
Log::info('NAS: local video directory pruned', ['video_id' => $video->id, 'dir' => $videoDir]);
}
private function isDirEmpty(string $dir): bool
{
$items = array_diff(scandir($dir) ?: [], ['.', '..']);
return empty($items);
}
// ── Direct NAS upload (NAS-primary mode) ──────────────────────────────────
/**
@@ -534,16 +643,107 @@ class NasSyncService
public function deleteVideo(Video $video): void
{
$video->loadMissing('user');
$video->loadMissing(['user', 'slides']);
$dir = $this->resolveVideoDir($video);
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
// ── Files in the video root ───────────────────────────────────────────
$this->deleteFile("{$dir}/" . $this->titleSlug($video->title) . ".{$ext}");
$this->deleteFile("{$dir}/thumb.webp");
$this->deleteFile("{$dir}/meta.json");
$this->deleteFile("{$dir}/view-log.json");
$this->deleteFile("{$dir}/edit-log.json");
// ── slides/ subdirectory ──────────────────────────────────────────────
// Delete each known slide file, then any leftover wildcard, then the dir.
foreach ($video->slides as $slide) {
$slideExt = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
$this->deleteFile("{$dir}/slides/{$slide->position}.{$slideExt}");
}
$this->deleteFilesInDir("{$dir}/slides"); // catch anything not in DB
$this->deleteFolder("{$dir}/slides");
// ── Video directory itself ────────────────────────────────────────────
$this->deleteFolder($dir);
// ── Local NAS stream-cache copy ───────────────────────────────────────
$cachePath = $this->localCachePath($video);
if (file_exists($cachePath)) @unlink($cachePath);
Log::info('NAS: video deleted', ['video_id' => $video->id, 'dir' => $dir]);
}
// ── Posts ─────────────────────────────────────────────────────────────────
/**
* NAS directory for a post's attachments: users/{slug}/posts/{id}/
*/
public function resolvePostDir(\App\Models\Post $post): string
{
$post->loadMissing('user');
return 'users/' . $this->userSlug($post->user) . '/posts/' . $post->id;
}
/**
* Upload all new-format post images to NAS (NAS path == relative path stored in DB).
*/
public function syncPostImages(\App\Models\Post $post): void
{
$post->loadMissing('postImages');
$dir = $this->resolvePostDir($post);
$this->mkdirp($dir);
if ($post->image && str_starts_with($post->image, 'users/')) {
$local = storage_path('app/' . $post->image);
if (file_exists($local)) $this->putFile($local, $post->image);
}
foreach ($post->postImages as $img) {
if (! str_starts_with($img->filename, 'users/')) continue;
$local = storage_path('app/' . $img->filename);
if (file_exists($local)) $this->putFile($local, $img->filename);
}
}
/**
* Delete local post image files and prune the empty local post directory.
* Handles both the old flat format and the new users/{slug}/posts/{id}/ format.
*/
public function deleteLocalPostImages(\App\Models\Post $post): void
{
$post->loadMissing('postImages');
$paths = [];
if ($post->image) {
$paths[] = str_starts_with($post->image, 'users/')
? storage_path('app/' . $post->image)
: storage_path('app/public/post_images/' . $post->image);
}
foreach ($post->postImages as $img) {
$paths[] = str_starts_with($img->filename, 'users/')
? storage_path('app/' . $img->filename)
: storage_path('app/public/post_images/' . $img->filename);
}
foreach ($paths as $path) {
if (file_exists($path)) @unlink($path);
}
// Prune empty local post dir
$localDir = storage_path('app/' . $this->resolvePostDir($post));
if (is_dir($localDir) && empty(array_diff(scandir($localDir) ?: [], ['.', '..']))) {
@rmdir($localDir);
}
}
/**
* Delete the post's entire NAS directory tree.
*/
public function deleteNasPost(\App\Models\Post $post): void
{
$dir = $this->resolvePostDir($post);
$this->deleteNasTree($dir);
Log::info('NAS: post deleted', ['post_id' => $post->id, 'dir' => $dir]);
}
public function syncAvatar(User $user, string $localAbsPath): void
@@ -560,6 +760,68 @@ class NasSyncService
$this->putFile($localAbsPath, "{$dir}/cover.webp");
}
/**
* Absolute path of the local profile directory for a user.
* Mirrors the NAS path: storage/app/users/{slug}/profile/
*/
public function localProfileDir(User $user): string
{
return storage_path('app/users/' . $this->userSlug($user) . '/profile');
}
public function deleteLocalAvatar(User $user): void
{
if (! $user->avatar) return;
if (str_starts_with($user->avatar, 'users/')) {
// New format: relative path stored in DB
$path = storage_path('app/' . $user->avatar);
} else {
// Legacy flat format
$path = storage_path('app/public/avatars/' . $user->avatar);
}
if (file_exists($path)) @unlink($path);
}
public function deleteLocalBanner(User $user): void
{
if (! $user->banner) return;
if (str_starts_with($user->banner, 'users/')) {
$path = storage_path('app/' . $user->banner);
} else {
$path = storage_path('app/public/banners/' . $user->banner);
}
if (file_exists($path)) @unlink($path);
}
/**
* Check whether a file exists on the NAS share.
* Uses a lightweight smbclient ls — does not download the file.
*/
public function nasFileExists(string $nasRelPath): bool
{
$cfg = $this->cfg();
$target = escapeshellarg($this->smbTarget($cfg));
$cred = escapeshellarg($this->smbCredential($cfg));
$cmd = 'ls "' . $nasRelPath . '"';
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code);
// smbclient exits 0 and output contains the filename when found;
// exits non-zero or contains NT_STATUS_NO_SUCH_FILE when missing.
if ($code !== 0) return false;
foreach ($output as $line) {
if (str_contains($line, 'NT_STATUS_NO_SUCH_FILE') ||
str_contains($line, 'NT_STATUS_OBJECT_NAME_NOT_FOUND')) {
return false;
}
}
return true;
}
// ── SMB primitives ────────────────────────────────────────────────────────
public function mkdirp(string $path): void
@@ -633,6 +895,21 @@ class NasSyncService
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rm "' . $nasRelPath . '"') . ' 2>&1');
}
/**
* Delete all files inside a NAS directory using a wildcard.
* Does NOT remove the directory itself — call deleteFolder() after.
*/
public function deleteFilesInDir(string $nasRelPath): void
{
$cfg = $this->cfg();
$target = escapeshellarg($this->smbTarget($cfg));
$cred = escapeshellarg($this->smbCredential($cfg));
// smbclient `del` accepts a mask relative to the share root
exec('smbclient ' . $target . ' -U ' . $cred
. ' -c ' . escapeshellarg('del "' . $nasRelPath . '/*"')
. ' 2>&1');
}
public function deleteFolder(string $nasRelPath): void
{
$cfg = $this->cfg();
@@ -641,6 +918,175 @@ class NasSyncService
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rmdir "' . $nasRelPath . '"') . ' 2>&1');
}
/**
* Rename the video's NAS and local directories to match the current title,
* then update all affected DB paths (video, slides).
*
* Call this after video.title has been saved but before syncVideo(), so that
* the sync job writes to the correctly-named folder.
*/
public function renameVideoDir(Video $video): void
{
if (! str_starts_with($video->path, 'users/')) return;
$video->loadMissing(['user', 'slides']);
$userSlug = $this->userSlug($video->user);
$base = "users/{$userSlug}/videos";
// Current folder derived from the stored path (title not yet reflected in folder name)
$currentDir = dirname($video->path); // "users/{slug}/videos/{old-slug}"
// Desired folder based on the (already-saved) new title
$newDir = $this->findFreeVideoDir($base, $this->titleSlug($video->title), $video->id);
if ($currentDir === $newDir) return;
// Rename on NAS
$this->renameNasPath($currentDir, $newDir);
// Rename locally if the dir exists
$oldLocal = storage_path('app/' . $currentDir);
$newLocal = storage_path('app/' . $newDir);
if (is_dir($oldLocal)) {
rename($oldLocal, $newLocal);
}
// Update all DB paths that reference the old directory
$oldPrefix = $currentDir . '/';
$newPrefix = $newDir . '/';
$videoUpdates = [];
if (str_starts_with($video->path, $oldPrefix)) {
$videoUpdates['path'] = $newPrefix . substr($video->path, strlen($oldPrefix));
}
if ($video->thumbnail && str_starts_with($video->thumbnail, $oldPrefix)) {
$videoUpdates['thumbnail'] = $newPrefix . substr($video->thumbnail, strlen($oldPrefix));
}
if (! empty($videoUpdates)) {
$video->update($videoUpdates);
$video->refresh();
}
foreach ($video->slides as $slide) {
if (str_starts_with($slide->filename, $oldPrefix)) {
$slide->update(['filename' => $newPrefix . substr($slide->filename, strlen($oldPrefix))]);
}
}
Log::info('NAS: video dir renamed', [
'video_id' => $video->id,
'from' => $currentDir,
'to' => $newDir,
]);
}
/**
* Find a free (or already-owned) NAS video directory for the given slug.
*/
private function findFreeVideoDir(string $base, string $slug, int $videoId): string
{
for ($i = 1; $i <= 50; $i++) {
$candidate = $i === 1 ? $slug : "{$slug}-{$i}";
$meta = $this->readMeta("{$base}/{$candidate}");
if ($meta === null) return "{$base}/{$candidate}";
if (($meta['id'] ?? null) === $videoId) return "{$base}/{$candidate}";
}
return "{$base}/{$slug}-{$videoId}";
}
/**
* Rename a path on the NAS share (works for both files and directories).
*/
private function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void
{
$cfg = $this->cfg();
$target = escapeshellarg($this->smbTarget($cfg));
$cred = escapeshellarg($this->smbCredential($cfg));
$old = str_replace('/', '\\', $oldNasRelPath);
$new = str_replace('/', '\\', $newNasRelPath);
exec(
'smbclient ' . $target . ' -U ' . $cred
. ' -c ' . escapeshellarg("rename \"{$old}\" \"{$new}\"")
. ' 2>&1',
$output, $code
);
if ($code !== 0) {
Log::warning('NAS rename failed', [
'old' => $oldNasRelPath,
'new' => $newNasRelPath,
'output' => implode(' ', $output),
]);
}
}
/**
* List subdirectory names directly under a NAS path.
*/
public function listNasDirs(string $nasRelPath): array
{
$cfg = $this->cfg();
$target = escapeshellarg($this->smbTarget($cfg));
$cred = escapeshellarg($this->smbCredential($cfg));
$smbPath = str_replace('/', '\\', $nasRelPath) . '\\*';
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg("ls \"{$smbPath}\"") . ' 2>&1', $output);
$dirs = [];
foreach ($output as $line) {
if (! preg_match('/^ (.+?)\s{2,}D\s/', $line, $m)) continue;
$name = trim($m[1]);
if ($name === '.' || $name === '..') continue;
$dirs[] = $name;
}
return $dirs;
}
/**
* Recursively delete a NAS directory tree (all files, subdirs, then the dir itself).
*/
public function deleteNasTree(string $nasRelPath): void
{
foreach ($this->listNasDirs($nasRelPath) as $subdir) {
$this->deleteNasTree("{$nasRelPath}/{$subdir}");
}
$this->deleteFilesInDir($nasRelPath);
$this->deleteFolder($nasRelPath);
}
/**
* Scan every video folder on the NAS and return those whose meta.json
* references a video ID that no longer exists in the database.
*
* Returns array of ['dir' => 'users/{slug}/videos/{slug}', 'video_id' => int|null]
*/
public function scanNasOrphans(): array
{
$orphans = [];
$userDirs = $this->listNasDirs('users');
foreach ($userDirs as $userSlug) {
$videosBase = "users/{$userSlug}/videos";
$videoDirs = $this->listNasDirs($videosBase);
foreach ($videoDirs as $videoSlug) {
$dir = "{$videosBase}/{$videoSlug}";
$meta = $this->readMeta($dir);
$videoId = $meta ? ($meta['id'] ?? null) : null;
if ($videoId === null) {
$orphans[] = ['dir' => $dir, 'video_id' => null];
continue;
}
if (! \App\Models\Video::where('id', $videoId)->exists()) {
$orphans[] = ['dir' => $dir, 'video_id' => $videoId];
}
}
}
return $orphans;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function cfg(): array