+ import Plus from '@lucide/svelte/icons/plus';
+
+ interface Props {
+ title: string;
+ subtitle?: string | null;
+ onclick: () => void;
+ index?: number;
+ }
+
+ let { title, subtitle, onclick, index = 0 }: Props = $props();
+
+
+
diff --git a/web-player/src/lib/components/ui/media/StatusBadge.svelte b/web-player/src/lib/components/ui/media/StatusBadge.svelte
new file mode 100644
index 0000000..95e7b52
--- /dev/null
+++ b/web-player/src/lib/components/ui/media/StatusBadge.svelte
@@ -0,0 +1,21 @@
+
+
+
{label}
diff --git a/web-player/src/lib/components/ui/primitives/Checkbox.svelte b/web-player/src/lib/components/ui/primitives/Checkbox.svelte
index 1cf27e2..d2de1e7 100644
--- a/web-player/src/lib/components/ui/primitives/Checkbox.svelte
+++ b/web-player/src/lib/components/ui/primitives/Checkbox.svelte
@@ -5,11 +5,12 @@
interface Props {
checked?: boolean;
name?: string;
+ label?: string;
class?: string;
- children: Snippet;
+ children?: Snippet;
}
- let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
+ let { checked = $bindable(false), name, label, class: klass = '', children }: Props = $props();
diff --git a/web-player/src/lib/components/ui/primitives/Chip.svelte b/web-player/src/lib/components/ui/primitives/Chip.svelte
index 6cdbc65..1853339 100644
--- a/web-player/src/lib/components/ui/primitives/Chip.svelte
+++ b/web-player/src/lib/components/ui/primitives/Chip.svelte
@@ -28,6 +28,7 @@
type={href ? undefined : 'button'}
disabled={href ? undefined : disabled}
onclick={href ? undefined : onclick}
+ aria-pressed={href ? undefined : selected}
class={classes}
>
{@render children()}
diff --git a/web-player/src/lib/components/ui/primitives/ChipGroup.svelte b/web-player/src/lib/components/ui/primitives/ChipGroup.svelte
new file mode 100644
index 0000000..b19be29
--- /dev/null
+++ b/web-player/src/lib/components/ui/primitives/ChipGroup.svelte
@@ -0,0 +1,30 @@
+
+
+
+ {#each options as option (option.value)}
+ (value = option.value)}
+ >
+ {option.label}
+
+ {/each}
+
diff --git a/web-player/src/lib/components/ui/primitives/Input.svelte b/web-player/src/lib/components/ui/primitives/Input.svelte
index 1ea8563..c9602a4 100644
--- a/web-player/src/lib/components/ui/primitives/Input.svelte
+++ b/web-player/src/lib/components/ui/primitives/Input.svelte
@@ -11,9 +11,11 @@
placeholder?: string;
maxlength?: number;
required?: boolean;
+ disabled?: boolean;
autocomplete?: 'on' | 'off';
icon?: LucideIcon;
oninput?: (event: Event) => void;
+ onkeydown?: (event: KeyboardEvent) => void;
class?: string;
}
@@ -27,9 +29,11 @@
placeholder,
maxlength,
required = false,
+ disabled = false,
autocomplete = 'off',
icon: Icon,
oninput,
+ onkeydown,
class: klass = ''
}: Props = $props();
@@ -49,8 +53,10 @@
{placeholder}
{maxlength}
{required}
+ {disabled}
{autocomplete}
{oninput}
+ {onkeydown}
bind:value
class="field-control {Icon ? 'pr-4 pl-11' : 'px-4'} {klass}"
/>
diff --git a/web-player/src/lib/components/ui/primitives/SearchInput.svelte b/web-player/src/lib/components/ui/primitives/SearchInput.svelte
new file mode 100644
index 0000000..5ca19b0
--- /dev/null
+++ b/web-player/src/lib/components/ui/primitives/SearchInput.svelte
@@ -0,0 +1,55 @@
+
+
+
diff --git a/web-player/src/lib/data/libraryTracks.test.ts b/web-player/src/lib/data/libraryTracks.test.ts
new file mode 100644
index 0000000..842488c
--- /dev/null
+++ b/web-player/src/lib/data/libraryTracks.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import type { LibraryTrack } from '$lib/types';
+import { filterLibraryTracks } from './libraryTracks';
+
+function track(id: string, title: string, status: LibraryTrack['status']): LibraryTrack {
+ return {
+ id,
+ title,
+ artist: 'Artista',
+ ownerUserId: '1',
+ explicit: false,
+ duration: 60,
+ listensCount: 0,
+ date: '2026-01-01T00:00:00Z',
+ status,
+ genres: [],
+ albumTitle: null
+ };
+}
+
+const tracks = [
+ track('1', 'Alba', 'Completed'),
+ track('2', 'Noche', 'Processing'),
+ track('3', 'Ruido', 'Pending'),
+ track('4', 'Roto', 'Failed')
+];
+
+describe('filterLibraryTracks', () => {
+ it('returns everything for the "all" filter without a query', () => {
+ expect(filterLibraryTracks(tracks, '', 'all')).toHaveLength(4);
+ });
+ it('groups pending and processing tracks together', () => {
+ expect(filterLibraryTracks(tracks, '', 'processing').map((t) => t.id)).toEqual(['2', '3']);
+ });
+ it('combines the status filter with the text query', () => {
+ expect(filterLibraryTracks(tracks, ' alb ', 'published').map((t) => t.id)).toEqual(['1']);
+ expect(filterLibraryTracks(tracks, 'alb', 'failed')).toEqual([]);
+ });
+ it('matches the artist too', () => {
+ expect(filterLibraryTracks(tracks, 'artista', 'all')).toHaveLength(4);
+ });
+});
diff --git a/web-player/src/lib/data/libraryTracks.ts b/web-player/src/lib/data/libraryTracks.ts
new file mode 100644
index 0000000..94405a5
--- /dev/null
+++ b/web-player/src/lib/data/libraryTracks.ts
@@ -0,0 +1,41 @@
+import type { LibraryTrack, TrackStatus } from '$lib/types';
+
+export type TrackTone = 'success' | 'neutral' | 'danger';
+
+export const TRACK_STATUS: Record
= {
+ Completed: { label: 'Publicada', tone: 'success' },
+ Processing: { label: 'Procesando', tone: 'neutral' },
+ Pending: { label: 'En cola', tone: 'neutral' },
+ Failed: { label: 'Con error', tone: 'danger' }
+};
+
+export const TRACK_FILTERS = [
+ { value: 'all', label: 'Todas', statuses: null },
+ { value: 'published', label: 'Publicadas', statuses: ['Completed'] },
+ { value: 'processing', label: 'Procesando', statuses: ['Pending', 'Processing'] },
+ { value: 'failed', label: 'Con error', statuses: ['Failed'] }
+] as const satisfies readonly {
+ value: string;
+ label: string;
+ statuses: readonly TrackStatus[] | null;
+}[];
+
+export type TrackFilter = (typeof TRACK_FILTERS)[number]['value'];
+
+export function filterLibraryTracks(
+ tracks: LibraryTrack[],
+ query: string,
+ filter: TrackFilter
+): LibraryTrack[] {
+ const statuses: readonly TrackStatus[] | null =
+ TRACK_FILTERS.find((option) => option.value === filter)?.statuses ?? null;
+ const needle = query.trim().toLowerCase();
+
+ return tracks.filter(
+ (track) =>
+ (!statuses || statuses.includes(track.status)) &&
+ (!needle ||
+ track.title.toLowerCase().includes(needle) ||
+ (track.artist ?? '').toLowerCase().includes(needle))
+ );
+}
diff --git a/web-player/src/lib/data/uploadQueue.test.ts b/web-player/src/lib/data/uploadQueue.test.ts
new file mode 100644
index 0000000..153f0e9
--- /dev/null
+++ b/web-player/src/lib/data/uploadQueue.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import {
+ summarizeQueue,
+ titleFromFileName,
+ toQueuedUpload,
+ uploadError,
+ UPLOAD_MAX_BYTES
+} from '$lib/data/uploadQueue';
+
+function file(name: string, size = 1024) {
+ return new File([new Uint8Array(size)], name);
+}
+
+describe('titleFromFileName', () => {
+ it('drops the extension and track number and humanizes underscores', () => {
+ expect(titleFromFileName('05_ultima_ola_master.wav')).toBe('Ultima ola master');
+ expect(titleFromFileName('12 - Niebla.flac')).toBe('Niebla');
+ });
+
+ it('keeps the base name when nothing is left after cleaning', () => {
+ expect(titleFromFileName('07.mp3')).toBe('07');
+ });
+});
+
+describe('uploadError', () => {
+ it('accepts supported formats', () => {
+ for (const name of ['a.wav', 'a.FLAC', 'a.mp3', 'a.aiff']) {
+ expect(uploadError(file(name))).toBeNull();
+ }
+ });
+
+ it('rejects unsupported formats, empty and oversized files', () => {
+ expect(uploadError(file('portada.psd'))).toBe('Formato no compatible');
+ expect(uploadError(file('a.wav', 0))).toBe('Archivo vacío');
+ const big = { name: 'a.wav', size: UPLOAD_MAX_BYTES + 1 } as File;
+ expect(uploadError(big)).toBe('Supera 500 MB');
+ });
+});
+
+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 });
+ });
+});
diff --git a/web-player/src/lib/data/uploadQueue.ts b/web-player/src/lib/data/uploadQueue.ts
new file mode 100644
index 0000000..2d73a99
--- /dev/null
+++ b/web-player/src/lib/data/uploadQueue.ts
@@ -0,0 +1,98 @@
+export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
+export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
+
+export type Visibility = 'private' | 'public';
+
+export interface QueuedUpload {
+ id: string;
+ file: File;
+ title: string;
+ format: string;
+ error: string | null;
+ duration: number | null;
+}
+
+export function fileExtension(name: string): string {
+ const dot = name.lastIndexOf('.');
+ return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
+}
+
+export function titleFromFileName(name: string): string {
+ const base = name.replace(/\.[^.]+$/, '');
+ const cleaned = base
+ .replace(/^\d{1,3}\s*[-_.)]\s*/, '')
+ .replace(/[_]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ if (cleaned === '') return base;
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
+}
+
+export function uploadError(file: File): string | null {
+ if (!UPLOAD_FORMATS.includes(fileExtension(file.name))) return 'Formato no compatible';
+ if (file.size > UPLOAD_MAX_BYTES) return 'Supera 500 MB';
+ if (file.size === 0) return 'Archivo vacío';
+ return null;
+}
+
+export function toQueuedUpload(file: File): QueuedUpload {
+ return {
+ id: crypto.randomUUID(),
+ file,
+ title: titleFromFileName(file.name),
+ format: fileExtension(file.name).toUpperCase() || '?',
+ error: uploadError(file),
+ duration: 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 };
+}
+
+async function readEntries(reader: FileSystemDirectoryReader): Promise {
+ const all: FileSystemEntry[] = [];
+ for (;;) {
+ const batch = await new Promise((resolve, reject) =>
+ reader.readEntries(resolve, reject)
+ );
+ if (batch.length === 0) return all;
+ all.push(...batch);
+ }
+}
+
+async function filesFromEntry(entry: FileSystemEntry): Promise {
+ if (entry.isFile) {
+ const file = await new Promise((resolve, reject) =>
+ (entry as FileSystemFileEntry).file(resolve, reject)
+ );
+ return [file];
+ }
+ if (!entry.isDirectory) return [];
+ const children = await readEntries((entry as FileSystemDirectoryEntry).createReader());
+ return (await Promise.all(children.map(filesFromEntry))).flat();
+}
+
+export async function filesFromDrop(transfer: DataTransfer): Promise {
+ const entries = Array.from(transfer.items)
+ .map((item) => item.webkitGetAsEntry?.() ?? null)
+ .filter((entry): entry is FileSystemEntry => entry !== null);
+ if (entries.length === 0) return Array.from(transfer.files);
+ return (await Promise.all(entries.map(filesFromEntry))).flat();
+}
+
+export function readDuration(file: File): Promise {
+ return new Promise((resolve) => {
+ const url = URL.createObjectURL(file);
+ const audio = new Audio();
+ const done = (value: number | null) => {
+ URL.revokeObjectURL(url);
+ resolve(value);
+ };
+ audio.preload = 'metadata';
+ audio.onloadedmetadata = () => done(Number.isFinite(audio.duration) ? audio.duration : null);
+ audio.onerror = () => done(null);
+ audio.src = url;
+ });
+}
diff --git a/web-player/src/lib/server/mappers.ts b/web-player/src/lib/server/mappers.ts
index 314c2db..6149532 100644
--- a/web-player/src/lib/server/mappers.ts
+++ b/web-player/src/lib/server/mappers.ts
@@ -1,5 +1,14 @@
import type { components } from '$lib/api/schema';
-import type { Album, LikedTrack, ListeningStats, Mix, Paged, Playlist, Track } from '$lib/types';
+import type {
+ Album,
+ LibraryTrack,
+ LikedTrack,
+ ListeningStats,
+ Mix,
+ Paged,
+ Playlist,
+ Track
+} from '$lib/types';
type TrackDto = components['schemas']['TrackApplicationResponse'];
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
@@ -30,6 +39,15 @@ export function toDatedTrack(dto: TrackDto): Track & { date: string } {
return { ...toTrack(dto), date: dto.createdAt };
}
+export function toLibraryTrack(dto: TrackDto, albumTitle: string | null): LibraryTrack {
+ return {
+ ...toDatedTrack(dto),
+ status: dto.audioStatus,
+ genres: dto.tags,
+ albumTitle
+ };
+}
+
export function toLikedTrack(dto: TrackDto): LikedTrack {
return { ...toTrack(dto), likedAt: new Date(dto.createdAt).getTime() };
}
diff --git a/web-player/src/lib/types.ts b/web-player/src/lib/types.ts
index 5eedb54..35b2e81 100644
--- a/web-player/src/lib/types.ts
+++ b/web-player/src/lib/types.ts
@@ -17,6 +17,15 @@ export interface Track {
listensCount: number;
}
+export type TrackStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed';
+
+export interface LibraryTrack extends Track {
+ date: string;
+ status: TrackStatus;
+ genres: string[];
+ albumTitle: string | null;
+}
+
export interface LikedTrack extends Track {
likedAt: number;
}
diff --git a/web-player/src/routes/(app)/albums/+page.svelte b/web-player/src/routes/(app)/albums/+page.svelte
index 8d31a5a..5bb2990 100644
--- a/web-player/src/routes/(app)/albums/+page.svelte
+++ b/web-player/src/routes/(app)/albums/+page.svelte
@@ -36,7 +36,7 @@
{#if items.length > 0}
-
+
{#each items as album, i (album.id)}
> {
+ const entries = await Promise.all(
+ albums.map(async (album) => {
+ const tracks = await api.GET('/albums/{id}/tracks', {
+ params: {
+ path: { id: album.id },
+ query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE }
+ }
+ });
+ return [album.id, (tracks.data?.items ?? []).map((track) => track.id)] as const;
+ })
+ );
+ return Object.fromEntries(entries);
+}
+
+function albumTitleByTrackId(albums: Album[], trackIds: Record) {
+ return new Map(
+ albums.flatMap((album) => (trackIds[album.id] ?? []).map((id) => [id, album.title] as const))
+ );
+}
+
+function formIds(form: FormData, key: string): string[] {
+ return formString(form, key).split(',').filter(Boolean);
+}
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
- const result = await api.GET('/tracks/users/{userId}', {
- params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
- });
+ const [tracksResult, albumsResult, genresResult] = await Promise.all([
+ api.GET('/tracks/users/{userId}', {
+ params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
+ }),
+ api.GET('/albums/users/{userId}', {
+ params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
+ }),
+ api.GET('/genres/available')
+ ]);
+
+ const albums = toPage(unwrapOrError(albumsResult, 'No se pudieron cargar tus álbumes.'), toAlbum);
+ const albumTrackIds = await trackIdsByAlbum(api, albums.items);
+ const albumTitles = albumTitleByTrackId(albums.items, albumTrackIds);
return {
- tracks: toPage(unwrapOrError(result, 'No se pudo cargar tu biblioteca.'), toDatedTrack)
+ tracks: toPage(unwrapOrError(tracksResult, 'No se pudo cargar tu biblioteca.'), (dto) =>
+ toLibraryTrack(dto, albumTitles.get(dto.id) ?? null)
+ ),
+ albums,
+ albumTrackIds,
+ genres: genresResult.data ?? []
};
};
-export const actions: Actions = {
- deleteTrack: authedAction(async ({ api, form }) => {
- const trackId = formString(form, 'trackId');
- if (!trackId) return fail(400, { message: 'Falta la canción.' });
+function tagged(
+ action: string,
+ handler: (event: Parameters[0]>[0]) => Promise
+) {
+ return authedAction(async (event) => {
+ const result = await handler(event);
+ return isActionFailure(result)
+ ? fail(result.status, { ...(result.data as Record | undefined), action })
+ : { ...result, action };
+ });
+}
- const result = await api.DELETE('/tracks/{id}', {
- params: { path: { id: trackId } }
- });
- return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true };
+export const actions: Actions = {
+ deleteTracks: tagged('deleteTracks', async ({ api, form }) => {
+ const trackIds = formIds(form, 'trackIds');
+ if (trackIds.length === 0) return fail(400, { message: 'Falta la canción.' });
+
+ const results = await Promise.all(
+ trackIds.map((id) => api.DELETE('/tracks/{id}', { params: { path: { id } } }))
+ );
+ const failed = results.find((result) => result.error);
+ return (
+ (failed &&
+ failOnError(
+ failed,
+ trackIds.length === 1
+ ? 'No se pudo borrar la canción.'
+ : 'No se pudieron borrar todas las canciones.'
+ )) ?? { deleted: true }
+ );
+ }),
+
+ saveAlbum: tagged('saveAlbum', async ({ api, form }) => {
+ const parsed = parseAlbumForm(form);
+ if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
+
+ const existingId = formString(form, 'albumId');
+ const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover'));
+ if ('failMessage' in cover)
+ return fail(502, { message: cover.failMessage, detail: cover.detail });
+
+ let albumId = existingId;
+ if (existingId) {
+ const updated = await api.PUT('/albums/{id}', {
+ params: { path: { id: existingId } },
+ body: parsed.body
+ });
+ const failure = failOnError(updated, 'No se pudo actualizar el álbum.');
+ if (failure) return failure;
+
+ if (cover.intentId) {
+ const picture = await api.PUT('/albums/{id}/picture', {
+ params: { path: { id: existingId } },
+ body: { pictureIntentId: cover.intentId }
+ });
+ const pictureFailure = failOnError(picture, 'No se pudo actualizar la portada.');
+ if (pictureFailure) return pictureFailure;
+ }
+ } else {
+ if (!cover.intentId) return fail(400, { message: 'La portada es obligatoria.' });
+ const created = await api.POST('/albums', {
+ body: { ...parsed.body, pictureIntentId: cover.intentId }
+ });
+ if (created.error || !created.data)
+ return fail(502, { message: 'No se pudo crear el álbum.' });
+ albumId = created.data.id;
+ }
+
+ const addIds = formIds(form, 'addTrackIds');
+ if (addIds.length > 0) {
+ const added = await api.POST('/albums/{id}/tracks', {
+ params: { path: { id: albumId } },
+ body: { trackIds: addIds }
+ });
+ const failure = failOnError(added, 'No se pudieron añadir las canciones.');
+ if (failure) return failure;
+ }
+
+ const removed = await Promise.all(
+ formIds(form, 'removeTrackIds').map((trackId) =>
+ api.DELETE('/albums/{id}/tracks/{trackId}', { params: { path: { id: albumId, trackId } } })
+ )
+ );
+ const removeFailed = removed.find((result) => result.error);
+ const failure =
+ removeFailed && failOnError(removeFailed, 'No se pudieron quitar las canciones.');
+ return failure ?? { saved: true };
+ }),
+
+ deleteAlbum: tagged('deleteAlbum', async ({ api, form }) => {
+ const albumId = formString(form, 'albumId');
+ if (!albumId) return fail(400, { message: 'Falta el álbum.' });
+
+ const result = await api.DELETE('/albums/{id}', { params: { path: { id: albumId } } });
+ const failure = failOnError(result, 'No se pudo eliminar el álbum.');
+ if (failure) return failure;
+ redirect(303, ALBUMS_SECTION_URL);
})
};
diff --git a/web-player/src/routes/(app)/library/+page.svelte b/web-player/src/routes/(app)/library/+page.svelte
index 3b5ac1e..45a4314 100644
--- a/web-player/src/routes/(app)/library/+page.svelte
+++ b/web-player/src/routes/(app)/library/+page.svelte
@@ -1,81 +1,82 @@
+
+
- Tus canciones subidas
+ Gestionar música
- {#snippet actions()}
- {#if items.length > 0}
-
- {/if}
- {/snippet}
-
+ title="Gestionar música"
+ description="{plural(total, 'canción', 'canciones')} subida{total === 1
+ ? ''
+ : 's'}. Puedes borrarlas o reproducirlas desde aquí."
+ />
- {#if items.length > 0}
-
+ {#each SECTIONS as option (option.value)}
+
+ {/each}
+
+
+ {#if section === 'tracks'}
+
(deleting = items.find((item) => item.id === track.id) ?? null),
- icon: X,
- label: 'Borrar canción'
- }}
+ errorMessage={form?.action === 'deleteTracks' ? form.message : undefined}
/>
+ {:else if section === 'albums'}
+ {#key resetKey}
+
+ {/key}
{:else}
-
- {#snippet actions()}
-
- {/snippet}
-
+
{/if}
-
- (deleting = null)}
- title="¿Borrar «{deleting?.title ?? ''}»?"
- description="Esta acción no se puede deshacer, la canción se eliminará de tu biblioteca, de tus álbumes y de las playlists donde esté."
- confirmLabel="Borrar"
- action="?/deleteTrack"
- fields={{ trackId: deleting?.id ?? '' }}
-/>
diff --git a/web-player/src/routes/layout.css b/web-player/src/routes/layout.css
index c6d0ea4..b2fa099 100644
--- a/web-player/src/routes/layout.css
+++ b/web-player/src/routes/layout.css
@@ -271,6 +271,16 @@
}
@layer base {
+ input[type='number'] {
+ appearance: textfield;
+ }
+
+ input[type='number']::-webkit-inner-spin-button,
+ input[type='number']::-webkit-outer-spin-button {
+ appearance: none;
+ margin: 0;
+ }
+
svg.lucide {
stroke-width: 1.5;
}