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

@@ -1305,6 +1305,7 @@ class SuperAdminController extends Controller
}
\DB::table('users')->update(['avatar' => null, 'banner' => null]);
Setting::set('nas_sync_enabled', 'false');
app(\App\Services\NasSyncService::class)->flushReachabilityCache();
AuditLog::record('admin.nas_disabled_fresh');
return response()->json(['ok' => true]);
}
+23 -11
View File
@@ -230,9 +230,10 @@ class UserController extends Controller
$videos = $baseQuery->where('is_shorts', false)->get();
$shorts = (clone $allQuery)->where('is_shorts', true)->latest()->get();
$withFirstVideo = fn($q) => $q->orderBy('playlist_videos.position')->limit(1);
$playlists = $isOwner
? $user->playlists()->orderBy('created_at', 'desc')->get()
: $user->playlists()->public()->where('is_default', false)->orderBy('created_at', 'desc')->get();
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get()
: $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get();
$totalViews = \DB::table('video_views')
->whereIn('video_id', $user->videos()->pluck('id'))
@@ -400,34 +401,45 @@ class UserController extends Controller
]);
}
public function notificationCount()
{
return response()->json(['unread_count' => Auth::user()->unreadNotifications()->count()]);
}
public function fetchNotifications()
{
$user = Auth::user();
$rawNotifications = $user->notifications()->latest()->take(50)->get();
// Bulk-fetch current video state for all notification types
// Bulk-fetch video state only for video-linked notifications
$videoIds = $rawNotifications
->pluck('data.video_id')
->filter()
->unique()
->values();
$videos = \App\Models\Video::whereIn('id', $videoIds)
->whereIn('visibility', ['public', 'unlisted'])
->get(['id', 'thumbnail', 'visibility'])
->keyBy('id');
$videos = $videoIds->isNotEmpty()
? \App\Models\Video::whereIn('id', $videoIds)
->whereIn('visibility', ['public', 'unlisted'])
->get(['id', 'thumbnail', 'visibility'])
->keyBy('id')
: collect();
$notifications = $rawNotifications
->filter(function ($n) use ($videos) {
$videoId = $n->data['video_id'] ?? null;
return $videoId && $videos->has($videoId);
// Non-video notifications (subscriber, like, post, new_user) always pass
if (!$videoId) return true;
// Video notifications only if the video is still visible
return $videos->has($videoId);
})
->take(30)
->map(function ($n) use ($videos) {
$data = $n->data;
$video = $videos->get($data['video_id']);
$data['video_thumbnail'] = $video?->thumbnail ?? null;
$data = $n->data;
if (!empty($data['video_id'])) {
$data['video_thumbnail'] = $videos->get($data['video_id'])?->thumbnail ?? null;
}
return [
'id' => $n->id,
'read' => ! is_null($n->read_at),
+531 -94
View File
@@ -11,6 +11,7 @@ use App\Models\AuditLog;
use App\Models\Playlist;
use App\Models\Setting;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use App\Models\VideoSlide;
use App\Services\GeoIpService;
use FFMpeg\FFMpeg;
@@ -25,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']);
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack']);
}
public function index()
@@ -36,7 +37,7 @@ class VideoController extends Controller
if ($filter === 'playlists') {
$playlists = Playlist::where('visibility', 'public')
->withCount('videos')
->with('user')
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
->latest()
->limit(60)
->get();
@@ -75,7 +76,9 @@ class VideoController extends Controller
// ── All (home) — mixed chronological feed ─────────────────
$videos = Video::public()->latest()->limit(60)->get();
$playlistQuery = Playlist::withCount('videos')->with('user')->latest()->limit(40);
$playlistQuery = Playlist::withCount('videos')
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
->latest()->limit(40);
$playlistQuery->where('visibility', 'public');
$playlists = $playlistQuery->where('is_default', false)->get()
@@ -112,7 +115,7 @@ class VideoController extends Controller
$playlistQuery = Playlist::where('name', 'like', "%{$query}%")
->withCount('videos')
->with('user')
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
->latest()
->limit(12);
@@ -139,16 +142,25 @@ class VideoController extends Controller
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'video' => $isAudioUpload
? 'required|file|max:512000'
: 'required|file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv|max:512000',
? 'required|file'
: 'required|file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv',
'thumbnail' => $isAudioUpload
? 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480'
: 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480',
'slides' => $isAudioUpload ? 'required|array|min:1' : 'nullable',
'slides.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
'visibility' => 'nullable|in:public,unlisted,private',
'type' => 'nullable|in:generic,music,match',
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
'visibility' => 'nullable|in:public,unlisted,private',
'type' => 'nullable|in:generic,music,match',
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
'primary_language' => 'nullable|string|max:10',
'extra_track_files' => 'nullable|array',
'extra_track_files.*' => 'file',
'extra_track_languages' => 'nullable|array',
'extra_track_languages.*' => 'nullable|string|max:10',
'extra_track_titles' => 'nullable|array',
'extra_track_titles.*' => 'nullable|string|max:255',
'extra_track_descriptions' => 'nullable|array',
'extra_track_descriptions.*'=> 'nullable|string',
]);
$videoFile = $request->file('video');
@@ -243,6 +255,7 @@ class VideoController extends Controller
'type' => $isAudioUpload ? 'music' : ($request->type ?? 'generic'),
'download_access' => $request->input('download_access', 'disabled'),
'share_token' => Str::random(32),
'language' => $request->input('primary_language') ?: null,
]);
// Save individual slide records for audio uploads with multiple images
@@ -256,6 +269,8 @@ class VideoController extends Controller
$nas = app(\App\Services\NasSyncService::class);
$nasUploadSucceeded = false;
if ($nas->isEnabled()) {
// ── NAS-primary: push directly to NAS, delete local temp files ──
try {
@@ -278,10 +293,14 @@ class VideoController extends Controller
$slideAbsPaths
);
$video->refresh();
$nasUploadSucceeded = true;
} catch (\Throwable $e) {
\Log::error('uploadDirectToNas failed: ' . $e->getMessage());
\Log::error('uploadDirectToNas failed (falling back to local): ' . $e->getMessage());
// NAS went down mid-upload — organise the surviving local files below
}
}
if ($nasUploadSucceeded) {
// For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local)
if (! $isAudioUpload) {
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
@@ -306,6 +325,61 @@ class VideoController extends Controller
}
}
// ── Extra language audio tracks ───────────────────────────────────────
if ($isAudioUpload && $request->hasFile('extra_track_files')) {
$nas = app(\App\Services\NasSyncService::class);
$trackFiles = $request->file('extra_track_files');
$trackLangs = $request->input('extra_track_languages', []);
$trackTitles = $request->input('extra_track_titles', []);
$trackDescs = $request->input('extra_track_descriptions', []);
foreach ($trackFiles as $i => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue;
$lang = $trackLangs[$i] ?? 'en';
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
$title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
$desc = !empty($trackDescs[$i]) ? $trackDescs[$i] : null;
// Create a placeholder record to get the DB ID for the filename
$track = VideoAudioTrack::create([
'video_id' => $video->id,
'language' => $lang,
'label' => strtoupper($lang),
'title' => $title,
'description' => $desc,
'path' => '__pending__',
'filename' => '__pending__',
]);
if ($nas->isEnabled()) {
try {
$userSlug = $nas->userSlug($video->user);
$videoDir = $nas->resolveVideoDir($video);
$nasDir = "{$videoDir}/tracks";
$nas->mkdirp($nasDir);
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$nasDir}/{$track->id}.{$ext}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update([
'path' => $nasPath,
'filename' => "{$track->id}.{$ext}",
]);
} catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed: " . $e->getMessage());
// Fall back to local storage
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
} else {
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
}
}
$video->load('user');
$userEmail = Auth::user()->email;
@@ -479,18 +553,35 @@ class VideoController extends Controller
? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()
: [];
$allLangData = \App\Data\Languages::all();
$audioTracks = $video->audioTracks->map(fn ($t) => [
'id' => $t->id,
'language' => $t->language,
'label' => $t->label,
'flag' => $allLangData[$t->language]['flag'] ?? null,
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp,
'title' => $t->title ?? '',
'description' => $t->description ?? '',
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
])->values()->all();
return response()->json([
'id' => $video->id,
'key' => $video->getRouteKey(),
'type' => $video->type,
'has_hls' => (bool) $video->has_hls,
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
'stream_url' => route('videos.stream', $video),
'cover_url' => $coverUrl,
'slides' => $slides,
'title' => $video->title,
'author' => $video->user->name ?? '',
'duration' => $video->duration,
'id' => $video->id,
'key' => $video->getRouteKey(),
'type' => $video->type,
'has_hls' => (bool) $video->has_hls,
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
'cover_url' => $coverUrl,
'slides' => $slides,
'title' => $video->title,
'author' => $video->user->name ?? '',
'duration' => $video->duration,
'description' => $video->description ?? '',
'download_url' => route('videos.downloadMp3', $video),
'language' => $video->language,
'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null,
'audio_tracks' => $audioTracks,
]);
}
@@ -522,6 +613,15 @@ class VideoController extends Controller
'url' => $s->url,
])->values();
$audioTracks = $video->audioTracks->map(fn ($t) => [
'id' => $t->id,
'language' => $t->language,
'label' => $t->label,
'title' => $t->title ?? '',
'description' => $t->description ?? '',
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]),
])->values();
return response()->json([
'success' => true,
'video' => [
@@ -535,6 +635,8 @@ class VideoController extends Controller
'download_access' => $video->download_access,
'is_audio' => $this->isAudioOnlyFile($video),
'slides' => $slides,
'language' => $video->language,
'audio_tracks' => $audioTracks,
],
]);
}
@@ -546,45 +648,73 @@ class VideoController extends Controller
}
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'thumbnail' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480',
'visibility' => 'nullable|in:public,unlisted,private',
'type' => 'nullable|in:generic,music,match',
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
'slides_add' => 'nullable|array',
'slides_add.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'thumbnail' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480',
'visibility' => 'nullable|in:public,unlisted,private',
'type' => 'nullable|in:generic,music,match',
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
'slides_add' => 'nullable|array',
'slides_add.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
'primary_language' => 'nullable|string|max:10',
'extra_track_files' => 'nullable|array',
'extra_track_files.*' => 'file',
'extra_track_languages' => 'nullable|array',
'extra_track_languages.*' => 'nullable|string|max:10',
'extra_track_titles' => 'nullable|array',
'extra_track_titles.*'=> 'nullable|string|max:255',
'track_title_updates' => 'nullable|array',
'track_title_updates.*' => 'nullable|string|max:255',
'track_description_updates' => 'nullable|array',
'track_description_updates.*' => 'nullable|string',
'track_language_updates' => 'nullable|array',
'track_language_updates.*' => 'nullable|string|max:10',
'track_file_updates' => 'nullable|array',
'track_file_updates.*' => 'nullable|file',
'promote_track_id' => 'nullable|integer',
'delete_track_ids' => 'nullable|array',
'delete_track_ids.*' => 'integer',
]);
$oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type']);
$data['download_access'] = $request->input('download_access', 'disabled');
if ($request->has('primary_language')) {
$data['language'] = $request->input('primary_language') ?: null;
}
if ($request->hasFile('thumbnail')) {
if ($video->thumbnail) {
Storage::delete($video->thumbnailStorageKey());
}
if (str_starts_with($video->path, 'users/')) {
// New-format video: store thumbnail in the video's local dir
$nas = app(\App\Services\NasSyncService::class);
$nas = app(\App\Services\NasSyncService::class);
$ext = strtolower($request->file('thumbnail')->getClientOriginalExtension() ?: 'jpg');
if ($nas->isEnabled()) {
// Push directly to NAS — use video root dir (handles promoted-track paths too)
$nasDir = $this->nasVideoDir($video, $nas);
$tempPath = $request->file('thumbnail')->storeAs('public/tmp', "thumb_{$video->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nas->mkdirp($nasDir);
if ($nas->putFile($tempAbs, "{$nasDir}/thumb.{$ext}")) {
@unlink($tempAbs);
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
} else {
// NAS push failed — fall back to local (auto-sync will retry)
$localDir = $nas->localVideoDir($video);
@mkdir($localDir, 0755, true);
rename($tempAbs, "{$localDir}/thumb.{$ext}");
$userSlug = $nas->userSlug($video->user);
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
}
} else {
// NAS disabled — keep on local disk
$localDir = $nas->localVideoDir($video);
@mkdir($localDir, 0755, true);
$ext = $request->file('thumbnail')->getClientOriginalExtension();
$request->file('thumbnail')->move($localDir, "thumb.{$ext}");
$userSlug = $nas->userSlug($video->user);
$data['thumbnail'] = 'users/' . $userSlug . '/videos/' . basename($localDir) . "/thumb.{$ext}";
} else {
// Legacy video: push thumbnail directly to NAS
$nas = app(\App\Services\NasSyncService::class);
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
$request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
$tempAbs = storage_path('app/public/thumbnails/' . $thumbFilename);
$nasDir = $nas->resolveVideoDir($video);
$ext = pathinfo($thumbFilename, PATHINFO_EXTENSION);
$nas->mkdirp($nasDir);
$nas->putFile($tempAbs, "{$nasDir}/thumb.{$ext}");
@unlink($tempAbs);
$nasDir = $this->nasVideoDir($video, $nas);
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
}
}
@@ -601,6 +731,10 @@ class VideoController extends Controller
// Delete slides not in the kept list
$video->slides()->whereNotIn('id', $keptOrder)->each(function ($slide) use (&$slidesChanged) {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled() && str_starts_with($slide->filename, 'users/')) {
try { $nas->deleteFile($slide->filename); } catch (\Throwable $e) {}
}
Storage::delete($slide->storageKey());
$slide->delete();
$slidesChanged = true;
@@ -615,36 +749,48 @@ class VideoController extends Controller
// Add new slides
if ($request->hasFile('slides_add')) {
$nextPos = count($keptOrder);
$isNewFormat = str_starts_with($video->path, 'users/');
$nextPos = count($keptOrder);
$nasForSlides = app(\App\Services\NasSyncService::class);
if ($isNewFormat) {
$localDir = $nasForSlides->localVideoDir($video);
$nasEnabled = $nasForSlides->isEnabled();
// Derive NAS dir without scanning when possible
$nasDir = $nasEnabled ? $this->nasVideoDir($video, $nasForSlides) : null;
// Local dir used as fallback or when NAS is off
$localDir = $nasForSlides->localVideoDir($video);
$userSlug = $nasForSlides->userSlug($video->user);
$relBase = $this->nasVideoDir($video, $nasForSlides);
if ($nasEnabled) {
$nasForSlides->mkdirp("{$nasDir}/slides");
} else {
@mkdir("{$localDir}/slides", 0755, true);
$userSlug = $nasForSlides->userSlug($video->user);
$relDir = 'users/' . $userSlug . '/videos/' . basename($localDir);
}
foreach ($request->file('slides_add') as $file) {
if ($isNewFormat) {
// Create placeholder record to get the auto-increment ID
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
$ext = $file->getClientOriginalExtension();
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
$slide->update(['filename' => "{$relDir}/slides/{$slide->id}.{$ext}"]);
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
if ($nasEnabled) {
// Push directly to NAS using position-based filename (matches MediaController)
$nasSlide = "{$nasDir}/slides/{$nextPos}.{$ext}";
$tempPath = $file->storeAs('public/tmp', "slide_{$slide->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
if ($nasForSlides->putFile($tempAbs, $nasSlide)) {
@unlink($tempAbs);
$slide->update(['filename' => $nasSlide]);
} else {
// NAS push failed — save locally, auto-sync will retry
@mkdir("{$localDir}/slides", 0755, true);
rename($tempAbs, "{$localDir}/slides/{$slide->id}.{$ext}");
$slide->update(['filename' => "{$relBase}/slides/{$slide->id}.{$ext}"]);
}
} else {
// Legacy video: push slide directly to NAS
$fname = self::generateFilename($file->getClientOriginalExtension());
$file->storeAs('public/thumbnails', $fname);
$tempAbs = storage_path('app/public/thumbnails/' . $fname);
$nasDir = $nasForSlides->resolveVideoDir($video);
$nasForSlides->mkdirp("{$nasDir}/slides");
$ext = pathinfo($fname, PATHINFO_EXTENSION);
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
$nasSlide = "{$nasDir}/slides/{$slide->id}.{$ext}";
$nasForSlides->putFile($tempAbs, $nasSlide);
@unlink($tempAbs);
$slide->update(['filename' => $nasSlide]);
// NAS off — store in local project folder
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
$slide->update(['filename' => "{$relBase}/slides/{$slide->id}.{$ext}"]);
}
$nextPos++;
$slidesChanged = true;
}
@@ -663,27 +809,164 @@ class VideoController extends Controller
}
}
// ── Audio track management (delete + add) ────────────────────────────
if ($this->isAudioOnlyFile($video)) {
$nas = app(\App\Services\NasSyncService::class);
// Delete tracks requested for removal
$deleteIds = $request->input('delete_track_ids', []);
if (! empty($deleteIds)) {
$video->audioTracks()->whereIn('id', $deleteIds)->each(function ($track) use ($nas) {
if ($track->path !== '__pending__') {
if ($nas->isEnabled() && str_starts_with($track->path, 'users/')) {
try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
} else {
@unlink($track->localPath());
}
}
$track->delete();
});
}
// Update titles, descriptions, and languages of existing tracks
$titleUpdates = $request->input('track_title_updates', []);
$descUpdates = $request->input('track_description_updates', []);
$languageUpdates = $request->input('track_language_updates', []);
$allTrackIds = array_unique(array_merge(
array_keys($titleUpdates), array_keys($descUpdates), array_keys($languageUpdates)
));
foreach ($allTrackIds as $trackId) {
$fields = [];
if (array_key_exists($trackId, $titleUpdates)) $fields['title'] = $titleUpdates[$trackId] ?: null;
if (array_key_exists($trackId, $descUpdates)) $fields['description'] = $descUpdates[$trackId] ?: null;
if (array_key_exists($trackId, $languageUpdates) && $languageUpdates[$trackId]) {
$fields['language'] = $languageUpdates[$trackId];
$fields['label'] = strtoupper($languageUpdates[$trackId]);
}
if ($fields) $video->audioTracks()->where('id', (int) $trackId)->update($fields);
}
// Replace audio files for existing tracks
if ($request->hasFile('track_file_updates')) {
foreach ($request->file('track_file_updates') as $trackId => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue;
$track = $video->audioTracks()->find((int) $trackId);
if (! $track) continue;
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
if ($nas->isEnabled()) {
try {
$videoDir = $this->nasVideoDir($video, $nas);
$nasDir = "{$videoDir}/tracks";
$nas->mkdirp($nasDir);
if ($track->path && $track->path !== '__pending__' && str_starts_with($track->path, 'users/')) {
try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
}
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$nasDir}/{$track->id}.{$ext}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update(['path' => $nasPath, 'filename' => "{$track->id}.{$ext}"]);
} catch (\Throwable $e) {
\Log::error("Track file replace NAS failed (track {$trackId}): " . $e->getMessage());
}
} else {
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
}
}
// Upload new extra tracks
if ($request->hasFile('extra_track_files')) {
$trackFiles = $request->file('extra_track_files');
$trackLangs = $request->input('extra_track_languages', []);
$trackTitles = $request->input('extra_track_titles', []);
foreach ($trackFiles as $i => $trackFile) {
if (! $trackFile || ! $trackFile->isValid()) continue;
$lang = $trackLangs[$i] ?? 'en';
$ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
$title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
$track = VideoAudioTrack::create([
'video_id' => $video->id,
'language' => $lang,
'label' => strtoupper($lang),
'title' => $title,
'path' => '__pending__',
'filename' => '__pending__',
]);
if ($nas->isEnabled()) {
try {
$videoDir = $this->nasVideoDir($video, $nas);
$nasDir = "{$videoDir}/tracks";
$nas->mkdirp($nasDir);
$tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
$tempAbs = storage_path('app/' . $tempPath);
$nasPath = "{$nasDir}/{$track->id}.{$ext}";
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
$track->update(['path' => $nasPath, 'filename' => "{$track->id}.{$ext}"]);
} catch (\Throwable $e) {
\Log::error("Extra track NAS upload failed (update): " . $e->getMessage());
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
} else {
$this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
}
}
}
}
// Promote a secondary track to primary (swap metadata)
if ($promoteId = (int) $request->input('promote_track_id')) {
$promoteTrack = $video->audioTracks()->find($promoteId);
if ($promoteTrack) {
\Log::info('Track promote: swapping primary ↔ secondary', [
'video_id' => $video->id,
'old_primary' => ['lang' => $video->language, 'path' => $video->path, 'filename' => $video->filename],
'new_primary' => ['track_id' => $promoteTrack->id, 'lang' => $promoteTrack->language, 'path' => $promoteTrack->path, 'filename' => $promoteTrack->filename],
]);
// Create new secondary track from current primary metadata
VideoAudioTrack::create([
'video_id' => $video->id,
'language' => $video->language ?? 'en',
'label' => strtoupper($video->language ?? 'en'),
'title' => $video->title,
'description' => $video->description,
'path' => $video->path,
'filename' => $video->filename,
]);
// Override $data with the promoted track's values
$data['title'] = $promoteTrack->title ?: ($data['title'] ?? $video->title);
$data['language'] = $promoteTrack->language;
$data['description'] = $promoteTrack->description ?: ($data['description'] ?? $video->description);
$data['path'] = $promoteTrack->path;
$data['filename'] = $promoteTrack->filename;
$promoteTrack->delete();
} else {
\Log::warning('Track promote: promote_track_id not found', ['video_id' => $video->id, 'promote_track_id' => $promoteId]);
}
}
$video->update($data);
// If the title changed, rename the NAS/local folder before syncing so
// the sync job writes to the correctly-named directory.
// If the title changed, rename the NAS folder and update meta.json.
// We do NOT dispatch NasSyncVideoJob for regular edits — thumbnails and
// slides are already pushed to NAS above, so there is nothing left to sync.
if (($data['title'] ?? $oldTitle) !== $oldTitle) {
try {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->renameVideoDir($video->fresh());
$nas->syncVideoMeta($video->fresh());
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NAS video dir rename failed: ' . $e->getMessage());
\Illuminate\Support\Facades\Log::warning('NAS video dir rename/meta failed: ' . $e->getMessage());
}
}
try {
NasSyncVideoJob::dispatch($video->fresh());
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NasSyncVideoJob dispatch failed: ' . $e->getMessage());
}
AuditLog::record('video.updated', [
'subject_type' => 'Video',
'subject_id' => (string) $video->id,
@@ -770,7 +1053,6 @@ class VideoController extends Controller
'required',
'file',
'mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus',
'max:512000',
],
]);
@@ -845,6 +1127,8 @@ class VideoController extends Controller
// organizeLocalFiles() which moves it to users/... layout.
// CompressVideoJob() sets status=ready and chains HLS.
$nasReplaceSucceeded = false;
if ($nas->isEnabled()) {
// Point filename at the new file (uploadDirectToNas uses this for ext)
@@ -854,11 +1138,15 @@ class VideoController extends Controller
// Pass null for thumb — we don't want to overwrite the existing thumbnail
$nas->uploadDirectToNas($video, $tempAbsPath, null);
$video->refresh();
$nasReplaceSucceeded = true;
} catch (\Throwable $e) {
\Log::error('replaceFile: NAS upload failed: ' . $e->getMessage());
@unlink($tempAbsPath);
return response()->json(['success' => false, 'message' => 'NAS upload failed. Please try again.'], 500);
\Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage());
// Temp file still exists — fall through to organizeLocalFiles below
$video->update(['path' => $tempRelPath, 'filename' => $tempFilename]);
}
}
if ($nasReplaceSucceeded) {
$metaUpdates = [
'size' => $newSize,
@@ -885,9 +1173,7 @@ class VideoController extends Controller
} else {
// Point the record at the temp file so organizeLocalFiles can move it
$video->update(['path' => $tempRelPath, 'filename' => $tempFilename]);
// NAS unavailable — keep file locally; NAS-mirrored layout
try {
$nas->organizeLocalFiles($video);
$video->refresh();
@@ -1032,7 +1318,7 @@ class VideoController extends Controller
header('Content-Length: '.$length);
header('Content-Range: bytes '.$start.'-'.$end.'/'.$fileSize);
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=3600');
header('Cache-Control: private, max-age=3600');
fseek($handle, $start);
$chunkSize = 8192;
@@ -1051,7 +1337,7 @@ class VideoController extends Controller
header('Content-Type: '.$mimeType);
header('Content-Length: '.$fileSize);
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=3600');
header('Cache-Control: private, max-age=3600');
fpassthru($handle);
fclose($handle);
@@ -1059,6 +1345,120 @@ class VideoController extends Controller
}
}
public function streamAudioTrack(Video $video, VideoAudioTrack $track)
{
if ($track->video_id !== $video->id) {
abort(404);
}
if (! $video->canView(Auth::user())) {
abort(404);
}
$localPath = $track->localPath();
if (! file_exists($localPath)) {
// Use the same robust fallback chain as the primary stream():
// local path → nas_cache → NAS download. This is what lets a demoted
// secondary track (e.g. the old primary after a language swap) keep
// playing even when its file only exists in the stream cache.
$nas = app(\App\Services\NasSyncService::class);
$resolved = $nas->ensureLocalTrackCopy($track);
if (! $resolved) {
\Log::warning('streamAudioTrack: file not found', [
'video_id' => $video->id,
'track_id' => $track->id,
'language' => $track->language,
'path' => $track->path,
'filename' => $track->filename,
'nas_enabled' => $nas->isEnabled(),
]);
abort(404, 'Audio track file not found');
}
$localPath = $resolved;
}
$fileSize = filesize($localPath);
$ext = strtolower(pathinfo($track->filename, PATHINFO_EXTENSION));
$mimeMap = ['mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'aac' => 'audio/aac',
'flac' => 'audio/flac', 'wav' => 'audio/wav', 'ogg' => 'audio/ogg'];
$mimeType = $mimeMap[$ext] ?? 'audio/mpeg';
$handle = fopen($localPath, 'rb');
if (! $handle) abort(500, 'Cannot open audio track');
$range = request()->header('Range');
if ($range) {
preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
$start = intval($matches[1] ?? 0);
$end = $matches[2] ? intval($matches[2]) : $fileSize - 1;
$length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content');
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . $length);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
header('Accept-Ranges: bytes');
header('Cache-Control: private, max-age=3600');
fseek($handle, $start);
$remaining = $length;
while (! feof($handle) && $remaining > 0) {
$buf = fread($handle, min(8192, $remaining));
echo $buf;
flush();
$remaining -= strlen($buf);
}
fclose($handle);
exit;
}
if (request()->boolean('download')) {
$downloadName = \Str::slug($video->title) . '-' . strtolower($track->label ?: $track->language) . '.' . $ext;
header('Content-Disposition: attachment; filename="' . $downloadName . '"');
}
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . $fileSize);
header('Accept-Ranges: bytes');
header('Cache-Control: private, max-age=3600');
fpassthru($handle);
fclose($handle);
exit;
}
public function deleteAudioTrack(Request $request, Video $video, VideoAudioTrack $track)
{
if ($track->video_id !== $video->id) abort(404);
if (Auth::id() !== $video->user_id) abort(403);
$nas = app(\App\Services\NasSyncService::class);
if ($track->path !== '__pending__') {
if ($nas->isEnabled() && str_starts_with($track->path, 'users/')) {
try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
} else {
@unlink($track->localPath());
}
}
$track->delete();
return response()->json(['success' => true]);
}
private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void
{
try {
$localDir = $nas->localVideoDir($video) . '/tracks';
@mkdir($localDir, 0755, true);
$trackFile->move($localDir, "{$track->id}.{$ext}");
$userSlug = $nas->userSlug($video->user);
$videoBase = basename($nas->localVideoDir($video));
$relPath = "users/{$userSlug}/videos/{$videoBase}/tracks/{$track->id}.{$ext}";
$track->update(['path' => $relPath, 'filename' => "{$track->id}.{$ext}"]);
} catch (\Throwable $e) {
\Log::error("storeTrackLocally failed: " . $e->getMessage());
}
}
public function hls(Video $video, $file = 'playlist.m3u8')
{
if (! $video->canView(Auth::user())) {
@@ -1456,6 +1856,23 @@ class VideoController extends Controller
return Setting::ffmpegHwaccelFlags($inputIsVideo);
}
/**
* Return the NAS video root directory (where thumb.jpg and slides/ live).
* When the primary file was promoted from a secondary track, video->path points
* into a 'tracks/' subfolder — in that case we go up one extra level.
*/
private function nasVideoDir(Video $video, \App\Services\NasSyncService $nas): string
{
if (str_starts_with($video->path, 'users/')) {
$dir = dirname($video->path);
if (basename($dir) === 'tracks') {
$dir = dirname($dir);
}
return $dir;
}
return $nas->resolveVideoDir($video);
}
private function isAudioOnlyFile(Video $video): bool
{
if ($video->mime_type) {
@@ -1998,14 +2415,34 @@ class VideoController extends Controller
// Per-link breakdown for insights panel
$shareBreakdown = $shareLinks->map(function ($s) {
$accesses = \DB::table('share_accesses')->where('share_id', $s->id)->count();
$sharer = $s->user_id
? \DB::table('users')->where('id', $s->user_id)->value('name')
: 'Guest';
$user = $s->user_id
? \DB::table('users')->where('id', $s->user_id)->first(['id', 'name', 'avatar', 'username'])
: null;
// Most common country among accesses for this link
$topCountry = \DB::table('share_accesses')
->where('share_id', $s->id)
->whereNotNull('country')
->selectRaw('country, country_name, count(*) as cnt')
->groupBy('country', 'country_name')
->orderByDesc('cnt')
->first();
return [
'token' => $s->token,
'sharer' => $sharer,
'reach' => $accesses,
'created_at'=> $s->created_at,
'token' => $s->token,
'sharer' => $user ? $user->name : 'Guest',
'sharer_id' => $user ? $user->id : null,
'sharer_channel' => $user ? ($user->username ?? $user->id) : null,
'avatar' => $user
? ($user->avatar
? route('media.avatar', $user->avatar)
: 'https://i.pravatar.cc/150?u=' . $user->id)
: null,
'country' => $topCountry ? $topCountry->country : null,
'country_name' => $topCountry ? $topCountry->country_name : null,
'reach' => $accesses,
'created_at' => $s->created_at,
];
})->sortByDesc('reach')->values()->take(10);