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

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