Upload from the library queue straight to storage and retire the upload page
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']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
];
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ export const COL = {
|
||||
status: '7rem',
|
||||
statusQueue: '8.75rem',
|
||||
statusMobile: '6rem',
|
||||
access: '6rem',
|
||||
actions: '4.75rem',
|
||||
actionsMobile: '4rem',
|
||||
size: '5.5rem',
|
||||
durationQueue: '4.5rem'
|
||||
} as const;
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
import { trackNavigation } from '$lib/state/history.svelte';
|
||||
import { restoreScroll } from '$lib/state/scroll';
|
||||
import { page } from '$app/state';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { uploadQueue } from '$lib/state/uploadQueue.svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
@@ -34,6 +36,9 @@
|
||||
);
|
||||
|
||||
trackNavigation();
|
||||
beforeNavigate((navigation) => {
|
||||
if (navigation.willUnload && uploadQueue.active) navigation.cancel();
|
||||
});
|
||||
restoreScroll(() => scroller);
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiFor } from '$lib/server/api';
|
||||
import { loadUserAlbums } from '$lib/server/albums';
|
||||
import { requireUser } from '$lib/server/guards';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
requireUser(locals, url);
|
||||
const user = requireUser(locals, url);
|
||||
const api = apiFor({ fetch, locals });
|
||||
const genres = await api.GET('/genres/available');
|
||||
return { genres: genres.data ?? [] };
|
||||
const [genres, albums] = await Promise.all([
|
||||
api.GET('/genres/available'),
|
||||
loadUserAlbums(api, user.sub)
|
||||
]);
|
||||
return {
|
||||
genres: genres.data ?? [],
|
||||
albums: albums.items.map(({ id, title }) => ({ id, title }))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<LibraryUploadsSection genres={data.genres} />
|
||||
<LibraryUploadsSection genres={data.genres} albums={data.albums} />
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { apiFor, requireData } from '$lib/server/api';
|
||||
import { requireUser } from '$lib/server/guards';
|
||||
import { authedAction } from '$lib/server/actions/authedAction';
|
||||
import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm';
|
||||
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
|
||||
import { findConflict, genreInfo } from '$lib/data/genres';
|
||||
import type { components } from '$lib/api/schema';
|
||||
|
||||
type Genre = components['schemas']['Genre'];
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
requireUser(locals, url);
|
||||
const api = apiFor({ fetch, locals });
|
||||
const { data } = await api.GET('/genres/available');
|
||||
return { genres: data ?? [] };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: authedAction(async ({ api, form }) => {
|
||||
const parsed = parseTrackUploadForm(form);
|
||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||
const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body;
|
||||
|
||||
const { data: available } = await api.GET('/genres/available');
|
||||
const options = available ?? [];
|
||||
const known = new Set<string>(options.map((option) => option.genre));
|
||||
if (!rawTags.every((tag) => known.has(tag))) {
|
||||
return fail(400, { message: 'Alguno de los géneros no es válido.' });
|
||||
}
|
||||
const tags = rawTags as Genre[];
|
||||
const conflict = findConflict(tags, options);
|
||||
if (conflict) {
|
||||
return fail(400, {
|
||||
message: `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.`
|
||||
});
|
||||
}
|
||||
|
||||
const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
|
||||
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
|
||||
|
||||
const urlsResult = await api.POST('/tracks/upload-urls', {
|
||||
body: {
|
||||
pictureFileType: extOf(cover.name) || 'jpg',
|
||||
pictureContentType,
|
||||
audioFileType: extOf(audio.name) || 'mp3',
|
||||
audioContentType,
|
||||
expectedPictureSizeBytes: cover.size,
|
||||
expectedAudioSizeBytes: audio.size
|
||||
}
|
||||
});
|
||||
|
||||
const { data: urls, failure: urlsFailure } = requireData(
|
||||
urlsResult,
|
||||
'No se pudieron reservar las URLs de subida.'
|
||||
);
|
||||
if (urlsFailure) return urlsFailure;
|
||||
|
||||
try {
|
||||
const [audioBuffer, coverBuffer] = await Promise.all([
|
||||
audio.arrayBuffer(),
|
||||
cover.arrayBuffer()
|
||||
]);
|
||||
await Promise.all([
|
||||
putPresigned(urls.audioUploadUrl, Buffer.from(audioBuffer), urls.audioContentType),
|
||||
urls.pictureUploadUrl && urls.pictureContentType
|
||||
? putPresigned(urls.pictureUploadUrl, Buffer.from(coverBuffer), urls.pictureContentType)
|
||||
: Promise.resolve()
|
||||
]);
|
||||
} catch (err) {
|
||||
return fail(502, {
|
||||
message: 'Falló la subida del archivo al almacenamiento.',
|
||||
detail: err instanceof Error ? err.message : undefined
|
||||
});
|
||||
}
|
||||
|
||||
const trackResult = await api.POST('/tracks', {
|
||||
body: {
|
||||
title,
|
||||
pictureIntentId: urls.pictureIntentId,
|
||||
audioIntentId: urls.audioIntentId,
|
||||
tags,
|
||||
isExplicit
|
||||
}
|
||||
});
|
||||
|
||||
const { data: track, failure: trackFailure } = requireData(
|
||||
trackResult,
|
||||
'No se pudo crear la pista tras la subida.'
|
||||
);
|
||||
if (trackFailure) return trackFailure;
|
||||
|
||||
return { success: true, trackId: track.id, title: track.title };
|
||||
}, 'Inicia sesión para subir música.')
|
||||
};
|
||||
@@ -1,299 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Headphones from '@lucide/svelte/icons/headphones';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
import Surface from '$lib/components/ui/primitives/Surface.svelte';
|
||||
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
|
||||
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||
import Input from '$lib/components/ui/primitives/Input.svelte';
|
||||
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
import { formatSize } from '$lib/utils/format';
|
||||
import GenrePicker from '$lib/components/music/GenrePicker.svelte';
|
||||
|
||||
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
||||
type StepState = 'idle' | 'active' | 'done';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let title = $state('');
|
||||
let audioName = $state('');
|
||||
let audioSize = $state(0);
|
||||
let hasCover = $state(false);
|
||||
let coverResetToken = $state(0);
|
||||
let dragging = $state(false);
|
||||
let status = $state<Status>('idle');
|
||||
let acceptedTerms = $state(false);
|
||||
let isExplicit = $state(false);
|
||||
let errorMsg = $state('');
|
||||
let errorDetail = $state('');
|
||||
let tags = $state<string[]>([]);
|
||||
let publishedTitle = $state('');
|
||||
let audioInput = $state<HTMLInputElement>();
|
||||
|
||||
const steps = [
|
||||
{ key: 'upload', label: 'Subir', hint: 'Portada y audio.' },
|
||||
{ key: 'done', label: 'Listo', hint: 'Se procesa y aparece en tu biblioteca.' }
|
||||
];
|
||||
|
||||
const busy = $derived(status === 'uploading');
|
||||
|
||||
const canPublish = $derived(
|
||||
title.trim() !== '' && audioName !== '' && hasCover && tags.length > 0 && acceptedTerms && !busy
|
||||
);
|
||||
|
||||
const stepStates = $derived.by<StepState[]>(() => {
|
||||
if (status === 'done') return ['done', 'done'];
|
||||
if (status === 'uploading') return ['active', 'idle'];
|
||||
return ['idle', 'idle'];
|
||||
});
|
||||
|
||||
function stepClass(s: StepState) {
|
||||
return s === 'idle' ? '' : 'bg-accent-tint';
|
||||
}
|
||||
|
||||
function pickAudio(file: File) {
|
||||
audioName = file.name;
|
||||
audioSize = file.size;
|
||||
if (title.trim() === '') title = file.name.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
function onAudioInput(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (file) pickAudio(file);
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragging = false;
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (!file || !audioInput) return;
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
audioInput.files = dt.files;
|
||||
pickAudio(file);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
title = '';
|
||||
audioName = '';
|
||||
audioSize = 0;
|
||||
hasCover = false;
|
||||
coverResetToken += 1;
|
||||
status = 'idle';
|
||||
acceptedTerms = false;
|
||||
isExplicit = false;
|
||||
errorMsg = '';
|
||||
errorDetail = '';
|
||||
tags = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Subir música</title>
|
||||
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="Subir música"
|
||||
description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
||||
/>
|
||||
|
||||
<ol class="grid gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
{#each steps as step, i (step.key)}
|
||||
<li>
|
||||
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="grid size-7 shrink-0 place-items-center rounded-full text-sm {stepStates[i] ===
|
||||
'idle'
|
||||
? 'bg-surface-2 text-fg-2'
|
||||
: 'bg-accent-btn text-accent-soft'}"
|
||||
>
|
||||
{#if stepStates[i] === 'done'}
|
||||
<Check class="size-icon-xs" />
|
||||
{:else}
|
||||
{i + 1}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-fg">{step.label}</span>
|
||||
</div>
|
||||
<p class="mt-2.5 text-xs text-fg-2">{step.hint}</p>
|
||||
</Surface>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
{#if status === 'done'}
|
||||
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
|
||||
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
|
||||
<Check class="size-icon-2xl" />
|
||||
</span>
|
||||
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
|
||||
<p class="mt-3 text-sm text-fg-2">
|
||||
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
|
||||
unos momentos.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4">
|
||||
<Button href="/library" size="lg" class="w-full sm:w-auto">Ir a la biblioteca</Button>
|
||||
<Button variant="secondary" size="lg" onclick={reset} class="w-full sm:w-auto">
|
||||
Subir otra
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
status = 'uploading';
|
||||
errorMsg = '';
|
||||
errorDetail = '';
|
||||
return async ({ result }) => {
|
||||
if (result.type === 'success') {
|
||||
publishedTitle = title;
|
||||
status = 'done';
|
||||
} else if (result.type === 'failure') {
|
||||
status = 'error';
|
||||
errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.';
|
||||
errorDetail = (result.data?.detail as string | undefined) ?? '';
|
||||
} else {
|
||||
status = 'error';
|
||||
errorMsg = 'Error inesperado durante la subida.';
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mt-10 space-y-8"
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={onDrop}
|
||||
class="relative flex min-h-48 flex-col items-center justify-center rounded-panel p-6 text-center transition sm:min-h-64 sm:p-10 {dragging
|
||||
? 'bg-surface-2 ring-2 ring-accent/50'
|
||||
: 'bg-surface'}"
|
||||
>
|
||||
<input
|
||||
bind:this={audioInput}
|
||||
type="file"
|
||||
name="audio"
|
||||
accept="audio/*"
|
||||
onchange={onAudioInput}
|
||||
class="absolute inset-0 cursor-pointer opacity-0 focus:outline-none"
|
||||
aria-label="Seleccionar archivo de audio"
|
||||
/>
|
||||
{#if audioName === ''}
|
||||
<Headphones class="size-icon-xl text-fg-2 sm:size-icon-3xl" />
|
||||
{:else}
|
||||
<Music class="size-icon-xl text-accent-soft sm:size-icon-3xl" />
|
||||
{/if}
|
||||
{#if audioName === ''}
|
||||
<p class="mt-4 text-sm font-medium text-fg">
|
||||
Arrastra tu audio aquí o haz clic para elegir
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-fg-2">MP3, FLAC, WAV…</p>
|
||||
{:else}
|
||||
<p class="mt-4 max-w-full truncate text-sm font-medium text-fg">
|
||||
{audioName}
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-fg-2">{formatSize(audioSize)} · Listo para subir</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Surface padding="md">
|
||||
<div class="grid items-start gap-6 md:grid-cols-[auto_1fr] md:gap-10">
|
||||
<Field label="Portada" class="items-center md:items-start">
|
||||
{#key coverResetToken}
|
||||
<ImageDropzone
|
||||
name="cover"
|
||||
icon={ImageIcon}
|
||||
gradient
|
||||
onselect={() => (hasCover = true)}
|
||||
/>
|
||||
{/key}
|
||||
{#snippet hint()}
|
||||
<p>{hasCover ? 'Portada lista' : 'Haz clic para elegir'}</p>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-7">
|
||||
<Field label="Título" for="title">
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
bind:value={title}
|
||||
maxlength={LIMITS.trackTitle}
|
||||
placeholder="Nombre de la canción"
|
||||
/>
|
||||
{#snippet hint()}
|
||||
<p>Se usa como nombre público de la pista en el catálogo.</p>
|
||||
<span class="shrink-0 tabular-nums">{title.length}/{LIMITS.trackTitle}</span>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
||||
<GenrePicker
|
||||
genres={data.genres}
|
||||
bind:selected={tags}
|
||||
name="tags"
|
||||
help="Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient."
|
||||
/>
|
||||
|
||||
<Checkbox bind:checked={isExplicit} name="isExplicit">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
Contenido explícito
|
||||
<ExplicitBadge />
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Checkbox bind:checked={acceptedTerms}>
|
||||
Acepto los términos y condiciones al subir {title.trim() !== ''
|
||||
? `«${title}»`
|
||||
: 'esta canción'}.
|
||||
</Checkbox>
|
||||
|
||||
{#if status === 'uploading'}
|
||||
<Surface padding="sm">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-fg">Subiendo y creando la pista…</span>
|
||||
<span class="text-xs text-fg-2">un momento</span>
|
||||
</div>
|
||||
<div class="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
|
||||
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
|
||||
</div>
|
||||
</Surface>
|
||||
{/if}
|
||||
|
||||
{#if status === 'error'}
|
||||
<Alert tone="danger">
|
||||
{errorMsg}
|
||||
{#if errorDetail}<span class="mt-1 block text-xs opacity-80">{errorDetail}</span>{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end sm:gap-4">
|
||||
<Button href="/explore" variant="secondary" size="lg" class="w-full sm:w-auto">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" size="lg" disabled={!canPublish} class="w-full sm:w-auto">
|
||||
Publicar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Page>
|
||||
@@ -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