WIP: storage-fix-local-nas work before playlist controls feature
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
6b3ab5b65e
commit
c160242dbc
46 files changed
+5365
-815
No files matched your search
@@ -12,7 +12,8 @@ class NasFreeLocalStorage extends Command
|
||||
{
|
||||
protected $signature = 'nas:free-local
|
||||
{--dry-run : Preview what would be deleted without deleting}
|
||||
{--force : Actually delete local files confirmed on NAS}';
|
||||
{--force : Actually delete local files confirmed on NAS}
|
||||
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
|
||||
|
||||
protected $description = 'Delete local files (videos, thumbnails, avatars, banners) already stored on the NAS';
|
||||
|
||||
@@ -140,6 +141,7 @@ class NasFreeLocalStorage extends Command
|
||||
$this->newLine();
|
||||
$this->info('Scanning avatar files…');
|
||||
|
||||
// Legacy flat dir
|
||||
$avatarDir = storage_path('app/public/avatars');
|
||||
if (is_dir($avatarDir)) {
|
||||
foreach (glob($avatarDir . '/*') as $file) {
|
||||
@@ -148,23 +150,42 @@ class NasFreeLocalStorage extends Command
|
||||
$user = User::where('avatar', $filename)->first();
|
||||
if (! $user) continue;
|
||||
|
||||
// Confirm avatar.webp is on NAS
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file);
|
||||
$totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
$this->line(' Done scanning avatars.');
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/avatar.*
|
||||
$usersBase = storage_path('app/users');
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
|
||||
$user = User::where('avatar', $relPath)->first();
|
||||
if (! $user) continue;
|
||||
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'avatar:' . basename($file), 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(' Done scanning avatars.');
|
||||
|
||||
// ── Banners ───────────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info('Scanning banner files…');
|
||||
|
||||
// Legacy flat dir
|
||||
$bannerDir = storage_path('app/public/banners');
|
||||
if (is_dir($bannerDir)) {
|
||||
foreach (glob($bannerDir . '/*') as $file) {
|
||||
@@ -177,14 +198,32 @@ class NasFreeLocalStorage extends Command
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file);
|
||||
$totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
$this->line(' Done scanning banners.');
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/cover.*
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
|
||||
$user = User::where('banner', $relPath)->first();
|
||||
if (! $user) continue;
|
||||
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'banner:' . basename($file), 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(' Done scanning banners.');
|
||||
|
||||
// ── Slideshow cache directories ───────────────────────────────────────
|
||||
// The slideshow/ directory is a render cache that is always regenerated on
|
||||
// demand, so its contents are safe to delete unconditionally.
|
||||
@@ -205,6 +244,27 @@ class NasFreeLocalStorage extends Command
|
||||
$this->line(' Done scanning slideshow cache.');
|
||||
}
|
||||
|
||||
// ── NAS stream cache (nas_cache/videos/) ──────────────────────────────
|
||||
// These are on-demand local copies of NAS videos used for HTTP streaming.
|
||||
// Always safe to delete — they are re-downloaded from NAS on next play.
|
||||
$this->newLine();
|
||||
$this->info('Scanning NAS stream cache…');
|
||||
|
||||
$nasCacheDir = storage_path('app/nas_cache/videos');
|
||||
$ttl = (int) $this->option('cache-ttl');
|
||||
$cutoff = time() - ($ttl * 3600);
|
||||
|
||||
if (is_dir($nasCacheDir)) {
|
||||
foreach (glob("{$nasCacheDir}/*") as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
if ($ttl > 0 && filemtime($file) >= $cutoff) continue;
|
||||
$bytes = filesize($file);
|
||||
$totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'nas-cache', 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
$this->line(' Done scanning NAS stream cache.');
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
|
||||
@@ -249,6 +309,15 @@ class NasFreeLocalStorage extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// Prune empty directories left behind under storage/app/users/ and flat asset dirs
|
||||
$this->pruneEmptyDirs(storage_path('app/users'));
|
||||
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
|
||||
$path = storage_path("app/{$rel}");
|
||||
if (is_dir($path) && empty(array_diff(scandir($path) ?: [], ['.', '..']))) {
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
|
||||
|
||||
@@ -259,6 +328,32 @@ class NasFreeLocalStorage extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-up prune: remove dirs that are empty or contain only meta.json.
|
||||
*/
|
||||
private function pruneEmptyDirs(string $root): void
|
||||
{
|
||||
if (! is_dir($root)) return;
|
||||
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iter as $item) {
|
||||
if (! $item->isDir()) continue;
|
||||
$path = $item->getPathname();
|
||||
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
|
||||
$nonMeta = array_diff($contents, ['meta.json']);
|
||||
if (empty($contents)) {
|
||||
@rmdir($path);
|
||||
} elseif (empty($nonMeta)) {
|
||||
@unlink("{$path}/meta.json");
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function humanBytes(int $bytes): string
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoSlide;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NasRepairLocalFiles extends Command
|
||||
{
|
||||
protected $signature = 'nas:repair
|
||||
{--dry-run : Preview what would be pushed without making changes}
|
||||
{--force : Actually upload to NAS and remove local copies}
|
||||
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
|
||||
|
||||
protected $description = 'Upload stuck local files to NAS, fix DB paths, and delete local copies';
|
||||
|
||||
private NasSyncService $nas;
|
||||
|
||||
public function handle(NasSyncService $nas): int
|
||||
{
|
||||
if (! $nas->isEnabled()) {
|
||||
$this->error('NAS sync is not enabled. Enable it in Admin → Settings first.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->nas = $nas;
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$force = $this->option('force');
|
||||
|
||||
if (! $dryRun && ! $force) {
|
||||
$this->warn('Pass --dry-run to preview, or --force to repair.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info($dryRun ? 'DRY RUN — nothing will be changed' : 'FORCE — uploading to NAS and removing local copies');
|
||||
$this->newLine();
|
||||
|
||||
$totalRepaired = 0;
|
||||
$totalFailed = 0;
|
||||
|
||||
// ── NAS-format videos / slides / thumbnails ───────────────────────────
|
||||
$stuckVideos = $this->findStuckVideos();
|
||||
if ($stuckVideos->isNotEmpty()) {
|
||||
$this->info('Video / slide files stuck locally:');
|
||||
$rows = [];
|
||||
foreach ($stuckVideos as $item) {
|
||||
foreach ($item['files'] as $label) {
|
||||
$rows[] = [$item['video']->id, substr($item['video']->title, 0, 40), $label];
|
||||
}
|
||||
}
|
||||
$this->table(['Video ID', 'Title', 'File'], $rows);
|
||||
$this->newLine();
|
||||
|
||||
if ($force) {
|
||||
foreach ($stuckVideos as $item) {
|
||||
$video = $item['video'];
|
||||
$this->line(" Repairing video #{$video->id}: {$video->title}…");
|
||||
try {
|
||||
$nas->syncVideo($video);
|
||||
$nas->deleteLocalAssets($video);
|
||||
if ($video->hls_path || $video->type === 'music') {
|
||||
$nas->deleteLocalVideo($video);
|
||||
}
|
||||
$nas->pruneLocalVideoDir($video);
|
||||
$totalRepaired++;
|
||||
$this->line(' <info>✓ Done</info>');
|
||||
Log::info("nas:repair: fixed video #{$video->id}");
|
||||
} catch (\Throwable $e) {
|
||||
$totalFailed++;
|
||||
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
||||
Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Avatars ───────────────────────────────────────────────────────────
|
||||
$stuckAvatars = $this->findStuckAvatars();
|
||||
if ($stuckAvatars->isNotEmpty()) {
|
||||
$this->info('Avatar files stuck locally:');
|
||||
$this->table(['User ID', 'Username', 'File'], $stuckAvatars->map(fn ($r) => [
|
||||
$r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'],
|
||||
])->all());
|
||||
$this->newLine();
|
||||
|
||||
if ($force) {
|
||||
foreach ($stuckAvatars as $item) {
|
||||
$user = $item['user'];
|
||||
$path = $item['path'];
|
||||
$this->line(" Repairing avatar for {$user->username}…");
|
||||
try {
|
||||
$nas->syncAvatar($user, $path);
|
||||
$nas->deleteLocalAvatar($user);
|
||||
$totalRepaired++;
|
||||
$this->line(' <info>✓ Done</info>');
|
||||
} catch (\Throwable $e) {
|
||||
$totalFailed++;
|
||||
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
||||
Log::error("nas:repair: failed avatar user#{$user->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Banners ───────────────────────────────────────────────────────────
|
||||
$stuckBanners = $this->findStuckBanners();
|
||||
if ($stuckBanners->isNotEmpty()) {
|
||||
$this->info('Banner files stuck locally:');
|
||||
$this->table(['User ID', 'Username', 'File'], $stuckBanners->map(fn ($r) => [
|
||||
$r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'],
|
||||
])->all());
|
||||
$this->newLine();
|
||||
|
||||
if ($force) {
|
||||
foreach ($stuckBanners as $item) {
|
||||
$user = $item['user'];
|
||||
$path = $item['path'];
|
||||
$this->line(" Repairing banner for {$user->username}…");
|
||||
try {
|
||||
$nas->syncCover($user, $path);
|
||||
$nas->deleteLocalBanner($user);
|
||||
$totalRepaired++;
|
||||
$this->line(' <info>✓ Done</info>');
|
||||
} catch (\Throwable $e) {
|
||||
$totalFailed++;
|
||||
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
||||
Log::error("nas:repair: failed banner user#{$user->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy flat thumbnails (public/thumbnails/) ───────────────────────
|
||||
$stuckThumbs = $this->findStuckLegacyThumbnails();
|
||||
if ($stuckThumbs->isNotEmpty()) {
|
||||
$this->info('Legacy thumbnail/slide files stuck locally:');
|
||||
$this->table(['Type', 'File', 'Video ID'], $stuckThumbs->map(fn ($r) => [
|
||||
$r['type'], $r['file'], $r['video_id'],
|
||||
])->all());
|
||||
$this->newLine();
|
||||
|
||||
if ($force) {
|
||||
foreach ($stuckThumbs as $item) {
|
||||
$this->line(" Repairing {$item['type']}: {$item['file']}…");
|
||||
try {
|
||||
if ($item['type'] === 'thumbnail' && $item['video']) {
|
||||
$nas->syncVideo($item['video']);
|
||||
} elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) {
|
||||
// Upload slide directly to NAS
|
||||
$video = $item['video'];
|
||||
$slide = $item['slide'];
|
||||
$dir = $nas->resolveVideoDir($video);
|
||||
$ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$nas->mkdirp("{$dir}/slides");
|
||||
$nas->putFile($item['path'], "{$dir}/slides/{$slide->position}.{$ext}");
|
||||
}
|
||||
@unlink($item['path']);
|
||||
$totalRepaired++;
|
||||
$this->line(' <info>✓ Done</info>');
|
||||
} catch (\Throwable $e) {
|
||||
$totalFailed++;
|
||||
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
||||
Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
$anyStuck = $stuckVideos->isNotEmpty() || $stuckAvatars->isNotEmpty()
|
||||
|| $stuckBanners->isNotEmpty() || $stuckThumbs->isNotEmpty();
|
||||
|
||||
// ── NAS orphaned video folders ─────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info('Scanning NAS for orphaned video folders (no matching DB record)…');
|
||||
$nasOrphans = $nas->scanNasOrphans();
|
||||
|
||||
if (! empty($nasOrphans)) {
|
||||
$this->table(
|
||||
['NAS Directory', 'meta.json video_id'],
|
||||
array_map(fn ($o) => [$o['dir'], $o['video_id'] ?? '(none)'], $nasOrphans)
|
||||
);
|
||||
$this->newLine();
|
||||
|
||||
if ($force) {
|
||||
$deletedOrphans = 0;
|
||||
$failedOrphans = 0;
|
||||
foreach ($nasOrphans as $orphan) {
|
||||
$this->line(" Deleting NAS orphan: {$orphan['dir']}…");
|
||||
try {
|
||||
$nas->deleteNasTree($orphan['dir']);
|
||||
$deletedOrphans++;
|
||||
$this->line(' <info>✓ Deleted</info>');
|
||||
Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]);
|
||||
} catch (\Throwable $e) {
|
||||
$failedOrphans++;
|
||||
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
||||
Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
$totalRepaired += $deletedOrphans;
|
||||
$totalFailed += $failedOrphans;
|
||||
$this->newLine();
|
||||
}
|
||||
} else {
|
||||
$this->line(' No orphaned NAS folders found.');
|
||||
}
|
||||
|
||||
$anyIssues = $anyStuck || ! empty($nasOrphans);
|
||||
|
||||
// ── NAS stream cache ──────────────────────────────────────────────────
|
||||
$cacheBytes = $nas->nasCacheSize();
|
||||
if ($cacheBytes > 0) {
|
||||
$ttl = (int) $this->option('cache-ttl');
|
||||
$ttlLabel = $ttl === 0 ? 'all files' : "files older than {$ttl}h";
|
||||
$this->info(sprintf(
|
||||
'NAS stream cache: <comment>%s</comment> occupying <comment>%s</comment> — will be evicted on --force (%s).',
|
||||
count(glob(storage_path('app/nas_cache/videos/*')) ?: []) . ' file(s)',
|
||||
$this->humanBytes($cacheBytes),
|
||||
$ttlLabel
|
||||
));
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if (! $anyIssues && $cacheBytes === 0) {
|
||||
$this->info('Nothing to repair — no stuck local files, no NAS orphans, and no stream cache found.');
|
||||
} elseif (! $anyIssues) {
|
||||
$this->info('No issues found (stream cache will be evicted on --force).');
|
||||
}
|
||||
|
||||
if ($dryRun && ($anyIssues || $cacheBytes > 0)) {
|
||||
$this->warn('Run with --force to repair.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
$this->pruneAllLocalDirs();
|
||||
|
||||
$ttl = (int) $this->option('cache-ttl');
|
||||
$evicted = $nas->clearNasCache($ttl);
|
||||
if ($evicted > 0) {
|
||||
$this->line("Evicted <comment>{$evicted}</comment> NAS stream-cache file(s).");
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalRepaired > 0 || $force) {
|
||||
$this->newLine();
|
||||
if ($totalFailed === 0) {
|
||||
$this->info("Repaired {$totalRepaired} item(s). All local directories cleaned up.");
|
||||
} else {
|
||||
$this->warn("Repaired: {$totalRepaired} Failed: {$totalFailed} — check logs for details.");
|
||||
}
|
||||
}
|
||||
|
||||
return $totalFailed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
// ── Scanners ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function findStuckVideos(): \Illuminate\Support\Collection
|
||||
{
|
||||
return Video::with(['user', 'slides'])->get()
|
||||
->filter(fn (Video $v) => str_starts_with($v->path, 'users/'))
|
||||
->map(function (Video $video) {
|
||||
$files = [];
|
||||
if (file_exists(storage_path('app/' . $video->path)))
|
||||
$files[] = basename($video->path) . ' (video)';
|
||||
if ($video->thumbnail && str_contains($video->thumbnail, '/') &&
|
||||
file_exists(storage_path('app/' . $video->thumbnail)))
|
||||
$files[] = basename($video->thumbnail) . ' (thumbnail)';
|
||||
foreach ($video->slides as $slide) {
|
||||
if (file_exists($slide->localPath()))
|
||||
$files[] = basename($slide->filename) . " (slide #{$slide->position})";
|
||||
}
|
||||
return $files ? ['video' => $video, 'files' => $files] : null;
|
||||
})
|
||||
->filter();
|
||||
}
|
||||
|
||||
private function findStuckAvatars(): \Illuminate\Support\Collection
|
||||
{
|
||||
$results = collect();
|
||||
|
||||
// Legacy flat dir: public/avatars/
|
||||
$dir = storage_path('app/public/avatars');
|
||||
if (is_dir($dir)) {
|
||||
$flat = collect(scandir($dir) ?: [])
|
||||
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
|
||||
->map(function ($filename) use ($dir) {
|
||||
$user = User::where('avatar', $filename)->first();
|
||||
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
|
||||
})
|
||||
->filter();
|
||||
$results = $results->merge($flat);
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/avatar.*
|
||||
$usersBase = storage_path('app/users');
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $path) {
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
|
||||
// relPath = "users/{slug}/profile/avatar.{ext}"
|
||||
$user = User::where('avatar', $relPath)->first();
|
||||
if ($user) {
|
||||
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function findStuckBanners(): \Illuminate\Support\Collection
|
||||
{
|
||||
$results = collect();
|
||||
|
||||
// Legacy flat dir: public/banners/
|
||||
$dir = storage_path('app/public/banners');
|
||||
if (is_dir($dir)) {
|
||||
$flat = collect(scandir($dir) ?: [])
|
||||
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
|
||||
->map(function ($filename) use ($dir) {
|
||||
$user = User::where('banner', $filename)->first();
|
||||
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
|
||||
})
|
||||
->filter();
|
||||
$results = $results->merge($flat);
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/cover.*
|
||||
$usersBase = storage_path('app/users');
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $path) {
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
|
||||
$user = User::where('banner', $relPath)->first();
|
||||
if ($user) {
|
||||
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function findStuckLegacyThumbnails(): \Illuminate\Support\Collection
|
||||
{
|
||||
$dir = storage_path('app/public/thumbnails');
|
||||
if (! is_dir($dir)) return collect();
|
||||
|
||||
$results = [];
|
||||
foreach (scandir($dir) ?: [] as $filename) {
|
||||
if ($filename === '.' || $filename === '..') continue;
|
||||
$path = "{$dir}/{$filename}";
|
||||
if (! is_file($path)) continue;
|
||||
|
||||
// Check if it's a video thumbnail
|
||||
$video = Video::where('thumbnail', $filename)->first();
|
||||
if ($video) {
|
||||
$results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's an old-format slide
|
||||
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
||||
if ($slide && $slide->video) {
|
||||
$results[] = ['type' => 'slide', 'file' => $filename, 'path' => $path, 'video_id' => $slide->video_id, 'video' => $slide->video, 'slide' => $slide];
|
||||
}
|
||||
}
|
||||
|
||||
return collect($results);
|
||||
}
|
||||
|
||||
// ── Directory pruning ─────────────────────────────────────────────────────
|
||||
|
||||
private function pruneAllLocalDirs(): void
|
||||
{
|
||||
// NAS-mirrored dirs
|
||||
$this->pruneEmptyDirTree(storage_path('app/users'));
|
||||
|
||||
// Flat asset dirs — remove if empty
|
||||
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
|
||||
$path = storage_path("app/{$rel}");
|
||||
if (is_dir($path) && $this->isDirEmpty($path)) {
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-up prune: remove dirs that contain only meta.json or nothing.
|
||||
*/
|
||||
private function pruneEmptyDirTree(string $root): void
|
||||
{
|
||||
if (! is_dir($root)) return;
|
||||
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iter as $item) {
|
||||
if (! $item->isDir()) continue;
|
||||
$path = $item->getPathname();
|
||||
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
|
||||
$nonMeta = array_diff($contents, ['meta.json']);
|
||||
if (empty($contents)) {
|
||||
@rmdir($path);
|
||||
} elseif (empty($nonMeta)) {
|
||||
@unlink("{$path}/meta.json");
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isDirEmpty(string $dir): bool
|
||||
{
|
||||
return empty(array_diff(scandir($dir) ?: [], ['.', '..']));
|
||||
}
|
||||
|
||||
private function humanBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1_073_741_824) return round($bytes / 1_073_741_824, 2) . ' GB';
|
||||
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 2) . ' MB';
|
||||
if ($bytes >= 1_024) return round($bytes / 1_024, 2) . ' KB';
|
||||
return $bytes . ' B';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NasToLocalMigrationJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
public int $timeout = 7200; // 2 hours
|
||||
|
||||
private function updateProgress(array $data): void
|
||||
{
|
||||
Cache::put('nas_disable_progress', json_encode($data), 3600);
|
||||
}
|
||||
|
||||
public function handle(NasSyncService $nas): void
|
||||
{
|
||||
$progress = [
|
||||
'current' => 0,
|
||||
'total' => 0,
|
||||
'phase' => 'Counting files...',
|
||||
'done' => false,
|
||||
'error' => null,
|
||||
];
|
||||
$this->updateProgress($progress);
|
||||
|
||||
try {
|
||||
// ── Count all items ───────────────────────────────────────────────
|
||||
$videos = Video::where('path', 'like', 'users/%')->get();
|
||||
|
||||
$slides = \DB::table('video_slides')
|
||||
->where('filename', 'like', 'users/%')
|
||||
->get();
|
||||
|
||||
$usersWithAvatar = User::where('avatar', 'like', 'users/%')->get();
|
||||
$usersWithBanner = User::where('banner', 'like', 'users/%')->get();
|
||||
|
||||
$postImages = \DB::table('post_images')
|
||||
->where('filename', 'like', 'users/%')
|
||||
->get();
|
||||
|
||||
// Count thumbnails separately (they're additional per-video downloads)
|
||||
$videoThumbs = $videos->filter(fn($v) => $v->thumbnail && str_starts_with($v->thumbnail, 'users/'));
|
||||
|
||||
$total = $videos->count()
|
||||
+ $videoThumbs->count()
|
||||
+ $slides->count()
|
||||
+ $usersWithAvatar->count()
|
||||
+ $usersWithBanner->count()
|
||||
+ $postImages->count();
|
||||
|
||||
$progress['total'] = $total;
|
||||
$progress['phase'] = 'Downloading files from NAS...';
|
||||
$this->updateProgress($progress);
|
||||
|
||||
// ── Download videos ───────────────────────────────────────────────
|
||||
foreach ($videos as $video) {
|
||||
$nasPath = $video->path;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download video #' . $video->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading videos... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Download thumbnails ───────────────────────────────────────────
|
||||
foreach ($videoThumbs as $video) {
|
||||
$nasPath = $video->thumbnail;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download thumbnail for video #' . $video->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading thumbnails... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Download slides ───────────────────────────────────────────────
|
||||
foreach ($slides as $slide) {
|
||||
$nasPath = $slide->filename;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download slide id=' . $slide->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading audio slides... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Download user avatars ─────────────────────────────────────────
|
||||
foreach ($usersWithAvatar as $user) {
|
||||
$nasPath = $user->avatar;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download avatar for user #' . $user->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading avatars... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Download user banners ─────────────────────────────────────────
|
||||
foreach ($usersWithBanner as $user) {
|
||||
$nasPath = $user->banner;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download banner for user #' . $user->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading banners... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Download post images ──────────────────────────────────────────
|
||||
foreach ($postImages as $img) {
|
||||
$nasPath = $img->filename;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download post image id=' . $img->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading post images... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Disable NAS ───────────────────────────────────────────────────
|
||||
Setting::set('nas_sync_enabled', 'false');
|
||||
|
||||
$progress['done'] = true;
|
||||
$progress['phase'] = 'Complete';
|
||||
$this->updateProgress($progress);
|
||||
|
||||
Log::info('NasToLocalMigrationJob: completed. ' . $total . ' files migrated. NAS disabled.');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: fatal error: ' . $e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$progress['error'] = $e->getMessage();
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,13 +43,12 @@ class Playlist extends Model
|
||||
}
|
||||
|
||||
// Accessors
|
||||
public function getThumbnailUrlAttribute()
|
||||
public function getThumbnailUrlAttribute(): string
|
||||
{
|
||||
if ($this->thumbnail) {
|
||||
return asset('storage/thumbnails/'.$this->thumbnail);
|
||||
return route('media.thumbnail', $this->thumbnail);
|
||||
}
|
||||
|
||||
// Generate a placeholder based on playlist name
|
||||
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ class PostImage extends Model
|
||||
|
||||
public function getImageUrlAttribute(): string
|
||||
{
|
||||
if (str_starts_with($this->filename, 'users/')) {
|
||||
return route('media.post-image', $this->filename);
|
||||
}
|
||||
return asset('storage/post_images/' . $this->filename);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user