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:
parent
da714958c6
commit
3d8d7b8efe
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user