takeone-youtube-clone/app/Http/Controllers/PlaylistController.php
ghassan f98e5415a3 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 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00

648 lines
23 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\Video;
use App\Services\GeoIpService;
use App\Services\NasSyncService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PlaylistController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare', 'ogImage']);
}
// List user's playlists
public function index()
{
$user = Auth::user();
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
return view('playlists.index', compact('playlists'));
}
// View a single playlist
public function show(Request $request, Playlist $playlist)
{
if (! $playlist->canView(Auth::user())) {
abort(404, 'Playlist not found');
}
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
// Count this visit (deduped per device) after the response is sent so
// the page render isn't blocked by the EXISTS / INSERT / UPDATE round-trip.
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
return view('playlists.show', compact('playlist', 'videos'));
}
// View playlist via unguessable share token (unlisted playlists)
public function showByToken(Request $request, string $token)
{
$playlist = Playlist::where('share_token', $token)->firstOrFail();
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
return view('playlists.show', compact('playlist', 'videos'));
}
// Generate (or reuse) a per-user share tracking token and return the tracking URL
public function recordShare(Playlist $playlist)
{
$userId = Auth::id();
if ($userId) {
$existing = DB::table('playlist_shares')
->where('playlist_id', $playlist->id)
->where('user_id', $userId)
->first();
if ($existing) {
return response()->json(['url' => route('playlists.accessShare', $existing->token)]);
}
}
do {
$token = Str::random(10);
} while (DB::table('playlist_shares')->where('token', $token)->exists());
DB::table('playlist_shares')->insert([
'playlist_id' => $playlist->id,
'user_id' => $userId,
'token' => $token,
'created_at' => now(),
]);
return response()->json(['url' => route('playlists.accessShare', $token)]);
}
// Handle a share link click: record the access, then redirect to the playlist
public function accessShare(Request $request, string $token)
{
$share = DB::table('playlist_shares')->where('token', $token)->first();
if (! $share) {
return redirect('/');
}
$playlist = Playlist::find($share->playlist_id);
if (! $playlist || ! $playlist->canViewViaToken(Auth::user())) {
return redirect('/');
}
$did = $request->cookie('_did') ?: (string) Str::uuid();
$seen = DB::table('playlist_share_accesses')
->where('share_id', $share->id)
->where('device_id', $did)
->exists();
if (! $seen) {
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = GeoIpService::lookup($ip);
DB::table('playlist_share_accesses')->insert([
'share_id' => $share->id,
'device_id' => $did,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name' => $geo['country_name'] ?? null,
'accessed_at' => now(),
]);
}
// Serve the playlist's own OG metadata to social-media crawlers so previews
// show the playlist's picture and name — not the first video's. Humans still
// get redirected to the first track for one-tap playback.
$ua = (string) $request->userAgent();
$isCrawler = (bool) preg_match(
'/facebookexternalhit|facebookcatalog|Facebot|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|TelegramBot|Pinterest|redditbot|Googlebot|bingbot|DuckDuckBot|YandexBot|Applebot|Embedly|vkShare|W3C_Validator|SkypeUriPreview/i',
$ua
);
if ($isCrawler) {
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
return response()
->view('playlists.show', compact('playlist', 'videos'))
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
// Human share-link click counts as a playlist view (deduped per device).
dispatch(function () use ($playlist, $request) {
$playlist->bumpViewIfNew($request);
})->afterResponse();
$firstVideo = $playlist->videos()->orderBy('position')->first();
$destination = $firstVideo
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
: route('playlists.showByToken', $playlist->share_token);
return redirect($destination)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
// Create new playlist form
public function create()
{
return view('playlists.create');
}
// Store new playlist
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'visibility' => 'nullable|in:public,private,unlisted',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
]);
$playlistData = [
'user_id' => Auth::id(),
'name' => $request->name,
'description' => $request->description,
'visibility' => $request->visibility ?? 'private',
'is_default' => false,
'share_token' => Str::random(32),
];
// Create playlist first to get ID for thumbnail naming
$playlist = Playlist::create($playlistData);
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$nasPath = self::pushPlaylistThumbnailToNas($file, $playlist);
$playlist->update(['thumbnail' => $nasPath]);
}
// Reload playlist with thumbnail
$playlist->refresh();
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'playlist' => [
'id' => $playlist->id,
'name' => $playlist->name,
'visibility' => $playlist->visibility,
'thumbnail_url' => $playlist->thumbnail_url,
],
]);
}
return redirect()->route('playlists.show', $playlist)->with('success', 'Playlist created!');
}
// Edit playlist form
public function edit(Playlist $playlist)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to edit this playlist.');
}
if (request()->expectsJson() || request()->ajax()) {
return response()->json([
'success' => true,
'playlist' => [
'id' => $playlist->id,
'name' => $playlist->name,
'description' => $playlist->description,
'visibility' => $playlist->visibility,
],
]);
}
return view('playlists.edit', compact('playlist'));
}
// Update playlist
public function update(Request $request, Playlist $playlist)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to edit this playlist.');
}
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'visibility' => 'nullable|in:public,private,unlisted',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
]);
$updateData = [
'name' => $request->name,
'description' => $request->description,
'visibility' => $request->visibility ?? 'private',
];
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
// Delete old thumbnail from NAS if exists
if ($playlist->thumbnail) {
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
}
$file = $request->file('thumbnail');
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
}
// Handle thumbnail removal
if ($request->input('remove_thumbnail') == '1') {
if ($playlist->thumbnail) {
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
$updateData['thumbnail'] = null;
}
}
$playlist->update($updateData);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Playlist updated!',
'playlist' => [
'id' => $playlist->id,
'name' => $playlist->name,
'visibility' => $playlist->visibility,
],
]);
}
return redirect()->route('playlists.show', $playlist)->with('success', 'Playlist updated!');
}
// Delete playlist
public function destroy(Request $request, Playlist $playlist)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to delete this playlist.');
}
// Don't allow deleting default playlists
if ($playlist->is_default) {
abort(400, 'Cannot delete default playlist.');
}
$playlist->delete();
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Playlist deleted!',
]);
}
return redirect()->route('playlists.index')->with('success', 'Playlist deleted!');
}
// Add video to playlist
public function addVideo(Request $request, Playlist $playlist)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to edit this playlist.');
}
$request->validate([
'video_id' => 'required|exists:videos,id',
]);
$video = Video::findOrFail($request->video_id);
// Check if video can be viewed
if (! $video->canView(Auth::user())) {
abort(403, 'You cannot add this video to your playlist.');
}
$added = $playlist->addVideo($video);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => $added ? 'Video added to playlist!' : 'Video is already in playlist.',
'video_count' => $playlist->video_count,
]);
}
return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.');
}
/**
* Body-based mirror of removeVideo: looks up the Video by raw numeric id
* from the request body. Lets callers (e.g. the add-to-playlist modal) drive
* the toggle without having to know the encoded route key for Video.
*/
public function removeVideoByBody(Request $request, Playlist $playlist)
{
$request->validate(['video_id' => 'required|exists:videos,id']);
$video = Video::findOrFail($request->video_id);
return $this->removeVideo($request, $playlist, $video);
}
// Remove video from playlist
public function removeVideo(Request $request, Playlist $playlist, Video $video)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to edit this playlist.');
}
$removed = $playlist->removeVideo($video);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Video removed from playlist.',
'video_count' => $playlist->video_count,
]);
}
return back()->with('success', 'Video removed from playlist.');
}
// Reorder videos in playlist
public function reorder(Request $request, Playlist $playlist)
{
// Check ownership
if (! $playlist->canEdit(Auth::user())) {
abort(403, 'You do not have permission to edit this playlist.');
}
$request->validate([
'video_ids' => 'required|array',
'video_ids.*' => 'integer|exists:videos,id',
]);
$playlist->reorderVideos($request->video_ids);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Playlist reordered!',
]);
}
return back()->with('success', 'Playlist reordered!');
}
// Get user's playlists (for dropdown)
public function userPlaylists()
{
// Handle unauthenticated users
if (! Auth::check()) {
return response()->json([
'success' => true,
'playlists' => [],
'authenticated' => false,
]);
}
$user = Auth::user();
$playlists = $user->playlists()->orderBy('name')->get();
// Get video IDs for each playlist
$playlistsWithVideoIds = $playlists->map(function ($p) {
return [
'id' => $p->id,
'name' => $p->name,
'description' => $p->description,
'video_count' => $p->videos()->count(),
'formatted_duration' => $p->formatted_duration,
'is_default' => $p->is_default,
'visibility' => $p->visibility,
'thumbnail_url' => $p->thumbnail_url,
'video_ids' => $p->videos()->pluck('videos.id')->toArray(),
];
});
return response()->json([
'success' => true,
'playlists' => $playlistsWithVideoIds,
'authenticated' => true,
]);
}
// Quick add to Watch Later
public function watchLater(Request $request, Video $video)
{
$watchLater = Playlist::getWatchLater(Auth::id());
$added = $watchLater->addVideo($video);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => $added ? 'Added to Watch Later!' : 'Already in Watch Later.',
]);
}
return back()->with('success', $added ? 'Added to Watch Later!' : 'Already in Watch Later.');
}
// Update watch progress
public function updateProgress(Request $request, Playlist $playlist, Video $video)
{
$request->validate([
'seconds' => 'required|integer|min:0',
]);
$playlist->updateWatchProgress($video, $request->seconds);
return response()->json([
'success' => true,
]);
}
// Play all videos in playlist (redirect to first video with playlist context)
public function playAll(Playlist $playlist)
{
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
$firstVideo = $playlist->videos()->orderBy('position')->first();
if (! $firstVideo) {
return back()->with('error', 'Playlist is empty.');
}
return redirect(route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token);
}
// Shuffle play - redirect to random video
public function shuffle(Playlist $playlist)
{
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
$randomVideo = $playlist->getRandomVideo();
if (! $randomVideo) {
return back()->with('error', 'Playlist is empty.');
}
return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token);
}
// ── NAS thumbnail helpers ─────────────────────────────────────────────────
private static function nasPlaylistThumbPath(Playlist $playlist, string $ext): string
{
$nas = app(\App\Services\NasSyncService::class);
$playlist->loadMissing('user');
$userSlug = $nas->userSlug($playlist->user);
return "users/{$userSlug}/playlists/{$playlist->id}/thumb.{$ext}";
}
private static function pushPlaylistThumbnailToNas(\Illuminate\Http\UploadedFile $file, Playlist $playlist): string
{
$nas = app(\App\Services\NasSyncService::class);
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$tmpName = self::generateFilename($ext);
$file->storeAs('public/thumbnails', $tmpName);
$tempAbs = storage_path('app/public/thumbnails/' . $tmpName);
$nasPath = self::nasPlaylistThumbPath($playlist, $ext);
$dir = dirname($nasPath);
$nas->mkdirp($dir);
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
return $nasPath;
}
private static function deletePlaylistThumbnailFromNas(?string $nasPath): void
{
if (! $nasPath || ! str_starts_with($nasPath, 'users/')) return;
try {
app(\App\Services\NasSyncService::class)->deleteFile($nasPath);
} catch (\Throwable) {}
}
// Social-preview image: resize the playlist's own thumbnail to a 1200x630 JPEG
// (small enough for WhatsApp/Telegram/Discord previews). Falls back to a branded card.
public function ogImage(Playlist $playlist, NasSyncService $nas)
{
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404);
}
if ($playlist->thumbnail) {
$local = storage_path('app/' . $playlist->thumbnail);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$nas->ensureLocalAsset($local, $playlist->thumbnail);
}
if (file_exists($local)) {
$ext = strtolower(pathinfo($local, PATHINFO_EXTENSION));
$src = match ($ext) {
'png' => @imagecreatefrompng($local),
'webp' => @imagecreatefromwebp($local),
'gif' => @imagecreatefromgif($local),
default => @imagecreatefromjpeg($local),
};
if ($src) {
$ow = imagesx($src); $oh = imagesy($src);
// Always output an exact 1200x630 canvas (cover-crop, no letterbox)
// so the served image matches the og:image:width/height we declare —
// a mismatch makes WhatsApp/Telegram fall back to the tiny thumbnail.
$cw = 1200; $ch = 630;
$dst = imagecreatetruecolor($cw, $ch);
// Cover: scale so the image fills the whole canvas, center-crop overflow
$scale = max($cw / $ow, $ch / $oh);
$sw = (int) round($cw / $scale);
$sh = (int) round($ch / $scale);
$sx = (int) round(($ow - $sw) / 2);
$sy = (int) round(($oh - $sh) / 2);
imagecopyresampled($dst, $src, 0, 0, $sx, $sy, $cw, $ch, $sw, $sh);
imagedestroy($src);
ob_start();
imagejpeg($dst, null, 82);
imagedestroy($dst);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
}
}
// Branded fallback card
$w = 1200; $h = 630;
$img = imagecreatetruecolor($w, $h);
$cBg = imagecolorallocate($img, 10, 10, 10);
$cRed = imagecolorallocate($img, 230, 30, 30);
$cWhite = imagecolorallocate($img, 240, 240, 240);
$cGray = imagecolorallocate($img, 120, 120, 120);
imagefill($img, 0, 0, $cBg);
imagefilledrectangle($img, 0, 0, $w, 8, $cRed);
imagefilledrectangle($img, 0, $h - 8, $w, $h, $cRed);
$cx = (int)($w / 2); $cy = (int)($h / 2) - 30; $r = 72;
imagefilledellipse($img, $cx, $cy, $r * 2, $r * 2, $cRed);
$tri = [$cx - 22, $cy - 30, $cx - 22, $cy + 30, $cx + 34, $cy];
imagefilledpolygon($img, $tri, $cWhite);
$fontBold = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
$fontNormal = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
imagettftext($img, 22, 0, 40, 56, $cRed, $fontBold, strtoupper(config('app.name')));
$title = $playlist->name ?: 'Playlist';
$maxChars = 42;
$lines = [];
if (mb_strlen($title) > $maxChars) {
$words = explode(' ', $title); $line = '';
foreach ($words as $word) {
if (mb_strlen($line . ' ' . $word) > $maxChars) { $lines[] = trim($line); $line = $word; }
else { $line .= ($line ? ' ' : '') . $word; }
}
if ($line) $lines[] = trim($line);
} else { $lines = [$title]; }
$lines = array_slice($lines, 0, 2);
$titleY = $cy + $r + 60;
foreach ($lines as $i => $line) {
$bbox = imagettfbbox(28, 0, $fontBold, $line);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 28, 0, $tx, $titleY + $i * 44, $cWhite, $fontBold, $line);
}
$meta = $playlist->video_count . ' videos' . ($playlist->user ? ' • by ' . $playlist->user->name : '');
$bbox = imagettfbbox(16, 0, $fontNormal, $meta);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 16, 0, $tx, $titleY + count($lines) * 44 + 20, $cGray, $fontNormal, $meta);
ob_start();
imagejpeg($img, null, 85);
imagedestroy($img);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
}