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) - {{ $video->title }} @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 + @else + NAS is disabled. Enable it under NAS Storage. + @endif + + + + @if($settings['nas_sync_enabled'] === 'true') +
+
+ Repair stuck files + + Scans for files that were saved locally but never reached the NAS (e.g. due to a + connection error during upload or edit). Uploads them to the NAS, then removes the + local copies. Safe to run at any time — nothing is deleted until the NAS confirms receipt. + +
+
+ + +
+
+ + @endif + + + + +{{-- ── Backup & Restore ──────────────────────────────────────────────────────── --}} +
+
+ + Backup & Restore +
+
+ +
+
+ Export users & settings + Downloads a JSON file containing all user accounts and system settings. Does not include media files. +
+ +
+ +
+
+ Restore users & settings + Upload a previously exported backup JSON. Existing users are matched by email and updated; new users are created. Settings are merged. +
+
+
+ @csrf + + +
@@ -392,15 +447,6 @@ function selectEncoder(el) { document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4'; } -// ── NAS sync toggle ─────────────────────────────────────────── -const nasToggle = document.getElementById('nasSyncInput'); -const nasHidden = document.getElementById('nasSyncHidden'); -const nasLabel = document.getElementById('nasSyncLabel'); -nasToggle.addEventListener('change', () => { - nasHidden.value = nasToggle.checked ? 'true' : 'false'; - nasLabel.textContent = nasToggle.checked ? 'Enabled' : 'Disabled'; -}); - // ── GPU toggle ──────────────────────────────────────────────── const gpuToggle = document.getElementById('gpuEnabledInput'); const gpuHidden = document.getElementById('gpuEnabledHidden'); @@ -476,9 +522,271 @@ function buildGpuCards(gpus, selectedDevice) { function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + +// ── NAS Repair ──────────────────────────────────────────────── +(function () { + const scanBtn = document.getElementById('nasRepairScanBtn'); + const fixBtn = document.getElementById('nasRepairFixBtn'); + const resultEl = document.getElementById('nasRepairResult'); + const inner = document.getElementById('nasRepairResultInner'); + const scanIcon = document.getElementById('nasRepairScanIcon'); + const fixIcon = document.getElementById('nasRepairFixIcon'); + if (! scanBtn) return; + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; + let stuckCount = 0; + + function showResult(html, type = 'info') { + const colours = { success: '#22c55e', warning: '#f59e0b', danger: '#ef4444', info: 'var(--text-secondary)' }; + inner.innerHTML = `
${html}
`; + resultEl.style.display = 'block'; + } + + scanBtn.addEventListener('click', async function () { + scanBtn.disabled = true; + fixBtn.style.display = 'none'; + scanIcon.className = 'bi bi-arrow-repeat spin'; + showResult('Scanning…', 'info'); + + try { + const res = await fetch('{{ url("/admin/nas-repair") }}?scan=1', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ scan_only: true }), + }); + const data = await res.json(); + stuckCount = data.stuck ?? 0; + + if (stuckCount === 0) { + showResult('✅ All clear — no stuck local files found.', 'success'); + } else { + let html = `⚠ ${stuckCount} video(s) have files stuck locally.`; + if (data.details && data.details.length) { + html += `
    ` + + data.details.map(d => `
  • ${escHtml(d)}
  • `).join('') + `
`; + } + html += `

Click Fix All to upload them to the NAS and remove local copies.

`; + showResult(html, 'warning'); + fixBtn.style.display = ''; + } + } catch (e) { + showResult('❌ Scan failed: ' + escHtml(e.message), 'danger'); + } + + scanIcon.className = 'bi bi-search'; + scanBtn.disabled = false; + }); + + fixBtn.addEventListener('click', async function () { + if (! confirm(`Upload ${stuckCount} stuck file(s) to NAS and remove local copies?`)) return; + fixBtn.disabled = true; + scanBtn.disabled = true; + fixIcon.className = 'bi bi-arrow-repeat spin'; + showResult('Uploading to NAS…', 'info'); + + try { + const res = await fetch('{{ url("/admin/nas-repair") }}', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' }, + }); + const data = await res.json(); + const type = data.failed > 0 ? 'warning' : 'success'; + let html = `${data.success ? '✅' : '⚠️'} ${escHtml(data.message)}`; + if (data.details && data.details.length) { + html += `
    ` + + data.details.map(d => `
  • ${escHtml(d)}
  • `).join('') + `
`; + } + showResult(html, type); + fixBtn.style.display = 'none'; + } catch (e) { + showResult('❌ Repair failed: ' + escHtml(e.message), 'danger'); + } + + fixIcon.className = 'bi bi-arrow-repeat'; + fixBtn.disabled = false; + scanBtn.disabled = false; + }); +})(); + +// ── NAS Disable Modal ──────────────────────────────────────── +let _nasOpt = null; +let _nasPollTimer = null; + +function openNasDisableModal() { + _nasOpt = null; + document.getElementById('nasDisableModal').style.display = 'flex'; + document.getElementById('nasDisableStep1').style.display = 'block'; + document.getElementById('nasDisableStep2Migrate').style.display = 'none'; + document.getElementById('nasDisableStep2Fresh').style.display = 'none'; + document.getElementById('nasDisableNextBtn').disabled = true; + ['optMigrate','optFresh'].forEach(id => { + document.getElementById(id).style.borderColor = 'var(--border)'; + }); +} + +function closeNasDisableModal() { + if (_nasPollTimer) clearInterval(_nasPollTimer); + document.getElementById('nasDisableModal').style.display = 'none'; +} + +function selectNasOpt(opt) { + _nasOpt = opt; + document.getElementById('optMigrate').style.borderColor = opt === 'migrate' ? 'var(--brand)' : 'var(--border)'; + document.getElementById('optFresh').style.borderColor = opt === 'fresh' ? '#e74c3c' : 'var(--border)'; + document.getElementById('nasDisableNextBtn').disabled = false; +} + +function nasDisableNext() { + document.getElementById('nasDisableStep1').style.display = 'none'; + if (_nasOpt === 'migrate') { + document.getElementById('nasDisableStep2Migrate').style.display = 'block'; + nasStartMigration(); + } else { + document.getElementById('nasDisableStep2Fresh').style.display = 'block'; + } +} + +async function nasStartMigration() { + try { + await fetch('{{ route("admin.nas.disable") }}', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }, + body: JSON.stringify({ mode: 'migrate' }), + }); + } catch(e) { + document.getElementById('nasDisableError').textContent = 'Failed to start migration: ' + e.message; + document.getElementById('nasDisableError').style.display = 'block'; + return; + } + _nasPollTimer = setInterval(nasPollProgress, 2000); +} + +async function nasPollProgress() { + try { + const r = await fetch('{{ route("admin.nas.migrate-progress") }}'); + const d = await r.json(); + const pct = d.total > 0 ? Math.round((d.current / d.total) * 100) : 0; + document.getElementById('nasDisableBar').style.width = pct + '%'; + document.getElementById('nasDisableCount').textContent = d.current + ' / ' + d.total; + document.getElementById('nasDisablePhase').textContent = d.phase || ''; + if (d.error) { + clearInterval(_nasPollTimer); + document.getElementById('nasDisableError').textContent = 'Error: ' + d.error; + document.getElementById('nasDisableError').style.display = 'block'; + } + if (d.done) { + clearInterval(_nasPollTimer); + document.getElementById('nasDisableBar').style.width = '100%'; + document.getElementById('nasDisableCount').textContent = d.total + ' / ' + d.total; + document.getElementById('nasDisableDone').style.display = 'block'; + } + } catch(e) { /* network blip, keep polling */ } +} + +async function nasDisableFreshConfirm() { + const val = document.getElementById('nasDeleteConfirmInput').value.trim(); + const errEl = document.getElementById('nasDeleteConfirmError'); + if (val !== 'DELETE') { + errEl.textContent = 'Type DELETE (all caps) to confirm.'; + errEl.style.display = 'block'; + return; + } + errEl.style.display = 'none'; + try { + const r = await fetch('{{ route("admin.nas.disable") }}', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }, + body: JSON.stringify({ mode: 'fresh' }), + }); + const d = await r.json(); + if (d.ok) { + closeNasDisableModal(); + location.reload(); + } else { + errEl.textContent = d.message || 'An error occurred.'; + errEl.style.display = 'block'; + } + } catch(e) { + errEl.textContent = 'Failed: ' + e.message; + errEl.style.display = 'block'; + } +} + +{{-- ── NAS Disable Modal ─────────────────────────────────────── --}} + @endsection diff --git a/resources/views/admin/users.blade.php b/resources/views/admin/users.blade.php index 8892473..be44b78 100644 --- a/resources/views/admin/users.blade.php +++ b/resources/views/admin/users.blade.php @@ -2,14 +2,229 @@ @section('title', 'Users') +@php +$adminNeedsOtp = auth()->user()->two_factor_enabled && + (! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800); + +$totalUsers = \App\Models\User::count(); +$totalAdmins = \App\Models\User::whereIn('role', ['admin','super_admin'])->count(); +$newThisWeek = \App\Models\User::where('created_at', '>=', now()->subDays(7))->count(); +$unverified = \App\Models\User::whereNull('email_verified_at')->count(); +@endphp + +@section('extra_styles') + +@endsection + @section('content') -{{-- ── Page header ──────────────────────────────────────────────────── --}} -{{-- ── Alerts ───────────────────────────────────────────────────────── --}} +{{-- Alerts --}} @if(session('success'))
@@ -25,181 +240,220 @@
@endif -{{-- ── Filter card ──────────────────────────────────────────────────── --}} -
-
-
-
@@ -226,19 +491,132 @@ @section('scripts') @endsection diff --git a/resources/views/admin/video-analytics.blade.php b/resources/views/admin/video-analytics.blade.php index 3ea1e31..09928b4 100644 --- a/resources/views/admin/video-analytics.blade.php +++ b/resources/views/admin/video-analytics.blade.php @@ -195,7 +195,7 @@ function flagEmoji(string $code): string {
@if($video->thumbnail) - {{ $video->title }} + {{ $video->title }} @else @endif @@ -414,7 +414,7 @@ function flagEmoji(string $code): string {
@if($view->viewer_avatar) - + @else @endif diff --git a/resources/views/admin/videos.blade.php b/resources/views/admin/videos.blade.php index 0c9a7c2..25c7cc5 100644 --- a/resources/views/admin/videos.blade.php +++ b/resources/views/admin/videos.blade.php @@ -2,6 +2,11 @@ @section('title', 'Videos') +@php +$adminNeedsOtp = auth()->user()->two_factor_enabled && + (! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800); +@endphp + @section('content') {{-- ── Page header ──────────────────────────────────────────────────── --}} @@ -100,18 +105,19 @@ Type Views Likes + Shares Uploaded Actions @forelse($videos as $video) - + {{-- Thumbnail + title --}} @@ -273,13 +294,65 @@ @section('scripts') diff --git a/resources/views/components/video-card.blade.php b/resources/views/components/video-card.blade.php index a89c714..4271d0c 100644 --- a/resources/views/components/video-card.blade.php +++ b/resources/views/components/video-card.blade.php @@ -3,7 +3,7 @@ @php $videoUrl = $video ? asset('storage/videos/' . $video->filename) : null; $thumbnailUrl = $video && $video->thumbnail - ? asset('storage/thumbnails/' . $video->thumbnail) + ? route('media.thumbnail', $video->thumbnail) : ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360'); $typeIcon = $video ? match($video->type) { @@ -29,7 +29,7 @@ $sizeClasses = match($size) {
- {{ $video->title ?? 'Video' }} + {{ $video->title ?? 'Video' }} @if($videoUrl) @if($video && $video->user && $video->user->avatar_url) - {{ $video->user->name }} + {{ $video->user->name }} @endif
@@ -275,6 +275,28 @@ $sizeClasses = match($size) { background: #1a1a1a; } +/* Skeleton shimmer while thumbnail loads */ +.yt-video-card .yt-video-thumb::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%); + background-size: 200% 100%; + animation: thumb-shimmer 1.4s ease infinite; + z-index: 0; + border-radius: inherit; +} + +@keyframes thumb-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.yt-video-card .yt-video-thumb.loaded::before { + animation: none; + opacity: 0; +} + .yt-video-card .yt-video-thumb img { width: 100%; height: 100%; @@ -282,9 +304,16 @@ $sizeClasses = match($size) { position: absolute; top: 0; left: 0; - transition: transform 0.2s ease; + opacity: 0; + transition: opacity 0.3s ease, transform 0.2s ease; + z-index: 1; } -.yt-video-card:hover .yt-video-thumb img { + +.yt-video-card .yt-video-thumb img.loaded { + opacity: 1; +} + +.yt-video-card:hover .yt-video-thumb img.loaded { transform: scale(1.03); } @@ -298,6 +327,7 @@ $sizeClasses = match($size) { opacity: 0; transition: opacity 0.3s ease; background: #000; + z-index: 2; } .yt-video-card .yt-video-thumb video.active { @@ -315,6 +345,7 @@ $sizeClasses = match($size) { opacity: 0; transition: opacity 0.3s ease; pointer-events: none; + z-index: 3; } .yt-video-thumb.audio-playing .audio-preview-overlay { @@ -360,6 +391,7 @@ $sizeClasses = match($size) { border-radius: 4px; font-size: 12px; font-weight: 500; + z-index: 3; } .yt-video-card .yt-shorts-badge { @@ -377,6 +409,7 @@ $sizeClasses = match($size) { gap: 4px; text-transform: uppercase; letter-spacing: 0.5px; + z-index: 3; } .yt-video-card .yt-shorts-badge i { @@ -395,6 +428,7 @@ $sizeClasses = match($size) { align-items: center; gap: 4px; pointer-events: none; + z-index: 3; } .yt-video-card .yt-visibility-private { @@ -423,6 +457,10 @@ $sizeClasses = match($size) { overflow: hidden; } +.yt-video-card .yt-channel-icon img.loaded { + opacity: 1 !important; +} + .yt-video-card .yt-video-details { flex: 1; min-width: 0; diff --git a/resources/views/components/video-insights.blade.php b/resources/views/components/video-insights.blade.php index 32bdc05..793cb58 100644 --- a/resources/views/components/video-insights.blade.php +++ b/resources/views/components/video-insights.blade.php @@ -79,6 +79,18 @@ .ins-demo-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; } .ins-demo-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; } +/* ── Two-column grid (collapses to 1 col on mobile) ─── */ +.ins-two-col { display:grid; grid-template-columns:1fr 1fr; gap:20px; } +@media(max-width:600px){ .ins-two-col { grid-template-columns:1fr; } } + +/* ── Who Liked rows ──────────────────────────────────── */ +.ins-liker-row { display:flex; align-items:center; gap:10px; padding:7px 6px; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:8px; transition:background .12s; } +.ins-liker-row:last-child { border-bottom:none; } +.ins-liker-row:hover { background:rgba(255,255,255,.05); } +.ins-liker-avatar { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); } +.ins-liker-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.ins-liker-time { font-size:11px; color:var(--text-secondary); flex-shrink:0; } + /* ── Skeleton ─────────────────────────────────────────── */ .ins-skeleton { display:flex; flex-direction:column; gap:12px; padding:4px 0; } .ins-skel-row { height:14px; border-radius:6px; background:rgba(255,255,255,.06); animation:skelPulse 1.4s ease-in-out infinite; } @@ -367,7 +379,7 @@ function renderInsights(d) {
Viewers — ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest
-
+
Top Viewers
${topViewerRows || '

No registered viewers yet.

'} @@ -379,6 +391,30 @@ function renderInsights(d) {
`; + // ── Who Liked section ─────────────────────────────── + let likersHtml = ''; + if (d.likes > 0 && d.likers && d.likers.length) { + const likerRows = d.likers.map(u => ` +
+ ${u.name} +
${u.name}
+
${_ago(u.liked_at)}
+ +
`).join(''); + + likersHtml = ` +
+
Liked by — ${_fmt(d.likes)} ${d.likes===1?'person':'people'}
+ ${likerRows} +
`; + } else if (d.likes === 0) { + likersHtml = ` +
+
Likes
+

No likes yet.

+
`; + } + // ── Downloads section ─────────────────────────────── let dlHtml = ''; if (d.downloads===0) { @@ -422,7 +458,7 @@ function renderInsights(d) {
Downloads — ${_fmt(d.downloads)} total
${pills}
-
+
Top Downloaders (tap to see history) @@ -523,7 +559,7 @@ function renderInsights(d) {
Audience by Country (tap for viewer breakdown)
${countriesHtml} -
` + viewersHtml + dlHtml + shareHtml + demographicsHtml; +
` + viewersHtml + likersHtml + dlHtml + shareHtml + demographicsHtml; } // ══════════════════════════════════════════════════════ diff --git a/resources/views/emails/new-video-notification.blade.php b/resources/views/emails/new-video-notification.blade.php index dd812cf..888856a 100644 --- a/resources/views/emails/new-video-notification.blade.php +++ b/resources/views/emails/new-video-notification.blade.php @@ -10,7 +10,7 @@
@@ -1403,6 +1439,75 @@ submitBtn.innerHTML = ' Save Changes'; }); }); + + // ── Replace File ────────────────────────────────────────────────────── + let _rflFile = null; + const _rflFmtSize = b => b >= 1073741824 ? (b/1073741824).toFixed(1)+' GB' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB'; + + function rflFileSelected(input) { + _rflFile = input.files[0] || null; + const info = document.getElementById('rfl-file-info'); + if (_rflFile) { + document.getElementById('rfl-file-name').textContent = _rflFile.name; + document.getElementById('rfl-file-size').textContent = _rflFmtSize(_rflFile.size); + document.getElementById('rfl-drop-label').textContent = _rflFile.name; + info.style.display = 'flex'; + } else { + info.style.display = 'none'; + document.getElementById('rfl-drop-label').textContent = 'Click to choose replacement file'; + } + document.getElementById('rfl-submit-btn').disabled = !_rflFile; + document.getElementById('rfl-status').style.display = 'none'; + } + + function rflClearFile() { + _rflFile = null; + document.getElementById('rfl-input').value = ''; + document.getElementById('rfl-file-info').style.display = 'none'; + document.getElementById('rfl-drop-label').textContent = 'Click to choose replacement file'; + document.getElementById('rfl-submit-btn').disabled = true; + document.getElementById('rfl-status').style.display = 'none'; + } + + function rflSubmit() { + if (!_rflFile || !window.currentVideoId) return; + const btn = document.getElementById('rfl-submit-btn'); + const status = document.getElementById('rfl-status'); + + btn.disabled = true; + btn.innerHTML = ' Uploading…'; + status.style.display = 'none'; + + const fd = new FormData(); + fd.append('_token', '{{ csrf_token() }}'); + fd.append('replacement_file', _rflFile); + + fetch(`/videos/${window.currentVideoId}/replace-file`, { + method: 'POST', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: fd + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + status.style.cssText = 'display:block;background:rgba(74,222,128,.1);border:1px solid rgba(74,222,128,.25);color:#4ade80;'; + status.innerHTML = ' ' + data.message; + rflClearFile(); + setTimeout(() => location.reload(), 2000); + } else { + status.style.cssText = 'display:block;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);color:#f87171;'; + status.textContent = data.message || 'Upload failed. Please try again.'; + btn.disabled = false; + btn.innerHTML = ' Replace File'; + } + }) + .catch(() => { + status.style.cssText = 'display:block;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);color:#f87171;'; + status.textContent = 'Upload failed. Please try again.'; + btn.disabled = false; + btn.innerHTML = ' Replace File'; + }); + }
-
@@ -1774,7 +1774,7 @@ $headerSocialMap = [ @foreach($shorts as $short)
- {{ $short->title }} @if($short->duration) {{ gmdate('i:s', $short->duration) }} @@ -2454,11 +2454,11 @@ if ($oldLinks) {
@if($v->thumbnail) - + @else
@endif diff --git a/resources/views/user/profile.blade.php b/resources/views/user/profile.blade.php index d244351..0c22e4a 100644 --- a/resources/views/user/profile.blade.php +++ b/resources/views/user/profile.blade.php @@ -328,7 +328,7 @@ if ($oldLinks) {
@if($user->avatar) - {{ $user->name }} + {{ $user->name }} @else {{ $user->name }} @endif @@ -416,7 +416,7 @@ if ($oldLinks) {
- {{ $video->title }} + {{ $video->title }} {{ $video->duration ?? '0:00' }}
@@ -525,7 +525,7 @@ if ($oldLinks) {
@if($user->avatar) - Avatar + Avatar @else Avatar @endif diff --git a/resources/views/vendor/nas-file-manager/file-manager.blade.php b/resources/views/vendor/nas-file-manager/file-manager.blade.php index aa3991f..6f8f7bb 100644 --- a/resources/views/vendor/nas-file-manager/file-manager.blade.php +++ b/resources/views/vendor/nas-file-manager/file-manager.blade.php @@ -3,187 +3,440 @@ $canEdit = $canEdit ?? (config('nas-file-manager.edit_gate') === null || \Illuminate\Support\Facades\Gate::allows(config('nas-file-manager.edit_gate'))); $title = $title ?? 'Folder Structure & File Manager'; - $conn = config('nas-file-manager.connection', []); - $hasConnection = !empty($conn['host']); - $connConfig = [ - 'protocol' => $conn['protocol'] ?? 'sftp', - 'host' => $conn['host'] ?? '', - 'port' => (int) ($conn['port'] ?? 22), - 'username' => $conn['username'] ?? '', - 'path' => $conn['path'] ?? '/media', - 'smb_share' => $conn['smb_share'] ?? '', - 'smb_domain' => $conn['smb_domain'] ?? '', - 'has_password' => !empty($conn['password']), - ]; + // Build connections — DB rows take priority, then config, then legacy key + $rawConns = \P7H\NasFileManager\Models\NasConnection::allAsConfig() + ?? config('nas-file-manager.connections', []); + + if (empty($rawConns)) { + $lc = config('nas-file-manager.connection', []); + $rawConns = [[ + 'name' => 'Primary NAS', + 'enabled' => true, + 'protocol' => $lc['protocol'] ?? 'sftp', + 'host' => $lc['host'] ?? '', + 'port' => (int)($lc['port'] ?? 22), + 'username' => $lc['username'] ?? '', + 'password' => $lc['password'] ?? '', + 'share' => $lc['smb_share'] ?? '', + 'smb_domain' => $lc['smb_domain'] ?? '', + 'subdirectory' => $lc['path'] ?? '/media', + ]]; + } + + $connectionsJs = collect($rawConns)->values()->map(fn($c, $i) => [ + '_id' => $c['db_id'] ?? ($i + 1), + 'db_id' => $c['db_id'] ?? null, + 'name' => $c['name'] ?? ('Connection ' . ($i + 1)), + 'enabled' => (bool)($c['enabled'] ?? true), + 'expanded' => $i === 0, + 'protocol' => $c['protocol'] ?? 'sftp', + 'host' => $c['host'] ?? '', + 'port' => (int)($c['port'] ?? 22), + 'username' => $c['username'] ?? '', + 'password' => '', + 'has_password' => !empty($c['password']), + 'share' => $c['share'] ?? ($c['smb_share'] ?? ''), + 'smb_domain' => $c['smb_domain'] ?? '', + 'subdirectory' => $c['subdirectory'] ?? ($c['path'] ?? '/media'), + 'testStatus' => null, + 'testMessage' => '', + 'saveStatus' => null, + 'saveMessage' => '', + 'pickerOpen' => false, + 'pickerItems' => [], + 'pickerPath' => '', + 'pickerSegments' => [], + 'pickerLoading' => false, + 'pickerError' => '', + 'shares' => [], + 'sharesLoading' => false, + ])->all(); + + $hasConnection = collect($rawConns)->contains(fn($c) => !empty($c['host'])); @endphp -
+
- {{-- ── Tab bar ── --}} -
- - - -
- - {{-- ── SCHEMA TAB ── --}} -
-

- Paths are relative to the configured base path. - Placeholders in {curly braces} are filled in at runtime. -

-
- - -
-
- - {{-- ── BROWSER TAB ── --}} -
- - {{-- Toast --}} - - - {{-- Breadcrumb --}} -
- - - -
- - {{-- Error --}} - - - {{-- File list --}} -
- - - -
- - @if($canEdit) -
- - -
- @endif - -
- - {{-- ── CONNECTION TAB ── --}} -
- - @if(!$hasConnection) -
- + {{-- ── Accordion header ── --}} + - {{-- Status --}} -