Add multi-language audio tracks and self-hosted flag-icons

Introduce per-video language support and multiple audio tracks
(VideoAudioTrack model + migrations for language, description, title),
a reusable language-select component, and a track-editor form. Bundle
the self-hosted flag-icons v7.2.3 library and a NAS auto-sync command.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-05-22 21:32:52 +03:00
1 parent f8d13457fa
commit 66fd78c10f
314 files changed
+16507 -3172

No files matched your search

+133 -13
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\Setting;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use Illuminate\Support\Facades\Log;
class NasSyncService
@@ -13,8 +14,29 @@ class NasSyncService
public function isEnabled(): bool
{
return Setting::get('nas_sync_enabled', 'false') === 'true'
$configured = Setting::get('nas_sync_enabled', 'false') === 'true'
&& ! empty($this->cfg()['host']);
if (! $configured) return false;
// Cache reachability for 2 minutes so a down NAS doesn't block every request.
// When NAS comes back online the cache naturally expires and uploads resume.
return \Illuminate\Support\Facades\Cache::remember('nas_reachable', 120, function () {
$host = $this->cfg()['host'] ?? null;
if (! $host) return false;
$sock = @fsockopen($host, 445, $errno, $errstr, 2);
if ($sock) { fclose($sock); return true; }
return false;
});
}
/**
* Flush the cached reachability flag so the next isEnabled() call re-tests.
* Call this after the admin toggles the NAS setting or from tests.
*/
public function flushReachabilityCache(): void
{
\Illuminate\Support\Facades\Cache::forget('nas_reachable');
}
// ── Slug helpers ──────────────────────────────────────────────────────────
@@ -136,7 +158,13 @@ class NasSyncService
{
// Already organised — derive from path
if (str_starts_with($video->path, 'users/')) {
return dirname(storage_path('app/' . $video->path));
$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);
}
return $dir;
}
$video->loadMissing('user');
@@ -288,6 +316,22 @@ class NasSyncService
// 3. Download from NAS
if (! $this->isEnabled()) return null;
// When the video's path is a full NAS-relative path (starts with 'users/'), use it
// directly — this handles promoted tracks whose path is e.g. 'users/…/tracks/14.mp3'
// rather than the standard 'users/…/title-slug.mp3' layout.
if (str_starts_with($video->path, 'users/')) {
$this->ensureLocalAsset($regularPath, $video->path);
if (file_exists($regularPath)) {
Log::info('NAS: video cached locally for streaming', ['video_id' => $video->id]);
return $regularPath;
}
Log::warning('NAS: failed to cache video for streaming', [
'video_id' => $video->id,
'nas_path' => $video->path,
]);
return null;
}
$cacheDir = dirname($cachePath);
if (! is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
@@ -318,6 +362,63 @@ class NasSyncService
return $cachePath;
}
/**
* Resolve a secondary audio track to a readable local path.
*
* Mirrors ensureLocalCopy() for the primary file so a demoted/secondary track
* (e.g. the old primary after a language swap) streams with the same robustness:
* 1. Regular local storage (file still at the track's stored path)
* 2. NAS stream cache (downloaded earlier, e.g. while it was the primary)
* 3. Download from NAS (when NAS is reachable)
*
* Returns the local absolute path, or null if the file cannot be located.
*/
public function ensureLocalTrackCopy(VideoAudioTrack $track): ?string
{
// 1. Regular local storage
$regularPath = storage_path('app/' . $track->path);
if (file_exists($regularPath)) return $regularPath;
// 2. Existing NAS stream cache (keyed by filename, shared with the primary path)
$cachePath = storage_path('app/nas_cache/videos/' . $track->filename);
if (file_exists($cachePath)) {
Log::info('NAS: audio track served from stream cache', [
'track_id' => $track->id,
'video_id' => $track->video_id,
'cache' => $cachePath,
]);
return $cachePath;
}
// 3. Download from NAS
if (! $this->isEnabled()) {
Log::warning('NAS: audio track unavailable (NAS disabled, no local/cache copy)', [
'track_id' => $track->id,
'video_id' => $track->video_id,
'path' => $track->path,
]);
return null;
}
if (str_starts_with($track->path, 'users/')) {
$this->ensureLocalAsset($regularPath, $track->path);
if (file_exists($regularPath)) {
Log::info('NAS: audio track cached locally for streaming', [
'track_id' => $track->id,
'video_id' => $track->video_id,
]);
return $regularPath;
}
}
Log::warning('NAS: failed to resolve audio track for streaming', [
'track_id' => $track->id,
'video_id' => $track->video_id,
'path' => $track->path,
]);
return null;
}
/**
* Ensure a local asset file exists, downloading it from the NAS if missing.
* Used by MediaController to serve thumbnails, avatars, and banners with a NAS fallback.
@@ -512,7 +613,11 @@ class NasSyncService
// ── Video file ───────────────────────────────────────────────────
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
if (file_exists($tempVideoPath)) {
$this->putFile($tempVideoPath, "{$dir}/{$fileSlug}.{$ext}");
$ok = $this->putFile($tempVideoPath, "{$dir}/{$fileSlug}.{$ext}");
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}");
}
@unlink($tempVideoPath);
$updates['path'] = "{$dir}/{$fileSlug}.{$ext}";
$updates['filename'] = "{$fileSlug}.{$ext}";
@@ -527,12 +632,13 @@ class NasSyncService
if (! $absPath || ! file_exists($absPath)) continue;
$slideExt = pathinfo($absPath, PATHINFO_EXTENSION) ?: 'jpg';
$nasSlideFile = "{$dir}/slides/{$slide->position}.{$slideExt}";
$this->putFile($absPath, $nasSlideFile);
@unlink($absPath);
$slideRelPath = "{$dir}/slides/{$slide->position}.{$slideExt}";
$slide->update(['filename' => $slideRelPath]);
if ($firstSlideNasPath === null) {
$firstSlideNasPath = $slideRelPath;
if ($this->putFile($absPath, $nasSlideFile)) {
@unlink($absPath);
$slideRelPath = $nasSlideFile;
$slide->update(['filename' => $slideRelPath]);
if ($firstSlideNasPath === null) {
$firstSlideNasPath = $slideRelPath;
}
}
}
if ($firstSlideNasPath !== null) {
@@ -542,9 +648,10 @@ class NasSyncService
// ── Standalone thumbnail (video uploads without slides) ──────────
if ($tempThumbPath && file_exists($tempThumbPath) && $firstSlideNasPath === null) {
$this->putFile($tempThumbPath, "{$dir}/thumb.webp");
@unlink($tempThumbPath);
$updates['thumbnail'] = "{$dir}/thumb.webp";
if ($this->putFile($tempThumbPath, "{$dir}/thumb.webp")) {
@unlink($tempThumbPath);
$updates['thumbnail'] = "{$dir}/thumb.webp";
}
}
// ── meta.json ────────────────────────────────────────────────────
@@ -929,12 +1036,17 @@ class NasSyncService
{
if (! str_starts_with($video->path, 'users/')) return;
$video->loadMissing(['user', 'slides']);
$video->loadMissing(['user', 'slides', 'audioTracks']);
$userSlug = $this->userSlug($video->user);
$base = "users/{$userSlug}/videos";
// Current folder derived from the stored path (title not yet reflected in folder name)
$currentDir = dirname($video->path); // "users/{slug}/videos/{old-slug}"
// If the primary file lives inside a 'tracks/' subfolder (promoted track),
// go up one extra level to reach the video root directory.
if (basename($currentDir) === 'tracks') {
$currentDir = dirname($currentDir);
}
// Desired folder based on the (already-saved) new title
$newDir = $this->findFreeVideoDir($base, $this->titleSlug($video->title), $video->id);
@@ -973,6 +1085,14 @@ class NasSyncService
}
}
// Secondary language tracks (incl. the demoted old primary after a swap) also
// live under the renamed directory — update their stored paths too.
foreach ($video->audioTracks as $track) {
if ($track->path && str_starts_with($track->path, $oldPrefix)) {
$track->update(['path' => $newPrefix . substr($track->path, strlen($oldPrefix))]);
}
}
Log::info('NAS: video dir renamed', [
'video_id' => $video->id,
'from' => $currentDir,