takeone-youtube-clone/app/Http/Controllers/SuperAdminController.php
ghassan a4384113c2 Audio songs: one-folder storage, version-aware download/share, GPU-checked renders
Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
  unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
  subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
  updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).

Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
  is carried through share links and auto-selects audio + title + flag + about +
  OG/meta on open.

GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
  256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
  frequency bars; loop-filter rebuild makes generation ~25x faster.

Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:03:43 +03:00

1371 lines
58 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers;
use App\Models\AuditLog;
use App\Models\Setting;
use App\Models\User;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class SuperAdminController extends Controller
{
public function __construct()
{
$this->middleware('super_admin')->except(['exitImpersonation']);
}
// Manually verify a user's email
public function verifyUser(User $user)
{
if ($user->email_verified_at) {
return back()->with('error', "{$user->name} is already verified.");
}
$user->email_verified_at = now();
$user->save();
return back()->with('success', "{$user->name}'s account has been verified.");
}
// Start impersonating a user
public function impersonate(User $user)
{
if ($user->isSuperAdmin()) {
return back()->with('error', 'You cannot impersonate another super admin.');
}
$admin = \Auth::user();
AuditLog::record('admin.impersonate', [
'user_id' => $admin->id,
'user_name' => $admin->name,
'subject_type' => 'User',
'subject_id' => (string) $user->id,
'subject_label' => $user->name,
]);
session(['impersonator_id' => \Auth::id()]);
\Auth::loginUsingId($user->id);
return redirect()->route('home')
->with('success', "You are now impersonating {$user->name}. Use the banner to exit.");
}
// Exit impersonation and return to original admin account
public function exitImpersonation()
{
$impersonatorId = session('impersonator_id');
if (! $impersonatorId) {
return redirect()->route('home');
}
$impersonatedUser = \Auth::user();
session()->forget('impersonator_id');
\Auth::loginUsingId($impersonatorId);
AuditLog::record('admin.impersonate.exit', [
'subject_type' => 'User',
'subject_id' => (string) $impersonatedUser->id,
'subject_label' => $impersonatedUser->name,
]);
return redirect()->route('admin.users')
->with('success', 'Impersonation ended. You are back as yourself.');
}
// Dashboard - Overview statistics
public function dashboard()
{
$now = now();
$w0 = $now->copy()->subDays(7); // start of this week window
$w1 = $now->copy()->subDays(14); // start of last week window
// ── Core totals ────────────────────────────────────────────
$totalUsers = User::count();
$totalVideos = Video::count();
$totalViews = \DB::table('video_views')->count();
$totalLikes = \DB::table('video_likes')->count();
$totalComments = \DB::table('comments')->count();
// ── Week-over-week growth ──────────────────────────────────
$usersThisWeek = User::where('created_at', '>=', $w0)->count();
$usersLastWeek = User::whereBetween('created_at', [$w1, $w0])->count();
$videosThisWeek = Video::where('created_at', '>=', $w0)->count();
$videosLastWeek = Video::whereBetween('created_at', [$w1, $w0])->count();
$viewsThisWeek = \DB::table('video_views')->where('watched_at', '>=', $w0)->count();
$viewsLastWeek = \DB::table('video_views')->whereBetween('watched_at', [$w1, $w0])->count();
$likesThisWeek = \DB::table('video_likes')->where('created_at', '>=', $w0)->count();
$likesLastWeek = \DB::table('video_likes')->whereBetween('created_at', [$w1, $w0])->count();
$commentsThisWeek = \DB::table('comments')->where('created_at', '>=', $w0)->count();
$growthUsers = $this->growthPct($usersLastWeek, $usersThisWeek);
$growthVideos = $this->growthPct($videosLastWeek, $videosThisWeek);
$growthViews = $this->growthPct($viewsLastWeek, $viewsThisWeek);
$growthLikes = $this->growthPct($likesLastWeek, $likesThisWeek);
$stats = compact(
'totalUsers','totalVideos','totalViews','totalLikes','totalComments',
'usersThisWeek','videosThisWeek','viewsThisWeek','likesThisWeek','commentsThisWeek',
'growthUsers','growthVideos','growthViews','growthLikes'
);
// ── 30-day daily activity (for charts) ────────────────────
$days30 = collect(range(29, 0))->map(fn($d) => $now->copy()->subDays($d)->format('Y-m-d'));
$rawUsers = User::selectRaw('DATE(created_at) as d, COUNT(*) as n')
->where('created_at', '>=', $now->copy()->subDays(30))
->groupBy('d')->pluck('n', 'd');
$rawVideos = Video::selectRaw('DATE(created_at) as d, COUNT(*) as n')
->where('created_at', '>=', $now->copy()->subDays(30))
->groupBy('d')->pluck('n', 'd');
$rawViews = \DB::table('video_views')
->selectRaw('DATE(watched_at) as d, COUNT(*) as n')
->where('watched_at', '>=', $now->copy()->subDays(30))
->groupBy('d')->pluck('n', 'd');
$chartLabels = $days30->map(fn($d) => date('M j', strtotime($d)))->values()->toJson();
$chartDatesRaw = $days30->values()->toJson();
$chartUsersData = $days30->map(fn($d) => $rawUsers->get($d, 0))->values()->toJson();
$chartVideosData = $days30->map(fn($d) => $rawVideos->get($d, 0))->values()->toJson();
$chartViewsData = $days30->map(fn($d) => $rawViews->get($d, 0))->values()->toJson();
// ── Video status & visibility ──────────────────────────────
$videosByStatus = Video::selectRaw('status, count(*) as n')->groupBy('status')->pluck('n', 'status');
$videosByVisibility = Video::selectRaw('visibility, count(*) as n')->groupBy('visibility')->pluck('n', 'visibility');
$videosByType = Video::selectRaw('type, count(*) as n')->groupBy('type')->pluck('n', 'type');
// ── Alerts ────────────────────────────────────────────────
$failedCount = $videosByStatus->get('failed', 0);
$processingCount = $videosByStatus->get('processing', 0);
$pendingCount = $videosByStatus->get('pending', 0);
// ── Top content ───────────────────────────────────────────
$topVideos = \DB::table('videos')
->join('users', 'videos.user_id', '=', 'users.id')
->select(
'videos.id', 'videos.title', 'videos.thumbnail', 'videos.type',
'users.name as username',
\DB::raw('(SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id) as view_count'),
\DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count')
)
->where('videos.status', 'ready')
->orderByDesc('view_count')
->take(5)->get();
$topUploaders = \DB::table('users')
->select('users.id','users.name','users.email','users.avatar',
\DB::raw('COUNT(videos.id) as video_count'),
\DB::raw('SUM((SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id)) as total_views')
)
->leftJoin('videos', 'users.id', '=', 'videos.user_id')
->groupBy('users.id','users.name','users.email','users.avatar')
->orderByDesc('video_count')
->take(5)->get();
// ── Engagement ────────────────────────────────────────────
$readyVideos = $videosByStatus->get('ready', 0);
$avgViewsPerVideo = $readyVideos > 0 ? round($totalViews / $readyVideos, 1) : 0;
$likeToViewRatio = $totalViews > 0 ? round(($totalLikes / $totalViews) * 100, 1) : 0;
$avgVideosPerUser = $totalUsers > 0 ? round($totalVideos / $totalUsers, 1) : 0;
// ── Viewers by country ────────────────────────────────────
$viewsByCountry = \DB::table('video_views')
->whereNotNull('country')
->selectRaw('country, country_name, COUNT(*) as total')
->groupBy('country', 'country_name')
->orderByDesc('total')
->take(20)
->get();
// ── Recent ────────────────────────────────────────────────
$recentUsers = User::latest()->take(5)->get();
$recentVideos = Video::with('user')->latest()->take(5)->get();
// ── Storage ───────────────────────────────────────────────
$disk = Storage::disk('public');
$sizeVideos = $this->dirSize($disk, 'videos');
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
$sizeAvatars = $this->dirSize($disk, 'avatars');
$sizeImages = $this->dirSize($disk, 'images');
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages;
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
// Convert to MB
$toMb = fn($b) => round($b / 1024 / 1024, 1);
$storage = [
'videos' => $toMb($sizeVideos),
'thumbnails' => $toMb($sizeThumbnails),
'avatars' => $toMb($sizeAvatars),
'images' => $toMb($sizeImages),
'total' => $toMb($totalPublicSize),
];
$videosDirSize = $storage['videos'];
$totalPublicSizeMb = $storage['total'];
return view('admin.dashboard', compact(
'stats',
'chartLabels','chartDatesRaw','chartUsersData','chartVideosData','chartViewsData',
'videosByStatus','videosByVisibility','videosByType',
'failedCount','processingCount','pendingCount',
'topVideos','topUploaders',
'avgViewsPerVideo','likeToViewRatio','avgVideosPerUser',
'recentUsers','recentVideos',
'storage','videosDirSize','totalPublicSizeMb','videosUsagePercent',
'viewsByCountry'
));
}
private function growthPct(int $prev, int $curr): array
{
if ($prev === 0) {
return ['pct' => $curr > 0 ? 100 : 0, 'dir' => $curr > 0 ? 'up' : 'flat'];
}
$pct = round((($curr - $prev) / $prev) * 100, 1);
return ['pct' => abs($pct), 'dir' => $pct >= 0 ? 'up' : 'down'];
}
private function dirSize($disk, string $dir): int
{
$size = 0;
foreach ($disk->allFiles($dir) as $file) {
try { $size += $disk->size($file); } catch (\Exception $e) {}
}
return $size;
}
// List all users with search/filter
public function users(Request $request)
{
$query = User::query();
// Search by name or email
if ($request->has('search') && $request->search) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by role
if ($request->has('role') && $request->role) {
$query->where('role', $request->role);
}
// Sort by
$sort = $request->get('sort', 'latest');
switch ($sort) {
case 'oldest':
$query->oldest();
break;
case 'name_asc':
$query->orderBy('name', 'asc');
break;
case 'name_desc':
$query->orderBy('name', 'desc');
break;
default:
$query->latest();
}
$users = $query->paginate(20);
$users->appends($request->query());
return view('admin.users', compact('users'));
}
// Show edit user form
public function editUser(User $user)
{
return view('admin.edit-user', compact('user'));
}
// Update user
public function updateUser(Request $request, User $user)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email,' . $user->id,
'role' => 'required|in:user,admin,super_admin',
'new_password' => 'nullable|min:8|confirmed',
]);
$data = [
'name' => $request->name,
'email' => $request->email,
'role' => $request->role,
];
// Update password if provided
if ($request->new_password) {
$data['password'] = Hash::make($request->new_password);
}
$user->update($data);
return redirect()->route('admin.users')->with('success', 'User updated successfully!');
}
// Returns true if admin is already within the 30-min verified window
private function adminIsVerified(): bool
{
$admin = auth()->user();
if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) {
return true;
}
$verifiedAt = session('admin_2fa_verified_at');
return $verifiedAt && now()->timestamp - $verifiedAt < 1800;
}
// Validates OTP and stamps the session on success
private function verify2fa(Request $request): bool
{
$admin = auth()->user();
if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) {
return true;
}
if ($this->adminIsVerified()) {
return true;
}
$code = $request->input('otp_code', '');
$google2fa = app('pragmarx.google2fa');
if ($google2fa->verifyKey(decrypt($admin->two_factor_secret), (string) $code)) {
session(['admin_2fa_verified_at' => now()->timestamp]);
return true;
}
return false;
}
// Delete user
public function deleteUser(Request $request, User $user)
{
// Prevent deleting yourself
if (auth()->id() === $user->id) {
return response()->json(['success' => false, 'message' => 'You cannot delete your own account!'], 422);
}
if (! $this->verify2fa($request)) {
return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422);
}
AuditLog::record('admin.user.deleted', [
'subject_type' => 'User',
'subject_id' => (string) $user->id,
'subject_label' => $user->name,
'details' => ['email' => $user->email, 'video_count' => $user->videos->count()],
]);
// Delete user's videos and associated files
foreach ($user->videos as $video) {
Storage::delete($video->path);
if ($video->thumbnail) {
Storage::delete($video->thumbnailStorageKey());
}
}
$user->videos()->delete();
// Delete user likes and views - use direct query since relationship is named 'viewers'
\DB::table('video_likes')->where('user_id', $user->id)->delete();
\DB::table('video_views')->where('user_id', $user->id)->delete();
$user->delete();
return response()->json(['success' => true, 'message' => 'User deleted successfully!']);
}
// List all videos
public function videos(Request $request)
{
$query = Video::with('user');
// Search by title or description
if ($request->has('search') && $request->search) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
// Filter by status
if ($request->has('status') && $request->status) {
$query->where('status', $request->status);
}
// Filter by visibility
if ($request->has('visibility') && $request->visibility) {
$query->where('visibility', $request->visibility);
}
// Filter by type
if ($request->has('type') && $request->type) {
$query->where('type', $request->type);
}
// Sort by
$sort = $request->get('sort', 'latest');
switch ($sort) {
case 'oldest':
$query->oldest();
break;
case 'title_asc':
$query->orderBy('title', 'asc');
break;
case 'title_desc':
$query->orderBy('title', 'desc');
break;
case 'views':
// Can't use withCount for views due to pivot table issue
$query->latest();
break;
case 'likes':
$query->withCount('likes')->orderBy('likes_count', 'desc');
break;
default:
$query->latest();
}
$videos = $query->paginate(20);
$videos->appends($request->query());
return view('admin.videos', compact('videos'));
}
// Per-video analytics
public function videoAnalytics(Video $video)
{
// ── Total view events (every watch counts) ──────────────────────────
$totalViews = \DB::table('video_views')->where('video_id', $video->id)->count();
$totalLikes = \DB::table('video_likes')->where('video_id', $video->id)->count();
$totalComments = \DB::table('comments')->where('video_id', $video->id)->count();
// ── Views per day last 30 days (raw events) ───────────────────────
$rawDaily = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subDays(29)->startOfDay())
->selectRaw('DATE(watched_at) as date, COUNT(*) as total')
->groupBy('date')
->orderBy('date')
->get()
->keyBy('date');
$dailyLabels = [];
$dailyViews = [];
for ($i = 29; $i >= 0; $i--) {
$d = now()->subDays($i);
$dailyLabels[] = $d->format('M d');
$dailyViews[] = $rawDaily[$d->format('Y-m-d')]->total ?? 0;
}
// ── Fetch all view records once, join user profile data ─────────────
// Ordered newest-first so the first occurrence per viewer is their
// most recent record (country, etc. from their latest watch).
$allViewRecords = \DB::table('video_views')
->leftJoin('users', 'video_views.user_id', '=', 'users.id')
->where('video_views.video_id', $video->id)
->select(
'video_views.id',
'video_views.user_id',
'video_views.ip_address',
'video_views.country',
'video_views.country_name',
'video_views.watched_at',
'users.name as viewer_name',
'users.avatar as viewer_avatar',
'users.birthday',
'users.gender'
)
->orderByDesc('video_views.watched_at')
->get();
// ── Deduplicate to one row per unique viewer ─────────────────────────
// Auth users → keyed by user_id (u_123)
// Guest users → keyed by ip_address (i_1.2.3.4)
$seenKeys = [];
$uniqueViewers = [];
foreach ($allViewRecords as $row) {
$key = $row->user_id ? 'u_' . $row->user_id : 'i_' . $row->ip_address;
if (isset($seenKeys[$key])) continue;
$seenKeys[$key] = true;
$uniqueViewers[] = $row;
}
$totalUniqueViewers = count($uniqueViewers);
$authViewers = count(array_filter($uniqueViewers, fn($v) => $v->user_id !== null));
$guestViewers = $totalUniqueViewers - $authViewers;
// ── Countries one count per unique viewer ──────────────────────────
$countryMap = [];
foreach ($uniqueViewers as $viewer) {
if (!$viewer->country) continue;
if (!isset($countryMap[$viewer->country])) {
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name, 'total' => 0];
}
$countryMap[$viewer->country]['total']++;
}
usort($countryMap, fn($a, $b) => $b['total'] - $a['total']);
$viewsByCountry = collect(array_slice($countryMap, 0, 20))->map(fn($r) => (object) $r);
// ── Age groups one count per unique viewer ─────────────────────────
$ageGroups = ['Under 18' => 0, '1824' => 0, '2534' => 0, '3544' => 0, '4554' => 0, '55+' => 0, 'Unknown' => 0];
$now = now();
foreach ($uniqueViewers as $viewer) {
if (!$viewer->birthday) { $ageGroups['Unknown']++; continue; }
$age = \Carbon\Carbon::parse($viewer->birthday)->diffInYears($now);
if ($age < 18) $ageGroups['Under 18']++;
elseif ($age < 25) $ageGroups['1824']++;
elseif ($age < 35) $ageGroups['2534']++;
elseif ($age < 45) $ageGroups['3544']++;
elseif ($age < 55) $ageGroups['4554']++;
else $ageGroups['55+']++;
}
// ── Gender one count per unique viewer ─────────────────────────────
$genderCounts = ['Male' => 0, 'Female' => 0, 'Prefer not to say' => 0];
foreach ($uniqueViewers as $viewer) {
if ($viewer->gender === 'male') $genderCounts['Male']++;
elseif ($viewer->gender === 'female') $genderCounts['Female']++;
elseif ($viewer->gender === 'prefer_not_to_say') $genderCounts['Prefer not to say']++;
}
// ── Recent 20 individual view events ────────────────────────────────
$recentViews = $allViewRecords->take(20);
return view('admin.video-analytics', compact(
'video', 'totalViews', 'totalUniqueViewers', 'authViewers', 'guestViewers',
'totalLikes', 'totalComments', 'dailyLabels', 'dailyViews',
'viewsByCountry', 'ageGroups', 'genderCounts', 'recentViews'
));
}
// Show edit video form
public function editVideo(Video $video)
{
return view('admin.edit-video', compact('video'));
}
// Update video
public function updateVideo(Request $request, Video $video)
{
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'visibility' => 'required|in:public,unlisted,private',
'type' => 'required|in:generic,music,match',
'status' => 'required|in:pending,processing,ready,failed',
'is_shorts' => 'nullable|boolean',
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
]);
$oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
$video->update($data);
if (($data['title'] ?? $oldTitle) !== $oldTitle) {
try {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->renameVideoDir($video->fresh());
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('NAS video dir rename failed: ' . $e->getMessage());
}
}
return redirect()->route('admin.videos')->with('success', 'Video updated successfully!');
}
// Delete video
public function deleteVideo(Request $request, Video $video)
{
if (! $this->verify2fa($request)) {
return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422);
}
$videoTitle = $video->title;
AuditLog::record('admin.video.deleted', [
'subject_type' => 'Video',
'subject_id' => (string) $video->id,
'subject_label' => $videoTitle,
'details' => ['owner_id' => $video->user_id, 'type' => $video->type],
]);
// Delete files
Storage::delete($video->path);
if ($video->thumbnail) {
Storage::delete($video->thumbnailStorageKey());
}
// Delete likes and views - use direct queries since relationships have timestamp issues
\DB::table('video_likes')->where('video_id', $video->id)->delete();
\DB::table('video_views')->where('video_id', $video->id)->delete();
$video->delete();
return response()->json(['success' => true, 'message' => 'Video "' . $videoTitle . '" deleted successfully!']);
}
/**
* Manual cleanup orphaned videos (admin instant trigger)
*/
public function cleanupOrphanedVideos(Request $request)
{
$output = [];
$returnCode = 0;
Artisan::call('cleanup:orphaned-videos --force', [], $output);
Log::channel('orphaned-videos')->info('Manual cleanup triggered by super admin');
$disk = Storage::disk('public');
$totalPublicSize = 0;
foreach ($disk->allFiles() as $file) {
$totalPublicSize += $disk->size($file);
}
$videosDirSize = 0;
foreach ($disk->allFiles('videos') as $file) {
$videosDirSize += $disk->size($file);
}
return response()->json([
'success' => true,
'message' => 'Cleanup completed! Check logs for details.',
'stats' => [
'return_code' => $returnCode,
'videos_dir_size_mb' => round($videosDirSize / 1024 / 1024, 1),
'total_public_size_mb' => round($totalPublicSize / 1024 / 1024, 1),
],
]);
}
public function modalData(Request $request)
{
$resource = $request->get('resource', '');
$limit = min((int) $request->get('limit', 30), 100);
switch ($resource) {
case 'users':
$query = User::latest();
if ($request->date) $query->whereDate('created_at', $request->date);
if ($request->filter === 'week') $query->where('created_at', '>=', now()->subDays(7));
$items = $query->take($limit)->get();
return response()->json(['type' => 'users', 'items' => $items->map(fn($u) => [
'avatar' => $u->avatar_url,
'name' => $u->name,
'email' => $u->email,
'role' => $u->role ?? 'user',
'joined' => $u->created_at->diffForHumans(),
'url' => route('channel', $u->channel),
])]);
case 'videos':
$query = Video::with('user')->latest();
if ($request->status) $query->where('status', $request->status);
if ($request->visibility) $query->where('visibility', $request->visibility);
if ($request->type) $query->where('type', $request->type);
if ($request->date) $query->whereDate('created_at', $request->date);
if ($request->uploader_id) $query->where('user_id', $request->uploader_id);
$items = $query->take($limit)->get();
return response()->json(['type' => 'videos', 'items' => $items->map(fn($v) => [
'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null,
'title' => $v->title,
'owner' => $v->user->name ?? 'Unknown',
'status' => $v->status,
'type' => $v->type,
'views' => \DB::table('video_views')->where('video_id', $v->id)->count(),
'uploaded' => $v->created_at->diffForHumans(),
'url' => route('videos.show', $v),
])]);
case 'top_videos':
$items = \DB::table('videos')
->join('users', 'videos.user_id', '=', 'users.id')
->select('videos.id', 'videos.title', 'videos.thumbnail', 'users.name as username',
\DB::raw('(SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id) as view_count'),
\DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count'))
->orderByDesc('view_count')->take($limit)->get();
return response()->json(['type' => 'top_videos', 'items' => $items->map(fn($v) => [
'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null,
'title' => $v->title,
'owner' => $v->username,
'views' => $v->view_count,
'likes' => $v->like_count,
'url' => route('videos.show', Video::encodeId($v->id)),
])]);
case 'views_day':
$items = \DB::table('video_views')
->join('videos', 'video_views.video_id', '=', 'videos.id')
->leftJoin('users', 'video_views.user_id', '=', 'users.id')
->select('videos.title', 'videos.id as video_id', 'users.name as viewer_name', 'video_views.country', 'video_views.watched_at')
->when($request->date, fn($q) => $q->whereDate('video_views.watched_at', $request->date))
->orderByDesc('video_views.watched_at')->take($limit)->get();
return response()->json(['type' => 'views', 'items' => $items->map(fn($v) => [
'video' => $v->title,
'viewer' => $v->viewer_name ?? 'Guest',
'country' => $v->country ?? '—',
'time' => \Carbon\Carbon::parse($v->watched_at)->diffForHumans(),
'url' => route('videos.show', Video::encodeId($v->video_id)),
])]);
case 'likes':
$items = \DB::table('videos')
->join('users', 'videos.user_id', '=', 'users.id')
->select('videos.id', 'videos.title', 'videos.thumbnail', 'users.name as username',
\DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count'))
->having('like_count', '>', 0)->orderByDesc('like_count')->take($limit)->get();
return response()->json(['type' => 'top_videos', 'items' => $items->map(fn($v) => [
'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null,
'title' => $v->title,
'owner' => $v->username,
'views' => 0,
'likes' => $v->like_count,
'url' => route('videos.show', Video::encodeId($v->id)),
])]);
case 'comments':
$items = \DB::table('comments')
->join('users', 'comments.user_id', '=', 'users.id')
->join('videos', 'comments.video_id', '=', 'videos.id')
->select('comments.body', 'users.name as user_name', 'videos.title as video_title', 'videos.id as video_id', 'comments.created_at')
->orderByDesc('comments.created_at')->take($limit)->get();
return response()->json(['type' => 'comments', 'items' => $items->map(fn($c) => [
'user' => $c->user_name,
'body' => \Str::limit($c->body, 120),
'video' => $c->video_title,
'time' => \Carbon\Carbon::parse($c->created_at)->diffForHumans(),
'url' => route('videos.show', Video::encodeId($c->video_id)),
])]);
case 'country_viewers':
$items = \DB::table('video_views')
->join('videos', 'video_views.video_id', '=', 'videos.id')
->leftJoin('users', 'video_views.user_id', '=', 'users.id')
->select('videos.title', 'videos.id as video_id', 'users.name as viewer_name', 'video_views.watched_at')
->where('video_views.country', $request->country)
->orderByDesc('video_views.watched_at')->take($limit)->get();
return response()->json(['type' => 'views', 'items' => $items->map(fn($v) => [
'video' => $v->title,
'viewer' => $v->viewer_name ?? 'Guest',
'country' => $request->country,
'time' => \Carbon\Carbon::parse($v->watched_at)->diffForHumans(),
'url' => route('videos.show', Video::encodeId($v->video_id)),
])]);
case 'uploaders':
$items = \DB::table('users')
->select('users.id', 'users.name', 'users.avatar',
\DB::raw('COUNT(videos.id) as video_count'),
\DB::raw('COALESCE(SUM((SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id)),0) as total_views'))
->leftJoin('videos', 'users.id', '=', 'videos.user_id')
->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,
'name' => $u->name,
'videos' => $u->video_count,
'views' => $u->total_views,
'url' => route('channel', $u->channel),
])]);
}
return response()->json(['error' => 'Unknown resource'], 400);
}
public function logs(Request $request)
{
$logFile = storage_path('logs/laravel.log');
$lines = [];
$filter = $request->get('filter', '');
$level = $request->get('level', '');
$limit = (int) $request->get('limit', 200);
if (file_exists($logFile)) {
$all = array_reverse(file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
foreach ($all as $line) {
if ($filter && stripos($line, $filter) === false) continue;
if ($level && stripos($line, ".$level:") === false) continue;
$lines[] = $line;
if (count($lines) >= $limit) break;
}
}
return view('admin.logs', compact('lines', 'filter', 'level', 'limit'));
}
public function auditLogs(Request $request)
{
$query = AuditLog::query()->latest('created_at');
if ($request->filled('action')) {
$query->where('action', $request->action);
}
if ($request->filled('user')) {
$query->where(function ($q) use ($request) {
$q->where('user_name', 'like', '%'.$request->user.'%')
->orWhereHas('user', fn($u) => $u->where('email', 'like', '%'.$request->user.'%'));
});
}
if ($request->filled('ip')) {
$query->where('ip_address', 'like', '%'.$request->ip.'%');
}
if ($request->filled('subject')) {
$query->where('subject_label', 'like', '%'.$request->subject.'%');
}
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->whereDate('created_at', '<=', $request->date_to);
}
$logs = $query->paginate(50)->withQueryString();
$actionTypes = AuditLog::query()
->select('action')
->distinct()
->orderBy('action')
->pluck('action');
return view('admin.audit-logs', compact('logs', 'actionTypes'));
}
// ── Settings ──────────────────────────────────────────────────────────
public function settings()
{
$settings = [
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
'gpu_device' => Setting::get('gpu_device', '0'),
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
];
$gpus = $this->probeGpus();
$nvencWorks = $this->probeNvenc();
return view('admin.settings', compact('settings', 'gpus', 'nvencWorks'));
}
public function updateSettings(Request $request)
{
$request->validate([
'gpu_enabled' => 'required|in:true,false',
'gpu_device' => 'required|integer|min:0|max:15',
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
'gpu_hwaccel' => 'required|in:cuda,none',
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
'ffmpeg_binary' => 'required|string|max:255',
]);
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
if (! file_exists($binary) || ! is_executable($binary)) {
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
}
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
// GPU config changed — drop the cached health-check so the next encode re-probes.
Setting::flushGpuProbe();
return back()->with('success', 'Settings saved.');
}
public function detectGpu()
{
return response()->json(['gpus' => $this->probeGpus()]);
}
private function probeGpus(): array
{
$gpus = [];
exec(
'nvidia-smi --query-gpu=index,name,memory.total,memory.free,utilization.gpu,temperature.gpu,driver_version'
. ' --format=csv,noheader,nounits 2>/dev/null',
$lines, $exit
);
if ($exit !== 0 || empty($lines)) return $gpus;
foreach ($lines as $line) {
$parts = array_map('trim', explode(',', $line));
if (count($parts) < 7) continue;
$gpus[] = [
'index' => (int) $parts[0],
'name' => $parts[1],
'mem_total' => (int) $parts[2],
'mem_free' => (int) $parts[3],
'util' => (int) $parts[4],
'temp' => (int) $parts[5],
'driver' => $parts[6],
];
}
return $gpus;
}
/**
* Quick smoke-test: encode one frame with h264_nvenc and return true if it succeeds.
* This catches CUDA compat / driver-version mismatches that nvidia-smi can't detect.
*/
private function probeNvenc(): bool
{
// Single source of truth lives on the Setting model; force the NVENC encoder so the
// admin indicator always reflects GPU capability regardless of the configured encoder.
return Setting::probeGpu('h264_nvenc');
}
public function nasStorage()
{
$nodes = config('nas-file-manager.schema', []);
return view('admin.nas-storage', compact('nodes'));
}
public function nasDelete(Request $request)
{
$path = trim($request->input('path', ''));
$type = $request->input('type', 'dir');
if ($path === '') {
return response()->json(['success' => false, 'message' => 'Path is required.'], 422);
}
$nas = app(\App\Services\NasSyncService::class);
if (! $nas->isEnabled()) {
return response()->json(['success' => false, 'message' => 'NAS not enabled.'], 422);
}
try {
if ($type === 'dir') {
$nas->deleteNasTree($path);
} else {
$nas->deleteFile($path);
}
return response()->json(['success' => true]);
} catch (\Throwable $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()]);
}
}
public function nasRepair(Request $request)
{
$nas = app(\App\Services\NasSyncService::class);
if (! $nas->isEnabled()) {
return response()->json(['success' => false, 'message' => 'NAS sync is not enabled.'], 422);
}
// ── Collect stuck items ───────────────────────────────────────────────
$stuckVideos = $this->collectStuckVideos();
$stuckAvatars = $this->collectStuckAvatars();
$stuckBanners = $this->collectStuckBanners();
$stuckThumbs = $this->collectStuckLegacyThumbnails();
$nasOrphans = $nas->scanNasOrphans();
$totalStuck = $stuckVideos->count() + $stuckAvatars->count()
+ $stuckBanners->count() + $stuckThumbs->count()
+ count($nasOrphans);
// Scan-only mode ───────────────────────────────────────────────────────
if ($request->boolean('scan_only')) {
$details = [];
foreach ($stuckVideos as $item) {
$details[] = "[video] #{$item['video']->id} {$item['video']->title}: " . implode(', ', $item['files']);
}
foreach ($stuckAvatars as $item) {
$details[] = "[avatar] {$item['user']->username}: {$item['file']}";
}
foreach ($stuckBanners as $item) {
$details[] = "[banner] {$item['user']->username}: {$item['file']}";
}
foreach ($stuckThumbs as $item) {
$details[] = "[{$item['type']}] {$item['file']} (video #{$item['video_id']})";
}
foreach ($nasOrphans as $orphan) {
$label = $orphan['video_id'] ? "video #{$orphan['video_id']}" : 'no meta.json';
$details[] = "[nas-orphan] {$orphan['dir']} ({$label} — not in DB)";
}
$cacheBytes = $nas->nasCacheSize();
if ($cacheBytes > 0) {
$cacheMb = round($cacheBytes / 1048576, 1);
$details[] = "[stream-cache] {$cacheMb} MB of on-demand video cache (safe to clear)";
$totalStuck++;
}
return response()->json(['stuck' => $totalStuck, 'details' => $details]);
}
// Repair mode ─────────────────────────────────────────────────────────
$repaired = 0;
$failed = 0;
$details = [];
foreach ($stuckVideos as $item) {
$video = $item['video'];
try {
$nas->syncVideo($video);
$nas->deleteLocalAssets($video);
if ($video->hls_path || $video->type === 'music') $nas->deleteLocalVideo($video);
$nas->pruneLocalVideoDir($video);
$repaired++;
$details[] = "✓ [video] #{$video->id}: {$video->title}";
\Log::info("nas:repair: fixed video #{$video->id}");
} catch (\Throwable $e) {
$failed++;
$details[] = "✗ [video] #{$video->id}: {$e->getMessage()}";
\Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage());
}
}
foreach ($stuckAvatars as $item) {
try {
$nas->syncAvatar($item['user'], $item['path']);
$nas->deleteLocalAvatar($item['user']);
$repaired++;
$details[] = "✓ [avatar] {$item['user']->username}";
} catch (\Throwable $e) {
$failed++;
$details[] = "✗ [avatar] {$item['user']->username}: {$e->getMessage()}";
\Log::error("nas:repair: failed avatar user#{$item['user']->id}: " . $e->getMessage());
}
}
foreach ($stuckBanners as $item) {
try {
$nas->syncCover($item['user'], $item['path']);
$nas->deleteLocalBanner($item['user']);
$repaired++;
$details[] = "✓ [banner] {$item['user']->username}";
} catch (\Throwable $e) {
$failed++;
$details[] = "✗ [banner] {$item['user']->username}: {$e->getMessage()}";
\Log::error("nas:repair: failed banner user#{$item['user']->id}: " . $e->getMessage());
}
}
foreach ($stuckThumbs as $item) {
try {
if ($item['type'] === 'thumbnail' && $item['video']) {
$nas->syncVideo($item['video']);
} elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) {
$dir = $nas->resolveVideoDir($item['video']);
$ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg';
$nas->mkdirp("{$dir}/slides");
$nas->putFile($item['path'], "{$dir}/slides/{$item['slide']->position}.{$ext}");
}
@unlink($item['path']);
$repaired++;
$details[] = "✓ [{$item['type']}] {$item['file']}";
} catch (\Throwable $e) {
$failed++;
$details[] = "✗ [{$item['type']}] {$item['file']}: {$e->getMessage()}";
\Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage());
}
}
// Delete NAS orphan folders
foreach ($nasOrphans as $orphan) {
try {
$nas->deleteNasTree($orphan['dir']);
$repaired++;
$details[] = "✓ [nas-orphan] deleted {$orphan['dir']}";
\Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]);
} catch (\Throwable $e) {
$failed++;
$details[] = "✗ [nas-orphan] {$orphan['dir']}: {$e->getMessage()}";
\Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]);
}
}
// Evict NAS stream cache (24h TTL by default)
$evicted = $nas->clearNasCache(24);
if ($evicted > 0) {
$details[] = "✓ [stream-cache] evicted {$evicted} cached file(s)";
$repaired += $evicted;
}
$this->pruneLocalStorageDirs();
if ($totalStuck === 0) {
return response()->json([
'success' => true,
'message' => 'Nothing to repair — no stuck local files and no NAS orphans found.',
'repaired' => 0, 'failed' => 0, 'details' => [],
]);
}
return response()->json([
'success' => $failed === 0,
'message' => $failed === 0
? "Repaired {$repaired} item(s) successfully."
: "Repaired {$repaired}, failed {$failed} — check logs.",
'repaired' => $repaired,
'failed' => $failed,
'details' => $details,
]);
}
private function collectStuckVideos(): \Illuminate\Support\Collection
{
return \App\Models\Video::with(['user', 'slides'])->get()
->filter(fn ($v) => str_starts_with($v->path, 'users/'))
->map(function ($video) {
$files = [];
if (file_exists(storage_path('app/' . $video->path)))
$files[] = basename($video->path) . ' (video)';
if ($video->thumbnail && str_contains($video->thumbnail, '/') &&
file_exists(storage_path('app/' . $video->thumbnail)))
$files[] = basename($video->thumbnail) . ' (thumbnail)';
foreach ($video->slides as $slide) {
if (file_exists($slide->localPath()))
$files[] = basename($slide->filename) . " (slide #{$slide->position})";
}
return $files ? ['video' => $video, 'files' => $files] : null;
})
->filter();
}
private function collectStuckAvatars(): \Illuminate\Support\Collection
{
$results = collect();
// Legacy flat dir
$dir = storage_path('app/public/avatars');
if (is_dir($dir)) {
$flat = collect(scandir($dir) ?: [])
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
->map(function ($filename) use ($dir) {
$user = \App\Models\User::where('avatar', $filename)->first();
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
})
->filter();
$results = $results->merge($flat);
}
// New structured dir: users/{slug}/profile/avatar.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $path) {
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
$user = \App\Models\User::where('avatar', $relPath)->first();
if ($user) {
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
}
}
}
return $results;
}
private function collectStuckBanners(): \Illuminate\Support\Collection
{
$results = collect();
// Legacy flat dir
$dir = storage_path('app/public/banners');
if (is_dir($dir)) {
$flat = collect(scandir($dir) ?: [])
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
->map(function ($filename) use ($dir) {
$user = \App\Models\User::where('banner', $filename)->first();
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
})
->filter();
$results = $results->merge($flat);
}
// New structured dir: users/{slug}/profile/cover.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $path) {
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
$user = \App\Models\User::where('banner', $relPath)->first();
if ($user) {
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
}
}
}
return $results;
}
private function collectStuckLegacyThumbnails(): \Illuminate\Support\Collection
{
$dir = storage_path('app/public/thumbnails');
if (! is_dir($dir)) return collect();
$results = [];
foreach (scandir($dir) ?: [] as $filename) {
if ($filename === '.' || $filename === '..') continue;
$path = "{$dir}/{$filename}";
if (! is_file($path)) continue;
$video = \App\Models\Video::where('thumbnail', $filename)->first();
if ($video) {
$results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null];
continue;
}
$slide = \App\Models\VideoSlide::where('filename', $filename)->with('video')->first();
if ($slide && $slide->video) {
$results[] = ['type' => 'slide', 'file' => $filename, 'path' => $path, 'video_id' => $slide->video_id, 'video' => $slide->video, 'slide' => $slide];
}
}
return collect($results);
}
private function pruneLocalStorageDirs(): void
{
// NAS-mirrored tree
$nasRoot = storage_path('app/users');
if (is_dir($nasRoot)) {
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($nasRoot, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $item) {
if (! $item->isDir()) continue;
$path = $item->getPathname();
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
$nonMeta = array_diff($contents, ['meta.json']);
if (empty($contents)) {
@rmdir($path);
} elseif (empty($nonMeta)) {
@unlink("{$path}/meta.json");
@rmdir($path);
}
}
}
// Flat asset dirs — remove if empty
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
$path = storage_path("app/{$rel}");
if (is_dir($path) && empty(array_diff(scandir($path) ?: [], ['.', '..']))) {
@rmdir($path);
}
}
}
// ── NAS Disable Flow ──────────────────────────────────────────────────
public function nasDisable(Request $request)
{
$mode = $request->input('mode'); // 'migrate' or 'fresh'
if ($mode === 'migrate') {
// Reset progress cache, dispatch job
\Cache::put('nas_disable_progress', json_encode([
'current' => 0, 'total' => 0,
'phase' => 'Starting...', 'done' => false, 'error' => null,
]), 3600);
\App\Jobs\NasToLocalMigrationJob::dispatch()
->onQueue('video-processing')
->onConnection('database');
return response()->json(['ok' => true]);
}
if ($mode === 'fresh') {
// Truncate all media tables, reset user avatars/banners, disable NAS
$tables = [
'videos','video_slides','video_likes','video_views','video_shares',
'video_downloads','playlist_videos','playlists','comments','comment_likes',
'posts','post_images','post_reactions','post_videos',
'coach_reviews','match_rounds','match_points',
'share_accesses','playlist_share_accesses','notifications',
];
foreach ($tables as $t) {
\DB::table($t)->delete();
}
\DB::table('users')->update(['avatar' => null, 'banner' => null]);
Setting::set('nas_sync_enabled', 'false');
app(\App\Services\NasSyncService::class)->flushReachabilityCache();
AuditLog::record('admin.nas_disabled_fresh');
return response()->json(['ok' => true]);
}
return response()->json(['ok' => false, 'message' => 'Invalid mode'], 422);
}
public function nasMigrateProgress()
{
$raw = \Cache::get('nas_disable_progress');
if (! $raw) return response()->json(['done' => false, 'current' => 0, 'total' => 0, 'phase' => 'Not started']);
return response()->json(json_decode($raw, true));
}
public function backupUsersSettings()
{
$users = \DB::table('users')->get()->map(function ($u) {
return (array) $u;
})->toArray();
$settings = \DB::table('settings')->get()->map(function ($s) {
return (array) $s;
})->toArray();
$payload = json_encode([
'version' => '1.0',
'exported_at' => now()->toIso8601String(),
'users' => $users,
'settings' => $settings,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return response($payload, 200, [
'Content-Type' => 'application/json',
'Content-Disposition' => 'attachment; filename="takeone-backup-' . now()->format('Ymd-His') . '.json"',
]);
}
public function restoreUsersSettings(Request $request)
{
$request->validate(['backup' => 'required|file|mimes:json|max:10240']);
$content = file_get_contents($request->file('backup')->getRealPath());
$data = json_decode($content, true);
if (! isset($data['users']) || ! isset($data['settings'])) {
return back()->with('toast_error', 'Invalid backup file.');
}
// Restore settings
foreach ($data['settings'] as $row) {
\DB::table('settings')->updateOrInsert(
['key' => $row['key']],
['key' => $row['key'], 'value' => $row['value']]
);
}
// Restore users (upsert by email)
$restored = 0;
foreach ($data['users'] as $row) {
unset($row['id']); // let DB assign new IDs to avoid PK conflicts
\DB::table('users')->updateOrInsert(
['email' => $row['email']],
$row
);
$restored++;
}
AuditLog::record('admin.backup_restored', ['users' => $restored]);
return back()->with('toast_success', "Backup restored: {$restored} users + settings.");
}
}