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 07e1daf2ce
commit 78f7fe06cf
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"
>
<PlaylistArt
{trackIds}
hue={hueFor(id)}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
{#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>
<div class="mt-0.5 truncate text-sm text-fg-3">{meta}</div>
{#if meta}
<div class="mt-0.5 truncate text-sm text-fg-3">{meta}</div>
{/if}
</a>