diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index 3abdb5c..378a829 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -1206,9 +1206,170 @@ class VideoController extends Controller unset($data['visibility']); } + // ── Optional main media file replacement + type conversion ────────── + // When the edit form ships a new `video` file, replace the primary + // media. If the new file is audio, force type=music. When the type + // folder changes (generic → music, etc.), relocate the file on NAS + // into the canonical per-type layout. + if ($request->hasFile('video')) { + $request->validate([ + 'video' => 'file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus', + ]); + + $newFile = $request->file('video'); + $mimeType = $newFile->getMimeType(); + $isAudio = str_starts_with((string) $mimeType, 'audio/'); + $newSize = $newFile->getSize(); + $newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4')); + $nas = app(\App\Services\NasSyncService::class); + + // Desired type — form value wins, but audio always forces music. + $desiredType = $data['type'] ?? $video->type ?? 'generic'; + if ($isAudio) $desiredType = 'music'; + $data['type'] = $desiredType; + + $oldPath = (string) $video->path; + $oldVideoDirNas = str_starts_with($oldPath, 'users/') + ? implode('/', array_slice(explode('/', $oldPath), 0, 4)) + : null; + // Actual on-disk type folder is segment index 2 of "users/{slug}/{type}/…". + // We must compare against this (not the DB type) because a prior type + // edit can leave a video with type=music but files still under videos/. + $oldPathTypeFolder = $oldVideoDirNas ? explode('/', $oldVideoDirNas)[2] ?? null : null; + + // Mutate in-memory so typeFolder / resolveVideoDir react to the new type + $video->type = $desiredType; + // For music, the primary track folder is {lang}-{id} — pick up any + // language the same form is about to save so the folder is right. + if (array_key_exists('language', $data)) { + $video->language = $data['language']; + } + $needsRelocate = $oldPathTypeFolder !== null && $oldPathTypeFolder !== $nas->typeFolder($video); + + // Clear old HLS (bytes + DB flags reset below) + if ($video->has_hls && $video->hls_path) { + \Storage::deleteDirectory($video->hls_path); + } + $localHlsAbs = storage_path('app/public/hls/' . $video->id); + if (is_dir($localHlsAbs)) @exec('rm -rf ' . escapeshellarg($localHlsAbs)); + + // Stash the uploaded file to a temp location + $tempFilename = \Str::uuid() . '.' . $newExt; + $newFile->storeAs('public/tmp', $tempFilename); + $tempAbs = storage_path('app/public/tmp/' . $tempFilename); + + if (! file_exists($tempAbs)) { + return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500); + } + + // ── ffprobe ───────────────────────────────────────────────────── + $width = $height = null; + $orientation = 'landscape'; + $duration = 0; + try { + $ffprobeBin = config('ffmpeg.ffprobe', '/usr/bin/ffprobe'); + $out = []; + exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($tempAbs), $out); + $duration = (int) round((float) ($out[0] ?? 0)); + if (! $isAudio) { + $ffprobe = \FFMpeg\FFProbe::create(); + $stream = $ffprobe->streams($tempAbs)->videos()->first(); + if ($stream) { + $width = $stream->get('width'); + $height = $stream->get('height'); + if ($width && $height) { + if ($height > $width) $orientation = 'portrait'; + elseif ($width > $height) $orientation = 'landscape'; + else $orientation = 'square'; + } + } + } + } catch (\Throwable $e) { + \Log::warning('update: FFprobe on replacement file failed: ' . $e->getMessage()); + } + + // Prep video for uploadDirectToNas — filename drives ext detection, + // clearing path when relocating forces a fresh dir under the new type. + $video->filename = 'placeholder.' . $newExt; + if ($needsRelocate) $video->path = null; + + $nasReplaceSucceeded = false; + if ($nas->isEnabled()) { + try { + $nas->uploadDirectToNas($video, $tempAbs, null); + $video->refresh(); + // Re-apply the in-memory type mutation lost by refresh() + $video->type = $desiredType; + $nasReplaceSucceeded = true; + } catch (\Throwable $e) { + \Log::error('update: NAS media replace failed: ' . $e->getMessage()); + // Fall through — file stays in /tmp; DB state below still lands + $video->update([ + 'path' => 'public/tmp/' . $tempFilename, + 'filename' => $tempFilename, + ]); + } + } else { + $video->update([ + 'path' => 'public/tmp/' . $tempFilename, + 'filename' => $tempFilename, + ]); + } + + // Cleanup: delete OLD primary file + meta.json from NAS (only when + // it's on NAS AND the new path is different). Leave the old thumbnail + // in place so it keeps serving until the user uploads a new one. + if ($nasReplaceSucceeded && $oldPath && str_starts_with($oldPath, 'users/') && $oldPath !== $video->path) { + try { $nas->deleteFile($oldPath); } catch (\Throwable $e) {} + if ($needsRelocate && $oldVideoDirNas) { + $newVideoDirNas = implode('/', array_slice(explode('/', (string) $video->path), 0, 4)); + if ($oldVideoDirNas !== $newVideoDirNas) { + // Old meta.json must go so resolveVideoDir doesn't get + // confused by two folders claiming the same video id. + try { $nas->deleteFile("{$oldVideoDirNas}/meta.json"); } catch (\Throwable $e) {} + } + } + } + + // Wipe local cache from the OLD dir (regenerable content only) + if ($oldVideoDirNas) { + $oldLocalCache = storage_path('app/' . $oldVideoDirNas . '/cache'); + if (is_dir($oldLocalCache)) @exec('rm -rf ' . escapeshellarg($oldLocalCache)); + } + + // Bake metadata into $data so the trailing $video->update($data) applies it + $data['size'] = $newSize; + $data['mime_type'] = $mimeType; + $data['has_hls'] = false; + $data['hls_path'] = null; + $data['status'] = 'ready'; + $data['slideshow_video_path'] = null; + if (! $isAudio) { + $data['duration'] = $duration ?: $video->duration; + $data['width'] = $width ?: $video->width; + $data['height'] = $height ?: $video->height; + $data['orientation'] = $orientation; + $data['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait'; + } else { + $data['duration'] = $duration ?: $video->duration; + $data['width'] = null; + $data['height'] = null; + $data['orientation'] = 'landscape'; + $data['is_shorts'] = false; + } + + if (! $isAudio) { + \App\Jobs\GenerateHlsJob::dispatch($video->fresh()) + ->onQueue('video-processing') + ->onConnection('database'); + } + } + // Handle slide reorder / removal / additions for audio tracks + // Gate on declared type, not the file extension — a video row can be + // type=music while its primary file is still an mp4 (mid-conversion). $slidesChanged = false; - if ($this->isAudioOnlyFile($video)) { + if (($data['type'] ?? $video->type) === 'music') { // slides_order is a JSON array of kept slide IDs in their new order $keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: []; @@ -1295,7 +1456,8 @@ class VideoController extends Controller } // ── Audio track management (delete + add) ──────────────────────────── - if ($this->isAudioOnlyFile($video)) { + // Gate on declared type, not extension (see slide gate above). + if (($data['type'] ?? $video->type) === 'music') { $nas = app(\App\Services\NasSyncService::class); // Delete tracks requested for removal @@ -1588,6 +1750,19 @@ class VideoController extends Controller $newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4')); $nas = app(\App\Services\NasSyncService::class); + // When the replacement is audio, the video must become type=music so the + // NAS layout / player pipeline agree with the file. Force it and relocate. + $desiredType = $isAudio ? 'music' : $video->type; + $oldPath = (string) $video->path; + $oldVideoDirNas = str_starts_with($oldPath, 'users/') + ? implode('/', array_slice(explode('/', $oldPath), 0, 4)) + : null; + // Actual on-disk type folder is segment index 2 of "users/{slug}/{type}/…". + // Compare against this (not DB type) so a stale type edit can still relocate. + $oldPathTypeFolder = $oldVideoDirNas ? explode('/', $oldVideoDirNas)[2] ?? null : null; + $video->type = $desiredType; + $needsRelocate = $oldPathTypeFolder !== null && $oldPathTypeFolder !== $nas->typeFolder($video); + // ── 1. Clear old HLS ───────────────────────────────────────────────── if ($video->has_hls && $video->hls_path) { \Storage::deleteDirectory($video->hls_path); @@ -1657,13 +1832,24 @@ class VideoController extends Controller if ($nas->isEnabled()) { // Point filename at the new file (uploadDirectToNas uses this for ext) - $video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]); + $update = ['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize, 'type' => $desiredType]; + if ($needsRelocate) $update['path'] = null; // force resolveVideoDir to pick a fresh type-appropriate dir + $video->update($update); try { // Pass null for thumb — we don't want to overwrite the existing thumbnail $nas->uploadDirectToNas($video, $tempAbsPath, null); $video->refresh(); $nasReplaceSucceeded = true; + + // Clean up the old NAS folder's meta.json if we relocated (so + // resolveVideoDir isn't confused by two folders claiming this id). + if ($needsRelocate && $oldVideoDirNas) { + $newVideoDirNas = implode('/', array_slice(explode('/', (string) $video->path), 0, 4)); + if ($oldVideoDirNas !== $newVideoDirNas) { + try { $nas->deleteFile("{$oldVideoDirNas}/meta.json"); } catch (\Throwable $e) {} + } + } } catch (\Throwable $e) { \Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage()); // Temp file still exists — fall through to organizeLocalFiles below diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index bb2242a..9e363f9 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -1198,12 +1198,15 @@ } body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; } .yt-main.video-view-page { padding: 0 !important; } - .yt-filter-bar { + .yt-filter-bar-wrap { position: relative !important; top: auto !important; z-index: auto !important; margin: -16px -16px 16px !important; } + .yt-filter-bar { + padding: 12px 16px !important; + } } diff --git a/resources/views/layouts/partials/edit-video-modal.blade.php b/resources/views/layouts/partials/edit-video-modal.blade.php index 6662643..6511474 100644 --- a/resources/views/layouts/partials/edit-video-modal.blade.php +++ b/resources/views/layouts/partials/edit-video-modal.blade.php @@ -288,8 +288,9 @@ function openEditVideoModal(videoId) { _editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url })); _editRenderSlides('t1'); - // Extra tracks - if (v.is_audio && v.audio_tracks) { + // Extra tracks — show whenever the row is declared music, even if the + // primary file is still a video (mid-conversion state). + if ((v.type === 'music' || v.is_audio) && v.audio_tracks) { v.audio_tracks.forEach(track => _editAddExistingTrack(track)); }