Design Prot Merge #1
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { gradientForHue } from '$lib/theme/color';
|
import { gradientForHue } from '$lib/theme/color';
|
||||||
|
import { thumbnailSrc } from '$lib/thumbnails';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
trackId: string | number;
|
trackId: string | number;
|
||||||
@@ -25,7 +26,9 @@
|
|||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`);
|
const imageSrc = $derived(
|
||||||
|
src ? (thumbnailSrc(src, size) ?? src) : `/api/tracks/${trackId}/cover?size=${size}`
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
imageSrc;
|
imageSrc;
|
||||||
@@ -43,6 +46,8 @@
|
|||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
{alt}
|
{alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority="low"
|
||||||
onload={() => (loaded = true)}
|
onload={() => (loaded = true)}
|
||||||
onerror={() => (failed = true)}
|
onerror={() => (failed = true)}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { HUES, accentForHue, gradientForHue, hueFor } from '$lib/theme/color';
|
import { HUES, accentForHue, gradientForHue, hueFor } from '$lib/theme/color';
|
||||||
import { extractAccent, type Accent } from '$lib/theme/palette';
|
import { extractAccent, type Accent } from '$lib/theme/palette';
|
||||||
|
import { thumbnailSrc } from '$lib/thumbnails';
|
||||||
|
|
||||||
export type TrackSourceKind = 'local' | 'youtube';
|
export type TrackSourceKind = 'local' | 'youtube';
|
||||||
|
|
||||||
@@ -190,7 +191,8 @@ class PlayerState {
|
|||||||
navigator.mediaSession.metadata = null;
|
navigator.mediaSession.metadata = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const artwork = track.coverUrl ?? `/api/tracks/${track.id}/cover?size=small`;
|
const artwork =
|
||||||
|
thumbnailSrc(track.coverUrl, 'medium') ?? `/api/tracks/${track.id}/cover?size=small`;
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
title: track.title,
|
title: track.title,
|
||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
@@ -242,7 +244,9 @@ class PlayerState {
|
|||||||
this.gradientColor = cached.gradient;
|
this.gradientColor = cached.gradient;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await extractAccent(coverUrl ?? `/api/tracks/${id}/cover?size=small`);
|
const result = await extractAccent(
|
||||||
|
thumbnailSrc(coverUrl, 'small') ?? `/api/tracks/${id}/cover?size=small`
|
||||||
|
);
|
||||||
if (this.currentId !== id) return;
|
if (this.currentId !== id) return;
|
||||||
if (result) {
|
if (result) {
|
||||||
this.#accentCache.set(key, result);
|
this.#accentCache.set(key, result);
|
||||||
|
|||||||
@@ -51,3 +51,30 @@ export const apiConfig = {
|
|||||||
return (env.API_BASE_URL ?? '').replace(/\/+$/, '');
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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 <= 120) return 'default.jpg';
|
||||||
|
if (size <= 320) return 'mqdefault.jpg';
|
||||||
|
if (size <= 480) 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export const THUMBNAIL_SIZES = {
|
||||||
|
small: 96,
|
||||||
|
medium: 160,
|
||||||
|
large: 320
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
|
||||||
|
|
||||||
|
const PROXY_PATH = '/api/youtube/thumbnail';
|
||||||
|
|
||||||
|
export function thumbnailSrc(
|
||||||
|
url: string | undefined,
|
||||||
|
size: ThumbnailSize = 'large'
|
||||||
|
): string | undefined {
|
||||||
|
if (!url) return undefined;
|
||||||
|
if (!url.startsWith('http')) return url;
|
||||||
|
|
||||||
|
return `${PROXY_PATH}?u=${encodeURIComponent(url)}&size=${THUMBNAIL_SIZES[size]}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { loadThumbnail, parseThumbnailUrl } from '$lib/server/thumbnails';
|
||||||
|
import { thumbnailConfig } from '$lib/server/config';
|
||||||
|
import { THUMBNAIL_SIZES } from '$lib/thumbnails';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, request, setHeaders }) => {
|
||||||
|
const source = parseThumbnailUrl(url.searchParams.get('u') ?? '');
|
||||||
|
if (!source) error(400, 'Miniatura no permitida.');
|
||||||
|
|
||||||
|
const requested = Number(url.searchParams.get('size'));
|
||||||
|
const size =
|
||||||
|
Number.isInteger(requested) && requested > 0
|
||||||
|
? Math.min(requested, thumbnailConfig.maxSize)
|
||||||
|
: THUMBNAIL_SIZES.large;
|
||||||
|
|
||||||
|
const image = await loadThumbnail(source, size);
|
||||||
|
if (!image) error(404, 'Miniatura no disponible.');
|
||||||
|
|
||||||
|
setHeaders({
|
||||||
|
'Cache-Control': `public, max-age=${thumbnailConfig.maxAgeSeconds}, immutable`,
|
||||||
|
'Content-Type': image.contentType,
|
||||||
|
ETag: image.etag
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request.headers.get('if-none-match') === image.etag) {
|
||||||
|
return new Response(null, { status: 304 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(image.body);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user