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:
1 parent
73527f3781
commit
f98e5415a3
60 files changed
+7920
-2675
No files matched your search
+261
-55
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user