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:
@@ -1,30 +1,8 @@
|
||||
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
||||
|
||||
interface YouTubeAlbumTracksResponse {
|
||||
album: { artist: string; thumbnailUrl: string };
|
||||
tracks: { videoId: string; title: string; isExplicit: boolean }[];
|
||||
}
|
||||
|
||||
export async function fetchAlbumQueueItems(
|
||||
kind: 'local' | 'youtube',
|
||||
albumId: string
|
||||
): Promise<QueueItem[]> {
|
||||
if (kind === 'local') {
|
||||
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
||||
return toQueueItems(data.items ?? []);
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/albums/external/${kind}/${albumId}/tracks`);
|
||||
export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]> {
|
||||
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
||||
if (!res.ok) return [];
|
||||
const detail = (await res.json()) as YouTubeAlbumTracksResponse;
|
||||
return detail.tracks.map((track) => ({
|
||||
id: track.videoId,
|
||||
title: track.title,
|
||||
artist: detail.album.artist,
|
||||
source: 'youtube' as const,
|
||||
coverUrl: detail.album.thumbnailUrl,
|
||||
explicit: track.isExplicit
|
||||
}));
|
||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
||||
return toQueueItems(data.items ?? []);
|
||||
}
|
||||
|
||||
Vendored
+11
-261
@@ -11,7 +11,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given. */
|
||||
/** Get Paginated Albums, Optionally Filtered By Title. */
|
||||
get: operations["GetAlbums"];
|
||||
put?: never;
|
||||
/** Create A New Album. */
|
||||
@@ -39,23 +39,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/albums/external/{source}/{externalId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist. */
|
||||
get: operations["GetExternalAlbum"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/albums/users/{userId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -220,7 +203,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed. */
|
||||
/** Like Or Unlike A Track By Id. */
|
||||
post: operations["ToggleTrackLike"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -324,7 +307,7 @@ export interface paths {
|
||||
/** Get Paginated Tracks Of A PlayList. */
|
||||
get: operations["GetPlayListTracks"];
|
||||
put?: never;
|
||||
/** Add A Track To A PlayList, By Id Or By External Source, Provisioning It In The Background If Needed. */
|
||||
/** Add One Of Your Tracks To A PlayList. */
|
||||
post: operations["AddPlayListTrack"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -408,7 +391,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given. */
|
||||
/** Get Paginated Tracks, Optionally Filtered By Name. */
|
||||
get: operations["GetTracks"];
|
||||
put?: never;
|
||||
/** Create A New Track. */
|
||||
@@ -470,23 +453,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/tracks/external/{source}/{externalId}/stream": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Resolve Playback For A Track From An External Source (Currently Only YouTube Music): Server Stream If Downloaded, Direct Stream From The Source Otherwise. Works Anonymously When The Playback Configuration Allows It. */
|
||||
get: operations["GetExternalTrackStream"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/tracks/users/{userId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -665,37 +631,9 @@ export interface components {
|
||||
schemas: {
|
||||
AddPlayListTrackRequest: {
|
||||
/** Format: uuid */
|
||||
trackId: null | string;
|
||||
source: null | components["schemas"]["TrackSource"];
|
||||
externalId: null | string;
|
||||
trackId: string;
|
||||
};
|
||||
AlbumApplicationResponse: components["schemas"]["AlbumApplicationResponseAlbumApplicationResponse"] | components["schemas"]["AlbumApplicationResponseExternalAlbumApplicationResponse"];
|
||||
AlbumApplicationResponseAlbumApplicationResponse: {
|
||||
/** @enum {string} */
|
||||
source?: "Local";
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
description: null | string;
|
||||
/** Format: int32 */
|
||||
releaseYear: null | number | string;
|
||||
ownerUserId: string;
|
||||
/** Format: int32 */
|
||||
trackCount: number | string;
|
||||
smallImageKeyName: null | string;
|
||||
mediumImageKeyName: null | string;
|
||||
largeImageKeyName: null | string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
coverTrackIds: string[];
|
||||
};
|
||||
AlbumApplicationResponseExternalAlbumApplicationResponse: {
|
||||
/** @enum {string} */
|
||||
source?: "YouTube";
|
||||
externalId: string;
|
||||
thumbnailUrl: null | string;
|
||||
AlbumApplicationResponse: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -726,9 +664,7 @@ export interface components {
|
||||
uploadUrl: string;
|
||||
};
|
||||
AlbumSearchItemResponse: {
|
||||
source: components["schemas"]["TrackSource"];
|
||||
album: null | components["schemas"]["AlbumApplicationResponse"];
|
||||
youTubeAlbum: null | components["schemas"]["YouTubeAlbumResult"];
|
||||
album: components["schemas"]["AlbumApplicationResponse"];
|
||||
};
|
||||
AlbumsSearchResponse: {
|
||||
items: components["schemas"]["AlbumSearchItemResponse"][];
|
||||
@@ -741,9 +677,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
totalItemCount: number | string;
|
||||
hasPreviousPage: boolean;
|
||||
hasNextLocalPage: boolean;
|
||||
nextYoutubeContinuationToken: null | string;
|
||||
youtubeUnavailable: boolean;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
CreateAlbumRequest: {
|
||||
@@ -804,19 +737,13 @@ export interface components {
|
||||
items: components["schemas"]["MixItemApplicationResponse"][];
|
||||
};
|
||||
MixItemApplicationResponse: {
|
||||
source: components["schemas"]["MixItemSource"];
|
||||
/** Format: uuid */
|
||||
trackId: null | string;
|
||||
videoId: null | string;
|
||||
trackId: string;
|
||||
title: string;
|
||||
artist: null | string;
|
||||
thumbnailUrl: null | string;
|
||||
/** Format: double */
|
||||
durationSeconds: number | string;
|
||||
isExplicit: boolean;
|
||||
};
|
||||
/** @enum {unknown} */
|
||||
MixItemSource: "Musify" | "YouTube";
|
||||
PaginatedResponseOfAlbumApplicationResponse: {
|
||||
items: components["schemas"]["AlbumApplicationResponse"][];
|
||||
/** Format: int32 */
|
||||
@@ -928,15 +855,9 @@ export interface components {
|
||||
};
|
||||
ToggleLikeRequest: {
|
||||
/** Format: uuid */
|
||||
trackId: null | string;
|
||||
source: null | components["schemas"]["TrackSource"];
|
||||
externalId: null | string;
|
||||
trackId: string;
|
||||
};
|
||||
TrackApplicationResponse: components["schemas"]["TrackApplicationResponseLocalTrackApplicationResponse"] | components["schemas"]["TrackApplicationResponseExternalTrackApplicationResponse"];
|
||||
TrackApplicationResponseExternalTrackApplicationResponse: {
|
||||
/** @enum {string} */
|
||||
source?: "YouTube";
|
||||
externalId: string;
|
||||
TrackApplicationResponse: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -950,32 +871,11 @@ export interface components {
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
TrackApplicationResponseLocalTrackApplicationResponse: {
|
||||
/** @enum {string} */
|
||||
source?: "Local";
|
||||
ownerUserId: string;
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
artist: null | string;
|
||||
audioStatus: components["schemas"]["ProcessingStatus"];
|
||||
/** Format: double */
|
||||
duration: number | string;
|
||||
/** Format: int32 */
|
||||
listensCount: number | string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
TrackSearchItemResponse: {
|
||||
source: components["schemas"]["TrackSource"];
|
||||
track: null | components["schemas"]["TrackApplicationResponse"];
|
||||
youTubeSong: null | components["schemas"]["YouTubeSongResult"];
|
||||
track: components["schemas"]["TrackApplicationResponse"];
|
||||
};
|
||||
/** @enum {unknown} */
|
||||
TrackSource: "Local" | "YouTube";
|
||||
TracksSearchResponse: {
|
||||
items: components["schemas"]["TrackSearchItemResponse"][];
|
||||
/** Format: int32 */
|
||||
@@ -987,9 +887,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
totalItemCount: number | string;
|
||||
hasPreviousPage: boolean;
|
||||
hasNextLocalPage: boolean;
|
||||
nextYoutubeContinuationToken: null | string;
|
||||
youtubeUnavailable: boolean;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
TrackStreamResponse: {
|
||||
@@ -1054,59 +951,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
followingCount: number | string;
|
||||
};
|
||||
YouTubeAlbumDetail: {
|
||||
album: components["schemas"]["YouTubeAlbumResult"];
|
||||
description: string;
|
||||
/** Format: int32 */
|
||||
totalDurationSeconds: number | string;
|
||||
tracks: components["schemas"]["YouTubeAlbumTrack"][];
|
||||
};
|
||||
YouTubeAlbumResult: {
|
||||
albumId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
thumbnailUrl: string;
|
||||
/** Format: int32 */
|
||||
releaseYear: null | number | string;
|
||||
isSingle: boolean;
|
||||
isEp: boolean;
|
||||
artists: components["schemas"]["YouTubeArtistRef"][];
|
||||
};
|
||||
YouTubeAlbumTrack: {
|
||||
videoId: string;
|
||||
title: string;
|
||||
/** Format: int32 */
|
||||
durationSeconds: number | string;
|
||||
/** Format: int32 */
|
||||
trackNumber: number | string;
|
||||
isExplicit: boolean;
|
||||
};
|
||||
YouTubeArtistRef: {
|
||||
id: null | string;
|
||||
name: string;
|
||||
};
|
||||
YouTubeSongResult: {
|
||||
videoId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
/** Format: int32 */
|
||||
durationSeconds: number | string;
|
||||
thumbnailUrl: string;
|
||||
isExplicit: boolean;
|
||||
artists: components["schemas"]["YouTubeArtistRef"][];
|
||||
};
|
||||
/** @enum {unknown} */
|
||||
YouTubeStreamMode: "Server" | "YouTube";
|
||||
YouTubeStreamResponse: {
|
||||
/** Format: uuid */
|
||||
trackId: null | string;
|
||||
mode: components["schemas"]["YouTubeStreamMode"];
|
||||
streamUrl: string;
|
||||
ticket: null | string;
|
||||
/** Format: int32 */
|
||||
expiresInSeconds: number | string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
@@ -1122,7 +966,6 @@ export interface operations {
|
||||
title?: string;
|
||||
pageNumber?: number | string;
|
||||
pageSize?: number | string;
|
||||
youtubeContinuationToken?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -1217,36 +1060,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
GetExternalAlbum: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
source: string;
|
||||
externalId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["YouTubeAlbumDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
GetAlbumsByUserId: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -1648,15 +1461,6 @@ export interface operations {
|
||||
"application/json": boolean;
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
@@ -1918,15 +1722,6 @@ export interface operations {
|
||||
"application/json": components["schemas"]["TrackApplicationResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
@@ -2150,7 +1945,6 @@ export interface operations {
|
||||
name?: string;
|
||||
pageNumber?: number | string;
|
||||
pageSize?: number | string;
|
||||
youtubeContinuationToken?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -2317,50 +2111,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
GetExternalTrackStream: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
source: string;
|
||||
externalId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["YouTubeStreamResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
GetTracksByUserId: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -2,18 +2,11 @@
|
||||
const MENU_WIDTH = 240;
|
||||
const SUBMENU_WIDTH = 256;
|
||||
|
||||
export type AlbumMenuState = { x: number; y: number; openLeft: boolean } & (
|
||||
{ kind: 'local'; albumId: string } | { kind: 'youtube'; albumId: string }
|
||||
);
|
||||
export type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
|
||||
|
||||
export function albumContextMenuStateFor(
|
||||
event: MouseEvent,
|
||||
kind: 'local' | 'youtube',
|
||||
albumId: string
|
||||
): AlbumMenuState {
|
||||
export function albumContextMenuStateFor(event: MouseEvent, albumId: string): AlbumMenuState {
|
||||
event.preventDefault();
|
||||
return {
|
||||
kind,
|
||||
albumId,
|
||||
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
|
||||
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
|
||||
@@ -93,9 +86,7 @@
|
||||
{#each playlists as playlist (playlist.id)}
|
||||
<form
|
||||
method="POST"
|
||||
action={menu.kind === 'youtube'
|
||||
? '?/addYoutubeAlbumToPlaylist'
|
||||
: '?/addAlbumToPlaylist'}
|
||||
action="?/addAlbumToPlaylist"
|
||||
use:enhance={() =>
|
||||
({ update }) => {
|
||||
onClose();
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
{#each playlists as playlist (playlist.id)}
|
||||
<form
|
||||
method="POST"
|
||||
action={menu.kind === 'youtube' ? '?/addYouTubeToPlaylist' : '?/addTrack'}
|
||||
action="?/addTrack"
|
||||
use:enhance={() =>
|
||||
({ update }) => {
|
||||
onClose();
|
||||
@@ -86,15 +86,7 @@
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="playlistId" value={playlist.id} />
|
||||
{#if menu.kind === 'youtube'}
|
||||
<input type="hidden" name="videoId" value={menu.song.videoId} />
|
||||
<input type="hidden" name="title" value={menu.song.title} />
|
||||
<input type="hidden" name="artist" value={menu.song.artist} />
|
||||
<input type="hidden" name="durationSeconds" value={menu.song.durationSeconds} />
|
||||
<input type="hidden" name="thumbnailUrl" value={menu.song.thumbnailUrl} />
|
||||
{:else}
|
||||
<input type="hidden" name="trackId" value={menu.track.id} />
|
||||
{/if}
|
||||
<input type="hidden" name="trackId" value={menu.track.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
src={player.current.coverUrl}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-10 w-10 shrink-0 rounded-[9px]"
|
||||
@@ -90,7 +89,6 @@
|
||||
>
|
||||
<Cover
|
||||
trackId={track.id}
|
||||
src={track.coverUrl}
|
||||
size="small"
|
||||
alt={track.title}
|
||||
class="h-9 w-9 shrink-0 rounded-lg opacity-90"
|
||||
@@ -104,9 +102,7 @@
|
||||
/>
|
||||
</div>
|
||||
{#if track.duration}
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums"
|
||||
>{fmtTime(track.duration)}</span
|
||||
>
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
id: player.current.id,
|
||||
title: player.current.title,
|
||||
artist: player.current.artist,
|
||||
coverUrl: player.current.coverUrl,
|
||||
source: player.current.source,
|
||||
explicit: player.current.explicit,
|
||||
ownerUserId: player.current.ownerUserId
|
||||
});
|
||||
@@ -32,7 +30,6 @@
|
||||
{#if player.current.id}
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
src={player.current.coverUrl}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-11 w-11 shrink-0 rounded-[11px] shadow-cover-xs md:h-12 md:w-12"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { thumbnailSrc } from '$lib/thumbnails';
|
||||
|
||||
interface Props {
|
||||
trackId: string | number;
|
||||
@@ -24,9 +23,7 @@
|
||||
|
||||
let failed = $state(false);
|
||||
|
||||
const imageSrc = $derived(
|
||||
src ? (thumbnailSrc(src, size) ?? src) : `/api/tracks/${trackId}/cover?size=${size}`
|
||||
);
|
||||
const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`);
|
||||
|
||||
$effect(() => {
|
||||
imageSrc;
|
||||
|
||||
@@ -15,21 +15,11 @@
|
||||
{#if tiles.length >= 4}
|
||||
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
|
||||
{#each tiles as item (mixItemKey(item))}
|
||||
<Cover
|
||||
trackId={item.trackId ?? item.videoId ?? ''}
|
||||
src={item.thumbnailUrl}
|
||||
size="small"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<Cover trackId={item.trackId} size="small" class="h-full w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if tiles.length > 0}
|
||||
<Cover
|
||||
trackId={tiles[0].trackId ?? tiles[0].videoId ?? ''}
|
||||
src={tiles[0].thumbnailUrl}
|
||||
size="medium"
|
||||
class={klass}
|
||||
/>
|
||||
<Cover trackId={tiles[0].trackId} size="medium" class={klass} />
|
||||
{:else}
|
||||
<div class="bg-surface {klass}"></div>
|
||||
{/if}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
|
||||
let track: HTMLDivElement | undefined = $state();
|
||||
|
||||
export function scrollByPage(direction: 1 | -1) {
|
||||
track?.scrollBy({ left: direction * track.clientWidth * 0.85, behavior: 'smooth' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={track} class="no-scrollbar -mx-1 flex gap-4 overflow-x-auto px-1 pb-2 {klass}">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -13,8 +13,6 @@ export const HOME_MIXES_LIMIT = 6;
|
||||
export const HOME_SHELF_LIMIT = 6;
|
||||
export const HOME_CONTINUE_LIMIT = 6;
|
||||
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||
export const HOME_POPULAR_FILLER_LIMIT = 10;
|
||||
export const HOME_POPULAR_MAX = 8;
|
||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
||||
|
||||
@@ -5,33 +5,15 @@ export type Mix = components['schemas']['MixApplicationResponse'];
|
||||
export type MixItem = components['schemas']['MixItemApplicationResponse'];
|
||||
|
||||
export function mixItemKey(item: MixItem): string {
|
||||
return item.source === 'YouTube' ? `yt:${item.videoId}` : `mf:${item.trackId}`;
|
||||
return `mf:${item.trackId}`;
|
||||
}
|
||||
|
||||
export function mixItemTarget(item: MixItem): TrackTarget {
|
||||
if (item.source === 'YouTube') {
|
||||
return {
|
||||
kind: 'youtube',
|
||||
song: {
|
||||
videoId: item.videoId ?? '',
|
||||
title: item.title,
|
||||
artist: item.artist ?? '',
|
||||
album: '',
|
||||
durationSeconds: Number(item.durationSeconds),
|
||||
thumbnailUrl: item.thumbnailUrl ?? '',
|
||||
isExplicit: item.isExplicit
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'local',
|
||||
track: {
|
||||
id: item.trackId ?? '',
|
||||
id: item.trackId,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
source: 'Local',
|
||||
isExplicit: item.isExplicit
|
||||
artist: item.artist
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ const LABELS: [RegExp, string][] = [
|
||||
[/^\/explore\/?$/, 'Volver a explorar'],
|
||||
[/^\/playlists\/[^/]+/, 'Volver a la lista'],
|
||||
[/^\/playlists\/?$/, 'Volver a tus listas'],
|
||||
[/^\/albums\/youtube\/[^/]+/, 'Volver al álbum'],
|
||||
[/^\/albums\/[^/]+/, 'Volver al álbum'],
|
||||
[/^\/albums\/?$/, 'Volver a tus álbumes'],
|
||||
[/^\/library\/?$/, 'Volver a tus canciones'],
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { toQueueItems, type ApiTrackLike, type TrackSourceKind } from './player.svelte';
|
||||
import { toQueueItems, type ApiTrackLike } from './player.svelte';
|
||||
|
||||
export interface LikedTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string;
|
||||
coverUrl?: string;
|
||||
source?: TrackSourceKind;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
likedAt: number;
|
||||
@@ -15,18 +13,10 @@ type LikeToggleInput = {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
coverUrl?: string;
|
||||
source?: TrackSourceKind;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
};
|
||||
|
||||
function toggleRequestBody(track: LikeToggleInput) {
|
||||
return track.source === 'youtube'
|
||||
? { source: 'YouTube' as const, externalId: String(track.id) }
|
||||
: { trackId: String(track.id) };
|
||||
}
|
||||
|
||||
class LikedStore {
|
||||
entries = $state<Record<string, LikedTrack>>({});
|
||||
private hydrated = false;
|
||||
@@ -45,8 +35,6 @@ class LikedStore {
|
||||
id: String(item.id),
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
coverUrl: item.coverUrl,
|
||||
source: item.source,
|
||||
explicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId,
|
||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
||||
@@ -70,8 +58,6 @@ class LikedStore {
|
||||
id,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
coverUrl: track.coverUrl,
|
||||
source: track.source,
|
||||
explicit: track.explicit,
|
||||
ownerUserId: track.ownerUserId,
|
||||
likedAt: Date.now()
|
||||
@@ -83,7 +69,7 @@ class LikedStore {
|
||||
const res = await fetch('/api/likes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(toggleRequestBody(track))
|
||||
body: JSON.stringify({ trackId: id })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to toggle like');
|
||||
} catch {
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
||||
import { extractAccent, type Accent } from '$lib/theme/palette';
|
||||
import { thumbnailSrc } from '$lib/thumbnails';
|
||||
|
||||
export type TrackSourceKind = 'local' | 'youtube';
|
||||
|
||||
export interface PlayerTrack {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: number;
|
||||
source: TrackSourceKind;
|
||||
coverUrl?: string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
@@ -26,8 +21,6 @@ export interface QueueItem {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
source?: TrackSourceKind;
|
||||
coverUrl?: string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
@@ -36,46 +29,18 @@ export interface ApiTrackLike {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
source?: 'Local' | 'YouTube';
|
||||
externalId?: string | null;
|
||||
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||
isExplicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
|
||||
function isYouTubeTrack(track: ApiTrackLike): boolean {
|
||||
return track.source === 'YouTube';
|
||||
}
|
||||
|
||||
export function isPendingYouTubeTrack(track: ApiTrackLike): boolean {
|
||||
return isYouTubeTrack(track) && track.audioStatus !== 'Completed';
|
||||
}
|
||||
|
||||
export function youTubeThumbnailUrl(videoId: string): string {
|
||||
return `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`;
|
||||
}
|
||||
|
||||
export function queueIdForTrack(track: ApiTrackLike): string | number {
|
||||
return isYouTubeTrack(track) && track.externalId ? track.externalId : track.id;
|
||||
}
|
||||
|
||||
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
||||
return tracks.map((track) => {
|
||||
const youTube = isYouTubeTrack(track);
|
||||
return {
|
||||
id: queueIdForTrack(track),
|
||||
title: track.title,
|
||||
artist: track.artist ?? undefined,
|
||||
source: youTube ? 'youtube' : 'local',
|
||||
coverUrl: youTube
|
||||
? isPendingYouTubeTrack(track) && track.externalId
|
||||
? youTubeThumbnailUrl(track.externalId)
|
||||
: `/api/tracks/${track.id}/cover?size=small`
|
||||
: undefined,
|
||||
explicit: track.isExplicit,
|
||||
ownerUserId: youTube ? undefined : track.ownerUserId
|
||||
};
|
||||
});
|
||||
return tracks.map((track) => ({
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
artist: track.artist ?? undefined,
|
||||
explicit: track.isExplicit,
|
||||
ownerUserId: track.ownerUserId
|
||||
}));
|
||||
}
|
||||
|
||||
async function readErrorMessage(res: Response): Promise<string> {
|
||||
@@ -93,8 +58,7 @@ const EMPTY: PlayerTrack = {
|
||||
id: '',
|
||||
title: '',
|
||||
artist: '',
|
||||
duration: 0,
|
||||
source: 'local'
|
||||
duration: 0
|
||||
};
|
||||
|
||||
function toTrack(item: QueueItem): PlayerTrack {
|
||||
@@ -104,8 +68,6 @@ function toTrack(item: QueueItem): PlayerTrack {
|
||||
artist: item.artist ?? '',
|
||||
duration: 0,
|
||||
explicit: item.explicit,
|
||||
source: item.source ?? 'local',
|
||||
coverUrl: item.coverUrl,
|
||||
ownerUserId: item.ownerUserId
|
||||
};
|
||||
}
|
||||
@@ -127,7 +89,6 @@ class PlayerState {
|
||||
|
||||
#audio: HTMLAudioElement | null = null;
|
||||
#loadToken = 0;
|
||||
#retriedId: string | number | null = null;
|
||||
#accentCache = new Map<string, Accent>();
|
||||
#rafId: number | null = null;
|
||||
|
||||
@@ -170,15 +131,6 @@ class PlayerState {
|
||||
});
|
||||
audio.addEventListener('error', () => {
|
||||
this.#stopProgressLoop();
|
||||
if (
|
||||
this.currentId !== null &&
|
||||
this.current.source === 'youtube' &&
|
||||
this.#retriedId !== this.currentId
|
||||
) {
|
||||
this.#retriedId = this.currentId;
|
||||
this.#loadCurrent();
|
||||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
this.playing = false;
|
||||
this.error = 'No se pudo reproducir la pista.';
|
||||
@@ -207,12 +159,10 @@ class PlayerState {
|
||||
navigator.mediaSession.metadata = null;
|
||||
return;
|
||||
}
|
||||
const artwork =
|
||||
thumbnailSrc(track.coverUrl, 'medium') ?? `/api/tracks/${track.id}/cover?size=small`;
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
artwork: [{ src: artwork, sizes: '256x256' }]
|
||||
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,16 +202,14 @@ class PlayerState {
|
||||
);
|
||||
}
|
||||
|
||||
async #applyAccent(id: string | number, coverUrl?: string) {
|
||||
async #applyAccent(id: string | number) {
|
||||
const key = String(id);
|
||||
const cached = this.#accentCache.get(key);
|
||||
if (cached) {
|
||||
this.accentColor = cached.accent;
|
||||
return;
|
||||
}
|
||||
const result = await extractAccent(
|
||||
thumbnailSrc(coverUrl, 'small') ?? `/api/tracks/${id}/cover?size=small`
|
||||
);
|
||||
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
|
||||
if (this.currentId !== id) return;
|
||||
if (result) {
|
||||
this.#accentCache.set(key, result);
|
||||
@@ -276,23 +224,19 @@ class PlayerState {
|
||||
if (!audio || this.currentId === null) return;
|
||||
const id = this.currentId;
|
||||
const track = this.tracks.find((t) => t.id === id);
|
||||
this.#applyAccent(id, track?.coverUrl);
|
||||
this.#applyAccent(id);
|
||||
if (track) this.#syncMediaSessionMetadata(track);
|
||||
const token = ++this.#loadToken;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.progress = 0;
|
||||
try {
|
||||
const src =
|
||||
track?.source === 'youtube'
|
||||
? await this.#resolveYouTubeSrc(String(id))
|
||||
: await this.#resolveLocalSrc(String(id));
|
||||
const src = await this.#resolveLocalSrc(String(id));
|
||||
if (token !== this.#loadToken) return;
|
||||
audio.src = src;
|
||||
audio.volume = this.volume / 100;
|
||||
await audio.play();
|
||||
this.loading = false;
|
||||
this.#retriedId = null;
|
||||
this.#pushRecent(this.current);
|
||||
} catch (exception) {
|
||||
console.error('playback failed', exception);
|
||||
@@ -316,17 +260,6 @@ class PlayerState {
|
||||
return this.#withTicket(manifestUrl, ticket);
|
||||
}
|
||||
|
||||
async #resolveYouTubeSrc(videoId: string): Promise<string> {
|
||||
const res = await fetch(`/api/tracks/external/youtube/${videoId}/stream`);
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res));
|
||||
const { mode, streamUrl, ticket } = (await res.json()) as {
|
||||
mode: string;
|
||||
streamUrl: string;
|
||||
ticket: string;
|
||||
};
|
||||
return mode === 'Server' ? this.#withTicket(streamUrl, ticket) : streamUrl;
|
||||
}
|
||||
|
||||
#withTicket(url: string, ticket: string): string {
|
||||
if (!ticket) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
|
||||
@@ -16,8 +16,6 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que
|
||||
id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
source: t.source,
|
||||
coverUrl: t.coverUrl,
|
||||
explicit: t.explicit,
|
||||
ownerUserId: t.ownerUserId
|
||||
});
|
||||
@@ -31,8 +29,6 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que
|
||||
id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
source: item.source ?? 'local',
|
||||
coverUrl: item.coverUrl,
|
||||
explicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export const THUMBNAIL_SIZES = {
|
||||
small: 128,
|
||||
medium: 192,
|
||||
large: 360
|
||||
} as const;
|
||||
|
||||
export type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
|
||||
|
||||
const PROXY_PATH = '/api/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]}`;
|
||||
}
|
||||
@@ -1,87 +1,48 @@
|
||||
import {
|
||||
player,
|
||||
queueIdForTrack,
|
||||
toQueueItems,
|
||||
type ApiTrackLike,
|
||||
type QueueItem
|
||||
} from '$lib/player/player.svelte';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
export type TrackTarget =
|
||||
{ kind: 'youtube'; song: YouTubeSong } | { kind: 'local'; track: ApiTrackLike };
|
||||
export type TrackTarget = { track: ApiTrackLike };
|
||||
|
||||
export function targetId(target: TrackTarget): string | number {
|
||||
return target.kind === 'youtube' ? target.song.videoId : target.track.id;
|
||||
return target.track.id;
|
||||
}
|
||||
|
||||
export function targetTitle(target: TrackTarget): string {
|
||||
return target.kind === 'youtube' ? target.song.title : target.track.title;
|
||||
return target.track.title;
|
||||
}
|
||||
|
||||
export function targetArtist(target: TrackTarget): string | undefined {
|
||||
return (target.kind === 'youtube' ? target.song.artist : target.track.artist) ?? undefined;
|
||||
return target.track.artist ?? undefined;
|
||||
}
|
||||
|
||||
export function targetOwnerUserId(target: TrackTarget): string | number | undefined {
|
||||
return target.kind === 'youtube' ? undefined : (target.track.ownerUserId ?? undefined);
|
||||
return target.track.ownerUserId ?? undefined;
|
||||
}
|
||||
|
||||
export function targetExplicit(target: TrackTarget): boolean {
|
||||
return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false);
|
||||
}
|
||||
|
||||
export function targetDurationSeconds(target: TrackTarget): number | undefined {
|
||||
return target.kind === 'youtube' ? target.song.durationSeconds : undefined;
|
||||
}
|
||||
|
||||
export function targetCoverSrc(target: TrackTarget): string | undefined {
|
||||
return target.kind === 'youtube' ? target.song.thumbnailUrl : undefined;
|
||||
}
|
||||
|
||||
function queueIdForTarget(target: TrackTarget): string | number {
|
||||
return target.kind === 'youtube' ? target.song.videoId : queueIdForTrack(target.track);
|
||||
return target.track.isExplicit ?? false;
|
||||
}
|
||||
|
||||
export function isTargetCurrent(target: TrackTarget): boolean {
|
||||
return player.current.id === queueIdForTarget(target);
|
||||
return player.current.id === target.track.id;
|
||||
}
|
||||
|
||||
export function queueItemForTarget(target: TrackTarget): QueueItem {
|
||||
return target.kind === 'youtube'
|
||||
? {
|
||||
id: target.song.videoId,
|
||||
title: target.song.title,
|
||||
artist: target.song.artist,
|
||||
source: 'youtube',
|
||||
coverUrl: target.song.thumbnailUrl,
|
||||
explicit: target.song.isExplicit
|
||||
}
|
||||
: toQueueItems([target.track])[0];
|
||||
return toQueueItems([target.track])[0];
|
||||
}
|
||||
|
||||
export function targetForQueueItem(item: QueueItem): TrackTarget {
|
||||
return item.source === 'youtube'
|
||||
? {
|
||||
kind: 'youtube',
|
||||
song: {
|
||||
videoId: String(item.id),
|
||||
title: item.title,
|
||||
artist: item.artist ?? '',
|
||||
album: '',
|
||||
durationSeconds: 0,
|
||||
thumbnailUrl: item.coverUrl ?? '',
|
||||
isExplicit: item.explicit ?? false
|
||||
}
|
||||
}
|
||||
: {
|
||||
kind: 'local',
|
||||
track: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
source: 'Local',
|
||||
isExplicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId
|
||||
}
|
||||
};
|
||||
return {
|
||||
track: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
isExplicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,21 +4,3 @@ export interface SessionUser {
|
||||
email: string;
|
||||
picture: string;
|
||||
}
|
||||
|
||||
export interface YouTubeSong {
|
||||
videoId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
durationSeconds: number;
|
||||
thumbnailUrl: string;
|
||||
isExplicit: boolean;
|
||||
}
|
||||
|
||||
export interface YouTubeAlbumResult {
|
||||
albumId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
thumbnailUrl: string;
|
||||
releaseYear: number | string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user