Merge direct upload queue
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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}
|
||||
</ul>
|
||||
{/if}
|
||||
</select>
|
||||
{#snippet hint()}
|
||||
<p>Aparecerán como coautores de cada canción.</p>
|
||||
<p>
|
||||
¿Otro álbum? <a class="text-fg underline" href="/library/albums/new">Crea uno nuevo</a>
|
||||
y vuelve.
|
||||
</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']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface PresignFile {
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface PresignRequest {
|
||||
cover: PresignFile | null;
|
||||
files: PresignFile[];
|
||||
}
|
||||
|
||||
export interface PresignTarget {
|
||||
uploadUrl: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export interface PresignedAudio extends PresignTarget {
|
||||
intentId: string;
|
||||
pictureIntentId: string | null;
|
||||
}
|
||||
|
||||
export interface PresignResponse {
|
||||
picture: PresignTarget | null;
|
||||
audios: PresignedAudio[];
|
||||
}
|
||||
|
||||
export interface CompleteRequest {
|
||||
title: string;
|
||||
audioIntentId: string;
|
||||
pictureIntentId: string | null;
|
||||
tags: string[];
|
||||
albumId: string | null;
|
||||
}
|
||||
|
||||
export interface CompleteResponse {
|
||||
trackId: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export type TrackProcessing = 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||
|
||||
export type StatusResponse = Record<string, TrackProcessing>;
|
||||
@@ -1,5 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
beginUpload,
|
||||
chunkStartable,
|
||||
failUpload,
|
||||
finishCreate,
|
||||
finishProcessing,
|
||||
finishUpload,
|
||||
genreConflictMessage,
|
||||
hasActiveUploads,
|
||||
reportProgress,
|
||||
retryUpload,
|
||||
summarizeQueue,
|
||||
titleFromFileName,
|
||||
toQueuedUpload,
|
||||
@@ -41,7 +51,14 @@ describe('uploadError', () => {
|
||||
describe('summarizeQueue', () => {
|
||||
it('counts ready and failed items', () => {
|
||||
const items = [file('a.wav'), file('b.mp3'), file('c.psd')].map(toQueuedUpload);
|
||||
expect(summarizeQueue(items)).toEqual({ ready: 2, failed: 1 });
|
||||
expect(summarizeQueue(items)).toEqual({
|
||||
ready: 2,
|
||||
failed: 1,
|
||||
uploading: 0,
|
||||
processing: 0,
|
||||
done: 0,
|
||||
errored: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,3 +69,94 @@ describe('uploadLimitsLabel', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload state machine', () => {
|
||||
const fresh = () => toQueuedUpload(file('a.wav'));
|
||||
|
||||
it('walks queued to done', () => {
|
||||
let item = fresh();
|
||||
expect(item.phase).toBe('queued');
|
||||
item = beginUpload(item);
|
||||
expect(item.phase).toBe('uploading');
|
||||
item = reportProgress(item, 0.4);
|
||||
expect(item.progress).toBe(0.4);
|
||||
item = finishUpload(item);
|
||||
expect(item).toMatchObject({ phase: 'creating', progress: 1 });
|
||||
item = finishCreate(item, 't1');
|
||||
expect(item).toMatchObject({ phase: 'processing', trackId: 't1' });
|
||||
item = finishProcessing(item);
|
||||
expect(item.phase).toBe('done');
|
||||
});
|
||||
|
||||
it('ignores transitions from the wrong phase', () => {
|
||||
const item = fresh();
|
||||
expect(finishUpload(item)).toBe(item);
|
||||
expect(finishCreate(item, 't1')).toBe(item);
|
||||
expect(finishProcessing(item)).toBe(item);
|
||||
expect(failUpload(item, 'x')).toBe(item);
|
||||
expect(retryUpload(item)).toBe(item);
|
||||
});
|
||||
|
||||
it('never starts an invalid file', () => {
|
||||
const item = toQueuedUpload(file('a.psd'));
|
||||
expect(beginUpload(item)).toBe(item);
|
||||
});
|
||||
|
||||
it('keeps progress monotonic and capped at 1', () => {
|
||||
let item = beginUpload(fresh());
|
||||
item = reportProgress(item, 0.6);
|
||||
expect(reportProgress(item, 0.3)).toBe(item);
|
||||
expect(reportProgress(item, 4).progress).toBe(1);
|
||||
});
|
||||
|
||||
it('fails and retries', () => {
|
||||
let item = beginUpload(fresh());
|
||||
item = failUpload(item, 'Cancelada');
|
||||
expect(item).toMatchObject({ phase: 'error', failure: 'Cancelada' });
|
||||
item = retryUpload(item);
|
||||
expect(item).toMatchObject({ phase: 'queued', progress: 0, failure: null });
|
||||
});
|
||||
|
||||
it('carries a note when the album step fails', () => {
|
||||
const item = finishCreate(finishUpload(beginUpload(fresh())), 't1', 'Sin álbum');
|
||||
expect(item.note).toBe('Sin álbum');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chunkStartable', () => {
|
||||
it('splits startable items into batches and skips the rest', () => {
|
||||
const items = ['a.wav', 'b.wav', 'c.wav', 'd.psd'].map((name) => toQueuedUpload(file(name)));
|
||||
items[1] = beginUpload(items[1]);
|
||||
const chunks = chunkStartable(items, 1);
|
||||
expect(chunks.map((chunk) => chunk.map((item) => item.file.name))).toEqual([
|
||||
['a.wav'],
|
||||
['c.wav']
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveUploads', () => {
|
||||
it('is true while uploading, creating or processing', () => {
|
||||
const item = toQueuedUpload(file('a.wav'));
|
||||
expect(hasActiveUploads([item])).toBe(false);
|
||||
expect(hasActiveUploads([beginUpload(item)])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('genreConflictMessage', () => {
|
||||
const options = [
|
||||
{ genre: 'Rock', incompatibleWith: ['Classical'] },
|
||||
{ genre: 'Classical', incompatibleWith: ['Rock'] },
|
||||
{ genre: 'Jazz', incompatibleWith: [] }
|
||||
];
|
||||
|
||||
it('names the conflicting genres', () => {
|
||||
expect(genreConflictMessage(['Rock', 'Classical'], options)).toBe(
|
||||
'Los géneros Rock y Clásica no se pueden combinar.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns nothing when compatible', () => {
|
||||
expect(genreConflictMessage(['Rock', 'Jazz'], options)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { findConflict, genreInfo } from '$lib/data/genres';
|
||||
import type { GenreOption } from '$lib/types';
|
||||
|
||||
export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||
export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
|
||||
|
||||
@@ -9,7 +12,10 @@ export function uploadLimitsLabel(): string {
|
||||
return `${list} · hasta ${UPLOAD_MAX_MB} MB por archivo · puedes soltar carpetas enteras`;
|
||||
}
|
||||
|
||||
export type Visibility = 'private' | 'public';
|
||||
export const UPLOAD_CONCURRENCY = 3;
|
||||
export const UPLOAD_BATCH_SIZE = 20;
|
||||
|
||||
export type UploadPhase = 'queued' | 'uploading' | 'creating' | 'processing' | 'done' | 'error';
|
||||
|
||||
export interface QueuedUpload {
|
||||
id: string;
|
||||
@@ -18,6 +24,11 @@ export interface QueuedUpload {
|
||||
format: string;
|
||||
error: string | null;
|
||||
duration: number | null;
|
||||
phase: UploadPhase;
|
||||
progress: number;
|
||||
failure: string | null;
|
||||
trackId: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export function fileExtension(name: string): string {
|
||||
@@ -50,13 +61,102 @@ export function toQueuedUpload(file: File): QueuedUpload {
|
||||
title: titleFromFileName(file.name),
|
||||
format: fileExtension(file.name).toUpperCase() || '?',
|
||||
error: uploadError(file),
|
||||
duration: null
|
||||
duration: null,
|
||||
phase: 'queued',
|
||||
progress: 0,
|
||||
failure: null,
|
||||
trackId: null,
|
||||
note: null
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeQueue(items: QueuedUpload[]): { ready: number; failed: number } {
|
||||
const failed = items.filter((item) => item.error !== null).length;
|
||||
return { ready: items.length - failed, failed };
|
||||
export function isStartable(item: QueuedUpload): boolean {
|
||||
return item.error === null && item.phase === 'queued';
|
||||
}
|
||||
|
||||
export function isBusy(item: QueuedUpload): boolean {
|
||||
return item.phase === 'uploading' || item.phase === 'creating';
|
||||
}
|
||||
|
||||
export function isActive(item: QueuedUpload): boolean {
|
||||
return isBusy(item) || item.phase === 'processing';
|
||||
}
|
||||
|
||||
export function beginUpload(item: QueuedUpload): QueuedUpload {
|
||||
if (!isStartable(item)) return item;
|
||||
return { ...item, phase: 'uploading', progress: 0, failure: null };
|
||||
}
|
||||
|
||||
export function reportProgress(item: QueuedUpload, fraction: number): QueuedUpload {
|
||||
if (item.phase !== 'uploading') return item;
|
||||
const progress = Math.min(1, Math.max(item.progress, fraction));
|
||||
return progress === item.progress ? item : { ...item, progress };
|
||||
}
|
||||
|
||||
export function finishUpload(item: QueuedUpload): QueuedUpload {
|
||||
if (item.phase !== 'uploading') return item;
|
||||
return { ...item, phase: 'creating', progress: 1 };
|
||||
}
|
||||
|
||||
export function finishCreate(
|
||||
item: QueuedUpload,
|
||||
trackId: string,
|
||||
note: string | null = null
|
||||
): QueuedUpload {
|
||||
if (item.phase !== 'creating') return item;
|
||||
return { ...item, phase: 'processing', trackId, note };
|
||||
}
|
||||
|
||||
export function finishProcessing(item: QueuedUpload): QueuedUpload {
|
||||
if (item.phase !== 'processing') return item;
|
||||
return { ...item, phase: 'done' };
|
||||
}
|
||||
|
||||
export function failUpload(item: QueuedUpload, reason: string): QueuedUpload {
|
||||
if (!isActive(item)) return item;
|
||||
return { ...item, phase: 'error', failure: reason };
|
||||
}
|
||||
|
||||
export function retryUpload(item: QueuedUpload): QueuedUpload {
|
||||
if (item.phase !== 'error') return item;
|
||||
return { ...item, phase: 'queued', progress: 0, failure: null, trackId: null, note: null };
|
||||
}
|
||||
|
||||
export function genreConflictMessage(tags: string[], options: GenreOption[]): string | undefined {
|
||||
const conflict = findConflict(tags, options);
|
||||
if (!conflict) return undefined;
|
||||
return `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.`;
|
||||
}
|
||||
|
||||
export function chunkStartable(items: QueuedUpload[], size = UPLOAD_BATCH_SIZE): QueuedUpload[][] {
|
||||
const startable = items.filter(isStartable);
|
||||
const chunks: QueuedUpload[][] = [];
|
||||
for (let i = 0; i < startable.length; i += size) chunks.push(startable.slice(i, i + size));
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function hasActiveUploads(items: QueuedUpload[]): boolean {
|
||||
return items.some(isActive);
|
||||
}
|
||||
|
||||
export function summarizeQueue(items: QueuedUpload[]): {
|
||||
ready: number;
|
||||
failed: number;
|
||||
uploading: number;
|
||||
processing: number;
|
||||
done: number;
|
||||
errored: number;
|
||||
} {
|
||||
const invalid = items.filter((item) => item.error !== null).length;
|
||||
const valid = items.filter((item) => item.error === null);
|
||||
return {
|
||||
ready: valid.filter((item) => item.phase === 'queued').length,
|
||||
failed: invalid,
|
||||
uploading: valid.filter(isBusy).length,
|
||||
processing: valid.filter((item) => item.phase === 'processing').length,
|
||||
done: valid.filter((item) => item.phase === 'done').length,
|
||||
errored: valid.filter((item) => item.phase === 'error').length
|
||||
};
|
||||
}
|
||||
|
||||
async function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
||||
|
||||
@@ -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' } };
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ApiClient } from '$lib/server/api';
|
||||
import {
|
||||
UploadError,
|
||||
completeTrackUpload,
|
||||
parseCompleteRequest,
|
||||
parsePresignRequest,
|
||||
parseStatusIds,
|
||||
presignTrackUploads,
|
||||
trackProcessingStatus
|
||||
} from '$lib/server/trackUploads';
|
||||
|
||||
const file = { fileName: 'a.wav', contentType: '', size: 10 };
|
||||
|
||||
function api(handlers: {
|
||||
get?: (path: string) => unknown;
|
||||
post?: (path: string, init: unknown) => unknown;
|
||||
}) {
|
||||
return {
|
||||
GET: vi.fn(async (path: string) => handlers.get?.(path) ?? {}),
|
||||
POST: vi.fn(async (path: string, init: unknown) => handlers.post?.(path, init) ?? {})
|
||||
};
|
||||
}
|
||||
|
||||
const asApi = (fake: unknown) => fake as ApiClient;
|
||||
|
||||
describe('parsePresignRequest', () => {
|
||||
it('accepts up to the batch size', () => {
|
||||
expect(parsePresignRequest({ cover: null, files: [file] }).files).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects empty, oversized and malformed batches', () => {
|
||||
expect(() => parsePresignRequest({ files: [] })).toThrow(UploadError);
|
||||
expect(() => parsePresignRequest({ files: Array(21).fill(file) })).toThrow(UploadError);
|
||||
expect(() => parsePresignRequest({ files: [{ fileName: 'a' }] })).toThrow(UploadError);
|
||||
expect(() => parsePresignRequest({ files: [{ ...file, size: 0 }] })).toThrow(UploadError);
|
||||
expect(() => parsePresignRequest(null)).toThrow(UploadError);
|
||||
});
|
||||
|
||||
it('rejects a malformed cover', () => {
|
||||
expect(() => parsePresignRequest({ cover: 3, files: [file] })).toThrow(UploadError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('presignTrackUploads', () => {
|
||||
it('sends every audio and the shared cover, and maps the intents', async () => {
|
||||
const fake = api({
|
||||
post: () => ({
|
||||
data: {
|
||||
pictureUploadUrl: 'https://s3/pic',
|
||||
pictureContentType: 'image/png',
|
||||
audios: [
|
||||
{
|
||||
intentId: 'i1',
|
||||
pictureIntentId: 'p1',
|
||||
uploadUrl: 'https://s3/a',
|
||||
contentType: 'audio/wav'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
});
|
||||
const result = await presignTrackUploads(asApi(fake), {
|
||||
cover: { fileName: 'c.png', contentType: 'image/png', size: 5 },
|
||||
files: [file]
|
||||
});
|
||||
expect(result.picture).toEqual({ uploadUrl: 'https://s3/pic', contentType: 'image/png' });
|
||||
expect(result.audios[0]).toMatchObject({ intentId: 'i1', pictureIntentId: 'p1' });
|
||||
const body = fake.POST.mock.calls[0][1] as { body: Record<string, unknown> };
|
||||
expect(body.body).toMatchObject({
|
||||
pictureFileType: 'png',
|
||||
audios: [{ fileType: 'wav', contentType: 'audio/wav', expectedSizeBytes: 10 }]
|
||||
});
|
||||
});
|
||||
|
||||
it('has no picture target without a cover', async () => {
|
||||
const fake = api({
|
||||
post: () => ({
|
||||
data: {
|
||||
pictureUploadUrl: null,
|
||||
pictureContentType: null,
|
||||
audios: [{ intentId: 'i1', pictureIntentId: null, uploadUrl: 'u', contentType: 'a' }]
|
||||
}
|
||||
})
|
||||
});
|
||||
const result = await presignTrackUploads(asApi(fake), { cover: null, files: [file] });
|
||||
expect(result.picture).toBeNull();
|
||||
});
|
||||
|
||||
it('fails when the backend errors', async () => {
|
||||
const fake = api({ post: () => ({ error: { detail: 'nope' } }) });
|
||||
await expect(
|
||||
presignTrackUploads(asApi(fake), { cover: null, files: [file] })
|
||||
).rejects.toMatchObject({ status: 502, message: 'nope' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCompleteRequest', () => {
|
||||
const valid = { title: ' Song ', audioIntentId: 'a', tags: ['Rock', 'Rock'] };
|
||||
|
||||
it('trims and dedupes', () => {
|
||||
expect(parseCompleteRequest(valid)).toEqual({
|
||||
title: 'Song',
|
||||
audioIntentId: 'a',
|
||||
pictureIntentId: null,
|
||||
tags: ['Rock'],
|
||||
albumId: null
|
||||
});
|
||||
});
|
||||
|
||||
it('requires title, intent and genres', () => {
|
||||
expect(() => parseCompleteRequest({ ...valid, title: ' ' })).toThrow(UploadError);
|
||||
expect(() => parseCompleteRequest({ ...valid, audioIntentId: '' })).toThrow(UploadError);
|
||||
expect(() => parseCompleteRequest({ ...valid, tags: [] })).toThrow(UploadError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeTrackUpload', () => {
|
||||
const request = {
|
||||
title: 'Song',
|
||||
audioIntentId: 'a',
|
||||
pictureIntentId: null,
|
||||
tags: ['Rock'],
|
||||
albumId: null as string | null
|
||||
};
|
||||
const genres = {
|
||||
data: [
|
||||
{ genre: 'Rock', incompatibleWith: ['Classical'] },
|
||||
{ genre: 'Classical', incompatibleWith: ['Rock'] }
|
||||
]
|
||||
};
|
||||
|
||||
function build(albumResult: unknown = {}) {
|
||||
return api({
|
||||
get: () => genres,
|
||||
post: (path) => (path === '/tracks' ? { data: { id: 't1' } } : albumResult)
|
||||
});
|
||||
}
|
||||
|
||||
it('creates an unpublished-by-default track', async () => {
|
||||
const fake = build();
|
||||
expect(await completeTrackUpload(asApi(fake), request)).toEqual({ trackId: 't1', note: null });
|
||||
expect(fake.POST).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects unknown and conflicting genres before creating', async () => {
|
||||
const fake = build();
|
||||
await expect(
|
||||
completeTrackUpload(asApi(fake), { ...request, tags: ['Nope'] })
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
await expect(
|
||||
completeTrackUpload(asApi(fake), { ...request, tags: ['Rock', 'Classical'] })
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(fake.POST).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds the track to the album', async () => {
|
||||
const fake = build();
|
||||
await completeTrackUpload(asApi(fake), { ...request, albumId: 'al' });
|
||||
expect(fake.POST).toHaveBeenLastCalledWith('/albums/{id}/tracks', {
|
||||
params: { path: { id: 'al' } },
|
||||
body: { trackIds: ['t1'] }
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the track and reports a note when the album step fails', async () => {
|
||||
const fake = build({ error: { status: 500 } });
|
||||
const result = await completeTrackUpload(asApi(fake), { ...request, albumId: 'al' });
|
||||
expect(result.trackId).toBe('t1');
|
||||
expect(result.note).toContain('álbum');
|
||||
});
|
||||
});
|
||||
|
||||
describe('track processing status', () => {
|
||||
it('parses ids', () => {
|
||||
expect(parseStatusIds('a,b,a')).toEqual(['a', 'b']);
|
||||
expect(() => parseStatusIds(null)).toThrow(UploadError);
|
||||
});
|
||||
|
||||
it('maps the audio status of each track', async () => {
|
||||
const fake = api({
|
||||
get: (path) => (path === '/tracks/{id}' ? { data: { audioStatus: 'Completed' } } : {})
|
||||
});
|
||||
expect(await trackProcessingStatus(asApi(fake), ['a'])).toEqual({ a: 'Completed' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { ApiClient } from '$lib/server/api';
|
||||
import { apiErrorDetail } from '$lib/server/api';
|
||||
import { AUDIO_TYPES, IMAGE_TYPES, extOf } from '$lib/server/upload';
|
||||
import { genreConflictMessage, UPLOAD_BATCH_SIZE, UPLOAD_MAX_BYTES } from '$lib/data/uploadQueue';
|
||||
import type {
|
||||
CompleteRequest,
|
||||
CompleteResponse,
|
||||
PresignFile,
|
||||
PresignRequest,
|
||||
PresignResponse,
|
||||
StatusResponse,
|
||||
TrackProcessing
|
||||
} from '$lib/data/uploadProtocol';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
import type { components } from '$lib/api/schema';
|
||||
|
||||
type Genre = components['schemas']['Genre'];
|
||||
|
||||
export class UploadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function contentType(file: PresignFile, table: Record<string, string>): string {
|
||||
return file.contentType || table[extOf(file.fileName)] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isFile(value: unknown): value is PresignFile {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const { fileName, contentType: type, size } = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof fileName === 'string' &&
|
||||
fileName !== '' &&
|
||||
typeof type === 'string' &&
|
||||
typeof size === 'number' &&
|
||||
Number.isFinite(size)
|
||||
);
|
||||
}
|
||||
|
||||
export function parsePresignRequest(payload: unknown): PresignRequest {
|
||||
const { cover, files } = (payload ?? {}) as { cover?: unknown; files?: unknown };
|
||||
if (!Array.isArray(files) || files.length === 0 || files.length > UPLOAD_BATCH_SIZE) {
|
||||
throw new UploadError(`Envía entre 1 y ${UPLOAD_BATCH_SIZE} archivos.`, 400);
|
||||
}
|
||||
if (!files.every(isFile)) throw new UploadError('Archivo de audio inválido.', 400);
|
||||
if (files.some((file) => file.size <= 0 || file.size > UPLOAD_MAX_BYTES)) {
|
||||
throw new UploadError('Un archivo está vacío o supera el tamaño máximo.', 400);
|
||||
}
|
||||
if (cover !== null && cover !== undefined && !isFile(cover)) {
|
||||
throw new UploadError('Portada inválida.', 400);
|
||||
}
|
||||
return { cover: cover ?? null, files };
|
||||
}
|
||||
|
||||
export async function presignTrackUploads(
|
||||
api: ApiClient,
|
||||
request: PresignRequest
|
||||
): Promise<PresignResponse> {
|
||||
const { cover, files } = request;
|
||||
const result = await api.POST('/tracks/upload-urls', {
|
||||
body: {
|
||||
pictureFileType: cover ? extOf(cover.fileName) || 'jpg' : null,
|
||||
pictureContentType: cover ? contentType(cover, IMAGE_TYPES) : null,
|
||||
expectedPictureSizeBytes: cover?.size ?? null,
|
||||
audios: files.map((file) => ({
|
||||
fileType: extOf(file.fileName) || 'mp3',
|
||||
contentType: contentType(file, AUDIO_TYPES),
|
||||
expectedSizeBytes: file.size
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
if (result.error || !result.data || result.data.audios.length !== files.length) {
|
||||
throw new UploadError(
|
||||
apiErrorDetail(result.error) ?? 'No se pudieron reservar las URLs de subida.',
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
const { pictureUploadUrl, pictureContentType, audios } = result.data;
|
||||
return {
|
||||
picture:
|
||||
pictureUploadUrl && pictureContentType
|
||||
? { uploadUrl: pictureUploadUrl, contentType: pictureContentType }
|
||||
: null,
|
||||
audios: audios.map((audio) => ({
|
||||
intentId: audio.intentId,
|
||||
pictureIntentId: audio.pictureIntentId,
|
||||
uploadUrl: audio.uploadUrl,
|
||||
contentType: audio.contentType
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function idOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
}
|
||||
|
||||
export function parseCompleteRequest(payload: unknown): CompleteRequest {
|
||||
const body = (payload ?? {}) as Record<string, unknown>;
|
||||
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
if (title === '' || title.length > LIMITS.trackTitle) {
|
||||
throw new UploadError(`El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).`, 400);
|
||||
}
|
||||
const audioIntentId = idOrNull(body.audioIntentId);
|
||||
if (!audioIntentId) throw new UploadError('Subida inválida.', 400);
|
||||
const tags = Array.isArray(body.tags)
|
||||
? [...new Set(body.tags.filter((tag): tag is string => typeof tag === 'string'))]
|
||||
: [];
|
||||
if (tags.length === 0) throw new UploadError('Elige al menos un género.', 400);
|
||||
return {
|
||||
title,
|
||||
audioIntentId,
|
||||
pictureIntentId: idOrNull(body.pictureIntentId),
|
||||
tags,
|
||||
albumId: idOrNull(body.albumId)
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeTrackUpload(
|
||||
api: ApiClient,
|
||||
request: CompleteRequest
|
||||
): Promise<CompleteResponse> {
|
||||
const { data: available } = await api.GET('/genres/available');
|
||||
const options = available ?? [];
|
||||
const known = new Set<string>(options.map((option) => option.genre));
|
||||
if (!request.tags.every((tag) => known.has(tag))) {
|
||||
throw new UploadError('Alguno de los géneros no es válido.', 400);
|
||||
}
|
||||
const conflict = genreConflictMessage(request.tags, options);
|
||||
if (conflict) throw new UploadError(conflict, 400);
|
||||
|
||||
const created = await api.POST('/tracks', {
|
||||
body: {
|
||||
title: request.title,
|
||||
pictureIntentId: request.pictureIntentId,
|
||||
audioIntentId: request.audioIntentId,
|
||||
tags: request.tags as Genre[],
|
||||
isExplicit: false
|
||||
}
|
||||
});
|
||||
if (created.error || !created.data) {
|
||||
throw new UploadError(
|
||||
apiErrorDetail(created.error) ?? 'No se pudo crear la canción tras la subida.',
|
||||
502
|
||||
);
|
||||
}
|
||||
const trackId = created.data.id;
|
||||
|
||||
if (!request.albumId) return { trackId, note: null };
|
||||
const added = await api.POST('/albums/{id}/tracks', {
|
||||
params: { path: { id: request.albumId } },
|
||||
body: { trackIds: [trackId] }
|
||||
});
|
||||
return {
|
||||
trackId,
|
||||
note: added.error ? 'Se creó, pero no se pudo añadir al álbum.' : null
|
||||
};
|
||||
}
|
||||
|
||||
export function parseStatusIds(raw: string | null): string[] {
|
||||
const ids = [...new Set((raw ?? '').split(',').filter(Boolean))];
|
||||
if (ids.length === 0 || ids.length > UPLOAD_BATCH_SIZE) {
|
||||
throw new UploadError('Canciones inválidas.', 400);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function trackProcessingStatus(
|
||||
api: ApiClient,
|
||||
ids: string[]
|
||||
): Promise<StatusResponse> {
|
||||
const entries = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const result = await api.GET('/tracks/{id}', { params: { path: { id } } });
|
||||
return [id, result.data?.audioStatus] as const;
|
||||
})
|
||||
);
|
||||
const known = entries.filter(
|
||||
(entry): entry is readonly [string, TrackProcessing] => entry[1] !== undefined
|
||||
);
|
||||
return Object.fromEntries(known);
|
||||
}
|
||||
@@ -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']
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
UPLOAD_CONCURRENCY,
|
||||
beginUpload,
|
||||
chunkStartable,
|
||||
failUpload,
|
||||
finishCreate,
|
||||
finishProcessing,
|
||||
finishUpload,
|
||||
genreConflictMessage,
|
||||
hasActiveUploads,
|
||||
isStartable,
|
||||
readDuration,
|
||||
reportProgress,
|
||||
retryUpload,
|
||||
summarizeQueue,
|
||||
toQueuedUpload,
|
||||
type QueuedUpload
|
||||
} from '$lib/data/uploadQueue';
|
||||
import type { PresignedAudio } from '$lib/data/uploadProtocol';
|
||||
import type { GenreOption } from '$lib/types';
|
||||
import { httpUploadTransport, type UploadTransport } from '$lib/utils/uploadTransport';
|
||||
|
||||
const STATUS_BATCH = 20;
|
||||
|
||||
interface QueueDeps {
|
||||
transport: UploadTransport;
|
||||
pollDelay: (attempt: number) => Promise<void>;
|
||||
readDuration: (file: File) => Promise<number | null>;
|
||||
}
|
||||
|
||||
interface BatchSettings {
|
||||
tags: string[];
|
||||
albumId: string | null;
|
||||
}
|
||||
|
||||
function reasonOf(err: unknown): string {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return 'Cancelada';
|
||||
return err instanceof Error && err.message !== '' ? err.message : 'La subida falló.';
|
||||
}
|
||||
|
||||
function backoff(attempt: number): Promise<void> {
|
||||
const delay = Math.min(3000 * 1.5 ** attempt, 15000);
|
||||
return new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
export class UploadQueue {
|
||||
items = $state<QueuedUpload[]>([]);
|
||||
selectedGenres = $state<string[]>([]);
|
||||
cover = $state<File | null>(null);
|
||||
albumId = $state('');
|
||||
notice = $state<string | null>(null);
|
||||
running = $state(false);
|
||||
|
||||
summary = $derived(summarizeQueue(this.items));
|
||||
active = $derived(hasActiveUploads(this.items));
|
||||
|
||||
private deps: QueueDeps;
|
||||
private controllers = new Map<string, AbortController>();
|
||||
private presignController: AbortController | null = null;
|
||||
private polling = false;
|
||||
|
||||
constructor(deps: Partial<QueueDeps> = {}) {
|
||||
this.deps = {
|
||||
transport: deps.transport ?? httpUploadTransport,
|
||||
pollDelay: deps.pollDelay ?? backoff,
|
||||
readDuration: deps.readDuration ?? readDuration
|
||||
};
|
||||
}
|
||||
|
||||
enqueue(files: File[]) {
|
||||
const added = files.map(toQueuedUpload);
|
||||
this.items = [...this.items, ...added];
|
||||
for (const item of added) {
|
||||
if (item.error) continue;
|
||||
this.deps.readDuration(item.file).then((duration) => {
|
||||
this.update(item.id, (current) => ({ ...current, duration }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTitle(id: string, title: string) {
|
||||
this.update(id, (item) => ({ ...item, title }));
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
this.controllers.get(id)?.abort();
|
||||
this.items = this.items.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
cancel(id: string) {
|
||||
this.controllers.get(id)?.abort();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.presignController?.abort();
|
||||
for (const controller of this.controllers.values()) controller.abort();
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
retry(id: string) {
|
||||
this.update(id, retryUpload);
|
||||
}
|
||||
|
||||
async start(genreOptions: GenreOption[]) {
|
||||
if (this.running) return;
|
||||
this.notice = null;
|
||||
if (this.selectedGenres.length === 0) {
|
||||
this.notice = 'Elige al menos un género para toda la cola.';
|
||||
return;
|
||||
}
|
||||
const conflict = genreConflictMessage(this.selectedGenres, genreOptions);
|
||||
if (conflict) {
|
||||
this.notice = conflict;
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = chunkStartable(this.items);
|
||||
if (chunks.length === 0) return;
|
||||
|
||||
const settings: BatchSettings = {
|
||||
tags: [...this.selectedGenres],
|
||||
albumId: this.albumId === '' ? null : this.albumId
|
||||
};
|
||||
const cover = this.cover;
|
||||
this.running = true;
|
||||
try {
|
||||
for (const chunk of chunks) await this.runChunk(chunk, cover, settings);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private update(id: string, change: (item: QueuedUpload) => QueuedUpload) {
|
||||
this.items = this.items.map((item) => (item.id === id ? change(item) : item));
|
||||
}
|
||||
|
||||
private find(id: string): QueuedUpload | undefined {
|
||||
return this.items.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
private async runChunk(chunk: QueuedUpload[], cover: File | null, settings: BatchSettings) {
|
||||
const current = chunk.filter((item) => {
|
||||
const live = this.find(item.id);
|
||||
return live !== undefined && isStartable(live);
|
||||
});
|
||||
if (current.length === 0) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
this.presignController = controller;
|
||||
|
||||
let audios: PresignedAudio[];
|
||||
try {
|
||||
const presigned = await this.deps.transport.presign(
|
||||
{
|
||||
cover: cover ? { fileName: cover.name, contentType: cover.type, size: cover.size } : null,
|
||||
files: current.map(({ file }) => ({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size
|
||||
}))
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
if (presigned.picture && cover) {
|
||||
await this.deps.transport.put(presigned.picture, cover, () => {}, controller.signal);
|
||||
}
|
||||
audios = presigned.audios;
|
||||
} catch (err) {
|
||||
const reason = reasonOf(err);
|
||||
for (const { id } of current)
|
||||
this.update(id, (item) => failUpload(beginUpload(item), reason));
|
||||
return;
|
||||
} finally {
|
||||
this.presignController = null;
|
||||
}
|
||||
|
||||
const jobs = current.map((item, index) => ({ id: item.id, audio: audios[index] }));
|
||||
const worker = async () => {
|
||||
for (let job = jobs.shift(); job; job = jobs.shift()) {
|
||||
await this.processOne(job.id, job.audio, settings);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, jobs.length) }, worker));
|
||||
}
|
||||
|
||||
private async processOne(id: string, audio: PresignedAudio, settings: BatchSettings) {
|
||||
const item = this.find(id);
|
||||
if (!item || !isStartable(item)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
this.controllers.set(id, controller);
|
||||
this.update(id, beginUpload);
|
||||
try {
|
||||
await this.deps.transport.put(
|
||||
audio,
|
||||
item.file,
|
||||
(fraction) => this.update(id, (current) => reportProgress(current, fraction)),
|
||||
controller.signal
|
||||
);
|
||||
this.update(id, finishUpload);
|
||||
const title = this.find(id)?.title.trim() || item.title;
|
||||
const created = await this.deps.transport.complete(
|
||||
{
|
||||
title,
|
||||
audioIntentId: audio.intentId,
|
||||
pictureIntentId: audio.pictureIntentId,
|
||||
tags: settings.tags,
|
||||
albumId: settings.albumId
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
this.update(id, (current) => finishCreate(current, created.trackId, created.note));
|
||||
void this.watchProcessing();
|
||||
} catch (err) {
|
||||
const reason = reasonOf(err);
|
||||
this.update(id, (current) => failUpload(current, reason));
|
||||
} finally {
|
||||
this.controllers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async watchProcessing() {
|
||||
if (this.polling) return;
|
||||
this.polling = true;
|
||||
try {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const trackIds = this.items
|
||||
.filter((item) => item.phase === 'processing' && item.trackId !== null)
|
||||
.map((item) => item.trackId as string);
|
||||
if (trackIds.length === 0) return;
|
||||
await this.deps.pollDelay(attempt);
|
||||
await this.refreshStatus(trackIds);
|
||||
}
|
||||
} finally {
|
||||
this.polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshStatus(trackIds: string[]) {
|
||||
const batches: string[][] = [];
|
||||
for (let i = 0; i < trackIds.length; i += STATUS_BATCH) {
|
||||
batches.push(trackIds.slice(i, i + STATUS_BATCH));
|
||||
}
|
||||
await Promise.all(
|
||||
batches.map(async (ids) => {
|
||||
try {
|
||||
const statuses = await this.deps.transport.status(ids);
|
||||
for (const item of this.items) {
|
||||
const status = item.trackId ? statuses[item.trackId] : undefined;
|
||||
if (status === 'Completed') this.update(item.id, finishProcessing);
|
||||
if (status === 'Failed') {
|
||||
this.update(item.id, (current) =>
|
||||
failUpload(current, 'No se pudo procesar el audio.')
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadQueue = new UploadQueue();
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { UploadQueue } from '$lib/state/uploadQueue.svelte';
|
||||
import type { UploadTransport } from '$lib/utils/uploadTransport';
|
||||
import { putHeaders } from '$lib/utils/uploadTransport';
|
||||
import type { GenreOption } from '$lib/types';
|
||||
|
||||
const GENRES: GenreOption[] = [
|
||||
{ genre: 'Rock', incompatibleWith: ['Classical'] },
|
||||
{ genre: 'Classical', incompatibleWith: ['Rock'] },
|
||||
{ genre: 'Jazz', incompatibleWith: [] }
|
||||
];
|
||||
|
||||
function file(name: string) {
|
||||
return new File([new Uint8Array(8)], name);
|
||||
}
|
||||
|
||||
function fakeTransport(overrides: Partial<UploadTransport> = {}) {
|
||||
let counter = 0;
|
||||
const transport: UploadTransport = {
|
||||
presign: vi.fn(async (request) => ({
|
||||
picture: request.cover ? { uploadUrl: 'https://s3/pic', contentType: 'image/png' } : null,
|
||||
audios: request.files.map(() => ({
|
||||
intentId: `intent-${counter++}`,
|
||||
pictureIntentId: request.cover ? 'pic' : null,
|
||||
uploadUrl: 'https://s3/audio',
|
||||
contentType: 'audio/wav'
|
||||
}))
|
||||
})),
|
||||
put: vi.fn(async (_target, _file, onProgress) => onProgress(0.5)),
|
||||
complete: vi.fn(async (request) => ({ trackId: `track-${request.audioIntentId}`, note: null })),
|
||||
status: vi.fn(async (ids) =>
|
||||
Object.fromEntries(ids.map((id: string) => [id, 'Completed' as const]))
|
||||
),
|
||||
...overrides
|
||||
};
|
||||
return transport;
|
||||
}
|
||||
|
||||
function queue(transport: UploadTransport) {
|
||||
const q = new UploadQueue({
|
||||
transport,
|
||||
pollDelay: async () => {},
|
||||
readDuration: async () => 60
|
||||
});
|
||||
q.selectedGenres = ['Jazz'];
|
||||
return q;
|
||||
}
|
||||
|
||||
describe('UploadQueue', () => {
|
||||
it('uploads every valid file, creates the tracks and finishes them', async () => {
|
||||
const transport = fakeTransport();
|
||||
const q = queue(transport);
|
||||
q.enqueue([file('a.wav'), file('b.mp3'), file('c.psd')]);
|
||||
await q.start(GENRES);
|
||||
await vi.waitFor(() => expect(q.summary.done).toBe(2));
|
||||
expect(q.summary.failed).toBe(1);
|
||||
expect(transport.put).toHaveBeenCalledTimes(2);
|
||||
expect(transport.complete).toHaveBeenCalledTimes(2);
|
||||
expect(transport.presign).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uploads the shared cover once and passes the album and genres', async () => {
|
||||
const transport = fakeTransport();
|
||||
const q = queue(transport);
|
||||
q.cover = new File([new Uint8Array(4)], 'c.png', { type: 'image/png' });
|
||||
q.albumId = 'album-1';
|
||||
q.enqueue([file('a.wav'), file('b.wav')]);
|
||||
await q.start(GENRES);
|
||||
const puts = vi.mocked(transport.put).mock.calls.map((call) => call[0].uploadUrl);
|
||||
expect(puts.filter((url) => url === 'https://s3/pic')).toHaveLength(1);
|
||||
expect(transport.complete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ albumId: 'album-1', tags: ['Jazz'], pictureIntentId: 'pic' }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('requires genres and rejects conflicting ones before sending anything', async () => {
|
||||
const transport = fakeTransport();
|
||||
const q = queue(transport);
|
||||
q.enqueue([file('a.wav')]);
|
||||
q.selectedGenres = [];
|
||||
await q.start(GENRES);
|
||||
expect(q.notice).toContain('género');
|
||||
q.selectedGenres = ['Rock', 'Classical'];
|
||||
await q.start(GENRES);
|
||||
expect(q.notice).toContain('no se pueden combinar');
|
||||
expect(transport.presign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never runs more than the concurrency limit at once', async () => {
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const transport = fakeTransport({
|
||||
put: async () => {
|
||||
running++;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
running--;
|
||||
}
|
||||
});
|
||||
const q = queue(transport);
|
||||
q.enqueue(Array.from({ length: 8 }, (_, i) => file(`${i}.wav`)));
|
||||
await q.start(GENRES);
|
||||
expect(peak).toBe(3);
|
||||
});
|
||||
|
||||
it('marks a file as failed and lets it retry', async () => {
|
||||
let fail = true;
|
||||
const transport = fakeTransport({
|
||||
put: async () => {
|
||||
if (fail) throw new Error('boom');
|
||||
}
|
||||
});
|
||||
const q = queue(transport);
|
||||
q.enqueue([file('a.wav')]);
|
||||
await q.start(GENRES);
|
||||
expect(q.items[0]).toMatchObject({ phase: 'error', failure: 'boom' });
|
||||
fail = false;
|
||||
q.retry(q.items[0].id);
|
||||
await q.start(GENRES);
|
||||
await vi.waitFor(() => expect(q.items[0].phase).toBe('done'));
|
||||
});
|
||||
|
||||
it('fails the whole batch when the presign request fails', async () => {
|
||||
const transport = fakeTransport({
|
||||
presign: async () => {
|
||||
throw new Error('sin URLs');
|
||||
}
|
||||
});
|
||||
const q = queue(transport);
|
||||
q.enqueue([file('a.wav'), file('b.wav')]);
|
||||
await q.start(GENRES);
|
||||
expect(q.items.map((item) => item.failure)).toEqual(['sin URLs', 'sin URLs']);
|
||||
});
|
||||
|
||||
it('cancels an upload in flight', async () => {
|
||||
const transport = fakeTransport({
|
||||
put: (_target, _file, _progress, signal) =>
|
||||
new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(new DOMException('x', 'AbortError')))
|
||||
)
|
||||
});
|
||||
const q = queue(transport);
|
||||
q.enqueue([file('a.wav')]);
|
||||
const started = q.start(GENRES);
|
||||
await vi.waitFor(() => expect(q.items[0].phase).toBe('uploading'));
|
||||
q.cancel(q.items[0].id);
|
||||
await started;
|
||||
expect(q.items[0]).toMatchObject({ phase: 'error', failure: 'Cancelada' });
|
||||
expect(transport.complete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the album note and turns a failed processing into an error', async () => {
|
||||
const transport = fakeTransport({
|
||||
complete: async () => ({ trackId: 't1', note: 'Sin álbum' }),
|
||||
status: async () => ({ t1: 'Failed' })
|
||||
});
|
||||
const q = queue(transport);
|
||||
q.enqueue([file('a.wav')]);
|
||||
await q.start(GENRES);
|
||||
await vi.waitFor(() => expect(q.items[0].phase).toBe('error'));
|
||||
expect(q.items[0].note).toBe('Sin álbum');
|
||||
});
|
||||
|
||||
it('reports active uploads and clears the queue', async () => {
|
||||
const transport = fakeTransport({
|
||||
put: (_target, _file, _progress, signal) =>
|
||||
new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(new DOMException('x', 'AbortError')))
|
||||
)
|
||||
});
|
||||
const q = queue(transport);
|
||||
q.enqueue([file('a.wav')]);
|
||||
const started = q.start(GENRES);
|
||||
await vi.waitFor(() => expect(q.active).toBe(true));
|
||||
q.clear();
|
||||
await started;
|
||||
expect(q.items).toEqual([]);
|
||||
expect(q.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('putHeaders', () => {
|
||||
it('sends only the signed headers', () => {
|
||||
const target = { uploadUrl: 'https://s3/a?X-Amz-SignedHeaders=host', contentType: 'audio/wav' };
|
||||
expect(putHeaders(target)).toEqual({});
|
||||
expect(
|
||||
putHeaders({
|
||||
...target,
|
||||
uploadUrl: 'https://s3/a?X-Amz-SignedHeaders=content-type%3Bhost%3Bif-none-match'
|
||||
})
|
||||
).toEqual({ 'Content-Type': 'audio/wav', 'If-None-Match': '*' });
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,8 @@ export const COL = {
|
||||
publish: '6.5rem',
|
||||
statusQueue: '8.75rem',
|
||||
statusMobile: '6rem',
|
||||
access: '6rem',
|
||||
actions: '4.75rem',
|
||||
actionsMobile: '4rem',
|
||||
size: '5.5rem',
|
||||
durationQueue: '4.5rem'
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
CompleteRequest,
|
||||
CompleteResponse,
|
||||
PresignRequest,
|
||||
PresignResponse,
|
||||
PresignTarget,
|
||||
StatusResponse
|
||||
} from '$lib/data/uploadProtocol';
|
||||
|
||||
export interface UploadTransport {
|
||||
presign(request: PresignRequest, signal: AbortSignal): Promise<PresignResponse>;
|
||||
put(
|
||||
target: PresignTarget,
|
||||
file: Blob,
|
||||
onProgress: (fraction: number) => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void>;
|
||||
complete(request: CompleteRequest, signal: AbortSignal): Promise<CompleteResponse>;
|
||||
status(ids: string[]): Promise<StatusResponse>;
|
||||
}
|
||||
|
||||
export class TransportError extends Error {}
|
||||
|
||||
export function signedHeaders(uploadUrl: string): Set<string> {
|
||||
const raw = new URL(uploadUrl).searchParams.get('X-Amz-SignedHeaders') ?? 'host';
|
||||
return new Set(raw.toLowerCase().split(';'));
|
||||
}
|
||||
|
||||
export function putHeaders(target: PresignTarget): Record<string, string> {
|
||||
const signed = signedHeaders(target.uploadUrl);
|
||||
const headers: Record<string, string> = {};
|
||||
if (signed.has('content-type')) headers['Content-Type'] = target.contentType;
|
||||
if (signed.has('if-none-match')) headers['If-None-Match'] = '*';
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function postJson<T>(url: string, body: unknown, signal: AbortSignal): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new TransportError(data?.message ?? 'La solicitud falló.');
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function putWithProgress(
|
||||
target: PresignTarget,
|
||||
file: Blob,
|
||||
onProgress: (fraction: number) => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', target.uploadUrl);
|
||||
for (const [name, value] of Object.entries(putHeaders(target))) {
|
||||
xhr.setRequestHeader(name, value);
|
||||
}
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && event.total > 0) onProgress(event.loaded / event.total);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new TransportError(`El almacenamiento rechazó el archivo (${xhr.status}).`));
|
||||
};
|
||||
xhr.onerror = () => reject(new TransportError('No se pudo conectar con el almacenamiento.'));
|
||||
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'));
|
||||
signal.addEventListener('abort', () => xhr.abort(), { once: true });
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
export const httpUploadTransport: UploadTransport = {
|
||||
presign: (request, signal) => postJson<PresignResponse>('/api/uploads', request, signal),
|
||||
put: putWithProgress,
|
||||
complete: (request, signal) =>
|
||||
postJson<CompleteResponse>('/api/uploads/complete', request, signal),
|
||||
async status(ids) {
|
||||
const response = await fetch(`/api/uploads/status?ids=${encodeURIComponent(ids.join(','))}`);
|
||||
if (!response.ok) throw new TransportError('No se pudo consultar el estado.');
|
||||
return (await response.json()) as StatusResponse;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user