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) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-07-31 02:42:25 +03:00
parent af276f2cd1
commit ba8ab11ce4
12 changed files with 547 additions and 49 deletions

View File

@ -323,7 +323,8 @@ class PlaylistController extends Controller
} }
$request->validate([ $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); $video = Video::findOrFail($request->video_id);
@ -333,7 +334,13 @@ class PlaylistController extends Controller
abort(403, 'You cannot add this video to your playlist.'); 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()) { if ($request->expectsJson() || $request->ajax()) {
return response()->json([ return response()->json([
@ -353,9 +360,30 @@ class PlaylistController extends Controller
*/ */
public function removeVideoByBody(Request $request, Playlist $playlist) 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); $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 // Remove video from playlist
@ -431,6 +459,17 @@ class PlaylistController extends Controller
'visibility' => $p->visibility, 'visibility' => $p->visibility,
'thumbnail_url' => $p->thumbnail_url, 'thumbnail_url' => $p->thumbnail_url,
'video_ids' => $p->videos()->pluck('videos.id')->toArray(), '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(),
]; ];
}); });

View File

@ -131,9 +131,92 @@ class VideoController extends Controller
return view('videos.create'); 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) public function store(Request $request)
{ {
try { 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']; $audioExtensions = ['mp3', 'm4a', 'aac', 'flac', 'wav'];
$uploadedExt = strtolower($request->file('video')?->getClientOriginalExtension() ?? ''); $uploadedExt = strtolower($request->file('video')?->getClientOriginalExtension() ?? '');
$isAudioUpload = in_array($uploadedExt, $audioExtensions); $isAudioUpload = in_array($uploadedExt, $audioExtensions);
@ -614,6 +697,13 @@ class VideoController extends Controller
? route('media.thumbnail', $video->thumbnail) ? route('media.thumbnail', $video->thumbnail)
: asset('storage/images/logo.png'); : 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 // Per-track slide map (key "0" = primary). Each entry already has the
// sharing fallback applied by Video::slidesForTrack — a track without its // sharing fallback applied by Video::slidesForTrack — a track without its
// own slides borrows the primary's (or a sibling's) automatically. // 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) $slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all(); ->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(); $allLangData = \App\Data\Languages::all();
$audioTracks = $video->audioTracks->map(fn ($t) => [ $audioTracks = $video->audioTracks->map(fn ($t) => [
@ -652,6 +744,15 @@ class VideoController extends Controller
'has_hls' => (bool) $video->has_hls, 'has_hls' => (bool) $video->has_hls,
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null, '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, '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, 'cover_url' => $coverUrl,
'slides' => $slides, 'slides' => $slides,
'slide_map' => $slideMap, 'slide_map' => $slideMap,
@ -959,6 +1060,12 @@ class VideoController extends Controller
'promote_track_id' => 'nullable|integer', 'promote_track_id' => 'nullable|integer',
'delete_track_ids' => 'nullable|array', 'delete_track_ids' => 'nullable|array',
'delete_track_ids.*' => 'integer', '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; $oldTitle = $video->title;
@ -1212,7 +1319,9 @@ class VideoController extends Controller
} }
// Promote a secondary track to primary (swap metadata) // 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); $promoteTrack = $video->audioTracks()->find($promoteId);
if ($promoteTrack) { if ($promoteTrack) {
\Log::info('Track promote: swapping primary ↔ secondary', [ \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], 'new_primary' => ['track_id' => $promoteTrack->id, 'lang' => $promoteTrack->language, 'path' => $promoteTrack->path, 'filename' => $promoteTrack->filename],
]); ]);
// Create new secondary track from current primary metadata // Create new secondary track from current primary metadata
VideoAudioTrack::create([ $newSecondary = VideoAudioTrack::create([
'video_id' => $video->id, 'video_id' => $video->id,
'language' => $video->language ?? 'en', 'language' => $video->language ?? 'en',
'label' => strtoupper($video->language ?? 'en'), 'label' => strtoupper($video->language ?? 'en'),
@ -1230,6 +1339,7 @@ class VideoController extends Controller
'path' => $video->path, 'path' => $video->path,
'filename' => $video->filename, 'filename' => $video->filename,
]); ]);
$newSecondaryId = $newSecondary->id;
// Override $data with the promoted track's values // Override $data with the promoted track's values
$data['title'] = $promoteTrack->title ?: ($data['title'] ?? $video->title); $data['title'] = $promoteTrack->title ?: ($data['title'] ?? $video->title);
$data['language'] = $promoteTrack->language; $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); $video->update($data);
// If the title changed, rename the NAS folder and update meta.json. // If the title changed, rename the NAS folder and update meta.json.
@ -2989,7 +3133,7 @@ class VideoController extends Controller
'name' => $u->name, 'name' => $u->name,
'avatar' => $u->avatar 'avatar' => $u->avatar
? route('media.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, 'count' => (int) $u->cnt,
'last_at' => $u->last_at, 'last_at' => $u->last_at,
'device' => $ua['device'], 'device' => $ua['device'],
@ -3044,7 +3188,7 @@ class VideoController extends Controller
'user_avatar' => $r->user_id 'user_avatar' => $r->user_id
? ($r->user_avatar ? ($r->user_avatar
? route('media.avatar', $r->user_avatar) ? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id) : asset('images/default-avatar.svg'))
: null, : null,
'country' => $r->country, 'country' => $r->country,
'device' => $ua['device'], 'device' => $ua['device'],
@ -3099,7 +3243,7 @@ class VideoController extends Controller
'name' => $u->name, 'name' => $u->name,
'avatar' => $u->avatar 'avatar' => $u->avatar
? route('media.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, 'count' => (int) $u->cnt,
'last_at' => $u->last_at, 'last_at' => $u->last_at,
'device' => $ua['device'], 'device' => $ua['device'],
@ -3145,7 +3289,7 @@ class VideoController extends Controller
'user_avatar'=> $r->user_id 'user_avatar'=> $r->user_id
? ($r->user_avatar ? ($r->user_avatar
? route('media.avatar', $r->user_avatar) ? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id) : asset('images/default-avatar.svg'))
: null, : null,
'country' => $r->country, 'country' => $r->country,
'type' => $r->type, 'type' => $r->type,
@ -3238,7 +3382,7 @@ class VideoController extends Controller
'name' => $u->name, 'name' => $u->name,
'avatar' => $u->avatar 'avatar' => $u->avatar
? route('media.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, 'liked_at' => $u->liked_at,
]); ]);
@ -3276,7 +3420,7 @@ class VideoController extends Controller
'avatar' => $user 'avatar' => $user
? ($user->avatar ? ($user->avatar
? route('media.avatar', $user->avatar) ? route('media.avatar', $user->avatar)
: 'https://i.pravatar.cc/150?u=' . $user->id) : asset('images/default-avatar.svg'))
: null, : null,
'country' => $topCountry ? $topCountry->country : null, 'country' => $topCountry ? $topCountry->country : null,
'country_name' => $topCountry ? $topCountry->country_name : null, 'country_name' => $topCountry ? $topCountry->country_name : null,
@ -3401,7 +3545,7 @@ class VideoController extends Controller
'id' => $u->id, 'id' => $u->id,
'channel' => $u->username, 'channel' => $u->username,
'name' => $u->name, '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, 'count' => (int) $u->cnt,
'last_at' => $u->last_at, 'last_at' => $u->last_at,
]); ]);
@ -3465,7 +3609,7 @@ class VideoController extends Controller
'id' => $u->id, 'id' => $u->id,
'channel' => $u->username, 'channel' => $u->username,
'name' => $u->name, '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, 'count' => (int) $u->cnt,
'last_at' => $u->last_at, 'last_at' => $u->last_at,
]); ]);
@ -3536,7 +3680,7 @@ class VideoController extends Controller
'user' => [ 'user' => [
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, '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(), 'total' => $records->count(),
'records' => $records, 'records' => $records,
@ -3640,7 +3784,7 @@ class VideoController extends Controller
'channel' => $u->username, 'channel' => $u->username,
'avatar' => $u->avatar 'avatar' => $u->avatar
? route('media.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, 'channel' => $user->username,
'avatar' => $user->avatar 'avatar' => $user->avatar
? route('media.avatar', $user->avatar) ? route('media.avatar', $user->avatar)
: 'https://i.pravatar.cc/150?u=' . $user->id, : asset('images/default-avatar.svg'),
]; ];
} }
} else { } else {
$identity['name'] = 'Guest (' . $key . ')'; $identity['name'] = 'Guest (' . $key . ')';
$identity['avatar'] = 'https://i.pravatar.cc/150?u=guest-' . $key; $identity['avatar'] = asset('images/default-avatar.svg');
} }
// Country aggregation // Country aggregation

