where('video_id', $source->id)->count(); $slides = DB::table('video_slides')->where('video_id', $source->id)->count(); $comments = DB::table('comments')->where('video_id', $source->id)->count(); $views = DB::table('video_views')->where('video_id', $source->id)->count(); $downloads = DB::table('video_downloads')->where('video_id', $source->id)->count(); $shares = DB::table('video_shares')->where('video_id', $source->id)->count(); $srcLikeUsers = DB::table('video_likes')->where('video_id', $source->id)->pluck('user_id'); $tgtLikeUsers = DB::table('video_likes')->where('video_id', $target->id)->pluck('user_id')->flip(); $newLikes = $srcLikeUsers->reject(fn ($uid) => $tgtLikeUsers->has($uid))->count(); $srcPlaylists = DB::table('playlist_videos')->where('video_id', $source->id)->pluck('playlist_id'); $tgtPlaylists = DB::table('playlist_videos')->where('video_id', $target->id)->pluck('playlist_id')->flip(); $plMerged = $srcPlaylists->reject(fn ($pid) => $tgtPlaylists->has($pid))->count(); return [ 'tracks' => $tracks + 1, // +1 = source's primary becomes a new track on target 'slides' => $slides, 'comments' => $comments, 'views' => $views, 'downloads' => $downloads, 'shares' => $shares, 'likes_source' => $srcLikeUsers->count(), 'likes_after_dedupe' => $newLikes, 'playlists_merged' => $plMerged, ]; } /** * Merge $source video into $target. Both must be music-type. * * Simplified model: everything under sourceDir moves to targetDir, filename * prefixes are rewritten in one pass, and the user rearranges tracks/slides * in the edit UI afterwards. No special "primary vs extra" bookkeeping. */ public function merge(Video $source, Video $target): void { if ($source->id === $target->id) { throw new \InvalidArgumentException('Cannot merge a video with itself.'); } if ($source->type !== 'music' || $target->type !== 'music') { throw new \InvalidArgumentException('Only music-type videos can be merged.'); } $source->loadMissing(['audioTracks']); $sourceDir = $this->nas->resolveVideoDir($source); $targetDir = $this->nas->resolveVideoDir($target); if ($sourceDir === $targetDir) { throw new \RuntimeException('Source and target resolve to the same folder — refusing to merge.'); } // Merge is DB-first: the schema changes always run, and file moves // are best-effort on both sides. When NAS is unreachable, per-track // NAS renames get queued in `pending_nas_moves` and drained by // `nas:auto-sync` once NAS returns. $nasReachable = $this->nas->isEnabled(); Log::info('Merge: begin', [ 'source_id' => $source->id, 'target_id' => $target->id, 'source_dir' => $sourceDir, 'target_dir' => $targetDir, 'nas_reachable' => $nasReachable, ]); // Ensure target has a tracks/ subfolder — always locally; on NAS // only when it's reachable (mkdirp itself no-ops otherwise). $this->nas->mkdirp("{$targetDir}/tracks"); @mkdir(storage_path("app/{$targetDir}/tracks"), 0755, true); DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) { // ── 1. Promote source's primary audio to a new track on target ─ // 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}"; $newPrimaryTrack = VideoAudioTrack::create([ 'video_id' => $target->id, 'language' => $source->language ?: 'xx', 'label' => $source->title ?: null, 'title' => $source->title ?: null, 'description' => $source->description, 'filename' => $srcPrimaryFilename, 'path' => '', ]); if (!$newPrimaryTrack || !$newPrimaryTrack->id) { throw new \RuntimeException('Failed to create promoted primary track record.'); } $newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack); // 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. We derive the actual old folder from // the track's stored path so unusual layouts still work. foreach ($source->audioTracks as $track) { $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) : "{$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. 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) { $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' => "{$owningFolder}/slides/{$base}", ]; if ($s->audio_track_id === null) { $updates['audio_track_id'] = $newPrimaryTrack->id; } DB::table('video_slides')->where('id', $s->id)->update($updates); } // ── 4. Re-parent comments / views / downloads / shares ───────── DB::table('comments')->where('video_id', $source->id)->update(['video_id' => $target->id]); DB::table('video_views')->where('video_id', $source->id)->update(['video_id' => $target->id]); DB::table('video_downloads')->where('video_id', $source->id)->update(['video_id' => $target->id]); DB::table('video_shares')->where('video_id', $source->id)->update(['video_id' => $target->id]); // ── 5. Likes — dedupe by user_id ──────────────────────────────── $existingLikeUsers = DB::table('video_likes')->where('video_id', $target->id)->pluck('user_id')->all(); if (!empty($existingLikeUsers)) { DB::table('video_likes') ->where('video_id', $source->id) ->whereIn('user_id', $existingLikeUsers) ->delete(); } DB::table('video_likes')->where('video_id', $source->id)->update(['video_id' => $target->id]); // ── 6. Playlist pivots — dedupe by playlist_id ───────────────── $existingPlaylists = DB::table('playlist_videos')->where('video_id', $target->id)->pluck('playlist_id')->all(); if (!empty($existingPlaylists)) { DB::table('playlist_videos') ->where('video_id', $source->id) ->whereIn('playlist_id', $existingPlaylists) ->delete(); } DB::table('playlist_videos')->where('video_id', $source->id)->update(['video_id' => $target->id]); // ── 7. Counters ──────────────────────────────────────────────── DB::table('videos')->where('id', $target->id)->update([ 'download_count' => DB::raw('COALESCE(download_count,0) + ' . (int) $source->download_count), 'share_count' => DB::raw('COALESCE(share_count,0) + ' . (int) $source->share_count), ]); // ── 8. Redirect row (remember source folder for later repair) ── DB::table('video_redirects')->insert([ 'source_id' => $source->id, 'target_id' => $target->id, 'source_folder' => $sourceDir, 'created_at' => now(), ]); // ── 9. Delete source video row ───────────────────────────────── DB::table('videos')->where('id', $source->id)->delete(); }); // ── 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. */ private function moveFolder(string $oldRel, string $newRel): void { if ($oldRel === $newRel) return; $nasMoved = false; if ($this->nas->isEnabled()) { $this->nas->renameNasPath($oldRel, $newRel); $nasMoved = true; } else { // Queue for later — nas:auto-sync will do the rename when NAS is back. DB::table('pending_nas_moves')->insert([ 'from_path' => $oldRel, 'to_path' => $newRel, 'kind' => 'folder', 'origin' => 'merge', 'created_at' => now(), 'updated_at' => now(), ]); } $oldLocal = storage_path('app/' . $oldRel); $newLocal = storage_path('app/' . $newRel); $localMoved = false; if (is_dir($oldLocal)) { @mkdir(dirname($newLocal), 0755, true); $localMoved = @rename($oldLocal, $newLocal); } Log::info('Merge: moveFolder', [ 'old' => $oldRel, 'new' => $newRel, 'nas' => $nasMoved, 'local' => $localMoved, 'queued' => !$nasMoved, ]); } private function pruneEmptyFolder(string $rel): void { if ($this->nas->isEnabled()) { $this->nas->deleteFile("{$rel}/meta.json"); $this->nas->deleteFolder($rel); } // If NAS was unreachable we DON'T queue a delete: after auto-sync // drains the pending moves, the leftover source folder will be // empty and can be reaped by a subsequent housekeeping pass (or by // the videos:repair-merged command). Deleting a folder we haven't // yet emptied would blow away data. $local = storage_path('app/' . $rel); if (is_dir($local)) { @unlink($local . '/meta.json'); @rmdir($local); } } }