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.'); } if (!$this->nas->isEnabled()) { throw new \RuntimeException( 'NAS is currently unreachable — merge is disabled until it comes back online.' ); } $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.'); } Log::info('Merge: begin', [ 'source_id' => $source->id, 'target_id' => $target->id, 'source_dir' => $sourceDir, 'target_dir' => $targetDir, ]); // Ensure target has a tracks/ subfolder on both NAS and local $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 ─ $srcPrimaryExt = pathinfo($source->filename ?: 'audio.mp3', PATHINFO_EXTENSION) ?: 'mp3'; $srcPrimaryFilename = "audio.{$srcPrimaryExt}"; $srcPrimaryOldFolder = "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, null); $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); $this->moveFolder($srcPrimaryOldFolder, $newPrimaryFolder); $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. foreach ($source->audioTracks as $track) { $basename = $this->nas->trackFolderName($source, $track); $oldFolder = "{$sourceDir}/tracks/{$basename}"; $newFolder = "{$targetDir}/tracks/{$basename}"; $this->moveFolder($oldFolder, $newFolder); $newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/') ? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1) : $track->path; $track->video_id = $target->id; $track->path = $newTrackPath; $track->save(); } // ── 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 . '/'; $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)); } $updates = [ 'video_id' => $target->id, 'filename' => $newFilename, ]; 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 ─────────── $this->pruneEmptyFolder("{$sourceDir}/tracks"); $this->pruneEmptyFolder($sourceDir); Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]); } /** * Move a folder on both NAS and local disk. Logs the outcome. */ 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; } $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, ]); } private function pruneEmptyFolder(string $rel): void { if ($this->nas->isEnabled()) { $this->nas->deleteFile("{$rel}/meta.json"); $this->nas->deleteFolder($rel); } $local = storage_path('app/' . $rel); if (is_dir($local)) { @unlink($local . '/meta.json'); @rmdir($local); } } }