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) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-07-31 02:42:43 +03:00
1 parent ba8ab11ce4
commit 8d27520bdb
17 files changed
+736 -13

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Console\Commands;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RepairMergedVideo extends Command
{
protected $signature = 'videos:repair-merged {target : Target video encoded id (from the URL) or numeric id} {--source-id= : Force a specific source id from video_redirects} {--source-folder= : Override the source folder path (use when the redirect row has none — pre-repair-column merges)} {--dry-run : Show what would happen, do not modify anything}';
protected $description = 'After a music-video merge, adopt any track subfolders still stuck in the source folder on NAS as new audio tracks on the target video. Safe to re-run.';
public function handle(NasSyncService $nas): int
{
if (!$nas->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;
}
}
@@ -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,
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers;
use App\Models\Video;
use App\Services\VideoMergeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class VideoMergeController extends Controller
{
public function __construct(private VideoMergeService $merger) {}
/**
* List the current user's other music videos that $target can be merged with.
*/
public function candidates(Request $request, Video $target)
{
$this->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);
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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));
+242
View File
@@ -0,0 +1,242 @@
<?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);
}
}
}