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 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;
+4 -8
View File
@@ -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);
}
}
+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 };
}
});
@@ -1,11 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api';
import { toAlbum, toPage } from '$lib/server/mappers';
import { ALBUMS_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm';
@@ -13,7 +8,7 @@ import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
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}', {
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 = {
create: async ({ request, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
create: authedAction(async ({ api, form }) => {
const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const cover = form.get('cover');
if (!(cover instanceof File) || cover.size === 0) {
return fail(400, { message: 'La portada es obligatoria.' });
}
const api = createApiClient({ fetch, accessToken });
const cover = formFile(form, 'cover');
if (!cover) return fail(400, { message: 'La portada es obligatoria.' });
const uploaded = await uploadPresignedImage(
(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 });
}
const { data, error: err } = await api.POST('/albums', {
const { data, error } = await api.POST('/albums', {
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}`);
}
})
};
@@ -1,11 +1,13 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
apiFor,
authedAction,
failOnError,
formFile,
formString,
optionalUser,
requireAccessTokenAction,
unwrapOrError,
unwrapOrFail
unwrapOrError
} from '$lib/server/api';
import { toAlbum, toTrack } from '$lib/server/mappers';
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 }) => {
const { allowAnonymousListening } = await parent();
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([
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 = {
addTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
addTrack: authedAction(async ({ api, form, params }) => {
const trackId = formString(form, 'trackId');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.POST('/albums/{albumId}/tracks/{trackId}', {
params: { path: { albumId: params.id, trackId } }
});
const failure = unwrapOrFail(result, 'No se pudo añadir la canción.');
if (failure) return failure;
return { added: true };
},
return failOnError(result, 'No se pudo añadir la canción.') ?? { added: true };
}),
removeTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
removeTrack: authedAction(async ({ api, form, params }) => {
const trackId = formString(form, 'trackId');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', {
params: { path: { albumId: params.id, trackId } }
});
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
if (failure) return failure;
return { removed: true };
},
return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
}),
edit: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
edit: authedAction(async ({ api, form, params }) => {
const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const api = createApiClient({ fetch, accessToken });
let newPictureIntentId: string | null = null;
const cover = form.get('cover');
if (cover instanceof File && cover.size > 0) {
const cover = formFile(form, 'cover');
if (cover) {
const uploaded = await uploadPresignedImage(
(args) => api.POST('/albums/upload-picture', { body: args }),
cover
@@ -113,21 +97,15 @@ export const actions: Actions = {
newPictureIntentId
}
});
const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.');
if (failure) return failure;
return { edited: true };
},
return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true };
}),
delete: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
delete: authedAction(async ({ api, params }) => {
const result = await api.DELETE('/albums/{albumId}', {
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;
redirect(303, '/albums');
}
})
};
@@ -1,12 +1,8 @@
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 { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import {
EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE,
EXPLORE_USERS_LIMIT
} from '$lib/config';
import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
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 user = locals.user;
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const api = apiFor({ fetch, locals });
const genresRes = await api.GET('/genres');
const genres = genresRes.data ?? [];
@@ -26,7 +22,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
name: query || undefined,
genre: genre ?? undefined,
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 { toDatedTrack, toPage } from '$lib/server/mappers';
import {
createApiClient,
requireAccessTokenAction,
apiFor,
authedAction,
failOnError,
formString,
requireUser,
unwrapOrError,
unwrapOrFail
unwrapOrError
} from '$lib/server/api';
const PAGE_SIZE = 50;
import { LIBRARY_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
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}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
});
return {
@@ -24,18 +24,13 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
};
export const actions: Actions = {
deleteTrack: async ({ request, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
deleteTrack: authedAction(async ({ api, form }) => {
const trackId = formString(form, 'trackId');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/tracks/{trackId}', {
params: { path: { trackId } }
});
const failure = unwrapOrFail(result, 'No se pudo borrar la canción.');
if (failure) return failure;
return { deleted: true };
}
return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true };
})
};
@@ -1,11 +1,11 @@
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 { addTrackAction } from '$lib/server/playlistActions';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
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 } } });
@@ -1,11 +1,14 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
requireAccessTokenAction,
apiFor,
authedAction,
formFile,
formString,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config';
import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
import { uploadPresignedImage } from '$lib/server/upload';
@@ -13,9 +16,9 @@ const MAX_NAME = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
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}', {
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.');
@@ -24,7 +27,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
data.items.map((playlist) =>
api
.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))
)
@@ -44,15 +50,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
};
export const actions: Actions = {
create: async ({ request, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
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');
create: authedAction(async ({ api, form }) => {
const name = formString(form, 'name').trim();
const description = formString(form, 'description').trim();
const visibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private';
if (name === '' || name.length > MAX_NAME) {
return fail(400, {
@@ -62,10 +63,9 @@ export const actions: Actions = {
});
}
const api = createApiClient({ fetch, accessToken });
let pictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
const cover = formFile(form, 'cover');
if (cover) {
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
@@ -76,13 +76,13 @@ export const actions: Actions = {
pictureIntentId = result.intentId;
}
const { data, error: err } = await api.POST('/playlists', {
const { data, error } = await api.POST('/playlists', {
body: { name, description, pictureIntentId, visibility }
});
if (err || !data)
if (error || !data)
return fail(502, { message: 'No se pudo crear la playlist.', name, description });
redirect(303, `/playlists/${data.id}`);
}
})
};
@@ -1,12 +1,15 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
apiFor,
authedAction,
failOnError,
formFile,
formString,
optionalUser,
requireAccessTokenAction,
unwrapOrError,
unwrapOrFail
unwrapOrError
} from '$lib/server/api';
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
import { uploadPresignedImage } from '$lib/server/upload';
@@ -15,12 +18,15 @@ const MAX_NAME = 100;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
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([
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
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 = {
removeTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
removeTrack: authedAction(async ({ api, form, params }) => {
const trackId = formString(form, 'trackId');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId: params.id, trackId } }
});
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
if (failure) return failure;
return { removed: true };
},
return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
}),
rename: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
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');
edit: authedAction(async ({ api, form, params }) => {
const name = formString(form, 'name').trim();
const description = formString(form, 'description').trim();
const newVisibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private';
if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
}
const api = createApiClient({ fetch, accessToken });
let newPictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
const cover = formFile(form, 'cover');
if (cover) {
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
@@ -78,21 +74,15 @@ export const actions: Actions = {
params: { path: { playlistId: params.id } },
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
});
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
if (failure) return failure;
return { renamed: true };
},
return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true };
}),
delete: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
delete: authedAction(async ({ api, params }) => {
const result = await api.DELETE('/playlists/{playlistId}', {
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;
redirect(303, '/playlists');
}
})
};
@@ -106,7 +106,7 @@
<Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
<PlaylistForm
action="?/rename"
action="?/edit"
initialName={playlist.name}
initialDescription={playlist.description ?? ''}
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
@@ -1,10 +1,12 @@
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrFail
apiFor,
authedAction,
formFile,
formString,
requireData,
requireUser
} from '$lib/server/api';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
import { findConflict, genreInfo } from '$lib/data/genres';
@@ -16,37 +18,28 @@ const MAX_TITLE = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const api = apiFor({ fetch, locals });
const { data } = await api.GET('/genres/available');
return { genres: data ?? [] };
};
export const actions: Actions = {
default: async ({ request, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.');
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
const audio = form.get('audio');
const cover = form.get('cover');
default: authedAction(async ({ api, form }) => {
const title = formString(form, 'title').trim();
const audio = formFile(form, 'audio');
const cover = formFile(form, 'cover');
const rawTags = form.getAll('tags').map(String);
const isExplicit = form.get('isExplicit') === 'on';
if (title === '' || title.length > MAX_TITLE) {
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
}
if (!(audio instanceof File) || audio.size === 0) {
return fail(400, { message: 'Selecciona un archivo de audio.' });
}
if (!(cover instanceof File) || cover.size === 0) {
return fail(400, { message: 'Selecciona una portada.' });
}
if (!audio) return fail(400, { message: 'Selecciona un archivo de audio.' });
if (!cover) return fail(400, { message: 'Selecciona una portada.' });
if (rawTags.length === 0) {
return fail(400, { message: 'Elige al menos un género.' });
}
const api = createApiClient({ fetch, accessToken });
const { data: available } = await api.GET('/genres/available');
const options = available ?? [];
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;
const urls = urlsResult.data!;
try {
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;
const track = trackResult.data!;
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 { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
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';
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
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([
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) =>
api
.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))
)
@@ -1,5 +1,5 @@
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 { fetchFollowList } from '$lib/server/follows';
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 }) => {
const { allowAnonymousListening } = await parent();
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([
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 { 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 { authConfig } from '$lib/server/config';
import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
const PUBLIC_PATHS = new Set(['/auth']);
async function fetchUserPlaylists(
fetchFn: typeof fetch,
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;
}
function orFallback<T>(load: Promise<T>, fallback: T): Promise<T> {
return load.catch(() => fallback);
}
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
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`;
@@ -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([
fetchUserPlaylists(fetch, locals.accessToken, locals.user.sub),
fetchLikedTracks(fetch, locals.accessToken),
fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub)
orFallback(
api
.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 {
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
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 { addTrackAction } from '$lib/server/playlistActions';
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 }) => {
if (!locals.user) redirect(302, '/explore');
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}', {
params: {
@@ -1,11 +1,11 @@
import type { RequestHandler } from './$types';
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 { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
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', {
params: {
path: { albumId: params.id },
+2 -2
View File
@@ -1,6 +1,6 @@
import type { RequestHandler } from './$types';
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 }) => {
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 === '')
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 } });
if (result.error || result.data === undefined)
@@ -1,6 +1,6 @@
import type { RequestHandler } from './$types';
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 }) => {
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)
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', {
params: { path: { listenId: params.id } },
body: { playedSeconds }
@@ -1,11 +1,11 @@
import type { RequestHandler } from './$types';
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 { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
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', {
params: {
path: { playlistId: params.id },
+2 -2
View File
@@ -1,6 +1,6 @@
import type { RequestHandler } from './$types';
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 { 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)
);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const api = apiFor({ fetch, locals });
const result = await api.GET('/tracks', {
params: { query: { name, genre, pageNumber, pageSize } }
});
@@ -1,9 +1,9 @@
import type { RequestHandler } from './$types';
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 }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const api = apiFor({ fetch, locals });
const result = await api.GET('/tracks/{id}/stream', {
params: { path: { id: params.id } }
});
@@ -1,13 +1,13 @@
import type { RequestHandler } from './$types';
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 { fetchFollowList } from '$lib/server/follows';
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
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(
api,
params.list,
@@ -34,8 +34,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
expectedNonce: nonce,
idTokenExpected: true
});
} catch (exception) {
console.error('Token exchange failed', exception);
} catch (err) {
console.error('Token exchange failed', err);
error(401, 'La autenticación ha fallado.');
}