Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
emoji-decoration from a song's description while preserving every
language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.
Admin pages:
- /admin/lyrics toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu extracted GPU section, encoder picker, FFmpeg path
- /admin/backup extracted users-and-settings export/import
- /admin/settings now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.
Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
and /videos/{id}?playlist={token}. Dispatched after-response so it
never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.
Player polish:
- Floating mini-player is draggable, persists its position in
localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
(channel tabs, etc.) gets re-executed after content swaps.
Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
414 lines
12 KiB
PHP
414 lines
12 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', '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
|
|
public function addVideo(Video $video)
|
|
{
|
|
if ($this->hasVideo($video)) {
|
|
return false;
|
|
}
|
|
|
|
$maxPosition = $this->videos()->max('position') ?? -1;
|
|
|
|
$this->videos()->attach($video->id, [
|
|
'position' => $maxPosition + 1,
|
|
'added_at' => now(),
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
// Remove video from playlist
|
|
public function removeVideo(Video $video)
|
|
{
|
|
if (! $this->hasVideo($video)) {
|
|
return false;
|
|
}
|
|
|
|
$this->videos()->detach($video->id);
|
|
|
|
// Reorder remaining videos
|
|
$this->reorderPositions();
|
|
|
|
return true;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|