View File

@ -34,7 +34,7 @@ class Playlist extends Model
public function videos() public function videos()
{ {
return $this->belongsToMany(Video::class, 'playlist_videos') return $this->belongsToMany(Video::class, 'playlist_videos')
->withPivot('position', 'watched_seconds', 'watched', 'added_at', 'last_watched_at') ->withPivot('position', 'audio_track_id', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
->orderBy('position'); ->orderBy('position');
} }
@ -296,24 +296,48 @@ class Playlist extends Model
} }
} }
// Add video to playlist // Add video to playlist. When $audioTrackId is null the primary track
public function addVideo(Video $video) // plays; otherwise the specified language track is pinned to this row.
// Same video with a *different* track becomes a separate row.
public function addVideo(Video $video, ?int $audioTrackId = null)
{ {
if ($this->hasVideo($video)) { if ($this->hasVideoWithTrack($video, $audioTrackId)) {
return false; return false;
} }
$maxPosition = $this->videos()->max('position') ?? -1; $maxPosition = $this->videos()->max('position') ?? -1;
$this->videos()->attach($video->id, [ $this->videos()->attach($video->id, [
'position' => $maxPosition + 1, 'position' => $maxPosition + 1,
'added_at' => now(), 'audio_track_id' => $audioTrackId,
'added_at' => now(),
]); ]);
return true; return true;
} }
// Remove video from playlist // Remove a specific (video, track) row from the playlist. When
// $audioTrackId is null, only the "primary track" row is removed —
// language-pinned rows for the same video are left in place.
public function removeVideoWithTrack(Video $video, ?int $audioTrackId = null)
{
$query = \DB::table('playlist_videos')
->where('playlist_id', $this->id)
->where('video_id', $video->id);
if ($audioTrackId === null) {
$query->whereNull('audio_track_id');
} else {
$query->where('audio_track_id', $audioTrackId);
}
$deleted = $query->delete();
if ($deleted) $this->reorderPositions();
return (bool) $deleted;
}
// Remove *all* rows for a given video (any track). Preserved for
// callers that don't care about the track distinction.
public function removeVideo(Video $video) public function removeVideo(Video $video)
{ {
if (! $this->hasVideo($video)) { if (! $this->hasVideo($video)) {
@ -328,6 +352,21 @@ class Playlist extends Model
return true; return true;
} }
// Does this playlist contain (video, track)? track=null matches the
// "primary track" row specifically, not any row for the video.
public function hasVideoWithTrack(Video $video, ?int $audioTrackId = null): bool
{
$query = \DB::table('playlist_videos')
->where('playlist_id', $this->id)
->where('video_id', $video->id);
if ($audioTrackId === null) {
$query->whereNull('audio_track_id');
} else {
$query->where('audio_track_id', $audioTrackId);
}
return $query->exists();
}
// Reorder positions after removal // Reorder positions after removal
protected function reorderPositions() protected function reorderPositions()
{ {

View File

@ -67,7 +67,9 @@ class Video extends Model
public function audioTracks() public function audioTracks()
{ {
return $this->hasMany(\App\Models\VideoAudioTrack::class)->orderBy('id'); return $this->hasMany(\App\Models\VideoAudioTrack::class)
->orderBy('position')
->orderBy('id');
} }
public function hasSlideshow(): bool public function hasSlideshow(): bool
@ -252,7 +254,29 @@ class Video extends Model
if ($realId === null || $realId <= 0) { if ($realId === null || $realId <= 0) {
abort(404); abort(404);
} }
return $this->where('id', $realId)->firstOrFail(); $video = $this->where('id', $realId)->first();
if ($video) {
return $video;
}
// Video not found — check the merge-redirect table so old links still work.
$redirect = \DB::table('video_redirects')->where('source_id', $realId)->first();
if ($redirect) {
$target = static::find($redirect->target_id);
if ($target) {
$newValue = self::encodeId($target->id);
$newUrl = preg_replace(
'#/'.preg_quote((string) $value, '#').'(?=/|\?|$)#',
'/'.$newValue,
request()->fullUrl(),
1
);
throw new \Illuminate\Http\Exceptions\HttpResponseException(
redirect()->to($newUrl, 302)
);
}
}
abort(404);
} }
// All share URLs use the unguessable token route // All share URLs use the unguessable token route

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('video_audio_tracks', function (Blueprint $table) {
$table->unsignedInteger('position')->default(0)->after('label');
$table->index(['video_id', 'position']);
});
// Backfill existing rows so their current id-order becomes the
// canonical position. New rows start at 0 and get placed at the
// end by the update handler.
DB::statement('UPDATE video_audio_tracks SET position = id');
}
public function down(): void
{
Schema::table('video_audio_tracks', function (Blueprint $table) {
$table->dropIndex(['video_id', 'position']);
$table->dropColumn('position');
});
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('playlist_videos', function (Blueprint $table) {
// Nullable — null means "play the primary track".
$table->unsignedBigInteger('audio_track_id')
->nullable()
->after('video_id');
// Drop the old (playlist_id, video_id) uniqueness so the same
// video can appear multiple times with different tracks.
$table->dropUnique(['playlist_id', 'video_id']);
$table->index(['playlist_id', 'video_id', 'audio_track_id'], 'playlist_video_track_idx');
});
}
public function down(): void
{
Schema::table('playlist_videos', function (Blueprint $table) {
$table->dropIndex('playlist_video_track_idx');
$table->unique(['playlist_id', 'video_id']);
$table->dropColumn('audio_track_id');
});
}
};

