Upload from the library queue straight to storage and retire the upload page

This commit is contained in:
2026-09-25 01:51:20 +02:00
parent e19aba777d
commit 3eccf6dc53
18 changed files with 185 additions and 648 deletions
+1 -1
View File
@@ -108,6 +108,6 @@ Server helpers:
- `guards.ts` holds `requireUser`, `optionalUser` and `loginRedirect`. Keep them in pages and actions even though the `(app)` layout also redirects: page loads run in parallel with the layout load, and form actions skip it.
- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, `toCount`, `toListeningStats`, ...) turn DTOs into the domain types. Every load and `/api` endpoint goes through them, so `Number()` conversions on DTOs never reach routes or the client.
- `actions/` holds `authedAction(handler)` (returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler) plus the shared `follow` and `playlist` actions.
- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
- Page sizes come from `$lib/config`; do not hardcode them.
+1 -3
View File
@@ -17,7 +17,6 @@ src/routes/
new/ /library/albums/new Create an album
[id]/ /library/albums/:id Edit an album
uploads/ /library/uploads Upload queue
upload/ /upload Upload a song
albums/ /albums Your albums
[id]/ /albums/:id Album detail
playlists/ /playlists Your playlists
@@ -36,8 +35,7 @@ Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar
- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`.
- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist.
- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row.
- **Library `/library`, `/library/albums`, `/library/uploads`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads with a confirmed delete; Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue.
- **Upload `/upload`**: audio, cover, title, genres (incompatible genres are blocked) and explicit flag. The server reserves presigned URLs, uploads both files and creates the track; processing happens asynchronously in the backend.
- **Library `/library`, `/library/albums`, `/library/uploads`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads with a confirmed delete; Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue: the browser sends each file straight to storage through presigned URLs (`/api/uploads`, with progress), `/api/uploads/complete` creates the track unpublished and optionally adds it to an album, and `/api/uploads/status` follows the processing. Genres and cover apply to the whole queue and the queue survives tab changes. `/upload` redirects here.
- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library.
- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view.
- **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu.
+1
View File
@@ -16,6 +16,7 @@ const ARBITRARY_WHITELIST = [
/^py-\[\d+d?vh\]$/, // Explorar: centrado vertical de "sin resultados"
/^transition-\[filter\]$/, // Explorar: genre-tile hover
/^transition-\[transform,width\]$/, // Tabs: indicador deslizante
/^transition-\[width\]$/, // cola de subida: barra de progreso
/^bg-\[image:var\(--mf-[\w-]+\)\]$/
];
@@ -1,68 +1,42 @@
<script lang="ts">
import { genreLabels } from '$lib/data/genres';
import {
filesFromDrop,
readDuration,
summarizeQueue,
toQueuedUpload,
UPLOAD_FORMATS,
uploadLimitsLabel,
type QueuedUpload,
type Visibility
} from '$lib/data/uploadQueue';
import { filesFromDrop, UPLOAD_FORMATS, uploadLimitsLabel } from '$lib/data/uploadQueue';
import { uploadQueue as queue } from '$lib/state/uploadQueue.svelte';
import { plural } from '$lib/utils/format';
import type { GenreOption } from '$lib/types';
import FileDropzone from '$lib/components/ui/forms/FileDropzone.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import UploadQueueList from '$lib/components/music/library/UploadQueueList.svelte';
import UploadSettings from '$lib/components/music/library/UploadSettings.svelte';
interface Props {
genres: GenreOption[];
interface AlbumChoice {
id: string;
title: string;
}
let { genres }: Props = $props();
interface Props {
genres: GenreOption[];
albums: AlbumChoice[];
}
let { genres, albums }: Props = $props();
const ACCEPT = UPLOAD_FORMATS.map((ext) => `.${ext}`).join(',');
let queue = $state<QueuedUpload[]>([]);
let selectedGenres = $state<string[]>([]);
let visibility = $state<Visibility>('private');
let coauthors = $state<string[]>([]);
const single = $derived(queue.length === 1 ? queue[0] : null);
const summary = $derived(summarizeQueue(queue));
const genresLabel = $derived(genreLabels(selectedGenres));
const visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público');
const summary = $derived(queue.summary);
const genresLabel = $derived(genreLabels(queue.selectedGenres));
const queueMeta = $derived(
[
plural(summary.ready, 'lista', 'listas'),
summary.failed > 0 ? `${summary.failed} con error` : null
summary.ready > 0 ? plural(summary.ready, 'lista', 'listas') : null,
summary.uploading > 0 ? `${summary.uploading} subiendo` : null,
summary.processing > 0 ? `${summary.processing} procesando` : null,
summary.done > 0 ? plural(summary.done, 'terminada', 'terminadas') : null,
summary.errored + summary.failed > 0 ? `${summary.errored + summary.failed} con error` : null
]
.filter(Boolean)
.join(' · ')
);
function enqueue(files: File[]) {
const added = files.map(toQueuedUpload);
queue = [...queue, ...added];
for (const item of added) {
if (item.error) continue;
readDuration(item.file).then((duration) => {
queue = queue.map((current) =>
current.id === item.id ? { ...current, duration } : current
);
});
}
}
function setTitle(id: string, title: string) {
queue = queue.map((item) => (item.id === id ? { ...item, title } : item));
}
function remove(id: string) {
queue = queue.filter((item) => item.id !== id);
}
</script>
<div class="mt-6 grid items-start gap-6 lg:grid-cols-2">
@@ -72,17 +46,18 @@
title="Arrastra tus archivos aquí"
hint={uploadLimitsLabel()}
inputLabel="Elegir archivos de audio"
onfiles={enqueue}
onfiles={(files) => queue.enqueue(files)}
readDrop={filesFromDrop}
/>
<UploadSettings
{genres}
bind:selectedGenres
bind:visibility
bind:coauthors
bind:title={() => single?.title ?? '', (value) => single && setTitle(single.id, value)}
titleDisabled={single === null}
{albums}
bind:selectedGenres={queue.selectedGenres}
bind:albumId={queue.albumId}
hasCover={queue.cover !== null}
oncover={(file) => (queue.cover = file)}
disabled={queue.running}
/>
</div>
@@ -91,24 +66,40 @@
<div>
<h2 class="text-display-3 text-fg">Cola de subida</h2>
<p class="mt-1 text-xs text-fg-2">
{queue.length > 0 ? queueMeta : 'Todavía no hay archivos'}
{queue.items.length > 0 ? queueMeta : 'Todavía no hay archivos'}
</p>
</div>
<div class="flex items-center gap-3">
{#if queue.length > 0}
<Button variant="secondary" size="sm" onclick={() => (queue = [])}>Vaciar cola</Button>
{#if queue.items.length > 0}
<Button variant="secondary" size="sm" onclick={() => queue.clear()}>Vaciar cola</Button>
{/if}
<Button disabled>Añadir {summary.ready} a la biblioteca</Button>
<Button
disabled={summary.ready === 0}
loading={queue.running}
onclick={() => queue.start(genres)}
>
Subir {summary.ready}
</Button>
</div>
</div>
{#if queue.length > 0}
<p class="mt-3 text-xs text-fg-2">
Se añadirá sin publicar: publícala desde Canciones cuando termine de procesarse.
</p>
{#if queue.notice}
<Alert tone="danger" class="mt-4">{queue.notice}</Alert>
{/if}
{#if queue.items.length > 0}
<UploadQueueList
class="mt-4"
items={queue}
items={queue.items}
{genresLabel}
{visibilityLabel}
onRemove={remove}
onTitle={setTitle}
onRemove={(id) => queue.remove(id)}
onCancel={(id) => queue.cancel(id)}
onRetry={(id) => queue.retry(id)}
onTitle={(id, title) => queue.setTitle(id, title)}
/>
{:else}
<p class="mt-8 mb-6 text-center text-sm text-fg-2">
@@ -12,7 +12,6 @@
mobileLabel: true
},
{ id: 'genres', label: 'Géneros', width: COL.genresWide, class: TEXT },
{ id: 'access', label: 'Acceso', width: COL.access, class: TEXT },
{ id: 'size', label: 'Tamaño', width: COL.size, class: `${TEXT} tabular-nums` },
{
id: 'duration',
@@ -28,14 +27,16 @@
mobile: COL.statusMobile,
mobileLabel: true
},
{ id: 'remove', width: COL.action, mobile: COL.actionMobile }
{ id: 'actions', width: COL.actions, mobile: COL.actionsMobile }
];
</script>
<script lang="ts">
import Check from '@lucide/svelte/icons/check';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import X from '@lucide/svelte/icons/x';
import { fmtTime, formatSize } from '$lib/utils/format';
import type { QueuedUpload } from '$lib/data/uploadQueue';
import { isBusy, type QueuedUpload } from '$lib/data/uploadQueue';
import DataGrid from '$lib/components/ui/media/DataGrid.svelte';
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
@@ -43,8 +44,9 @@
interface Props {
items: QueuedUpload[];
genresLabel: string;
visibilityLabel: string;
onRemove: (id: string) => void;
onCancel: (id: string) => void;
onRetry: (id: string) => void;
onTitle: (id: string, title: string) => void;
class?: string;
}
@@ -52,16 +54,20 @@
let {
items,
genresLabel,
visibilityLabel,
onRemove,
onCancel,
onRetry,
onTitle,
class: klass = ''
}: Props = $props();
function editable(item: QueuedUpload): boolean {
return item.phase === 'queued' || item.phase === 'error';
}
function details(item: QueuedUpload): string {
return [
genresLabel,
visibilityLabel,
formatSize(item.file.size),
item.duration === null ? '' : fmtTime(item.duration)
]
@@ -89,17 +95,16 @@
<input
value={item.title}
oninput={(event) => onTitle(item.id, event.currentTarget.value)}
disabled={!editable(item)}
aria-label="Título de {item.file.name}"
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none"
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none disabled:hover:bg-transparent"
/>
<p class="truncate text-xs text-fg-2">{item.file.name}</p>
<p class="truncate text-xs text-fg-2">{item.note ?? item.file.name}</p>
<p class="truncate text-xs text-fg-2 sm:hidden">{details(item)}</p>
</div>
</div>
{:else if column.id === 'genres'}
{genresLabel}
{:else if column.id === 'access'}
{visibilityLabel}
{:else if column.id === 'size'}
{formatSize(item.file.size) || '-'}
{:else if column.id === 'duration'}
@@ -107,13 +112,58 @@
{:else if column.id === 'status'}
{#if item.error}
<StatusBadge label={item.error} tone="danger" />
{:else if item.phase === 'uploading'}
<div
class="flex items-center gap-2"
role="progressbar"
aria-label="Subiendo {item.file.name}"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(item.progress * 100)}
>
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-btn">
<div
class="h-full rounded-full bg-accent transition-[width]"
style="width:{item.progress * 100}%"
></div>
</div>
<span class="w-9 text-right text-xs text-fg-2 tabular-nums">
{Math.round(item.progress * 100)}%
</span>
</div>
{:else if item.phase === 'creating'}
<StatusBadge label="Creando" />
{:else if item.phase === 'processing'}
<StatusBadge label="Procesando" tone="neutral" pulse />
{:else if item.phase === 'done'}
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-accent-soft">
<Check class="animate-pop size-icon-sm" />
Lista
</span>
{:else if item.phase === 'error'}
<StatusBadge label={item.failure ?? 'Error'} tone="danger" />
{:else}
<StatusBadge label="Lista" tone="success" />
<StatusBadge label="En cola" />
{/if}
{:else}
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
<X class="size-icon-sm" />
</IconButton>
<div class="flex items-center justify-end">
{#if isBusy(item)}
<IconButton label="Cancelar subida" size="xs" onclick={() => onCancel(item.id)}>
<X class="size-icon-sm" />
</IconButton>
{:else}
{#if item.phase === 'error' && !item.error}
<IconButton label="Reintentar" size="xs" onclick={() => onRetry(item.id)}>
<RotateCcw class="size-icon-sm" />
</IconButton>
{/if}
{#if item.phase !== 'processing'}
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
<X class="size-icon-sm" />
</IconButton>
{/if}
{/if}
</div>
{/if}
{/snippet}
</DataGrid>
@@ -1,139 +1,68 @@
<script lang="ts" module>
import type { Visibility } from '$lib/data/uploadQueue';
const VISIBILITY: { value: Visibility; label: string }[] = [
{ value: 'private', label: 'Privado' },
{ value: 'public', label: 'Público' }
];
</script>
<script lang="ts">
import ImageIcon from '@lucide/svelte/icons/image';
import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
import type { GenreOption } from '$lib/types';
import { LIMITS } from '$lib/validation';
import GenrePicker from '$lib/components/music/GenrePicker.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte';
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
import Input from '$lib/components/ui/primitives/Input.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
interface AlbumChoice {
id: string;
title: string;
}
interface Props {
genres: GenreOption[];
albums: AlbumChoice[];
selectedGenres: string[];
visibility: Visibility;
coauthors: string[];
title: string;
titleDisabled: boolean;
albumId: string;
hasCover: boolean;
oncover: (file: File) => void;
disabled?: boolean;
class?: string;
}
let {
genres,
albums,
selectedGenres = $bindable(),
visibility = $bindable(),
coauthors = $bindable(),
title = $bindable(),
titleDisabled,
albumId = $bindable(),
hasCover,
oncover,
disabled = false,
class: klass = ''
}: Props = $props();
let artist = $state('');
let hasCover = $state(false);
function addArtist() {
const name = artist.trim();
if (name === '') return;
const known = coauthors.some((current) => current.toLowerCase() === name.toLowerCase());
if (!known) coauthors = [...coauthors, name];
artist = '';
}
function onArtistKey(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
event.preventDefault();
addArtist();
}
</script>
<aside class="flex flex-col gap-6 {klass}">
<p class="text-eyebrow text-fg-2">Aplicar a esta subida</p>
<p class="text-eyebrow text-fg-2">Aplicar a toda la cola</p>
<div class="grid gap-8 sm:grid-cols-[auto_1fr]">
<Field label="Portada">
<div class="w-full max-w-cover-fill">
<ImageDropzone
name="cover"
icon={ImageIcon}
gradient
size="fill"
onselect={() => (hasCover = true)}
/>
<ImageDropzone name="cover" icon={ImageIcon} gradient size="fill" onselect={oncover} />
</div>
{#snippet hint()}
<p>{hasCover ? 'Portada lista' : 'Haz clic para elegir una imagen'}</p>
<p>{hasCover ? 'Portada lista' : 'Opcional. Haz clic para elegir una imagen'}</p>
{/snippet}
</Field>
<div class="flex min-w-0 flex-col gap-7">
<GenrePicker {genres} bind:selected={selectedGenres} />
<Field label="Título" for="upload-title">
<Input
id="upload-title"
bind:value={title}
maxlength={LIMITS.trackTitle}
disabled={titleDisabled}
placeholder={titleDisabled
? 'Varios archivos · edita cada título en la cola'
: 'Nombre de la canción'}
/>
<Field label="Añadir a un álbum" for="upload-album">
<select id="upload-album" class="field-control px-4" bind:value={albumId} {disabled}>
<option value="">Ninguno</option>
{#each albums as album (album.id)}
<option value={album.id}>{album.title}</option>
{/each}
</select>
{#snippet hint()}
<p>
¿Otro álbum? <a class="text-fg underline" href="/library/albums/new">Crea uno nuevo</a>
y vuelve.
</p>
{/snippet}
</Field>
<div class="grid gap-7 md:grid-cols-2">
<Field label="Visibilidad">
<SegmentedControl options={VISIBILITY} bind:value={visibility} />
</Field>
<Field label="Artistas colaboradores" for="upload-coauthor">
<div class="flex items-center gap-2">
<Input
id="upload-coauthor"
bind:value={artist}
maxlength={LIMITS.trackTitle}
placeholder="Nombre del artista"
onkeydown={onArtistKey}
/>
<IconButton label="Añadir artista" surface onclick={addArtist}>
<Plus class="size-icon-sm" />
</IconButton>
</div>
{#if coauthors.length > 0}
<ul class="mt-3 flex flex-wrap gap-2">
{#each coauthors as name (name)}
<li
class="flex items-center gap-1 rounded-full bg-btn py-1 pr-1 pl-3.5 text-xs text-fg"
>
{name}
<IconButton
label="Quitar a {name}"
size="xs"
plain
onclick={() => (coauthors = coauthors.filter((current) => current !== name))}
>
<X class="size-icon-xs" />
</IconButton>
</li>
{/each}
</ul>
{/if}
{#snippet hint()}
<p>Aparecerán como coautores de cada canción.</p>
{/snippet}
</Field>
</div>
</div>
</div>
</aside>
@@ -63,7 +63,7 @@ export function appNavSections(
label: 'Gestionar música',
icon: Music,
inMobileTabs: false,
activeFor: ['/albums', '/upload']
activeFor: ['/albums']
}
]
}
@@ -1,36 +0,0 @@
import { describe, expect, it } from 'vitest';
import { LIMITS } from '$lib/validation';
import { parseTrackUploadForm } from './trackUploadForm';
function form(fields: Record<string, string | string[] | File>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) value.forEach((item) => data.append(key, item));
else data.set(key, value);
}
return data;
}
const audio = new File(['a'], 'a.mp3');
const cover = new File(['c'], 'c.png');
describe('parseTrackUploadForm', () => {
it('parses a valid form and deduplicates tags', () => {
const parsed = parseTrackUploadForm(
form({ title: ' Song ', audio, cover, tags: ['rock', 'rock', 'pop'], isExplicit: 'on' })
);
expect(parsed).toMatchObject({
body: { title: 'Song', tags: ['rock', 'pop'], isExplicit: true }
});
});
it('validates each field', () => {
const ok = { title: 'Song', audio, cover, tags: ['rock'] };
expect(parseTrackUploadForm(form({ ...ok, title: '' }))).toHaveProperty('failMessage');
expect(
parseTrackUploadForm(form({ ...ok, title: 'x'.repeat(LIMITS.trackTitle + 1) }))
).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, audio: '' }))).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, cover: '' }))).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, tags: [] }))).toHaveProperty('failMessage');
});
});
@@ -1,30 +0,0 @@
import { formFile, formString } from '$lib/server/forms/fields';
import { LIMITS } from '$lib/validation';
interface TrackUploadBody {
title: string;
audio: File;
cover: File;
tags: string[];
isExplicit: boolean;
}
export function parseTrackUploadForm(
form: FormData
): { body: TrackUploadBody } | { failMessage: string } {
const title = formString(form, 'title').trim();
if (title === '' || title.length > LIMITS.trackTitle) {
return { failMessage: `El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).` };
}
const audio = formFile(form, 'audio');
if (!audio) return { failMessage: 'Selecciona un archivo de audio.' };
const cover = formFile(form, 'cover');
if (!cover) return { failMessage: 'Selecciona una portada.' };
const tags = [...new Set(form.getAll('tags').map(String))];
if (tags.length === 0) return { failMessage: 'Elige al menos un género.' };
return { body: { title, audio, cover, tags, isExplicit: form.get('isExplicit') === 'on' } };
}
@@ -12,7 +12,6 @@ const LABELS: [RegExp, string][] = [
[/^\/library\/albums\/?$/, 'Volver a tus álbumes'],
[/^\/library\/uploads\/?$/, 'Volver a subir música'],
[/^\/library\/?$/, 'Volver a tus canciones'],
[/^\/upload\/?$/, 'Volver a subir música'],
[/^\/mixes\/[^/]+/, 'Volver a la mezcla']
];
+2 -1
View File
@@ -29,7 +29,8 @@ export const COL = {
status: '7rem',
statusQueue: '8.75rem',
statusMobile: '6rem',
access: '6rem',
actions: '4.75rem',
actionsMobile: '4rem',
size: '5.5rem',
durationQueue: '4.5rem'
} as const;
@@ -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>
+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' });
}
});
});