warn(' ! no music dirs configured (set MUSIC_DIR_1 in .env)'); } $ffprobe = (string) config('rig.ffprobe', '/usr/bin/ffprobe'); if (!is_executable($ffprobe)) { $this->error(" ✗ ffprobe not found at {$ffprobe} (install ffmpeg or set FFPROBE_BIN)"); return self::FAILURE; } $this->line('── Indexing audio roots ──'); foreach ($roots as $r) { $this->line(is_dir($r) ? " + {$r}" : " ! {$r} (missing — skipped)"); } $tracks = []; foreach ($roots as $root) { if (!is_dir($root)) continue; $finder = (new Finder()) ->files() ->in($root) ->name('/\.(' . implode('|', self::EXTS) . ')$/i') ->ignoreUnreadableDirs() ->sortByName(); foreach ($finder as $file) { $name = $file->getFilename(); $base = $file->getFilenameWithoutExtension(); // Skip yt-dlp intermediates (e.g. foo.f140.m4a, foo.temp.opus). if (preg_match('/\.(?:f\d+|temp|part)$/i', $base)) continue; $path = $file->getRealPath(); $meta = $this->probe($ffprobe, $path); $tracks[] = [ 'title' => $this->cleanTitle($base), 'file' => 'file://' . $this->urlEncodePath($path), 'durationSec' => isset($meta['durationSec']) ? (int) $meta['durationSec'] : null, ]; } } $doc = [ 'syncedAt' => gmdate('Y-m-d\TH:i:s\Z'), 'sourceDirs' => $roots, 'trackCount' => count($tracks), 'tracks' => $tracks, ]; $path = rtrim(config('rig.storage_data'), '/') . '/playlist.json'; @mkdir(dirname($path), 0775, true); file_put_contents( $path, json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ); $this->line(''); $this->info(" ✓ wrote {$path} ({$doc['trackCount']} tracks)"); $this->line(' → refresh the Music Daemon browser source in OBS to reload the queue.'); return self::SUCCESS; } private function probe(string $ffprobe, string $path): array { try { $proc = new Process([ $ffprobe, '-v', 'error', '-show_entries', 'format=duration:format_tags=title,artist', '-of', 'json', $path, ]); $proc->setTimeout(10); $proc->run(); if (!$proc->isSuccessful()) return []; $json = json_decode($proc->getOutput(), true) ?: []; $fmt = $json['format'] ?? []; return [ 'durationSec' => isset($fmt['duration']) ? (float) $fmt['duration'] : null, ]; } catch (Throwable) { return []; } } /** * Mirrors scripts/playlist.sh's clean_title(): * - drop "| ..." (or fullwidth |) tails (genre/label noise) * - strip trailing YouTube IDs in brackets like "[-XxZTgMWKV0]" * - strip "[NCS Release]" / "(NCS10 Release)" suffixes */ private function cleanTitle(string $t): string { $t = preg_replace('/\s*[||].*$/u', '', $t) ?? $t; $t = preg_replace('/\s*\[[A-Za-z0-9_-]{11}\]\s*$/', '', $t) ?? $t; $t = preg_replace( '/\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$/i', '', $t ) ?? $t; return trim($t); } private function urlEncodePath(string $path): string { // Same as Python's urllib.parse.quote(..., safe='/') — keep separators, encode the rest. return implode('/', array_map('rawurlencode', explode('/', $path))); } }