View File

@ -237,9 +237,22 @@
.then(data => { .then(data => {
if (data.success && data.playlists && data.playlists.length > 0) { if (data.success && data.playlists && data.playlists.length > 0) {
let html = ''; let html = '';
// Language-track the modal is scoped to (0 = primary).
// Read once here so both the "already in?" check and the
// toggle call use the same value.
const currentTrackId = parseInt(window._ytpTrackId || 0);
data.playlists.forEach(function(playlist) { data.playlists.forEach(function(playlist) {
const isInPlaylist = playlist.video_ids && playlist.video_ids.includes(parseInt( // Prefer entries[] (per-track granularity) when present.
videoId)); // Fall back to video_ids for older API responses.
let isInPlaylist = false;
if (Array.isArray(playlist.entries)) {
isInPlaylist = playlist.entries.some(function (e) {
return e.video_id === parseInt(videoId)
&& (e.audio_track_id || 0) === currentTrackId;
});
} else if (playlist.video_ids) {
isInPlaylist = playlist.video_ids.includes(parseInt(videoId));
}
const visibilityText = playlist.visibility === 'public' ? 'Public' : 'Private'; const visibilityText = playlist.visibility === 'public' ? 'Public' : 'Private';
const durationText = playlist.formatted_duration || '0m'; const durationText = playlist.formatted_duration || '0m';
html += ` html += `
@ -328,7 +341,11 @@
// of the encoded route key used by GET /videos/{video}. // of the encoded route key used by GET /videos/{video}.
var url = `/playlists/${playlistId}/videos`; var url = `/playlists/${playlistId}/videos`;
var method = currentlyIn ? 'DELETE' : 'POST'; var method = currentlyIn ? 'DELETE' : 'POST';
var body = JSON.stringify({ video_id: videoId }); // Pin the row to the currently-active audio track (0 = primary/null).
var trackId = parseInt(window._ytpTrackId || 0);
var payload = { video_id: videoId };
if (trackId > 0) payload.audio_track_id = trackId;
var body = JSON.stringify(payload);
fetch(url, { fetch(url, {
method, method,

View File

@ -980,6 +980,14 @@ document.getElementById('edit-form').addEventListener('submit', function(e) {
// Append delete IDs // Append delete IDs
_editDeleteTrackIds.forEach(id => formData.append('delete_track_ids[]', id)); _editDeleteTrackIds.forEach(id => formData.append('delete_track_ids[]', id));
// Ordered list of track IDs as displayed. Index 0 is the primary slot;
// "primary" is the sentinel for the original primary track row, since
// the primary isn't a video_audio_tracks row of its own.
Array.from(document.querySelectorAll('#edit-tc-list > .um-track-card')).forEach(card => {
const val = (card.id === 'edit-tc-t1-card') ? 'primary' : (card.dataset.trackId || '');
if (val) formData.append('track_order[]', val);
});
// ── Log tracker: dump the exact track-related payload being submitted ────── // ── Log tracker: dump the exact track-related payload being submitted ──────
(function () { (function () {
const promoteId = document.getElementById('edit-promote-track-id')?.value || '(none)'; const promoteId = document.getElementById('edit-promote-track-id')?.value || '(none)';

View File

@ -1626,6 +1626,42 @@ function removeExtraTrackModal(n) {
} }
}()); }());
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
async function _chunkedUploadVideo(file, onProgress) {
const CHUNK = 8 * 1024 * 1024; // 8 MB — well under CF 100 MB limit
const total = Math.max(1, Math.ceil(file.size / CHUNK));
const uploadId = (window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
for (let i = 0; i < total; i++) {
const start = i * CHUNK;
const end = Math.min(start + CHUNK, file.size);
const blob = file.slice(start, end);
const fd = new FormData();
fd.append('upload_id', uploadId);
fd.append('chunk_index', i);
fd.append('total_chunks', total);
fd.append('filename', file.name);
fd.append('chunk', blob, 'chunk');
const res = await fetch('{{ route("videos.uploadChunk") }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
body: fd,
credentials: 'same-origin',
});
if (!res.ok) {
let msg = 'Chunk ' + (i + 1) + '/' + total + ' failed (' + res.status + ')';
try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {}
throw new Error(msg);
}
if (onProgress) onProgress({ loaded: end, total: file.size });
}
return { uploadId: uploadId, filename: file.name };
}
// ── Form submission ─────────────────────────────────────────── // ── Form submission ───────────────────────────────────────────
document.getElementById('upload-form-modal').addEventListener('submit', function(e) { document.getElementById('upload-form-modal').addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
@ -1685,11 +1721,39 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
document.getElementById('submit-btn-modal').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…'; document.getElementById('submit-btn-modal').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
document.getElementById('status-message-modal').className = 'status-message-modal'; document.getElementById('status-message-modal').className = 'status-message-modal';
// The video file is the big one — upload it in 8 MB chunks first, then
// submit the small form (thumbnails/slides/text) as the final request.
const _videoFile = videoInput.files[0];
formData.delete('video');
const _pbar = document.getElementById('progress-bar-modal');
const _ptext = document.getElementById('progress-text-modal');
_pbar.style.width = '0%';
_ptext.textContent = 'Uploading… 0%';
_chunkedUploadVideo(_videoFile, function(p) {
// Chunk upload accounts for 095% of the visible progress.
const pct = Math.round((p.loaded / p.total) * 95);
_pbar.style.width = pct + '%';
_ptext.textContent = 'Uploading… ' + pct + '%';
}).then(function(res) {
formData.append('video_upload_id', res.uploadId);
formData.append('video_filename', res.filename);
_pbar.style.width = '96%';
_ptext.textContent = 'Finalising…';
xhr.open('POST', '{{ route("videos.store") }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
xhr.send(formData);
}).catch(function(err) {
_uploadInProgress = false;
_showUploadError(err && err.message ? err.message : 'Chunked upload failed');
});
xhr.upload.addEventListener('progress', function(ev) { xhr.upload.addEventListener('progress', function(ev) {
if (ev.lengthComputable) { if (ev.lengthComputable && ev.total > 0) {
const pct = Math.round((ev.loaded / ev.total) * 100); const pct = 96 + Math.round((ev.loaded / ev.total) * 3);
document.getElementById('progress-bar-modal').style.width = pct + '%'; _pbar.style.width = pct + '%';
document.getElementById('progress-text-modal').textContent = 'Uploading… ' + pct + '%';
} }
}); });
@ -1730,10 +1794,6 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
xhr.addEventListener('error', () => { _uploadInProgress = false; _showUploadError('Upload failed. Please check your connection.'); }); xhr.addEventListener('error', () => { _uploadInProgress = false; _showUploadError('Upload failed. Please check your connection.'); });
xhr.addEventListener('timeout', () => { _uploadInProgress = false; _showUploadError('Upload timed out.'); }); xhr.addEventListener('timeout', () => { _uploadInProgress = false; _showUploadError('Upload timed out.'); });
xhr.addEventListener('abort', () => { _uploadInProgress = false; _showUploadError('Upload was cancelled.'); }); xhr.addEventListener('abort', () => { _uploadInProgress = false; _showUploadError('Upload was cancelled.'); });
xhr.open('POST', '{{ route("videos.store") }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
xhr.send(formData);
}); });
function _showUploadError(message) { function _showUploadError(message) {

View File

@ -798,6 +798,42 @@
}); });
}); });
// ── Chunked upload helper (bypasses Cloudflare's 100 MB cap) ─
async function _cChunkedUploadVideo(file, onProgress) {
const CHUNK = 8 * 1024 * 1024;
const total = Math.max(1, Math.ceil(file.size / CHUNK));
const uploadId = (window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
for (let i = 0; i < total; i++) {
const start = i * CHUNK;
const end = Math.min(start + CHUNK, file.size);
const blob = file.slice(start, end);
const fd = new FormData();
fd.append('upload_id', uploadId);
fd.append('chunk_index', i);
fd.append('total_chunks', total);
fd.append('filename', file.name);
fd.append('chunk', blob, 'chunk');
const res = await fetch('{{ route("videos.uploadChunk") }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
body: fd,
credentials: 'same-origin',
});
if (!res.ok) {
let msg = 'Chunk ' + (i + 1) + '/' + total + ' failed (' + res.status + ')';
try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {}
throw new Error(msg);
}
if (onProgress) onProgress({ loaded: end, total: file.size });
}
return { uploadId: uploadId, filename: file.name };
}
// ── Form submission ─────────────────────────────────────── // ── Form submission ───────────────────────────────────────
document.getElementById('upload-form').addEventListener('submit', function(e) { document.getElementById('upload-form').addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
@ -845,11 +881,34 @@
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...'; document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...';
document.getElementById('status-message').className = ''; document.getElementById('status-message').className = '';
// Chunk-upload the (potentially huge) video/audio file first, then
// send the small form + upload_id. Keeps every request <100 MB.
formData.delete('video');
const _pbar = document.getElementById('progress-bar');
const _ptext = document.getElementById('progress-text');
_pbar.style.width = '0%';
_ptext.textContent = 'Uploading... 0%';
_cChunkedUploadVideo(currentFile, function(p) {
const pct = Math.round((p.loaded / p.total) * 95);
_pbar.style.width = pct + '%';
_ptext.textContent = 'Uploading... ' + pct + '%';
}).then(function(res) {
formData.append('video_upload_id', res.uploadId);
formData.append('video_filename', res.filename);
_pbar.style.width = '96%';
_ptext.textContent = 'Finalising...';
xhr.open('POST', '{{ route("videos.store") }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
xhr.send(formData);
}).catch(function(err) {
showError(err && err.message ? err.message : 'Chunked upload failed');
});
xhr.upload.addEventListener('progress', function(e) { xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) { if (e.lengthComputable && e.total > 0) {
const pct = Math.round((e.loaded / e.total) * 100); const pct = 96 + Math.round((e.loaded / e.total) * 3);
document.getElementById('progress-bar').style.width = pct + '%'; _pbar.style.width = pct + '%';
document.getElementById('progress-text').textContent = 'Uploading... ' + pct + '%';
} }
}); });
@ -886,10 +945,6 @@
xhr.addEventListener('error', () => showError('Upload failed. Check your connection.')); xhr.addEventListener('error', () => showError('Upload failed. Check your connection.'));
xhr.addEventListener('timeout', () => showError('Upload timed out.')); xhr.addEventListener('timeout', () => showError('Upload timed out.'));
xhr.open('POST', '{{ route("videos.store") }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
xhr.send(formData);
}); });
function showError(message) { function showError(message) {

View File

@ -576,10 +576,14 @@
if (d.type && d.type !== 'music') { window.location.href = url; return; } if (d.type && d.type !== 'music') { window.location.href = url; return; }
// swap audio src (keeps browser autoplay permission) // swap audio src (keeps browser autoplay permission)
// Playlist rows may pin a specific language track — prefer
// active_stream_url when present so we play that one instead
// of the primary.
var audio = document.getElementById('audioEl'); var audio = document.getElementById('audioEl');
var srcToPlay = d.active_stream_url || d.stream_url;
if (audio) { if (audio) {
var _savedVol = audio.volume; var _savedMuted = audio.muted; var _savedVol = audio.volume; var _savedMuted = audio.muted;
audio.src = d.stream_url; audio.load(); audio.src = srcToPlay; audio.load();
audio.volume = _savedVol; audio.muted = _savedMuted; audio.volume = _savedVol; audio.muted = _savedMuted;
} }
@ -587,6 +591,23 @@
try { if (window._audioPlayerUpdate) window._audioPlayerUpdate(d); } try { if (window._audioPlayerUpdate) window._audioPlayerUpdate(d); }
catch(e) { console.warn('_audioPlayerUpdate', e); } catch(e) { console.warn('_audioPlayerUpdate', e); }
// After the popup is rebuilt, mark the pinned language
// option as active so the flag/lyrics/share reflect it.
if (d.active_track_id) {
try {
var pop = document.getElementById('ytpLangPopup');
var opt = pop && pop.querySelector('.ytp-lang-option[data-lang-id="' + d.active_track_id + '"]');
if (opt) {
pop.querySelectorAll('.ytp-lang-option').forEach(function(o){ o.classList.remove('active'); });
opt.classList.add('active');
window._ytpTrackId = parseInt(d.active_track_id, 10) || 0;
var lb = document.getElementById('ytpLangBtn') || document.querySelector('.ytp-lang-btn');
var flag = opt.dataset.langFlag;
if (lb && flag) lb.innerHTML = '<span class="fi fi-' + flag + '" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>';
}
} catch(e) { console.warn('active_track_id apply', e); }
}
// update state // update state
PL_CURRENT = d.id; PL_CURRENT = d.id;
if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false; if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false;
@ -699,6 +720,23 @@
if(a&&plLoop==='one') a.loop=true; if(a&&plLoop==='one') a.loop=true;
plRender(); plRender();
plHighlight(PL_CURRENT, false); plHighlight(PL_CURRENT, false);
// Honour ?track=ID on initial load — same effect as the
// pin logic in plTransitionTo, but applied to the server-
// rendered primary track on first paint.
try {
var params = new URLSearchParams(window.location.search);
var initTrack = parseInt(params.get('track') || '0', 10);
if (initTrack > 0) {
var applyPin = function() {
var pop = document.getElementById('ytpLangPopup');
var opt = pop && pop.querySelector('.ytp-lang-option[data-lang-id="' + initTrack + '"]');
if (opt) { opt.click(); return true; }
return false;
};
if (!applyPin()) setTimeout(applyPin, 300);
}
} catch(e) { console.warn('initial track pin', e); }
} }
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',plInit); else plInit(); if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',plInit); else plInit();
})(); })();
@ -707,9 +745,14 @@
<div class="recommended-videos-list"> <div class="recommended-videos-list">
@foreach ($playlistVideos as $index => $playlistVideo) @foreach ($playlistVideos as $index => $playlistVideo)
@php $isCurrentTrack = $playlistVideo->id === $video->id; @endphp @php $isCurrentTrack = $playlistVideo->id === $video->id; @endphp
@php
$pinnedTrackId = $playlistVideo->pivot->audio_track_id ?? null;
$trackQs = $pinnedTrackId ? '&track=' . $pinnedTrackId : '';
@endphp
<div class="sidebar-video-card{{ $isCurrentTrack ? ' current-video' : '' }}" <div class="sidebar-video-card{{ $isCurrentTrack ? ' current-video' : '' }}"
data-pl-id="{{ $playlistVideo->id }}" data-pl-id="{{ $playlistVideo->id }}"
onclick="plGoTo('{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}')" data-pl-track-id="{{ $pinnedTrackId ?? '' }}"
onclick="plGoTo('{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}{{ $trackQs }}')"
style="cursor:pointer;"> style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail) @if ($playlistVideo->thumbnail)

