Add lyrics pipeline, playlist views, admin toggles, and player polish

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 <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-05-31 22:01:47 +03:00
1 parent 73527f3781
commit f98e5415a3
60 files changed
+7920 -2675

No files matched your search

+266
View File
@@ -0,0 +1,266 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Optional LLM helper for the lyrics pipeline.
*
* Supports multiple providers configured in Admin → Settings → AI / LLM
* (local Ollama, hosted Anthropic Claude, or any OpenAI-compatible endpoint).
* Picks the provider flagged "Active" and dispatches the request through the
* matching adapter. Results are cached so a regenerate doesn't re-bill / re-hit
* the local model.
*/
class LlmLyricsService
{
public function isEnabled(): bool
{
return Setting::get('llm_enabled', 'false') === 'true'
&& $this->activeProvider() !== null;
}
public function cleanLyricsEnabled(): bool
{
return $this->isEnabled() && Setting::get('llm_clean_lyrics', 'true') === 'true';
}
public function decorateEnabled(): bool
{
return $this->isEnabled() && Setting::get('llm_decorate_lyrics', 'false') === 'true';
}
/** The currently selected provider config, or null if none. */
public function activeProvider(): ?array
{
$providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
if (! $providers) return null;
$activeId = (string) Setting::get('llm_active_id', '');
foreach ($providers as $p) {
if (($p['id'] ?? null) === $activeId) {
$kind = $p['kind'] ?? 'ollama';
// An Ollama provider doesn't need a key; the others do.
if ($kind !== 'ollama' && trim((string) ($p['api_key'] ?? '')) === '') return null;
if (trim((string) ($p['model'] ?? '')) === '') return null;
return $p;
}
}
return null;
}
/** Returns clean lyric lines extracted by the LLM, or [] on any failure. */
public function cleanDescription(?string $description): array
{
if (! $description || ! $this->cleanLyricsEnabled()) return [];
$provider = $this->activeProvider();
// v2 cache key: the prompt was rewritten to stop dropping English/Thai
// lyric lines that happened to carry leading/trailing emoji decoration.
$cacheKey = 'llm_lyrics_clean_v2:' . ($provider['id'] ?? '') . ':' . sha1($description);
return Cache::remember($cacheKey, now()->addDays(30), function () use ($description) {
$prompt = "Extract the SUNG lyric lines from this song description, preserving every\n"
. "language exactly as written. Songs are often MULTILINGUAL (e.g. mixed English\n"
. "and Thai, English and Italian, English and Arabic) — KEEP EVERY LANGUAGE.\n\n"
. "KEEP a line when it contains real lyric words, even if it's wrapped in or\n"
. " punctuated by emojis. Example: '🛡️💻 Met behind the firewalls 🌌' → KEEP.\n"
. " Strip ONLY the emojis themselves; the lyric words stay untouched.\n"
. "DROP a line ONLY when it is one of:\n"
. " • the song title or artist credit\n"
. " • a pure section header (Verse / Chorus / Bridge / Verso / Ritornello /\n"
. " Pre-Chorus / Outro / Intro / 副歌 / 후렴 / كورس / ท่อน / etc.) — typically\n"
. " one or two words, possibly numbered\n"
. " • an instrument or production note inside 【…】 or 〔…〕 brackets\n"
. " • a row that is ONLY emojis / separators / decorative symbols with no words\n"
. " • commentary or social-media call-to-action (subscribe, follow, link in bio)\n\n"
. "Hard rules:\n"
. " - DO NOT translate. DO NOT re-script (no romanising Thai/Arabic, no converting\n"
. " English to Thai). The output of each kept line must be in the SAME language\n"
. " and script as the original line.\n"
. " - DO NOT merge or split lines. One source lyric line → one output entry.\n"
. " - Preserve original punctuation (drop only the emojis).\n"
. " - Maintain the original order.\n\n"
. "Respond with ONLY a JSON array of strings. No prose, no markdown, no code fence.\n\n"
. "DESCRIPTION:\n" . $description;
$raw = $this->call($prompt, 8192);
if ($raw === '') return [];
$raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
$arr = json_decode($raw, true);
if (! is_array($arr)) return [];
$out = [];
foreach ($arr as $line) {
$line = trim((string) $line);
if ($line === '') continue;
$out[] = $line;
}
return $out;
});
}
/**
* Rewrite each lyric line with heavy, expressive emoji styling. Emojis go
* inside the line AND at the end; multiple per line where it fits. The
* original words are NEVER changed — emojis are layered on top.
*
* Returns [index => decoratedLineText]. The caller swaps line.text and
* re-distributes the word timings across the new tokens.
*/
public function decorateLines(array $lines): array
{
if (! $lines || ! $this->decorateEnabled()) return [];
$provider = $this->activeProvider();
$cacheKey = 'llm_lyrics_deco_v3:' . ($provider['id'] ?? '') . ':' . sha1(json_encode($lines));
return Cache::remember($cacheKey, now()->addDays(30), function () use ($lines) {
$numbered = [];
foreach ($lines as $i => $l) $numbered[] = ($i + 1) . '. ' . $l;
$prompt = "Decorate the following song lyrics with heavy, expressive emoji styling.\n\n"
. "Strict instructions:\n"
. "- Add emojis to almost every line (rich and visually striking, not minimal).\n"
. "- Place emojis both WITHIN lines and AT THE END where they enhance meaning.\n"
. "- Use 2–4 emojis per line on average, more on emotional peaks.\n"
. "- Match emojis to the line's specific emotion, action, image, or vibe.\n"
. "- VARIETY IS CRITICAL. Across the WHOLE song you must use a wide palette:\n"
. " • Aim for 30+ distinct emojis across the song.\n"
. " • Never reuse the same emoji on two adjacent lines.\n"
. " • Do NOT lean on the same 5–6 staples (🔥💔✨🎵❤️). Reach for less obvious\n"
. " ones that fit: ⚡🌊🌙🕯️🪞🥀🦋🌪️🗡️👁️🩸🦅🌀💎🪽🌑🪐🩹🌹🫧🌧️🔮🧨🪞🛡️\n"
. " ⚔️🏹🪄💫🥷🧿🪙🥀🎭🩰🪦⛓️🌌🚪🧊🌠💢🪶🩷🫀🪐🕊️ and many others.\n"
. "- Keep the original lyrics 100% UNCHANGED — no rewriting, no translation, no\n"
. " re-spelling, no script conversion. Preserve every original word verbatim.\n"
. "- Style should feel bold, dramatic, pop-star, Gen Z, visually addictive — like a\n"
. " designed lyric post or viral TikTok caption.\n"
. "- Do NOT add section headers, titles, intros, or any new lines. Every input line\n"
. " must map to exactly one output line, in the same order, with the same words.\n\n"
. "Output format: ONLY a JSON object mapping the 1-based line number to the fully\n"
. "decorated line text. No prose, no markdown, no code fence.\n\n"
. "LINES:\n" . implode("\n", $numbered);
$raw = $this->call($prompt, 8192);
if ($raw === '') return [];
$raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw));
$obj = json_decode($raw, true);
if (! is_array($obj)) return [];
$out = [];
foreach ($obj as $k => $v) {
if (! is_string($v)) continue;
$v = trim($v);
if ($v === '') continue;
$idx = ((int) $k) - 1;
if ($idx < 0 || ! isset($lines[$idx])) continue;
// Cheap safety check: the original words must survive verbatim
// (the LLM should only LAYER emojis on top). Drop the
// decoration if too many original characters are missing.
if (! self::preservesOriginal($lines[$idx], $v)) continue;
$out[$idx] = $v;
}
return $out;
});
}
/**
* Verify the decorated line still contains every alphanumeric character of
* the original (in the same order). Stops the LLM from quietly rewording
* a line — we keep only decorations that strictly add emojis on top.
*/
private static function preservesOriginal(string $orig, string $decorated): bool
{
$strip = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
$a = $strip($orig);
$b = $strip($decorated);
if ($a === '') return true;
// Sequential subsequence check: every char of $a must appear in $b in order.
$aLen = mb_strlen($a); $bLen = mb_strlen($b);
$j = 0;
for ($i = 0; $i < $aLen; $i++) {
$needle = mb_substr($a, $i, 1);
$found = false;
for (; $j < $bLen; $j++) {
if (mb_substr($b, $j, 1) === $needle) { $found = true; $j++; break; }
}
if (! $found) return false;
}
return true;
}
/** Dispatch to the active provider's adapter. */
private function call(string $prompt, int $maxTokens): string
{
$p = $this->activeProvider();
if (! $p) return '';
try {
return match ($p['kind']) {
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
default => $this->callOllama($p, $prompt, $maxTokens),
};
} catch (\Throwable $e) {
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
return '';
}
}
private function callOllama(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'stream' => false,
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
]);
if (! $resp->successful()) {
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['message']['content'] ?? '');
}
private function callAnthropic(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
$resp = Http::timeout(120)->withHeaders([
'x-api-key' => (string) $p['api_key'],
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])->post($endpoint . '/v1/messages', [
'model' => $p['model'],
'max_tokens' => $maxTokens,
'messages' => [['role' => 'user', 'content' => $prompt]],
]);
if (! $resp->successful()) {
Log::warning('Anthropic API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['content'][0]['text'] ?? '');
}
private function callOpenAI(array $p, string $prompt, int $maxTokens): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
->acceptJson()
->post($endpoint . '/v1/chat/completions', [
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => $maxTokens,
'temperature' => 0.2,
]);
if (! $resp->successful()) {
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['choices'][0]['message']['content'] ?? '');
}
}
+261 -55
View File
@@ -67,18 +67,69 @@ class NasSyncService
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}/videos";
$base = "users/{$userSlug}/" . $this->typeFolder($video);
$titleSlug = $this->titleSlug($video->title);
// 1. Try the current title slug and numbered variants (-2, -3 …)
@@ -156,20 +207,22 @@ class NasSyncService
*/
public function localVideoDir(Video $video): string
{
// Already organised — derive from path
if (str_starts_with($video->path, 'users/')) {
$dir = dirname(storage_path('app/' . $video->path));
// If the primary file lives inside a 'tracks/' subfolder (promoted track),
// go up one extra level to reach the video root directory.
if (basename($dir) === 'tracks') {
$dir = dirname($dir);
// 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}/videos");
$base = storage_path("app/users/{$userSlug}/" . $this->typeFolder($video));
$titleSlug = $this->titleSlug($video->title);
for ($i = 1; $i <= 50; $i++) {
@@ -206,60 +259,68 @@ class NasSyncService
$video->loadMissing(['user', 'slides']);
$dir = $this->localVideoDir($video);
$fileSlug = $this->titleSlug($video->title);
$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($dir, 0755, true);
@mkdir($primaryDir, 0755, true);
$userSlug = $this->userSlug($video->user);
$relDir = 'users/' . $userSlug . '/videos/' . basename($dir);
$updates = [];
$userSlug = $this->userSlug($video->user);
$videoRel = 'users/' . $userSlug . '/' . $this->typeFolder($video) . '/' . basename($videoDir);
$primaryRel = $isMusic
? $videoRel . '/tracks/' . $this->trackFolderName($video, null)
: $videoRel;
$updates = [];
// ── Video file ───────────────────────────────────────────────────
// ── Video / primary audio file ───────────────────────────────────
$oldVideoPath = storage_path('app/' . $video->path);
if (file_exists($oldVideoPath)) {
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
$newFileName = "{$fileSlug}.{$ext}";
rename($oldVideoPath, "{$dir}/{$newFileName}");
$updates['path'] = "{$relDir}/{$newFileName}";
$updates['filename'] = $newFileName;
$canonical = ($isMusic ? 'audio' : 'video') . ".{$ext}";
rename($oldVideoPath, "{$primaryDir}/{$canonical}");
$updates['path'] = "{$primaryRel}/{$canonical}";
$updates['filename'] = $canonical;
}
// ── Slides (process first; for audio, thumbnail IS slide 0) ──────
// ── Slides — music primary track only ────────────────────────────
$firstSlideRelPath = null;
if ($video->slides->isNotEmpty()) {
@mkdir("{$dir}/slides", 0755, true);
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->id}.{$ext}";
rename($oldSlidePath, "{$dir}/slides/{$newSlideName}");
$newSlideFilename = "{$relDir}/slides/{$newSlideName}";
$newSlideName = "{$slide->position}.{$ext}";
rename($oldSlidePath, "{$primaryDir}/slides/{$newSlideName}");
$newSlideFilename = "{$primaryRel}/slides/{$newSlideName}";
$slide->update(['filename' => $newSlideFilename]);
if ($firstSlideRelPath === null) {
$firstSlideRelPath = $newSlideFilename;
}
}
// For audio uploads the thumbnail is the first slide
if ($firstSlideRelPath !== null) {
$updates['thumbnail'] = $firstSlideRelPath;
}
}
// ── Standalone thumbnail (video uploads, no slides) ──────────────
// ── 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}";
rename($oldThumbPath, "{$dir}/{$newThumbName}");
$updates['thumbnail'] = "{$relDir}/{$newThumbName}";
$thumbDirAbs = $isMusic ? $primaryDir : $videoDir;
$thumbRelDir = $isMusic ? $primaryRel : $videoRel;
rename($oldThumbPath, "{$thumbDirAbs}/{$newThumbName}");
$updates['thumbnail'] = "{$thumbRelDir}/{$newThumbName}";
}
}
// ── meta.json (enables localVideoDir to identify this dir later) ─
$this->writeLocalMeta($video, $dir);
// ── meta.json (lives at the video / song root, not per-track) ────
$this->writeLocalMeta($video, $videoDir);
if (! empty($updates)) {
$video->update($updates);
@@ -603,41 +664,46 @@ class NasSyncService
): void {
$video->loadMissing(['user', 'slides']);
$dir = $this->resolveVideoDir($video); // NAS-relative, e.g. users/john/videos/my-video
$fileSlug = $this->titleSlug($video->title);
$this->mkdirp($dir);
$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 file ───────────────────────────────────────────────────
// ── Video / primary audio file ───────────────────────────────────
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
if (file_exists($tempVideoPath)) {
$ok = $this->putFile($tempVideoPath, "{$dir}/{$fileSlug}.{$ext}");
// 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) {
// Leave the local temp file intact so the caller can fall back to local storage
throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$fileSlug}.{$ext}");
throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$canonical}");
}
@unlink($tempVideoPath);
$updates['path'] = "{$dir}/{$fileSlug}.{$ext}";
$updates['filename'] = "{$fileSlug}.{$ext}";
$updates['path'] = $nasFile;
$updates['filename'] = $canonical;
}
// ── Slides (audio uploads — thumbnail IS the first slide) ────────
// ── Slides — music primary track only ────────────────────────────
$firstSlideNasPath = null;
if (! empty($slideAbsPaths)) {
$this->mkdirp("{$dir}/slides");
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 = "{$dir}/slides/{$slide->position}.{$slideExt}";
$nasSlideFile = "{$primaryDir}/slides/{$slide->position}.{$slideExt}";
if ($this->putFile($absPath, $nasSlideFile)) {
@unlink($absPath);
$slideRelPath = $nasSlideFile;
$slide->update(['filename' => $slideRelPath]);
$slide->update(['filename' => $nasSlideFile]);
if ($firstSlideNasPath === null) {
$firstSlideNasPath = $slideRelPath;
$firstSlideNasPath = $nasSlideFile;
}
}
}
@@ -646,26 +712,31 @@ class NasSyncService
}
}
// ── Standalone thumbnail (video uploads without slides) ──────────
// ── Standalone thumbnail (sports/generic + music-without-slides) ──
if ($tempThumbPath && file_exists($tempThumbPath) && $firstSlideNasPath === null) {
if ($this->putFile($tempThumbPath, "{$dir}/thumb.webp")) {
// 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'] = "{$dir}/thumb.webp";
$updates['thumbnail'] = $nasThumb;
}
}
// ── meta.json ────────────────────────────────────────────────────
// ── 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), "{$dir}/meta.json");
], 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' => $dir]);
Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $videoDir]);
$video->update($updates);
}
@@ -705,6 +776,17 @@ class NasSyncService
}
}
// 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,
@@ -994,6 +1076,130 @@ class NasSyncService
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();