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:
1 parent
468820c9fe
commit
5058eaff2f
1 file changed
+110
-22
@@ -90,9 +90,15 @@ class VideoMergeService
|
||||
DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) {
|
||||
|
||||
// ── 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}";
|
||||
$srcPrimaryOldFolder = "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, null);
|
||||
|
||||
$newPrimaryTrack = VideoAudioTrack::create([
|
||||
'video_id' => $target->id,
|
||||
@@ -108,43 +114,83 @@ class VideoMergeService
|
||||
}
|
||||
|
||||
$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}"]);
|
||||
|
||||
// ── 2. Move each source extra track's folder into target/tracks/
|
||||
// The folder basename ({lang}-{track_id}) is globally unique because
|
||||
// track ids are globally unique — no collisions with target's own tracks.
|
||||
// The folder basename ({lang}-{track_id}) is globally unique
|
||||
// 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) {
|
||||
$basename = $this->nas->trackFolderName($source, $track);
|
||||
$oldFolder = "{$sourceDir}/tracks/{$basename}";
|
||||
$newFolder = "{$targetDir}/tracks/{$basename}";
|
||||
$this->moveFolder($oldFolder, $newFolder);
|
||||
$oldFolder = $track->path ? dirname($track->path) : "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||
$newFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||
if ($oldFolder !== $newFolder) {
|
||||
$this->moveFolder($oldFolder, $newFolder);
|
||||
}
|
||||
|
||||
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
|
||||
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
|
||||
: $track->path;
|
||||
: "{$newFolder}/" . ($track->filename ?: 'audio.mp3');
|
||||
|
||||
$track->video_id = $target->id;
|
||||
$track->path = $newTrackPath;
|
||||
$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 ────────────────────
|
||||
// One pass: re-parent every source slide, rewrite any filename
|
||||
// that started with sourceDir/... to targetDir/..., and if the
|
||||
// slide had NULL audio_track_id (belonged to source's primary),
|
||||
// point it at the newly-created primary track.
|
||||
$sourcePrefix = $sourceDir . '/';
|
||||
$targetPrefix = $targetDir . '/';
|
||||
// ── 3. Rewrite every source slide's filename to its new on-disk
|
||||
// location, derived from the track it belongs to AFTER the
|
||||
// per-track folder moves above. This is layout-agnostic —
|
||||
// it works for both new (/tracks/{lang-id}/slides/N) and
|
||||
// legacy (/{sourceDir}/slides/N) source layouts.
|
||||
$updatedTracks = VideoAudioTrack::where('video_id', $target->id)->get()->keyBy('id');
|
||||
$slides = DB::table('video_slides')->where('video_id', $source->id)->get();
|
||||
foreach ($slides as $s) {
|
||||
$newFilename = $s->filename;
|
||||
if (is_string($s->filename) && str_starts_with($s->filename, $sourcePrefix)) {
|
||||
$newFilename = $targetPrefix . substr($s->filename, strlen($sourcePrefix));
|
||||
}
|
||||
$base = $s->filename ? basename($s->filename) : null;
|
||||
if (!$base) continue; // nothing we can safely rewrite
|
||||
|
||||
$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 = [
|
||||
'video_id' => $target->id,
|
||||
'filename' => $newFilename,
|
||||
'filename' => "{$owningFolder}/slides/{$base}",
|
||||
];
|
||||
if ($s->audio_track_id === null) {
|
||||
$updates['audio_track_id'] = $newPrimaryTrack->id;
|
||||
@@ -197,12 +243,54 @@ class VideoMergeService
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
|
||||
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
|
||||
* pending_nas_moves when NAS is unreachable; nas:auto-sync drains them.
|
||||
|
||||
Reference in new issue
Block a user