Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
emoji-decoration from a song's description while preserving every
language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.
Admin pages:
- /admin/lyrics toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu extracted GPU section, encoder picker, FFmpeg path
- /admin/backup extracted users-and-settings export/import
- /admin/settings now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.
Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
and /videos/{id}?playlist={token}. Dispatched after-response so it
never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.
Player polish:
- Floating mini-player is draggable, persists its position in
localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
(channel tabs, etc.) gets re-executed after content swaps.
Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1434 lines
57 KiB
PHP
1434 lines
57 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use App\Models\Video;
|
|
use App\Models\VideoAudioTrack;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class NasSyncService
|
|
{
|
|
// ── Enable check ──────────────────────────────────────────────────────────
|
|
|
|
public function isEnabled(): bool
|
|
{
|
|
$configured = Setting::get('nas_sync_enabled', 'false') === 'true'
|
|
&& ! empty($this->cfg()['host']);
|
|
|
|
if (! $configured) return false;
|
|
|
|
// Cache reachability for 2 minutes so a down NAS doesn't block every request.
|
|
// When NAS comes back online the cache naturally expires and uploads resume.
|
|
return \Illuminate\Support\Facades\Cache::remember('nas_reachable', 120, function () {
|
|
$host = $this->cfg()['host'] ?? null;
|
|
if (! $host) return false;
|
|
$sock = @fsockopen($host, 445, $errno, $errstr, 2);
|
|
if ($sock) { fclose($sock); return true; }
|
|
return false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Flush the cached reachability flag so the next isEnabled() call re-tests.
|
|
* Call this after the admin toggles the NAS setting or from tests.
|
|
*/
|
|
public function flushReachabilityCache(): void
|
|
{
|
|
\Illuminate\Support\Facades\Cache::forget('nas_reachable');
|
|
}
|
|
|
|
// ── Slug helpers ──────────────────────────────────────────────────────────
|
|
|
|
public function userSlug(User $user): string
|
|
{
|
|
return $user->username ?: (string) $user->id;
|
|
}
|
|
|
|
public function titleSlug(string $title): string
|
|
{
|
|
// 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';
|
|
}
|
|
|
|
/**
|
|
* Top-level folder under users/{slug}/ for a video of the given type.
|
|
* Frozen at upload time — see CLAUDE.md (canonical storage layout).
|
|
*/
|
|
public function typeFolder(Video $video): string
|
|
{
|
|
return match ($video->type) {
|
|
'music' => 'music',
|
|
'match' => 'sports',
|
|
default => 'videos', // generic + any unknown
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Track folder name inside a music song folder. Format: {lang}-{db-id}.
|
|
* For the primary audio (no VideoAudioTrack row), pass null — the videos
|
|
* row id is used as the track id and the video's primary language as the
|
|
* language. Always lowercase. Falls back to 'xx' when no language is set.
|
|
*/
|
|
public function trackFolderName(Video $video, ?VideoAudioTrack $track = null): string
|
|
{
|
|
$lang = mb_strtolower(trim((string) ($track ? $track->language : $video->language)));
|
|
if ($lang === '') $lang = 'xx';
|
|
$id = $track ? $track->id : $video->id;
|
|
return "{$lang}-{$id}";
|
|
}
|
|
|
|
/**
|
|
* Absolute NAS-relative path to a music track's folder. Music only.
|
|
* Caller must ensure $video->type === 'music'.
|
|
*/
|
|
public function trackDir(Video $video, ?VideoAudioTrack $track = null): string
|
|
{
|
|
return $this->resolveVideoDir($video) . '/tracks/' . $this->trackFolderName($video, $track);
|
|
}
|
|
|
|
/**
|
|
* Return the NAS directory for a video.
|
|
*
|
|
* On first sync → pick a conflict-free slug and create the folder.
|
|
* On subsequent → find the existing folder by matching video ID in meta.json
|
|
* so renames of the title never lose the folder.
|
|
*
|
|
* Type-segregated: music → music/, sports → sports/, generic → videos/.
|
|
* The folder is frozen at first resolution — if the video's path already
|
|
* points somewhere, that location wins (so type edits don't move files).
|
|
*/
|
|
public function resolveVideoDir(Video $video): string
|
|
{
|
|
// Already organised — trust the stored path verbatim. This keeps legacy
|
|
// flat layouts working and prevents type edits from relocating files.
|
|
if (str_starts_with((string) $video->path, 'users/')) {
|
|
// Walk up past any tracks/{lang-id}/ then file-name segments.
|
|
$segs = explode('/', $video->path);
|
|
// Expect users/{slug}/{type-folder}/{video-slug}/...
|
|
if (count($segs) >= 4) {
|
|
return implode('/', array_slice($segs, 0, 4));
|
|
}
|
|
}
|
|
|
|
$video->loadMissing('user');
|
|
$userSlug = $this->userSlug($video->user);
|
|
$base = "users/{$userSlug}/" . $this->typeFolder($video);
|
|
$titleSlug = $this->titleSlug($video->title);
|
|
|
|
// 1. Try the current title slug and numbered variants (-2, -3 …)
|
|
// If any of them already hold meta.json with our video ID → reuse it.
|
|
for ($serial = 1; $serial <= 50; $serial++) {
|
|
$candidate = $serial === 1 ? $titleSlug : "{$titleSlug}-{$serial}";
|
|
$meta = $this->readMeta("{$base}/{$candidate}");
|
|
|
|
if ($meta === null) {
|
|
// Folder is free — this is where we'll write
|
|
return "{$base}/{$candidate}";
|
|
}
|
|
|
|
if (($meta['id'] ?? null) === $video->id) {
|
|
// This folder already belongs to our video
|
|
return "{$base}/{$candidate}";
|
|
}
|
|
}
|
|
|
|
// 2. Fallback: scan all folders in the user's videos directory
|
|
$found = $this->scanForVideoDir($base, $video->id);
|
|
if ($found) return $found;
|
|
|
|
// 3. Last resort: use title slug + video ID suffix (guaranteed unique)
|
|
return "{$base}/{$titleSlug}-{$video->id}";
|
|
}
|
|
|
|
/**
|
|
* Read meta.json from a NAS directory. Returns decoded array or null.
|
|
*/
|
|
private function readMeta(string $dir): ?array
|
|
{
|
|
$content = $this->getContent("{$dir}/meta.json");
|
|
if ($content === null) return null;
|
|
$decoded = json_decode($content, true);
|
|
return is_array($decoded) ? $decoded : null;
|
|
}
|
|
|
|
/**
|
|
* List all subdirectories under $base and find the one whose
|
|
* meta.json carries the given video ID.
|
|
*/
|
|
private function scanForVideoDir(string $base, int $videoId): ?string
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
|
|
$smbPath = str_replace('/', '\\', $base) . '\\*';
|
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg("ls \"{$smbPath}\"") . ' 2>&1', $output);
|
|
|
|
foreach ($output as $line) {
|
|
if (! preg_match('/^ (.+?)\s{2,}D\s/', $line, $m)) continue;
|
|
$name = trim($m[1]);
|
|
if ($name === '.' || $name === '..') continue;
|
|
|
|
$meta = $this->readMeta("{$base}/{$name}");
|
|
if (($meta['id'] ?? null) === $videoId) {
|
|
return "{$base}/{$name}";
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ── Local directory layout (mirrors NAS schema) ───────────────────────────
|
|
|
|
/**
|
|
* Absolute path to the local directory for a video, mirroring the NAS structure:
|
|
* storage/app/users/{username}/videos/{title-slug}/
|
|
*
|
|
* If the video is already organised (video->path starts with "users/"), the dir
|
|
* is derived directly from the stored path so title renames never relocate files.
|
|
* Otherwise a free slug-based slot is found (same logic as resolveVideoDir but local).
|
|
*/
|
|
public function localVideoDir(Video $video): string
|
|
{
|
|
// Already organised — derive from path (respects whichever type folder it
|
|
// ended up in, even if the type has since been edited).
|
|
if (str_starts_with((string) $video->path, 'users/')) {
|
|
$segs = explode('/', $video->path);
|
|
if (count($segs) >= 4) {
|
|
return storage_path('app/' . implode('/', array_slice($segs, 0, 4)));
|
|
}
|
|
// Defensive fallback for malformed legacy paths
|
|
$dir = dirname(storage_path('app/' . $video->path));
|
|
if (basename($dir) === 'tracks') $dir = dirname($dir);
|
|
return $dir;
|
|
}
|
|
|
|
$video->loadMissing('user');
|
|
$userSlug = $this->userSlug($video->user);
|
|
$base = storage_path("app/users/{$userSlug}/" . $this->typeFolder($video));
|
|
$titleSlug = $this->titleSlug($video->title);
|
|
|
|
for ($i = 1; $i <= 50; $i++) {
|
|
$candidate = $i === 1 ? $titleSlug : "{$titleSlug}-{$i}";
|
|
$dir = "{$base}/{$candidate}";
|
|
|
|
if (! is_dir($dir)) {
|
|
return $dir; // free slot
|
|
}
|
|
|
|
$metaFile = "{$dir}/meta.json";
|
|
if (file_exists($metaFile)) {
|
|
$meta = @json_decode(file_get_contents($metaFile), true);
|
|
if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
|
|
return $dir; // already belongs to this video
|
|
}
|
|
}
|
|
}
|
|
|
|
return "{$base}/{$titleSlug}-{$video->id}";
|
|
}
|
|
|
|
/**
|
|
* Move a freshly uploaded video's files into the NAS-mirrored local directory
|
|
* structure and update the DB record paths.
|
|
*
|
|
* Call this immediately after Video::create() in the upload flow.
|
|
* The method is idempotent: if the video is already organised it does nothing.
|
|
*/
|
|
public function organizeLocalFiles(Video $video): void
|
|
{
|
|
// Already organised
|
|
if (str_starts_with($video->path, 'users/')) return;
|
|
|
|
$video->loadMissing(['user', 'slides']);
|
|
|
|
$videoDir = $this->localVideoDir($video); // users/{slug}/{type-folder}/{slug}
|
|
$isMusic = ($video->type === 'music');
|
|
// Music wraps the primary inside tracks/{lang-id}/; others stay flat.
|
|
$primaryDir = $isMusic
|
|
? $videoDir . '/tracks/' . $this->trackFolderName($video, null)
|
|
: $videoDir;
|
|
|
|
@mkdir($primaryDir, 0755, true);
|
|
|
|
$userSlug = $this->userSlug($video->user);
|
|
$videoRel = 'users/' . $userSlug . '/' . $this->typeFolder($video) . '/' . basename($videoDir);
|
|
$primaryRel = $isMusic
|
|
? $videoRel . '/tracks/' . $this->trackFolderName($video, null)
|
|
: $videoRel;
|
|
$updates = [];
|
|
|
|
// ── Video / primary audio file ───────────────────────────────────
|
|
$oldVideoPath = storage_path('app/' . $video->path);
|
|
if (file_exists($oldVideoPath)) {
|
|
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
|
$canonical = ($isMusic ? 'audio' : 'video') . ".{$ext}";
|
|
rename($oldVideoPath, "{$primaryDir}/{$canonical}");
|
|
$updates['path'] = "{$primaryRel}/{$canonical}";
|
|
$updates['filename'] = $canonical;
|
|
}
|
|
|
|
// ── Slides — music primary track only ────────────────────────────
|
|
$firstSlideRelPath = null;
|
|
if ($isMusic && $video->slides->isNotEmpty()) {
|
|
@mkdir("{$primaryDir}/slides", 0755, true);
|
|
foreach ($video->slides->sortBy('position') as $slide) {
|
|
$oldSlidePath = storage_path('app/public/thumbnails/' . $slide->filename);
|
|
if (! file_exists($oldSlidePath)) continue;
|
|
$ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
|
|
$newSlideName = "{$slide->position}.{$ext}";
|
|
rename($oldSlidePath, "{$primaryDir}/slides/{$newSlideName}");
|
|
$newSlideFilename = "{$primaryRel}/slides/{$newSlideName}";
|
|
$slide->update(['filename' => $newSlideFilename]);
|
|
if ($firstSlideRelPath === null) {
|
|
$firstSlideRelPath = $newSlideFilename;
|
|
}
|
|
}
|
|
if ($firstSlideRelPath !== null) {
|
|
$updates['thumbnail'] = $firstSlideRelPath;
|
|
}
|
|
}
|
|
|
|
// ── Standalone thumbnail (sports/generic + music-without-slides) ─
|
|
if ($video->thumbnail && ! isset($updates['thumbnail'])) {
|
|
$oldThumbPath = storage_path('app/public/thumbnails/' . $video->thumbnail);
|
|
if (file_exists($oldThumbPath)) {
|
|
$ext = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'jpg';
|
|
$newThumbName = "thumb.{$ext}";
|
|
$thumbDirAbs = $isMusic ? $primaryDir : $videoDir;
|
|
$thumbRelDir = $isMusic ? $primaryRel : $videoRel;
|
|
rename($oldThumbPath, "{$thumbDirAbs}/{$newThumbName}");
|
|
$updates['thumbnail'] = "{$thumbRelDir}/{$newThumbName}";
|
|
}
|
|
}
|
|
|
|
// ── meta.json (lives at the video / song root, not per-track) ────
|
|
$this->writeLocalMeta($video, $videoDir);
|
|
|
|
if (! empty($updates)) {
|
|
$video->update($updates);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write a meta.json to the local video directory so localVideoDir() can
|
|
* resolve the folder even after a title rename.
|
|
*/
|
|
public function writeLocalMeta(Video $video, string $dir): void
|
|
{
|
|
$meta = json_encode([
|
|
'id' => $video->id,
|
|
'user_id' => $video->user_id,
|
|
'title' => $video->title,
|
|
'created_at' => $video->created_at?->toIso8601String(),
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
|
|
@file_put_contents("{$dir}/meta.json", $meta);
|
|
}
|
|
|
|
// ── Local-cache helpers (used when NAS is the primary storage) ───────────
|
|
|
|
/**
|
|
* Absolute path where a NAS video is cached locally for HTTP streaming.
|
|
* Separate from public storage so it's never accidentally served statically.
|
|
*/
|
|
public function localCachePath(Video $video): string
|
|
{
|
|
return storage_path('app/nas_cache/videos/' . $video->filename);
|
|
}
|
|
|
|
/**
|
|
* Ensure the video file is available at a local path for streaming.
|
|
*
|
|
* Priority:
|
|
* 1. Regular local storage (video uploaded while NAS was off, or not yet cleaned)
|
|
* 2. NAS stream cache (previously downloaded copy)
|
|
* 3. Download from NAS (first-time stream after NAS-primary upload)
|
|
*
|
|
* Returns the local absolute path, or null if unavailable.
|
|
*/
|
|
public function ensureLocalCopy(Video $video): ?string
|
|
{
|
|
// 1. Regular local storage
|
|
$regularPath = storage_path('app/' . $video->path);
|
|
if (file_exists($regularPath)) return $regularPath;
|
|
|
|
// 2. Existing stream cache
|
|
$cachePath = $this->localCachePath($video);
|
|
if (file_exists($cachePath)) return $cachePath;
|
|
|
|
// 3. Download from NAS
|
|
if (! $this->isEnabled()) return null;
|
|
|
|
// When the video's path is a full NAS-relative path (starts with 'users/'), use it
|
|
// directly — this handles promoted tracks whose path is e.g. 'users/…/tracks/14.mp3'
|
|
// rather than the standard 'users/…/title-slug.mp3' layout.
|
|
if (str_starts_with($video->path, 'users/')) {
|
|
$this->ensureLocalAsset($regularPath, $video->path);
|
|
if (file_exists($regularPath)) {
|
|
Log::info('NAS: video cached locally for streaming', ['video_id' => $video->id]);
|
|
return $regularPath;
|
|
}
|
|
Log::warning('NAS: failed to cache video for streaming', [
|
|
'video_id' => $video->id,
|
|
'nas_path' => $video->path,
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
$cacheDir = dirname($cachePath);
|
|
if (! is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
|
|
|
|
$nasDir = $this->resolveVideoDir($video);
|
|
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
|
$fileSlug = $this->titleSlug($video->title);
|
|
$nasFile = "{$nasDir}/{$fileSlug}.{$ext}";
|
|
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
|
|
exec('smbclient ' . $target . ' -U ' . $cred
|
|
. ' -c ' . escapeshellarg('get "' . $nasFile . '" "' . $cachePath . '"')
|
|
. ' 2>&1', $out, $code);
|
|
|
|
if ($code !== 0 || ! file_exists($cachePath)) {
|
|
@unlink($cachePath);
|
|
Log::warning('NAS: failed to cache video for streaming', [
|
|
'video_id' => $video->id,
|
|
'nas_path' => $nasFile,
|
|
'output' => implode(' ', $out),
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
Log::info('NAS: video cached locally for streaming', ['video_id' => $video->id]);
|
|
return $cachePath;
|
|
}
|
|
|
|
/**
|
|
* Resolve a secondary audio track to a readable local path.
|
|
*
|
|
* Mirrors ensureLocalCopy() for the primary file so a demoted/secondary track
|
|
* (e.g. the old primary after a language swap) streams with the same robustness:
|
|
* 1. Regular local storage (file still at the track's stored path)
|
|
* 2. NAS stream cache (downloaded earlier, e.g. while it was the primary)
|
|
* 3. Download from NAS (when NAS is reachable)
|
|
*
|
|
* Returns the local absolute path, or null if the file cannot be located.
|
|
*/
|
|
public function ensureLocalTrackCopy(VideoAudioTrack $track): ?string
|
|
{
|
|
// 1. Regular local storage
|
|
$regularPath = storage_path('app/' . $track->path);
|
|
if (file_exists($regularPath)) return $regularPath;
|
|
|
|
// 2. Existing NAS stream cache (keyed by filename, shared with the primary path)
|
|
$cachePath = storage_path('app/nas_cache/videos/' . $track->filename);
|
|
if (file_exists($cachePath)) {
|
|
Log::info('NAS: audio track served from stream cache', [
|
|
'track_id' => $track->id,
|
|
'video_id' => $track->video_id,
|
|
'cache' => $cachePath,
|
|
]);
|
|
return $cachePath;
|
|
}
|
|
|
|
// 3. Download from NAS
|
|
if (! $this->isEnabled()) {
|
|
Log::warning('NAS: audio track unavailable (NAS disabled, no local/cache copy)', [
|
|
'track_id' => $track->id,
|
|
'video_id' => $track->video_id,
|
|
'path' => $track->path,
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
if (str_starts_with($track->path, 'users/')) {
|
|
$this->ensureLocalAsset($regularPath, $track->path);
|
|
if (file_exists($regularPath)) {
|
|
Log::info('NAS: audio track cached locally for streaming', [
|
|
'track_id' => $track->id,
|
|
'video_id' => $track->video_id,
|
|
]);
|
|
return $regularPath;
|
|
}
|
|
}
|
|
|
|
Log::warning('NAS: failed to resolve audio track for streaming', [
|
|
'track_id' => $track->id,
|
|
'video_id' => $track->video_id,
|
|
'path' => $track->path,
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Ensure a local asset file exists, downloading it from the NAS if missing.
|
|
* Used by MediaController to serve thumbnails, avatars, and banners with a NAS fallback.
|
|
*
|
|
* Returns true if the file is available locally after the call.
|
|
*/
|
|
public function ensureLocalAsset(string $localPath, string $nasPath): bool
|
|
{
|
|
if (file_exists($localPath)) return true;
|
|
|
|
$dir = dirname($localPath);
|
|
if (! is_dir($dir)) @mkdir($dir, 0755, true);
|
|
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
|
|
exec('smbclient ' . $target . ' -U ' . $cred
|
|
. ' -c ' . escapeshellarg('get "' . $nasPath . '" "' . $localPath . '"')
|
|
. ' 2>&1', $out, $code);
|
|
|
|
if ($code !== 0 || ! file_exists($localPath)) {
|
|
@unlink($localPath);
|
|
Log::warning('NAS: failed to fetch asset', [
|
|
'nas_path' => $nasPath,
|
|
'output' => implode(' ', $out),
|
|
]);
|
|
return false;
|
|
}
|
|
|
|
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.
|
|
*/
|
|
public function deleteLocalVideo(Video $video): void
|
|
{
|
|
$path = storage_path('app/' . $video->path);
|
|
if (file_exists($path)) {
|
|
@unlink($path);
|
|
Log::info('NAS: local video removed after NAS push', ['video_id' => $video->id]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete local thumbnail and slide images after they have been pushed to NAS.
|
|
* MediaController will re-fetch them from NAS on demand via ensureLocalAsset().
|
|
*/
|
|
public function deleteLocalAssets(Video $video): void
|
|
{
|
|
$thumb = $video->localThumbnailPath();
|
|
if ($thumb && file_exists($thumb)) {
|
|
@unlink($thumb);
|
|
}
|
|
|
|
$video->loadMissing('slides');
|
|
foreach ($video->slides as $slide) {
|
|
$path = $slide->localPath();
|
|
if (file_exists($path)) {
|
|
@unlink($path);
|
|
}
|
|
}
|
|
|
|
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) ──────────────────────────────────
|
|
|
|
/**
|
|
* Push a freshly uploaded video directly to the NAS without keeping a local copy.
|
|
*
|
|
* Called from VideoController::store() when NAS is enabled.
|
|
* Updates video->path, video->filename, video->thumbnail, and status in the DB.
|
|
*
|
|
* @param Video $video
|
|
* @param string $tempVideoPath Absolute path to the temporary local video file
|
|
* @param string|null $tempThumbPath Absolute path to the temporary thumbnail (may be null)
|
|
* @param array $slideAbsPaths [ position => absPath ] for audio slides
|
|
*/
|
|
public function uploadDirectToNas(
|
|
Video $video,
|
|
string $tempVideoPath,
|
|
?string $tempThumbPath,
|
|
array $slideAbsPaths = []
|
|
): void {
|
|
$video->loadMissing(['user', 'slides']);
|
|
|
|
$videoDir = $this->resolveVideoDir($video); // users/{slug}/{type-folder}/{slug}
|
|
$isMusic = ($video->type === 'music');
|
|
// Music uses per-track folders. Primary audio + its slides + lyrics live in
|
|
// tracks/{lang-id}/. Sports + generic stay flat: video.{ext} at the root.
|
|
$primaryDir = $isMusic
|
|
? $videoDir . '/tracks/' . $this->trackFolderName($video, null)
|
|
: $videoDir;
|
|
|
|
$this->mkdirp($primaryDir);
|
|
$updates = [];
|
|
|
|
// ── Video / primary audio file ───────────────────────────────────
|
|
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
|
if (file_exists($tempVideoPath)) {
|
|
// New canonical name. Music: audio.{ext}. Sports/generic: video.{ext}.
|
|
$canonical = ($isMusic ? 'audio' : 'video') . ".{$ext}";
|
|
$nasFile = "{$primaryDir}/{$canonical}";
|
|
$ok = $this->putFile($tempVideoPath, $nasFile);
|
|
if (! $ok) {
|
|
throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$canonical}");
|
|
}
|
|
@unlink($tempVideoPath);
|
|
$updates['path'] = $nasFile;
|
|
$updates['filename'] = $canonical;
|
|
}
|
|
|
|
// ── Slides — music primary track only ────────────────────────────
|
|
$firstSlideNasPath = null;
|
|
if ($isMusic && ! empty($slideAbsPaths)) {
|
|
$this->mkdirp("{$primaryDir}/slides");
|
|
foreach ($video->slides->sortBy('position') as $slide) {
|
|
$absPath = $slideAbsPaths[$slide->position] ?? null;
|
|
if (! $absPath || ! file_exists($absPath)) continue;
|
|
$slideExt = pathinfo($absPath, PATHINFO_EXTENSION) ?: 'jpg';
|
|
$nasSlideFile = "{$primaryDir}/slides/{$slide->position}.{$slideExt}";
|
|
if ($this->putFile($absPath, $nasSlideFile)) {
|
|
@unlink($absPath);
|
|
$slide->update(['filename' => $nasSlideFile]);
|
|
if ($firstSlideNasPath === null) {
|
|
$firstSlideNasPath = $nasSlideFile;
|
|
}
|
|
}
|
|
}
|
|
if ($firstSlideNasPath !== null) {
|
|
$updates['thumbnail'] = $firstSlideNasPath;
|
|
}
|
|
}
|
|
|
|
// ── Standalone thumbnail (sports/generic + music-without-slides) ──
|
|
if ($tempThumbPath && file_exists($tempThumbPath) && $firstSlideNasPath === null) {
|
|
// Music thumb lives next to the primary audio inside the track folder.
|
|
$thumbDir = $isMusic ? $primaryDir : $videoDir;
|
|
$thumbExt = pathinfo($tempThumbPath, PATHINFO_EXTENSION) ?: 'webp';
|
|
$nasThumb = "{$thumbDir}/thumb.{$thumbExt}";
|
|
if ($this->putFile($tempThumbPath, $nasThumb)) {
|
|
@unlink($tempThumbPath);
|
|
$updates['thumbnail'] = $nasThumb;
|
|
}
|
|
}
|
|
|
|
// ── meta.json (song / video root level, not per-track) ───────────
|
|
$this->putContent(json_encode([
|
|
'id' => $video->id,
|
|
'user_id' => $video->user_id,
|
|
'title' => $video->title,
|
|
'type' => $video->type,
|
|
'created_at' => $video->created_at?->toIso8601String(),
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$videoDir}/meta.json");
|
|
|
|
// File is now on NAS and accessible — mark as ready
|
|
$updates['status'] = 'ready';
|
|
|
|
Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $videoDir]);
|
|
|
|
$video->update($updates);
|
|
}
|
|
|
|
// ── High-level sync methods ───────────────────────────────────────────────
|
|
|
|
public function syncVideo(Video $video): void
|
|
{
|
|
$video->loadMissing('user');
|
|
$dir = $this->resolveVideoDir($video);
|
|
$fileSlug = $this->titleSlug($video->title);
|
|
$this->mkdirp($dir);
|
|
|
|
// {title-slug}.{ext}
|
|
$localVideo = storage_path('app/' . $video->path);
|
|
if (file_exists($localVideo)) {
|
|
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
|
$this->putFile($localVideo, "{$dir}/{$fileSlug}.{$ext}");
|
|
}
|
|
|
|
// thumb.webp
|
|
$localThumb = $video->localThumbnailPath();
|
|
if ($localThumb && file_exists($localThumb)) {
|
|
$this->putFile($localThumb, "{$dir}/thumb.webp");
|
|
}
|
|
|
|
// slides/{position}.{ext}
|
|
$video->loadMissing('slides');
|
|
if ($video->slides->isNotEmpty()) {
|
|
$this->mkdirp("{$dir}/slides");
|
|
foreach ($video->slides as $slide) {
|
|
$localSlide = $slide->localPath();
|
|
if (file_exists($localSlide)) {
|
|
$ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
|
|
$this->putFile($localSlide, "{$dir}/slides/{$slide->position}.{$ext}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// lyrics/*.json (source-of-truth; push any local mirror that exists)
|
|
$video->loadMissing('audioTracks');
|
|
$lyricsTargets = array_merge([null], $video->audioTracks->all());
|
|
foreach ($lyricsTargets as $lt) {
|
|
$localLyrics = $this->lyricsLocalPath($video, $lt);
|
|
if (is_file($localLyrics)) {
|
|
$this->mkdirp("{$dir}/lyrics");
|
|
$this->putFile($localLyrics, $this->lyricsNasPath($video, $lt));
|
|
}
|
|
}
|
|
|
|
// meta.json (always written last so readMeta can find the folder)
|
|
$this->putContent(json_encode([
|
|
'id' => $video->id,
|
|
'user_id' => $video->user_id,
|
|
'title' => $video->title,
|
|
'description' => $video->description,
|
|
'type' => $video->type,
|
|
'visibility' => $video->visibility,
|
|
'status' => $video->status,
|
|
'duration' => $video->duration,
|
|
'width' => $video->width,
|
|
'height' => $video->height,
|
|
'orientation' => $video->orientation,
|
|
'mime_type' => $video->mime_type,
|
|
'size' => $video->size,
|
|
'created_at' => $video->created_at?->toIso8601String(),
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json");
|
|
}
|
|
|
|
public function syncVideoMeta(Video $video): void
|
|
{
|
|
$video->loadMissing('user');
|
|
$dir = $this->resolveVideoDir($video);
|
|
$this->mkdirp($dir);
|
|
|
|
$this->putContent(json_encode([
|
|
'id' => $video->id,
|
|
'user_id' => $video->user_id,
|
|
'title' => $video->title,
|
|
'description' => $video->description,
|
|
'type' => $video->type,
|
|
'visibility' => $video->visibility,
|
|
'status' => $video->status,
|
|
'duration' => $video->duration,
|
|
'width' => $video->width,
|
|
'height' => $video->height,
|
|
'orientation' => $video->orientation,
|
|
'mime_type' => $video->mime_type,
|
|
'size' => $video->size,
|
|
'updated_at' => $video->updated_at?->toIso8601String(),
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json");
|
|
}
|
|
|
|
public function deleteVideo(Video $video): void
|
|
{
|
|
$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
|
|
{
|
|
$dir = "users/{$this->userSlug($user)}/profile";
|
|
$this->mkdirp($dir);
|
|
$this->putFile($localAbsPath, "{$dir}/avatar.webp");
|
|
}
|
|
|
|
public function syncCover(User $user, string $localAbsPath): void
|
|
{
|
|
$dir = "users/{$this->userSlug($user)}/profile";
|
|
$this->mkdirp($dir);
|
|
$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
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
|
|
$parts = array_values(array_filter(explode('/', str_replace('\\', '/', $path))));
|
|
$cmds = [];
|
|
$built = '';
|
|
foreach ($parts as $part) {
|
|
$built = $built ? "{$built}/{$part}" : $part;
|
|
$cmds[] = 'mkdir "' . $built . '"';
|
|
}
|
|
|
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg(implode('; ', $cmds)) . ' 2>&1');
|
|
// Intentionally ignore exit code — NT_STATUS_OBJECT_NAME_COLLISION means dir exists
|
|
}
|
|
|
|
public function putFile(string $localAbsPath, string $nasRelPath): bool
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
$cmd = 'put "' . $localAbsPath . '" "' . $nasRelPath . '"';
|
|
|
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code);
|
|
|
|
if ($code !== 0) {
|
|
Log::warning('NAS putFile failed [' . $nasRelPath . ']: ' . implode(' ', $output));
|
|
}
|
|
|
|
return $code === 0;
|
|
}
|
|
|
|
public function putContent(string $content, string $nasRelPath): bool
|
|
{
|
|
$tmp = tempnam(sys_get_temp_dir(), 'nassync_');
|
|
file_put_contents($tmp, $content);
|
|
$ok = $this->putFile($tmp, $nasRelPath);
|
|
@unlink($tmp);
|
|
return $ok;
|
|
}
|
|
|
|
public function getContent(string $nasRelPath): ?string
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
$tmp = tempnam(sys_get_temp_dir(), 'nasget_');
|
|
$cmd = 'get "' . $nasRelPath . '" "' . $tmp . '"';
|
|
|
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code);
|
|
|
|
if ($code !== 0 || ! file_exists($tmp)) {
|
|
@unlink($tmp);
|
|
return null;
|
|
}
|
|
|
|
$content = file_get_contents($tmp);
|
|
@unlink($tmp);
|
|
return $content !== false ? $content : null;
|
|
}
|
|
|
|
// ── Lyrics (per-track, source-of-truth JSON synced to NAS) ─────────────────
|
|
|
|
public function lyricsDir(Video $video): string
|
|
{
|
|
// Legacy shared lyrics/ folder location — kept for read-fallback only.
|
|
// Derived from the stored path (no NAS lookup) — see callers for context.
|
|
if (str_starts_with((string) $video->path, 'users/')) {
|
|
$dir = dirname($video->path);
|
|
if (basename($dir) === 'tracks') $dir = dirname($dir);
|
|
return $dir . '/lyrics';
|
|
}
|
|
return $this->resolveVideoDir($video) . '/lyrics';
|
|
}
|
|
|
|
/**
|
|
* NAS-relative path for a track's lyrics under the new per-track-folder layout:
|
|
* tracks/{lang-id}/lyrics.json
|
|
* Falls back to the legacy shared lyrics/ folder if the song still uses the
|
|
* old flat layout (we detect this by checking whether the path passes through
|
|
* a tracks/ segment).
|
|
*/
|
|
public function lyricsNasPath(Video $video, ?VideoAudioTrack $track = null): string
|
|
{
|
|
// New layout (music with per-track folders).
|
|
if ($video->type === 'music' && str_starts_with((string) $video->path, 'users/')) {
|
|
$segs = explode('/', $video->path);
|
|
// users/{slug}/music/{song-slug}/tracks/{lang-id}/audio.{ext}
|
|
if (count($segs) >= 6 && $segs[4] === 'tracks') {
|
|
$songRoot = implode('/', array_slice($segs, 0, 4));
|
|
$folder = $this->trackFolderName($video, $track);
|
|
return "{$songRoot}/tracks/{$folder}/lyrics.json";
|
|
}
|
|
}
|
|
// Legacy fallback — shared lyrics/ folder, keyed by track suffix.
|
|
$name = $track ? "track-{$track->id}" : 'primary';
|
|
return $this->lyricsDir($video) . "/{$name}.json";
|
|
}
|
|
|
|
/** Canonical local mirror path for a track's lyrics. */
|
|
public function lyricsLocalPath(Video $video, ?VideoAudioTrack $track = null): string
|
|
{
|
|
return storage_path('app/' . $this->lyricsNasPath($video, $track));
|
|
}
|
|
|
|
/**
|
|
* Write lyrics JSON for a track. Always writes the local mirror; pushes to
|
|
* NAS when reachable. Returns false only if the NAS push was attempted and
|
|
* failed (the local copy is written regardless, so nas:auto-sync can retry).
|
|
*/
|
|
public function putLyrics(Video $video, ?VideoAudioTrack $track, array $data): bool
|
|
{
|
|
$json = json_encode($data, JSON_UNESCAPED_UNICODE);
|
|
$local = $this->lyricsLocalPath($video, $track);
|
|
if (! is_dir(dirname($local))) @mkdir(dirname($local), 0775, true);
|
|
file_put_contents($local, $json);
|
|
|
|
if ($this->isEnabled()) {
|
|
// Make sure the directory containing this lyrics file exists on NAS.
|
|
// New layout: tracks/{lang-id}/. Legacy: shared lyrics/ folder.
|
|
$this->mkdirp(dirname($this->lyricsNasPath($video, $track)));
|
|
return $this->putContent($json, $this->lyricsNasPath($video, $track));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Read lyrics JSON from the LOCAL mirror only — never touches the NAS.
|
|
* Use this on hot paths (page render, player-data) so a missing file can't
|
|
* block the request on smbclient or the port-445 reachability probe. The
|
|
* lyrics mirror is written by putLyrics() and is never wiped by cache
|
|
* cleanup, so a song that has lyrics will have them locally.
|
|
*/
|
|
public function getLocalLyrics(Video $video, ?VideoAudioTrack $track = null): ?array
|
|
{
|
|
$local = $this->lyricsLocalPath($video, $track);
|
|
if (! is_file($local)) {
|
|
// Read fallback to the legacy shared lyrics/ folder so songs that
|
|
// haven't been migrated yet still serve their lyrics.
|
|
$legacy = storage_path('app/' . $this->lyricsDir($video) . '/' . ($track ? "track-{$track->id}" : 'primary') . '.json');
|
|
if (is_file($legacy)) $local = $legacy;
|
|
}
|
|
if (is_file($local)) {
|
|
$d = json_decode((string) file_get_contents($local), true);
|
|
if (is_array($d)) return $d;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Remove a track's lyrics from both the local mirror and the NAS. Used by
|
|
* the owner when the generated lyrics are wrong and they want to start
|
|
* over — after calling this, the next Generate produces a fresh file.
|
|
*/
|
|
public function deleteLyrics(Video $video, ?VideoAudioTrack $track = null): void
|
|
{
|
|
$local = $this->lyricsLocalPath($video, $track);
|
|
if (is_file($local)) @unlink($local);
|
|
|
|
if ($this->isEnabled()) {
|
|
try { $this->deleteFile($this->lyricsNasPath($video, $track)); }
|
|
catch (\Throwable $e) { /* best-effort: local removal is what matters for next regenerate */ }
|
|
}
|
|
}
|
|
|
|
/** Read lyrics JSON for a track, pulling from NAS into the local mirror if needed. */
|
|
public function getLyrics(Video $video, ?VideoAudioTrack $track = null): ?array
|
|
{
|
|
$local = $this->lyricsLocalPath($video, $track);
|
|
if (is_file($local)) {
|
|
$d = json_decode((string) file_get_contents($local), true);
|
|
if (is_array($d)) return $d;
|
|
}
|
|
if ($this->isEnabled()) {
|
|
$c = $this->getContent($this->lyricsNasPath($video, $track));
|
|
if ($c !== null) {
|
|
if (! is_dir(dirname($local))) @mkdir(dirname($local), 0775, true);
|
|
file_put_contents($local, $c);
|
|
$d = json_decode($c, true);
|
|
if (is_array($d)) return $d;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public function deleteFile(string $nasRelPath): void
|
|
{
|
|
$cfg = $this->cfg();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
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();
|
|
$target = escapeshellarg($this->smbTarget($cfg));
|
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
|
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', 'audioTracks']);
|
|
$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}"
|
|
// If the primary file lives inside a 'tracks/' subfolder (promoted track),
|
|
// go up one extra level to reach the video root directory.
|
|
if (basename($currentDir) === 'tracks') {
|
|
$currentDir = dirname($currentDir);
|
|
}
|
|
|
|
// 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))]);
|
|
}
|
|
}
|
|
|
|
// Secondary language tracks (incl. the demoted old primary after a swap) also
|
|
// live under the renamed directory — update their stored paths too.
|
|
foreach ($video->audioTracks as $track) {
|
|
if ($track->path && str_starts_with($track->path, $oldPrefix)) {
|
|
$track->update(['path' => $newPrefix . substr($track->path, 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
|
|
{
|
|
return app(\P7H\NasFileManager\NasStorageService::class)->cfg();
|
|
}
|
|
|
|
private function smbTarget(array $cfg): string
|
|
{
|
|
return '//' . $cfg['host'] . '/' . trim($cfg['smb_share'] ?? '', '/');
|
|
}
|
|
|
|
private function smbCredential(array $cfg): string
|
|
{
|
|
$domain = ! empty($cfg['smb_domain']) ? $cfg['smb_domain'] . '/' : '';
|
|
return $domain . ($cfg['username'] ?? '') . '%' . ($cfg['password'] ?? '');
|
|
}
|
|
}
|