web-player: share form limits between client and server and extract form parsers

This commit is contained in:
2026-09-24 05:19:41 +02:00
parent 2915737f03
commit 1df7f6ccf6
18 changed files with 239 additions and 119 deletions
@@ -3,7 +3,7 @@ import { fail, redirect } from '@sveltejs/kit';
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';
import { parseAlbumForm } from '$lib/server/forms/albumForm';
import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
@@ -11,8 +11,8 @@ import {
} from '$lib/server/api';
import { toAlbum, toTrack } from '$lib/server/mappers';
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm';
import { uploadPresignedImage } from '$lib/server/upload';
import { parseAlbumForm } from '$lib/server/forms/albumForm';
import { uploadOptionalCover } from '$lib/server/upload';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
@@ -75,17 +75,9 @@ export const actions: Actions = {
const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
let newPictureIntentId: string | null = null;
const cover = formFile(form, 'cover');
if (cover) {
const uploaded = await uploadPresignedImage(
(args) => api.POST('/albums/upload-picture', { body: args }),
cover
);
if ('failMessage' in uploaded) {
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
}
newPictureIntentId = uploaded.intentId;
const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover'));
if ('failMessage' in cover) {
return fail(502, { message: cover.failMessage, detail: cover.detail });
}
const result = await api.PUT('/albums/{albumId}', {
@@ -94,7 +86,7 @@ export const actions: Actions = {
newTitle: parsed.body.title,
newDescription: parsed.body.description,
newReleaseYear: parsed.body.releaseYear,
newPictureIntentId
newPictureIntentId: cover.intentId
}
});
return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true };
@@ -1,18 +1,10 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
apiFor,
authedAction,
formFile,
formString,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { apiFor, authedAction, 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';
const MAX_NAME = 100;
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
import { uploadOptionalCover } from '$lib/server/upload';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
@@ -51,33 +43,25 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
export const actions: Actions = {
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';
const parsed = parsePlaylistForm(form);
if ('failMessage' in parsed) {
const { failMessage: message, name, description } = parsed;
return fail(400, { message, name, description });
}
const { name, description, visibility, cover } = parsed.body;
if (name === '' || name.length > MAX_NAME) {
return fail(400, {
message: 'El nombre es obligatorio (máx. 100 caracteres).',
const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
if ('failMessage' in uploaded) {
return fail(502, {
message: uploaded.failMessage,
name,
description
description,
detail: uploaded.detail
});
}
let pictureIntentId: string | null = null;
const cover = formFile(form, 'cover');
if (cover) {
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
);
if ('failMessage' in result) {
return fail(502, { message: result.failMessage, name, description, detail: result.detail });
}
pictureIntentId = result.intentId;
}
const { data, error } = await api.POST('/playlists', {
body: { name, description, pictureIntentId, visibility }
body: { name, description, pictureIntentId: uploaded.intentId, visibility }
});
if (error || !data)
@@ -4,16 +4,14 @@ import {
apiFor,
authedAction,
failOnError,
formFile,
formString,
optionalUser,
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';
const MAX_NAME = 100;
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
import { uploadOptionalCover } from '$lib/server/upload';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
@@ -50,29 +48,23 @@ export const actions: Actions = {
}),
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 parsed = parsePlaylistForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const { name, description, visibility, cover } = parsed.body;
let newPictureIntentId: string | null = null;
const cover = formFile(form, 'cover');
if (cover) {
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
);
if ('failMessage' in result) {
return fail(502, { message: result.failMessage, detail: result.detail });
}
newPictureIntentId = result.intentId;
const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
if ('failMessage' in uploaded) {
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
}
const result = await api.PUT('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } },
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
body: {
newName: name,
newDescription: description,
newPictureIntentId: uploaded.intentId,
newVisibility: visibility
}
});
return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true };
}),
@@ -1,21 +1,13 @@
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import {
apiFor,
authedAction,
formFile,
formString,
requireData,
requireUser
} from '$lib/server/api';
import { apiFor, authedAction, requireData, requireUser } from '$lib/server/api';
import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
import { findConflict, genreInfo } from '$lib/data/genres';
import type { components } from '$lib/api/schema';
type Genre = components['schemas']['Genre'];
const MAX_TITLE = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url);
const api = apiFor({ fetch, locals });
@@ -25,28 +17,17 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
export const actions: Actions = {
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';
const parsed = parseTrackUploadForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body;
if (title === '' || title.length > MAX_TITLE) {
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
}
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 { data: available } = await api.GET('/genres/available');
const options = available ?? [];
const known = new Set<string>(options.map((option) => option.genre));
if (!rawTags.every((tag) => known.has(tag))) {
return fail(400, { message: 'Alguno de los géneros no es válido.' });
}
const tags = [...new Set(rawTags)] as Genre[];
const tags = rawTags as Genre[];
const conflict = findConflict(tags, options);
if (conflict) {
return fail(400, {
@@ -15,13 +15,13 @@
import Button from '$lib/components/ui/primitives/Button.svelte';
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
import Chip from '$lib/components/ui/primitives/Chip.svelte';
import { LIMITS } from '$lib/validation';
import { formatSize } from '$lib/utils/format';
import { genreOptions } from '$lib/data/genres';
type Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done';
const MAX_TITLE = 100;
let { data } = $props();
let title = $state('');
@@ -70,12 +70,6 @@
return s === 'idle' ? '' : 'bg-accent-tint';
}
function formatSize(bytes: number) {
if (bytes === 0) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
}
function pickAudio(file: File) {
audioName = file.name;
audioSize = file.size;
@@ -256,12 +250,12 @@
id="title"
name="title"
bind:value={title}
maxlength={MAX_TITLE}
maxlength={LIMITS.trackTitle}
placeholder="Nombre de la canción"
/>
{#snippet hint()}
<p>Se usa como nombre público de la pista en el catálogo.</p>
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
<span class="shrink-0 tabular-nums">{title.length}/{LIMITS.trackTitle}</span>
{/snippet}
</Field>