From 8d27520bdb12613d5d19f9de7b94d1618ebe6c8d Mon Sep 17 00:00:00 2001 From: ghassan Date: Fri, 31 Jul 2026 02:42:43 +0300 Subject: [PATCH] Video merge feature, default avatar, video-redirects table, misc UI tweaks 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) --- app/Console/Commands/RepairMergedVideo.php | 141 ++++++++++ app/Http/Controllers/SuperAdminController.php | 2 +- app/Http/Controllers/VideoMergeController.php | 98 +++++++ app/Models/User.php | 2 +- app/Services/NasSyncService.php | 2 +- app/Services/VideoMergeService.php | 242 ++++++++++++++++++ ...30_000001_create_video_redirects_table.php | 23 ++ ...2_add_source_folder_to_video_redirects.php | 21 ++ public/images/default-avatar.svg | 6 + resources/views/admin/layout.blade.php | 4 +- .../views/components/video-actions.blade.php | 11 + .../views/components/video-comments.blade.php | 8 +- .../views/components/video-insights.blade.php | 4 +- resources/views/layouts/app.blade.php | 1 + .../partials/merge-video-modal.blade.php | 175 +++++++++++++ resources/views/user/profile.blade.php | 4 +- .../videos/partials/video-details.blade.php | 5 + 17 files changed, 736 insertions(+), 13 deletions(-) create mode 100644 app/Console/Commands/RepairMergedVideo.php create mode 100644 app/Http/Controllers/VideoMergeController.php create mode 100644 app/Services/VideoMergeService.php create mode 100644 database/migrations/2026_07_30_000001_create_video_redirects_table.php create mode 100644 database/migrations/2026_07_30_000002_add_source_folder_to_video_redirects.php create mode 100644 public/images/default-avatar.svg create mode 100644 resources/views/layouts/partials/merge-video-modal.blade.php diff --git a/app/Console/Commands/RepairMergedVideo.php b/app/Console/Commands/RepairMergedVideo.php new file mode 100644 index 0000000..971485c --- /dev/null +++ b/app/Console/Commands/RepairMergedVideo.php @@ -0,0 +1,141 @@ +isEnabled()) { + $this->error('NAS is not reachable — cannot repair. Try again when NAS is back online.'); + return self::FAILURE; + } + + $targetArg = $this->argument('target'); + $target = is_numeric($targetArg) + ? Video::find((int) $targetArg) + : Video::find(Video::decodeId((string) $targetArg)); + if (!$target) { + $this->error("Target video not found: {$targetArg}"); + return self::FAILURE; + } + $this->info("Target: [{$target->id}] {$target->title}"); + + $redirects = DB::table('video_redirects')->where('target_id', $target->id); + if ($sid = $this->option('source-id')) { + $redirects->where('source_id', (int) $sid); + } + $redirects = $redirects->get(); + + if ($redirects->isEmpty()) { + $this->warn('No video_redirects rows found for this target.'); + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $adopted = 0; + + $folderOverride = $this->option('source-folder'); + + foreach ($redirects as $r) { + $this->line(''); + $sourceFolder = $folderOverride ?: $r->source_folder; + $this->info("Source id {$r->source_id} → folder: " . ($sourceFolder ?: '(unknown)')); + + if (!$sourceFolder) { + $this->warn(' Skipping — no source_folder recorded. Pass --source-folder="users/…/music/…" to specify.'); + continue; + } + // Backfill the redirect row so future runs don't need the override. + if (!$r->source_folder && !$dryRun && $folderOverride) { + DB::table('video_redirects')->where('source_id', $r->source_id) + ->update(['source_folder' => $folderOverride]); + $r->source_folder = $folderOverride; + } else { + $r->source_folder = $sourceFolder; + } + + $tracksDir = $r->source_folder . '/tracks'; + $subdirs = $nas->listNasDirs($tracksDir); + if (empty($subdirs)) { + $this->line(' No leftover track subfolders. Cleaning up empty source folder.'); + if (!$dryRun) { + $nas->deleteFile($r->source_folder . '/meta.json'); + $nas->deleteFolder($tracksDir); + $nas->deleteFolder($r->source_folder); + } + continue; + } + + $targetDir = $nas->resolveVideoDir($target); + $nas->mkdirp("{$targetDir}/tracks"); + + foreach ($subdirs as $sub) { + // Parse "{lang}-{origId}" to recover the language for the new track. + $lang = 'xx'; + if (preg_match('/^([a-z]{2,10})-\d+$/i', $sub, $m)) { + $lang = strtolower($m[1]); + } + + $this->line(" Adopting track folder: {$sub} (lang={$lang})"); + + if ($dryRun) { $adopted++; continue; } + + $newTrack = VideoAudioTrack::create([ + 'video_id' => $target->id, + 'language' => $lang, + 'label' => "Recovered from merge #{$r->source_id}", + 'title' => null, + 'filename' => 'audio.mp3', + 'path' => '', + ]); + $newBasename = $nas->trackFolderName($target, $newTrack); + $oldRel = "{$tracksDir}/{$sub}"; + $newRel = "{$targetDir}/tracks/{$newBasename}"; + + $nas->renameNasPath($oldRel, $newRel); + + // Local mirror (if the folder happens to exist locally too). + $oldLocal = storage_path('app/' . $oldRel); + $newLocal = storage_path('app/' . $newRel); + if (is_dir($oldLocal)) { + @mkdir(dirname($newLocal), 0755, true); + @rename($oldLocal, $newLocal); + } + + // Detect audio filename from local mirror if present, else default. + $audioName = 'audio.mp3'; + foreach (['audio.mp3', 'audio.m4a', 'audio.aac', 'audio.ogg', 'audio.wav', 'audio.opus'] as $cand) { + if (file_exists("{$newLocal}/{$cand}")) { $audioName = $cand; break; } + } + $newTrack->update([ + 'filename' => $audioName, + 'path' => "{$newRel}/{$audioName}", + ]); + + $adopted++; + } + + // Clean up now-empty source folder. + if (!$dryRun) { + $nas->deleteFile($r->source_folder . '/meta.json'); + $nas->deleteFolder($tracksDir); + $nas->deleteFolder($r->source_folder); + } + } + + $this->line(''); + $this->info(($dryRun ? '[dry-run] Would adopt ' : 'Adopted ') . $adopted . ' track folder(s).'); + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/SuperAdminController.php b/app/Http/Controllers/SuperAdminController.php index 36eb2fa..95926bf 100644 --- a/app/Http/Controllers/SuperAdminController.php +++ b/app/Http/Controllers/SuperAdminController.php @@ -772,7 +772,7 @@ class SuperAdminController extends Controller ->groupBy('users.id', 'users.name', 'users.avatar') ->orderByDesc('video_count')->take($limit)->get(); return response()->json(['type' => 'uploaders', 'items' => $items->map(fn($u) => [ - 'avatar' => $u->avatar ? asset('storage/avatars/'.$u->avatar) : 'https://i.pravatar.cc/40?u='.$u->id, + 'avatar' => $u->avatar ? asset('storage/avatars/'.$u->avatar) : asset('images/default-avatar.svg'), 'name' => $u->name, 'videos' => $u->video_count, 'views' => $u->total_views, diff --git a/app/Http/Controllers/VideoMergeController.php b/app/Http/Controllers/VideoMergeController.php new file mode 100644 index 0000000..76ecf10 --- /dev/null +++ b/app/Http/Controllers/VideoMergeController.php @@ -0,0 +1,98 @@ +authorizeOwner($target); + if ($target->type !== 'music') { + return response()->json(['error' => 'Only music videos can be merged.'], 422); + } + + $q = trim((string) $request->query('q', '')); + $query = Video::where('user_id', Auth::id()) + ->where('type', 'music') + ->where('id', '!=', $target->id) + ->orderByDesc('created_at'); + if ($q !== '') { + $query->where('title', 'like', '%' . $q . '%'); + } + $items = $query->limit(30)->get(['id', 'title', 'thumbnail', 'created_at'])->map(function ($v) { + return [ + 'id' => $v->getRouteKey(), + 'title' => $v->title, + 'thumbnail' => $v->thumbnail_url, + 'created' => $v->created_at?->diffForHumans(), + ]; + }); + + return response()->json(['items' => $items]); + } + + public function preview(Video $target, Video $source) + { + $this->authorizeOwner($target); + $this->authorizeOwner($source); + if ($target->type !== 'music' || $source->type !== 'music') { + return response()->json(['error' => 'Only music videos can be merged.'], 422); + } + if ($target->id === $source->id) { + return response()->json(['error' => 'Cannot merge a video with itself.'], 422); + } + + return response()->json([ + 'source' => [ + 'id' => $source->getRouteKey(), + 'title' => $source->title, + 'thumb' => $source->thumbnail_url, + ], + 'target' => [ + 'id' => $target->getRouteKey(), + 'title' => $target->title, + 'thumb' => $target->thumbnail_url, + ], + 'counts' => $this->merger->preview($source, $target), + ]); + } + + public function merge(Request $request, Video $target, Video $source) + { + $this->authorizeOwner($target); + $this->authorizeOwner($source); + + try { + $this->merger->merge($source, $target); + } catch (\Throwable $e) { + \Log::error('Video merge failed', [ + 'source' => $source->id, + 'target' => $target->id, + 'err' => $e->getMessage(), + ]); + return response()->json(['error' => 'Merge failed: ' . $e->getMessage()], 500); + } + + return response()->json([ + 'ok' => true, + 'redirect' => route('videos.show', $target), + ]); + } + + private function authorizeOwner(Video $video): void + { + $user = Auth::user(); + if (!$user) abort(401); + if ($user->id !== $video->user_id && !$user->isSuperAdmin()) abort(403); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 176e5fc..1814ca3 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -168,7 +168,7 @@ class User extends Authenticatable implements MustVerifyEmail return route('media.avatar', $this->avatar).'?v='.($this->updated_at?->timestamp ?? '0'); } - return 'https://i.pravatar.cc/150?u='.$this->id; + return asset('images/default-avatar.svg'); } public function getBannerUrlAttribute(): ?string diff --git a/app/Services/NasSyncService.php b/app/Services/NasSyncService.php index 1ee9229..18d8fa5 100644 --- a/app/Services/NasSyncService.php +++ b/app/Services/NasSyncService.php @@ -1333,7 +1333,7 @@ class NasSyncService /** * Rename a path on the NAS share (works for both files and directories). */ - private function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void + public function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void { $cfg = $this->cfg(); $target = escapeshellarg($this->smbTarget($cfg)); diff --git a/app/Services/VideoMergeService.php b/app/Services/VideoMergeService.php new file mode 100644 index 0000000..de9f887 --- /dev/null +++ b/app/Services/VideoMergeService.php @@ -0,0 +1,242 @@ +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); + } + } +} diff --git a/database/migrations/2026_07_30_000001_create_video_redirects_table.php b/database/migrations/2026_07_30_000001_create_video_redirects_table.php new file mode 100644 index 0000000..db39825 --- /dev/null +++ b/database/migrations/2026_07_30_000001_create_video_redirects_table.php @@ -0,0 +1,23 @@ +id(); + $table->unsignedBigInteger('source_id')->unique(); + $table->foreignId('target_id')->constrained('videos')->cascadeOnDelete(); + $table->timestamp('created_at')->nullable(); + $table->index('target_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('video_redirects'); + } +}; diff --git a/database/migrations/2026_07_30_000002_add_source_folder_to_video_redirects.php b/database/migrations/2026_07_30_000002_add_source_folder_to_video_redirects.php new file mode 100644 index 0000000..3df845f --- /dev/null +++ b/database/migrations/2026_07_30_000002_add_source_folder_to_video_redirects.php @@ -0,0 +1,21 @@ +string('source_folder', 500)->nullable()->after('target_id'); + }); + } + + public function down(): void + { + Schema::table('video_redirects', function (Blueprint $table) { + $table->dropColumn('source_folder'); + }); + } +}; diff --git a/public/images/default-avatar.svg b/public/images/default-avatar.svg new file mode 100644 index 0000000..afc43cd --- /dev/null +++ b/public/images/default-avatar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/views/admin/layout.blade.php b/resources/views/admin/layout.blade.php index befb78b..b012be2 100644 --- a/resources/views/admin/layout.blade.php +++ b/resources/views/admin/layout.blade.php @@ -538,7 +538,7 @@ @if(Auth::user()->avatar) @else - + @endif