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
@@ -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