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:
parent
af276f2cd1
commit
ba8ab11ce4
@ -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(),
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -34,7 +34,7 @@ class Playlist extends Model
|
||||
public function 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');
|
||||
}
|
||||
|
||||
@ -296,24 +296,48 @@ class Playlist extends Model
|
||||
}
|
||||
}
|
||||
|
||||
// Add video to playlist
|
||||
public function addVideo(Video $video)
|
||||
// Add video to playlist. When $audioTrackId is null the primary track
|
||||
// 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;
|
||||
}
|
||||
|
||||
$maxPosition = $this->videos()->max('position') ?? -1;
|
||||
|
||||
$this->videos()->attach($video->id, [
|
||||
'position' => $maxPosition + 1,
|
||||
'added_at' => now(),
|
||||
'position' => $maxPosition + 1,
|
||||
'audio_track_id' => $audioTrackId,
|
||||
'added_at' => now(),
|
||||
]);
|
||||
|
||||
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)
|
||||
{
|
||||
if (! $this->hasVideo($video)) {
|
||||
@ -328,6 +352,21 @@ class Playlist extends Model
|
||||
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
|
||||
protected function reorderPositions()
|
||||
{
|
||||
|
||||
@ -67,7 +67,9 @@ class Video extends Model
|
||||
|
||||
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
|
||||
@ -252,7 +254,29 @@ class Video extends Model
|
||||
if ($realId === null || $realId <= 0) {
|
||||
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
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -237,9 +237,22 @@
|
||||
.then(data => {
|
||||
if (data.success && data.playlists && data.playlists.length > 0) {
|
||||
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) {
|
||||
const isInPlaylist = playlist.video_ids && playlist.video_ids.includes(parseInt(
|
||||
videoId));
|
||||
// Prefer entries[] (per-track granularity) when present.
|
||||
// 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 durationText = playlist.formatted_duration || '0m';
|
||||
html += `
|
||||
@ -328,7 +341,11 @@
|
||||
// of the encoded route key used by GET /videos/{video}.
|
||||
var url = `/playlists/${playlistId}/videos`;
|
||||
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, {
|
||||
method,
|
||||
|
||||
@ -980,6 +980,14 @@ document.getElementById('edit-form').addEventListener('submit', function(e) {
|
||||
// Append delete IDs
|
||||
_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 ──────
|
||||
(function () {
|
||||
const promoteId = document.getElementById('edit-promote-track-id')?.value || '(none)';
|
||||
|
||||
@ -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 ───────────────────────────────────────────
|
||||
document.getElementById('upload-form-modal').addEventListener('submit', function(e) {
|
||||
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('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 0–95% 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) {
|
||||
if (ev.lengthComputable) {
|
||||
const pct = Math.round((ev.loaded / ev.total) * 100);
|
||||
document.getElementById('progress-bar-modal').style.width = pct + '%';
|
||||
document.getElementById('progress-text-modal').textContent = 'Uploading… ' + pct + '%';
|
||||
if (ev.lengthComputable && ev.total > 0) {
|
||||
const pct = 96 + Math.round((ev.loaded / ev.total) * 3);
|
||||
_pbar.style.width = 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('timeout', () => { _uploadInProgress = false; _showUploadError('Upload timed out.'); });
|
||||
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) {
|
||||
|
||||
@ -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 ───────────────────────────────────────
|
||||
document.getElementById('upload-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
@ -845,11 +881,34 @@
|
||||
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...';
|
||||
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) {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
document.getElementById('progress-bar').style.width = pct + '%';
|
||||
document.getElementById('progress-text').textContent = 'Uploading... ' + pct + '%';
|
||||
if (e.lengthComputable && e.total > 0) {
|
||||
const pct = 96 + Math.round((e.loaded / e.total) * 3);
|
||||
_pbar.style.width = pct + '%';
|
||||
}
|
||||
});
|
||||
|
||||
@ -886,10 +945,6 @@
|
||||
|
||||
xhr.addEventListener('error', () => showError('Upload failed. Check your connection.'));
|
||||
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) {
|
||||
|
||||
@ -576,10 +576,14 @@
|
||||
if (d.type && d.type !== 'music') { window.location.href = url; return; }
|
||||
|
||||
// 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 srcToPlay = d.active_stream_url || d.stream_url;
|
||||
if (audio) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -587,6 +591,23 @@
|
||||
try { if (window._audioPlayerUpdate) window._audioPlayerUpdate(d); }
|
||||
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
|
||||
PL_CURRENT = d.id;
|
||||
if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false;
|
||||
@ -699,6 +720,23 @@
|
||||
if(a&&plLoop==='one') a.loop=true;
|
||||
plRender();
|
||||
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();
|
||||
})();
|
||||
@ -707,9 +745,14 @@
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($playlistVideos as $index => $playlistVideo)
|
||||
@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' : '' }}"
|
||||
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;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($playlistVideo->thumbnail)
|
||||
|
||||
@ -70,12 +70,18 @@ Route::post('/videos/{video}/identify', [VideoController::class, 'identify'])->n
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
||||
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::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
|
||||
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}/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
|
||||
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
||||
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user