diff --git a/CLAUDE.md b/CLAUDE.md
index ecc9239..22a3751 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -142,6 +142,58 @@ The tracker is the source of truth for blast radius. If the tracker is out of da
**Match highlights sidebar must always match the video player height** — use a `ResizeObserver` on `#ytpWrap` to write `--sidebar-height` to `document.documentElement` and bind `.events-sidebar { height: var(--sidebar-height) }`. Never hardcode a pixel or viewport height for the sidebar. The pattern lives in `videos/types/match.blade.php` (`initSidebarHeightSync`).
+### NAS is always enabled — it is the only storage backend
+
+**The NAS is permanently enabled in this project. Local disk is never a storage destination — it is a temporary write buffer only. Every user file must end up on the NAS and be served from the NAS. No exceptions.**
+
+File types and their NAS locations:
+
+| File type | NAS path | Served via |
+|---|---|---|
+| Video / audio | `users/{slug}/videos/{title-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` |
+| Video thumbnail | `users/{slug}/videos/{title-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
+| Audio slides | `users/{slug}/videos/{title-slug}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
+| Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
+| Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` |
+| Banner | `users/{slug}/profile/banner.{ext}` | `MediaController::banner` + `ensureLocalAsset()` |
+| Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` |
+
+The only files that live permanently on local disk are HLS segments (`storage/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
+
+**The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:
+- `storage/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
+- `storage/app/public/avatars/` — formerly held user avatars; now NAS only
+- `storage/app/public/videos/` — formerly held uploaded video files; now NAS only
+
+These directories may appear temporarily during an upload (as a write buffer before NAS push) and are cleaned up immediately. If you ever find files lingering there after an upload completes, it means the NAS push failed — investigate the NAS connection, do not leave files there.
+
+**Absolute rules — these must never be violated:**
+
+1. **Never use `asset('storage/...')` for any user file URL.** Always use the named media routes: `route('media.thumbnail', $path)`, `route('media.avatar', $path)`, `route('media.banner', $path)`, `route('media.post-image', $path)`. These routes go through `MediaController` which calls `ensureLocalAsset()` and pulls from NAS automatically.
+
+2. **After writing any file to local disk, immediately push it to NAS and delete the local copy.** The upload flow is always: write to temp → push to NAS → delete local. Use the correct service method for each type:
+ - Videos/audio → `NasSyncService::uploadDirectToNas()` then `deleteLocalVideo()`
+ - Thumbnails (video/slide) → `NasSyncService::putFile($tempAbs, "{$nasDir}/thumb.{$ext}")` then `@unlink($tempAbs)`, store full NAS path in DB
+ - Playlist thumbnails → `PlaylistController::pushPlaylistThumbnailToNas()` (handles mkdirp, putFile, unlink internally)
+ - Avatars → `NasSyncService::syncAvatar()` then `deleteLocalAvatar()`
+ - Banners → `NasSyncService::syncBanner()` then `deleteLocalBanner()`
+ - Post images → `NasSyncService::syncPostImages()` then `deleteLocalPostImages()`
+
+3. **Always store the full NAS relative path in the DB, never just the filename.** The DB column must contain the full `users/...` path (e.g. `users/hanzo-hattori-bfnmwq/videos/my-title/thumb.png`). Storing only the basename (e.g. `thumb.png` or a UUID filename) is the legacy format that breaks NAS serving and the MediaController fallback logic.
+
+4. **Never call `putFile()` directly for video/audio uploads.** Always use `uploadDirectToNas()` — it resolves the correct `users/...` directory, writes `meta.json`, and updates the DB `path` and `filename` columns. Calling `putFile()` with a manually constructed path will create files in the wrong location that the streaming layer cannot find.
+
+5. **Set `video->status = 'ready'` before dispatching `GenerateHlsJob` for NAS uploads.** The job checks `if ($video->status !== 'ready') return` and silently does nothing otherwise. For NAS, the upload is the compression step — the video is ready as soon as `uploadDirectToNas()` completes. For local storage, `CompressVideoJob` handles the status transition automatically.
+
+6. **Never check `NasSyncService::isEnabled()` before doing a NAS operation in this project.** It is always enabled. Writing code with an `if ($nas->isEnabled())` branch that falls back to local-only storage will result in broken files the moment that branch is taken.
+
+**If you find legacy local files that should be on NAS**, follow this migration procedure (same pattern used to clean up thumbnails and avatars):
+1. Identify which DB record owns each local file (`Video::where('thumbnail', $filename)`, `User::where('avatar', $filename)`, etc.)
+2. For each owned file: call `NasSyncService::mkdirp($nasDir)` then `putFile($localAbs, $nasPath)` then `@unlink($localAbs)`, then update the DB record to the full NAS path
+3. Delete files with no DB match (orphans) directly with `@unlink()`
+4. Once a directory is empty, `rmdir()` it — do not leave empty legacy directories
+5. For playlists: use `PlaylistController::pushPlaylistThumbnailToNas()` or replicate its pattern (`mkdirp` + `putFile` + `unlink`)
+
### Infrastructure Notes
- **Cloudflare proxy**: `AppServiceProvider` forces HTTPS and trusts Cloudflare headers via `TrustProxies`.
- **FFmpeg config**: `/config/ffmpeg.php` — binaries at `/usr/bin/ffmpeg` and `/usr/bin/ffprobe`, GPU device 0, 3600 s timeout.
diff --git a/app/Console/Commands/NasFreeLocalStorage.php b/app/Console/Commands/NasFreeLocalStorage.php
index 96c33a8..2ce308b 100644
--- a/app/Console/Commands/NasFreeLocalStorage.php
+++ b/app/Console/Commands/NasFreeLocalStorage.php
@@ -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
diff --git a/app/Console/Commands/NasRepairLocalFiles.php b/app/Console/Commands/NasRepairLocalFiles.php
new file mode 100644
index 0000000..0cd5614
--- /dev/null
+++ b/app/Console/Commands/NasRepairLocalFiles.php
@@ -0,0 +1,435 @@
+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(' ✓ Done');
+ 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(' ✓ Done');
+ } 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(' ✓ Done');
+ } 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(' ✓ Done');
+ } 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(' ✓ Deleted');
+ 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: %s occupying %s — 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 {$evicted} 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';
+ }
+}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index 91cb828..e73a682 100755
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -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();
}
/**
diff --git a/app/Http/Controllers/MediaController.php b/app/Http/Controllers/MediaController.php
index 76b5927..a94bb77 100644
--- a/app/Http/Controllers/MediaController.php
+++ b/app/Http/Controllers/MediaController.php
@@ -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
diff --git a/app/Http/Controllers/PlaylistController.php b/app/Http/Controllers/PlaylistController.php
index 1f144ae..91a55fb 100644
--- a/app/Http/Controllers/PlaylistController.php
+++ b/app/Http/Controllers/PlaylistController.php
@@ -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) {}
+ }
}
diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php
index 63800da..6b0db23 100644
--- a/app/Http/Controllers/PostController.php
+++ b/app/Http/Controllers/PostController.php
@@ -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) {
diff --git a/app/Http/Controllers/SuperAdminController.php b/app/Http/Controllers/SuperAdminController.php
index 5b29208..faf8d60 100644
--- a/app/Http/Controllers/SuperAdminController.php
+++ b/app/Http/Controllers/SuperAdminController.php
@@ -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.");
+ }
}
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index b5a5416..d9a254b 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -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]);
}
}
diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php
index 1882d8b..d85a98a 100644
--- a/app/Http/Controllers/VideoController.php
+++ b/app/Http/Controllers/VideoController.php
@@ -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,
diff --git a/app/Jobs/NasSyncVideoJob.php b/app/Jobs/NasSyncVideoJob.php
index 4f9b6d1..6761bcb 100644
--- a/app/Jobs/NasSyncVideoJob.php
+++ b/app/Jobs/NasSyncVideoJob.php
@@ -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.'
+ );
}
}
}
diff --git a/app/Jobs/NasToLocalMigrationJob.php b/app/Jobs/NasToLocalMigrationJob.php
new file mode 100644
index 0000000..89952c8
--- /dev/null
+++ b/app/Jobs/NasToLocalMigrationJob.php
@@ -0,0 +1,183 @@
+ 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);
+ }
+ }
+}
diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php
index 5196156..021bba2 100644
--- a/app/Models/Playlist.php
+++ b/app/Models/Playlist.php
@@ -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';
}
diff --git a/app/Models/Post.php b/app/Models/Post.php
index 587ecf5..4f9f0c7 100644
--- a/app/Models/Post.php
+++ b/app/Models/Post.php
@@ -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);
}
}
diff --git a/app/Models/PostImage.php b/app/Models/PostImage.php
index 6a86571..7cb91b0 100644
--- a/app/Models/PostImage.php
+++ b/app/Models/PostImage.php
@@ -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);
}
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 36d2c69..6170e2e 100755
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -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;
}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 90ad43f..dfd77fd 100755
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -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
+ }
+ });
}
}
diff --git a/app/Services/NasSyncService.php b/app/Services/NasSyncService.php
index eea3573..d8961c4 100644
--- a/app/Services/NasSyncService.php
+++ b/app/Services/NasSyncService.php
@@ -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
diff --git a/composer.lock b/composer.lock
index ff8eee9..f1dcf43 100755
--- a/composer.lock
+++ b/composer.lock
@@ -2808,18 +2808,21 @@
"source": {
"type": "git",
"url": "https://github.com/itsp7h/File-Structure-package.git",
- "reference": "9018271e2b73099730328191c8a4a3f2606ddc47"
+ "reference": "44462665a31200d2ff791557bafc970fdf07a6c2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/9018271e2b73099730328191c8a4a3f2606ddc47",
- "reference": "9018271e2b73099730328191c8a4a3f2606ddc47",
+ "url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/44462665a31200d2ff791557bafc970fdf07a6c2",
+ "reference": "44462665a31200d2ff791557bafc970fdf07a6c2",
"shasum": ""
},
"require": {
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.1"
},
+ "require-dev": {
+ "composer/composer": "^2.0"
+ },
"default-branch": true,
"type": "library",
"extra": {
@@ -2834,6 +2837,14 @@
"P7H\\NasFileManager\\": "src/"
}
},
+ "scripts": {
+ "post-install-cmd": [
+ "P7H\\NasFileManager\\Installer::install"
+ ],
+ "post-update-cmd": [
+ "P7H\\NasFileManager\\Installer::install"
+ ]
+ },
"license": [
"MIT"
],
@@ -2842,7 +2853,7 @@
"source": "https://github.com/itsp7h/File-Structure-package/tree/main",
"issues": "https://github.com/itsp7h/File-Structure-package/issues"
},
- "time": "2026-05-13T10:39:12+00:00"
+ "time": "2026-05-14T12:07:04+00:00"
},
{
"name": "paragonie/constant_time_encoding",
diff --git a/config/nas-file-manager.php b/config/nas-file-manager.php
index 60d22a2..b30c5fe 100644
--- a/config/nas-file-manager.php
+++ b/config/nas-file-manager.php
@@ -49,9 +49,32 @@ return [
|
*/
'schema' => [
- // Example — uncomment and adapt:
- // ['depth' => 0, 'label' => 'Media', 'path' => 'Media', 'parent_path' => null, 'is_template' => false, 'can_edit' => false],
- // ['depth' => 1, 'label' => 'Outlets', 'path' => 'Media/Outlets', 'parent_path' => 'Media', 'is_template' => false, 'can_edit' => true],
+
+ // ── users ─────────────────────────────────────────────────────────────
+ ['depth' => 0, 'label' => 'users', 'path' => 'users', 'parent_path' => null, 'is_template' => false, 'can_edit' => false],
+ ['depth' => 1, 'label' => '{username}', 'path' => 'users/{username}', 'parent_path' => 'users', 'is_template' => true, 'can_edit' => false],
+
+ // ── profile ───────────────────────────────────────────────────────────
+ ['depth' => 2, 'label' => 'profile', 'path' => 'u/profile', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
+ ['depth' => 3, 'label' => 'avatar.webp', 'path' => 'profile/avatar.webp', 'parent_path' => 'u/profile', 'is_template' => false, 'can_edit' => false],
+ ['depth' => 3, 'label' => 'cover.webp', 'path' => 'profile/cover.webp', 'parent_path' => 'u/profile', 'is_template' => false, 'can_edit' => false],
+
+ // ── videos ────────────────────────────────────────────────────────────
+ ['depth' => 2, 'label' => 'videos', 'path' => 'u/videos', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
+ ['depth' => 3, 'label' => '{video-slug}', 'path' => 'videos/{video-slug}', 'parent_path' => 'u/videos', 'is_template' => true, 'can_edit' => false],
+ ['depth' => 4, 'label' => '{video-slug}.{ext}', 'path' => 'vid/file', 'parent_path' => 'videos/{video-slug}', 'is_template' => true, 'can_edit' => false],
+ ['depth' => 4, 'label' => 'thumb.webp', 'path' => 'vid/thumb.webp', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
+ ['depth' => 4, 'label' => 'meta.json', 'path' => 'vid/meta.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
+ ['depth' => 4, 'label' => 'view-log.json', 'path' => 'vid/view-log.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
+ ['depth' => 4, 'label' => 'edit-log.json', 'path' => 'vid/edit-log.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
+ ['depth' => 4, 'label' => 'slides', 'path' => 'vid/slides', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
+ ['depth' => 5, 'label' => '{position}.{ext}','path' => 'slide/file', 'parent_path' => 'vid/slides', 'is_template' => true, 'can_edit' => false],
+
+ // ── posts ─────────────────────────────────────────────────────────────
+ ['depth' => 2, 'label' => 'posts', 'path' => 'u/posts', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
+ ['depth' => 3, 'label' => '{post_id}', 'path' => 'posts/{post_id}', 'parent_path' => 'u/posts', 'is_template' => true, 'can_edit' => false],
+ ['depth' => 4, 'label' => '{n}.{ext}', 'path' => 'post/image', 'parent_path' => 'posts/{post_id}', 'is_template' => true, 'can_edit' => false],
+
],
];
diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php
index 2bfd5dc..e8d73af 100644
--- a/resources/views/admin/dashboard.blade.php
+++ b/resources/views/admin/dashboard.blade.php
@@ -366,7 +366,7 @@
{{ $i + 1 }} |
@if($v->thumbnail)
-
+
@else
@endif
@@ -559,7 +559,7 @@
|
@if($video->thumbnail)
-
+
@else
@endif
@@ -619,6 +619,12 @@
Clean Orphaned Files
+ @if(\App\Models\Setting::get('nas_sync_enabled', 'false') === 'true')
+
+
+ @endif
@@ -1046,5 +1052,32 @@ document.getElementById('cleanupBtn')?.addEventListener('click', async function(
this.disabled = false;
this.innerHTML = ' Clean Orphaned Files';
});
+
+// ── NAS Repair ──────────────────────────────────────────────────
+document.getElementById('nasRepairBtn')?.addEventListener('click', async function() {
+ this.disabled = true;
+ this.innerHTML = ' Scanning…';
+ const status = document.getElementById('nasRepairStatus');
+ status.innerHTML = '';
+ try {
+ const res = await fetch('{{ url("/admin/nas-repair") }}', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content, 'X-Requested-With': 'XMLHttpRequest' }
+ });
+ const data = await res.json();
+ const cls = data.success ? 'alert-success' : 'alert-warning';
+ const icon = data.success ? '✅' : '⚠️';
+ let html = ` ${icon} ${data.message}`;
+ if (data.details && data.details.length) {
+ html += ` ` + data.details.map(d => `- ${d}
`).join('') + ` `;
+ }
+ html += ` `;
+ status.innerHTML = html;
+ } catch(e) {
+ status.innerHTML = ` ❌ ${e.message} `;
+ }
+ this.disabled = false;
+ this.innerHTML = ' Repair NAS Storage';
+});
@endsection
diff --git a/resources/views/admin/edit-video.blade.php b/resources/views/admin/edit-video.blade.php
index eed5ba9..c562ca9 100644
--- a/resources/views/admin/edit-video.blade.php
+++ b/resources/views/admin/edit-video.blade.php
@@ -3,6 +3,11 @@
@section('title', 'Edit Video')
@section('page_title', 'Edit Video')
+@php
+$adminNeedsOtp = auth()->user()->two_factor_enabled &&
+ (! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
+@endphp
+
@section('extra_styles')
@endsection
@@ -203,7 +211,7 @@
@if($video->thumbnail)
-  }})
@else
@@ -259,6 +267,41 @@
+ {{-- Replace File --}}
+
+
+ Replace Media File
+
+
+ Fix a corrupted or missing file. All stats are preserved.
+
+
+
+
+ Click to choose replacement file
+
+
+
+
+
+
+
+
+
+
{{-- Danger zone --}}
Danger Zone
@@ -289,15 +332,25 @@
The video file, thumbnail, and all associated data will be removed. This cannot be undone.
+ @if($adminNeedsOtp)
+
+
+
+
+ @endif
+
@@ -306,9 +359,130 @@
@section('scripts')
@endsection
diff --git a/resources/views/admin/layout.blade.php b/resources/views/admin/layout.blade.php
index 8496e85..b398964 100644
--- a/resources/views/admin/layout.blade.php
+++ b/resources/views/admin/layout.blade.php
@@ -535,7 +535,7 @@
@auth
|