Introduce per-video language support and multiple audio tracks (VideoAudioTrack model + migrations for language, description, title), a reusable language-select component, and a track-editor form. Bundle the self-hosted flag-icons v7.2.3 library and a NAS auto-sync command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1380 lines
59 KiB
PHP
1380 lines
59 KiB
PHP
<?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, '18–24' => 0, '25–34' => 0, '35–44' => 0, '45–54' => 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['18–24']++;
|
||
elseif ($age < 35) $ageGroups['25–34']++;
|
||
elseif ($age < 45) $ageGroups['35–44']++;
|
||
elseif ($age < 55) $ageGroups['45–54']++;
|
||
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);
|
||
|
||
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
|
||
{
|
||
$ffmpeg = Setting::ffmpegBinary();
|
||
$tmp = sys_get_temp_dir() . '/nvenc_probe_' . getmypid() . '.mp4';
|
||
$device = Setting::gpuDevice();
|
||
|
||
exec(
|
||
escapeshellcmd($ffmpeg)
|
||
. ' -f lavfi -i nullsrc=s=128x72:r=1 -frames:v 1'
|
||
. " -c:v h264_nvenc -gpu {$device}"
|
||
. ' -y ' . escapeshellarg($tmp) . ' 2>/dev/null',
|
||
$out, $exit
|
||
);
|
||
|
||
$ok = ($exit === 0 && file_exists($tmp) && filesize($tmp) > 0);
|
||
@unlink($tmp);
|
||
return $ok;
|
||
}
|
||
|
||
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.");
|
||
}
|
||
}
|