Add lyrics pipeline, playlist views, admin toggles, and player polish
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 <[email protected]>
This commit is contained in:
1 parent
73527f3781
commit
f98e5415a3
60 files changed
+7920
-2675
No files matched your search
+78
-1
@@ -17,10 +17,12 @@ class Playlist extends Model
|
||||
'visibility',
|
||||
'is_default',
|
||||
'share_token',
|
||||
'view_count',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
'view_count' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
@@ -83,12 +85,87 @@ class Playlist extends Model
|
||||
return "{$minutes}m";
|
||||
}
|
||||
|
||||
// Get total views of all videos in playlist
|
||||
// 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)
|
||||
{
|
||||
|
||||
@@ -75,6 +75,40 @@ class Video extends Model
|
||||
return $this->slides()->count() > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the slide list for a given audio track, applying the sharing rule:
|
||||
* 1. Slides explicitly owned by this track (audio_track_id = $trackId).
|
||||
* 2. Slides owned by the primary (audio_track_id IS NULL = song-wide / legacy).
|
||||
* 3. Slides owned by any other track (first track in id order).
|
||||
* 4. Empty (caller falls back to cover image).
|
||||
*
|
||||
* Pass `null` for the primary audio (the one stored on the videos row).
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<int,\App\Models\VideoSlide>
|
||||
*/
|
||||
public function slidesForTrack(?int $audioTrackId)
|
||||
{
|
||||
$all = $this->slides; // eager-loaded collection of every slide
|
||||
|
||||
if ($audioTrackId !== null) {
|
||||
$own = $all->where('audio_track_id', $audioTrackId)->values();
|
||||
if ($own->isNotEmpty()) return $own;
|
||||
}
|
||||
|
||||
// Primary / song-wide bucket (audio_track_id IS NULL).
|
||||
$primary = $all->whereNull('audio_track_id')->values();
|
||||
if ($primary->isNotEmpty()) return $primary;
|
||||
|
||||
// Borrow from the first track (by id) that has any.
|
||||
$byTrack = $all->whereNotNull('audio_track_id')->groupBy('audio_track_id');
|
||||
if ($byTrack->isNotEmpty()) {
|
||||
$firstTrackId = $byTrack->keys()->sort()->first();
|
||||
return $byTrack[$firstTrackId]->values();
|
||||
}
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
// ── Local filesystem path helpers ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,12 @@ class VideoAudioTrack extends Model
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
/** Slides explicitly owned by this track. Use Video::slidesForTrack() for fallback resolution. */
|
||||
public function slides()
|
||||
{
|
||||
return $this->hasMany(VideoSlide::class, 'audio_track_id')->orderBy('position');
|
||||
}
|
||||
|
||||
public function getStreamUrlAttribute(): string
|
||||
{
|
||||
return route('videos.audio-track', ['video' => $this->video_id, 'track' => $this->id]);
|
||||
|
||||
@@ -6,13 +6,18 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class VideoSlide extends Model
|
||||
{
|
||||
protected $fillable = ['video_id', 'filename', 'position'];
|
||||
protected $fillable = ['video_id', 'audio_track_id', 'filename', 'position'];
|
||||
|
||||
public function video()
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
public function audioTrack()
|
||||
{
|
||||
return $this->belongsTo(VideoAudioTrack::class, 'audio_track_id');
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return route('media.thumbnail', $this->filename);
|
||||
|
||||
Reference in new issue
Block a user