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
+109
-21
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user