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
+4 -3
View File
@@ -1,3 +1,4 @@
import { formString } from '$lib/server/api';
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface AlbumFormBody {
@@ -7,17 +8,17 @@ interface AlbumFormBody {
}
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) {
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) {
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;
let releaseYear: number | null = null;
+58 -14
View File
@@ -1,5 +1,5 @@
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 { SessionUser } from '$lib/types';
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 {
if (!locals.user)
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
if (!locals.user) loginRedirect(url);
return locals.user;
}
@@ -29,16 +38,42 @@ export function optionalUser(
allowAnonymousListening: boolean
): SessionUser | null {
if (locals.user) return locals.user;
if (!allowAnonymousListening)
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
if (!allowAnonymousListening) loginRedirect(url);
return null;
}
export function requireAccessTokenAction(
locals: App.Locals,
message = 'Inicia sesión.'
): string | ActionFailure<{ message: string }> {
return locals.accessToken ?? fail(401, { message });
export function formString(form: FormData, key: string): string {
return String(form.get(key) ?? '');
}
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>(
@@ -70,9 +105,18 @@ export function apiErrorDetail(error: unknown): string | undefined {
return undefined;
}
export function unwrapOrFail(
result: { error?: unknown },
message: string
): ActionFailure<{ message: string; detail?: string }> | undefined {
type Failure = ActionFailure<{ message: string; detail?: string }>;
export function failOnError(result: { error?: unknown }, message: string): Failure | 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
? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
})().catch((exception) => {
})().catch((err) => {
configPromise = undefined;
throw exception;
throw err;
});
return configPromise;
}
@@ -151,8 +151,8 @@ export async function refreshSession(session: Session): Promise<Session | null>
idToken: tokens.id_token ?? session.idToken,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
};
} catch (exception) {
console.error('Token refresh failed', exception);
} catch (err) {
console.error('Token refresh failed', err);
return null;
}
}
+19 -23
View File
@@ -1,28 +1,24 @@
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
import { fail } from '@sveltejs/kit';
import { authedAction } from '$lib/server/api';
async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const setFollowing = (follow: boolean) =>
authedAction(async ({ api, params }) => {
const id = params.id ?? '';
if (!id) return fail(400, { message: 'Falta el usuario.' });
const form = await request.formData();
const id = String(form.get('userId') ?? '') || String(params.id ?? '');
if (!id) return fail(400, { message: 'Falta el usuario.' });
const options = { params: { path: { id } } };
const { error } = follow
? await api.POST('/users/{id}/follow', options)
: await api.DELETE('/users/{id}/follow', options);
const api = createApiClient({ fetch, accessToken });
const options = { params: { path: { id } } };
const { error } = follow
? await api.POST('/users/{id}/follow', options)
: await api.DELETE('/users/{id}/follow', options);
if (error) {
return fail(502, {
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
});
}
if (error) {
return fail(502, {
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
});
}
return { following: follow, userId: id };
});
return { following: follow, userId: id };
}
export const followUserAction = (event: RequestEvent) => setFollowing(event, true);
export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, false);
export const followUserAction = setFollowing(true);
export const unfollowUserAction = setFollowing(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';
type ApiClient = ReturnType<typeof createApiClient>;
export function fetchFollowList(
api: ApiClient,
list: FollowList,
+17 -27
View File
@@ -1,26 +1,21 @@
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
import { fail } from '@sveltejs/kit';
import { authedAction, formString } from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const trackId = String(form.get('trackId') ?? '');
export const addTrackAction = authedAction(async ({ api, form, params }) => {
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
const trackId = formString(form, 'trackId');
if (!playlistId || !trackId) {
return fail(400, { message: 'Faltan datos de la canción.' });
}
const api = createApiClient({ fetch, accessToken });
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
const { error, response } = await api.POST('/playlists/{playlistId}/tracks', {
params: { path: { playlistId } },
body: { trackId }
});
if (err) {
if (error) {
if (response?.status === 409) {
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 };
}
});
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const albumId = String(form.get('albumId') ?? '');
export const addAlbumToPlaylistAction = authedAction(async ({ api, form, params }) => {
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
const albumId = formString(form, 'albumId');
if (!playlistId || !albumId) {
return fail(400, { message: 'Faltan datos del álbum.' });
}
const api = createApiClient({ fetch, accessToken });
const { data: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', {
const { data: tracks, error: tracksError } = await api.GET('/albums/{albumId}/tracks', {
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 });
}
let added = 0;
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 } },
body: { trackId: String(track.id) }
body: { trackId: track.id }
});
if (!err) added++;
if (!error) added++;
}
return { addedAlbum: true, albumId, playlistId, added };
}
});