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
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\GenerateLyricsJob;
|
||||
use App\Models\Video;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Backfill synced lyrics for existing songs. New uploads generate automatically;
|
||||
* this covers the catalogue that predates the feature.
|
||||
*
|
||||
* php artisan lyrics:generate 163 # one video (primary + every track)
|
||||
* php artisan lyrics:generate --all # every music video missing lyrics
|
||||
* php artisan lyrics:generate --all --force # regenerate even if a file exists
|
||||
*/
|
||||
class GenerateLyrics extends Command
|
||||
{
|
||||
protected $signature = 'lyrics:generate {video? : Video id} {--all : All music videos} {--force : Regenerate even when lyrics already exist}';
|
||||
protected $description = 'Generate word-level synced lyrics for songs (dispatched to the video-processing queue)';
|
||||
|
||||
public function handle(NasSyncService $nas): int
|
||||
{
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
if ($videoId = $this->argument('video')) {
|
||||
$video = Video::find($videoId);
|
||||
if (! $video) {
|
||||
$this->error("Video #{$videoId} not found.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
$videos = collect([$video]);
|
||||
} elseif ($this->option('all')) {
|
||||
$videos = Video::where('type', 'music')->get();
|
||||
} else {
|
||||
$this->error('Pass a video id or --all.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$dispatched = 0;
|
||||
foreach ($videos as $video) {
|
||||
$video->loadMissing('audioTracks');
|
||||
|
||||
if ($force || ! is_array($nas->getLyrics($video, null))) {
|
||||
GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
|
||||
$dispatched++;
|
||||
}
|
||||
foreach ($video->audioTracks as $track) {
|
||||
if ($force || ! is_array($nas->getLyrics($video, $track))) {
|
||||
GenerateLyricsJob::dispatch($video->id, $track->id)->onConnection('database');
|
||||
$dispatched++;
|
||||
}
|
||||
}
|
||||
$this->line("Queued lyrics for #{$video->id} — {$video->title}");
|
||||
}
|
||||
|
||||
$this->info("Dispatched {$dispatched} lyrics job(s) to the video-processing queue.");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use App\Models\VideoSlide;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* One-time migration that moves existing songs/videos from the legacy flat
|
||||
* layout into the canonical type-segregated, per-track-folder layout described
|
||||
* in CLAUDE.md.
|
||||
*
|
||||
* Dry-run by default — pass --force to actually move files and update the DB.
|
||||
* Run this only after backing up the DB and confirming the dry-run plan looks
|
||||
* correct. The command is idempotent: rows already in the new layout are
|
||||
* skipped.
|
||||
*/
|
||||
class MigrateStorageLayout extends Command
|
||||
{
|
||||
protected $signature = 'storage:migrate-layout {--force : Apply changes (default is dry-run)} {--video= : Migrate only this video id}';
|
||||
protected $description = 'Move existing songs/videos to the type-segregated + per-track-folder layout';
|
||||
|
||||
public function handle(NasSyncService $nas): int
|
||||
{
|
||||
$apply = (bool) $this->option('force');
|
||||
$only = $this->option('video') ? (int) $this->option('video') : null;
|
||||
|
||||
$this->info($apply ? '→ APPLY mode (files and DB will be modified)' : '→ DRY-RUN (no changes will be made)');
|
||||
|
||||
$q = Video::query()->with(['user', 'audioTracks', 'slides']);
|
||||
if ($only) $q->where('id', $only);
|
||||
$videos = $q->orderBy('id')->get();
|
||||
|
||||
$this->info("Found {$videos->count()} video(s) to inspect.");
|
||||
|
||||
$stats = ['skipped' => 0, 'planned' => 0, 'applied' => 0, 'errors' => 0];
|
||||
|
||||
foreach ($videos as $video) {
|
||||
try {
|
||||
$plan = $this->planVideo($video, $nas);
|
||||
if ($plan === null) { $stats['skipped']++; continue; }
|
||||
|
||||
$this->line("");
|
||||
$this->info("Video #{$video->id} ({$video->type}): {$video->title}");
|
||||
foreach ($plan as $move) {
|
||||
$this->line(" {$move['from']} → {$move['to']}");
|
||||
}
|
||||
$stats['planned']++;
|
||||
|
||||
if ($apply) {
|
||||
$this->applyPlan($video, $plan, $nas);
|
||||
$stats['applied']++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$stats['errors']++;
|
||||
$this->error("Video #{$video->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->line("");
|
||||
$this->info("Done. " . json_encode($stats));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the move list for one video. Returns null if it's already in the
|
||||
* target layout. Each plan entry is ['from' => oldPath, 'to' => newPath,
|
||||
* 'kind' => 'video'|'audio'|'slide'|'thumb', 'model' => modelOrNull,
|
||||
* 'slide_field' => 'filename' | 'path' | 'thumbnail'].
|
||||
*/
|
||||
private function planVideo(Video $video, NasSyncService $nas): ?array
|
||||
{
|
||||
$path = (string) $video->path;
|
||||
if (! str_starts_with($path, 'users/')) {
|
||||
return null; // unorganised — outside this migration's scope
|
||||
}
|
||||
$segs = explode('/', $path);
|
||||
if (count($segs) < 4) return null;
|
||||
|
||||
$expectedTypeFolder = $nas->typeFolder($video);
|
||||
$currentTypeFolder = $segs[2] ?? null;
|
||||
$isMusic = ($video->type === 'music');
|
||||
|
||||
// Already in target layout? Music: tracks/{lang-id}/audio.{ext}. Others: video.{ext}.
|
||||
$inTracks = ($segs[4] ?? null) === 'tracks';
|
||||
$canonicalPrimary = $isMusic ? 'audio' : 'video';
|
||||
$endsCanonical = preg_match("#/{$canonicalPrimary}\\.[a-z0-9]+$#i", $path) === 1;
|
||||
if ($currentTypeFolder === $expectedTypeFolder
|
||||
&& (! $isMusic || $inTracks)
|
||||
&& $endsCanonical) {
|
||||
return null; // already migrated
|
||||
}
|
||||
|
||||
// Compute new song/video root: users/{slug}/{type-folder}/{slug}
|
||||
$userSlug = $segs[1];
|
||||
$videoSlug = $isMusic ? ($segs[3] ?? null) : ($segs[3] ?? null);
|
||||
if (! $videoSlug) return null;
|
||||
$newRoot = "users/{$userSlug}/{$expectedTypeFolder}/{$videoSlug}";
|
||||
|
||||
$ext = pathinfo($video->filename ?: $path, PATHINFO_EXTENSION) ?: 'mp4';
|
||||
|
||||
$plan = [];
|
||||
|
||||
// Primary file
|
||||
if ($isMusic) {
|
||||
$primaryFolder = $nas->trackFolderName($video, null);
|
||||
$plan[] = [
|
||||
'from' => $path,
|
||||
'to' => "{$newRoot}/tracks/{$primaryFolder}/audio.{$ext}",
|
||||
'kind' => 'video',
|
||||
'model' => $video,
|
||||
'set' => ['path', 'filename'],
|
||||
'new_filename'=> "audio.{$ext}",
|
||||
];
|
||||
} else {
|
||||
$plan[] = [
|
||||
'from' => $path,
|
||||
'to' => "{$newRoot}/video.{$ext}",
|
||||
'kind' => 'video',
|
||||
'model' => $video,
|
||||
'set' => ['path', 'filename'],
|
||||
'new_filename'=> "video.{$ext}",
|
||||
];
|
||||
}
|
||||
|
||||
// Thumbnail
|
||||
if ($video->thumbnail && str_starts_with($video->thumbnail, 'users/')) {
|
||||
$thumbExt = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'webp';
|
||||
$thumbDir = $isMusic
|
||||
? "{$newRoot}/tracks/" . $nas->trackFolderName($video, null)
|
||||
: $newRoot;
|
||||
$plan[] = [
|
||||
'from' => $video->thumbnail,
|
||||
'to' => "{$thumbDir}/thumb.{$thumbExt}",
|
||||
'kind' => 'thumb',
|
||||
'model' => $video,
|
||||
'set' => ['thumbnail'],
|
||||
];
|
||||
}
|
||||
|
||||
// Extra audio tracks (music only)
|
||||
if ($isMusic) {
|
||||
foreach ($video->audioTracks as $track) {
|
||||
if (! str_starts_with((string) $track->path, 'users/')) continue;
|
||||
$tExt = pathinfo($track->filename ?: $track->path, PATHINFO_EXTENSION) ?: 'mp3';
|
||||
$trackFolder = $nas->trackFolderName($video, $track);
|
||||
$plan[] = [
|
||||
'from' => $track->path,
|
||||
'to' => "{$newRoot}/tracks/{$trackFolder}/audio.{$tExt}",
|
||||
'kind' => 'audio',
|
||||
'model' => $track,
|
||||
'set' => ['path', 'filename'],
|
||||
'new_filename'=> "audio.{$tExt}",
|
||||
];
|
||||
}
|
||||
|
||||
// Slides (music only) — owners may be NULL (primary), or any track id
|
||||
foreach ($video->slides as $slide) {
|
||||
if (! str_starts_with((string) $slide->filename, 'users/')) continue;
|
||||
$sExt = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$ownerTrack = $slide->audio_track_id
|
||||
? $video->audioTracks->firstWhere('id', $slide->audio_track_id)
|
||||
: null;
|
||||
$folder = $nas->trackFolderName($video, $ownerTrack);
|
||||
$plan[] = [
|
||||
'from' => $slide->filename,
|
||||
'to' => "{$newRoot}/tracks/{$folder}/slides/{$slide->position}.{$sExt}",
|
||||
'kind' => 'slide',
|
||||
'model' => $slide,
|
||||
'set' => ['filename'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one video's plan. On NAS: copy then delete (smbclient has no rename).
|
||||
* On local: rename(). Either way, DB columns are updated after the move succeeds.
|
||||
*/
|
||||
private function applyPlan(Video $video, array $plan, NasSyncService $nas): void
|
||||
{
|
||||
$nasOn = $nas->isEnabled();
|
||||
|
||||
foreach ($plan as $move) {
|
||||
// Ensure target dir exists
|
||||
$targetDir = dirname($move['to']);
|
||||
if ($nasOn) {
|
||||
$nas->mkdirp($targetDir);
|
||||
}
|
||||
@mkdir(storage_path('app/' . $targetDir), 0755, true);
|
||||
|
||||
// Move on local
|
||||
$localFrom = storage_path('app/' . $move['from']);
|
||||
$localTo = storage_path('app/' . $move['to']);
|
||||
if (is_file($localFrom)) {
|
||||
@rename($localFrom, $localTo);
|
||||
}
|
||||
|
||||
// Move on NAS via copy+delete
|
||||
if ($nasOn) {
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'mig_');
|
||||
if ($nas->getContent($move['from']) !== null) {
|
||||
// small file like .json — already cached as content; round-trip
|
||||
}
|
||||
// For binary files, pull → push → delete-source
|
||||
$localCache = storage_path('app/' . $move['from']);
|
||||
if (! is_file($localCache)) {
|
||||
$nas->ensureLocalAsset($localCache, $move['from']);
|
||||
}
|
||||
if (is_file($localCache)) {
|
||||
if ($nas->putFile($localCache, $move['to'])) {
|
||||
$nas->deleteFile($move['from']);
|
||||
}
|
||||
}
|
||||
@unlink($tmp);
|
||||
}
|
||||
|
||||
// Update DB
|
||||
$model = $move['model'];
|
||||
$updates = [];
|
||||
foreach ($move['set'] as $col) {
|
||||
if ($col === 'path' || $col === 'thumbnail' || $col === 'filename') {
|
||||
$updates[$col] = ($col === 'filename' && isset($move['new_filename']))
|
||||
? $move['new_filename']
|
||||
: $move['to'];
|
||||
}
|
||||
}
|
||||
// VideoSlide stores the full path under `filename`
|
||||
if ($model instanceof VideoSlide) {
|
||||
$model->update(['filename' => $move['to']]);
|
||||
} else {
|
||||
$model->update($updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,8 @@ class PlaylistController extends Controller
|
||||
}
|
||||
|
||||
// View a single playlist
|
||||
public function show(Playlist $playlist)
|
||||
public function show(Request $request, Playlist $playlist)
|
||||
{
|
||||
// Check if user can view this playlist
|
||||
if (! $playlist->canView(Auth::user())) {
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
@@ -38,11 +37,17 @@ class PlaylistController extends Controller
|
||||
$playlist->loadMissing('user');
|
||||
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
|
||||
|
||||
// Count this visit (deduped per device) after the response is sent so
|
||||
// the page render isn't blocked by the EXISTS / INSERT / UPDATE round-trip.
|
||||
dispatch(function () use ($playlist, $request) {
|
||||
$playlist->bumpViewIfNew($request);
|
||||
})->afterResponse();
|
||||
|
||||
return view('playlists.show', compact('playlist', 'videos'));
|
||||
}
|
||||
|
||||
// View playlist via unguessable share token (unlisted playlists)
|
||||
public function showByToken(string $token)
|
||||
public function showByToken(Request $request, string $token)
|
||||
{
|
||||
$playlist = Playlist::where('share_token', $token)->firstOrFail();
|
||||
|
||||
@@ -53,6 +58,10 @@ class PlaylistController extends Controller
|
||||
$playlist->loadMissing('user');
|
||||
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
|
||||
|
||||
dispatch(function () use ($playlist, $request) {
|
||||
$playlist->bumpViewIfNew($request);
|
||||
})->afterResponse();
|
||||
|
||||
return view('playlists.show', compact('playlist', 'videos'));
|
||||
}
|
||||
|
||||
@@ -137,6 +146,11 @@ class PlaylistController extends Controller
|
||||
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
|
||||
}
|
||||
|
||||
// Human share-link click counts as a playlist view (deduped per device).
|
||||
dispatch(function () use ($playlist, $request) {
|
||||
$playlist->bumpViewIfNew($request);
|
||||
})->afterResponse();
|
||||
|
||||
$firstVideo = $playlist->videos()->orderBy('position')->first();
|
||||
$destination = $firstVideo
|
||||
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
|
||||
@@ -336,6 +350,18 @@ class PlaylistController extends Controller
|
||||
return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Body-based mirror of removeVideo: looks up the Video by raw numeric id
|
||||
* from the request body. Lets callers (e.g. the add-to-playlist modal) drive
|
||||
* the toggle without having to know the encoded route key for Video.
|
||||
*/
|
||||
public function removeVideoByBody(Request $request, Playlist $playlist)
|
||||
{
|
||||
$request->validate(['video_id' => 'required|exists:videos,id']);
|
||||
$video = Video::findOrFail($request->video_id);
|
||||
return $this->removeVideo($request, $playlist, $video);
|
||||
}
|
||||
|
||||
// Remove video from playlist
|
||||
public function removeVideo(Request $request, Playlist $playlist, Video $video)
|
||||
{
|
||||
|
||||
@@ -846,48 +846,204 @@ class SuperAdminController extends Controller
|
||||
public function settings()
|
||||
{
|
||||
$settings = [
|
||||
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
|
||||
'gpu_device' => Setting::get('gpu_device', '0'),
|
||||
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
|
||||
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
|
||||
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
|
||||
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
|
||||
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
|
||||
'llm_enabled' => Setting::get('llm_enabled', 'false'),
|
||||
'llm_clean_lyrics' => Setting::get('llm_clean_lyrics', 'true'),
|
||||
'llm_decorate_lyrics' => Setting::get('llm_decorate_lyrics', 'false'),
|
||||
'llm_providers' => json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [],
|
||||
'llm_active_id' => (string) Setting::get('llm_active_id', ''),
|
||||
];
|
||||
|
||||
return view('admin.settings', compact('settings'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings save handler — accepts partial submissions from any of the
|
||||
* separated admin pages (GPU, NAS, Backup, AI/LLM). Only updates the keys
|
||||
* that appear in the request.
|
||||
*/
|
||||
public function updateSettings(Request $request)
|
||||
{
|
||||
// ── GPU section ──────────────────────────────────────────────────────
|
||||
if ($request->has('gpu_enabled')) {
|
||||
$request->validate([
|
||||
'gpu_enabled' => 'required|in:true,false',
|
||||
'gpu_device' => 'required|integer|min:0|max:15',
|
||||
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
|
||||
'gpu_hwaccel' => 'required|in:cuda,none',
|
||||
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
||||
'ffmpeg_binary' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
|
||||
if (! file_exists($binary) || ! is_executable($binary)) {
|
||||
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
|
||||
}
|
||||
|
||||
Setting::set('gpu_enabled', $request->gpu_enabled);
|
||||
Setting::set('gpu_device', (string) $request->gpu_device);
|
||||
Setting::set('gpu_encoder', $request->gpu_encoder);
|
||||
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
||||
Setting::set('gpu_preset', $request->gpu_preset);
|
||||
Setting::set('ffmpeg_binary', $binary);
|
||||
Setting::flushGpuProbe();
|
||||
}
|
||||
|
||||
// ── Lyrics pipeline section ──────────────────────────────────────────
|
||||
if ($request->has('lyrics_section')) {
|
||||
foreach ([
|
||||
'lyrics_enabled', // master switch
|
||||
'lyrics_use_description', // align to description text
|
||||
'lyrics_vad_enabled', // Silero VAD filter
|
||||
'lyrics_vocal_region_gapfill', // snap gap-filled lines to vocal regions
|
||||
'lyrics_demucs_enabled', // vocal isolation (Demucs)
|
||||
'lyrics_llm_decorate', // post-bake emojis via LLM
|
||||
] as $k) {
|
||||
Setting::set($k, $request->input($k, 'false') === 'true' ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI / LLM section ─────────────────────────────────────────────────
|
||||
if ($request->has('llm_section')) {
|
||||
Setting::set('llm_enabled', $request->input('llm_enabled', 'false') === 'true' ? 'true' : 'false');
|
||||
Setting::set('llm_clean_lyrics', $request->input('llm_clean_lyrics', 'false') === 'true' ? 'true' : 'false');
|
||||
Setting::set('llm_decorate_lyrics', $request->input('llm_decorate_lyrics', 'false') === 'true' ? 'true' : 'false');
|
||||
$this->saveLlmProviders($request);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Settings saved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe an LLM provider endpoint: verify the connection and list
|
||||
* available models. Used by the AI / LLM settings page.
|
||||
*
|
||||
* Accepts kind / endpoint / api_key from the form, plus an optional
|
||||
* provider id so we can fall back to the saved key when the admin
|
||||
* left the password field blank (placeholder ••••••••).
|
||||
*/
|
||||
public function llmProviderTest(Request $request)
|
||||
{
|
||||
$kind = (string) $request->input('kind', 'ollama');
|
||||
$endpoint = trim((string) $request->input('endpoint', '')) ?: self::defaultEndpoint($kind);
|
||||
$endpoint = rtrim($endpoint, '/');
|
||||
$apiKey = (string) $request->input('api_key', '');
|
||||
$id = (string) $request->input('id', '');
|
||||
|
||||
if ($apiKey === '' && $id !== '') {
|
||||
$providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
|
||||
foreach ($providers as $p) {
|
||||
if (($p['id'] ?? '') === $id) {
|
||||
$apiKey = (string) ($p['api_key'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! in_array($kind, ['ollama', 'anthropic', 'openai'], true)) {
|
||||
return response()->json(['ok' => false, 'message' => 'Unknown provider kind.'], 422);
|
||||
}
|
||||
|
||||
if ($kind !== 'ollama' && $apiKey === '') {
|
||||
return response()->json(['ok' => false, 'message' => 'API key required for ' . $kind . '.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$models = match ($kind) {
|
||||
'ollama' => $this->fetchOllamaModels($endpoint),
|
||||
'anthropic' => $this->fetchAnthropicModels($endpoint, $apiKey),
|
||||
'openai' => $this->fetchOpenAIModels($endpoint, $apiKey),
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
sort($models, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'count' => count($models),
|
||||
'models' => $models,
|
||||
]);
|
||||
}
|
||||
|
||||
private function fetchOllamaModels(string $endpoint): array
|
||||
{
|
||||
$resp = \Illuminate\Support\Facades\Http::timeout(10)->acceptJson()->get($endpoint . '/api/tags');
|
||||
if (! $resp->successful()) {
|
||||
throw new \RuntimeException('Ollama returned HTTP ' . $resp->status());
|
||||
}
|
||||
$j = $resp->json();
|
||||
return array_values(array_filter(array_map(
|
||||
fn ($m) => (string) ($m['name'] ?? ''),
|
||||
$j['models'] ?? []
|
||||
)));
|
||||
}
|
||||
|
||||
private function fetchAnthropicModels(string $endpoint, string $apiKey): array
|
||||
{
|
||||
$resp = \Illuminate\Support\Facades\Http::timeout(15)->withHeaders([
|
||||
'x-api-key' => $apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
])->get($endpoint . '/v1/models');
|
||||
if (! $resp->successful()) {
|
||||
$body = $resp->json();
|
||||
throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
|
||||
}
|
||||
$j = $resp->json();
|
||||
return array_values(array_filter(array_map(
|
||||
fn ($m) => (string) ($m['id'] ?? ''),
|
||||
$j['data'] ?? []
|
||||
)));
|
||||
}
|
||||
|
||||
private function fetchOpenAIModels(string $endpoint, string $apiKey): array
|
||||
{
|
||||
$resp = \Illuminate\Support\Facades\Http::timeout(15)
|
||||
->withToken($apiKey)->acceptJson()
|
||||
->get($endpoint . '/v1/models');
|
||||
if (! $resp->successful()) {
|
||||
$body = $resp->json();
|
||||
throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
|
||||
}
|
||||
$j = $resp->json();
|
||||
return array_values(array_filter(array_map(
|
||||
fn ($m) => (string) ($m['id'] ?? ''),
|
||||
$j['data'] ?? []
|
||||
)));
|
||||
}
|
||||
|
||||
public function lyrics()
|
||||
{
|
||||
$settings = [
|
||||
'lyrics_enabled' => Setting::get('lyrics_enabled', 'true'),
|
||||
'lyrics_use_description' => Setting::get('lyrics_use_description', 'true'),
|
||||
'lyrics_vad_enabled' => Setting::get('lyrics_vad_enabled', 'true'),
|
||||
'lyrics_vocal_region_gapfill' => Setting::get('lyrics_vocal_region_gapfill', 'true'),
|
||||
'lyrics_demucs_enabled' => Setting::get('lyrics_demucs_enabled', 'false'),
|
||||
'lyrics_llm_decorate' => Setting::get('lyrics_llm_decorate', Setting::get('llm_decorate_lyrics', 'false')),
|
||||
];
|
||||
return view('admin.lyrics', compact('settings'));
|
||||
}
|
||||
|
||||
public function gpu()
|
||||
{
|
||||
$settings = [
|
||||
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
|
||||
'gpu_device' => Setting::get('gpu_device', '0'),
|
||||
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
|
||||
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
|
||||
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
|
||||
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
|
||||
];
|
||||
|
||||
$gpus = $this->probeGpus();
|
||||
$nvencWorks = $this->probeNvenc();
|
||||
|
||||
return view('admin.settings', compact('settings', 'gpus', 'nvencWorks'));
|
||||
return view('admin.gpu', compact('settings', 'gpus', 'nvencWorks'));
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request)
|
||||
public function backup()
|
||||
{
|
||||
$request->validate([
|
||||
'gpu_enabled' => 'required|in:true,false',
|
||||
'gpu_device' => 'required|integer|min:0|max:15',
|
||||
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
|
||||
'gpu_hwaccel' => 'required|in:cuda,none',
|
||||
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
||||
'ffmpeg_binary' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
|
||||
if (! file_exists($binary) || ! is_executable($binary)) {
|
||||
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
|
||||
}
|
||||
|
||||
Setting::set('gpu_enabled', $request->gpu_enabled);
|
||||
Setting::set('gpu_device', (string) $request->gpu_device);
|
||||
Setting::set('gpu_encoder', $request->gpu_encoder);
|
||||
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
||||
Setting::set('gpu_preset', $request->gpu_preset);
|
||||
Setting::set('ffmpeg_binary', $binary);
|
||||
|
||||
// GPU config changed — drop the cached health-check so the next encode re-probes.
|
||||
Setting::flushGpuProbe();
|
||||
|
||||
return back()->with('success', 'Settings saved.');
|
||||
return view('admin.backup');
|
||||
}
|
||||
|
||||
public function detectGpu()
|
||||
@@ -895,6 +1051,67 @@ class SuperAdminController extends Controller
|
||||
return response()->json(['gpus' => $this->probeGpus()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the LLM provider list from the multi-provider form. Each row
|
||||
* carries id / name / kind (ollama|anthropic|openai) / endpoint / model /
|
||||
* api_key. An empty api_key means "keep the previously stored value" so the
|
||||
* admin doesn't have to retype it on every save.
|
||||
*/
|
||||
private function saveLlmProviders(Request $request): void
|
||||
{
|
||||
$existing = collect(json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [])
|
||||
->keyBy(fn ($p) => $p['id'] ?? '');
|
||||
|
||||
$kinds = ['ollama', 'anthropic', 'openai'];
|
||||
$rows = (array) $request->input('providers', []);
|
||||
$out = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
$kind = (string) ($row['kind'] ?? 'ollama');
|
||||
if (! in_array($kind, $kinds, true)) $kind = 'ollama';
|
||||
if ($name === '') continue;
|
||||
|
||||
$id = (string) ($row['id'] ?? '') ?: (string) \Illuminate\Support\Str::uuid();
|
||||
$endpoint = trim((string) ($row['endpoint'] ?? '')) ?: self::defaultEndpoint($kind);
|
||||
$model = trim((string) ($row['model'] ?? ''));
|
||||
$apiKeyIn = (string) ($row['api_key'] ?? '');
|
||||
// Blank input → keep the previously-stored key for this id (admin
|
||||
// didn't retype it). Non-blank → use the new value verbatim.
|
||||
$apiKey = $apiKeyIn !== '' ? $apiKeyIn : (string) ($existing[$id]['api_key'] ?? '');
|
||||
|
||||
$out[] = [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'kind' => $kind,
|
||||
'endpoint' => $endpoint,
|
||||
'model' => $model,
|
||||
'api_key' => $apiKey,
|
||||
];
|
||||
}
|
||||
|
||||
Setting::set('llm_providers', json_encode($out, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$activeId = trim((string) $request->input('llm_active_id', ''));
|
||||
$validIds = array_column($out, 'id');
|
||||
if ($activeId !== '' && in_array($activeId, $validIds, true)) {
|
||||
Setting::set('llm_active_id', $activeId);
|
||||
} elseif (count($validIds) === 1) {
|
||||
Setting::set('llm_active_id', $validIds[0]);
|
||||
} elseif (! in_array((string) Setting::get('llm_active_id', ''), $validIds, true)) {
|
||||
Setting::set('llm_active_id', '');
|
||||
}
|
||||
}
|
||||
|
||||
private static function defaultEndpoint(string $kind): string
|
||||
{
|
||||
return match ($kind) {
|
||||
'anthropic' => 'https://api.anthropic.com',
|
||||
'openai' => 'https://api.openai.com',
|
||||
default => 'http://localhost:11434',
|
||||
};
|
||||
}
|
||||
|
||||
private function probeGpus(): array
|
||||
{
|
||||
$gpus = [];
|
||||
@@ -935,7 +1152,10 @@ class SuperAdminController extends Controller
|
||||
public function nasStorage()
|
||||
{
|
||||
$nodes = config('nas-file-manager.schema', []);
|
||||
return view('admin.nas-storage', compact('nodes'));
|
||||
$settings = [
|
||||
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
|
||||
];
|
||||
return view('admin.nas-storage', compact('nodes', 'settings'));
|
||||
}
|
||||
|
||||
public function nasDelete(Request $request)
|
||||
|
||||
@@ -26,7 +26,7 @@ class VideoController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack']);
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -161,6 +161,12 @@ class VideoController extends Controller
|
||||
'extra_track_titles.*' => 'nullable|string|max:255',
|
||||
'extra_track_descriptions' => 'nullable|array',
|
||||
'extra_track_descriptions.*'=> 'nullable|string',
|
||||
// Optional per-extra-track slides. Sent as extra_track_slides[i][] = file.
|
||||
// If absent for a given index, the track inherits the primary's slides
|
||||
// at render time via Video::slidesForTrack().
|
||||
'extra_track_slides' => 'nullable|array',
|
||||
'extra_track_slides.*' => 'nullable|array',
|
||||
'extra_track_slides.*.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
|
||||
]);
|
||||
|
||||
$videoFile = $request->file('video');
|
||||
@@ -332,6 +338,7 @@ class VideoController extends Controller
|
||||
$trackLangs = $request->input('extra_track_languages', []);
|
||||
$trackTitles = $request->input('extra_track_titles', []);
|
||||
$trackDescs = $request->input('extra_track_descriptions', []);
|
||||
$trackSlides = $request->file('extra_track_slides') ?: [];
|
||||
|
||||
foreach ($trackFiles as $i => $trackFile) {
|
||||
if (! $trackFile || ! $trackFile->isValid()) continue;
|
||||
@@ -354,19 +361,20 @@ class VideoController extends Controller
|
||||
|
||||
if ($nas->isEnabled()) {
|
||||
try {
|
||||
$videoDir = $nas->resolveVideoDir($video);
|
||||
$nas->mkdirp($videoDir);
|
||||
$trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
|
||||
// Extra music track → its own folder under tracks/{lang-id}/audio.{ext}
|
||||
$trackDir = $nas->trackDir($video, $track);
|
||||
$nas->mkdirp($trackDir);
|
||||
$canonical = "audio.{$ext}";
|
||||
$nasPath = "{$trackDir}/{$canonical}";
|
||||
|
||||
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
|
||||
$tempAbs = storage_path('app/' . $tempPath);
|
||||
$nasPath = "{$videoDir}/{$trackName}";
|
||||
$nas->putFile($tempAbs, $nasPath);
|
||||
@unlink($tempAbs);
|
||||
|
||||
$track->update([
|
||||
'path' => $nasPath,
|
||||
'filename' => $trackName,
|
||||
'filename' => $canonical,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error("Extra track NAS upload failed: " . $e->getMessage());
|
||||
@@ -376,6 +384,23 @@ class VideoController extends Controller
|
||||
} else {
|
||||
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
|
||||
}
|
||||
|
||||
// ── Optional per-track slides ──────────────────────────────
|
||||
// The track only owns slides that were uploaded for it. If none
|
||||
// were uploaded, the player falls back to the primary's at render
|
||||
// time via Video::slidesForTrack — no row needed here.
|
||||
$files = $trackSlides[$i] ?? null;
|
||||
if (is_array($files) && count($files) > 0) {
|
||||
$this->storeTrackSlides($video, $track, $files, $nas);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Synced lyrics generation (audio/music uploads only) ───────────────
|
||||
if ($isAudioUpload) {
|
||||
\App\Jobs\GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
|
||||
foreach ($video->audioTracks()->pluck('id') as $tid) {
|
||||
\App\Jobs\GenerateLyricsJob::dispatch($video->id, (int) $tid)->onConnection('database');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,9 +569,19 @@ class VideoController extends Controller
|
||||
if ($playlistParam) {
|
||||
$playlist = Playlist::where('share_token', $playlistParam)->first();
|
||||
if ($playlist && $playlist->canViewViaToken(Auth::user())) {
|
||||
$nextVideo = $playlist->getNextVideo($video);
|
||||
$previousVideo = $playlist->getPreviousVideo($video);
|
||||
$playlistVideos = $playlist->videos;
|
||||
// Load the videos ONCE with their owners eager-loaded, then
|
||||
// compute prev/next in PHP. The old code fired 4+ separate
|
||||
// queries for prev/next/list — the sidebar lag the user
|
||||
// reported was almost entirely those extra round-trips.
|
||||
$playlistVideos = $playlist->videos()->with('user')->orderBy('position')->get();
|
||||
[$previousVideo, $nextVideo] = $playlist->neighborsFromCollection($playlistVideos, $video);
|
||||
|
||||
// Count the playlist view (deduped per device, 1-hour window)
|
||||
// after the response is flushed so we don't pay the round-trip
|
||||
// on the hot path.
|
||||
dispatch(function () use ($playlist, $request) {
|
||||
$playlist->bumpViewIfNew($request);
|
||||
})->afterResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,9 +614,16 @@ class VideoController extends Controller
|
||||
? route('media.thumbnail', $video->thumbnail)
|
||||
: asset('storage/images/logo.png');
|
||||
|
||||
$slides = $video->slides->count() > 1
|
||||
? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()
|
||||
: [];
|
||||
// Per-track slide map (key "0" = primary). Each entry already has the
|
||||
// sharing fallback applied by Video::slidesForTrack — a track without its
|
||||
// own slides borrows the primary's (or a sibling's) automatically.
|
||||
$slideMap = ['0' => $video->slidesForTrack(null)
|
||||
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()];
|
||||
foreach ($video->audioTracks as $_t) {
|
||||
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
|
||||
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
|
||||
}
|
||||
$slides = $slideMap['0'];
|
||||
|
||||
$allLangData = \App\Data\Languages::all();
|
||||
$audioTracks = $video->audioTracks->map(fn ($t) => [
|
||||
@@ -595,6 +637,14 @@ class VideoController extends Controller
|
||||
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
|
||||
])->values()->all();
|
||||
|
||||
// Synced lyrics embedded inline (no separate request), keyed by track id; "0" = primary.
|
||||
// Local mirror only — must not block this hot path on NAS I/O.
|
||||
$nasLyrics = app(\App\Services\NasSyncService::class);
|
||||
$lyricsMap = ['0' => $nasLyrics->getLocalLyrics($video, null)];
|
||||
foreach ($video->audioTracks as $t) {
|
||||
$lyricsMap[(string) $t->id] = $nasLyrics->getLocalLyrics($video, $t);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'id' => $video->id,
|
||||
'key' => $video->getRouteKey(),
|
||||
@@ -604,6 +654,7 @@ class VideoController extends Controller
|
||||
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
|
||||
'cover_url' => $coverUrl,
|
||||
'slides' => $slides,
|
||||
'slide_map' => $slideMap,
|
||||
'title' => $video->title,
|
||||
'author' => $video->user->name ?? '',
|
||||
'duration' => $video->duration,
|
||||
@@ -612,9 +663,212 @@ class VideoController extends Controller
|
||||
'language' => $video->language,
|
||||
'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null,
|
||||
'audio_tracks' => $audioTracks,
|
||||
'lyrics' => $lyricsMap,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-triggered lyrics generation for the current audio track (?track={id},
|
||||
* 0/absent = primary). Dispatches the GPU pipeline to the queue; the player
|
||||
* polls player-data and shows the lyrics once the file lands.
|
||||
*/
|
||||
public function generateLyrics(Request $request, Video $video)
|
||||
{
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403);
|
||||
}
|
||||
if (\App\Models\Setting::get('lyrics_enabled', 'true') !== 'true') {
|
||||
return response()->json(['error' => 'Lyrics generation is currently disabled by the administrator.'], 422);
|
||||
}
|
||||
if (! $this->isAudioOnlyFile($video)) {
|
||||
return response()->json(['error' => 'Lyrics are only for audio tracks.'], 422);
|
||||
}
|
||||
|
||||
$trackId = (int) $request->input('track', 0);
|
||||
if ($trackId) {
|
||||
$track = $video->audioTracks()->find($trackId);
|
||||
if (! $track) {
|
||||
return response()->json(['error' => 'Track not found.'], 404);
|
||||
}
|
||||
}
|
||||
|
||||
\App\Jobs\GenerateLyricsJob::dispatch($video->id, $trackId ?: null)->onConnection('database');
|
||||
|
||||
return response()->json(['status' => 'queued']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-triggered delete of the saved lyrics for a track. Wipes the local
|
||||
* mirror + the NAS copy and removes any in-flight progress / temp files,
|
||||
* so the next Generate produces a fresh result.
|
||||
*/
|
||||
public function deleteLyrics(Request $request, Video $video)
|
||||
{
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$trackId = (int) $request->input('track', 0);
|
||||
$track = null;
|
||||
if ($trackId) {
|
||||
$track = $video->audioTracks()->find($trackId);
|
||||
if (! $track) {
|
||||
return response()->json(['error' => 'Track not found.'], 404);
|
||||
}
|
||||
}
|
||||
|
||||
app(\App\Services\NasSyncService::class)->deleteLyrics($video, $track);
|
||||
|
||||
// Clear any in-flight progress / temp artifacts so a queued job that
|
||||
// fires later can't repopulate stale output.
|
||||
@unlink(\App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null));
|
||||
@unlink(storage_path('app/tmp/lyrics_' . $video->id . '_' . ($trackId ?: 'primary') . '.json'));
|
||||
|
||||
return response()->json(['status' => 'deleted']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live progress for an in-flight lyrics generation, driving the player's
|
||||
* progress bar. Returns {status: ready|failed|processing|none, pct, stage}.
|
||||
*/
|
||||
public function lyricsProgress(Request $request, Video $video)
|
||||
{
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$trackId = (int) $request->input('track', 0);
|
||||
$track = null;
|
||||
if ($trackId) {
|
||||
$track = $video->audioTracks()->find($trackId);
|
||||
if (! $track) return response()->json(['status' => 'none']);
|
||||
}
|
||||
|
||||
$data = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $track);
|
||||
if (is_array($data)) {
|
||||
$st = $data['status'] ?? 'ready';
|
||||
if ($st === 'ready' && ! empty($data['lines'])) return response()->json(['status' => 'ready', 'pct' => 100]);
|
||||
if ($st === 'failed') return response()->json(['status' => 'failed']);
|
||||
}
|
||||
|
||||
// Live percentage from the pipeline's progress file.
|
||||
$progFile = \App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null);
|
||||
if (is_file($progFile)) {
|
||||
$p = json_decode((string) file_get_contents($progFile), true);
|
||||
if (is_array($p)) {
|
||||
return response()->json([
|
||||
'status' => 'processing',
|
||||
'pct' => (int) ($p['pct'] ?? 1),
|
||||
'stage' => $p['stage'] ?? 'Working',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// A 'processing' lyrics marker but no progress file yet → just queued.
|
||||
if (is_array($data)) return response()->json(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']);
|
||||
|
||||
return response()->json(['status' => 'none']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-edited lyrics save. Receives the (possibly corrected) lines for a
|
||||
* track; preserves precise word timing for lines that weren't changed and
|
||||
* redistributes timing evenly across the new words for edited lines.
|
||||
*/
|
||||
public function saveLyrics(Request $request, Video $video)
|
||||
{
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$trackId = (int) $request->input('track', 0);
|
||||
$track = null;
|
||||
if ($trackId) {
|
||||
$track = $video->audioTracks()->find($trackId);
|
||||
if (! $track) return response()->json(['error' => 'Track not found.'], 404);
|
||||
}
|
||||
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$existing = $nas->getLocalLyrics($video, $track) ?: [];
|
||||
$spaceless = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
|
||||
|
||||
$inLines = $request->input('lines', []);
|
||||
if (! is_array($inLines)) $inLines = [];
|
||||
|
||||
$out = [];
|
||||
foreach ($inLines as $ln) {
|
||||
$text = trim(strip_tags((string) ($ln['text'] ?? '')));
|
||||
if ($text === '' || mb_strlen($text) > 1000) {
|
||||
if ($text === '') continue;
|
||||
$text = mb_substr($text, 0, 1000);
|
||||
}
|
||||
$start = round((float) ($ln['start'] ?? 0), 3);
|
||||
$end = round((float) ($ln['end'] ?? $start), 3);
|
||||
$lang = (string) ($ln['lang'] ?? ($existing['language'] ?? 'en'));
|
||||
$isSpaceless = in_array($lang, $spaceless, true);
|
||||
|
||||
// Keep original word timings if the text wasn't changed; otherwise
|
||||
// redistribute the line's span evenly across the new tokens.
|
||||
$origWords = (isset($ln['words']) && is_array($ln['words'])) ? $ln['words'] : [];
|
||||
$sep = $isSpaceless ? '' : ' ';
|
||||
$joined = implode($sep, array_map(fn ($w) => (string) ($w['text'] ?? ''), $origWords));
|
||||
$unchanged = $origWords
|
||||
&& preg_replace('/\s+/u', '', $joined) === preg_replace('/\s+/u', '', $text);
|
||||
|
||||
if ($unchanged) {
|
||||
$words = array_map(fn ($w) => [
|
||||
'start' => round((float) ($w['start'] ?? $start), 3),
|
||||
'end' => round((float) ($w['end'] ?? $end), 3),
|
||||
'text' => (string) ($w['text'] ?? ''),
|
||||
], $origWords);
|
||||
} else {
|
||||
$words = $this->redistributeWords($start, $end, $text, $isSpaceless);
|
||||
}
|
||||
|
||||
$out[] = ['start' => $start, 'end' => $end, 'text' => $text, 'lang' => $lang, 'words' => $words];
|
||||
if (count($out) >= 1000) break;
|
||||
}
|
||||
|
||||
usort($out, fn ($a, $b) => $a['start'] <=> $b['start']);
|
||||
|
||||
$nas->putLyrics($video, $track, [
|
||||
'version' => 1,
|
||||
'status' => 'ready',
|
||||
'source' => 'edited',
|
||||
'language' => $existing['language'] ?? ($out[0]['lang'] ?? 'en'),
|
||||
'multilingual' => $existing['multilingual'] ?? false,
|
||||
'lines' => $out,
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => 'ok', 'lines' => count($out)]);
|
||||
}
|
||||
|
||||
/** Evenly distribute a line's [start,end] across its words (used for edited lines). */
|
||||
private function redistributeWords(float $start, float $end, string $text, bool $spaceless): array
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '' || $end <= $start) {
|
||||
return $text === '' ? [] : [['start' => $start, 'end' => max($end, $start + 0.5), 'text' => $text]];
|
||||
}
|
||||
$tokens = $spaceless
|
||||
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
|
||||
: preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$tokens = array_values(array_filter($tokens, fn ($t) => trim($t) !== ''));
|
||||
$n = count($tokens);
|
||||
if ($n === 0) return [];
|
||||
$slice = ($end - $start) / $n;
|
||||
$words = [];
|
||||
foreach ($tokens as $i => $tok) {
|
||||
$words[] = [
|
||||
'start' => round($start + $i * $slice, 3),
|
||||
'end' => round($start + ($i + 1) * $slice, 3),
|
||||
'text' => $tok,
|
||||
];
|
||||
}
|
||||
return $words;
|
||||
}
|
||||
|
||||
public function matchData(Video $video)
|
||||
{
|
||||
if (! $video->canView(Auth::user())) {
|
||||
@@ -639,8 +893,9 @@ class VideoController extends Controller
|
||||
}
|
||||
|
||||
$slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
|
||||
'id' => $s->id,
|
||||
'url' => $s->url,
|
||||
'id' => $s->id,
|
||||
'url' => $s->url,
|
||||
'audio_track_id' => $s->audio_track_id, // null = primary / song-wide
|
||||
])->values();
|
||||
|
||||
$audioTracks = $video->audioTracks->map(fn ($t) => [
|
||||
@@ -1485,21 +1740,93 @@ class VideoController extends Controller
|
||||
private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void
|
||||
{
|
||||
try {
|
||||
// All tracks (primary + secondary) live directly in the song's own folder —
|
||||
// never a separate tracks/ subfolder — with a unique, lowercase name.
|
||||
$localDir = $nas->localVideoDir($video);
|
||||
@mkdir($localDir, 0755, true);
|
||||
$base = basename($localDir);
|
||||
$trackName = $this->audioTrackName($base, $track->language, $track->id, $ext);
|
||||
$trackFile->move($localDir, $trackName);
|
||||
$userSlug = $nas->userSlug($video->user);
|
||||
$relPath = "users/{$userSlug}/videos/{$base}/{$trackName}";
|
||||
$track->update(['path' => $relPath, 'filename' => $trackName]);
|
||||
// Music: each track gets its own folder tracks/{lang-id}/audio.{ext}.
|
||||
// (storeTrackLocally is only reached for music since extra tracks
|
||||
// only exist on music videos.)
|
||||
$songLocalDir = $nas->localVideoDir($video);
|
||||
$trackFolder = $nas->trackFolderName($video, $track);
|
||||
$trackDirAbs = $songLocalDir . '/tracks/' . $trackFolder;
|
||||
@mkdir($trackDirAbs, 0755, true);
|
||||
|
||||
$canonical = "audio.{$ext}";
|
||||
$trackFile->move($trackDirAbs, $canonical);
|
||||
|
||||
// Build the NAS-relative path from the song's relative path so it
|
||||
// works on both local-only and NAS-enabled setups.
|
||||
$songRel = $this->relFromStoragePath($songLocalDir);
|
||||
$relPath = "{$songRel}/tracks/{$trackFolder}/{$canonical}";
|
||||
|
||||
$track->update(['path' => $relPath, 'filename' => $canonical]);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error("storeTrackLocally failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert an absolute storage_path() value back to a storage-relative path. */
|
||||
private function relFromStoragePath(string $abs): string
|
||||
{
|
||||
$prefix = storage_path('app/');
|
||||
if (str_starts_with($abs, $prefix)) {
|
||||
return substr($abs, strlen($prefix));
|
||||
}
|
||||
return $abs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist per-track slides — the new track owns them via audio_track_id.
|
||||
* Filename scheme keeps tracks from colliding in the shared slides/ folder:
|
||||
* slides/track-{trackId}-{position}.{ext}
|
||||
* Primary slides keep the legacy slides/{position}.{ext} scheme.
|
||||
*/
|
||||
private function storeTrackSlides(Video $video, VideoAudioTrack $track, array $files, $nas): void
|
||||
{
|
||||
// Per-track slides live in the track's own folder:
|
||||
// tracks/{lang-id}/slides/{position}.{ext}
|
||||
// Filenames are canonical (just {position}.{ext}) because the folder
|
||||
// already disambiguates by track.
|
||||
$nasEnabled = $nas->isEnabled();
|
||||
$songLocal = $nas->localVideoDir($video);
|
||||
$songRel = $this->relFromStoragePath($songLocal);
|
||||
$trackFold = $nas->trackFolderName($video, $track);
|
||||
$nasTrackDir = "{$songRel}/tracks/{$trackFold}";
|
||||
$localTrackDir = "{$songLocal}/tracks/{$trackFold}";
|
||||
|
||||
if ($nasEnabled) {
|
||||
try { $nas->mkdirp("{$nasTrackDir}/slides"); } catch (\Throwable $e) {}
|
||||
} else {
|
||||
@mkdir("{$localTrackDir}/slides", 0755, true);
|
||||
}
|
||||
|
||||
foreach ($files as $pos => $file) {
|
||||
if (! $file || ! $file->isValid()) continue;
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
|
||||
$name = "{$pos}.{$ext}";
|
||||
$slide = VideoSlide::create([
|
||||
'video_id' => $video->id,
|
||||
'audio_track_id' => $track->id,
|
||||
'filename' => '__pending__',
|
||||
'position' => $pos,
|
||||
]);
|
||||
|
||||
$tempPath = $file->storeAs('public/tmp', "trackslide_{$slide->id}.{$ext}");
|
||||
$tempAbs = storage_path('app/' . $tempPath);
|
||||
|
||||
if ($nasEnabled) {
|
||||
$nasPath = "{$nasTrackDir}/slides/{$name}";
|
||||
if ($nas->putFile($tempAbs, $nasPath)) {
|
||||
@unlink($tempAbs);
|
||||
$slide->update(['filename' => $nasPath]);
|
||||
continue;
|
||||
}
|
||||
// Fall through to local on NAS failure
|
||||
}
|
||||
|
||||
@mkdir("{$localTrackDir}/slides", 0755, true);
|
||||
@rename($tempAbs, "{$localTrackDir}/slides/{$name}");
|
||||
$slide->update(['filename' => "{$nasTrackDir}/slides/{$name}"]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique, lowercase filename for an audio track kept in the song's own folder:
|
||||
* {song-slug}-{lang}-{db-id}.{ext}. The db id guarantees no two tracks can ever
|
||||
@@ -1602,10 +1929,11 @@ class VideoController extends Controller
|
||||
// Audio-only file → generate/serve a video for the version being played.
|
||||
$trackId = (int) request()->input('track', 0);
|
||||
$viz = request()->boolean('visualizer');
|
||||
$lyrics = request()->boolean('lyrics');
|
||||
|
||||
// Any non-primary or visualizer variant is served straight off disk (no DB column).
|
||||
if ($viz || $trackId) {
|
||||
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
|
||||
// Any non-primary, visualizer, or lyrics variant is served straight off disk (no DB column).
|
||||
if ($viz || $trackId || $lyrics) {
|
||||
$cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
|
||||
if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
|
||||
return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
|
||||
'Content-Type' => 'video/mp4',
|
||||
@@ -1707,7 +2035,8 @@ class VideoController extends Controller
|
||||
// combination is cached under its own filename so they never clobber each other.
|
||||
$viz = $request->boolean('visualizer');
|
||||
$trackId = (int) $request->input('track', 0);
|
||||
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
|
||||
$lyrics = $request->boolean('lyrics');
|
||||
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
|
||||
|
||||
$ffmpeg = \App\Models\Setting::ffmpegBinary();
|
||||
$ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
|
||||
@@ -1731,7 +2060,7 @@ class VideoController extends Controller
|
||||
return response()->json(['error' => 'Audio file not found'], 404);
|
||||
}
|
||||
|
||||
$outRel = $this->slideshowRel($video, $viz, $trackId); // song's cache/ folder
|
||||
$outRel = $this->slideshowRel($video, $viz, $trackId, $lyrics); // song's cache/ folder
|
||||
$outPath = storage_path('app/' . $outRel);
|
||||
if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true);
|
||||
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
|
||||
@@ -1772,12 +2101,37 @@ class VideoController extends Controller
|
||||
return response()->json(['error' => 'Cannot probe audio duration'], 500);
|
||||
}
|
||||
|
||||
$slides = $video->slides()->orderBy('position')->get();
|
||||
// Use the slides for the track being rendered, applying the per-track
|
||||
// sharing fallback (own → primary → sibling). Ensures the download .mp4
|
||||
// matches the slideshow the listener saw in the player.
|
||||
$video->loadMissing('slides');
|
||||
$slides = $video->slidesForTrack($trackId ?: null)->sortBy('position')->values();
|
||||
$validSlides = $slides->filter(fn($s) => file_exists($s->localPath()))->values();
|
||||
|
||||
$scale = 'scale=1280:720:force_original_aspect_ratio=decrease,'
|
||||
. 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p';
|
||||
|
||||
// ── Optional burned-in lyrics (ASS via libass) ───────────────────────
|
||||
// When ?lyrics=1 and a ready lyrics file exists, build an .ass karaoke
|
||||
// track and weave it into the final video output. $voutLabel/$assArg/
|
||||
// $assTail let each render branch inject the burn uniformly.
|
||||
$voutLabel = '[vout]';
|
||||
$assArg = ''; // for -vf branches: ",ass=/tmp/x.ass"
|
||||
$assTail = ''; // for filter_complex branches: ";[vout]ass=/tmp/x.ass[vsub]"
|
||||
if ($lyrics) {
|
||||
$lyrTarget = $trackId ? $video->audioTracks()->find($trackId) : null;
|
||||
$lyrData = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $lyrTarget);
|
||||
if (is_array($lyrData) && ($lyrData['status'] ?? null) === 'ready') {
|
||||
$assPath = sys_get_temp_dir() . '/lyr_' . $video->id . $suffix . '.ass';
|
||||
if (\App\Support\LyricsAss::write($lyrData, $assPath)) {
|
||||
$assFilter = 'ass=' . $assPath;
|
||||
$assArg = ',' . $assFilter;
|
||||
$assTail = ';[vout]' . $assFilter . '[vsub]';
|
||||
$voutLabel = '[vsub]';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@unlink($progressFile);
|
||||
@unlink($pidFile);
|
||||
|
||||
@@ -1850,10 +2204,10 @@ class VideoController extends Controller
|
||||
$fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]";
|
||||
$fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]";
|
||||
|
||||
$fc = implode(';', $fcParts);
|
||||
$fc = implode(';', $fcParts) . $assTail;
|
||||
$cmd = "{$ffmpeg} -y{$inputs}"
|
||||
. ' -filter_complex ' . escapeshellarg($fc)
|
||||
. ' -map [vout] -map ' . $audioIdx . ':a'
|
||||
. ' -map ' . $voutLabel . ' -map ' . $audioIdx . ':a'
|
||||
. ' ' . $vFlags
|
||||
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
|
||||
|
||||
@@ -1881,10 +2235,10 @@ class VideoController extends Controller
|
||||
$prev = $outLabel;
|
||||
}
|
||||
|
||||
$fc = implode(';', array_merge($scaleFc, $xfadeFc));
|
||||
$fc = implode(';', array_merge($scaleFc, $xfadeFc)) . $assTail;
|
||||
$cmd = "{$ffmpeg} -y{$inputs}"
|
||||
. ' -filter_complex ' . escapeshellarg($fc)
|
||||
. ' -map [vout] -map ' . $n . ':a'
|
||||
. ' -map ' . $voutLabel . ' -map ' . $n . ':a'
|
||||
. ' ' . $vFlags
|
||||
. ' -c:a aac -b:a 192k -movflags +faststart -shortest';
|
||||
|
||||
@@ -1894,7 +2248,7 @@ class VideoController extends Controller
|
||||
. ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
|
||||
. ' -i ' . escapeshellarg($audioPath)
|
||||
. ' -map 0:v:0 -map 1:a:0 -shortest'
|
||||
. ' -vf ' . escapeshellarg($scale)
|
||||
. ' -vf ' . escapeshellarg($scale . $assArg)
|
||||
. ' ' . $vFlags
|
||||
. ' -c:a aac -b:a 192k -movflags +faststart';
|
||||
} else {
|
||||
@@ -1902,6 +2256,7 @@ class VideoController extends Controller
|
||||
. ' -f lavfi -i color=c=black:s=1280x720:r=1'
|
||||
. ' -i ' . escapeshellarg($audioPath)
|
||||
. ' -map 0:v:0 -map 1:a:0 -shortest'
|
||||
. ($assArg !== '' ? ' -vf ' . escapeshellarg('format=yuv420p' . $assArg) : '')
|
||||
. ' ' . $vFlags
|
||||
. ' -c:a aac -b:a 192k -movflags +faststart';
|
||||
}
|
||||
@@ -1934,9 +2289,10 @@ class VideoController extends Controller
|
||||
{
|
||||
$viz = request()->boolean('visualizer');
|
||||
$trackId = (int) request()->input('track', 0);
|
||||
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '');
|
||||
$lyrics = request()->boolean('lyrics');
|
||||
$suffix = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');
|
||||
|
||||
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId));
|
||||
$outPath = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
|
||||
$progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
|
||||
$pidFile = sys_get_temp_dir() . '/sl_pid_' . $video->id . $suffix . '.txt';
|
||||
|
||||
@@ -1955,7 +2311,7 @@ class VideoController extends Controller
|
||||
if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
|
||||
// Only the plain variant is tracked by the DB column; the visualizer variant
|
||||
// is served straight off disk (see download()).
|
||||
if (! $viz && ! $trackId && ! $video->slideshow_video_path) {
|
||||
if (! $viz && ! $trackId && ! $lyrics && ! $video->slideshow_video_path) {
|
||||
$video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
|
||||
}
|
||||
return response()->json(['percent' => 100, 'status' => 'ready']);
|
||||
@@ -2089,12 +2445,13 @@ class VideoController extends Controller
|
||||
* source files and kept LOCAL-only (never pushed to NAS):
|
||||
* {song-folder}/cache/video.mp4 / cache/video-viz.mp4
|
||||
*/
|
||||
private function slideshowRel(Video $video, bool $viz, int $trackId = 0): string
|
||||
private function slideshowRel(Video $video, bool $viz, int $trackId = 0, bool $lyrics = false): string
|
||||
{
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
return $this->nasVideoDir($video, $nas) . '/cache/video'
|
||||
. ($trackId ? '-t' . $trackId : '')
|
||||
. ($viz ? '-viz' : '') . '.mp4';
|
||||
. ($viz ? '-viz' : '')
|
||||
. ($lyrics ? '-lyr' : '') . '.mp4';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use App\Services\LlmLyricsService;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Bake heavy emoji decoration into the saved lyrics JSON using the active LLM.
|
||||
* Original words are preserved verbatim; emojis are layered on top (in-line +
|
||||
* trailing, multiple per line) per the admin's decoration prompt.
|
||||
*
|
||||
* Runs as its own job so a flaky LLM call can never delay or fail a successful
|
||||
* transcription. Safe to re-run — already-decorated lines are skipped, so a
|
||||
* second pass only fills in gaps.
|
||||
*/
|
||||
class DecorateLyricsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 600;
|
||||
public int $tries = 1;
|
||||
|
||||
/** Languages written without spaces between words (mirrors transcribe.py). */
|
||||
private const SPACELESS_LANGS = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
|
||||
|
||||
public function __construct(public int $videoId, public ?int $trackId = null)
|
||||
{
|
||||
$this->onQueue('video-processing');
|
||||
}
|
||||
|
||||
public function handle(LlmLyricsService $llm, NasSyncService $nas): void
|
||||
{
|
||||
// Two-layer toggle: the admin's per-pipeline switch (Lyrics Pipeline
|
||||
// page) gates this job, and the LLM-service-level switch (AI/LLM page)
|
||||
// gates the LLM call inside it. Either being OFF skips decoration.
|
||||
if (\App\Models\Setting::get('lyrics_llm_decorate', 'true') !== 'true') return;
|
||||
if (! $llm->decorateEnabled()) return;
|
||||
|
||||
$video = Video::find($this->videoId);
|
||||
if (! $video) return;
|
||||
|
||||
$track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
|
||||
if ($this->trackId && ! $track) return;
|
||||
|
||||
$data = $nas->getLyrics($video, $track);
|
||||
if (! is_array($data) || empty($data['lines'])) return;
|
||||
if (($data['status'] ?? null) !== 'ready') return;
|
||||
|
||||
// Decorate only lines that haven't been decorated yet — a re-run fills
|
||||
// gaps cheaply instead of re-stamping the whole song.
|
||||
$texts = [];
|
||||
$indices = [];
|
||||
foreach ($data['lines'] as $i => $ln) {
|
||||
if (! empty($ln['decorated'])) continue;
|
||||
$t = (string) ($ln['text'] ?? '');
|
||||
if (trim($t) === '') continue;
|
||||
$texts[] = $t;
|
||||
$indices[] = $i;
|
||||
}
|
||||
if (! $texts) return;
|
||||
|
||||
try {
|
||||
$decorated = $llm->decorateLines($texts);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('DecorateLyricsJob: LLM call failed: ' . $e->getMessage(), [
|
||||
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (! $decorated) return;
|
||||
|
||||
$applied = 0;
|
||||
foreach ($decorated as $localIdx => $newText) {
|
||||
if (! isset($indices[$localIdx])) continue;
|
||||
$globalIdx = $indices[$localIdx];
|
||||
$line = &$data['lines'][$globalIdx];
|
||||
|
||||
$line['text'] = $newText;
|
||||
$line['decorated'] = true;
|
||||
// The words array no longer matches the new (emoji-laced) text. We
|
||||
// redistribute the existing [start,end] window evenly across the
|
||||
// new tokens so the karaoke word-highlight still tracks the audio.
|
||||
// Tokens that are pure emoji get the same per-slot timing as words.
|
||||
$lang = (string) ($line['lang'] ?? ($data['language'] ?? 'en'));
|
||||
$line['words'] = $this->redistributeWords(
|
||||
(float) ($line['start'] ?? 0),
|
||||
(float) ($line['end'] ?? 0),
|
||||
$newText, $lang
|
||||
);
|
||||
unset($line);
|
||||
$applied++;
|
||||
}
|
||||
|
||||
if (! $applied) return;
|
||||
|
||||
$data['decorated_at'] = now()->toIso8601String();
|
||||
$nas->putLyrics($video, $track, $data);
|
||||
|
||||
Log::info('DecorateLyricsJob: done', [
|
||||
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||
'decorated' => $applied,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evenly distribute [start,end] across the line's tokens. Words for spaced
|
||||
* languages, characters for spaceless scripts (Thai/CJK/…). Used after
|
||||
* decoration so the karaoke word-highlight still tracks the audio.
|
||||
*/
|
||||
private function redistributeWords(float $start, float $end, string $text, string $lang): array
|
||||
{
|
||||
if ($text === '' || $end <= $start) return [];
|
||||
$spaceless = in_array($lang, self::SPACELESS_LANGS, true);
|
||||
$tokens = $spaceless
|
||||
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
|
||||
: preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY);
|
||||
$n = count($tokens ?: []);
|
||||
if ($n === 0) return [];
|
||||
$slot = ($end - $start) / $n;
|
||||
$out = [];
|
||||
foreach ($tokens as $i => $t) {
|
||||
$out[] = [
|
||||
'start' => round($start + $i * $slot, 3),
|
||||
'end' => round($start + ($i + 1) * $slot, 3),
|
||||
'text' => $t,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* Generate word-level synced lyrics for one audio track (the video's primary
|
||||
* audio when $trackId is null, otherwise a specific extra-language track).
|
||||
*
|
||||
* Output is a per-track lyrics JSON written through NasSyncService::putLyrics()
|
||||
* — source-of-truth, synced to NAS, never under cache/. Runs the GPU pipeline
|
||||
* exactly once; playback just loads the file afterwards.
|
||||
*/
|
||||
class GenerateLyricsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 3600;
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(public int $videoId, public ?int $trackId = null)
|
||||
{
|
||||
$this->onQueue('video-processing');
|
||||
}
|
||||
|
||||
/** Shared progress-file path (written by the pipeline, read by the status endpoint). */
|
||||
public static function progressPath(int $videoId, ?int $trackId): string
|
||||
{
|
||||
return storage_path('app/tmp/lyrics_prog_' . $videoId . '_' . ($trackId ?? 'primary') . '.json');
|
||||
}
|
||||
|
||||
/** Index of the GPU with the most free memory, or null if it can't be queried. */
|
||||
private function freestGpu(): ?int
|
||||
{
|
||||
$out = []; $code = 1;
|
||||
@exec('nvidia-smi --query-gpu=index,memory.free --format=csv,noheader,nounits 2>/dev/null', $out, $code);
|
||||
if ($code !== 0 || empty($out)) return null;
|
||||
$best = null; $bestFree = -1;
|
||||
foreach ($out as $line) {
|
||||
$parts = array_map('trim', explode(',', $line));
|
||||
if (count($parts) < 2) continue;
|
||||
$idx = (int) $parts[0]; $free = (int) $parts[1];
|
||||
if ($free > $bestFree) { $bestFree = $free; $best = $idx; }
|
||||
}
|
||||
return $best;
|
||||
}
|
||||
|
||||
public function handle(NasSyncService $nas): void
|
||||
{
|
||||
$video = Video::find($this->videoId);
|
||||
if (! $video) return;
|
||||
|
||||
$track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
|
||||
if ($this->trackId && ! $track) return;
|
||||
|
||||
$language = $track ? $track->language : $video->language;
|
||||
|
||||
// Mark as processing so the UI can show a generating state before the file lands.
|
||||
$nas->putLyrics($video, $track, [
|
||||
'version' => 1,
|
||||
'status' => 'processing',
|
||||
'language' => $language,
|
||||
]);
|
||||
|
||||
// Resolve a readable local copy of the audio (downloads from NAS if needed).
|
||||
$audioPath = $track ? $nas->ensureLocalTrackCopy($track) : $nas->ensureLocalCopy($video);
|
||||
$nasDownloaded = $audioPath && str_starts_with($audioPath, storage_path('app/nas_cache/'))
|
||||
? $audioPath : null;
|
||||
|
||||
if (! $audioPath || ! file_exists($audioPath)) {
|
||||
Log::error('GenerateLyricsJob: audio file unavailable', [
|
||||
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||
]);
|
||||
$nas->putLyrics($video, $track, [
|
||||
'version' => 1, 'status' => 'failed', 'language' => $language,
|
||||
'error' => 'audio file unavailable',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$python = base_path('ml/venv/bin/python');
|
||||
$script = base_path('ml/transcribe.py');
|
||||
$outTmp = storage_path('app/tmp/lyrics_' . $this->videoId . '_' . ($this->trackId ?? 'primary') . '.json');
|
||||
$progress = self::progressPath($this->videoId, $this->trackId);
|
||||
if (! is_dir(dirname($outTmp))) @mkdir(dirname($outTmp), 0775, true);
|
||||
@file_put_contents($progress, json_encode(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']));
|
||||
|
||||
// Model/weight downloads land in a www-data-writable cache, not root's $HOME.
|
||||
$cacheDir = base_path('ml/cache');
|
||||
if (! is_dir($cacheDir)) @mkdir($cacheDir, 0775, true);
|
||||
|
||||
// NOTE: we deliberately do NOT force --language. The stored label is just
|
||||
// metadata and is often wrong (e.g. a Tagalog song mislabeled "en"), which
|
||||
// made WhisperX transcribe the wrong language. Auto-detecting from the
|
||||
// isolated vocals is ground truth; the detected language is saved instead.
|
||||
$args = [$python, $script, '--audio', $audioPath, '--out', $outTmp, '--progress', $progress];
|
||||
|
||||
// Pipeline feature toggles (admin → Lyrics Pipeline). Defaults preserve
|
||||
// current behavior; admin can disable any sub-step that misbehaves.
|
||||
$useDescription = Setting::get('lyrics_use_description', 'true') === 'true';
|
||||
$vadEnabled = Setting::get('lyrics_vad_enabled', 'true') === 'true';
|
||||
$vocalGapFill = Setting::get('lyrics_vocal_region_gapfill', 'true') === 'true';
|
||||
$demucsEnabled = Setting::get('lyrics_demucs_enabled', 'false') === 'true';
|
||||
|
||||
if (! $vadEnabled) $args[] = '--no-vad';
|
||||
if (! $vocalGapFill) $args[] = '--no-vocal-gapfill';
|
||||
|
||||
// If the song's description contains the lyrics (typed by the uploader),
|
||||
// pass them to the pipeline so it ALIGNS those exact lines to the audio
|
||||
// instead of generating noisier text from scratch. Only for the primary
|
||||
// track — extra-language tracks have their own audio and aren't paired
|
||||
// with the description text.
|
||||
$userLyrFile = null;
|
||||
if ($useDescription && ! $this->trackId && $video->description) {
|
||||
// Prefer the deterministic regex parser. It strips emojis line-by-line
|
||||
// without touching the underlying words, so it preserves every
|
||||
// language a multilingual song contains (e.g. an English+Thai song
|
||||
// keeps both halves). The LLM cleaner is only a backup for cases
|
||||
// where the regex returns nothing — we've seen the LLM silently
|
||||
// drop whole verses that happened to be wrapped in emoji decoration.
|
||||
$descLines = \App\Support\LyricsDescriptionParser::extract($video->description);
|
||||
$source = 'regex';
|
||||
|
||||
if (empty($descLines)) {
|
||||
$llm = app(\App\Services\LlmLyricsService::class);
|
||||
if ($llm->cleanLyricsEnabled()) {
|
||||
try {
|
||||
$descLines = $llm->cleanDescription($video->description);
|
||||
$source = 'llm';
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('LLM clean failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($descLines) {
|
||||
$userLyrFile = storage_path('app/tmp/userlyr_' . $this->videoId . '.txt');
|
||||
file_put_contents($userLyrFile, implode("\n", $descLines));
|
||||
$args[] = '--user-lyrics';
|
||||
$args[] = $userLyrFile;
|
||||
// With description lyrics, Whisper is only providing word-timing
|
||||
// anchors — its actual transcription text is discarded by the
|
||||
// aligner. Vocal isolation (Demucs) helps transcription QUALITY
|
||||
// but is unnecessary for timing, AND the Demucs→Whisper CUDA-
|
||||
// context handoff has caused intermittent 50% futex deadlocks.
|
||||
// So we skip Demucs in this mode by default; the admin can
|
||||
// re-enable via the Lyrics Pipeline page.
|
||||
$args[] = '--no-demucs';
|
||||
Log::info('GenerateLyricsJob: using description lyrics', [
|
||||
'video_id' => $this->videoId, 'lines' => count($descLines),
|
||||
'source' => $source, 'demucs' => false,
|
||||
'vad' => $vadEnabled, 'vocal_gapfill' => $vocalGapFill,
|
||||
]);
|
||||
}
|
||||
}
|
||||
// Honor the admin Demucs toggle for tracks WITHOUT description lyrics
|
||||
// (where Whisper's transcription quality actually matters).
|
||||
if (! $userLyrFile && ! $demucsEnabled) {
|
||||
$args[] = '--no-demucs';
|
||||
}
|
||||
if (Setting::gpuUsable()) {
|
||||
// Run on the GPU with the most free VRAM so a busy card never forces an
|
||||
// out-of-memory fall back to slow CPU. With two cards this keeps every
|
||||
// generation on the GPU and fast.
|
||||
$args[] = '--gpu';
|
||||
$args[] = (string) ($this->freestGpu() ?? Setting::gpuDevice());
|
||||
}
|
||||
|
||||
Log::info('GenerateLyricsJob: starting', [
|
||||
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||
'language' => $language, 'gpu' => Setting::gpuUsable(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = Process::timeout($this->timeout)
|
||||
->env([
|
||||
'HOME' => $cacheDir,
|
||||
'XDG_CACHE_HOME' => $cacheDir,
|
||||
'HF_HOME' => $cacheDir . '/huggingface',
|
||||
'TORCH_HOME' => $cacheDir . '/torch',
|
||||
// Demucs runs as a subprocess BEFORE faster-whisper is imported.
|
||||
// If OpenMP gets initialised in the parent before that fork, the
|
||||
// post-fork CUDA/ctranslate2 stack can deadlock in futex_wait —
|
||||
// we've seen this hang lyrics jobs at 50% indefinitely. Forcing
|
||||
// single-threaded OpenMP in the parent eliminates the race
|
||||
// (faster-whisper sets its own thread count internally anyway).
|
||||
'OMP_NUM_THREADS' => '1',
|
||||
'MKL_NUM_THREADS' => '1',
|
||||
'OPENBLAS_NUM_THREADS' => '1',
|
||||
])
|
||||
->run($args);
|
||||
|
||||
if (! $result->successful() || ! file_exists($outTmp)) {
|
||||
throw new \RuntimeException('transcribe.py failed: ' . substr($result->errorOutput(), -2000));
|
||||
}
|
||||
|
||||
$data = json_decode((string) file_get_contents($outTmp), true);
|
||||
if (! is_array($data) || empty($data['lines'])) {
|
||||
throw new \RuntimeException('transcribe.py produced no lines');
|
||||
}
|
||||
|
||||
$data['status'] = 'ready';
|
||||
$data['generated_at'] = now()->toIso8601String();
|
||||
$data['language'] = $data['language'] ?? $language;
|
||||
|
||||
$nas->putLyrics($video, $track, $data);
|
||||
|
||||
// Decoration is independent of the audio pipeline — kick it off as
|
||||
// its own job so a flaky LLM call can't delay or fail a successful
|
||||
// transcription. Skips itself silently if the decorator is off.
|
||||
DecorateLyricsJob::dispatch($this->videoId, $this->trackId)
|
||||
->onConnection('database');
|
||||
|
||||
Log::info('GenerateLyricsJob: done', [
|
||||
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||
'lines' => count($data['lines']),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('GenerateLyricsJob failed: ' . $e->getMessage(), [
|
||||
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
||||
]);
|
||||
$nas->putLyrics($video, $track, [
|
||||
'version' => 1, 'status' => 'failed', 'language' => $language,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} finally {
|
||||
@unlink($outTmp);
|
||||
@unlink($progress);
|
||||
if ($userLyrFile) @unlink($userLyrFile);
|
||||
if ($nasDownloaded) @unlink($nasDownloaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-1
@@ -17,10 +17,12 @@ class Playlist extends Model
|
||||
'visibility',
|
||||
'is_default',
|
||||
'share_token',
|
||||
'view_count',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
'view_count' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
@@ -83,12 +85,87 @@ class Playlist extends Model
|
||||
return "{$minutes}m";
|
||||
}
|
||||
|
||||
// Get total views of all videos in playlist
|
||||
// Total of every viewer-session aggregated across the playlist's videos.
|
||||
// Kept for the analytics-style "video time watched" metric — for the
|
||||
// playlist's OWN view counter (cards, share link), use $playlist->view_count
|
||||
// which is incremented per-device by bumpViewIfNew().
|
||||
public function getTotalViewsAttribute()
|
||||
{
|
||||
return $this->videos()->get()->sum('view_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a playlist view if this viewer hasn't already counted within the
|
||||
* dedup window. Mirrors the video_views pattern: signed-in users dedup by
|
||||
* user_id, guests dedup by device fingerprint (preferred) or device cookie.
|
||||
*
|
||||
* Cheap and idempotent — runs as one EXISTS + (optionally) one INSERT +
|
||||
* one atomic increment, all on indexed columns. Called via
|
||||
* dispatchAfterResponse() so it never adds latency to the page render.
|
||||
*/
|
||||
public function bumpViewIfNew(\Illuminate\Http\Request $request): void
|
||||
{
|
||||
$userId = \Illuminate\Support\Facades\Auth::id();
|
||||
$did = $request->cookie('_did');
|
||||
$fp = $request->cookie('_fp');
|
||||
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
|
||||
|
||||
// No identifier at all? Skip silently — a unidentifiable request would
|
||||
// count on every refresh and inflate the counter.
|
||||
if (! $userId && ! $did && ! $fp) return;
|
||||
|
||||
$q = \DB::table('playlist_views')
|
||||
->where('playlist_id', $this->id)
|
||||
->where('viewed_at', '>', now()->subHour());
|
||||
|
||||
if ($userId) {
|
||||
$q->where('user_id', $userId);
|
||||
} else {
|
||||
$q->whereNull('user_id')->where(function ($w) use ($fp, $did) {
|
||||
if ($fp) $w->orWhere('device_hash', $fp);
|
||||
if ($did) $w->orWhere('device_id', $did);
|
||||
});
|
||||
}
|
||||
|
||||
if ($q->exists()) return;
|
||||
|
||||
$ip = $request->header('CF-Connecting-IP')
|
||||
?? $request->header('X-Real-IP')
|
||||
?? $request->ip();
|
||||
$geo = \App\Services\GeoIpService::lookup($ip);
|
||||
|
||||
\DB::table('playlist_views')->insert([
|
||||
'playlist_id' => $this->id,
|
||||
'user_id' => $userId,
|
||||
'device_id' => $did,
|
||||
'device_hash' => $fp,
|
||||
'ip_address' => $ip,
|
||||
'country' => $geo['country'] ?? null,
|
||||
'country_name' => $geo['country_name'] ?? null,
|
||||
'user_agent' => substr((string) $request->userAgent(), 0, 512),
|
||||
'viewed_at' => now(),
|
||||
]);
|
||||
|
||||
\DB::table('playlists')->where('id', $this->id)->increment('view_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute previous/next videos from an already-loaded ordered collection
|
||||
* (the playlist's videos in position order). Saves the 4+ extra queries
|
||||
* that getNextVideo() / getPreviousVideo() would each fire.
|
||||
*/
|
||||
public function neighborsFromCollection(\Illuminate\Support\Collection $orderedVideos, Video $current): array
|
||||
{
|
||||
$idx = $orderedVideos->search(fn ($v) => $v->id === $current->id);
|
||||
if ($idx === false) {
|
||||
return [null, $orderedVideos->first()];
|
||||
}
|
||||
return [
|
||||
$idx > 0 ? $orderedVideos[$idx - 1] : null,
|
||||
$idx < $orderedVideos->count() - 1 ? $orderedVideos[$idx + 1] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// Check if user owns this playlist
|
||||
public function isOwnedBy($user)
|
||||
{
|
||||
|
||||
@@ -75,6 +75,40 @@ class Video extends Model
|
||||
return $this->slides()->count() > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the slide list for a given audio track, applying the sharing rule:
|
||||
* 1. Slides explicitly owned by this track (audio_track_id = $trackId).
|
||||
* 2. Slides owned by the primary (audio_track_id IS NULL = song-wide / legacy).
|
||||
* 3. Slides owned by any other track (first track in id order).
|
||||
* 4. Empty (caller falls back to cover image).
|
||||
*
|
||||
* Pass `null` for the primary audio (the one stored on the videos row).
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<int,\App\Models\VideoSlide>
|
||||
*/
|
||||
public function slidesForTrack(?int $audioTrackId)
|
||||
{
|
||||
$all = $this->slides; // eager-loaded collection of every slide
|
||||
|
||||
if ($audioTrackId !== null) {
|
||||
$own = $all->where('audio_track_id', $audioTrackId)->values();
|
||||
if ($own->isNotEmpty()) return $own;
|
||||
}
|
||||
|
||||
// Primary / song-wide bucket (audio_track_id IS NULL).
|
||||
$primary = $all->whereNull('audio_track_id')->values();
|
||||
if ($primary->isNotEmpty()) return $primary;
|
||||
|
||||
// Borrow from the first track (by id) that has any.
|
||||
$byTrack = $all->whereNotNull('audio_track_id')->groupBy('audio_track_id');
|
||||
if ($byTrack->isNotEmpty()) {
|
||||
$firstTrackId = $byTrack->keys()->sort()->first();
|
||||
return $byTrack[$firstTrackId]->values();
|
||||
}
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
// ── Local filesystem path helpers ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,12 @@ class VideoAudioTrack extends Model
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
/** Slides explicitly owned by this track. Use Video::slidesForTrack() for fallback resolution. */
|
||||
public function slides()
|
||||
{
|
||||
return $this->hasMany(VideoSlide::class, 'audio_track_id')->orderBy('position');
|
||||
}
|
||||
|
||||
public function getStreamUrlAttribute(): string
|
||||
{
|
||||
return route('videos.audio-track', ['video' => $this->video_id, 'track' => $this->id]);
|
||||
|
||||
@@ -6,13 +6,18 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class VideoSlide extends Model
|
||||
{
|
||||
protected $fillable = ['video_id', 'filename', 'position'];
|
||||
protected $fillable = ['video_id', 'audio_track_id', 'filename', 'position'];
|
||||
|
||||
public function video()
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
public function audioTrack()
|
||||
{
|
||||
return $this->belongsTo(VideoAudioTrack::class, 'audio_track_id');
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return route('media.thumbnail', $this->filename);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Build an ASS (Advanced SubStation Alpha) subtitle file with word-level
|
||||
* karaoke timing from a lyrics JSON payload (the shape produced by
|
||||
* ml/transcribe.py). Burned into the downloadable mp4 via libass.
|
||||
*
|
||||
* Karaoke fill uses ASS \k tags: each word's \k duration (centiseconds) is the
|
||||
* time it waits before the highlight reaches it, so the cumulative \k before a
|
||||
* word equals its onset relative to the line start. PrimaryColour is the sung
|
||||
* (filled) colour, SecondaryColour the not-yet-sung colour.
|
||||
*/
|
||||
class LyricsAss
|
||||
{
|
||||
/** Render at the same canvas the slideshow uses. */
|
||||
private const W = 1280;
|
||||
private const H = 720;
|
||||
|
||||
/**
|
||||
* Write an .ass file for the given lyrics data. Returns true on success,
|
||||
* false when there are no usable timed lines (caller should skip burning).
|
||||
*/
|
||||
public static function write(array $lyrics, string $outPath): bool
|
||||
{
|
||||
$lines = $lyrics['lines'] ?? [];
|
||||
if (! is_array($lines) || ! $lines) return false;
|
||||
|
||||
$events = [];
|
||||
foreach ($lines as $ln) {
|
||||
$start = $ln['start'] ?? null;
|
||||
$end = $ln['end'] ?? null;
|
||||
if ($start === null || $end === null) continue;
|
||||
|
||||
$words = (isset($ln['words']) && is_array($ln['words']) && $ln['words']) ? $ln['words'] : null;
|
||||
$text = $words
|
||||
? self::karaokeText($words, (float) $start)
|
||||
: self::escape((string) ($ln['text'] ?? ''));
|
||||
if ($text === '') continue;
|
||||
|
||||
// Hold the line a touch past its last word for readability.
|
||||
// Fields per the Format line: Layer,Start,End,Style,Name,MarginL,MarginR,Effect,Text
|
||||
$events[] = 'Dialogue: 0,' . self::ts((float) $start) . ',' . self::ts((float) $end + 0.4)
|
||||
. ',Lyrics,,0,0,,' . $text;
|
||||
}
|
||||
if (! $events) return false;
|
||||
|
||||
$ass = self::header() . implode("\n", $events) . "\n";
|
||||
return file_put_contents($outPath, $ass) !== false;
|
||||
}
|
||||
|
||||
private static function karaokeText(array $words, float $lineStart): string
|
||||
{
|
||||
$out = '';
|
||||
$lead = (int) round((((float) $words[0]['start']) - $lineStart) * 100);
|
||||
if ($lead > 0) $out .= '{\k' . $lead . '}';
|
||||
|
||||
$n = count($words);
|
||||
foreach ($words as $i => $w) {
|
||||
$wStart = (float) $w['start'];
|
||||
$wEnd = (float) $w['end'];
|
||||
$dur = max(1, (int) round(($wEnd - $wStart) * 100));
|
||||
$out .= '{\k' . $dur . '}' . self::escape((string) $w['text']);
|
||||
|
||||
if ($i < $n - 1) {
|
||||
$gap = (int) round((((float) $words[$i + 1]['start']) - $wEnd) * 100);
|
||||
$out .= ($gap > 0 ? '{\k' . $gap . '}' : '') . ' ';
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** ASS timestamp: H:MM:SS.cc (centiseconds). */
|
||||
private static function ts(float $t): string
|
||||
{
|
||||
if ($t < 0) $t = 0;
|
||||
$cs = (int) round($t * 100);
|
||||
$h = intdiv($cs, 360000);
|
||||
$m = intdiv($cs % 360000, 6000);
|
||||
$s = intdiv($cs % 6000, 100);
|
||||
$c = $cs % 100;
|
||||
return sprintf('%d:%02d:%02d.%02d', $h, $m, $s, $c);
|
||||
}
|
||||
|
||||
private static function escape(string $s): string
|
||||
{
|
||||
// Strip ASS override delimiters and collapse newlines.
|
||||
$s = str_replace(['{', '}', '\\'], ['(', ')', '/'], $s);
|
||||
return str_replace(["\r\n", "\n", "\r"], ' ', $s);
|
||||
}
|
||||
|
||||
private static function header(): string
|
||||
{
|
||||
// Colours are &HAABBGGRR. Primary = sung (white), Secondary = unsung
|
||||
// (translucent grey), heavy outline + shadow for legibility over artwork.
|
||||
return "[Script Info]\n"
|
||||
. "ScriptType: v4.00+\n"
|
||||
. 'PlayResX: ' . self::W . "\n"
|
||||
. 'PlayResY: ' . self::H . "\n"
|
||||
. "WrapStyle: 0\n"
|
||||
. "ScaledBorderAndShadow: yes\n\n"
|
||||
. "[V4+ Styles]\n"
|
||||
. "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
|
||||
. "Style: Lyrics,Sans,54,&H00FFFFFF,&H64C8C8C8,&H00101010,&H80000000,-1,0,0,0,100,100,0,0,1,3,2,2,80,80,70,1\n\n"
|
||||
. "[Events]\n"
|
||||
. "Format: Layer, Start, End, Style, Name, MarginL, MarginR, Effect, Text\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Extract clean lyric lines from a video's free-text description.
|
||||
*
|
||||
* Many users paste the song's lyrics into the description with section markers,
|
||||
* emoji decorations, instrument tags, etc. When that's present we want to USE
|
||||
* those exact lines (and let the pipeline only do the *sync*, not the
|
||||
* transcription), because they're far more accurate than anything Whisper can
|
||||
* derive from sung audio.
|
||||
*
|
||||
* Returns an empty array when no usable lyric block is found.
|
||||
*/
|
||||
class LyricsDescriptionParser
|
||||
{
|
||||
/** Heuristic threshold: descriptions with fewer cleaned lines aren't worth aligning. */
|
||||
private const MIN_LYRIC_LINES = 4;
|
||||
|
||||
public static function extract(?string $desc): array
|
||||
{
|
||||
if (! $desc) return [];
|
||||
|
||||
// HTML descriptions use <br> for line breaks — convert those (and other
|
||||
// block-ending tags) into real newlines BEFORE stripping tags, otherwise
|
||||
// the entire body collapses into one long run-on line.
|
||||
// Heading tags (<h1>…<h6>) carry the song title — drop their content
|
||||
// entirely so the title never leaks into the lyric list.
|
||||
$text = preg_replace('/<\s*h[1-6][^>]*>.*?<\s*\/\s*h[1-6]\s*>/isu', "\n", $desc);
|
||||
$text = preg_replace('/<\s*br\s*\/?>/i', "\n", $text);
|
||||
$text = preg_replace('/<\s*\/\s*(p|div|li|tr|blockquote)\s*>/i', "\n", $text);
|
||||
$text = strip_tags($text);
|
||||
// Decode HTML entities ( , &, etc.) so the comparison later isn't fooled.
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = preg_replace('/\r\n|\r/u', "\n", $text);
|
||||
|
||||
// First pass: clean each line and flag section headers (Verse / Ritornello
|
||||
// / Bridge / etc.) so they can be dropped — those aren't sung.
|
||||
$cleaned = [];
|
||||
foreach (explode("\n", $text) as $line) {
|
||||
$line = self::cleanLine($line);
|
||||
if ($line === null) continue;
|
||||
$cleaned[] = ['text' => $line, 'header' => self::isSectionHeader($line)];
|
||||
}
|
||||
|
||||
// Title detection: if the first non-header line is immediately followed by
|
||||
// a section header (e.g. "Figlio Mio — Viaggio di Vita" then "Verso 1"),
|
||||
// that first line is the song title — drop it too.
|
||||
$firstIdx = null;
|
||||
foreach ($cleaned as $i => $c) {
|
||||
if (! $c['header']) { $firstIdx = $i; break; }
|
||||
}
|
||||
$dropTitle = false;
|
||||
if ($firstIdx !== null) {
|
||||
for ($j = $firstIdx + 1; $j < count($cleaned); $j++) {
|
||||
if ($cleaned[$j]['header']) { $dropTitle = true; break; }
|
||||
break; // first thing after is a real lyric line → not a title block
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($cleaned as $i => $c) {
|
||||
if ($c['header']) continue;
|
||||
if ($dropTitle && $i === $firstIdx) continue;
|
||||
$out[] = $c['text'];
|
||||
}
|
||||
|
||||
// Avoid mistakenly aligning non-lyric descriptions (a credit line, a URL,
|
||||
// etc.). Require at least a handful of plausible lyric lines.
|
||||
if (count($out) < self::MIN_LYRIC_LINES) return [];
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a line is a section marker (Verse / Chorus / Bridge / Outro /
|
||||
* their many translations) rather than a sung lyric. Matches the WHOLE line
|
||||
* so a real lyric containing one of these words isn't mistakenly dropped.
|
||||
*/
|
||||
private static function isSectionHeader(string $line): bool
|
||||
{
|
||||
$t = mb_strtolower(trim($line));
|
||||
if ($t === '') return false;
|
||||
|
||||
$roots = [
|
||||
'intro', 'outro', 'interlude', 'instrumental',
|
||||
'verse', 'verso', 'verset', 'couplet', 'estrofa', 'strofa',
|
||||
'chorus', 'ritornello', 'refrain', 'refrão', 'refrao', 'coro', 'estribillo',
|
||||
'pre[\s\-]?chorus', 'pre[\s\-]?ritornello', 'pre[\s\-]?coro',
|
||||
'pre[\s\-]?refrain', 'pre[\s\-]?refrão', 'pré[\s\-]?refrain',
|
||||
'bridge', 'ponte', 'puente', 'pont', 'brücke', 'brucke',
|
||||
'hook', 'drop', 'breakdown', 'tag', 'vamp', 'coda', 'reprise',
|
||||
// CJK / Thai / Arabic / Korean
|
||||
'サビ', 'コーラス', 'バース', 'ブリッジ', 'イントロ', 'アウトロ', 'フック', '間奏',
|
||||
'ท่อน', 'คอรัส', 'ฮุก', 'บริดจ์', 'อินโทร', 'เอาท์โทร',
|
||||
'前奏', '副歌', '桥段', '主歌', '尾奏',
|
||||
'كورس', 'بريدج', 'كوبليه',
|
||||
'후렴', '브릿지', '인트로', '아웃트로', '훅',
|
||||
];
|
||||
$rootRe = implode('|', $roots);
|
||||
// Optional trailing number, "final/finale/reprise", roman numerals.
|
||||
$pattern = '/^(?:' . $rootRe . ')[\s\d:\-—\.]*(?:final|finale|reprise|ii|iii|iv|v|vi|2|3|4|5)?\s*$/iu';
|
||||
return (bool) preg_match($pattern, $t);
|
||||
}
|
||||
|
||||
/** Returns the cleaned line, or null if it should be discarded. */
|
||||
private static function cleanLine(string $line): ?string
|
||||
{
|
||||
$line = trim($line);
|
||||
if ($line === '') return null;
|
||||
|
||||
// Strip markdown emphasis (* _ ~) and leading list bullets / quote markers.
|
||||
$line = preg_replace('/^[\s>\-\*•♪♫·]+/u', '', $line);
|
||||
$line = preg_replace('/[\*_~`]+/u', '', $line);
|
||||
|
||||
// Drop instrument / section annotations inside Japanese-style brackets:
|
||||
// 【 箏・尺八・篠笛・優しい歌声 】 — these aren't lyrics.
|
||||
$line = preg_replace('/【[^】]*】/u', '', $line);
|
||||
$line = preg_replace('/〔[^〕]*〕/u', '', $line);
|
||||
$line = preg_replace('/\[\[[^\]]*\]\]/u', '', $line);
|
||||
|
||||
// Strip emoji / pictographic symbols and the invisible glue that often
|
||||
// sticks to them (variation selectors, ZWJ) so nothing leaves behind a
|
||||
// bare diacritic when the visible emoji is removed.
|
||||
$line = preg_replace(
|
||||
'/[\x{1F000}-\x{1FFFF}\x{2600}-\x{27BF}\x{2B00}-\x{2BFF}\x{0F3A}-\x{0F3D}\x{FE00}-\x{FE0F}\x{200B}-\x{200F}\x{2060}]/u',
|
||||
'', $line
|
||||
);
|
||||
|
||||
// Collapse internal whitespace.
|
||||
$line = preg_replace('/\s+/u', ' ', $line);
|
||||
$line = trim($line);
|
||||
|
||||
if ($line === '') return null;
|
||||
|
||||
// Must contain at least one letter (Unicode), and at least 3 characters
|
||||
// after stripping — discards "🌸 平穏 🌸" (header) and "──" separators.
|
||||
if (! preg_match('/\p{L}/u', $line)) return null;
|
||||
if (mb_strlen($line) < 3) return null;
|
||||
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user