From ba8ab11ce4a3fc5bb0316e568674ccb4eb4d03e2 Mon Sep 17 00:00:00 2001 From: ghassan Date: Fri, 31 Jul 2026 02:42:25 +0300 Subject: [PATCH] Chunked video upload, persistent track order, per-track playlist entries Chunked uploads: bypass Cloudflare's 100 MB request cap by slicing the video/audio into 8 MB chunks and reassembling server-side before the normal store flow runs. Track reorder: audio tracks gain a `position` column so a user's edit- modal ordering survives reload. `Video::audioTracks()` now orders by position, `promote_track_id` remembers the swapped row, and freshly uploaded tracks append to the end. Playlist per-track pinning: `playlist_videos.audio_track_id` remembers which language the user pinned when adding a song. Same song with a different track becomes a separate row (Spotify-style duplicates). Player-data exposes `active_stream_url` + `active_track_id`, sidebar cards render `?track=` from the pivot, and both `plTransitionTo` and initial-load code activate the corresponding language option. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Http/Controllers/PlaylistController.php | 47 ++++- app/Http/Controllers/VideoController.php | 174 ++++++++++++++++-- app/Models/Playlist.php | 53 +++++- app/Models/Video.php | 28 ++- ...001_add_position_to_video_audio_tracks.php | 30 +++ ..._add_audio_track_id_to_playlist_videos.php | 33 ++++ .../partials/add-to-playlist-modal.blade.php | 23 ++- .../partials/edit-video-modal.blade.php | 8 + .../layouts/partials/upload-modal.blade.php | 76 +++++++- resources/views/videos/create.blade.php | 71 ++++++- resources/views/videos/types/music.blade.php | 47 ++++- routes/web.php | 6 + 12 files changed, 547 insertions(+), 49 deletions(-) create mode 100644 database/migrations/2026_07_31_000001_add_position_to_video_audio_tracks.php create mode 100644 database/migrations/2026_07_31_000002_add_audio_track_id_to_playlist_videos.php diff --git a/app/Http/Controllers/PlaylistController.php b/app/Http/Controllers/PlaylistController.php index 770248b..7f921a8 100644 --- a/app/Http/Controllers/PlaylistController.php +++ b/app/Http/Controllers/PlaylistController.php @@ -323,7 +323,8 @@ class PlaylistController extends Controller } $request->validate([ - 'video_id' => 'required|exists:videos,id', + 'video_id' => 'required|exists:videos,id', + 'audio_track_id' => 'nullable|integer|exists:video_audio_tracks,id', ]); $video = Video::findOrFail($request->video_id); @@ -333,7 +334,13 @@ class PlaylistController extends Controller abort(403, 'You cannot add this video to your playlist.'); } - $added = $playlist->addVideo($video); + // Track must actually belong to this video (avoid arbitrary FK pinning). + $audioTrackId = $request->input('audio_track_id'); + if ($audioTrackId && ! $video->audioTracks()->whereKey($audioTrackId)->exists()) { + $audioTrackId = null; + } + + $added = $playlist->addVideo($video, $audioTrackId ? (int) $audioTrackId : null); if ($request->expectsJson() || $request->ajax()) { return response()->json([ @@ -353,9 +360,30 @@ class PlaylistController extends Controller */ public function removeVideoByBody(Request $request, Playlist $playlist) { - $request->validate(['video_id' => 'required|exists:videos,id']); + $request->validate([ + 'video_id' => 'required|exists:videos,id', + 'audio_track_id' => 'nullable|integer', + ]); + + if (! $playlist->canEdit(Auth::user())) { + abort(403, 'You do not have permission to edit this playlist.'); + } + $video = Video::findOrFail($request->video_id); - return $this->removeVideo($request, $playlist, $video); + $trackId = $request->filled('audio_track_id') + ? (int) $request->input('audio_track_id') + : null; + + $removed = $playlist->removeVideoWithTrack($video, $trackId); + + if ($request->expectsJson() || $request->ajax()) { + return response()->json([ + 'success' => true, + 'message' => $removed ? 'Video removed from playlist.' : 'Video was not in the playlist.', + 'video_count' => $playlist->video_count, + ]); + } + return back()->with('success', 'Video removed from playlist.'); } // Remove video from playlist @@ -431,6 +459,17 @@ class PlaylistController extends Controller 'visibility' => $p->visibility, 'thumbnail_url' => $p->thumbnail_url, 'video_ids' => $p->videos()->pluck('videos.id')->toArray(), + // Track-aware entries: each row = {video_id, audio_track_id or null}. + // Consumers that need per-track granularity use this; older callers + // can still rely on video_ids above. + 'entries' => \DB::table('playlist_videos') + ->where('playlist_id', $p->id) + ->select('video_id', 'audio_track_id') + ->get() + ->map(fn ($r) => [ + 'video_id' => (int) $r->video_id, + 'audio_track_id' => $r->audio_track_id ? (int) $r->audio_track_id : null, + ])->all(), ]; }); diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index 32ea95b..54cb491 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -131,9 +131,92 @@ class VideoController extends Controller return view('videos.create'); } + /** + * Receive one chunk of a large upload. Client posts chunks of ~8 MB + * (well under Cloudflare's 100 MB per-request cap) sequentially; server + * appends each to a per-upload buffer under data/app/tmp/uploads/{uid}/. + * When the final `store` call is made with `video_upload_id`, the + * assembled buffer is promoted to the real UploadedFile. + */ + public function uploadChunk(Request $request) + { + $request->validate([ + 'upload_id' => ['required', 'string', 'regex:/^[A-Za-z0-9\-]{8,64}$/'], + 'chunk_index' => 'required|integer|min:0', + 'total_chunks' => 'required|integer|min:1|max:100000', + 'filename' => 'required|string|max:255', + 'chunk' => 'required|file', + ]); + + $uid = Auth::id(); + $uploadId = $request->input('upload_id'); + $idx = (int) $request->input('chunk_index'); + $total = (int) $request->input('total_chunks'); + $filename = basename($request->input('filename')); + + $dir = storage_path("app/tmp/uploads/{$uid}"); + if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { + return response()->json(['success' => false, 'message' => 'Cannot create upload buffer'], 500); + } + + $partPath = "{$dir}/{$uploadId}.part"; + $mode = $idx === 0 ? 'wb' : 'ab'; + + $out = @fopen($partPath, $mode); + if (!$out) { + return response()->json(['success' => false, 'message' => 'Cannot open upload buffer'], 500); + } + $in = fopen($request->file('chunk')->getRealPath(), 'rb'); + while (!feof($in)) fwrite($out, fread($in, 1 << 20)); + fclose($in); + fclose($out); + + @file_put_contents("{$dir}/{$uploadId}.meta", json_encode([ + 'filename' => $filename, + 'total_chunks' => $total, + 'received' => $idx + 1, + 'updated_at' => time(), + ])); + + return response()->json([ + 'success' => true, + 'chunk_index' => $idx, + 'received' => $idx + 1, + 'total_chunks' => $total, + 'complete' => ($idx + 1) === $total, + 'size' => filesize($partPath), + ]); + } + public function store(Request $request) { try { + // Reassemble chunked upload (see uploadChunk). Must run BEFORE any + // call to $request->file()/hasFile() — Laravel caches convertedFiles + // on first access, and a stale cache would ignore our injected file. + // Using $request->files->has() (Symfony FileBag) does not trigger + // that cache, so it's safe to check here. + if ($request->filled('video_upload_id') && !$request->files->has('video')) { + $uid = Auth::id(); + $uploadId = preg_replace('/[^A-Za-z0-9\-]/', '', (string) $request->input('video_upload_id')); + $filename = basename((string) $request->input('video_filename', 'upload.mp4')); + $partPath = storage_path("app/tmp/uploads/{$uid}/{$uploadId}.part"); + $metaPath = storage_path("app/tmp/uploads/{$uid}/{$uploadId}.meta"); + + if (!$uploadId || !is_file($partPath)) { + return response()->json([ + 'success' => false, + 'message' => 'Upload buffer not found — please retry.', + ], 422); + } + + $mime = @mime_content_type($partPath) ?: 'application/octet-stream'; + $request->files->set('video', new \Illuminate\Http\UploadedFile( + $partPath, $filename, $mime, null, true + )); + @unlink($metaPath); + } + $audioExtensions = ['mp3', 'm4a', 'aac', 'flac', 'wav']; $uploadedExt = strtolower($request->file('video')?->getClientOriginalExtension() ?? ''); $isAudioUpload = in_array($uploadedExt, $audioExtensions); @@ -614,6 +697,13 @@ class VideoController extends Controller ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png'); + // Requested language track (0/absent = primary). Only honoured when + // the id actually belongs to this video. + $requestedTrackId = (int) $request->query('track', 0); + $requestedTrack = $requestedTrackId + ? $video->audioTracks->firstWhere('id', $requestedTrackId) + : null; + // Per-track slide map (key "0" = primary). Each entry already has the // sharing fallback applied by Video::slidesForTrack — a track without its // own slides borrows the primary's (or a sibling's) automatically. @@ -623,7 +713,9 @@ class VideoController extends Controller $slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id) ->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all(); } - $slides = $slideMap['0']; + $slides = $requestedTrack + ? ($slideMap[(string) $requestedTrack->id] ?: $slideMap['0']) + : $slideMap['0']; $allLangData = \App\Data\Languages::all(); $audioTracks = $video->audioTracks->map(fn ($t) => [ @@ -652,6 +744,15 @@ class VideoController extends Controller 'has_hls' => (bool) $video->has_hls, 'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null, 'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp, + // When a specific language track is requested (playlist row pin, + // shared link with ?track=), expose it separately. The player uses + // this for the actual