WIP: storage-fix-local-nas work before playlist controls feature
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
6b3ab5b65e
commit
c160242dbc
46 files changed
+5365
-815
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoSlide;
|
||||
@@ -47,6 +48,11 @@ class MediaController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Might be a playlist thumbnail
|
||||
if (! file_exists($local)) {
|
||||
$nas->ensureLocalAsset($local, $filename);
|
||||
}
|
||||
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
@@ -87,6 +93,27 @@ class MediaController extends Controller
|
||||
|
||||
public function avatar(string $filename, NasSyncService $nas): Response
|
||||
{
|
||||
// New format: "users/{slug}/profile/avatar.{ext}"
|
||||
if (str_starts_with($filename, 'users/')) {
|
||||
$local = storage_path('app/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
@mkdir(dirname($local), 0755, true);
|
||||
$user = User::where('avatar', $filename)->first();
|
||||
if ($user) {
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
||||
foreach (["avatar.webp", "avatar.{$ext}"] as $nasFile) {
|
||||
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
|
||||
}
|
||||
}
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// Legacy flat format
|
||||
$local = storage_path('app/public/avatars/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
@@ -104,6 +131,27 @@ class MediaController extends Controller
|
||||
|
||||
public function banner(string $filename, NasSyncService $nas): Response
|
||||
{
|
||||
// New format: "users/{slug}/profile/cover.{ext}"
|
||||
if (str_starts_with($filename, 'users/')) {
|
||||
$local = storage_path('app/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
@mkdir(dirname($local), 0755, true);
|
||||
$user = User::where('banner', $filename)->first();
|
||||
if ($user) {
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
||||
foreach (["cover.webp", "cover.{$ext}"] as $nasFile) {
|
||||
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
|
||||
}
|
||||
}
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// Legacy flat format
|
||||
$local = storage_path('app/public/banners/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
@@ -119,6 +167,29 @@ class MediaController extends Controller
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
public function postImage(string $filename, NasSyncService $nas): Response
|
||||
{
|
||||
// New format: "users/{slug}/posts/{id}/{seq}.{ext}"
|
||||
if (str_starts_with($filename, 'users/')) {
|
||||
$local = storage_path('app/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
@mkdir(dirname($local), 0755, true);
|
||||
// NAS path is identical to the relative path stored in DB
|
||||
$nas->ensureLocalAsset($local, $filename);
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// Legacy flat format
|
||||
$local = storage_path('app/public/post_images/' . $filename);
|
||||
if (! file_exists($local)) abort(404);
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
private function fileResponse(string $path): Response
|
||||
|
||||
@@ -159,9 +159,8 @@ class PlaylistController extends Controller
|
||||
// Handle thumbnail upload
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
$file = $request->file('thumbnail');
|
||||
$filename = self::generateFilename($file->getClientOriginalExtension());
|
||||
$file->storeAs('public/thumbnails', $filename);
|
||||
$playlist->update(['thumbnail' => $filename]);
|
||||
$nasPath = self::pushPlaylistThumbnailToNas($file, $playlist);
|
||||
$playlist->update(['thumbnail' => $nasPath]);
|
||||
}
|
||||
|
||||
// Reload playlist with thumbnail
|
||||
@@ -228,28 +227,19 @@ class PlaylistController extends Controller
|
||||
|
||||
// Handle thumbnail upload
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
// Delete old thumbnail if exists
|
||||
// Delete old thumbnail from NAS if exists
|
||||
if ($playlist->thumbnail) {
|
||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
||||
if (file_exists($oldPath)) {
|
||||
unlink($oldPath);
|
||||
}
|
||||
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||
}
|
||||
|
||||
// Upload new thumbnail
|
||||
$file = $request->file('thumbnail');
|
||||
$filename = self::generateFilename($file->getClientOriginalExtension());
|
||||
$file->storeAs('public/thumbnails', $filename);
|
||||
$updateData['thumbnail'] = $filename;
|
||||
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
|
||||
}
|
||||
|
||||
// Handle thumbnail removal
|
||||
if ($request->input('remove_thumbnail') == '1') {
|
||||
if ($playlist->thumbnail) {
|
||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
||||
if (file_exists($oldPath)) {
|
||||
unlink($oldPath);
|
||||
}
|
||||
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||
$updateData['thumbnail'] = null;
|
||||
}
|
||||
}
|
||||
@@ -472,4 +462,37 @@ class PlaylistController extends Controller
|
||||
|
||||
return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token);
|
||||
}
|
||||
|
||||
// ── NAS thumbnail helpers ─────────────────────────────────────────────────
|
||||
|
||||
private static function nasPlaylistThumbPath(Playlist $playlist, string $ext): string
|
||||
{
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$playlist->loadMissing('user');
|
||||
$userSlug = $nas->userSlug($playlist->user);
|
||||
return "users/{$userSlug}/playlists/{$playlist->id}/thumb.{$ext}";
|
||||
}
|
||||
|
||||
private static function pushPlaylistThumbnailToNas(\Illuminate\Http\UploadedFile $file, Playlist $playlist): string
|
||||
{
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$tmpName = self::generateFilename($ext);
|
||||
$file->storeAs('public/thumbnails', $tmpName);
|
||||
$tempAbs = storage_path('app/public/thumbnails/' . $tmpName);
|
||||
$nasPath = self::nasPlaylistThumbPath($playlist, $ext);
|
||||
$dir = dirname($nasPath);
|
||||
$nas->mkdirp($dir);
|
||||
$nas->putFile($tempAbs, $nasPath);
|
||||
@unlink($tempAbs);
|
||||
return $nasPath;
|
||||
}
|
||||
|
||||
private static function deletePlaylistThumbnailFromNas(?string $nasPath): void
|
||||
{
|
||||
if (! $nasPath || ! str_starts_with($nasPath, 'users/')) return;
|
||||
try {
|
||||
app(\App\Services\NasSyncService::class)->deleteFile($nasPath);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,9 @@ use App\Models\Post;
|
||||
use App\Models\PostImage;
|
||||
use App\Models\PostVideo;
|
||||
use App\Models\User;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
@@ -29,7 +29,6 @@ class PostController extends Controller
|
||||
'images.*' => 'image|mimes:jpg,jpeg,png,webp,gif|max:8192',
|
||||
'video_ids' => 'nullable|array|max:10',
|
||||
'video_ids.*' => 'exists:videos,id',
|
||||
// Legacy fields
|
||||
'video_id' => 'nullable|exists:videos,id',
|
||||
'image' => 'nullable|image|mimes:jpg,jpeg,png,webp,gif|max:8192',
|
||||
]);
|
||||
@@ -43,35 +42,69 @@ class PostController extends Controller
|
||||
return back()->withErrors(['body' => 'Post cannot be empty.']);
|
||||
}
|
||||
|
||||
$data = [
|
||||
// Create post first — we need the ID as the folder name
|
||||
$post = Post::create([
|
||||
'user_id' => $user->id,
|
||||
'body' => $request->body,
|
||||
'video_id' => $request->video_id ?? null,
|
||||
];
|
||||
]);
|
||||
|
||||
// Legacy single image (backward compat)
|
||||
if ($hasLegacyImg) {
|
||||
$filename = uniqid('post_', true) . '.' . $request->file('image')->getClientOriginalExtension();
|
||||
$request->file('image')->storeAs('public/post_images', $filename);
|
||||
$data['image'] = $filename;
|
||||
}
|
||||
$nas = app(NasSyncService::class);
|
||||
$nasMode = $nas->isEnabled();
|
||||
$postDir = $nas->resolvePostDir($post); // "users/{slug}/posts/{id}"
|
||||
|
||||
$post = Post::create($data);
|
||||
if ($hasImages || $hasLegacyImg) {
|
||||
if ($nasMode) {
|
||||
// ── NAS primary: upload directly from PHP temp files ──────────
|
||||
$nas->mkdirp($postDir);
|
||||
|
||||
// New multi-image
|
||||
if ($hasImages) {
|
||||
foreach ($request->file('images') as $idx => $file) {
|
||||
$filename = uniqid('post_', true) . '.' . $file->getClientOriginalExtension();
|
||||
$file->storeAs('public/post_images', $filename);
|
||||
PostImage::create([
|
||||
'post_id' => $post->id,
|
||||
'filename' => $filename,
|
||||
'sort_order' => $idx,
|
||||
]);
|
||||
if ($hasLegacyImg) {
|
||||
$file = $request->file('image');
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$nasPath = "{$postDir}/0.{$ext}";
|
||||
$nas->putFile($file->getRealPath(), $nasPath);
|
||||
$post->update(['image' => $nasPath]);
|
||||
}
|
||||
|
||||
if ($hasImages) {
|
||||
foreach ($request->file('images') as $idx => $file) {
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$nasPath = "{$postDir}/" . ($idx + 1) . ".{$ext}";
|
||||
$nas->putFile($file->getRealPath(), $nasPath);
|
||||
PostImage::create([
|
||||
'post_id' => $post->id,
|
||||
'filename' => $nasPath,
|
||||
'sort_order' => $idx,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Local storage: save inside the user's posts directory ─────
|
||||
$localDir = storage_path('app/' . $postDir);
|
||||
@mkdir($localDir, 0755, true);
|
||||
|
||||
if ($hasLegacyImg) {
|
||||
$ext = $request->file('image')->getClientOriginalExtension() ?: 'jpg';
|
||||
$filename = "0.{$ext}";
|
||||
$request->file('image')->move($localDir, $filename);
|
||||
$post->update(['image' => "{$postDir}/{$filename}"]);
|
||||
}
|
||||
|
||||
if ($hasImages) {
|
||||
foreach ($request->file('images') as $idx => $file) {
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$filename = ($idx + 1) . ".{$ext}";
|
||||
$file->move($localDir, $filename);
|
||||
PostImage::create([
|
||||
'post_id' => $post->id,
|
||||
'filename' => "{$postDir}/{$filename}",
|
||||
'sort_order' => $idx,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New multi-video
|
||||
if ($hasVideoIds) {
|
||||
foreach ($request->input('video_ids') as $idx => $videoId) {
|
||||
PostVideo::create([
|
||||
@@ -91,14 +124,17 @@ class PostController extends Controller
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($post->image) {
|
||||
Storage::delete('public/post_images/' . $post->image);
|
||||
$post->loadMissing('postImages');
|
||||
$nas = app(NasSyncService::class);
|
||||
|
||||
if ($nas->isEnabled()) {
|
||||
try {
|
||||
$nas->deleteNasPost($post);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
// Delete multi-image files
|
||||
foreach ($post->postImages as $postImage) {
|
||||
Storage::delete('public/post_images/' . $postImage->filename);
|
||||
}
|
||||
// Always clean up local copies (handles both legacy flat and new structured format)
|
||||
$nas->deleteLocalPostImages($post);
|
||||
|
||||
$post->delete();
|
||||
|
||||
@@ -107,7 +143,7 @@ class PostController extends Controller
|
||||
|
||||
public function react(Post $post)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$user = Auth::user();
|
||||
$existing = $post->reactions()->where('user_id', $user->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
|
||||
@@ -313,12 +313,46 @@ class SuperAdminController extends Controller
|
||||
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(User $user)
|
||||
public function deleteUser(Request $request, User $user)
|
||||
{
|
||||
// Prevent deleting yourself
|
||||
if (auth()->id() === $user->id) {
|
||||
return back()->with('error', 'You cannot delete your own account!');
|
||||
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', [
|
||||
@@ -343,7 +377,7 @@ class SuperAdminController extends Controller
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('admin.users')->with('success', 'User deleted successfully!');
|
||||
return response()->json(['success' => true, 'message' => 'User deleted successfully!']);
|
||||
}
|
||||
|
||||
// List all videos
|
||||
@@ -530,16 +564,33 @@ class SuperAdminController extends Controller
|
||||
'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(Video $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', [
|
||||
@@ -561,7 +612,7 @@ class SuperAdminController extends Controller
|
||||
|
||||
$video->delete();
|
||||
|
||||
return redirect()->route('admin.videos')->with('success', 'Video "' . $videoTitle . '" deleted successfully!');
|
||||
return response()->json(['success' => true, 'message' => 'Video "' . $videoTitle . '" deleted successfully!']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -819,7 +870,6 @@ class SuperAdminController extends Controller
|
||||
'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';
|
||||
@@ -827,13 +877,12 @@ class SuperAdminController extends Controller
|
||||
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);
|
||||
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.');
|
||||
}
|
||||
@@ -897,4 +946,433 @@ class SuperAdminController extends Controller
|
||||
$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');
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -68,17 +68,36 @@ class UserController extends Controller
|
||||
'timezone' => $request->timezone ?: null,
|
||||
];
|
||||
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
if ($request->hasFile('avatar')) {
|
||||
if ($user->avatar) {
|
||||
Storage::delete('public/avatars/'.$user->avatar);
|
||||
}
|
||||
$filename = self::generateFilename($request->file('avatar')->getClientOriginalExtension());
|
||||
$request->file('avatar')->storeAs('public/avatars', $filename);
|
||||
$data['avatar'] = $filename;
|
||||
// Delete old avatar (handles both flat and new relative-path formats)
|
||||
$nas->deleteLocalAvatar($user);
|
||||
|
||||
$ext = $request->file('avatar')->getClientOriginalExtension() ?: 'webp';
|
||||
$profileDir = $nas->localProfileDir($user);
|
||||
$destFilename = "avatar.{$ext}";
|
||||
$destPath = "{$profileDir}/{$destFilename}";
|
||||
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
|
||||
|
||||
@mkdir($profileDir, 0755, true);
|
||||
$request->file('avatar')->move($profileDir, $destFilename);
|
||||
$data['avatar'] = $relPath;
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
// Push avatar to NAS and remove local copy when NAS is primary storage
|
||||
if ($nas->isEnabled()) {
|
||||
if ($request->hasFile('avatar')) {
|
||||
$destPath = storage_path('app/' . $data['avatar']);
|
||||
if (file_exists($destPath)) {
|
||||
$nas->syncAvatar($user, $destPath);
|
||||
$nas->deleteLocalAvatar($user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync social links
|
||||
$user->socialLinks()->delete();
|
||||
$order = 0;
|
||||
@@ -418,14 +437,68 @@ class UserController extends Controller
|
||||
public function updateAvatar(Request $request)
|
||||
{
|
||||
$request->validate(['path' => 'required|string|max:300']);
|
||||
Auth::user()->update(['avatar' => basename($request->path)]);
|
||||
$user = Auth::user();
|
||||
$filename = basename($request->path);
|
||||
$tempPath = storage_path('app/public/avatars/' . $filename);
|
||||
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
// Move temp file into the user's profile directory
|
||||
$profileDir = $nas->localProfileDir($user);
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
||||
$destFilename = "avatar.{$ext}";
|
||||
$destPath = "{$profileDir}/{$destFilename}";
|
||||
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
|
||||
|
||||
// Delete old avatar before moving new one in (handles both path formats)
|
||||
$nas->deleteLocalAvatar($user);
|
||||
|
||||
@mkdir($profileDir, 0755, true);
|
||||
if (file_exists($tempPath)) {
|
||||
rename($tempPath, $destPath);
|
||||
}
|
||||
|
||||
$user->update(['avatar' => $relPath]);
|
||||
|
||||
if ($nas->isEnabled() && file_exists($destPath)) {
|
||||
$nas->syncAvatar($user, $destPath);
|
||||
$nas->deleteLocalAvatar($user);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function updateBanner(Request $request)
|
||||
{
|
||||
$request->validate(['path' => 'required|string|max:300']);
|
||||
Auth::user()->update(['banner' => basename($request->path)]);
|
||||
$user = Auth::user();
|
||||
$filename = basename($request->path);
|
||||
$tempPath = storage_path('app/public/banners/' . $filename);
|
||||
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
// Move temp file into the user's profile directory
|
||||
$profileDir = $nas->localProfileDir($user);
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
||||
$destFilename = "cover.{$ext}";
|
||||
$destPath = "{$profileDir}/{$destFilename}";
|
||||
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
|
||||
|
||||
// Delete old banner before moving new one in (handles both path formats)
|
||||
$nas->deleteLocalBanner($user);
|
||||
|
||||
@mkdir($profileDir, 0755, true);
|
||||
if (file_exists($tempPath)) {
|
||||
rename($tempPath, $destPath);
|
||||
}
|
||||
|
||||
$user->update(['banner' => $relPath]);
|
||||
|
||||
if ($nas->isEnabled() && file_exists($destPath)) {
|
||||
$nas->syncCover($user, $destPath);
|
||||
$nas->deleteLocalBanner($user);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -527,6 +527,8 @@ class VideoController extends Controller
|
||||
'slides_add.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
|
||||
]);
|
||||
|
||||
$oldTitle = $video->title;
|
||||
|
||||
$data = $request->only(['title', 'description', 'visibility', 'type']);
|
||||
$data['download_access'] = $request->input('download_access', 'disabled');
|
||||
|
||||
@@ -544,10 +546,17 @@ class VideoController extends Controller
|
||||
$userSlug = $nas->userSlug($video->user);
|
||||
$data['thumbnail'] = 'users/' . $userSlug . '/videos/' . basename($localDir) . "/thumb.{$ext}";
|
||||
} else {
|
||||
// Legacy video: keep in flat thumbnails dir
|
||||
// Legacy video: push thumbnail directly to NAS
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
|
||||
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||
$data['thumbnail'] = basename($data['thumbnail']);
|
||||
$request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||
$tempAbs = storage_path('app/public/thumbnails/' . $thumbFilename);
|
||||
$nasDir = $nas->resolveVideoDir($video);
|
||||
$ext = pathinfo($thumbFilename, PATHINFO_EXTENSION);
|
||||
$nas->mkdirp($nasDir);
|
||||
$nas->putFile($tempAbs, "{$nasDir}/thumb.{$ext}");
|
||||
@unlink($tempAbs);
|
||||
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,11 +588,11 @@ class VideoController extends Controller
|
||||
if ($request->hasFile('slides_add')) {
|
||||
$nextPos = count($keptOrder);
|
||||
$isNewFormat = str_starts_with($video->path, 'users/');
|
||||
$nasForSlides = app(\App\Services\NasSyncService::class);
|
||||
if ($isNewFormat) {
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$localDir = $nas->localVideoDir($video);
|
||||
$localDir = $nasForSlides->localVideoDir($video);
|
||||
@mkdir("{$localDir}/slides", 0755, true);
|
||||
$userSlug = $nas->userSlug($video->user);
|
||||
$userSlug = $nasForSlides->userSlug($video->user);
|
||||
$relDir = 'users/' . $userSlug . '/videos/' . basename($localDir);
|
||||
}
|
||||
foreach ($request->file('slides_add') as $file) {
|
||||
@@ -594,9 +603,18 @@ class VideoController extends Controller
|
||||
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
|
||||
$slide->update(['filename' => "{$relDir}/slides/{$slide->id}.{$ext}"]);
|
||||
} else {
|
||||
$fname = self::generateFilename($file->getClientOriginalExtension());
|
||||
// Legacy video: push slide directly to NAS
|
||||
$fname = self::generateFilename($file->getClientOriginalExtension());
|
||||
$file->storeAs('public/thumbnails', $fname);
|
||||
VideoSlide::create(['video_id' => $video->id, 'filename' => $fname, 'position' => $nextPos]);
|
||||
$tempAbs = storage_path('app/public/thumbnails/' . $fname);
|
||||
$nasDir = $nasForSlides->resolveVideoDir($video);
|
||||
$nasForSlides->mkdirp("{$nasDir}/slides");
|
||||
$ext = pathinfo($fname, PATHINFO_EXTENSION);
|
||||
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
|
||||
$nasSlide = "{$nasDir}/slides/{$slide->id}.{$ext}";
|
||||
$nasForSlides->putFile($tempAbs, $nasSlide);
|
||||
@unlink($tempAbs);
|
||||
$slide->update(['filename' => $nasSlide]);
|
||||
}
|
||||
$nextPos++;
|
||||
$slidesChanged = true;
|
||||
@@ -618,6 +636,19 @@ class VideoController extends Controller
|
||||
|
||||
$video->update($data);
|
||||
|
||||
// If the title changed, rename the NAS/local folder before syncing so
|
||||
// the sync job writes to the correctly-named directory.
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
NasSyncVideoJob::dispatch($video->fresh());
|
||||
} catch (\Throwable $e) {
|
||||
@@ -692,6 +723,182 @@ class VideoController extends Controller
|
||||
return redirect()->route('videos.index')->with('success', 'Video deleted!');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Replace media file (keeps all metadata, views, likes, comments intact)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Replace media file (keeps all metadata, views, likes, comments intact)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
public function replaceFile(Request $request, Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->id !== $video->user_id && ! $user->isSuperAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'replacement_file' => [
|
||||
'required',
|
||||
'file',
|
||||
'mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus',
|
||||
'max:512000',
|
||||
],
|
||||
]);
|
||||
|
||||
$newFile = $request->file('replacement_file');
|
||||
$mimeType = $newFile->getMimeType();
|
||||
$isAudio = str_starts_with($mimeType, 'audio/');
|
||||
$newSize = $newFile->getSize();
|
||||
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
// ── 1. Clear old HLS ─────────────────────────────────────────────────
|
||||
if ($video->has_hls && $video->hls_path) {
|
||||
\Storage::deleteDirectory($video->hls_path);
|
||||
}
|
||||
|
||||
// ── 2. Delete old media file ─────────────────────────────────────────
|
||||
// NAS: only delete the video file; thumbnail/slides/meta.json stay
|
||||
// Local: unlink the local copy
|
||||
if ($nas->isEnabled() && str_starts_with($video->path, 'users/')) {
|
||||
try { $nas->deleteFile($video->path); } catch (\Throwable) {}
|
||||
} else {
|
||||
$oldLocal = storage_path('app/' . $video->path);
|
||||
if (file_exists($oldLocal)) @unlink($oldLocal);
|
||||
}
|
||||
|
||||
// ── 3. Store new file to a temporary local path ──────────────────────
|
||||
$tempFilename = \Str::uuid() . '.' . $newExt;
|
||||
$tempRelPath = 'public/videos/' . $tempFilename;
|
||||
$newFile->storeAs('public/videos', $tempFilename);
|
||||
$tempAbsPath = storage_path('app/' . $tempRelPath);
|
||||
|
||||
if (! file_exists($tempAbsPath)) {
|
||||
return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500);
|
||||
}
|
||||
|
||||
// ── 4. Extract metadata via FFprobe ──────────────────────────────────
|
||||
$width = $height = null;
|
||||
$orientation = 'landscape';
|
||||
$duration = 0;
|
||||
|
||||
try {
|
||||
$ffprobeBin = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
|
||||
$out = [];
|
||||
exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($tempAbsPath), $out);
|
||||
$duration = (int) round((float) ($out[0] ?? 0));
|
||||
|
||||
if (! $isAudio) {
|
||||
$ffprobe = \FFMpeg\FFProbe::create();
|
||||
$stream = $ffprobe->streams($tempAbsPath)->videos()->first();
|
||||
if ($stream) {
|
||||
$width = $stream->get('width');
|
||||
$height = $stream->get('height');
|
||||
if ($width && $height) {
|
||||
if ($height > $width) $orientation = 'portrait';
|
||||
elseif ($width > $height) $orientation = 'landscape';
|
||||
else $orientation = 'square';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('replaceFile: FFprobe failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// ── 5. Persist the file to its final location ─────────────────────────
|
||||
//
|
||||
// NAS path: push directly to NAS using uploadDirectToNas()
|
||||
// This handles legacy paths correctly by computing the
|
||||
// proper users/.../videos/... directory.
|
||||
// uploadDirectToNas() updates path/filename in DB.
|
||||
//
|
||||
// Local path: update path/filename to temp location, then call
|
||||
// organizeLocalFiles() which moves it to users/... layout.
|
||||
// CompressVideoJob() sets status=ready and chains HLS.
|
||||
|
||||
if ($nas->isEnabled()) {
|
||||
|
||||
// Point filename at the new file (uploadDirectToNas uses this for ext)
|
||||
$video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]);
|
||||
|
||||
try {
|
||||
// Pass null for thumb — we don't want to overwrite the existing thumbnail
|
||||
$nas->uploadDirectToNas($video, $tempAbsPath, null);
|
||||
$video->refresh();
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('replaceFile: NAS upload failed: ' . $e->getMessage());
|
||||
@unlink($tempAbsPath);
|
||||
return response()->json(['success' => false, 'message' => 'NAS upload failed. Please try again.'], 500);
|
||||
}
|
||||
|
||||
$metaUpdates = [
|
||||
'size' => $newSize,
|
||||
'mime_type'=> $mimeType,
|
||||
'has_hls' => false,
|
||||
'hls_path' => null,
|
||||
// For NAS the upload is the "done" state — set ready so GenerateHlsJob runs
|
||||
'status' => 'ready',
|
||||
];
|
||||
if (! $isAudio) {
|
||||
$metaUpdates['duration'] = $duration ?: $video->duration;
|
||||
$metaUpdates['width'] = $width ?: $video->width;
|
||||
$metaUpdates['height'] = $height ?: $video->height;
|
||||
$metaUpdates['orientation'] = $orientation;
|
||||
$metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
|
||||
}
|
||||
$video->update($metaUpdates);
|
||||
|
||||
if (! $isAudio) {
|
||||
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
|
||||
->onQueue('video-processing')
|
||||
->onConnection('database');
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
// Point the record at the temp file so organizeLocalFiles can move it
|
||||
$video->update(['path' => $tempRelPath, 'filename' => $tempFilename]);
|
||||
|
||||
try {
|
||||
$nas->organizeLocalFiles($video);
|
||||
$video->refresh();
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('replaceFile: organizeLocalFiles failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$metaUpdates = [
|
||||
'size' => $newSize,
|
||||
'mime_type'=> $mimeType,
|
||||
'has_hls' => false,
|
||||
'hls_path' => null,
|
||||
'status' => $isAudio ? 'ready' : 'processing',
|
||||
];
|
||||
if (! $isAudio) {
|
||||
$metaUpdates['duration'] = $duration ?: $video->duration;
|
||||
$metaUpdates['width'] = $width ?: $video->width;
|
||||
$metaUpdates['height'] = $height ?: $video->height;
|
||||
$metaUpdates['orientation'] = $orientation;
|
||||
$metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
|
||||
}
|
||||
$video->update($metaUpdates);
|
||||
|
||||
if (! $isAudio) {
|
||||
\App\Jobs\CompressVideoJob::dispatch($video->fresh())
|
||||
->onQueue('video-processing')
|
||||
->onConnection('database');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $isAudio
|
||||
? 'Audio file replaced successfully.'
|
||||
: 'File replaced — re-encoding has started. The video will be ready shortly.',
|
||||
'status' => $video->fresh()->status,
|
||||
'is_audio' => $isAudio,
|
||||
]);
|
||||
}
|
||||
|
||||
public function trending(Request $request)
|
||||
{
|
||||
$hours = $request->get('hours', 48);
|
||||
@@ -1127,7 +1334,11 @@ class VideoController extends Controller
|
||||
$path = $video->localVideoPath();
|
||||
|
||||
if (! file_exists($path)) {
|
||||
abort(404, 'Video file not found.');
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$path = $nas->ensureLocalCopy($video);
|
||||
if (! $path) {
|
||||
abort(404, 'Video file not found.');
|
||||
}
|
||||
}
|
||||
|
||||
$slug = $this->safeFilename($video->title, 'audio');
|
||||
@@ -1568,7 +1779,7 @@ class VideoController extends Controller
|
||||
'channel' => $u->username,
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar
|
||||
? asset('storage/avatars/' . $u->avatar)
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
@@ -1602,7 +1813,7 @@ class VideoController extends Controller
|
||||
'user_name' => $r->user_name ?? 'Guest',
|
||||
'user_avatar' => $r->user_id
|
||||
? ($r->user_avatar
|
||||
? asset('storage/avatars/' . $r->user_avatar)
|
||||
? route('media.avatar', $r->user_avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||||
: null,
|
||||
]);
|
||||
@@ -1631,7 +1842,7 @@ class VideoController extends Controller
|
||||
'id' => $u->id,
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar
|
||||
? asset('storage/avatars/' . $u->avatar)
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
@@ -1668,7 +1879,7 @@ class VideoController extends Controller
|
||||
'user_name' => $r->user_name ?? 'Guest',
|
||||
'user_avatar'=> $r->user_id
|
||||
? ($r->user_avatar
|
||||
? asset('storage/avatars/' . $r->user_avatar)
|
||||
? route('media.avatar', $r->user_avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||||
: null,
|
||||
]);
|
||||
@@ -1728,6 +1939,24 @@ class VideoController extends Controller
|
||||
'pct' => $totalAge > 0 ? round($cnt / $totalAge * 100) : 0,
|
||||
])->values();
|
||||
|
||||
// Who liked this video
|
||||
$likers = \DB::table('video_likes')
|
||||
->join('users', 'users.id', '=', 'video_likes.user_id')
|
||||
->select('users.id', 'users.name', 'users.avatar', 'users.username', 'video_likes.created_at as liked_at')
|
||||
->where('video_likes.video_id', $id)
|
||||
->orderByDesc('video_likes.created_at')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(fn ($u) => [
|
||||
'id' => $u->id,
|
||||
'channel' => $u->username,
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
'liked_at' => $u->liked_at,
|
||||
]);
|
||||
|
||||
// ── Share analytics ────────────────────────────────────────
|
||||
$shareLinks = \DB::table('video_shares')->where('video_id', $id)->get();
|
||||
$shareIds = $shareLinks->pluck('id');
|
||||
@@ -1774,6 +2003,7 @@ class VideoController extends Controller
|
||||
'daily' => $daily,
|
||||
'peak_hour' => $peakHour,
|
||||
'likes' => $video->like_count,
|
||||
'likers' => $likers,
|
||||
'genders' => $genders,
|
||||
'age_groups' => $ageGroups,
|
||||
]);
|
||||
@@ -1805,7 +2035,7 @@ class VideoController extends Controller
|
||||
'id' => $u->id,
|
||||
'channel' => $u->username,
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar ? asset('storage/avatars/' . $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
]);
|
||||
@@ -1869,7 +2099,7 @@ class VideoController extends Controller
|
||||
'id' => $u->id,
|
||||
'channel' => $u->username,
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar ? asset('storage/avatars/' . $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
]);
|
||||
@@ -1940,7 +2170,7 @@ class VideoController extends Controller
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'avatar' => $user->avatar ? asset('storage/avatars/' . $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
|
||||
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
|
||||
],
|
||||
'total' => $records->count(),
|
||||
'records' => $records,
|
||||
|
||||
Reference in new issue
Block a user