Merge direct upload queue

This commit is contained in:
2026-09-25 01:52:28 +02:00
30 changed files with 1491 additions and 654 deletions
@@ -17,6 +17,8 @@
import { trackNavigation } from '$lib/state/history.svelte';
import { restoreScroll } from '$lib/state/scroll';
import { page } from '$app/state';
import { beforeNavigate } from '$app/navigation';
import { uploadQueue } from '$lib/state/uploadQueue.svelte';
interface Props {
children: Snippet;
@@ -34,6 +36,9 @@
);
trackNavigation();
beforeNavigate((navigation) => {
if (navigation.willUnload && uploadQueue.active) navigation.cancel();
});
restoreScroll(() => scroller);
$effect(() => {
@@ -1,10 +1,17 @@
import type { PageServerLoad } from './$types';
import { apiFor } from '$lib/server/api';
import { loadUserAlbums } from '$lib/server/albums';
import { requireUser } from '$lib/server/guards';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url);
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
const genres = await api.GET('/genres/available');
return { genres: genres.data ?? [] };
const [genres, albums] = await Promise.all([
api.GET('/genres/available'),
loadUserAlbums(api, user.sub)
]);
return {
genres: genres.data ?? [],
albums: albums.items.map(({ id, title }) => ({ id, title }))
};
};
@@ -4,4 +4,4 @@
let { data } = $props();
</script>
<LibraryUploadsSection genres={data.genres} />
<LibraryUploadsSection genres={data.genres} albums={data.albums} />
@@ -1,96 +0,0 @@
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import { apiFor, requireData } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { authedAction } from '$lib/server/actions/authedAction';
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'];
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url);
const api = apiFor({ fetch, locals });
const { data } = await api.GET('/genres/available');
return { genres: data ?? [] };
};
export const actions: Actions = {
default: authedAction(async ({ api, form }) => {
const parsed = parseTrackUploadForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body;
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 = rawTags as Genre[];
const conflict = findConflict(tags, options);
if (conflict) {
return fail(400, {
message: `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.`
});
}
const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
const urlsResult = await api.POST('/tracks/upload-urls', {
body: {
pictureFileType: extOf(cover.name) || 'jpg',
pictureContentType,
audioFileType: extOf(audio.name) || 'mp3',
audioContentType,
expectedPictureSizeBytes: cover.size,
expectedAudioSizeBytes: audio.size
}
});
const { data: urls, failure: urlsFailure } = requireData(
urlsResult,
'No se pudieron reservar las URLs de subida.'
);
if (urlsFailure) return urlsFailure;
try {
const [audioBuffer, coverBuffer] = await Promise.all([
audio.arrayBuffer(),
cover.arrayBuffer()
]);
await Promise.all([
putPresigned(urls.audioUploadUrl, Buffer.from(audioBuffer), urls.audioContentType),
urls.pictureUploadUrl && urls.pictureContentType
? putPresigned(urls.pictureUploadUrl, Buffer.from(coverBuffer), urls.pictureContentType)
: Promise.resolve()
]);
} catch (err) {
return fail(502, {
message: 'Falló la subida del archivo al almacenamiento.',
detail: err instanceof Error ? err.message : undefined
});
}
const trackResult = await api.POST('/tracks', {
body: {
title,
pictureIntentId: urls.pictureIntentId,
audioIntentId: urls.audioIntentId,
tags,
isExplicit
}
});
const { data: track, failure: trackFailure } = requireData(
trackResult,
'No se pudo crear la pista tras la subida.'
);
if (trackFailure) return trackFailure;
return { success: true, trackId: track.id, title: track.title };
}, 'Inicia sesión para subir música.')
};
@@ -1,299 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Headphones from '@lucide/svelte/icons/headphones';
import Music from '@lucide/svelte/icons/music';
import ImageIcon from '@lucide/svelte/icons/image';
import Check from '@lucide/svelte/icons/check';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Surface from '$lib/components/ui/primitives/Surface.svelte';
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte';
import Input from '$lib/components/ui/primitives/Input.svelte';
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
import { LIMITS } from '$lib/validation';
import { formatSize } from '$lib/utils/format';
import GenrePicker from '$lib/components/music/GenrePicker.svelte';
type Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done';
let { data } = $props();
let title = $state('');
let audioName = $state('');
let audioSize = $state(0);
let hasCover = $state(false);
let coverResetToken = $state(0);
let dragging = $state(false);
let status = $state<Status>('idle');
let acceptedTerms = $state(false);
let isExplicit = $state(false);
let errorMsg = $state('');
let errorDetail = $state('');
let tags = $state<string[]>([]);
let publishedTitle = $state('');
let audioInput = $state<HTMLInputElement>();
const steps = [
{ key: 'upload', label: 'Subir', hint: 'Portada y audio.' },
{ key: 'done', label: 'Listo', hint: 'Se procesa y aparece en tu biblioteca.' }
];
const busy = $derived(status === 'uploading');
const canPublish = $derived(
title.trim() !== '' && audioName !== '' && hasCover && tags.length > 0 && acceptedTerms && !busy
);
const stepStates = $derived.by<StepState[]>(() => {
if (status === 'done') return ['done', 'done'];
if (status === 'uploading') return ['active', 'idle'];
return ['idle', 'idle'];
});
function stepClass(s: StepState) {
return s === 'idle' ? '' : 'bg-accent-tint';
}
function pickAudio(file: File) {
audioName = file.name;
audioSize = file.size;
if (title.trim() === '') title = file.name.replace(/\.[^.]+$/, '');
}
function onAudioInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) pickAudio(file);
}
function onDrop(event: DragEvent) {
event.preventDefault();
dragging = false;
const file = event.dataTransfer?.files?.[0];
if (!file || !audioInput) return;
const dt = new DataTransfer();
dt.items.add(file);
audioInput.files = dt.files;
pickAudio(file);
}
function reset() {
title = '';
audioName = '';
audioSize = 0;
hasCover = false;
coverResetToken += 1;
status = 'idle';
acceptedTerms = false;
isExplicit = false;
errorMsg = '';
errorDetail = '';
tags = [];
}
</script>
<svelte:head>
<title>Subir música</title>
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
</svelte:head>
<Page>
<PageHeader
title="Subir música"
description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
/>
<ol class="grid gap-3 sm:grid-cols-2 sm:gap-4">
{#each steps as step, i (step.key)}
<li>
<Surface padding="sm" class={stepClass(stepStates[i])}>
<div class="flex items-center gap-3">
<span
class="grid size-7 shrink-0 place-items-center rounded-full text-sm {stepStates[i] ===
'idle'
? 'bg-surface-2 text-fg-2'
: 'bg-accent-btn text-accent-soft'}"
>
{#if stepStates[i] === 'done'}
<Check class="size-icon-xs" />
{:else}
{i + 1}
{/if}
</span>
<span class="text-sm font-medium text-fg">{step.label}</span>
</div>
<p class="mt-2.5 text-xs text-fg-2">{step.hint}</p>
</Surface>
</li>
{/each}
</ol>
{#if status === 'done'}
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
<Check class="size-icon-2xl" />
</span>
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
<p class="mt-3 text-sm text-fg-2">
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
unos momentos.
</p>
<div class="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4">
<Button href="/library" size="lg" class="w-full sm:w-auto">Ir a la biblioteca</Button>
<Button variant="secondary" size="lg" onclick={reset} class="w-full sm:w-auto">
Subir otra
</Button>
</div>
</Surface>
{:else}
<form
method="POST"
enctype="multipart/form-data"
use:enhance={() => {
status = 'uploading';
errorMsg = '';
errorDetail = '';
return async ({ result }) => {
if (result.type === 'success') {
publishedTitle = title;
status = 'done';
} else if (result.type === 'failure') {
status = 'error';
errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.';
errorDetail = (result.data?.detail as string | undefined) ?? '';
} else {
status = 'error';
errorMsg = 'Error inesperado durante la subida.';
}
};
}}
class="mt-10 space-y-8"
>
<div
role="button"
tabindex="0"
ondragover={(e) => {
e.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
class="relative flex min-h-48 flex-col items-center justify-center rounded-panel p-6 text-center transition sm:min-h-64 sm:p-10 {dragging
? 'bg-surface-2 ring-2 ring-accent/50'
: 'bg-surface'}"
>
<input
bind:this={audioInput}
type="file"
name="audio"
accept="audio/*"
onchange={onAudioInput}
class="absolute inset-0 cursor-pointer opacity-0 focus:outline-none"
aria-label="Seleccionar archivo de audio"
/>
{#if audioName === ''}
<Headphones class="size-icon-xl text-fg-2 sm:size-icon-3xl" />
{:else}
<Music class="size-icon-xl text-accent-soft sm:size-icon-3xl" />
{/if}
{#if audioName === ''}
<p class="mt-4 text-sm font-medium text-fg">
Arrastra tu audio aquí o haz clic para elegir
</p>
<p class="mt-1.5 text-xs text-fg-2">MP3, FLAC, WAV…</p>
{:else}
<p class="mt-4 max-w-full truncate text-sm font-medium text-fg">
{audioName}
</p>
<p class="mt-1.5 text-xs text-fg-2">{formatSize(audioSize)} · Listo para subir</p>
{/if}
</div>
<Surface padding="md">
<div class="grid items-start gap-6 md:grid-cols-[auto_1fr] md:gap-10">
<Field label="Portada" class="items-center md:items-start">
{#key coverResetToken}
<ImageDropzone
name="cover"
icon={ImageIcon}
gradient
onselect={() => (hasCover = true)}
/>
{/key}
{#snippet hint()}
<p>{hasCover ? 'Portada lista' : 'Haz clic para elegir'}</p>
{/snippet}
</Field>
<div class="flex min-w-0 flex-col gap-7">
<Field label="Título" for="title">
<Input
id="title"
name="title"
bind:value={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}/{LIMITS.trackTitle}</span>
{/snippet}
</Field>
<GenrePicker
genres={data.genres}
bind:selected={tags}
name="tags"
help="Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient."
/>
<Checkbox bind:checked={isExplicit} name="isExplicit">
<span class="inline-flex items-center gap-2">
Contenido explícito
<ExplicitBadge />
</span>
</Checkbox>
</div>
</div>
</Surface>
<Checkbox bind:checked={acceptedTerms}>
Acepto los términos y condiciones al subir {title.trim() !== ''
? `«${title}»`
: 'esta canción'}.
</Checkbox>
{#if status === 'uploading'}
<Surface padding="sm">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-fg">Subiendo y creando la pista…</span>
<span class="text-xs text-fg-2">un momento</span>
</div>
<div class="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
</div>
</Surface>
{/if}
{#if status === 'error'}
<Alert tone="danger">
{errorMsg}
{#if errorDetail}<span class="mt-1 block text-xs opacity-80">{errorDetail}</span>{/if}
</Alert>
{/if}
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end sm:gap-4">
<Button href="/explore" variant="secondary" size="lg" class="w-full sm:w-auto">
Cancelar
</Button>
<Button type="submit" size="lg" disabled={!canPublish} class="w-full sm:w-auto">
Publicar
</Button>
</div>
</form>
{/if}
</Page>
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { apiFor } from '$lib/server/api';
import { UploadError, parsePresignRequest, presignTrackUploads } from '$lib/server/trackUploads';
export const POST: RequestHandler = async ({ request, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
try {
const parsed = parsePresignRequest(await request.json().catch(() => null));
return json(await presignTrackUploads(apiFor({ fetch, locals }), parsed));
} catch (err) {
if (err instanceof UploadError) return json({ message: err.message }, { status: err.status });
throw err;
}
};
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { apiFor } from '$lib/server/api';
import { UploadError, completeTrackUpload, parseCompleteRequest } from '$lib/server/trackUploads';
export const POST: RequestHandler = async ({ request, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
try {
const parsed = parseCompleteRequest(await request.json().catch(() => null));
return json(await completeTrackUpload(apiFor({ fetch, locals }), parsed));
} catch (err) {
if (err instanceof UploadError) return json({ message: err.message }, { status: err.status });
throw err;
}
};
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } }));
import { POST } from './+server';
import { POST as COMPLETE } from './complete/+server';
import { GET as STATUS } from './status/+server';
type PostEvent = Parameters<typeof POST>[0];
type CompleteEvent = Parameters<typeof COMPLETE>[0];
type StatusEvent = Parameters<typeof STATUS>[0];
function event(body: unknown, token: string | null = 'token') {
return {
request: new Request('http://x/api/uploads', { method: 'POST', body: JSON.stringify(body) }),
locals: { accessToken: token, user: null },
fetch: vi.fn()
};
}
describe('POST /api/uploads', () => {
it('requires a session', async () => {
const res = await POST(event({}, null) as unknown as PostEvent);
expect(res.status).toBe(401);
});
it('rejects an invalid batch', async () => {
const res = await POST(event({ files: [] }) as unknown as PostEvent);
expect(res.status).toBe(400);
});
it('proxies the presign request to the API', async () => {
const ev = event({ cover: null, files: [{ fileName: 'a.wav', contentType: '', size: 4 }] });
ev.fetch.mockResolvedValue(
Response.json({
pictureUploadUrl: null,
pictureContentType: null,
audios: [{ intentId: 'i', pictureIntentId: null, uploadUrl: 'u', contentType: 'audio/wav' }]
})
);
const res = await POST(ev as unknown as PostEvent);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ picture: null, audios: [{ intentId: 'i' }] });
});
});
describe('POST /api/uploads/complete', () => {
it('requires a session', async () => {
const res = await COMPLETE(event({}, null) as unknown as CompleteEvent);
expect(res.status).toBe(401);
});
it('rejects a missing title', async () => {
const res = await COMPLETE(
event({ title: '', audioIntentId: 'a', tags: ['Rock'] }) as unknown as CompleteEvent
);
expect(res.status).toBe(400);
});
});
describe('GET /api/uploads/status', () => {
it('requires a session', async () => {
const res = await STATUS({
url: new URL('http://x/api/uploads/status?ids=a'),
locals: { accessToken: null, user: null },
fetch: vi.fn()
} as unknown as StatusEvent);
expect(res.status).toBe(401);
});
it('rejects missing ids', async () => {
const res = await STATUS({
url: new URL('http://x/api/uploads/status'),
locals: { accessToken: 't', user: null },
fetch: vi.fn()
} as unknown as StatusEvent);
expect(res.status).toBe(400);
});
});
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { apiFor } from '$lib/server/api';
import { UploadError, parseStatusIds, trackProcessingStatus } from '$lib/server/trackUploads';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
try {
const ids = parseStatusIds(url.searchParams.get('ids'));
return json(await trackProcessingStatus(apiFor({ fetch, locals }), ids));
} catch (err) {
if (err instanceof UploadError) return json({ message: err.message }, { status: err.status });
throw err;
}
};
+4
View File
@@ -0,0 +1,4 @@
import type { RequestHandler } from './$types';
import { redirect } from '@sveltejs/kit';
export const GET: RequestHandler = () => redirect(308, '/library/uploads');
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { GET } from './+server';
describe('GET /upload', () => {
it('redirects permanently to the uploads section', () => {
try {
GET({} as Parameters<typeof GET>[0]);
expect.unreachable();
} catch (err) {
expect(err).toMatchObject({ status: 308, location: '/library/uploads' });
}
});
});