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) <noreply@anthropic.com>
This commit is contained in:
parent
ba8ab11ce4
commit
8d27520bdb
141
app/Console/Commands/RepairMergedVideo.php
Normal file
141
app/Console/Commands/RepairMergedVideo.php
Normal 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,
|
||||
|
||||
98
app/Http/Controllers/VideoMergeController.php
Normal file
98
app/Http/Controllers/VideoMergeController.php
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
app/Services/VideoMergeService.php
Normal file
242
app/Services/VideoMergeService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('video_redirects', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('video_redirects', function (Blueprint $table) {
|
||||
$table->string('source_folder', 500)->nullable()->after('target_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('video_redirects', function (Blueprint $table) {
|
||||
$table->dropColumn('source_folder');
|
||||
});
|
||||
}
|
||||
};
|
||||
6
public/images/default-avatar.svg
Normal file
6
public/images/default-avatar.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150" width="150" height="150">
|
||||
<rect width="150" height="150" fill="#2a2a2a"/>
|
||||
<circle cx="75" cy="60" r="26" fill="#6a6a6a"/>
|
||||
<path d="M25,150 C25,110 50,92 75,92 C100,92 125,110 125,150 Z" fill="#6a6a6a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 317 B |
@ -538,7 +538,7 @@
|
||||
@if(Auth::user()->avatar)
|
||||
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" class="adm-user-avatar" alt="">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" class="adm-user-avatar" alt="">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="adm-user-avatar" alt="">
|
||||
@endif
|
||||
<div class="adm-user-info">
|
||||
<div class="adm-user-name">{{ Str::limit(Auth::user()->name, 18) }}</div>
|
||||
@ -637,7 +637,7 @@
|
||||
@if(Auth::user()->avatar)
|
||||
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" alt="">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" alt="">
|
||||
<img src="{{ Auth::user()->avatar_url }}" alt="">
|
||||
@endif
|
||||
<div class="adm-sidebar-user-info">
|
||||
<div class="adm-sidebar-user-name">{{ Str::limit(Auth::user()->name, 20) }}</div>
|
||||
|
||||
@ -128,6 +128,12 @@
|
||||
<i class="bi bi-pencil"></i>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
@if($video->type === 'music')
|
||||
<button class="action-btn desktop-action" onclick="openMergeModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-signpost-split"></i>
|
||||
<span>Merge</span>
|
||||
</button>
|
||||
@endif
|
||||
{{-- Lyrics generate/regenerate + edit now live inside the player's gear menu
|
||||
so they're always reachable on both mobile and desktop. --}}
|
||||
<button class="action-btn desktop-action" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})"
|
||||
@ -250,6 +256,11 @@
|
||||
<button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@if($video->type === 'music')
|
||||
<button type="button" class="dropdown-item" onclick="openMergeModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-signpost-split"></i> Merge
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
@else
|
||||
<button class="dropdown-item" onclick="window.location.href='{{ route('login') }}'">
|
||||
|
||||
@ -24,8 +24,8 @@
|
||||
|
||||
if (!function_exists('ytcAvatar')) {
|
||||
function ytcAvatar($user) {
|
||||
if (!$user) return 'https://ui-avatars.com/api/?name=User&background=333&color=fff';
|
||||
return $user->avatar_url ?? ('https://ui-avatars.com/api/?name='.urlencode($user->name ?? 'User').'&background=333&color=fff');
|
||||
if (!$user) return asset('images/default-avatar.svg');
|
||||
return $user->avatar_url ?? asset('images/default-avatar.svg');
|
||||
}
|
||||
}
|
||||
if (!function_exists('ytcTime')) {
|
||||
@ -707,8 +707,8 @@ function esc(s) {
|
||||
}
|
||||
function fmt(s) { return s ?? ''; }
|
||||
function avatarUrl(user) {
|
||||
if (!user) return 'https://ui-avatars.com/api/?name=User&background=333&color=fff';
|
||||
return user.avatar_url || ('https://ui-avatars.com/api/?name=' + encodeURIComponent(user.name || 'User') + '&background=333&color=fff');
|
||||
if (!user) return '{{ asset('images/default-avatar.svg') }}';
|
||||
return user.avatar_url || '{{ asset('images/default-avatar.svg') }}';
|
||||
}
|
||||
|
||||
// ── Toast (use global if available, else local) ───────
|
||||
|
||||
@ -468,7 +468,7 @@ function renderInsights(d) {
|
||||
</div>`).join('');
|
||||
|
||||
const recentViewerRows = (d.recent_viewers || []).map(r => {
|
||||
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
|
||||
const avatarSrc = r.user_avatar || '{{ asset('images/default-avatar.svg') }}';
|
||||
const title = `Tap for full activity — ${r.user_name}`;
|
||||
const safeName = (r.user_name || 'Viewer').replace(/'/g, "\\'");
|
||||
return `<div class="ins-dl-user-row" title="${title}"
|
||||
@ -554,7 +554,7 @@ function renderInsights(d) {
|
||||
: '';
|
||||
|
||||
const recentRows = (d.dl_recent||[]).map(r => {
|
||||
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
|
||||
const avatarSrc = r.user_avatar || '{{ asset('images/default-avatar.svg') }}';
|
||||
const safeName = (r.user_name || 'Downloader').replace(/'/g, "\\'");
|
||||
const clickAttr = r.user_id
|
||||
? `onclick="openDownloaderHistory(${r.user_id},'${safeName}','${avatarSrc}')" style="cursor:pointer;" title="See full download history"`
|
||||
|
||||
@ -1230,6 +1230,7 @@
|
||||
@include('layouts.partials.upload-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
@include('layouts.partials.sports-match-modal')
|
||||
@include('layouts.partials.merge-video-modal')
|
||||
@endauth
|
||||
|
||||
<!-- Add to Playlist Modal - Available for all users (shows login prompt if not authenticated) -->
|
||||
|
||||
175
resources/views/layouts/partials/merge-video-modal.blade.php
Normal file
175
resources/views/layouts/partials/merge-video-modal.blade.php
Normal file
@ -0,0 +1,175 @@
|
||||
{{-- Music-video merge modal. Opened via openMergeModal(targetId).
|
||||
Target = the video currently being viewed (survives).
|
||||
Source = the picked video (folded in, then deleted, then 302s to target). --}}
|
||||
<div class="modal fade" id="mergeVideoModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content" style="background:#181818; color:#eee; border:1px solid #2a2a2a;">
|
||||
<div class="modal-header" style="border-bottom:1px solid #2a2a2a;">
|
||||
<h5 class="modal-title"><i class="bi bi-signpost-split"></i> Merge songs</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="color:#aaa; font-size:14px;">
|
||||
The song you pick below will be <strong>merged into this song</strong>.
|
||||
Its audio tracks, slides, comments, views, likes, downloads and shares
|
||||
all move here. The old link will redirect to this song.
|
||||
</p>
|
||||
<p style="color:#f5b544; font-size:13px;"><i class="bi bi-exclamation-triangle"></i> This cannot be undone.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<input id="mergeSearchInput" type="text" class="form-control"
|
||||
placeholder="Search your music songs…"
|
||||
style="background:#0f0f0f; color:#eee; border:1px solid #2a2a2a;">
|
||||
</div>
|
||||
|
||||
<div id="mergeCandidateList" style="max-height:320px; overflow-y:auto; border:1px solid #2a2a2a; border-radius:8px;">
|
||||
<div class="text-center text-secondary p-4">Loading…</div>
|
||||
</div>
|
||||
|
||||
<div id="mergePreviewBox" style="display:none; margin-top:16px; padding:12px; background:#0f0f0f; border:1px solid #2a2a2a; border-radius:8px;">
|
||||
<div style="display:flex; gap:12px; align-items:center; margin-bottom:12px;">
|
||||
<img id="mergePreviewThumb" src="" style="width:80px; height:45px; object-fit:cover; border-radius:4px;">
|
||||
<div>
|
||||
<div style="font-size:13px; color:#aaa;">Merging in:</div>
|
||||
<div id="mergePreviewTitle" style="font-weight:600;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mergePreviewCounts" style="font-size:13px; color:#ccc; line-height:1.8;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top:1px solid #2a2a2a;">
|
||||
<button type="button" class="action-btn" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" id="mergeConfirmBtn" class="action-btn action-btn-primary" disabled onclick="doMerge()">
|
||||
<i class="bi bi-signpost-split"></i> <span>Merge into this song</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
let targetId = null;
|
||||
let sourceId = null;
|
||||
let searchTimer = null;
|
||||
|
||||
window.openMergeModal = function (id) {
|
||||
targetId = id;
|
||||
sourceId = null;
|
||||
document.getElementById('mergePreviewBox').style.display = 'none';
|
||||
document.getElementById('mergeConfirmBtn').disabled = true;
|
||||
document.getElementById('mergeSearchInput').value = '';
|
||||
loadCandidates('');
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('mergeVideoModal'));
|
||||
modal.show();
|
||||
};
|
||||
|
||||
document.getElementById('mergeSearchInput').addEventListener('input', function (e) {
|
||||
clearTimeout(searchTimer);
|
||||
const q = e.target.value;
|
||||
searchTimer = setTimeout(() => loadCandidates(q), 250);
|
||||
});
|
||||
|
||||
function loadCandidates(q) {
|
||||
const list = document.getElementById('mergeCandidateList');
|
||||
list.innerHTML = '<div class="text-center text-secondary p-4">Loading…</div>';
|
||||
const url = '/videos/' + encodeURIComponent(targetId) + '/merge/candidates?q=' + encodeURIComponent(q);
|
||||
fetch(url, { headers: { 'Accept': 'application/json' }})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (!d.items || d.items.length === 0) {
|
||||
list.innerHTML = '<div class="text-center text-secondary p-4">No other music songs to merge.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = d.items.map(it => `
|
||||
<div class="merge-cand" data-id="${it.id}"
|
||||
style="display:flex; gap:10px; align-items:center; padding:8px 10px; cursor:pointer; border-bottom:1px solid #1e1e1e;">
|
||||
<img src="${it.thumbnail}" style="width:64px; height:36px; object-fit:cover; border-radius:4px; flex:none;">
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div style="font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(it.title)}</div>
|
||||
<div style="font-size:12px; color:#888;">${it.created || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
list.querySelectorAll('.merge-cand').forEach(el => {
|
||||
el.addEventListener('click', () => pickSource(el.dataset.id));
|
||||
el.addEventListener('mouseenter', () => el.style.background = '#242424');
|
||||
el.addEventListener('mouseleave', () => el.style.background = '');
|
||||
});
|
||||
})
|
||||
.catch(() => { list.innerHTML = '<div class="text-center text-danger p-4">Failed to load.</div>'; });
|
||||
}
|
||||
|
||||
function pickSource(id) {
|
||||
sourceId = id;
|
||||
const box = document.getElementById('mergePreviewBox');
|
||||
const counts = document.getElementById('mergePreviewCounts');
|
||||
counts.innerHTML = '<div class="text-secondary">Calculating…</div>';
|
||||
box.style.display = 'block';
|
||||
document.getElementById('mergeConfirmBtn').disabled = true;
|
||||
|
||||
const url = '/videos/' + encodeURIComponent(targetId) + '/merge/' + encodeURIComponent(sourceId) + '/preview';
|
||||
fetch(url, { headers: { 'Accept': 'application/json' }})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.error) {
|
||||
counts.innerHTML = '<div class="text-danger">' + escapeHtml(d.error) + '</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('mergePreviewThumb').src = d.source.thumb;
|
||||
document.getElementById('mergePreviewTitle').textContent = d.source.title;
|
||||
const c = d.counts;
|
||||
counts.innerHTML = `
|
||||
<div><i class="bi bi-music-note-list"></i> ${c.tracks} audio track(s) will be added</div>
|
||||
<div><i class="bi bi-images"></i> ${c.slides} slide(s) will move</div>
|
||||
<div><i class="bi bi-chat-left-text"></i> ${c.comments} comment(s) will move</div>
|
||||
<div><i class="bi bi-eye"></i> ${c.views} view(s) will merge</div>
|
||||
<div><i class="bi bi-hand-thumbs-up"></i> ${c.likes_source} like(s) → ${c.likes_after_dedupe} new (deduped by user)</div>
|
||||
<div><i class="bi bi-download"></i> ${c.downloads} download(s) will merge</div>
|
||||
<div><i class="bi bi-share"></i> ${c.shares} share(s) will merge</div>
|
||||
<div><i class="bi bi-collection-play"></i> ${c.playlists_merged} playlist(s) will re-point (dedupe)</div>
|
||||
`;
|
||||
document.getElementById('mergeConfirmBtn').disabled = false;
|
||||
})
|
||||
.catch(() => { counts.innerHTML = '<div class="text-danger">Preview failed.</div>'; });
|
||||
}
|
||||
|
||||
window.doMerge = function () {
|
||||
if (!sourceId || !targetId) return;
|
||||
const btn = document.getElementById('mergeConfirmBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> <span>Merging…</span>';
|
||||
|
||||
const url = '/videos/' + encodeURIComponent(targetId) + '/merge/' + encodeURIComponent(sourceId);
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
if (window.showToast) window.showToast('Songs merged successfully.', 'success');
|
||||
setTimeout(() => window.location.href = d.redirect, 400);
|
||||
} else {
|
||||
if (window.showToast) window.showToast(d.error || 'Merge failed.', 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-signpost-split"></i> <span>Merge into this song</span>';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (window.showToast) window.showToast('Merge failed.', 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-signpost-split"></i> <span>Merge into this song</span>';
|
||||
});
|
||||
};
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[c]);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@ -330,7 +330,7 @@ if ($oldLinks) {
|
||||
@if($user->avatar)
|
||||
<img src="{{ route('media.avatar', $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -527,7 +527,7 @@ if ($oldLinks) {
|
||||
@if($user->avatar)
|
||||
<img src="{{ route('media.avatar', $user->avatar) }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||
<img src="{{ $user->avatar_url }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||
@endif
|
||||
<button type="button" class="avatar-edit-btn" id="avatarEditBtn" title="Change photo">
|
||||
<i class="bi bi-camera-fill"></i>
|
||||
|
||||
@ -62,6 +62,11 @@
|
||||
<button class="action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-pencil"></i> <span>Edit</span>
|
||||
</button>
|
||||
@if($video->type === 'music')
|
||||
<button class="action-btn" onclick="openMergeModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-signpost-split"></i> <span>Merge</span>
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="action-btn">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user