Bundled pre-existing changes on the branch: - Video merge: controller, service, admin merge modal, and a repair console command; new video_redirects table (with source_folder) so merged/moved videos keep resolving under their old keys. - Default avatar SVG for users without a profile picture. - Assorted view tweaks across profile, video actions/comments/insights, admin layout, video-details, and the app layout. - Small touch-ups in SuperAdminController, User model, NasSyncService. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
11 KiB
PHP
243 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Video;
|
|
use App\Models\VideoAudioTrack;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class VideoMergeService
|
|
{
|
|
public function __construct(private NasSyncService $nas) {}
|
|
|
|
/**
|
|
* Preview counts for merging $source into $target. Read-only.
|
|
*/
|
|
public function preview(Video $source, Video $target): array
|
|
{
|
|
$tracks = DB::table('video_audio_tracks')->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);
|
|
}
|
|
}
|
|
}
|