Merge direct upload queue
This commit is contained in:
@@ -1,68 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { genreLabels } from '$lib/data/genres';
|
||||
import {
|
||||
filesFromDrop,
|
||||
readDuration,
|
||||
summarizeQueue,
|
||||
toQueuedUpload,
|
||||
UPLOAD_FORMATS,
|
||||
uploadLimitsLabel,
|
||||
type QueuedUpload,
|
||||
type Visibility
|
||||
} from '$lib/data/uploadQueue';
|
||||
import { filesFromDrop, UPLOAD_FORMATS, uploadLimitsLabel } from '$lib/data/uploadQueue';
|
||||
import { uploadQueue as queue } from '$lib/state/uploadQueue.svelte';
|
||||
import { plural } from '$lib/utils/format';
|
||||
import type { GenreOption } from '$lib/types';
|
||||
import FileDropzone from '$lib/components/ui/forms/FileDropzone.svelte';
|
||||
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import UploadQueueList from '$lib/components/music/library/UploadQueueList.svelte';
|
||||
import UploadSettings from '$lib/components/music/library/UploadSettings.svelte';
|
||||
|
||||
interface Props {
|
||||
genres: GenreOption[];
|
||||
interface AlbumChoice {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
let { genres }: Props = $props();
|
||||
interface Props {
|
||||
genres: GenreOption[];
|
||||
albums: AlbumChoice[];
|
||||
}
|
||||
|
||||
let { genres, albums }: Props = $props();
|
||||
|
||||
const ACCEPT = UPLOAD_FORMATS.map((ext) => `.${ext}`).join(',');
|
||||
|
||||
let queue = $state<QueuedUpload[]>([]);
|
||||
let selectedGenres = $state<string[]>([]);
|
||||
let visibility = $state<Visibility>('private');
|
||||
let coauthors = $state<string[]>([]);
|
||||
|
||||
const single = $derived(queue.length === 1 ? queue[0] : null);
|
||||
const summary = $derived(summarizeQueue(queue));
|
||||
const genresLabel = $derived(genreLabels(selectedGenres));
|
||||
const visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público');
|
||||
const summary = $derived(queue.summary);
|
||||
const genresLabel = $derived(genreLabels(queue.selectedGenres));
|
||||
const queueMeta = $derived(
|
||||
[
|
||||
plural(summary.ready, 'lista', 'listas'),
|
||||
summary.failed > 0 ? `${summary.failed} con error` : null
|
||||
summary.ready > 0 ? plural(summary.ready, 'lista', 'listas') : null,
|
||||
summary.uploading > 0 ? `${summary.uploading} subiendo` : null,
|
||||
summary.processing > 0 ? `${summary.processing} procesando` : null,
|
||||
summary.done > 0 ? plural(summary.done, 'terminada', 'terminadas') : null,
|
||||
summary.errored + summary.failed > 0 ? `${summary.errored + summary.failed} con error` : null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
|
||||
function enqueue(files: File[]) {
|
||||
const added = files.map(toQueuedUpload);
|
||||
queue = [...queue, ...added];
|
||||
for (const item of added) {
|
||||
if (item.error) continue;
|
||||
readDuration(item.file).then((duration) => {
|
||||
queue = queue.map((current) =>
|
||||
current.id === item.id ? { ...current, duration } : current
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setTitle(id: string, title: string) {
|
||||
queue = queue.map((item) => (item.id === id ? { ...item, title } : item));
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
queue = queue.filter((item) => item.id !== id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-6 grid items-start gap-6 lg:grid-cols-2">
|
||||
@@ -72,17 +46,18 @@
|
||||
title="Arrastra tus archivos aquí"
|
||||
hint={uploadLimitsLabel()}
|
||||
inputLabel="Elegir archivos de audio"
|
||||
onfiles={enqueue}
|
||||
onfiles={(files) => queue.enqueue(files)}
|
||||
readDrop={filesFromDrop}
|
||||
/>
|
||||
|
||||
<UploadSettings
|
||||
{genres}
|
||||
bind:selectedGenres
|
||||
bind:visibility
|
||||
bind:coauthors
|
||||
bind:title={() => single?.title ?? '', (value) => single && setTitle(single.id, value)}
|
||||
titleDisabled={single === null}
|
||||
{albums}
|
||||
bind:selectedGenres={queue.selectedGenres}
|
||||
bind:albumId={queue.albumId}
|
||||
hasCover={queue.cover !== null}
|
||||
oncover={(file) => (queue.cover = file)}
|
||||
disabled={queue.running}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -91,24 +66,40 @@
|
||||
<div>
|
||||
<h2 class="text-display-3 text-fg">Cola de subida</h2>
|
||||
<p class="mt-1 text-xs text-fg-2">
|
||||
{queue.length > 0 ? queueMeta : 'Todavía no hay archivos'}
|
||||
{queue.items.length > 0 ? queueMeta : 'Todavía no hay archivos'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if queue.length > 0}
|
||||
<Button variant="secondary" size="sm" onclick={() => (queue = [])}>Vaciar cola</Button>
|
||||
{#if queue.items.length > 0}
|
||||
<Button variant="secondary" size="sm" onclick={() => queue.clear()}>Vaciar cola</Button>
|
||||
{/if}
|
||||
<Button disabled>Añadir {summary.ready} a la biblioteca</Button>
|
||||
<Button
|
||||
disabled={summary.ready === 0}
|
||||
loading={queue.running}
|
||||
onclick={() => queue.start(genres)}
|
||||
>
|
||||
Subir {summary.ready}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if queue.length > 0}
|
||||
|
||||
<p class="mt-3 text-xs text-fg-2">
|
||||
Se añadirá sin publicar: publícala desde Canciones cuando termine de procesarse.
|
||||
</p>
|
||||
|
||||
{#if queue.notice}
|
||||
<Alert tone="danger" class="mt-4">{queue.notice}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if queue.items.length > 0}
|
||||
<UploadQueueList
|
||||
class="mt-4"
|
||||
items={queue}
|
||||
items={queue.items}
|
||||
{genresLabel}
|
||||
{visibilityLabel}
|
||||
onRemove={remove}
|
||||
onTitle={setTitle}
|
||||
onRemove={(id) => queue.remove(id)}
|
||||
onCancel={(id) => queue.cancel(id)}
|
||||
onRetry={(id) => queue.retry(id)}
|
||||
onTitle={(id, title) => queue.setTitle(id, title)}
|
||||
/>
|
||||
{:else}
|
||||
<p class="mt-8 mb-6 text-center text-sm text-fg-2">
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
mobileLabel: true
|
||||
},
|
||||
{ id: 'genres', label: 'Géneros', width: COL.genresWide, class: TEXT },
|
||||
{ id: 'access', label: 'Acceso', width: COL.access, class: TEXT },
|
||||
{ id: 'size', label: 'Tamaño', width: COL.size, class: `${TEXT} tabular-nums` },
|
||||
{
|
||||
id: 'duration',
|
||||
@@ -28,14 +27,16 @@
|
||||
mobile: COL.statusMobile,
|
||||
mobileLabel: true
|
||||
},
|
||||
{ id: 'remove', width: COL.action, mobile: COL.actionMobile }
|
||||
{ id: 'actions', width: COL.actions, mobile: COL.actionsMobile }
|
||||
];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { fmtTime, formatSize } from '$lib/utils/format';
|
||||
import type { QueuedUpload } from '$lib/data/uploadQueue';
|
||||
import { isBusy, type QueuedUpload } from '$lib/data/uploadQueue';
|
||||
import DataGrid from '$lib/components/ui/media/DataGrid.svelte';
|
||||
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
@@ -43,8 +44,9 @@
|
||||
interface Props {
|
||||
items: QueuedUpload[];
|
||||
genresLabel: string;
|
||||
visibilityLabel: string;
|
||||
onRemove: (id: string) => void;
|
||||
onCancel: (id: string) => void;
|
||||
onRetry: (id: string) => void;
|
||||
onTitle: (id: string, title: string) => void;
|
||||
class?: string;
|
||||
}
|
||||
@@ -52,16 +54,20 @@
|
||||
let {
|
||||
items,
|
||||
genresLabel,
|
||||
visibilityLabel,
|
||||
onRemove,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onTitle,
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
|
||||
function editable(item: QueuedUpload): boolean {
|
||||
return item.phase === 'queued' || item.phase === 'error';
|
||||
}
|
||||
|
||||
function details(item: QueuedUpload): string {
|
||||
return [
|
||||
genresLabel,
|
||||
visibilityLabel,
|
||||
formatSize(item.file.size),
|
||||
item.duration === null ? '' : fmtTime(item.duration)
|
||||
]
|
||||
@@ -89,17 +95,16 @@
|
||||
<input
|
||||
value={item.title}
|
||||
oninput={(event) => onTitle(item.id, event.currentTarget.value)}
|
||||
disabled={!editable(item)}
|
||||
aria-label="Título de {item.file.name}"
|
||||
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none"
|
||||
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none disabled:hover:bg-transparent"
|
||||
/>
|
||||
<p class="truncate text-xs text-fg-2">{item.file.name}</p>
|
||||
<p class="truncate text-xs text-fg-2">{item.note ?? item.file.name}</p>
|
||||
<p class="truncate text-xs text-fg-2 sm:hidden">{details(item)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if column.id === 'genres'}
|
||||
{genresLabel}
|
||||
{:else if column.id === 'access'}
|
||||
{visibilityLabel}
|
||||
{:else if column.id === 'size'}
|
||||
{formatSize(item.file.size) || '-'}
|
||||
{:else if column.id === 'duration'}
|
||||
@@ -107,13 +112,58 @@
|
||||
{:else if column.id === 'status'}
|
||||
{#if item.error}
|
||||
<StatusBadge label={item.error} tone="danger" />
|
||||
{:else if item.phase === 'uploading'}
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
role="progressbar"
|
||||
aria-label="Subiendo {item.file.name}"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(item.progress * 100)}
|
||||
>
|
||||
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-btn">
|
||||
<div
|
||||
class="h-full rounded-full bg-accent transition-[width]"
|
||||
style="width:{item.progress * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="w-9 text-right text-xs text-fg-2 tabular-nums">
|
||||
{Math.round(item.progress * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{:else if item.phase === 'creating'}
|
||||
<StatusBadge label="Creando" />
|
||||
{:else if item.phase === 'processing'}
|
||||
<StatusBadge label="Procesando" tone="neutral" pulse />
|
||||
{:else if item.phase === 'done'}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-accent-soft">
|
||||
<Check class="animate-pop size-icon-sm" />
|
||||
Lista
|
||||
</span>
|
||||
{:else if item.phase === 'error'}
|
||||
<StatusBadge label={item.failure ?? 'Error'} tone="danger" />
|
||||
{:else}
|
||||
<StatusBadge label="Lista" tone="success" />
|
||||
<StatusBadge label="En cola" />
|
||||
{/if}
|
||||
{:else}
|
||||
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
|
||||
<X class="size-icon-sm" />
|
||||
</IconButton>
|
||||
<div class="flex items-center justify-end">
|
||||
{#if isBusy(item)}
|
||||
<IconButton label="Cancelar subida" size="xs" onclick={() => onCancel(item.id)}>
|
||||
<X class="size-icon-sm" />
|
||||
</IconButton>
|
||||
{:else}
|
||||
{#if item.phase === 'error' && !item.error}
|
||||
<IconButton label="Reintentar" size="xs" onclick={() => onRetry(item.id)}>
|
||||
<RotateCcw class="size-icon-sm" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
{#if item.phase !== 'processing'}
|
||||
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
|
||||
<X class="size-icon-sm" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataGrid>
|
||||
|
||||
@@ -1,139 +1,68 @@
|
||||
<script lang="ts" module>
|
||||
import type { Visibility } from '$lib/data/uploadQueue';
|
||||
|
||||
const VISIBILITY: { value: Visibility; label: string }[] = [
|
||||
{ value: 'private', label: 'Privado' },
|
||||
{ value: 'public', label: 'Público' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import type { GenreOption } from '$lib/types';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
import GenrePicker from '$lib/components/music/GenrePicker.svelte';
|
||||
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
|
||||
import Input from '$lib/components/ui/primitives/Input.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
|
||||
|
||||
interface AlbumChoice {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
genres: GenreOption[];
|
||||
albums: AlbumChoice[];
|
||||
selectedGenres: string[];
|
||||
visibility: Visibility;
|
||||
coauthors: string[];
|
||||
title: string;
|
||||
titleDisabled: boolean;
|
||||
albumId: string;
|
||||
hasCover: boolean;
|
||||
oncover: (file: File) => void;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
genres,
|
||||
albums,
|
||||
selectedGenres = $bindable(),
|
||||
visibility = $bindable(),
|
||||
coauthors = $bindable(),
|
||||
title = $bindable(),
|
||||
titleDisabled,
|
||||
albumId = $bindable(),
|
||||
hasCover,
|
||||
oncover,
|
||||
disabled = false,
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
|
||||
let artist = $state('');
|
||||
let hasCover = $state(false);
|
||||
|
||||
function addArtist() {
|
||||
const name = artist.trim();
|
||||
if (name === '') return;
|
||||
const known = coauthors.some((current) => current.toLowerCase() === name.toLowerCase());
|
||||
if (!known) coauthors = [...coauthors, name];
|
||||
artist = '';
|
||||
}
|
||||
|
||||
function onArtistKey(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
addArtist();
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="flex flex-col gap-6 {klass}">
|
||||
<p class="text-eyebrow text-fg-2">Aplicar a esta subida</p>
|
||||
<p class="text-eyebrow text-fg-2">Aplicar a toda la cola</p>
|
||||
|
||||
<div class="grid gap-8 sm:grid-cols-[auto_1fr]">
|
||||
<Field label="Portada">
|
||||
<div class="w-full max-w-cover-fill">
|
||||
<ImageDropzone
|
||||
name="cover"
|
||||
icon={ImageIcon}
|
||||
gradient
|
||||
size="fill"
|
||||
onselect={() => (hasCover = true)}
|
||||
/>
|
||||
<ImageDropzone name="cover" icon={ImageIcon} gradient size="fill" onselect={oncover} />
|
||||
</div>
|
||||
{#snippet hint()}
|
||||
<p>{hasCover ? 'Portada lista' : 'Haz clic para elegir una imagen'}</p>
|
||||
<p>{hasCover ? 'Portada lista' : 'Opcional. Haz clic para elegir una imagen'}</p>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-7">
|
||||
<GenrePicker {genres} bind:selected={selectedGenres} />
|
||||
|
||||
<Field label="Título" for="upload-title">
|
||||
<Input
|
||||
id="upload-title"
|
||||
bind:value={title}
|
||||
maxlength={LIMITS.trackTitle}
|
||||
disabled={titleDisabled}
|
||||
placeholder={titleDisabled
|
||||
? 'Varios archivos · edita cada título en la cola'
|
||||
: 'Nombre de la canción'}
|
||||
/>
|
||||
<Field label="Añadir a un álbum" for="upload-album">
|
||||
<select id="upload-album" class="field-control px-4" bind:value={albumId} {disabled}>
|
||||
<option value="">Ninguno</option>
|
||||
{#each albums as album (album.id)}
|
||||
<option value={album.id}>{album.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#snippet hint()}
|
||||
<p>
|
||||
¿Otro álbum? <a class="text-fg underline" href="/library/albums/new">Crea uno nuevo</a>
|
||||
y vuelve.
|
||||
</p>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
||||
<div class="grid gap-7 md:grid-cols-2">
|
||||
<Field label="Visibilidad">
|
||||
<SegmentedControl options={VISIBILITY} bind:value={visibility} />
|
||||
</Field>
|
||||
|
||||
<Field label="Artistas colaboradores" for="upload-coauthor">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
id="upload-coauthor"
|
||||
bind:value={artist}
|
||||
maxlength={LIMITS.trackTitle}
|
||||
placeholder="Nombre del artista"
|
||||
onkeydown={onArtistKey}
|
||||
/>
|
||||
<IconButton label="Añadir artista" surface onclick={addArtist}>
|
||||
<Plus class="size-icon-sm" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{#if coauthors.length > 0}
|
||||
<ul class="mt-3 flex flex-wrap gap-2">
|
||||
{#each coauthors as name (name)}
|
||||
<li
|
||||
class="flex items-center gap-1 rounded-full bg-btn py-1 pr-1 pl-3.5 text-xs text-fg"
|
||||
>
|
||||
{name}
|
||||
<IconButton
|
||||
label="Quitar a {name}"
|
||||
size="xs"
|
||||
plain
|
||||
onclick={() => (coauthors = coauthors.filter((current) => current !== name))}
|
||||
>
|
||||
<X class="size-icon-xs" />
|
||||
</IconButton>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#snippet hint()}
|
||||
<p>Aparecerán como coautores de cada canción.</p>
|
||||
{/snippet}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function appNavSections(
|
||||
label: 'Gestionar música',
|
||||
icon: Music,
|
||||
inMobileTabs: false,
|
||||
activeFor: ['/albums', '/upload']
|
||||
activeFor: ['/albums']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user