takeone-youtube-clone/app/Http/Controllers/SuperAdminController.php
ghassan f98e5415a3 Add lyrics pipeline, playlist views, admin toggles, and player polish
Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
  alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
  several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
  emoji-decoration from a song's description while preserving every
  language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
  vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.

Admin pages:
- /admin/lyrics    toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu       extracted GPU section, encoder picker, FFmpeg path
- /admin/backup    extracted users-and-settings export/import
- /admin/settings  now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.

Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
  and /videos/{id}?playlist={token}.  Dispatched after-response so it
  never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
  the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.

Player polish:
- Floating mini-player is draggable, persists its position in
  localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
  user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
  (channel tabs, etc.) gets re-executed after content swaps.

Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00

1591 lines
68 KiB
PHP
Raw 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 = [
'llm_enabled' => Setting::get('llm_enabled', 'false'),
'llm_clean_lyrics' => Setting::get('llm_clean_lyrics', 'true'),
'llm_decorate_lyrics' => Setting::get('llm_decorate_lyrics', 'false'),
'llm_providers' => json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [],
'llm_active_id' => (string) Setting::get('llm_active_id', ''),
];
return view('admin.settings', compact('settings'));
}
/**
* Settings save handler — accepts partial submissions from any of the
* separated admin pages (GPU, NAS, Backup, AI/LLM). Only updates the keys
* that appear in the request.
*/
public function updateSettings(Request $request)
{
// ── GPU section ──────────────────────────────────────────────────────
if ($request->has('gpu_enabled')) {
$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);
Setting::flushGpuProbe();
}
// ── Lyrics pipeline section ──────────────────────────────────────────
if ($request->has('lyrics_section')) {
foreach ([
'lyrics_enabled', // master switch
'lyrics_use_description', // align to description text
'lyrics_vad_enabled', // Silero VAD filter
'lyrics_vocal_region_gapfill', // snap gap-filled lines to vocal regions
'lyrics_demucs_enabled', // vocal isolation (Demucs)
'lyrics_llm_decorate', // post-bake emojis via LLM
] as $k) {
Setting::set($k, $request->input($k, 'false') === 'true' ? 'true' : 'false');
}
}
// ── AI / LLM section ─────────────────────────────────────────────────
if ($request->has('llm_section')) {
Setting::set('llm_enabled', $request->input('llm_enabled', 'false') === 'true' ? 'true' : 'false');
Setting::set('llm_clean_lyrics', $request->input('llm_clean_lyrics', 'false') === 'true' ? 'true' : 'false');
Setting::set('llm_decorate_lyrics', $request->input('llm_decorate_lyrics', 'false') === 'true' ? 'true' : 'false');
$this->saveLlmProviders($request);
}
return back()->with('success', 'Settings saved.');
}
/**
* Probe an LLM provider endpoint: verify the connection and list
* available models. Used by the AI / LLM settings page.
*
* Accepts kind / endpoint / api_key from the form, plus an optional
* provider id so we can fall back to the saved key when the admin
* left the password field blank (placeholder ••••••••).
*/
public function llmProviderTest(Request $request)
{
$kind = (string) $request->input('kind', 'ollama');
$endpoint = trim((string) $request->input('endpoint', '')) ?: self::defaultEndpoint($kind);
$endpoint = rtrim($endpoint, '/');
$apiKey = (string) $request->input('api_key', '');
$id = (string) $request->input('id', '');
if ($apiKey === '' && $id !== '') {
$providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [];
foreach ($providers as $p) {
if (($p['id'] ?? '') === $id) {
$apiKey = (string) ($p['api_key'] ?? '');
break;
}
}
}
if (! in_array($kind, ['ollama', 'anthropic', 'openai'], true)) {
return response()->json(['ok' => false, 'message' => 'Unknown provider kind.'], 422);
}
if ($kind !== 'ollama' && $apiKey === '') {
return response()->json(['ok' => false, 'message' => 'API key required for ' . $kind . '.'], 422);
}
try {
$models = match ($kind) {
'ollama' => $this->fetchOllamaModels($endpoint),
'anthropic' => $this->fetchAnthropicModels($endpoint, $apiKey),
'openai' => $this->fetchOpenAIModels($endpoint, $apiKey),
};
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()]);
}
sort($models, SORT_NATURAL | SORT_FLAG_CASE);
return response()->json([
'ok' => true,
'count' => count($models),
'models' => $models,
]);
}
private function fetchOllamaModels(string $endpoint): array
{
$resp = \Illuminate\Support\Facades\Http::timeout(10)->acceptJson()->get($endpoint . '/api/tags');
if (! $resp->successful()) {
throw new \RuntimeException('Ollama returned HTTP ' . $resp->status());
}
$j = $resp->json();
return array_values(array_filter(array_map(
fn ($m) => (string) ($m['name'] ?? ''),
$j['models'] ?? []
)));
}
private function fetchAnthropicModels(string $endpoint, string $apiKey): array
{
$resp = \Illuminate\Support\Facades\Http::timeout(15)->withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
])->get($endpoint . '/v1/models');
if (! $resp->successful()) {
$body = $resp->json();
throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
}
$j = $resp->json();
return array_values(array_filter(array_map(
fn ($m) => (string) ($m['id'] ?? ''),
$j['data'] ?? []
)));
}
private function fetchOpenAIModels(string $endpoint, string $apiKey): array
{
$resp = \Illuminate\Support\Facades\Http::timeout(15)
->withToken($apiKey)->acceptJson()
->get($endpoint . '/v1/models');
if (! $resp->successful()) {
$body = $resp->json();
throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status()));
}
$j = $resp->json();
return array_values(array_filter(array_map(
fn ($m) => (string) ($m['id'] ?? ''),
$j['data'] ?? []
)));
}
public function lyrics()
{
$settings = [
'lyrics_enabled' => Setting::get('lyrics_enabled', 'true'),
'lyrics_use_description' => Setting::get('lyrics_use_description', 'true'),
'lyrics_vad_enabled' => Setting::get('lyrics_vad_enabled', 'true'),
'lyrics_vocal_region_gapfill' => Setting::get('lyrics_vocal_region_gapfill', 'true'),
'lyrics_demucs_enabled' => Setting::get('lyrics_demucs_enabled', 'false'),
'lyrics_llm_decorate' => Setting::get('lyrics_llm_decorate', Setting::get('llm_decorate_lyrics', 'false')),
];
return view('admin.lyrics', compact('settings'));
}
public function gpu()
{
$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')),
];
$gpus = $this->probeGpus();
$nvencWorks = $this->probeNvenc();
return view('admin.gpu', compact('settings', 'gpus', 'nvencWorks'));
}
public function backup()
{
return view('admin.backup');
}
public function detectGpu()
{
return response()->json(['gpus' => $this->probeGpus()]);
}
/**
* Persist the LLM provider list from the multi-provider form. Each row
* carries id / name / kind (ollama|anthropic|openai) / endpoint / model /
* api_key. An empty api_key means "keep the previously stored value" so the
* admin doesn't have to retype it on every save.
*/
private function saveLlmProviders(Request $request): void
{
$existing = collect(json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [])
->keyBy(fn ($p) => $p['id'] ?? '');
$kinds = ['ollama', 'anthropic', 'openai'];
$rows = (array) $request->input('providers', []);
$out = [];
foreach ($rows as $row) {
$name = trim((string) ($row['name'] ?? ''));
$kind = (string) ($row['kind'] ?? 'ollama');
if (! in_array($kind, $kinds, true)) $kind = 'ollama';
if ($name === '') continue;
$id = (string) ($row['id'] ?? '') ?: (string) \Illuminate\Support\Str::uuid();
$endpoint = trim((string) ($row['endpoint'] ?? '')) ?: self::defaultEndpoint($kind);
$model = trim((string) ($row['model'] ?? ''));
$apiKeyIn = (string) ($row['api_key'] ?? '');
// Blank input → keep the previously-stored key for this id (admin
// didn't retype it). Non-blank → use the new value verbatim.
$apiKey = $apiKeyIn !== '' ? $apiKeyIn : (string) ($existing[$id]['api_key'] ?? '');
$out[] = [
'id' => $id,
'name' => $name,
'kind' => $kind,
'endpoint' => $endpoint,
'model' => $model,
'api_key' => $apiKey,
];
}
Setting::set('llm_providers', json_encode($out, JSON_UNESCAPED_UNICODE));
$activeId = trim((string) $request->input('llm_active_id', ''));
$validIds = array_column($out, 'id');
if ($activeId !== '' && in_array($activeId, $validIds, true)) {
Setting::set('llm_active_id', $activeId);
} elseif (count($validIds) === 1) {
Setting::set('llm_active_id', $validIds[0]);
} elseif (! in_array((string) Setting::get('llm_active_id', ''), $validIds, true)) {
Setting::set('llm_active_id', '');
}
}
private static function defaultEndpoint(string $kind): string
{
return match ($kind) {
'anthropic' => 'https://api.anthropic.com',
'openai' => 'https://api.openai.com',
default => 'http://localhost:11434',
};
}
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', []);
$settings = [
'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'),
];
return view('admin.nas-storage', compact('nodes', 'settings'));
}
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.");
}
}