ghassan ba8ab11ce4 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>
2026-07-31 02:42:25 +03:00

453 lines
13 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Playlist extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'name',
'description',
'thumbnail',
'visibility',
'is_default',
'share_token',
'view_count',
];
protected $casts = [
'is_default' => 'boolean',
'view_count' => 'integer',
];
// Relationships
public function user()
{
return $this->belongsTo(User::class);
}
public function videos()
{
return $this->belongsToMany(Video::class, 'playlist_videos')
->withPivot('position', 'audio_track_id', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
->orderBy('position');
}
// Get videos with their pivot data
public function getVideosWithPivot()
{
return $this->videos()->orderBy('position')->get();
}
// Accessors
public function getThumbnailUrlAttribute(): string
{
if ($this->thumbnail) {
return route('media.thumbnail', $this->thumbnail);
}
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
}
// Get total video count
public function getVideoCountAttribute()
{
return $this->videos()->count();
}
// Get total duration of all videos in playlist
public function getTotalDurationAttribute()
{
return $this->videos()->sum('duration');
}
// Get formatted total duration (e.g., "2h 30m")
public function getFormattedDurationAttribute()
{
$totalSeconds = $this->total_duration;
if (! $totalSeconds) {
return '0m';
}
$hours = floor($totalSeconds / 3600);
$minutes = floor(($totalSeconds % 3600) / 60);
if ($hours > 0) {
return "{$hours}h {$minutes}m";
}
return "{$minutes}m";
}
// Total of every viewer-session aggregated across the playlist's videos.
// Kept for the analytics-style "video time watched" metric — for the
// playlist's OWN view counter (cards, share link), use $playlist->view_count
// which is incremented per-device by bumpViewIfNew().
public function getTotalViewsAttribute()
{
return $this->videos()->get()->sum('view_count');
}
/**
* Record a playlist view if this viewer hasn't already counted within the
* dedup window. Mirrors the video_views pattern: signed-in users dedup by
* user_id, guests dedup by device fingerprint (preferred) or device cookie.
*
* Cheap and idempotent — runs as one EXISTS + (optionally) one INSERT +
* one atomic increment, all on indexed columns. Called via
* dispatchAfterResponse() so it never adds latency to the page render.
*/
public function bumpViewIfNew(\Illuminate\Http\Request $request): void
{
$userId = \Illuminate\Support\Facades\Auth::id();
$did = $request->cookie('_did');
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
// No identifier at all? Skip silently — a unidentifiable request would
// count on every refresh and inflate the counter.
if (! $userId && ! $did && ! $fp) return;
$q = \DB::table('playlist_views')
->where('playlist_id', $this->id)
->where('viewed_at', '>', now()->subHour());
if ($userId) {
$q->where('user_id', $userId);
} else {
$q->whereNull('user_id')->where(function ($w) use ($fp, $did) {
if ($fp) $w->orWhere('device_hash', $fp);
if ($did) $w->orWhere('device_id', $did);
});
}
if ($q->exists()) return;
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = \App\Services\GeoIpService::lookup($ip);
\DB::table('playlist_views')->insert([
'playlist_id' => $this->id,
'user_id' => $userId,
'device_id' => $did,
'device_hash' => $fp,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name' => $geo['country_name'] ?? null,
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'viewed_at' => now(),
]);
\DB::table('playlists')->where('id', $this->id)->increment('view_count');
}
/**
* Compute previous/next videos from an already-loaded ordered collection
* (the playlist's videos in position order). Saves the 4+ extra queries
* that getNextVideo() / getPreviousVideo() would each fire.
*/
public function neighborsFromCollection(\Illuminate\Support\Collection $orderedVideos, Video $current): array
{
$idx = $orderedVideos->search(fn ($v) => $v->id === $current->id);
if ($idx === false) {
return [null, $orderedVideos->first()];
}
return [
$idx > 0 ? $orderedVideos[$idx - 1] : null,
$idx < $orderedVideos->count() - 1 ? $orderedVideos[$idx + 1] : null,
];
}
// Check if user owns this playlist
public function isOwnedBy($user)
{
if (! $user) {
return false;
}
return $this->user_id === $user->id;
}
// Check if video is in this playlist
public function hasVideo(Video $video)
{
return $this->videos()->where('video_id', $video->id)->exists();
}
// Get video position in playlist
public function getVideoPosition(Video $video)
{
$pivot = $this->videos()->where('video_id', $video->id)->first();
return $pivot ? $pivot->pivot->position : null;
}
// Get next video in playlist
public function getNextVideo(Video $currentVideo)
{
$currentPosition = $this->getVideoPosition($currentVideo);
if ($currentPosition === null) {
return $this->videos()->first();
}
return $this->videos()
->wherePivot('position', '>', $currentPosition)
->orderBy('position')
->first();
}
// Get previous video in playlist
public function getPreviousVideo(Video $currentVideo)
{
$currentPosition = $this->getVideoPosition($currentVideo);
if ($currentPosition === null) {
return $this->videos()->last();
}
return $this->videos()
->wherePivot('position', '<', $currentPosition)
->orderByDesc('position')
->first();
}
// All share URLs use the unguessable token route
public function getShareUrlAttribute()
{
return route('playlists.showByToken', $this->share_token);
}
// Scope for public playlists
public function scopePublic($query)
{
return $query->where('visibility', 'public');
}
// Scope for user's playlists
public function scopeForUser($query, $userId)
{
return $query->where('user_id', $userId);
}
// Scope for default playlists (Watch Later)
public function scopeDefault($query)
{
return $query->where('is_default', true);
}
// Visibility helpers
public function isPublic()
{
return $this->visibility === 'public';
}
public function isPrivate()
{
return $this->visibility === 'private';
}
public function isUnlisted()
{
return $this->visibility === 'unlisted';
}
// Check if user can view this playlist via the ID route
public function canView($user = null)
{
// Owner can always view
if ($user && $this->user_id === $user->id) {
return true;
}
// Only public playlists are accessible via the ID route
return $this->visibility === 'public';
}
// Check if user can view this playlist via the share-token route
public function canViewViaToken($user = null)
{
if ($user && $this->user_id === $user->id) {
return true;
}
return in_array($this->visibility, ['public', 'unlisted']);
}
// Check if user can edit this playlist
public function canEdit($user = null)
{
return $user && $this->user_id === $user->id;
}
// Update video positions after reordering
public function reorderVideos($videoIds)
{
foreach ($videoIds as $index => $videoId) {
$this->videos()->updateExistingPivot($videoId, ['position' => $index]);
}
}
// 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->hasVideoWithTrack($video, $audioTrackId)) {
return false;
}
$maxPosition = $this->videos()->max('position') ?? -1;
$this->videos()->attach($video->id, [
'position' => $maxPosition + 1,
'audio_track_id' => $audioTrackId,
'added_at' => now(),
]);
return true;
}
// 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)) {
return false;
}
$this->videos()->detach($video->id);
// Reorder remaining videos
$this->reorderPositions();
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()
{
$videos = $this->videos()->orderBy('position')->get();
$position = 0;
foreach ($videos as $video) {
$this->videos()->updateExistingPivot($video->id, ['position' => $position]);
$position++;
}
}
// Update watch progress for a video in playlist
public function updateWatchProgress(Video $video, $seconds)
{
$pivot = $this->videos()->where('video_id', $video->id)->first()?->pivot;
if ($pivot) {
$pivot->watched_seconds = $seconds;
$pivot->last_watched_at = now();
// Mark as watched if 90% complete
if ($video->duration && $seconds >= ($video->duration * 0.9)) {
$pivot->watched = true;
}
$pivot->save();
}
}
// Get watch progress for a video in playlist
public function getWatchProgress(Video $video)
{
$pivot = $this->videos()->where('video_id', $video->id)->first()?->pivot;
return $pivot ? [
'watched_seconds' => $pivot->watched_seconds,
'watched' => $pivot->watched,
'last_watched_at' => $pivot->last_watched_at,
] : null;
}
// Get first unwatched video
public function getFirstUnwatchedVideo()
{
return $this->videos()
->wherePivot('watched', false)
->orderBy('position')
->first();
}
// Get random video (for shuffle)
public function getRandomVideo()
{
return $this->videos()->inRandomOrder()->first();
}
// Static method to create default "Watch Later" playlist
public static function createWatchLater($userId)
{
return self::create([
'user_id' => $userId,
'name' => 'Watch Later',
'description' => 'Save videos to watch later',
'visibility' => 'private',
'is_default' => true,
]);
}
// Get or create watch later playlist for user
public static function getWatchLater($userId)
{
$playlist = self::where('user_id', $userId)
->where('is_default', true)
->first();
if (! $playlist) {
$playlist = self::createWatchLater($userId);
}
return $playlist;
}
}