feat(web-player): show YouTube Music albums in the explore search

The Albums chip now returns both your own albums and what YouTube Music has
for the term. A YouTube card opens a read-only album page with its cover,
tracklist and play button, and right-clicking a track still offers to add it
to a playlist through the existing menu.
This commit is contained in:
2026-07-20 20:39:21 +02:00
parent 0afb365427
commit 29c220a1cf
6 changed files with 408 additions and 18 deletions
+140
View File
@@ -454,6 +454,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/youtube/albums": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Search Albums On YouTube Music. */
get: operations["SearchYouTubeAlbums"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/youtube/albums/{albumId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get A YouTube Music Album With Its Tracklist. */
get: operations["GetYouTubeAlbum"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/youtube/tracks/{videoId}/stream": {
parameters: {
query?: never;
@@ -696,6 +730,37 @@ export interface components {
/** Format: date-time */
updatedAt: 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"][];
};
YouTubeAlbumSearchResult: {
items: components["schemas"]["YouTubeAlbumResult"][];
continuationToken: string;
};
YouTubeAlbumTrack: {
videoId: string;
title: string;
/** Format: int32 */
durationSeconds: number | string;
/** Format: int32 */
trackNumber: number | string;
isExplicit: boolean;
};
YouTubeArtistRef: {
id: null | string;
name: string;
@@ -1962,6 +2027,81 @@ export interface operations {
};
};
};
SearchYouTubeAlbums: {
parameters: {
query: {
query: string;
continuation?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeAlbumSearchResult"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetYouTubeAlbum: {
parameters: {
query?: never;
header?: never;
path: {
albumId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeAlbumDetail"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetYouTubeTrackStream: {
parameters: {
query?: never;
@@ -1,35 +1,68 @@
<script lang="ts">
import PlaylistArt from './PlaylistArt.svelte';
import Cover from './Cover.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
id: string;
title: string;
href?: string;
subtitle?: string;
releaseYear?: number;
trackCount: number;
trackIds: (string | number)[];
trackCount?: number;
trackIds?: (string | number)[];
coverSrc?: string | null;
index?: number;
}
let { id, title, releaseYear, trackCount, trackIds, index = 0 }: Props = $props();
let {
id,
title,
href,
subtitle,
releaseYear,
trackCount,
trackIds = [],
coverSrc,
index = 0
}: Props = $props();
const meta = $derived(
[releaseYear, `${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`]
.filter((part) => part !== undefined && part !== null)
[
subtitle,
releaseYear ? String(releaseYear) : undefined,
trackCount === undefined
? undefined
: `${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`
]
.filter((part) => !!part)
.join(' · ')
);
</script>
<a
href="/albums/{id}"
href={href ?? `/albums/${id}`}
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
>
{#if coverSrc}
<Cover
trackId={id}
src={coverSrc}
hue={hueFor(id)}
size="large"
alt={title}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
{:else}
<PlaylistArt
{trackIds}
hue={hueFor(id)}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
{/if}
<div class="mt-2.5 truncate text-fg">{title}</div>
{#if meta}
<div class="mt-0.5 truncate text-sm text-fg-3">{meta}</div>
{/if}
</a>
@@ -0,0 +1,23 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [albumRes, playlistsRes] = await Promise.all([
api.GET('/youtube/albums/{albumId}', { params: { path: { albumId: params.albumId } } }),
api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
]);
const album = unwrapOrError(albumRes, 'No se pudo cargar el álbum de YouTube Music.', 404);
return { album, playlists: playlistsRes.data?.items ?? [] };
};
export const actions: Actions = {
addYouTubeToPlaylist: addYouTubeToPlaylistAction
};
@@ -0,0 +1,175 @@
<script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import { player, type QueueItem } from '$lib/player/player.svelte';
import { hueFor } from '$lib/theme/color';
import { fmtTime } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import type { TrackTarget } from '$lib/tracks';
let { data } = $props();
const album = $derived(data.album.album);
const tracks = $derived(data.album.tracks);
let contextMenu = $state<ContextMenuState | null>(null);
const queue = $derived<QueueItem[]>(
tracks.map((track) => ({
id: track.videoId,
title: track.title,
artist: album.artist,
source: 'youtube' as const,
coverUrl: album.thumbnailUrl,
explicit: track.isExplicit
}))
);
const isCurrentQueue = $derived(tracks.some((track) => track.videoId === player.current.id));
function targetFor(index: number): TrackTarget {
const track = tracks[index];
return {
kind: 'youtube',
song: {
videoId: track.videoId,
title: track.title,
artist: album.artist,
album: album.title,
durationSeconds: Number(track.durationSeconds),
thumbnailUrl: album.thumbnailUrl,
isExplicit: track.isExplicit
}
};
}
function playFrom(index: number) {
player.playOrToggle(queue, index);
}
function playAll() {
if (queue.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(queue, 0);
}
</script>
<svelte:head>
<title>{album.title} · Musify</title>
<meta name="description" content="Álbum {album.title} de {album.artist} en YouTube Music." />
</svelte:head>
<Page>
<a
href="/explore"
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
>
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
<ArrowLeft class="h-4 w-4" />
</span>
Volver a explorar
</a>
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<Cover
trackId={album.albumId}
src={album.thumbnailUrl}
hue={hueFor(album.albumId)}
size="large"
alt={album.title}
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'} · YouTube Music
</p>
<h1
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
>
{album.title}
</h1>
<p class="mt-3 text-base text-fg-2">{album.artist}</p>
<p class="mt-1 text-sm text-muted">
{[
album.releaseYear ? String(album.releaseYear) : undefined,
`${tracks.length} ${tracks.length === 1 ? 'canción' : 'canciones'}`,
fmtTime(Number(data.album.totalDurationSeconds))
]
.filter((part) => !!part)
.join(' · ')}
</p>
</div>
</div>
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
</div>
{#if data.album.description}
<p class="mt-6 max-w-3xl text-sm text-fg-3">{data.album.description}</p>
{/if}
<TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8">
{#snippet headers()}
<span class="text-center">#</span>
<span>Título</span>
<span class="hidden text-center sm:block">Duración</span>
{/snippet}
{#each tracks as track, i (track.videoId)}
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}>
<TrackIndexCell
index={i}
active={player.current.id === track.videoId}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
trackId={track.videoId}
title={track.title}
artist={album.artist}
coverSrc={album.thumbnailUrl}
hue={hueFor(track.videoId)}
explicit={track.isExplicit}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(track.durationSeconds))}</TrackMeta>
</TrackRow>
{/each}
</TrackTable>
</Page>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
playlists={data.playlists}
onClose={() => (contextMenu = null)}
onAddToQueue={() => {
const menu = contextMenu;
if (menu?.kind === 'youtube') {
const index = tracks.findIndex((track) => track.videoId === menu.song.videoId);
if (index >= 0) player.addToQueue(queue[index]);
}
contextMenu = null;
}}
/>
{/if}
@@ -35,11 +35,17 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
})
: Promise.resolve(null);
const [tracksRes, searchRes, playlistsRes, albumsRes] = await Promise.all([
const youtubeAlbumsPromise =
query.length >= SEARCH_MIN_LENGTH
? api.GET('/youtube/albums', { params: { query: { query } } })
: Promise.resolve(null);
const [tracksRes, searchRes, playlistsRes, albumsRes, youtubeAlbumsRes] = await Promise.all([
tracksPromise,
searchPromise,
playlistsPromise,
albumsPromise
albumsPromise,
youtubeAlbumsPromise
]);
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
@@ -48,6 +54,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
query,
tracks,
albums: albumsRes?.data?.items ?? [],
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
ytResults: searchRes?.data ?? null,
ytError: Boolean(searchRes?.error),
youtubeFiller: query
@@ -101,12 +101,14 @@
const hasMore = $derived(showTracks && (localHasNext || ytContinuation !== ''));
const albums = $derived(data.albums);
const youtubeAlbums = $derived(data.youtubeAlbums);
const hasAlbums = $derived(albums.length > 0 || youtubeAlbums.length > 0);
const nothingFound = $derived(
!!data.query &&
!data.ytError &&
(showTracks ? items.length === 0 : true) &&
(showAlbums ? albums.length === 0 : true) &&
(showAlbums ? !hasAlbums : true) &&
filters.length > 0
);
@@ -230,7 +232,7 @@
/>
{/if}
{#if showAlbums && albums.length > 0}
{#if showAlbums && hasAlbums}
<div class="mt-8">
<SectionHeading title="Álbumes" />
<MediaGrid class="mt-4">
@@ -240,16 +242,26 @@
title={album.title}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)}
trackIds={[]}
index={i}
/>
{/each}
{#each youtubeAlbums as album, i (album.albumId)}
<AlbumCard
id={album.albumId}
href="/albums/youtube/{album.albumId}"
title={album.title}
subtitle={album.artist}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
coverSrc={album.thumbnailUrl}
index={albums.length + i}
/>
{/each}
</MediaGrid>
</div>
{/if}
{#if showTracks && items.length > 0}
{#if showAlbums && albums.length > 0}
{#if showAlbums && hasAlbums}
<SectionHeading title="Canciones" class="mt-10" />
{/if}
<MediaGrid as="ul" class="mt-6">