Fix video merge losing images, audio tracks, and lyrics

Three bugs made merge destructive and unrecoverable:

1. Primary-track slide paths were only prefix-swapped {sourceDir} → {targetDir}.
   The promoted primary track got a NEW folder name
   tracks/{lang}-{newTrackId}, but the slide filename kept the old
   tracks/{lang}-{srcVideoId} segment, pointing at a folder that no longer
   existed. Rewrite is now computed from the owning track's updated path, so
   it survives regardless of what the stored filename used to look like.

2. Legacy-layout sources (primary audio directly at {sourceDir}/audio.ext,
   slides at {sourceDir}/slides/) were silently skipped. The old code
   hard-coded tracks/{lang-id}/ as the source location. Merge is now
   layout-aware: it reads dirname($source->path) to pick between folder-move
   (new layout) and per-file moves (legacy — audio, slides/ dir, thumb).

3. Lyrics were lost on legacy sources. {sourceDir}/lyrics/primary.json and
   {sourceDir}/lyrics/track-{id}.json are now explicitly moved into the
   target's per-track folders as lyrics.json, matching the new layout the
   LyricsService reads from.

Added moveFile() helper (mirrors moveFolder on NAS + local, queues to
pending_nas_moves when NAS is unreachable). Verified against six scenarios
including new-layout, legacy, target-with-existing-extras, minimal, 2-extras
with slides and reversed-position lyrics source: all broken-file counts are
zero and slide position order is preserved verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-02 12:18:49 +03:00
1 parent 468820c9fe
commit 5058eaff2f
1 file changed
+110 -22
+110 -22
View File
@@ -90,9 +90,15 @@ class VideoMergeService
DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) { DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) {
// ── 1. Promote source's primary audio to a new track on target ─ // ── 1. Promote source's primary audio to a new track on target ─
$srcPrimaryExt = pathinfo($source->filename ?: 'audio.mp3', PATHINFO_EXTENSION) ?: 'mp3'; // Layout-aware: the source may be stored either as the modern
// per-track folder ({sourceDir}/tracks/{srcLang}-{srcVideoId}/…)
// or as the legacy flat layout ({sourceDir}/audio.ext,
// {sourceDir}/slides/, …). We detect which from source->path
// and move only what actually exists in each case.
$srcPrimaryPath = (string) $source->path;
$srcPrimaryFolder = $srcPrimaryPath !== '' ? dirname($srcPrimaryPath) : $sourceDir;
$srcPrimaryExt = pathinfo($source->filename ?: ($srcPrimaryPath ?: 'audio.mp3'), PATHINFO_EXTENSION) ?: 'mp3';
$srcPrimaryFilename = "audio.{$srcPrimaryExt}"; $srcPrimaryFilename = "audio.{$srcPrimaryExt}";
$srcPrimaryOldFolder = "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, null);
$newPrimaryTrack = VideoAudioTrack::create([ $newPrimaryTrack = VideoAudioTrack::create([
'video_id' => $target->id, 'video_id' => $target->id,
@@ -108,43 +114,83 @@ class VideoMergeService
} }
$newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack); $newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack);
$this->moveFolder($srcPrimaryOldFolder, $newPrimaryFolder); // Case A: primary audio sits in a dedicated per-track folder → one
// folder move brings the audio, its slides and its thumb along.
// Case B: primary audio sits directly at the video root (legacy) →
// move the individual files, since the video root also holds the
// extra tracks' subfolders which stay put until step 2.
if ($srcPrimaryFolder !== $sourceDir && str_starts_with($srcPrimaryFolder, "{$sourceDir}/tracks/")) {
$this->moveFolder($srcPrimaryFolder, $newPrimaryFolder);
} else {
$this->moveFile($srcPrimaryPath, "{$newPrimaryFolder}/{$srcPrimaryFilename}");
// Legacy slides live at {sourceDir}/slides/ as a sibling of audio.
if (is_dir(storage_path('app/' . "{$sourceDir}/slides"))) {
$this->moveFolder("{$sourceDir}/slides", "{$newPrimaryFolder}/slides");
}
// Opportunistically move thumb.{ext} if present.
foreach (['jpg', 'jpeg', 'png', 'webp'] as $ext) {
$thumb = "{$sourceDir}/thumb.{$ext}";
if (file_exists(storage_path('app/' . $thumb))) {
$this->moveFile($thumb, "{$newPrimaryFolder}/thumb.{$ext}");
}
}
// Legacy primary lyrics live at {sourceDir}/lyrics/primary.json.
// The new per-track layout stores lyrics at
// {newPrimaryFolder}/lyrics.json — move the file so lyricsNasPath
// on the target resolves it immediately.
$legacyPrimaryLyrics = "{$sourceDir}/lyrics/primary.json";
if (file_exists(storage_path('app/' . $legacyPrimaryLyrics))) {
$this->moveFile($legacyPrimaryLyrics, "{$newPrimaryFolder}/lyrics.json");
}
}
$newPrimaryTrack->update(['path' => "{$newPrimaryFolder}/{$srcPrimaryFilename}"]); $newPrimaryTrack->update(['path' => "{$newPrimaryFolder}/{$srcPrimaryFilename}"]);
// ── 2. Move each source extra track's folder into target/tracks/ // ── 2. Move each source extra track's folder into target/tracks/
// The folder basename ({lang}-{track_id}) is globally unique because // The folder basename ({lang}-{track_id}) is globally unique
// track ids are globally unique — no collisions with target's own tracks. // because track ids are globally unique — no collisions with
// target's own tracks. We derive the actual old folder from
// the track's stored path so unusual layouts still work.
foreach ($source->audioTracks as $track) { foreach ($source->audioTracks as $track) {
$basename = $this->nas->trackFolderName($source, $track); $oldFolder = $track->path ? dirname($track->path) : "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, $track);
$oldFolder = "{$sourceDir}/tracks/{$basename}"; $newFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($source, $track);
$newFolder = "{$targetDir}/tracks/{$basename}"; if ($oldFolder !== $newFolder) {
$this->moveFolder($oldFolder, $newFolder); $this->moveFolder($oldFolder, $newFolder);
}
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/') $newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1) ? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
: $track->path; : "{$newFolder}/" . ($track->filename ?: 'audio.mp3');
$track->video_id = $target->id; $track->video_id = $target->id;
$track->path = $newTrackPath; $track->path = $newTrackPath;
$track->save(); $track->save();
// Legacy per-track lyrics live at {sourceDir}/lyrics/track-{id}.json.
// In the new layout they belong inside the moved track folder.
$legacyTrackLyrics = "{$sourceDir}/lyrics/track-{$track->id}.json";
if (file_exists(storage_path('app/' . $legacyTrackLyrics))) {
$this->moveFile($legacyTrackLyrics, "{$newFolder}/lyrics.json");
}
} }
// ── 3. Move ALL slides for the source video ──────────────────── // ── 3. Rewrite every source slide's filename to its new on-disk
// One pass: re-parent every source slide, rewrite any filename // location, derived from the track it belongs to AFTER the
// that started with sourceDir/... to targetDir/..., and if the // per-track folder moves above. This is layout-agnostic —
// slide had NULL audio_track_id (belonged to source's primary), // it works for both new (/tracks/{lang-id}/slides/N) and
// point it at the newly-created primary track. // legacy (/{sourceDir}/slides/N) source layouts.
$sourcePrefix = $sourceDir . '/'; $updatedTracks = VideoAudioTrack::where('video_id', $target->id)->get()->keyBy('id');
$targetPrefix = $targetDir . '/';
$slides = DB::table('video_slides')->where('video_id', $source->id)->get(); $slides = DB::table('video_slides')->where('video_id', $source->id)->get();
foreach ($slides as $s) { foreach ($slides as $s) {
$newFilename = $s->filename; $base = $s->filename ? basename($s->filename) : null;
if (is_string($s->filename) && str_starts_with($s->filename, $sourcePrefix)) { if (!$base) continue; // nothing we can safely rewrite
$newFilename = $targetPrefix . substr($s->filename, strlen($sourcePrefix));
} $owningTrackId = $s->audio_track_id === null ? $newPrimaryTrack->id : $s->audio_track_id;
$owningTrack = $updatedTracks->get($owningTrackId);
$owningFolder = $owningTrack && $owningTrack->path ? dirname($owningTrack->path) : $newPrimaryFolder;
$updates = [ $updates = [
'video_id' => $target->id, 'video_id' => $target->id,
'filename' => $newFilename, 'filename' => "{$owningFolder}/slides/{$base}",
]; ];
if ($s->audio_track_id === null) { if ($s->audio_track_id === null) {
$updates['audio_track_id'] = $newPrimaryTrack->id; $updates['audio_track_id'] = $newPrimaryTrack->id;
@@ -197,12 +243,54 @@ class VideoMergeService
}); });
// ── 10. Best-effort cleanup of the emptied source folder ─────────── // ── 10. Best-effort cleanup of the emptied source folder ───────────
// The legacy lyrics/ sibling is tidied first so pruneEmptyFolder's
// rmdir on sourceDir can succeed.
$this->pruneEmptyFolder("{$sourceDir}/lyrics");
$this->pruneEmptyFolder("{$sourceDir}/tracks"); $this->pruneEmptyFolder("{$sourceDir}/tracks");
$this->pruneEmptyFolder($sourceDir); $this->pruneEmptyFolder($sourceDir);
Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]); Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]);
} }
/**
* Move an individual file (not a directory) on NAS + local. Needed for
* legacy-layout sources where the primary audio and thumb live directly
* in the video root rather than inside a per-track subfolder.
*/
private function moveFile(string $oldRel, string $newRel): void
{
if ($oldRel === '' || $newRel === '' || $oldRel === $newRel) return;
$nasMoved = false;
if ($this->nas->isEnabled()) {
$this->nas->mkdirp(dirname($newRel));
$this->nas->renameNasPath($oldRel, $newRel);
$nasMoved = true;
} else {
DB::table('pending_nas_moves')->insert([
'from_path' => $oldRel,
'to_path' => $newRel,
'kind' => 'file',
'origin' => 'merge',
'created_at' => now(),
'updated_at' => now(),
]);
}
$oldLocal = storage_path('app/' . $oldRel);
$newLocal = storage_path('app/' . $newRel);
$localMoved = false;
if (is_file($oldLocal)) {
@mkdir(dirname($newLocal), 0755, true);
$localMoved = @rename($oldLocal, $newLocal);
}
Log::info('Merge: moveFile', [
'old' => $oldRel, 'new' => $newRel,
'nas' => $nasMoved, 'local' => $localMoved,
'queued' => !$nasMoved,
]);
}
/** /**
* Move a folder on both NAS and local disk. NAS ops are queued in * Move a folder on both NAS and local disk. NAS ops are queued in
* pending_nas_moves when NAS is unreachable; nas:auto-sync drains them. * pending_nas_moves when NAS is unreachable; nas:auto-sync drains them.