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
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\NasSyncVideoJob;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class NasAutoSync extends Command
|
||||
{
|
||||
protected $signature = 'nas:auto-sync';
|
||||
protected $description = 'Push locally-stored files to NAS when NAS is available. Runs as a scheduled task.';
|
||||
|
||||
public function handle(NasSyncService $nas): int
|
||||
{
|
||||
if (! $nas->isEnabled()) {
|
||||
return 0; // NAS down or disabled — nothing to do
|
||||
}
|
||||
|
||||
// Videos whose file OR thumbnail/slides are still on local disk
|
||||
$synced = 0;
|
||||
Video::with(['user', 'slides'])
|
||||
->where('status', 'ready')
|
||||
->where('path', 'like', 'users/%')
|
||||
->get()
|
||||
->each(function (Video $video) use ($nas, &$synced) {
|
||||
$hasLocalVideo = file_exists(storage_path('app/' . $video->path));
|
||||
|
||||
// Check for locally-stored thumbnail (NAS push failed during edit)
|
||||
$hasLocalThumb = $video->thumbnail
|
||||
&& str_starts_with($video->thumbnail, 'users/')
|
||||
&& file_exists(storage_path('app/' . $video->thumbnail));
|
||||
|
||||
// Check for locally-stored slides
|
||||
$hasLocalSlide = false;
|
||||
foreach ($video->slides as $slide) {
|
||||
if (str_starts_with($slide->filename, 'users/')
|
||||
&& file_exists(storage_path('app/' . $slide->filename))) {
|
||||
$hasLocalSlide = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasLocalVideo || $hasLocalThumb || $hasLocalSlide) {
|
||||
NasSyncVideoJob::dispatch($video);
|
||||
$synced++;
|
||||
}
|
||||
});
|
||||
|
||||
// Avatars and banners stuck locally
|
||||
User::whereNotNull('avatar')->orWhereNotNull('banner')->get()
|
||||
->each(function (User $user) use ($nas) {
|
||||
if ($user->avatar
|
||||
&& str_starts_with($user->avatar, 'users/')
|
||||
&& file_exists(storage_path('app/' . $user->avatar))) {
|
||||
$nas->syncAvatar($user, storage_path('app/' . $user->avatar));
|
||||
$nas->deleteLocalAvatar($user);
|
||||
}
|
||||
if ($user->banner
|
||||
&& str_starts_with($user->banner, 'users/')
|
||||
&& file_exists(storage_path('app/' . $user->banner))) {
|
||||
$nas->syncCover($user, storage_path('app/' . $user->banner));
|
||||
$nas->deleteLocalBanner($user);
|
||||
}
|
||||
});
|
||||
|
||||
if ($synced > 0) {
|
||||
$this->info("Queued {$synced} video(s) for NAS sync.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -24,7 +24,24 @@ class Kernel extends ConsoleKernel
|
||||
if ($nas->isEnabled()) {
|
||||
$nas->clearNasCache(24);
|
||||
}
|
||||
})->daily()->name('nas-cache-evict')->withoutOverlapping();
|
||||
})->daily()->name('nas-cache-evict');
|
||||
|
||||
// Clean up stale temp files in public/tmp older than 1 hour
|
||||
$schedule->call(function () {
|
||||
$tmpDir = storage_path('app/public/tmp');
|
||||
if (! is_dir($tmpDir)) return;
|
||||
$cutoff = time() - 3600;
|
||||
foreach (glob("{$tmpDir}/*") as $file) {
|
||||
if (is_file($file) && filemtime($file) < $cutoff) @unlink($file);
|
||||
}
|
||||
})->hourly()->name('tmp-cleanup');
|
||||
|
||||
// Auto-sync local files to NAS when NAS comes back online.
|
||||
// Runs every 10 minutes; exits immediately when NAS is unreachable.
|
||||
$schedule->command('nas:auto-sync')
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Weekly activity digest — every Monday at 9:00 AM (Bahrain time)
|
||||
$schedule->command('digest:weekly')
|
||||
|
||||
@@ -10,10 +10,8 @@ class Countries
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
// Generate flag emoji from ISO2 (two regional indicator letters)
|
||||
$f = fn(string $c): string =>
|
||||
mb_chr(0x1F1E6 + ord($c[0]) - 65) .
|
||||
mb_chr(0x1F1E6 + ord($c[1]) - 65);
|
||||
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)
|
||||
$f = fn(string $c): string => strtolower($c);
|
||||
|
||||
return [
|
||||
|
||||
@@ -305,7 +303,7 @@ class Countries
|
||||
$location = $dtze->getLocation();
|
||||
$countryCode = strtoupper($location['country_code'] ?? '');
|
||||
$countryData = $countries[$countryCode] ?? null;
|
||||
$flag = $countryData ? $countryData['flag'] : '🌐';
|
||||
$flag = $countryData ? $countryData['flag'] : '';
|
||||
$countryName = $countryData ? $countryData['name'] : '';
|
||||
|
||||
$parts = explode('/', $tz);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
class Languages
|
||||
{
|
||||
/**
|
||||
* All supported languages keyed by ISO 639-1 code.
|
||||
* Fields: name (English), native (native script), code (uppercase ISO), flag (ISO 3166-1 alpha-2 lowercase for flag-icons)
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
// ── HIGH-PRIORITY (Middle East / platform focus) ────────────────────
|
||||
'ar' => ['name' => 'Arabic', 'native' => 'العربية', 'code' => 'AR', 'flag' => 'sa'],
|
||||
'en' => ['name' => 'English', 'native' => 'English', 'code' => 'EN', 'flag' => 'gb'],
|
||||
'fa' => ['name' => 'Persian', 'native' => 'فارسی', 'code' => 'FA', 'flag' => 'ir'],
|
||||
'ur' => ['name' => 'Urdu', 'native' => 'اردو', 'code' => 'UR', 'flag' => 'pk'],
|
||||
'tr' => ['name' => 'Turkish', 'native' => 'Türkçe', 'code' => 'TR', 'flag' => 'tr'],
|
||||
'ku' => ['name' => 'Kurdish', 'native' => 'Kurdî', 'code' => 'KU', 'flag' => 'iq'],
|
||||
|
||||
// ── SOUTH ASIA ───────────────────────────────────────────────────────
|
||||
'hi' => ['name' => 'Hindi', 'native' => 'हिन्दी', 'code' => 'HI', 'flag' => 'in'],
|
||||
'bn' => ['name' => 'Bengali', 'native' => 'বাংলা', 'code' => 'BN', 'flag' => 'bd'],
|
||||
'ta' => ['name' => 'Tamil', 'native' => 'தமிழ்', 'code' => 'TA', 'flag' => 'lk'],
|
||||
'te' => ['name' => 'Telugu', 'native' => 'తెలుగు', 'code' => 'TE', 'flag' => 'in'],
|
||||
'ml' => ['name' => 'Malayalam', 'native' => 'മലയാളം', 'code' => 'ML', 'flag' => 'in'],
|
||||
'si' => ['name' => 'Sinhala', 'native' => 'සිංහල', 'code' => 'SI', 'flag' => 'lk'],
|
||||
'ne' => ['name' => 'Nepali', 'native' => 'नेपाली', 'code' => 'NE', 'flag' => 'np'],
|
||||
|
||||
// ── EAST ASIA ────────────────────────────────────────────────────────
|
||||
'zh' => ['name' => 'Chinese', 'native' => '中文', 'code' => 'ZH', 'flag' => 'cn'],
|
||||
'ja' => ['name' => 'Japanese', 'native' => '日本語', 'code' => 'JA', 'flag' => 'jp'],
|
||||
'ko' => ['name' => 'Korean', 'native' => '한국어', 'code' => 'KO', 'flag' => 'kr'],
|
||||
|
||||
// ── SOUTHEAST ASIA ───────────────────────────────────────────────────
|
||||
'id' => ['name' => 'Indonesian', 'native' => 'Bahasa Indonesia', 'code' => 'ID', 'flag' => 'id'],
|
||||
'ms' => ['name' => 'Malay', 'native' => 'Bahasa Melayu', 'code' => 'MS', 'flag' => 'my'],
|
||||
'tl' => ['name' => 'Tagalog', 'native' => 'Filipino', 'code' => 'TL', 'flag' => 'ph'],
|
||||
'th' => ['name' => 'Thai', 'native' => 'ภาษาไทย', 'code' => 'TH', 'flag' => 'th'],
|
||||
'vi' => ['name' => 'Vietnamese', 'native' => 'Tiếng Việt', 'code' => 'VI', 'flag' => 'vn'],
|
||||
'my' => ['name' => 'Burmese', 'native' => 'မြန်မာဘာသာ', 'code' => 'MY', 'flag' => 'mm'],
|
||||
'km' => ['name' => 'Khmer', 'native' => 'ភាសាខ្មែរ', 'code' => 'KM', 'flag' => 'kh'],
|
||||
|
||||
// ── CENTRAL ASIA ─────────────────────────────────────────────────────
|
||||
'az' => ['name' => 'Azerbaijani', 'native' => 'Azərbaycan', 'code' => 'AZ', 'flag' => 'az'],
|
||||
'kk' => ['name' => 'Kazakh', 'native' => 'Қазақша', 'code' => 'KK', 'flag' => 'kz'],
|
||||
'uz' => ['name' => 'Uzbek', 'native' => "O'zbek", 'code' => 'UZ', 'flag' => 'uz'],
|
||||
'tg' => ['name' => 'Tajik', 'native' => 'Тоҷикӣ', 'code' => 'TG', 'flag' => 'tj'],
|
||||
'tk' => ['name' => 'Turkmen', 'native' => 'Türkmençe', 'code' => 'TK', 'flag' => 'tm'],
|
||||
'ky' => ['name' => 'Kyrgyz', 'native' => 'Кыргызча', 'code' => 'KY', 'flag' => 'kg'],
|
||||
|
||||
// ── EUROPE (ROMANCE) ─────────────────────────────────────────────────
|
||||
'fr' => ['name' => 'French', 'native' => 'Français', 'code' => 'FR', 'flag' => 'fr'],
|
||||
'es' => ['name' => 'Spanish', 'native' => 'Español', 'code' => 'ES', 'flag' => 'es'],
|
||||
'pt' => ['name' => 'Portuguese', 'native' => 'Português', 'code' => 'PT', 'flag' => 'pt'],
|
||||
'it' => ['name' => 'Italian', 'native' => 'Italiano', 'code' => 'IT', 'flag' => 'it'],
|
||||
'ro' => ['name' => 'Romanian', 'native' => 'Română', 'code' => 'RO', 'flag' => 'ro'],
|
||||
'ca' => ['name' => 'Catalan', 'native' => 'Català', 'code' => 'CA', 'flag' => 'es'],
|
||||
|
||||
// ── EUROPE (GERMANIC) ────────────────────────────────────────────────
|
||||
'de' => ['name' => 'German', 'native' => 'Deutsch', 'code' => 'DE', 'flag' => 'de'],
|
||||
'nl' => ['name' => 'Dutch', 'native' => 'Nederlands', 'code' => 'NL', 'flag' => 'nl'],
|
||||
'sv' => ['name' => 'Swedish', 'native' => 'Svenska', 'code' => 'SV', 'flag' => 'se'],
|
||||
'no' => ['name' => 'Norwegian', 'native' => 'Norsk', 'code' => 'NO', 'flag' => 'no'],
|
||||
'da' => ['name' => 'Danish', 'native' => 'Dansk', 'code' => 'DA', 'flag' => 'dk'],
|
||||
'fi' => ['name' => 'Finnish', 'native' => 'Suomi', 'code' => 'FI', 'flag' => 'fi'],
|
||||
'is' => ['name' => 'Icelandic', 'native' => 'Íslenska', 'code' => 'IS', 'flag' => 'is'],
|
||||
|
||||
// ── EUROPE (SLAVIC) ──────────────────────────────────────────────────
|
||||
'ru' => ['name' => 'Russian', 'native' => 'Русский', 'code' => 'RU', 'flag' => 'ru'],
|
||||
'uk' => ['name' => 'Ukrainian', 'native' => 'Українська', 'code' => 'UK', 'flag' => 'ua'],
|
||||
'pl' => ['name' => 'Polish', 'native' => 'Polski', 'code' => 'PL', 'flag' => 'pl'],
|
||||
'cs' => ['name' => 'Czech', 'native' => 'Čeština', 'code' => 'CS', 'flag' => 'cz'],
|
||||
'sk' => ['name' => 'Slovak', 'native' => 'Slovenčina', 'code' => 'SK', 'flag' => 'sk'],
|
||||
'hr' => ['name' => 'Croatian', 'native' => 'Hrvatski', 'code' => 'HR', 'flag' => 'hr'],
|
||||
'sr' => ['name' => 'Serbian', 'native' => 'Srpski', 'code' => 'SR', 'flag' => 'rs'],
|
||||
'bg' => ['name' => 'Bulgarian', 'native' => 'Български', 'code' => 'BG', 'flag' => 'bg'],
|
||||
'sl' => ['name' => 'Slovenian', 'native' => 'Slovenščina', 'code' => 'SL', 'flag' => 'si'],
|
||||
'mk' => ['name' => 'Macedonian', 'native' => 'Македонски', 'code' => 'MK', 'flag' => 'mk'],
|
||||
|
||||
// ── EUROPE (OTHER) ───────────────────────────────────────────────────
|
||||
'el' => ['name' => 'Greek', 'native' => 'Ελληνικά', 'code' => 'EL', 'flag' => 'gr'],
|
||||
'hu' => ['name' => 'Hungarian', 'native' => 'Magyar', 'code' => 'HU', 'flag' => 'hu'],
|
||||
'he' => ['name' => 'Hebrew', 'native' => 'עברית', 'code' => 'HE', 'flag' => 'il'],
|
||||
'lt' => ['name' => 'Lithuanian', 'native' => 'Lietuvių', 'code' => 'LT', 'flag' => 'lt'],
|
||||
'lv' => ['name' => 'Latvian', 'native' => 'Latviešu', 'code' => 'LV', 'flag' => 'lv'],
|
||||
'et' => ['name' => 'Estonian', 'native' => 'Eesti', 'code' => 'ET', 'flag' => 'ee'],
|
||||
'sq' => ['name' => 'Albanian', 'native' => 'Shqip', 'code' => 'SQ', 'flag' => 'al'],
|
||||
|
||||
// ── AFRICA ───────────────────────────────────────────────────────────
|
||||
'sw' => ['name' => 'Swahili', 'native' => 'Kiswahili', 'code' => 'SW', 'flag' => 'tz'],
|
||||
'am' => ['name' => 'Amharic', 'native' => 'አማርኛ', 'code' => 'AM', 'flag' => 'et'],
|
||||
'so' => ['name' => 'Somali', 'native' => 'Soomaali', 'code' => 'SO', 'flag' => 'so'],
|
||||
'ha' => ['name' => 'Hausa', 'native' => 'Hausa', 'code' => 'HA', 'flag' => 'ng'],
|
||||
'yo' => ['name' => 'Yoruba', 'native' => 'Yorùbá', 'code' => 'YO', 'flag' => 'ng'],
|
||||
'ig' => ['name' => 'Igbo', 'native' => 'Igbo', 'code' => 'IG', 'flag' => 'ng'],
|
||||
'zu' => ['name' => 'Zulu', 'native' => 'isiZulu', 'code' => 'ZU', 'flag' => 'za'],
|
||||
'af' => ['name' => 'Afrikaans', 'native' => 'Afrikaans', 'code' => 'AF', 'flag' => 'za'],
|
||||
|
||||
// ── AMERICAS ─────────────────────────────────────────────────────────
|
||||
'qu' => ['name' => 'Quechua', 'native' => 'Runa Simi', 'code' => 'QU', 'flag' => 'pe'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the flag icon code (lowercase ISO 3166-1 alpha-2) for a given ISO 639-1 language code.
|
||||
* Returns null when the code is unknown or empty.
|
||||
*/
|
||||
public static function flag(?string $iso639): ?string
|
||||
{
|
||||
if (!$iso639) return null;
|
||||
return self::all()[strtolower($iso639)]['flag'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options list for the language-select component.
|
||||
* value = ISO 639-1 code (e.g. "ar", "en").
|
||||
* Arabic and English are pinned to the top; everything else is sorted alphabetically by English name.
|
||||
*/
|
||||
public static function forLanguage(): array
|
||||
{
|
||||
$pinned = ['ar', 'en'];
|
||||
$all = self::all();
|
||||
$list = [];
|
||||
|
||||
foreach ($all as $iso => $lang) {
|
||||
$list[] = [
|
||||
'value' => $iso,
|
||||
'code' => $lang['code'],
|
||||
'flag' => $lang['flag'],
|
||||
'label' => $lang['name'],
|
||||
'native' => $lang['native'],
|
||||
'search' => strtolower($iso . ' ' . $lang['name'] . ' ' . $lang['native']),
|
||||
'pinned' => in_array($iso, $pinned, true),
|
||||
];
|
||||
}
|
||||
|
||||
usort($list, function ($a, $b) use ($pinned) {
|
||||
$aPin = array_search($a['value'], $pinned, true);
|
||||
$bPin = array_search($b['value'], $pinned, true);
|
||||
|
||||
if ($aPin !== false && $bPin !== false) return $aPin <=> $bPin;
|
||||
if ($aPin !== false) return -1;
|
||||
if ($bPin !== false) return 1;
|
||||
|
||||
return strcmp($a['label'], $b['label']);
|
||||
});
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ class NasToLocalMigrationJob implements ShouldQueue
|
||||
|
||||
// ── Disable NAS ───────────────────────────────────────────────────
|
||||
Setting::set('nas_sync_enabled', 'false');
|
||||
app(\App\Services\NasSyncService::class)->flushReachabilityCache();
|
||||
|
||||
$progress['done'] = true;
|
||||
$progress['phase'] = 'Complete';
|
||||
|
||||
@@ -30,6 +30,7 @@ class Video extends Model
|
||||
'share_count',
|
||||
'share_token',
|
||||
'slideshow_video_path',
|
||||
'language',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -64,6 +65,11 @@ class Video extends Model
|
||||
return $this->hasMany(VideoSlide::class)->orderBy('position');
|
||||
}
|
||||
|
||||
public function audioTracks()
|
||||
{
|
||||
return $this->hasMany(\App\Models\VideoAudioTrack::class)->orderBy('id');
|
||||
}
|
||||
|
||||
public function hasSlideshow(): bool
|
||||
{
|
||||
return $this->slides()->count() > 1;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VideoAudioTrack extends Model
|
||||
{
|
||||
protected $fillable = ['video_id', 'language', 'label', 'title', 'description', 'path', 'filename'];
|
||||
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
public function getStreamUrlAttribute(): string
|
||||
{
|
||||
return route('videos.audio-track', ['video' => $this->video_id, 'track' => $this->id]);
|
||||
}
|
||||
|
||||
/** Absolute local path to the file, regardless of NAS or local storage. */
|
||||
public function localPath(): string
|
||||
{
|
||||
return storage_path('app/' . $this->path);
|
||||
}
|
||||
}
|
||||
+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