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) <[email protected]>
This commit is contained in:
1 parent
af276f2cd1
commit
ba8ab11ce4
12 files changed
+547
-49
No files matched your search
@@ -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 <audio> src while stream_url stays as the
|
||||
// primary, so the language popup still lists the true primary.
|
||||
'active_track_id' => $requestedTrack->id ?? null,
|
||||
'active_stream_url' => $requestedTrack
|
||||
? route('videos.audio-track', ['video' => $video, 'track' => $requestedTrack->id])
|
||||
. '?v=' . $requestedTrack->updated_at->timestamp
|
||||
: null,
|
||||
'cover_url' => $coverUrl,
|
||||
'slides' => $slides,
|
||||
'slide_map' => $slideMap,
|
||||
@@ -959,6 +1060,12 @@ class VideoController extends Controller
|
||||
'promote_track_id' => 'nullable|integer',
|
||||
'delete_track_ids' => 'nullable|array',
|
||||
'delete_track_ids.*' => 'integer',
|
||||
// Ordered list of track IDs as they appear in the edit UI. Index
|
||||
// 0 is the primary; the value at each index is either a numeric
|
||||
// track id (existing secondary) or the literal string "primary"
|
||||
// for the original primary. Missing = no reorder was performed.
|
||||
'track_order' => 'nullable|array',
|
||||
'track_order.*' => 'nullable|string|max:32',
|
||||
]);
|
||||
|
||||
$oldTitle = $video->title;
|
||||
@@ -1212,7 +1319,9 @@ class VideoController extends Controller
|
||||
}
|
||||
|
||||
// Promote a secondary track to primary (swap metadata)
|
||||
if ($promoteId = (int) $request->input('promote_track_id')) {
|
||||
$promoteId = (int) $request->input('promote_track_id');
|
||||
$newSecondaryId = null; // set below if a promote happens
|
||||
if ($promoteId) {
|
||||
$promoteTrack = $video->audioTracks()->find($promoteId);
|
||||
if ($promoteTrack) {
|
||||
\Log::info('Track promote: swapping primary ↔ secondary', [
|
||||
@@ -1221,7 +1330,7 @@ class VideoController extends Controller
|
||||
'new_primary' => ['track_id' => $promoteTrack->id, 'lang' => $promoteTrack->language, 'path' => $promoteTrack->path, 'filename' => $promoteTrack->filename],
|
||||
]);
|
||||
// Create new secondary track from current primary metadata
|
||||
VideoAudioTrack::create([
|
||||
$newSecondary = VideoAudioTrack::create([
|
||||
'video_id' => $video->id,
|
||||
'language' => $video->language ?? 'en',
|
||||
'label' => strtoupper($video->language ?? 'en'),
|
||||
@@ -1230,6 +1339,7 @@ class VideoController extends Controller
|
||||
'path' => $video->path,
|
||||
'filename' => $video->filename,
|
||||
]);
|
||||
$newSecondaryId = $newSecondary->id;
|
||||
// Override $data with the promoted track's values
|
||||
$data['title'] = $promoteTrack->title ?: ($data['title'] ?? $video->title);
|
||||
$data['language'] = $promoteTrack->language;
|
||||
@@ -1242,6 +1352,40 @@ class VideoController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the user's reordered track list. `track_order` is the
|
||||
// ordered list of items in the edit UI, index 0 being the primary.
|
||||
// Each entry is either the literal string "primary" (the original
|
||||
// primary track row placeholder) or a numeric secondary-track id.
|
||||
$trackOrder = $request->input('track_order', []);
|
||||
if (is_array($trackOrder) && count($trackOrder) > 0) {
|
||||
foreach ($trackOrder as $i => $val) {
|
||||
if ($i === 0) continue; // slot 0 is the primary, not a track row
|
||||
if ($val === 'primary') {
|
||||
// The old primary was demoted to secondary in the promote
|
||||
// block above; write its new position.
|
||||
if ($newSecondaryId) {
|
||||
VideoAudioTrack::where('id', $newSecondaryId)->update(['position' => $i]);
|
||||
}
|
||||
} else {
|
||||
$tid = (int) $val;
|
||||
if ($tid <= 0 || $tid === $promoteId) continue; // promoted id is now the primary
|
||||
VideoAudioTrack::where('id', $tid)
|
||||
->where('video_id', $video->id)
|
||||
->update(['position' => $i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any tracks that were freshly created in this same request (e.g.
|
||||
// brand-new secondaries uploaded via extra_track_files) still sit at
|
||||
// position=0 because the client couldn't know their DB id in advance.
|
||||
// Append them to the end of the list so the ordering stays stable.
|
||||
$maxPos = (int) $video->audioTracks()->max('position');
|
||||
foreach ($video->audioTracks()->where('position', 0)->orderBy('id')->get() as $t) {
|
||||
$maxPos++;
|
||||
$t->update(['position' => $maxPos]);
|
||||
}
|
||||
|
||||
$video->update($data);
|
||||
|
||||
// If the title changed, rename the NAS folder and update meta.json.
|
||||
@@ -2989,7 +3133,7 @@ class VideoController extends Controller
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
: asset('images/default-avatar.svg'),
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
'device' => $ua['device'],
|
||||
@@ -3044,7 +3188,7 @@ class VideoController extends Controller
|
||||
'user_avatar' => $r->user_id
|
||||
? ($r->user_avatar
|
||||
? route('media.avatar', $r->user_avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||||
: asset('images/default-avatar.svg'))
|
||||
: null,
|
||||
'country' => $r->country,
|
||||
'device' => $ua['device'],
|
||||
@@ -3099,7 +3243,7 @@ class VideoController extends Controller
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
: asset('images/default-avatar.svg'),
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
'device' => $ua['device'],
|
||||
@@ -3145,7 +3289,7 @@ class VideoController extends Controller
|
||||
'user_avatar'=> $r->user_id
|
||||
? ($r->user_avatar
|
||||
? route('media.avatar', $r->user_avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||||
: asset('images/default-avatar.svg'))
|
||||
: null,
|
||||
'country' => $r->country,
|
||||
'type' => $r->type,
|
||||
@@ -3238,7 +3382,7 @@ class VideoController extends Controller
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
: asset('images/default-avatar.svg'),
|
||||
'liked_at' => $u->liked_at,
|
||||
]);
|
||||
|
||||
@@ -3276,7 +3420,7 @@ class VideoController extends Controller
|
||||
'avatar' => $user
|
||||
? ($user->avatar
|
||||
? route('media.avatar', $user->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $user->id)
|
||||
: asset('images/default-avatar.svg'))
|
||||
: null,
|
||||
'country' => $topCountry ? $topCountry->country : null,
|
||||
'country_name' => $topCountry ? $topCountry->country_name : null,
|
||||
@@ -3401,7 +3545,7 @@ class VideoController extends Controller
|
||||
'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,
|
||||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : asset('images/default-avatar.svg'),
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
]);
|
||||
@@ -3465,7 +3609,7 @@ class VideoController extends Controller
|
||||
'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,
|
||||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : asset('images/default-avatar.svg'),
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
]);
|
||||
@@ -3536,7 +3680,7 @@ class VideoController extends Controller
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
|
||||
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : asset('images/default-avatar.svg'),
|
||||
],
|
||||
'total' => $records->count(),
|
||||
'records' => $records,
|
||||
@@ -3640,7 +3784,7 @@ class VideoController extends Controller
|
||||
'channel' => $u->username,
|
||||
'avatar' => $u->avatar
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
: asset('images/default-avatar.svg'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3743,12 +3887,12 @@ class VideoController extends Controller
|
||||
'channel' => $user->username,
|
||||
'avatar' => $user->avatar
|
||||
? route('media.avatar', $user->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $user->id,
|
||||
: asset('images/default-avatar.svg'),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$identity['name'] = 'Guest (' . $key . ')';
|
||||
$identity['avatar'] = 'https://i.pravatar.cc/150?u=guest-' . $key;
|
||||
$identity['avatar'] = asset('images/default-avatar.svg');
|
||||
}
|
||||
|
||||
// Country aggregation
|
||||
|
||||
Reference in new issue
Block a user