feat(web-player): page the album results and save YouTube albums

The albums section keeps a Ver más button while YouTube Music has another
page, fed by a new /api/youtube/albums route that forwards the continuation
token. The YouTube album page gains a save button that drops the whole album,
tracks included, into your library and takes you to it.
This commit is contained in:
2026-07-20 20:49:56 +02:00
parent df1cd5f1e9
commit 62b0ab37a9
6 changed files with 182 additions and 4 deletions
+60
View File
@@ -73,6 +73,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/albums/youtube/{albumId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Save A YouTube Music Album To Your Library With All Of Its Tracks. */
post: operations["SaveYouTubeAlbum"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/albums/{albumId}": { "/albums/{albumId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -964,6 +981,49 @@ export interface operations {
}; };
}; };
}; };
SaveYouTubeAlbum: {
parameters: {
query?: never;
header?: never;
path: {
albumId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlbumApplicationResponse"];
};
};
/** @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;
};
};
};
UpdateAlbum: { UpdateAlbum: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1,5 +1,11 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; import { addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
@@ -19,5 +25,28 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
addYouTubeToPlaylist: addYouTubeToPlaylistAction addYouTubeToPlaylist: addYouTubeToPlaylistAction,
save: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const {
data,
error: err,
response
} = await api.POST('/albums/youtube/{albumId}', {
params: { path: { albumId: params.albumId } }
});
if (response?.status === 409) {
return fail(409, { message: 'Ese álbum ya está en tu biblioteca.' });
}
if (err || !data) {
return fail(502, { message: 'No se pudo guardar el álbum.' });
}
redirect(303, `/albums/${data.id}`);
}
}; };
@@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Play from '@lucide/svelte/icons/play'; import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause'; import Pause from '@lucide/svelte/icons/pause';
import Plus from '@lucide/svelte/icons/plus';
import { player, type QueueItem } from '$lib/player/player.svelte'; import { player, type QueueItem } from '$lib/player/player.svelte';
import { hueFor } from '$lib/theme/color'; import { hueFor } from '$lib/theme/color';
import { fmtTime } from '$lib/format'; import { fmtTime } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import Cover from '$lib/components/ui/Cover.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte'; import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte'; import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte'; import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
@@ -19,12 +22,13 @@
} from '$lib/components/TrackContextMenu.svelte'; } from '$lib/components/TrackContextMenu.svelte';
import type { TrackTarget } from '$lib/tracks'; import type { TrackTarget } from '$lib/tracks';
let { data } = $props(); let { data, form } = $props();
const album = $derived(data.album.album); const album = $derived(data.album.album);
const tracks = $derived(data.album.tracks); const tracks = $derived(data.album.tracks);
let contextMenu = $state<ContextMenuState | null>(null); let contextMenu = $state<ContextMenuState | null>(null);
let saving = $state(false);
const queue = $derived<QueueItem[]>( const queue = $derived<QueueItem[]>(
tracks.map((track) => ({ tracks.map((track) => ({
@@ -123,8 +127,28 @@
Reproducir Reproducir
{/if} {/if}
</Button> </Button>
<form
method="POST"
action="?/save"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update();
saving = false;
};
}}
>
<Button type="submit" size="sm" variant="secondary" loading={saving}>
<Plus class="h-4 w-4" strokeWidth={2} />
Guardar en mi biblioteca
</Button>
</form>
</div> </div>
{#if form?.message}
<Alert tone="danger" class="mt-4">{form.message}</Alert>
{/if}
{#if data.album.description} {#if data.album.description}
<p class="mt-6 max-w-3xl text-sm text-fg-3">{data.album.description}</p> <p class="mt-6 max-w-3xl text-sm text-fg-3">{data.album.description}</p>
{/if} {/if}
@@ -55,6 +55,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
tracks, tracks,
albums: albumsRes?.data?.items ?? [], albums: albumsRes?.data?.items ?? [],
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [], youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
youtubeAlbumsContinuation: youtubeAlbumsRes?.data?.continuationToken ?? '',
ytResults: searchRes?.data ?? null, ytResults: searchRes?.data ?? null,
ytError: Boolean(searchRes?.error), ytError: Boolean(searchRes?.error),
youtubeFiller: query youtubeFiller: query
@@ -12,6 +12,7 @@
import Alert from '$lib/components/ui/Alert.svelte'; import Alert from '$lib/components/ui/Alert.svelte';
import Input from '$lib/components/ui/Input.svelte'; import Input from '$lib/components/ui/Input.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import ChipGroup, { type ChipOption } from '$lib/components/ui/ChipGroup.svelte'; import ChipGroup, { type ChipOption } from '$lib/components/ui/ChipGroup.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte'; import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
@@ -101,9 +102,43 @@
const hasMore = $derived(showTracks && (localHasNext || ytContinuation !== '')); const hasMore = $derived(showTracks && (localHasNext || ytContinuation !== ''));
const albums = $derived(data.albums); const albums = $derived(data.albums);
const youtubeAlbums = $derived(data.youtubeAlbums);
type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
let youtubeAlbums = $state<YouTubeAlbum[]>([]);
let ytAlbumsContinuation = $state('');
let loadingAlbums = $state(false);
$effect(() => {
youtubeAlbums = appendUnique([], data.youtubeAlbums, (album) => album.albumId);
ytAlbumsContinuation = data.youtubeAlbumsContinuation;
});
const hasAlbums = $derived(albums.length > 0 || youtubeAlbums.length > 0); const hasAlbums = $derived(albums.length > 0 || youtubeAlbums.length > 0);
async function loadMoreAlbums() {
if (loadingAlbums || !ytAlbumsContinuation) return;
loadingAlbums = true;
try {
const params = new URLSearchParams({
query: data.query,
continuation: ytAlbumsContinuation
});
const res = await fetch(`/api/youtube/albums?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as {
items: YouTubeAlbum[];
continuationToken: string;
};
youtubeAlbums = appendUnique(youtubeAlbums, next.items, (album) => album.albumId);
ytAlbumsContinuation = next.continuationToken;
} catch {
ytAlbumsContinuation = '';
} finally {
loadingAlbums = false;
}
}
const nothingFound = $derived( const nothingFound = $derived(
!!data.query && !!data.query &&
!data.ytError && !data.ytError &&
@@ -257,6 +292,14 @@
/> />
{/each} {/each}
</MediaGrid> </MediaGrid>
{#if ytAlbumsContinuation}
<div class="mt-6 flex justify-center">
<Button variant="secondary" onclick={loadMoreAlbums} loading={loadingAlbums}>
Ver más álbumes
</Button>
</div>
{/if}
</div> </div>
{/if} {/if}
@@ -0,0 +1,21 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const accessToken = requireAccessToken(locals, 'Inicia sesión para buscar.');
const query = url.searchParams.get('query')?.trim() ?? '';
if (!query) {
error(400, 'Falta el término de búsqueda.');
}
const continuation = url.searchParams.get('continuation') ?? undefined;
const api = createApiClient({ fetch, accessToken });
const result = await api.GET('/youtube/albums', {
params: { query: { query, continuation } }
});
return json(unwrapOrError(result, 'No se pudieron buscar álbumes en YouTube Music.'));
};