Compare commits

..

No commits in common. "e0aa6e481778a06b13e7d6277a551bc25e49ab77" and "da714958c6a492b0798f97bf9dd3acb7ffaa76c1" have entirely different histories.

3 changed files with 32 additions and 304 deletions

View File

@ -1206,170 +1206,9 @@ 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 (($data['type'] ?? $video->type) === 'music') {
if ($this->isAudioOnlyFile($video)) {
// slides_order is a JSON array of kept slide IDs in their new order
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
@ -1456,8 +1295,7 @@ class VideoController extends Controller
}
// ── Audio track management (delete + add) ────────────────────────────
// Gate on declared type, not extension (see slide gate above).
if (($data['type'] ?? $video->type) === 'music') {
if ($this->isAudioOnlyFile($video)) {
$nas = app(\App\Services\NasSyncService::class);
// Delete tracks requested for removal
@ -1750,19 +1588,6 @@ 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);
@ -1832,24 +1657,13 @@ class VideoController extends Controller
if ($nas->isEnabled()) {
// Point filename at the new file (uploadDirectToNas uses this for ext)
$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);
$video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]);
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

View File

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

View File

@ -108,7 +108,6 @@
<div class="um-track-card" id="edit-tc-t1-card">
<div class="um-tc-body">
<div class="um-tc-left">
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
<div class="um-tc-num">1</div>
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-t1"></span>
<div class="um-tc-info">
@ -117,6 +116,12 @@
</div>
</div>
<div class="um-tc-right">
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('t1-card','up')" title="Move up" style="display:none;">
<i class="bi bi-arrow-up"></i>
</button>
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('t1-card','down')" title="Move down" style="display:none;">
<i class="bi bi-arrow-down"></i>
</button>
<button type="button" class="action-btn" onclick="editOpenTrackPopup('t1')">
<i class="bi bi-pencil"></i> <span>Edit</span>
</button>
@ -198,24 +203,6 @@
<style>
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
#editVideoModal.show .modal-dialog { opacity: 1; }
/* ── Track drag-and-drop reorder ─────────────────────────────── */
#edit-tc-list .um-track-card { position: relative; }
#edit-tc-list .um-tc-drag {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; margin-right: 2px;
color: #666; cursor: grab; user-select: none;
border-radius: 6px; transition: color .15s, background .15s;
flex-shrink: 0;
}
#edit-tc-list .um-tc-drag:hover { color: #e5e5e5; background: rgba(255,255,255,.05); }
#edit-tc-list .um-tc-drag:active { cursor: grabbing; }
#edit-tc-list .um-tc-drag i { font-size: 14px; line-height: 1; }
#edit-tc-list .um-track-card.dragging {
opacity: .4; outline: 2px dashed #e61e1e; outline-offset: -2px;
}
#edit-tc-list .um-track-card.drop-before { box-shadow: 0 -3px 0 0 #e61e1e inset; }
#edit-tc-list .um-track-card.drop-after { box-shadow: 0 3px 0 0 #e61e1e inset; }
</style>
<script>
@ -301,9 +288,8 @@ function openEditVideoModal(videoId) {
_editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url }));
_editRenderSlides('t1');
// 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) {
// Extra tracks
if (v.is_audio && v.audio_tracks) {
v.audio_tracks.forEach(track => _editAddExistingTrack(track));
}
@ -460,16 +446,33 @@ function _editFmtSize(bytes) {
return parseFloat((bytes / Math.pow(k,i)).toFixed(2)) + ' ' + sizes[i];
}
// ── Track ordering (drag-and-drop) ────────────────────────────────────────────
// ── Track ordering ────────────────────────────────────────────────────────────
function editMoveTrack(cardSuffix, dir) {
const card = document.getElementById('edit-tc-' + cardSuffix);
if (!card) return;
const list = document.getElementById('edit-tc-list');
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const idx = cards.indexOf(card);
console.log('%c[EditTrack] Reorder:', 'color:#3b82f6', { card: card.id, trackId: card.dataset.trackId || 'primary', direction: dir, fromPos: idx + 1 });
if (dir === 'up' && idx > 0) list.insertBefore(card, cards[idx - 1]);
if (dir === 'down' && idx < cards.length - 1) list.insertBefore(cards[idx + 1], card);
_editUpdateTrackPositions();
}
function _editUpdateTrackPositions() {
const list = document.getElementById('edit-tc-list');
if (!list) return;
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const total = cards.length;
cards.forEach((card, i) => {
const numEl = card.querySelector('.um-tc-num');
if (numEl) numEl.textContent = i + 1;
const badge = card.querySelector('.um-tc-primary');
if (badge) badge.style.display = i === 0 ? '' : 'none';
const up = card.querySelector('.edit-tc-arrow-up');
const down = card.querySelector('.edit-tc-arrow-down');
if (up) up.style.display = (i === 0) ? 'none' : '';
if (down) down.style.display = (i === total - 1) ? 'none' : '';
});
// Set promote_track_id: non-empty only when a secondary is at position 1
const first = cards[0];
@ -482,93 +485,6 @@ function _editUpdateTrackPositions() {
}
}
// HTML5 drag-and-drop: bind once on the shared parent so newly appended cards
// pick it up without extra wiring. The card starts non-draggable so text
// selection / button clicks work; grabbing the grip handle flips draggable=on
// for the duration of the drag.
(function () {
const list = document.getElementById('edit-tc-list');
if (!list || list.dataset.dndWired) return;
list.dataset.dndWired = '1';
// Reset any draggable="true" set by the initial HTML — we only enable it
// on demand via the grip handle.
list.querySelectorAll('.um-track-card').forEach(c => c.removeAttribute('draggable'));
let dragged = null;
list.addEventListener('mousedown', function (e) {
const handle = e.target.closest('.um-tc-drag');
if (!handle) return;
const card = handle.closest('.um-track-card');
if (!card || card.parentElement !== list) return;
card.setAttribute('draggable', 'true');
});
list.addEventListener('mouseup', function () {
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
});
list.addEventListener('dragstart', function (e) {
const card = e.target.closest('.um-track-card');
if (!card || card.parentElement !== list || card.getAttribute('draggable') !== 'true') {
e.preventDefault();
return;
}
dragged = card;
card.classList.add('dragging');
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', card.id); } catch (_) {}
});
list.addEventListener('dragend', function () {
if (dragged) dragged.classList.remove('dragging');
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
dragged = null;
});
// Always accept drops anywhere inside the list so the browser doesn't
// veto the drop when the cursor slips into gaps between cards.
list.addEventListener('dragover', function (e) {
if (!dragged) return;
e.preventDefault();
try { e.dataTransfer.dropEffect = 'move'; } catch (_) {}
const over = e.target.closest('.um-track-card');
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
if (!over || over === dragged || over.parentElement !== list) return;
const r = over.getBoundingClientRect();
const after = (e.clientY - r.top) > (r.height / 2);
over.classList.add(after ? 'drop-after' : 'drop-before');
});
list.addEventListener('drop', function (e) {
if (!dragged) return;
e.preventDefault();
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const over = e.target.closest('.um-track-card');
let refNode;
if (over && over !== dragged && over.parentElement === list) {
const r = over.getBoundingClientRect();
const after = (e.clientY - r.top) > (r.height / 2);
refNode = after ? over.nextSibling : over;
} else {
// Cursor fell into a gap or outside a card — figure out the closest slot
// by comparing cursor Y against each card's midpoint.
let insertBefore = null;
for (const c of cards) {
if (c === dragged) continue;
const r = c.getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { insertBefore = c; break; }
}
refNode = insertBefore; // null → append to end
}
if (refNode === dragged) refNode = dragged.nextSibling;
list.insertBefore(dragged, refNode);
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
console.log('%c[EditTrack] Drag-reordered:', 'color:#3b82f6', { moved: dragged.id, refNode: refNode?.id || '(end)' });
_editUpdateTrackPositions();
});
})();
function _editSetLangCsd(wrap, code) {
if (!wrap || !code) return;
const opt = wrap.querySelector(`.csd-opt[data-v="${code}"]`);
@ -947,7 +863,6 @@ function _editTrackCard(n, trackId, isExisting) {
card.innerHTML = `
<div class="um-tc-body">
<div class="um-tc-left">
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
<div class="um-tc-num">${n + 1}</div>
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-e${n}"></span>
<div class="um-tc-info">
@ -956,6 +871,8 @@ function _editTrackCard(n, trackId, isExisting) {
</div>
</div>
<div class="um-tc-right">
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('e${n}','up')" title="Move up" style="display:none;"><i class="bi bi-arrow-up"></i></button>
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('e${n}','down')" title="Move down" style="display:none;"><i class="bi bi-arrow-down"></i></button>
<button type="button" class="action-btn" onclick="editOpenTrackPopup('e${n}')"><i class="bi bi-pencil"></i> <span>Edit</span></button>
${deleteBtn}
</div>