View File

@ -70,12 +70,18 @@ Route::post('/videos/{video}/identify', [VideoController::class, 'identify'])->n
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create'); Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
Route::post('/videos', [VideoController::class, 'store'])->name('videos.store'); Route::post('/videos', [VideoController::class, 'store'])->name('videos.store');
Route::post('/videos/upload-chunk', [VideoController::class, 'uploadChunk'])->name('videos.uploadChunk');
Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit'); Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update'); Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
Route::post('/videos/{video}/replace-file', [VideoController::class, 'replaceFile'])->name('videos.replaceFile'); Route::post('/videos/{video}/replace-file', [VideoController::class, 'replaceFile'])->name('videos.replaceFile');
Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy'); Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy');
Route::delete('/videos/{video}/audio-track/{track}', [VideoController::class, 'deleteAudioTrack'])->name('videos.audio-track.delete'); Route::delete('/videos/{video}/audio-track/{track}', [VideoController::class, 'deleteAudioTrack'])->name('videos.audio-track.delete');
// Music-video merge — fold {source} into {target}. Owner-only.
Route::get('/videos/{target}/merge/candidates', [\App\Http\Controllers\VideoMergeController::class, 'candidates'])->name('videos.merge.candidates');
Route::get('/videos/{target}/merge/{source}/preview',[\App\Http\Controllers\VideoMergeController::class, 'preview'])->name('videos.merge.preview');
Route::post('/videos/{target}/merge/{source}', [\App\Http\Controllers\VideoMergeController::class, 'merge'])->name('videos.merge');
// Like/unlike routes // Like/unlike routes
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like'); Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike'); Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');