diff --git a/app/Services/VideoMergeService.php b/app/Services/VideoMergeService.php index a80f1c8..6a4ba84 100644 --- a/app/Services/VideoMergeService.php +++ b/app/Services/VideoMergeService.php @@ -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.