From 78f7fe06cf5936d01367ac1581561fe048bad67c Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 20 Jul 2026 20:39:21 +0200 Subject: [PATCH] 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. --- web-player/src/lib/api/schema.d.ts | 140 ++++++++++++++ .../src/lib/components/ui/AlbumCard.svelte | 57 ++++-- .../albums/youtube/[albumId]/+page.server.ts | 23 +++ .../albums/youtube/[albumId]/+page.svelte | 175 ++++++++++++++++++ .../src/routes/(app)/explore/+page.server.ts | 11 +- .../src/routes/(app)/explore/+page.svelte | 20 +- 6 files changed, 408 insertions(+), 18 deletions(-) create mode 100644 web-player/src/routes/(app)/albums/youtube/[albumId]/+page.server.ts create mode 100644 web-player/src/routes/(app)/albums/youtube/[albumId]/+page.svelte diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 00f3fc3..a23394a 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -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; diff --git a/web-player/src/lib/components/ui/AlbumCard.svelte b/web-player/src/lib/components/ui/AlbumCard.svelte index 662b4c8..8cc5b27 100644 --- a/web-player/src/lib/components/ui/AlbumCard.svelte +++ b/web-player/src/lib/components/ui/AlbumCard.svelte @@ -1,35 +1,68 @@ - + {#if coverSrc} + + {:else} + + {/if}
{title}
-
{meta}
+ {#if meta} +
{meta}
+ {/if}
diff --git a/web-player/src/routes/(app)/albums/youtube/[albumId]/+page.server.ts b/web-player/src/routes/(app)/albums/youtube/[albumId]/+page.server.ts new file mode 100644 index 0000000..0f76dd2 --- /dev/null +++ b/web-player/src/routes/(app)/albums/youtube/[albumId]/+page.server.ts @@ -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 +}; diff --git a/web-player/src/routes/(app)/albums/youtube/[albumId]/+page.svelte b/web-player/src/routes/(app)/albums/youtube/[albumId]/+page.svelte new file mode 100644 index 0000000..efba78c --- /dev/null +++ b/web-player/src/routes/(app)/albums/youtube/[albumId]/+page.svelte @@ -0,0 +1,175 @@ + + + + {album.title} · Musify + + + + + + + + + Volver a explorar + + +
+ +
+

+ {album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'} · YouTube Music +

+

+ {album.title} +

+

{album.artist}

+

+ {[ + album.releaseYear ? String(album.releaseYear) : undefined, + `${tracks.length} ${tracks.length === 1 ? 'canción' : 'canciones'}`, + fmtTime(Number(data.album.totalDurationSeconds)) + ] + .filter((part) => !!part) + .join(' · ')} +

+
+
+ +
+ +
+ + {#if data.album.description} +

{data.album.description}

+ {/if} + + + {#snippet headers()} + # + Título + + {/snippet} + {#each tracks as track, i (track.videoId)} + (contextMenu = contextMenuStateFor(e, targetFor(i)))}> + playFrom(i)} + /> + playFrom(i)} + /> + {fmtTime(Number(track.durationSeconds))} + + {/each} + +
+ +{#if contextMenu} + (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} diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 638123c..b8449a3 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -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 diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index 8edc5d0..697fd25 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -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}
@@ -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)} + + {/each}
{/if} {#if showTracks && items.length > 0} - {#if showAlbums && albums.length > 0} + {#if showAlbums && hasAlbums} {/if}