When NAS storage is enabled, uploaded files go directly to the NAS share
(users/{username}/videos/{title-slug}/) with no permanent local copy kept.
Thumbnails and video are fetched from NAS on demand for streaming/playback.
When NAS is disabled, files are organised into the same directory schema
in local storage.
- VideoController: branch upload flow on NAS enabled/disabled
- NasSyncService: add uploadDirectToNas() for direct NAS writes,
organizeLocalFiles() for local NAS-schema, localVideoDir() resolver,
deleteLocalAssets() for post-sync cleanup
- GenerateHlsJob: download from NAS via ensureLocalCopy() when local
file is absent (NAS-primary mode); clean up temp after HLS generation
- CompressVideoJob: place compressed file alongside original (any dir)
- Video/VideoSlide models: localVideoPath(), localThumbnailPath(),
thumbnailStorageKey(), localPath(), storageKey() helpers for
format-agnostic path resolution (old flat paths + new NAS-schema paths)
- MediaController: serve thumbnails from NAS-mirrored paths with NAS fallback
- SuperAdminController: use model path helpers for file deletion
- NasFreeLocalStorage: scan new users/ tree in addition to legacy flat dirs
- Settings: rename "NAS Storage Sync" tab to "NAS Storage", update description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
901 lines
39 KiB
PHP
901 lines
39 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!');
|
||
}
|
||
|
||
// Delete user
|
||
public function deleteUser(User $user)
|
||
{
|
||
// Prevent deleting yourself
|
||
if (auth()->id() === $user->id) {
|
||
return back()->with('error', 'You cannot delete your own account!');
|
||
}
|
||
|
||
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 redirect()->route('admin.users')->with('success', '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',
|
||
]);
|
||
|
||
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
|
||
|
||
$video->update($data);
|
||
|
||
return redirect()->route('admin.videos')->with('success', 'Video updated successfully!');
|
||
}
|
||
|
||
// Delete video
|
||
public function deleteVideo(Video $video)
|
||
{
|
||
$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 redirect()->route('admin.videos')->with('success', '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',
|
||
'nas_sync_enabled' => 'required|in:true,false',
|
||
]);
|
||
|
||
$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);
|
||
Setting::set('nas_sync_enabled', $request->nas_sync_enabled);
|
||
|
||
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'));
|
||
}
|
||
}
|