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