web-player: centralize server helpers (apiFor, authedAction, form readers) and unify page sizes

This commit is contained in:
2026-09-24 05:13:51 +02:00
parent 5b2a0e4d46
commit 2915737f03
29 changed files with 293 additions and 334 deletions
+5
View File
@@ -8,6 +8,11 @@ export const ALBUM_TRACKS_PAGE_SIZE = 200;
export const PLAYLIST_TRACKS_PAGE_SIZE = 200; export const PLAYLIST_TRACKS_PAGE_SIZE = 200;
export const ALBUM_EARLIEST_YEAR = 1877; export const ALBUM_EARLIEST_YEAR = 1877;
export const LIBRARY_PICKER_PAGE_SIZE = 50; export const LIBRARY_PICKER_PAGE_SIZE = 50;
export const LIBRARY_PAGE_SIZE = 50;
export const PLAYLISTS_PAGE_SIZE = 50;
export const PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE = 500;
export const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
export const COUNT_ONLY_PAGE_SIZE = 1;
export const EXPLORE_ALBUMS_PAGE_SIZE = 10; export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
export const EXPLORE_ALBUMS_LIMIT = 18; export const EXPLORE_ALBUMS_LIMIT = 18;
export const HOME_MIXES_LIMIT = 6; export const HOME_MIXES_LIMIT = 6;
+4 -8
View File
@@ -242,17 +242,13 @@ class PlayerState {
await audio.play(); await audio.play();
this.loading = false; this.loading = false;
if (track) this.#pushRecent(track); if (track) this.#pushRecent(track);
} catch (exception) { } catch (err) {
console.error('playback failed', exception); console.error('playback failed', err);
if (token !== this.#loadToken) return; if (token !== this.#loadToken) return;
this.loading = false; this.loading = false;
this.playing = false; this.playing = false;
if (exception instanceof DOMException && exception.name === 'AbortError') return; if (err instanceof DOMException && err.name === 'AbortError') return;
this.#fail( this.#fail(err instanceof Error && !(err instanceof DOMException) ? err.message : undefined);
exception instanceof Error && !(exception instanceof DOMException)
? exception.message
: undefined
);
} }
} }
+4 -3
View File
@@ -1,3 +1,4 @@
import { formString } from '$lib/server/api';
import { ALBUM_EARLIEST_YEAR } from '$lib/config'; import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface AlbumFormBody { interface AlbumFormBody {
@@ -7,17 +8,17 @@ interface AlbumFormBody {
} }
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } { export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
const title = String(form.get('title') ?? '').trim(); const title = formString(form, 'title').trim();
if (title === '' || title.length > 200) { if (title === '' || title.length > 200) {
return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' }; return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' };
} }
const description = String(form.get('description') ?? '').trim(); const description = formString(form, 'description').trim();
if (description.length > 256) { if (description.length > 256) {
return { failMessage: 'La descripción no puede pasar de 256 caracteres.' }; return { failMessage: 'La descripción no puede pasar de 256 caracteres.' };
} }
const rawYear = String(form.get('releaseYear') ?? '').trim(); const rawYear = formString(form, 'releaseYear').trim();
const latestYear = new Date().getFullYear() + 1; const latestYear = new Date().getFullYear() + 1;
let releaseYear: number | null = null; let releaseYear: number | null = null;
+58 -14
View File
@@ -1,5 +1,5 @@
import createClient from 'openapi-fetch'; import createClient from 'openapi-fetch';
import { error, fail, redirect, type ActionFailure } from '@sveltejs/kit'; import { error, fail, redirect, type ActionFailure, type RequestEvent } from '@sveltejs/kit';
import type { paths } from '$lib/api/schema'; import type { paths } from '$lib/api/schema';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import { apiConfig } from '$lib/server/config'; import { apiConfig } from '$lib/server/config';
@@ -17,9 +17,18 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
}); });
} }
export type ApiClient = ReturnType<typeof createApiClient>;
export function apiFor({ fetch, locals }: Pick<RequestEvent, 'fetch' | 'locals'>): ApiClient {
return createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
}
export function loginRedirect(url: URL): never {
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
}
export function requireUser(locals: App.Locals, url: URL): SessionUser { export function requireUser(locals: App.Locals, url: URL): SessionUser {
if (!locals.user) if (!locals.user) loginRedirect(url);
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
return locals.user; return locals.user;
} }
@@ -29,16 +38,42 @@ export function optionalUser(
allowAnonymousListening: boolean allowAnonymousListening: boolean
): SessionUser | null { ): SessionUser | null {
if (locals.user) return locals.user; if (locals.user) return locals.user;
if (!allowAnonymousListening) if (!allowAnonymousListening) loginRedirect(url);
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
return null; return null;
} }
export function requireAccessTokenAction( export function formString(form: FormData, key: string): string {
locals: App.Locals, return String(form.get(key) ?? '');
message = 'Inicia sesión.' }
): string | ActionFailure<{ message: string }> {
return locals.accessToken ?? fail(401, { message }); export function formFile(form: FormData, key: string): File | null {
const value = form.get(key);
return value instanceof File && value.size > 0 ? value : null;
}
interface ActionContext {
api: ApiClient;
form: FormData;
params: Record<string, string>;
locals: App.Locals;
url: URL;
}
export function authedAction<R>(
handler: (context: ActionContext) => R | Promise<R>,
unauthorizedMessage = 'Inicia sesión.'
) {
return async (event: RequestEvent) => {
if (!event.locals.accessToken) return fail(401, { message: unauthorizedMessage });
const form = await event.request.formData();
return handler({
api: apiFor(event),
form,
params: event.params as Record<string, string>,
locals: event.locals,
url: event.url
});
};
} }
export function unwrapOrError<T>( export function unwrapOrError<T>(
@@ -70,9 +105,18 @@ export function apiErrorDetail(error: unknown): string | undefined {
return undefined; return undefined;
} }
export function unwrapOrFail( type Failure = ActionFailure<{ message: string; detail?: string }>;
result: { error?: unknown },
message: string export function failOnError(result: { error?: unknown }, message: string): Failure | undefined {
): ActionFailure<{ message: string; detail?: string }> | undefined {
return result.error ? fail(502, { message, detail: apiErrorDetail(result.error) }) : undefined; return result.error ? fail(502, { message, detail: apiErrorDetail(result.error) }) : undefined;
} }
export function requireData<T>(
result: { data?: T; error?: unknown },
message: string
): { data: T; failure?: undefined } | { data?: undefined; failure: Failure } {
if (result.error || result.data === undefined) {
return { failure: fail(502, { message, detail: apiErrorDetail(result.error) }) };
}
return { data: result.data };
}
+4 -4
View File
@@ -36,9 +36,9 @@ export function getOidcConfig(): Promise<client.Configuration> {
return secret return secret
? client.discovery(issuer, authConfig.clientId, secret, undefined, options) ? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options); : client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
})().catch((exception) => { })().catch((err) => {
configPromise = undefined; configPromise = undefined;
throw exception; throw err;
}); });
return configPromise; return configPromise;
} }
@@ -151,8 +151,8 @@ export async function refreshSession(session: Session): Promise<Session | null>
idToken: tokens.id_token ?? session.idToken, idToken: tokens.id_token ?? session.idToken,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600) expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
}; };
} catch (exception) { } catch (err) {
console.error('Token refresh failed', exception); console.error('Token refresh failed', err);
return null; return null;
} }
} }
+19 -23
View File
@@ -1,28 +1,24 @@
import { fail, type RequestEvent } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api'; import { authedAction } from '$lib/server/api';
async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) { const setFollowing = (follow: boolean) =>
const accessToken = requireAccessTokenAction(locals); authedAction(async ({ api, params }) => {
if (typeof accessToken !== 'string') return accessToken; const id = params.id ?? '';
if (!id) return fail(400, { message: 'Falta el usuario.' });
const form = await request.formData(); const options = { params: { path: { id } } };
const id = String(form.get('userId') ?? '') || String(params.id ?? ''); const { error } = follow
if (!id) return fail(400, { message: 'Falta el usuario.' }); ? await api.POST('/users/{id}/follow', options)
: await api.DELETE('/users/{id}/follow', options);
const api = createApiClient({ fetch, accessToken }); if (error) {
const options = { params: { path: { id } } }; return fail(502, {
const { error } = follow message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
? await api.POST('/users/{id}/follow', options) });
: await api.DELETE('/users/{id}/follow', options); }
if (error) { return { following: follow, userId: id };
return fail(502, { });
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
});
}
return { following: follow, userId: id }; export const followUserAction = setFollowing(true);
} export const unfollowUserAction = setFollowing(false);
export const followUserAction = (event: RequestEvent) => setFollowing(event, true);
export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, false);
+1 -3
View File
@@ -1,8 +1,6 @@
import type { createApiClient } from '$lib/server/api'; import type { ApiClient } from '$lib/server/api';
import type { FollowList } from '$lib/types'; import type { FollowList } from '$lib/types';
type ApiClient = ReturnType<typeof createApiClient>;
export function fetchFollowList( export function fetchFollowList(
api: ApiClient, api: ApiClient,
list: FollowList, list: FollowList,
+17 -27
View File
@@ -1,26 +1,21 @@
import { fail, type RequestEvent } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api'; import { authedAction, formString } from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config'; import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) { export const addTrackAction = authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const playlistId = formString(form, 'playlistId') || (params.id ?? '');
if (typeof accessToken !== 'string') return accessToken; const trackId = formString(form, 'trackId');
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const trackId = String(form.get('trackId') ?? '');
if (!playlistId || !trackId) { if (!playlistId || !trackId) {
return fail(400, { message: 'Faltan datos de la canción.' }); return fail(400, { message: 'Faltan datos de la canción.' });
} }
const api = createApiClient({ fetch, accessToken }); const { error, response } = await api.POST('/playlists/{playlistId}/tracks', {
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
params: { path: { playlistId } }, params: { path: { playlistId } },
body: { trackId } body: { trackId }
}); });
if (err) { if (error) {
if (response?.status === 409) { if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', trackId }); return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
} }
@@ -28,37 +23,32 @@ export async function addTrackAction({ request, params, locals, fetch }: Request
} }
return { added: true, trackId, playlistId }; return { added: true, trackId, playlistId };
} });
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) { export const addAlbumToPlaylistAction = authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const playlistId = formString(form, 'playlistId') || (params.id ?? '');
if (typeof accessToken !== 'string') return accessToken; const albumId = formString(form, 'albumId');
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const albumId = String(form.get('albumId') ?? '');
if (!playlistId || !albumId) { if (!playlistId || !albumId) {
return fail(400, { message: 'Faltan datos del álbum.' }); return fail(400, { message: 'Faltan datos del álbum.' });
} }
const api = createApiClient({ fetch, accessToken }); const { data: tracks, error: tracksError } = await api.GET('/albums/{albumId}/tracks', {
const { data: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', {
params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } } params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } }
}); });
if (tracksErr || !tracks) { if (tracksError || !tracks) {
return fail(502, { message: 'No se pudo cargar el álbum.', albumId }); return fail(502, { message: 'No se pudo cargar el álbum.', albumId });
} }
let added = 0; let added = 0;
for (const track of tracks.items) { for (const track of tracks.items) {
const { error: err } = await api.POST('/playlists/{playlistId}/tracks', { const { error } = await api.POST('/playlists/{playlistId}/tracks', {
params: { path: { playlistId } }, params: { path: { playlistId } },
body: { trackId: String(track.id) } body: { trackId: track.id }
}); });
if (!err) added++; if (!error) added++;
} }
return { addedAlbum: true, albumId, playlistId, added }; return { addedAlbum: true, albumId, playlistId, added };
} });
@@ -1,11 +1,6 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api';
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { toAlbum, toPage } from '$lib/server/mappers'; import { toAlbum, toPage } from '$lib/server/mappers';
import { ALBUMS_PAGE_SIZE } from '$lib/config'; import { ALBUMS_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm'; import { parseAlbumForm } from '$lib/server/albumForm';
@@ -13,7 +8,7 @@ import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url); const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/albums/users/{userId}', { const result = await api.GET('/albums/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } } params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
@@ -25,20 +20,12 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
create: async ({ request, locals, fetch }) => { create: authedAction(async ({ api, form }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const parsed = parseAlbumForm(form); const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const cover = form.get('cover'); const cover = formFile(form, 'cover');
if (!(cover instanceof File) || cover.size === 0) { if (!cover) return fail(400, { message: 'La portada es obligatoria.' });
return fail(400, { message: 'La portada es obligatoria.' });
}
const api = createApiClient({ fetch, accessToken });
const uploaded = await uploadPresignedImage( const uploaded = await uploadPresignedImage(
(args) => api.POST('/albums/upload-picture', { body: args }), (args) => api.POST('/albums/upload-picture', { body: args }),
@@ -48,12 +35,12 @@ export const actions: Actions = {
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
} }
const { data, error: err } = await api.POST('/albums', { const { data, error } = await api.POST('/albums', {
body: { ...parsed.body, pictureIntentId: uploaded.intentId } body: { ...parsed.body, pictureIntentId: uploaded.intentId }
}); });
if (err || !data) return fail(502, { message: 'No se pudo crear el álbum.' }); if (error || !data) return fail(502, { message: 'No se pudo crear el álbum.' });
redirect(303, `/albums/${data.id}`); redirect(303, `/albums/${data.id}`);
} })
}; };
@@ -1,11 +1,13 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { import {
createApiClient, apiFor,
authedAction,
failOnError,
formFile,
formString,
optionalUser, optionalUser,
requireAccessTokenAction, unwrapOrError
unwrapOrError,
unwrapOrFail
} from '$lib/server/api'; } from '$lib/server/api';
import { toAlbum, toTrack } from '$lib/server/mappers'; import { toAlbum, toTrack } from '$lib/server/mappers';
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config'; import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
@@ -15,7 +17,7 @@ import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent(); const { allowAnonymousListening } = await parent();
const user = optionalUser(locals, url, allowAnonymousListening); const user = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const [albumRes, tracksRes, libraryRes] = await Promise.all([ const [albumRes, tracksRes, libraryRes] = await Promise.all([
api.GET('/albums/{id}', { params: { path: { id: params.id } } }), api.GET('/albums/{id}', { params: { path: { id: params.id } } }),
@@ -49,51 +51,33 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
}; };
export const actions: Actions = { export const actions: Actions = {
addTrack: async ({ request, params, locals, fetch }) => { addTrack: authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const trackId = formString(form, 'trackId');
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' }); if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.POST('/albums/{albumId}/tracks/{trackId}', { const result = await api.POST('/albums/{albumId}/tracks/{trackId}', {
params: { path: { albumId: params.id, trackId } } params: { path: { albumId: params.id, trackId } }
}); });
const failure = unwrapOrFail(result, 'No se pudo añadir la canción.'); return failOnError(result, 'No se pudo añadir la canción.') ?? { added: true };
if (failure) return failure; }),
return { added: true };
},
removeTrack: async ({ request, params, locals, fetch }) => { removeTrack: authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const trackId = formString(form, 'trackId');
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' }); if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', { const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', {
params: { path: { albumId: params.id, trackId } } params: { path: { albumId: params.id, trackId } }
}); });
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.'); return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
if (failure) return failure; }),
return { removed: true };
},
edit: async ({ request, params, locals, fetch }) => { edit: authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const parsed = parseAlbumForm(form); const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const api = createApiClient({ fetch, accessToken });
let newPictureIntentId: string | null = null; let newPictureIntentId: string | null = null;
const cover = form.get('cover'); const cover = formFile(form, 'cover');
if (cover instanceof File && cover.size > 0) { if (cover) {
const uploaded = await uploadPresignedImage( const uploaded = await uploadPresignedImage(
(args) => api.POST('/albums/upload-picture', { body: args }), (args) => api.POST('/albums/upload-picture', { body: args }),
cover cover
@@ -113,21 +97,15 @@ export const actions: Actions = {
newPictureIntentId newPictureIntentId
} }
}); });
const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.'); return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true };
if (failure) return failure; }),
return { edited: true };
},
delete: async ({ params, locals, fetch }) => { delete: authedAction(async ({ api, params }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/albums/{albumId}', { const result = await api.DELETE('/albums/{albumId}', {
params: { path: { albumId: params.id } } params: { path: { albumId: params.id } }
}); });
const failure = unwrapOrFail(result, 'No se pudo eliminar el álbum.'); const failure = failOnError(result, 'No se pudo eliminar el álbum.');
if (failure) return failure; if (failure) return failure;
redirect(303, '/albums'); redirect(303, '/albums');
} })
}; };
@@ -1,12 +1,8 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
import { toAlbum, toPage, toTrack } from '$lib/server/mappers'; import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions'; import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import { import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE,
EXPLORE_USERS_LIMIT
} from '$lib/config';
export const load: PageServerLoad = async ({ url, locals, fetch }) => { export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const query = url.searchParams.get('q')?.trim() ?? ''; const query = url.searchParams.get('q')?.trim() ?? '';
@@ -14,7 +10,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const page = Math.max(1, Number(url.searchParams.get('page')) || 1); const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
const user = locals.user; const user = locals.user;
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const genresRes = await api.GET('/genres'); const genresRes = await api.GET('/genres');
const genres = genresRes.data ?? []; const genres = genresRes.data ?? [];
@@ -26,7 +22,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
name: query || undefined, name: query || undefined,
genre: genre ?? undefined, genre: genre ?? undefined,
pageNumber: page, pageNumber: page,
pageSize: PAGE_SIZE pageSize: EXPLORE_PAGE_SIZE
} }
} }
}); });
@@ -2,20 +2,20 @@ import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { toDatedTrack, toPage } from '$lib/server/mappers'; import { toDatedTrack, toPage } from '$lib/server/mappers';
import { import {
createApiClient, apiFor,
requireAccessTokenAction, authedAction,
failOnError,
formString,
requireUser, requireUser,
unwrapOrError, unwrapOrError
unwrapOrFail
} from '$lib/server/api'; } from '$lib/server/api';
import { LIBRARY_PAGE_SIZE } from '$lib/config';
const PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url); const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/tracks/users/{userId}', { const result = await api.GET('/tracks/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } } params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
}); });
return { return {
@@ -24,18 +24,13 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
deleteTrack: async ({ request, locals, fetch }) => { deleteTrack: authedAction(async ({ api, form }) => {
const accessToken = requireAccessTokenAction(locals); const trackId = formString(form, 'trackId');
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' }); if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/tracks/{trackId}', { const result = await api.DELETE('/tracks/{trackId}', {
params: { path: { trackId } } params: { path: { trackId } }
}); });
const failure = unwrapOrFail(result, 'No se pudo borrar la canción.'); return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true };
if (failure) return failure; })
return { deleted: true };
}
}; };
@@ -1,11 +1,11 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; import { apiFor, requireUser, unwrapOrError } from '$lib/server/api';
import { toMix } from '$lib/server/mappers'; import { toMix } from '$lib/server/mappers';
import { addTrackAction } from '$lib/server/playlistActions'; import { addTrackAction } from '$lib/server/playlistActions';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
requireUser(locals, url); requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } }); const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } });
@@ -1,11 +1,14 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { import {
createApiClient, apiFor,
requireAccessTokenAction, authedAction,
formFile,
formString,
requireUser, requireUser,
unwrapOrError unwrapOrError
} from '$lib/server/api'; } from '$lib/server/api';
import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config';
import { toPlaylistSummary, toTrack } from '$lib/server/mappers'; import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
import { uploadPresignedImage } from '$lib/server/upload'; import { uploadPresignedImage } from '$lib/server/upload';
@@ -13,9 +16,9 @@ const MAX_NAME = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url); const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/playlists/users/{userId}', { const result = await api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } } params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PLAYLISTS_PAGE_SIZE } }
}); });
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.'); const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
@@ -24,7 +27,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
data.items.map((playlist) => data.items.map((playlist) =>
api api
.GET('/playlists/{playlistId}/tracks', { .GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } } params: {
path: { playlistId: playlist.id },
query: { pageNumber: 1, pageSize: PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE }
}
}) })
.then((res) => (res.data?.items ?? []).map(toTrack)) .then((res) => (res.data?.items ?? []).map(toTrack))
) )
@@ -44,15 +50,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
create: async ({ request, locals, fetch }) => { create: authedAction(async ({ api, form }) => {
const accessToken = requireAccessTokenAction(locals); const name = formString(form, 'name').trim();
if (typeof accessToken !== 'string') return accessToken; const description = formString(form, 'description').trim();
const visibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private';
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) { if (name === '' || name.length > MAX_NAME) {
return fail(400, { return fail(400, {
@@ -62,10 +63,9 @@ export const actions: Actions = {
}); });
} }
const api = createApiClient({ fetch, accessToken });
let pictureIntentId: string | null = null; let pictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) { const cover = formFile(form, 'cover');
if (cover) {
const result = await uploadPresignedImage( const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }), (args) => api.POST('/playlists/upload-picture', { body: args }),
cover cover
@@ -76,13 +76,13 @@ export const actions: Actions = {
pictureIntentId = result.intentId; pictureIntentId = result.intentId;
} }
const { data, error: err } = await api.POST('/playlists', { const { data, error } = await api.POST('/playlists', {
body: { name, description, pictureIntentId, visibility } body: { name, description, pictureIntentId, visibility }
}); });
if (err || !data) if (error || !data)
return fail(502, { message: 'No se pudo crear la playlist.', name, description }); return fail(502, { message: 'No se pudo crear la playlist.', name, description });
redirect(303, `/playlists/${data.id}`); redirect(303, `/playlists/${data.id}`);
} })
}; };
@@ -1,12 +1,15 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { import {
createApiClient, apiFor,
authedAction,
failOnError,
formFile,
formString,
optionalUser, optionalUser,
requireAccessTokenAction, unwrapOrError
unwrapOrError,
unwrapOrFail
} from '$lib/server/api'; } from '$lib/server/api';
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
import { toDatedTrack, toPlaylist } from '$lib/server/mappers'; import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
import { uploadPresignedImage } from '$lib/server/upload'; import { uploadPresignedImage } from '$lib/server/upload';
@@ -15,12 +18,15 @@ const MAX_NAME = 100;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent(); const { allowAnonymousListening } = await parent();
const user = optionalUser(locals, url, allowAnonymousListening); const user = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const [playlistRes, tracksRes] = await Promise.all([ const [playlistRes, tracksRes] = await Promise.all([
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }), api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/{playlistId}/tracks', { api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } } params: {
path: { playlistId: params.id },
query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE }
}
}) })
]); ]);
@@ -33,37 +39,27 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
}; };
export const actions: Actions = { export const actions: Actions = {
removeTrack: async ({ request, params, locals, fetch }) => { removeTrack: authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const trackId = formString(form, 'trackId');
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' }); if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', { const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId: params.id, trackId } } params: { path: { playlistId: params.id, trackId } }
}); });
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.'); return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
if (failure) return failure; }),
return { removed: true };
},
rename: async ({ request, params, locals, fetch }) => { edit: authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const name = formString(form, 'name').trim();
if (typeof accessToken !== 'string') return accessToken; const description = formString(form, 'description').trim();
const form = await request.formData(); const newVisibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private';
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const newVisibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) { if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' }); return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
} }
const api = createApiClient({ fetch, accessToken });
let newPictureIntentId: string | null = null; let newPictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) { const cover = formFile(form, 'cover');
if (cover) {
const result = await uploadPresignedImage( const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }), (args) => api.POST('/playlists/upload-picture', { body: args }),
cover cover
@@ -78,21 +74,15 @@ export const actions: Actions = {
params: { path: { playlistId: params.id } }, params: { path: { playlistId: params.id } },
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility } body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
}); });
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.'); return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true };
if (failure) return failure; }),
return { renamed: true };
},
delete: async ({ params, locals, fetch }) => { delete: authedAction(async ({ api, params }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}', { const result = await api.DELETE('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } } params: { path: { playlistId: params.id } }
}); });
const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.'); const failure = failOnError(result, 'No se pudo eliminar la playlist.');
if (failure) return failure; if (failure) return failure;
redirect(303, '/playlists'); redirect(303, '/playlists');
} })
}; };
@@ -106,7 +106,7 @@
<Modal open={editing} onClose={() => (editing = false)} title="Editar playlist"> <Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
<PlaylistForm <PlaylistForm
action="?/rename" action="?/edit"
initialName={playlist.name} initialName={playlist.name}
initialDescription={playlist.description ?? ''} initialDescription={playlist.description ?? ''}
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'} initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
@@ -1,10 +1,12 @@
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { import {
createApiClient, apiFor,
requireAccessTokenAction, authedAction,
requireUser, formFile,
unwrapOrFail formString,
requireData,
requireUser
} from '$lib/server/api'; } from '$lib/server/api';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
import { findConflict, genreInfo } from '$lib/data/genres'; import { findConflict, genreInfo } from '$lib/data/genres';
@@ -16,37 +18,28 @@ const MAX_TITLE = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url); requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const { data } = await api.GET('/genres/available'); const { data } = await api.GET('/genres/available');
return { genres: data ?? [] }; return { genres: data ?? [] };
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, locals, fetch }) => { default: authedAction(async ({ api, form }) => {
const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.'); const title = formString(form, 'title').trim();
if (typeof accessToken !== 'string') return accessToken; const audio = formFile(form, 'audio');
const cover = formFile(form, 'cover');
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
const audio = form.get('audio');
const cover = form.get('cover');
const rawTags = form.getAll('tags').map(String); const rawTags = form.getAll('tags').map(String);
const isExplicit = form.get('isExplicit') === 'on'; const isExplicit = form.get('isExplicit') === 'on';
if (title === '' || title.length > MAX_TITLE) { if (title === '' || title.length > MAX_TITLE) {
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' }); return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
} }
if (!(audio instanceof File) || audio.size === 0) { if (!audio) return fail(400, { message: 'Selecciona un archivo de audio.' });
return fail(400, { message: 'Selecciona un archivo de audio.' }); if (!cover) return fail(400, { message: 'Selecciona una portada.' });
}
if (!(cover instanceof File) || cover.size === 0) {
return fail(400, { message: 'Selecciona una portada.' });
}
if (rawTags.length === 0) { if (rawTags.length === 0) {
return fail(400, { message: 'Elige al menos un género.' }); return fail(400, { message: 'Elige al menos un género.' });
} }
const api = createApiClient({ fetch, accessToken });
const { data: available } = await api.GET('/genres/available'); const { data: available } = await api.GET('/genres/available');
const options = available ?? []; const options = available ?? [];
const known = new Set<string>(options.map((option) => option.genre)); const known = new Set<string>(options.map((option) => option.genre));
@@ -75,9 +68,11 @@ export const actions: Actions = {
} }
}); });
const urlsFailure = unwrapOrFail(urlsResult, 'No se pudieron reservar las URLs de subida.'); const { data: urls, failure: urlsFailure } = requireData(
urlsResult,
'No se pudieron reservar las URLs de subida.'
);
if (urlsFailure) return urlsFailure; if (urlsFailure) return urlsFailure;
const urls = urlsResult.data!;
try { try {
const [audioBuffer, coverBuffer] = await Promise.all([ const [audioBuffer, coverBuffer] = await Promise.all([
@@ -105,10 +100,12 @@ export const actions: Actions = {
} }
}); });
const trackFailure = unwrapOrFail(trackResult, 'No se pudo crear la pista tras la subida.'); const { data: track, failure: trackFailure } = requireData(
trackResult,
'No se pudo crear la pista tras la subida.'
);
if (trackFailure) return trackFailure; if (trackFailure) return trackFailure;
const track = trackResult.data!;
return { success: true, trackId: track.id, title: track.title }; return { success: true, trackId: track.id, title: track.title };
} }, 'Inicia sesión para subir música.')
}; };
@@ -1,14 +1,13 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api'; import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
import { toPlaylistSummary } from '$lib/server/mappers'; import { toPlaylistSummary } from '$lib/server/mappers';
import { COUNT_ONLY_PAGE_SIZE, PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions'; import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent(); const { allowAnonymousListening } = await parent();
const viewer = optionalUser(locals, url, allowAnonymousListening); const viewer = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const [profileRes, playlistsRes] = await Promise.all([ const [profileRes, playlistsRes] = await Promise.all([
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }), api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
@@ -27,7 +26,10 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
playlistItems.map((playlist) => playlistItems.map((playlist) =>
api api
.GET('/playlists/{playlistId}/tracks', { .GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 1 } } params: {
path: { playlistId: playlist.id },
query: { pageNumber: 1, pageSize: COUNT_ONLY_PAGE_SIZE }
}
}) })
.then((res) => Number(res.data?.totalItemCount ?? 0)) .then((res) => Number(res.data?.totalItemCount ?? 0))
) )
@@ -1,5 +1,5 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api'; import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
import { toPage } from '$lib/server/mappers'; import { toPage } from '$lib/server/mappers';
import { fetchFollowList } from '$lib/server/follows'; import { fetchFollowList } from '$lib/server/follows';
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
@@ -7,7 +7,7 @@ import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent(); const { allowAnonymousListening } = await parent();
const viewer = optionalUser(locals, url, allowAnonymousListening); const viewer = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const [profileRes, listRes] = await Promise.all([ const [profileRes, listRes] = await Promise.all([
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }), api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
+35 -51
View File
@@ -1,65 +1,21 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { getAllowAnonymousListening } from '$lib/server/playbackConfig'; import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
import { createApiClient } from '$lib/server/api'; import { apiFor, loginRedirect } from '$lib/server/api';
import { toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers'; import { toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers';
import { authConfig } from '$lib/server/config'; import { authConfig } from '$lib/server/config';
import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config'; import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
const PUBLIC_PATHS = new Set(['/auth']); const PUBLIC_PATHS = new Set(['/auth']);
async function fetchUserPlaylists( function orFallback<T>(load: Promise<T>, fallback: T): Promise<T> {
fetchFn: typeof fetch, return load.catch(() => fallback);
accessToken: string | null,
userId: string
) {
try {
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
const { data } = await api.GET('/playlists/users/{userId}', {
params: {
path: { userId },
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
}
});
return { items: (data?.items ?? []).map(toPlaylist), total: Number(data?.totalItemCount ?? 0) };
} catch {
return { items: [], total: 0 };
}
}
async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | null) {
try {
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
const { data } = await api.GET('/likes', {
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
});
return (data?.items ?? []).map(toLikedTrack);
} catch {
return [];
}
}
async function fetchLastPlayedTrack(
fetchFn: typeof fetch,
accessToken: string | null,
userId: string
) {
try {
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
const { data } = await api.GET('/users/{id}/last-listened-track', {
params: { path: { id: userId } }
});
return data ? toTrack(data) : null;
} catch {
return null;
}
} }
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
const allowAnonymousListening = await getAllowAnonymousListening(fetch); const allowAnonymousListening = await getAllowAnonymousListening(fetch);
if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) { if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) {
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`); loginRedirect(url);
} }
const accountUrl = `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`; const accountUrl = `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`;
@@ -76,10 +32,38 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
}; };
} }
const api = apiFor({ fetch, locals });
const userId = locals.user.sub;
const [userPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([ const [userPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([
fetchUserPlaylists(fetch, locals.accessToken, locals.user.sub), orFallback(
fetchLikedTracks(fetch, locals.accessToken), api
fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub) .GET('/playlists/users/{userId}', {
params: {
path: { userId },
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
}
})
.then(({ data }) => ({
items: (data?.items ?? []).map(toPlaylist),
total: Number(data?.totalItemCount ?? 0)
})),
{ items: [], total: 0 }
),
orFallback(
api
.GET('/likes', {
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
})
.then(({ data }) => (data?.items ?? []).map(toLikedTrack)),
[]
),
orFallback(
api
.GET('/users/{id}/last-listened-track', { params: { path: { id: userId } } })
.then(({ data }) => (data ? toTrack(data) : null)),
null
)
]); ]);
return { return {
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser } from '$lib/server/api'; import { apiFor, requireUser } from '$lib/server/api';
import { toMix, toPlaylist, toTrack } from '$lib/server/mappers'; import { toMix, toPlaylist, toTrack } from '$lib/server/mappers';
import { addTrackAction } from '$lib/server/playlistActions'; import { addTrackAction } from '$lib/server/playlistActions';
import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config'; import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config';
@@ -9,7 +9,7 @@ import { pickGreeting } from '$lib/server/greeting';
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
if (!locals.user) redirect(302, '/explore'); if (!locals.user) redirect(302, '/explore');
const user = requireUser(locals, url); const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const playlistsPromise = api.GET('/playlists/users/{userId}', { const playlistsPromise = api.GET('/playlists/users/{userId}', {
params: { params: {
@@ -1,11 +1,11 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
import { toPage, toTrack } from '$lib/server/mappers'; import { toPage, toTrack } from '$lib/server/mappers';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config'; import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, locals, fetch }) => { export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/albums/{albumId}/tracks', { const result = await api.GET('/albums/{albumId}/tracks', {
params: { params: {
path: { albumId: params.id }, path: { albumId: params.id },
+2 -2
View File
@@ -1,6 +1,6 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api'; import { apiFor } from '$lib/server/api';
export const POST: RequestHandler = async ({ request, locals, fetch }) => { export const POST: RequestHandler = async ({ request, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 }); if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
@@ -10,7 +10,7 @@ export const POST: RequestHandler = async ({ request, locals, fetch }) => {
if (typeof trackId !== 'string' || trackId === '') if (typeof trackId !== 'string' || trackId === '')
return json({ message: 'Canción inválida.' }, { status: 400 }); return json({ message: 'Canción inválida.' }, { status: 400 });
const api = createApiClient({ fetch, accessToken: locals.accessToken }); const api = apiFor({ fetch, locals });
const result = await api.POST('/likes/toggle', { body: { trackId } }); const result = await api.POST('/likes/toggle', { body: { trackId } });
if (result.error || result.data === undefined) if (result.error || result.data === undefined)
@@ -1,6 +1,6 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api'; import { apiFor } from '$lib/server/api';
export const PUT: RequestHandler = async ({ params, request, locals, fetch }) => { export const PUT: RequestHandler = async ({ params, request, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 }); if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
@@ -10,7 +10,7 @@ export const PUT: RequestHandler = async ({ params, request, locals, fetch }) =>
if (typeof playedSeconds !== 'number' || !Number.isFinite(playedSeconds) || playedSeconds < 0) if (typeof playedSeconds !== 'number' || !Number.isFinite(playedSeconds) || playedSeconds < 0)
return json({ message: 'Tiempo inválido.' }, { status: 400 }); return json({ message: 'Tiempo inválido.' }, { status: 400 });
const api = createApiClient({ fetch, accessToken: locals.accessToken }); const api = apiFor({ fetch, locals });
const result = await api.PUT('/tracks/listens/{listenId}/progress', { const result = await api.PUT('/tracks/listens/{listenId}/progress', {
params: { path: { listenId: params.id } }, params: { path: { listenId: params.id } },
body: { playedSeconds } body: { playedSeconds }
@@ -1,11 +1,11 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
import { toPage, toTrack } from '$lib/server/mappers'; import { toPage, toTrack } from '$lib/server/mappers';
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config'; import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, locals, fetch }) => { export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/playlists/{playlistId}/tracks', { const result = await api.GET('/playlists/{playlistId}/tracks', {
params: { params: {
path: { playlistId: params.id }, path: { playlistId: params.id },
+2 -2
View File
@@ -1,6 +1,6 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
import { toPage, toTrack } from '$lib/server/mappers'; import { toPage, toTrack } from '$lib/server/mappers';
import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config'; import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config';
@@ -13,7 +13,7 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => {
Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE) Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE)
); );
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/tracks', { const result = await api.GET('/tracks', {
params: { query: { name, genre, pageNumber, pageSize } } params: { query: { name, genre, pageNumber, pageSize } }
}); });
@@ -1,9 +1,9 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, locals, fetch }) => { export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/tracks/{id}/stream', { const result = await api.GET('/tracks/{id}/stream', {
params: { path: { id: params.id } } params: { path: { id: params.id } }
}); });
@@ -1,13 +1,13 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
import { toPage } from '$lib/server/mappers'; import { toPage } from '$lib/server/mappers';
import { fetchFollowList } from '$lib/server/follows'; import { fetchFollowList } from '$lib/server/follows';
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, url, locals, fetch }) => { export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await fetchFollowList( const result = await fetchFollowList(
api, api,
params.list, params.list,
@@ -34,8 +34,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
expectedNonce: nonce, expectedNonce: nonce,
idTokenExpected: true idTokenExpected: true
}); });
} catch (exception) { } catch (err) {
console.error('Token exchange failed', exception); console.error('Token exchange failed', err);
error(401, 'La autenticación ha fallado.'); error(401, 'La autenticación ha fallado.');
} }