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:
Vendored
+60
@@ -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.'));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user