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
@@ -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