Allow converting a video to a music track without losing views/URL

VideoController@update() now handles a replacement media file: if the
new file is audio the row is forced to type=music and, when the on-disk
type folder differs from the declared type (e.g. a video sitting under
users/{u}/videos/ that was later marked music), the media is relocated
into the canonical users/{u}/music/{slug}/tracks/{lang}-{id}/audio.ext
layout. Old primary file and stale meta.json are deleted from NAS; HLS
and slideshow caches are reset. The same relocation is applied by
replaceFile() so the mobile "Replace Media File" flow behaves the same.

Slide- and audio-track-management gates in update() also switched from
isAudioOnlyFile() (extension-based) to the declared type, so secondary
tracks and cover slides added mid-conversion are no longer silently
dropped. The edit modal now renders existing secondary tracks whenever
type=music regardless of the primary file extension.

Also carries an unrelated app.blade.php tweak: the mobile filter bar's
sticky reset moved from .yt-filter-bar to its wrapper, with padding
kept on the inner bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-08-01 14:39:19 +03:00
parent da714958c6
commit 3d8d7b8efe
3 changed files with 196 additions and 6 deletions

View File

@ -1206,9 +1206,170 @@ class VideoController extends Controller
unset($data['visibility']); 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 // 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; $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 // slides_order is a JSON array of kept slide IDs in their new order
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: []; $keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
@ -1295,7 +1456,8 @@ class VideoController extends Controller
} }
// ── Audio track management (delete + add) ──────────────────────────── // ── 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); $nas = app(\App\Services\NasSyncService::class);
// Delete tracks requested for removal // Delete tracks requested for removal
@ -1588,6 +1750,19 @@ class VideoController extends Controller
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4')); $newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
$nas = app(\App\Services\NasSyncService::class); $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 ───────────────────────────────────────────────── // ── 1. Clear old HLS ─────────────────────────────────────────────────
if ($video->has_hls && $video->hls_path) { if ($video->has_hls && $video->hls_path) {
\Storage::deleteDirectory($video->hls_path); \Storage::deleteDirectory($video->hls_path);
@ -1657,13 +1832,24 @@ class VideoController extends Controller
if ($nas->isEnabled()) { if ($nas->isEnabled()) {
// Point filename at the new file (uploadDirectToNas uses this for ext) // 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 { try {
// Pass null for thumb — we don't want to overwrite the existing thumbnail // Pass null for thumb — we don't want to overwrite the existing thumbnail
$nas->uploadDirectToNas($video, $tempAbsPath, null); $nas->uploadDirectToNas($video, $tempAbsPath, null);
$video->refresh(); $video->refresh();
$nasReplaceSucceeded = true; $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) { } catch (\Throwable $e) {
\Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage()); \Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage());
// Temp file still exists — fall through to organizeLocalFiles below // Temp file still exists — fall through to organizeLocalFiles below

View File

@ -1198,12 +1198,15 @@
} }
body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; } body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; }
.yt-main.video-view-page { padding: 0 !important; } .yt-main.video-view-page { padding: 0 !important; }
.yt-filter-bar { .yt-filter-bar-wrap {
position: relative !important; position: relative !important;
top: auto !important; top: auto !important;
z-index: auto !important; z-index: auto !important;
margin: -16px -16px 16px !important; margin: -16px -16px 16px !important;
} }
.yt-filter-bar {
padding: 12px 16px !important;
}
} }
</style> </style>

View File

@ -288,8 +288,9 @@ function openEditVideoModal(videoId) {
_editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url })); _editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url }));
_editRenderSlides('t1'); _editRenderSlides('t1');
// Extra tracks // Extra tracks — show whenever the row is declared music, even if the
if (v.is_audio && v.audio_tracks) { // 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)); v.audio_tracks.forEach(track => _editAddExistingTrack(track));
} }