WIP: storage-fix-local-nas work before playlist controls feature

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-16 11:15:20 +03:00
1 parent 6b3ab5b65e
commit c160242dbc
46 files changed
+5365 -815

No files matched your search

+246 -16
View File
@@ -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,