web-player: centralize server helpers (apiFor, authedAction, form readers) and unify page sizes
This commit is contained in:
@@ -8,6 +8,11 @@ export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
||||
export const PLAYLIST_TRACKS_PAGE_SIZE = 200;
|
||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
||||
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_LIMIT = 18;
|
||||
export const HOME_MIXES_LIMIT = 6;
|
||||
|
||||
@@ -242,17 +242,13 @@ class PlayerState {
|
||||
await audio.play();
|
||||
this.loading = false;
|
||||
if (track) this.#pushRecent(track);
|
||||
} catch (exception) {
|
||||
console.error('playback failed', exception);
|
||||
} catch (err) {
|
||||
console.error('playback failed', err);
|
||||
if (token !== this.#loadToken) return;
|
||||
this.loading = false;
|
||||
this.playing = false;
|
||||
if (exception instanceof DOMException && exception.name === 'AbortError') return;
|
||||
this.#fail(
|
||||
exception instanceof Error && !(exception instanceof DOMException)
|
||||
? exception.message
|
||||
: undefined
|
||||
);
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
this.#fail(err instanceof Error && !(err instanceof DOMException) ? err.message : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user