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:
1 parent
f8d13457fa
commit
66fd78c10f
314 files changed
+16507
-3172
No files matched your search
+133
-13
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user