Remove YouTube integration from web-player; unify Home track rows
Drops every YouTube-backed route, proxy and type (external albums/tracks, thumbnail proxy, youtube.ts) and simplifies player/track/mix types to the local-only catalog now that the backend contract dropped Source/ExternalId. Genre tiles on Explore and "Populares esta semana" on Home stay as deliberate client-only placeholders per product decision, just without the YouTube backing. Also finishes the design-system unification pass: Home's three hand-rolled track rows (Continuar escuchando, Playlist destacada, Populares esta semana) now reuse the shared TrackTitleCell component instead of duplicating cover+title+artist markup, and the orphaned Rail.svelte (no importers) is removed.
This commit is contained in:
@@ -51,30 +51,3 @@ export const apiConfig = {
|
||||
return (env.API_BASE_URL ?? '').replace(/\/+$/, '');
|
||||
}
|
||||
};
|
||||
|
||||
export const thumbnailConfig = {
|
||||
get maxAgeSeconds() {
|
||||
return positiveInt(env.THUMBNAIL_CACHE_MAX_AGE_SECONDS, 30 * DAY_SECONDS);
|
||||
},
|
||||
get failureCacheSeconds() {
|
||||
return positiveInt(env.THUMBNAIL_FAILURE_CACHE_SECONDS, 300);
|
||||
},
|
||||
get memoryCacheBytes() {
|
||||
return positiveInt(env.THUMBNAIL_CACHE_BYTES, 128 * 1024 * 1024);
|
||||
},
|
||||
get maxConcurrentFetches() {
|
||||
return positiveInt(env.THUMBNAIL_MAX_CONCURRENT_FETCHES, 4);
|
||||
},
|
||||
get fetchTimeoutMs() {
|
||||
return positiveInt(env.THUMBNAIL_FETCH_TIMEOUT_MS, 8000);
|
||||
},
|
||||
get cooldownSeconds() {
|
||||
return positiveInt(env.THUMBNAIL_COOLDOWN_SECONDS, 30);
|
||||
},
|
||||
get maxCooldownSeconds() {
|
||||
return positiveInt(env.THUMBNAIL_MAX_COOLDOWN_SECONDS, 300);
|
||||
},
|
||||
get maxSize() {
|
||||
return positiveInt(env.THUMBNAIL_MAX_SIZE, 720);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function addTrackAction({ request, params, locals, fetch }: Request
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId } },
|
||||
body: { trackId, source: null, externalId: null }
|
||||
body: { trackId }
|
||||
});
|
||||
|
||||
if (err) {
|
||||
@@ -30,34 +30,6 @@ export async function addTrackAction({ request, params, locals, fetch }: Request
|
||||
return { added: true, trackId, playlistId };
|
||||
}
|
||||
|
||||
export async function addYouTubeToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const form = await request.formData();
|
||||
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
|
||||
const videoId = String(form.get('videoId') ?? '');
|
||||
|
||||
if (!playlistId || !videoId) {
|
||||
return fail(400, { message: 'Faltan datos de la canción.' });
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId } },
|
||||
body: { trackId: null, source: 'YouTube', externalId: videoId }
|
||||
});
|
||||
|
||||
if (err) {
|
||||
if (response?.status === 409) {
|
||||
return fail(409, { message: 'La canción ya está en esa playlist.', videoId });
|
||||
}
|
||||
return fail(502, { message: 'No se pudo añadir la canción.', videoId });
|
||||
}
|
||||
|
||||
return { added: true, videoId, playlistId };
|
||||
}
|
||||
|
||||
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
@@ -83,45 +55,7 @@ export async function addAlbumToPlaylistAction({ request, params, locals, fetch
|
||||
for (const track of tracks.items) {
|
||||
const { error: err } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId } },
|
||||
body: { trackId: String(track.id), source: null, externalId: null }
|
||||
});
|
||||
if (!err) added++;
|
||||
}
|
||||
|
||||
return { addedAlbum: true, albumId, playlistId, added };
|
||||
}
|
||||
|
||||
export async function addYoutubeAlbumToPlaylistAction({
|
||||
request,
|
||||
params,
|
||||
locals,
|
||||
fetch
|
||||
}: RequestEvent) {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const form = await request.formData();
|
||||
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
|
||||
const albumId = String(form.get('albumId') ?? '');
|
||||
|
||||
if (!playlistId || !albumId) {
|
||||
return fail(400, { message: 'Faltan datos del álbum.' });
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const { data: detail, error: detailErr } = await api.GET('/albums/external/{source}/{externalId}', {
|
||||
params: { path: { source: 'YouTube', externalId: albumId } }
|
||||
});
|
||||
|
||||
if (detailErr || !detail) {
|
||||
return fail(502, { message: 'No se pudo cargar el álbum de YouTube.', albumId });
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
for (const track of detail.tracks) {
|
||||
const { error: err } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId } },
|
||||
body: { trackId: null, source: 'YouTube', externalId: track.videoId }
|
||||
body: { trackId: String(track.id) }
|
||||
});
|
||||
if (!err) added++;
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { thumbnailConfig } from '$lib/server/config';
|
||||
|
||||
const ALLOWED_HOST_SUFFIXES = ['.googleusercontent.com', '.ytimg.com', '.ggpht.com'];
|
||||
|
||||
export interface ThumbnailImage {
|
||||
body: ArrayBuffer;
|
||||
contentType: string;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
image: ThumbnailImage | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inFlight = new Map<string, Promise<ThumbnailImage | null>>();
|
||||
|
||||
let cachedBytes = 0;
|
||||
let cooldownUntil = 0;
|
||||
let active = 0;
|
||||
const waiting: (() => void)[] = [];
|
||||
|
||||
export function parseThumbnailUrl(rawUrl: string): URL | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:') return null;
|
||||
if (!ALLOWED_HOST_SUFFIXES.some((suffix) => url.hostname.endsWith(suffix))) return null;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function resizeThumbnailUrl(url: URL, size: number): string {
|
||||
if (url.hostname.endsWith('.googleusercontent.com')) {
|
||||
const raw = url.toString();
|
||||
const separator = raw.lastIndexOf('=');
|
||||
const base =
|
||||
separator > 0 && /^[wsh]\d/.test(raw.slice(separator + 1)) ? raw.slice(0, separator) : raw;
|
||||
return `${base}=w${size}-h${size}-l90-rj`;
|
||||
}
|
||||
|
||||
if (url.hostname.endsWith('.ytimg.com')) {
|
||||
const path = url.origin + url.pathname;
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash < 0) return path;
|
||||
return `${path.slice(0, lastSlash)}/${ytImgFileName(size)}`;
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function ytImgFileName(size: number): string {
|
||||
if (size <= 90) return 'default.jpg';
|
||||
if (size <= 180) return 'mqdefault.jpg';
|
||||
if (size <= 360) return 'hqdefault.jpg';
|
||||
return 'maxresdefault.jpg';
|
||||
}
|
||||
|
||||
export async function loadThumbnail(url: URL, size: number): Promise<ThumbnailImage | null> {
|
||||
const target = resizeThumbnailUrl(url, size);
|
||||
|
||||
const cached = cache.get(target);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
touch(target, cached);
|
||||
return cached.image;
|
||||
}
|
||||
|
||||
const pending = inFlight.get(target);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = fetchThumbnail(target).finally(() => inFlight.delete(target));
|
||||
inFlight.set(target, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function fetchThumbnail(target: string): Promise<ThumbnailImage | null> {
|
||||
if (Date.now() < cooldownUntil) return null;
|
||||
|
||||
await acquire();
|
||||
try {
|
||||
const response = await fetch(target, {
|
||||
headers: { Accept: 'image/webp,image/avif,image/*;q=0.8' },
|
||||
signal: AbortSignal.timeout(thumbnailConfig.fetchTimeoutMs)
|
||||
});
|
||||
|
||||
if (response.status === 429 || response.status === 503) {
|
||||
cooldownUntil = Date.now() + retryAfterMs(response);
|
||||
store(target, null, thumbnailConfig.failureCacheSeconds);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
store(target, null, thumbnailConfig.failureCacheSeconds);
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const image: ThumbnailImage = {
|
||||
body: buffer,
|
||||
contentType: response.headers.get('content-type') ?? 'image/jpeg',
|
||||
etag: `W/"${buffer.byteLength.toString(16)}-${hash(target).toString(16)}"`
|
||||
};
|
||||
|
||||
store(target, image, thumbnailConfig.maxAgeSeconds);
|
||||
return image;
|
||||
} catch {
|
||||
store(target, null, thumbnailConfig.failureCacheSeconds);
|
||||
return null;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
function retryAfterMs(response: Response): number {
|
||||
const header = Number(response.headers.get('retry-after'));
|
||||
const seconds = Number.isFinite(header) && header > 0 ? header : thumbnailConfig.cooldownSeconds;
|
||||
return Math.min(seconds, thumbnailConfig.maxCooldownSeconds) * 1000;
|
||||
}
|
||||
|
||||
function store(target: string, image: ThumbnailImage | null, ttlSeconds: number) {
|
||||
const previous = cache.get(target);
|
||||
if (previous?.image) cachedBytes -= previous.image.body.byteLength;
|
||||
|
||||
cache.delete(target);
|
||||
cache.set(target, { image, expiresAt: Date.now() + ttlSeconds * 1000 });
|
||||
if (image) cachedBytes += image.body.byteLength;
|
||||
|
||||
evict();
|
||||
}
|
||||
|
||||
function touch(target: string, entry: CacheEntry) {
|
||||
cache.delete(target);
|
||||
cache.set(target, entry);
|
||||
}
|
||||
|
||||
function evict() {
|
||||
while (cachedBytes > thumbnailConfig.memoryCacheBytes) {
|
||||
const oldest = cache.keys().next();
|
||||
if (oldest.done) return;
|
||||
|
||||
const entry = cache.get(oldest.value);
|
||||
if (entry?.image) cachedBytes -= entry.image.body.byteLength;
|
||||
cache.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
|
||||
function acquire(): Promise<void> {
|
||||
if (active < thumbnailConfig.maxConcurrentFetches) {
|
||||
active++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => waiting.push(resolve));
|
||||
}
|
||||
|
||||
function release() {
|
||||
const next = waiting.shift();
|
||||
if (next) next();
|
||||
else active--;
|
||||
}
|
||||
|
||||
function hash(value: string): number {
|
||||
let result = 2166136261;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
result ^= value.charCodeAt(i);
|
||||
result = Math.imul(result, 16777619);
|
||||
}
|
||||
return result >>> 0;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { createApiClient } from '$lib/server/api';
|
||||
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
|
||||
|
||||
export const YOUTUBE_FILLER_QUERIES = [
|
||||
'pop',
|
||||
'rock',
|
||||
'reggaeton',
|
||||
'lo-fi',
|
||||
'indie',
|
||||
'salsa',
|
||||
'k-pop',
|
||||
'electrónica',
|
||||
'jazz',
|
||||
'baladas',
|
||||
'trap',
|
||||
'cumbia',
|
||||
'hip hop',
|
||||
'música clásica'
|
||||
];
|
||||
|
||||
export interface YoutubeFiller {
|
||||
query: string;
|
||||
items: YouTubeSong[];
|
||||
continuationToken: string;
|
||||
}
|
||||
|
||||
export interface YoutubeAlbumFiller {
|
||||
query: string;
|
||||
items: YouTubeAlbumResult[];
|
||||
}
|
||||
|
||||
function pickRandomFillerQuery(): string {
|
||||
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
|
||||
}
|
||||
|
||||
function hash(text: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
function pickFillerQueryForUser(userId: string, date: Date, variant = 0): string {
|
||||
const dayKey = date.toISOString().slice(0, 10);
|
||||
const index = (hash(`${userId}:${dayKey}`) + variant) % YOUTUBE_FILLER_QUERIES.length;
|
||||
return YOUTUBE_FILLER_QUERIES[index];
|
||||
}
|
||||
|
||||
export async function fetchYoutubeFiller(
|
||||
api: ReturnType<typeof createApiClient>,
|
||||
accessToken: string | null | undefined,
|
||||
limit: number,
|
||||
seedQuery?: string,
|
||||
userId?: string,
|
||||
variant = 0
|
||||
): Promise<YoutubeFiller> {
|
||||
if (!accessToken) return { query: '', items: [], continuationToken: '' };
|
||||
const query =
|
||||
seedQuery?.trim() ||
|
||||
(userId ? pickFillerQueryForUser(userId, new Date(), variant) : pickRandomFillerQuery());
|
||||
const { data } = await api.GET('/tracks', { params: { query: { name: query, pageSize: 1 } } });
|
||||
const items = (data?.items ?? [])
|
||||
.map((item) => item.youTubeSong)
|
||||
.filter((song): song is NonNullable<typeof song> => song !== null && song !== undefined);
|
||||
return {
|
||||
query,
|
||||
items: (items as YouTubeSong[]).slice(0, limit),
|
||||
continuationToken: data?.nextYoutubeContinuationToken ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchYoutubeAlbumFiller(
|
||||
api: ReturnType<typeof createApiClient>,
|
||||
accessToken: string | null | undefined,
|
||||
limit: number,
|
||||
seedQuery?: string,
|
||||
userId?: string,
|
||||
variant = 0
|
||||
): Promise<YoutubeAlbumFiller> {
|
||||
if (!accessToken) return { query: '', items: [] };
|
||||
const query =
|
||||
seedQuery?.trim() ||
|
||||
(userId ? pickFillerQueryForUser(userId, new Date(), variant) : pickRandomFillerQuery());
|
||||
const { data } = await api.GET('/albums', {
|
||||
params: { query: { title: query, pageNumber: 1, pageSize: limit + 5 } }
|
||||
});
|
||||
const items = (data?.items ?? [])
|
||||
.map((item) => item.youTubeAlbum)
|
||||
.filter((album): album is NonNullable<typeof album> => album !== null && album !== undefined);
|
||||
return { query, items: (items as YouTubeAlbumResult[]).slice(0, limit) };
|
||||
}
|
||||
Reference in New Issue
Block a user