diff --git a/web-player/src/lib/components/music/TrackStatusBadge.svelte b/web-player/src/lib/components/music/TrackStatusBadge.svelte
index d22ba28..5ff2dc0 100644
--- a/web-player/src/lib/components/music/TrackStatusBadge.svelte
+++ b/web-player/src/lib/components/music/TrackStatusBadge.svelte
@@ -1,21 +1,16 @@
-
+
diff --git a/web-player/src/lib/components/music/library/LibraryAlbumEditor.svelte b/web-player/src/lib/components/music/library/LibraryAlbumEditor.svelte
index 05a8796..b2ae497 100644
--- a/web-player/src/lib/components/music/library/LibraryAlbumEditor.svelte
+++ b/web-player/src/lib/components/music/library/LibraryAlbumEditor.svelte
@@ -27,6 +27,7 @@
layout="split"
{artist}
showType
+ initialType={album ? (album.type.toLowerCase() as 'album' | 'ep' | 'single') : 'album'}
initialTitle={album?.title}
initialDescription={album?.description ?? ''}
initialReleaseYear={album?.releaseYear ?? new Date().getFullYear()}
diff --git a/web-player/src/lib/components/music/library/LibraryTracksSection.svelte b/web-player/src/lib/components/music/library/LibraryTracksSection.svelte
index 92e4d12..f2d2c00 100644
--- a/web-player/src/lib/components/music/library/LibraryTracksSection.svelte
+++ b/web-player/src/lib/components/music/library/LibraryTracksSection.svelte
@@ -1,19 +1,30 @@
{#if errorMessage}
{errorMessage}
+{:else if notice}
+ {notice}
{/if}
-{#if tracks.length > 0}
+
+
+{#if counts.total > 0}
{#if selectedIds.length > 0}
@@ -76,7 +148,11 @@
label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
onClear={() => (selectedIds = [])}
>
-
+
+
+
{:else}
@@ -86,29 +162,43 @@
out:fade={FADE}
>
-
+
filter, (value) => navigate(search, value)}
+ />
{/if}
- {#if visible.length > 0}
+ {#if items.length > 0}
(pendingDelete = [track])}
/>
+ list.loadMore()}
+ hasMore={list.hasMore}
+ loading={list.loading}
+ error={list.error}
+ />
{:else}
{/if}
{:else}
@@ -129,9 +219,19 @@
0}
onClose={() => (pendingDelete = [])}
- title="¿Borrar {pendingTitle}?"
+ title="¿Borrar {pendingTitle(pendingDelete)}?"
description="Esta acción no se puede deshacer: se eliminarán de tu biblioteca, de tus álbumes y de las playlists donde estén."
confirmLabel="Borrar"
action="?/deleteTracks"
fields={{ trackIds: pendingDelete.map((track) => track.id).join(',') }}
/>
+
+ 0}
+ onClose={() => (pendingUnpublish = [])}
+ title="¿Despublicar {pendingTitle(pendingUnpublish)}?"
+ description="Dejarán de verse en álbumes, playlists, búsquedas y perfiles. Solo tú podrás verlas y reproducirlas."
+ confirmLabel="Despublicar"
+ action="?/setPublished"
+ fields={{ trackIds: pendingUnpublish.map((track) => track.id).join(','), isPublished: 'false' }}
+/>
diff --git a/web-player/src/lib/components/music/library/ManageTrackList.svelte b/web-player/src/lib/components/music/library/ManageTrackList.svelte
index 6eff14a..f62ed51 100644
--- a/web-player/src/lib/components/music/library/ManageTrackList.svelte
+++ b/web-player/src/lib/components/music/library/ManageTrackList.svelte
@@ -17,6 +17,7 @@
align: 'center',
class: `${TEXT} tabular-nums`
},
+ { id: 'publish', width: COL.publish },
{ id: 'delete', width: COL.action, mobile: COL.actionMobile }
];
@@ -26,6 +27,8 @@
import type { LibraryTrack } from '$lib/types';
import { player } from '$lib/player/player.svelte';
import { genreLabels } from '$lib/data/genres';
+ import { canPublish } from '$lib/data/libraryTracks';
+ import Button from '$lib/components/ui/primitives/Button.svelte';
import { fmtTime } from '$lib/utils/format';
import TrackTitleCell from '$lib/components/music/TrackTitleCell.svelte';
import TrackStatusBadge from '$lib/components/music/TrackStatusBadge.svelte';
@@ -37,11 +40,19 @@
tracks: LibraryTrack[];
selectedIds: string[];
onPlay: (index: number) => void;
+ onPublish: (track: LibraryTrack) => void;
onDelete: (track: LibraryTrack) => void;
class?: string;
}
- let { tracks, selectedIds = $bindable(), onPlay, onDelete, class: klass = '' }: Props = $props();
+ let {
+ tracks,
+ selectedIds = $bindable(),
+ onPlay,
+ onPublish,
+ onDelete,
+ class: klass = ''
+ }: Props = $props();
const selected = $derived(new Set(selectedIds));
const allSelected = $derived(
@@ -81,9 +92,20 @@
{:else if column.id === 'genres'}
{genreLabels(track.genres)}
{:else if column.id === 'status'}
-
+
{:else if column.id === 'duration'}
{fmtTime(track.duration)}
+ {:else if column.id === 'publish'}
+
+
+
{:else}
onDelete(track)}>
diff --git a/web-player/src/lib/data/libraryTracks.test.ts b/web-player/src/lib/data/libraryTracks.test.ts
index 842488c..1f6238d 100644
--- a/web-player/src/lib/data/libraryTracks.test.ts
+++ b/web-player/src/lib/data/libraryTracks.test.ts
@@ -1,42 +1,47 @@
import { describe, expect, it } from 'vitest';
-import type { LibraryTrack } from '$lib/types';
-import { filterLibraryTracks } from './libraryTracks';
+import {
+ canPublish,
+ filterCounts,
+ filterQuery,
+ parseTrackFilter,
+ trackState
+} 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);
+describe('trackState', () => {
+ it('never calls a processed track published unless it is', () => {
+ expect(trackState({ status: 'Completed', published: false }).label).toBe('Sin publicar');
+ expect(trackState({ status: 'Completed', published: true }).label).toBe('Publicada');
});
- it('groups pending and processing tracks together', () => {
- expect(filterLibraryTracks(tracks, '', 'processing').map((t) => t.id)).toEqual(['2', '3']);
+ it('pulses while processing', () => {
+ expect(trackState({ status: 'Processing', published: false })).toMatchObject({
+ label: 'Procesando',
+ pulse: true
+ });
+ expect(trackState({ status: 'Failed', published: false }).tone).toBe('danger');
});
- 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([]);
+});
+
+describe('canPublish', () => {
+ it('only allows processed tracks', () => {
+ expect(canPublish({ status: 'Completed' })).toBe(true);
+ expect(canPublish({ status: 'Pending' })).toBe(false);
});
- it('matches the artist too', () => {
- expect(filterLibraryTracks(tracks, 'artista', 'all')).toHaveLength(4);
+});
+
+describe('filters', () => {
+ it('falls back to "all" for unknown values', () => {
+ expect(parseTrackFilter('nope')).toBe('all');
+ expect(parseTrackFilter(null)).toBe('all');
+ expect(parseTrackFilter('failed')).toBe('failed');
+ });
+ it('maps a filter to an API query', () => {
+ expect(filterQuery('processing')).toEqual({ status: ['Pending', 'Processing'] });
+ expect(filterQuery('unpublished')).toEqual({ status: ['Completed'], published: false });
+ expect(filterQuery('all')).toEqual({});
+ });
+ it('derives the chip counts', () => {
+ expect(
+ filterCounts({ total: 10, processing: 2, completed: 6, failed: 2, published: 4 })
+ ).toEqual({ all: 10, published: 4, unpublished: 2, processing: 2, failed: 2 });
});
});
diff --git a/web-player/src/lib/data/libraryTracks.ts b/web-player/src/lib/data/libraryTracks.ts
index 94405a5..249b7be 100644
--- a/web-player/src/lib/data/libraryTracks.ts
+++ b/web-player/src/lib/data/libraryTracks.ts
@@ -1,41 +1,67 @@
-import type { LibraryTrack, TrackStatus } from '$lib/types';
+import type { LibraryTrack, TrackStatus, TrackStatusCounts } from '$lib/types';
export type TrackTone = 'success' | 'neutral' | 'danger';
export const TRACK_STATUS: Record = {
- Completed: { label: 'Publicada', tone: 'success' },
+ Completed: { label: 'Lista', tone: 'neutral' },
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;
+export function trackState(track: Pick): {
label: string;
- statuses: readonly TrackStatus[] | null;
-}[];
+ tone: TrackTone;
+ pulse: boolean;
+} {
+ if (track.status === 'Completed') {
+ return track.published
+ ? { label: 'Publicada', tone: 'success', pulse: false }
+ : { label: 'Sin publicar', tone: 'neutral', pulse: false };
+ }
+ return {
+ ...TRACK_STATUS[track.status],
+ pulse: track.status === 'Processing' || track.status === 'Pending'
+ };
+}
+
+export function canPublish(track: Pick): boolean {
+ return track.status === 'Completed';
+}
+
+export interface TrackFilterQuery {
+ status?: TrackStatus[];
+ published?: boolean;
+}
+
+export const TRACK_FILTERS = [
+ { value: 'all', label: 'Todas', query: {} },
+ { value: 'published', label: 'Publicadas', query: { published: true } },
+ {
+ value: 'unpublished',
+ label: 'Sin publicar',
+ query: { status: ['Completed'], published: false }
+ },
+ { value: 'processing', label: 'Procesando', query: { status: ['Pending', 'Processing'] } },
+ { value: 'failed', label: 'Con error', query: { status: ['Failed'] } }
+] as const satisfies readonly { value: string; label: string; query: TrackFilterQuery }[];
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();
+export function parseTrackFilter(value: string | null): TrackFilter {
+ return TRACK_FILTERS.find((option) => option.value === value)?.value ?? 'all';
+}
- return tracks.filter(
- (track) =>
- (!statuses || statuses.includes(track.status)) &&
- (!needle ||
- track.title.toLowerCase().includes(needle) ||
- (track.artist ?? '').toLowerCase().includes(needle))
- );
+export function filterQuery(filter: TrackFilter): TrackFilterQuery {
+ return TRACK_FILTERS.find((option) => option.value === filter)?.query ?? {};
+}
+
+export function filterCounts(counts: TrackStatusCounts): Record {
+ return {
+ all: counts.total,
+ published: counts.published,
+ unpublished: Math.max(0, counts.completed - counts.published),
+ processing: counts.processing,
+ failed: counts.failed
+ };
}
diff --git a/web-player/src/lib/server/actions/libraryAlbum.ts b/web-player/src/lib/server/actions/libraryAlbum.ts
index 9ceb0a4..caa3653 100644
--- a/web-player/src/lib/server/actions/libraryAlbum.ts
+++ b/web-player/src/lib/server/actions/libraryAlbum.ts
@@ -20,29 +20,18 @@ export const saveAlbum = tagged('saveAlbum', async ({ api, form }) => {
if ('failMessage' in cover)
return fail(502, { message: cover.failMessage, detail: cover.detail });
- let albumId = existingId;
- if (existingId) {
- const failure = await updateAlbum(api, existingId, parsed.body, cover.intentId);
- if (failure) return failure;
- } else {
+ const addIds = formIds(form, 'addTrackIds');
+ if (!existingId) {
if (!cover.intentId) return fail(400, { message: 'La portada es obligatoria.' });
- const created = await createAlbum(api, parsed.body, cover.intentId);
+ const created = await createAlbum(api, parsed.body, cover.intentId, addIds);
if ('failMessage' in created) return fail(502, { message: created.failMessage });
- albumId = created.id;
+ return { saved: true };
}
- const failure = await syncAlbumTracks(
- api,
- albumId,
- formIds(form, 'addTrackIds'),
- formIds(form, 'removeTrackIds')
- );
- if (failure && !existingId) {
- return fail(502, {
- message: 'El álbum se creó, pero no se pudieron añadir las canciones. Inténtalo de nuevo.',
- albumId
- });
- }
+ const updateFailure = await updateAlbum(api, existingId, parsed.body, cover.intentId);
+ if (updateFailure) return updateFailure;
+
+ const failure = await syncAlbumTracks(api, existingId, addIds, formIds(form, 'removeTrackIds'));
return failure ?? { saved: true };
});
diff --git a/web-player/src/lib/server/albums.ts b/web-player/src/lib/server/albums.ts
index 90446a1..ce9067c 100644
--- a/web-player/src/lib/server/albums.ts
+++ b/web-player/src/lib/server/albums.ts
@@ -1,34 +1,14 @@
-import type { Album } from '$lib/types';
-import { toAlbum, toLibraryTrack, toPage } from '$lib/server/mappers';
+import { toAlbum, toPage } from '$lib/server/mappers';
import { failOnError, unwrapOrError, type ApiClient } from '$lib/server/api';
import { uploadOptionalCover } from '$lib/server/upload';
import type { AlbumFormBody } from '$lib/server/forms/albumForm';
-import { ALBUMS_PAGE_SIZE, ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PAGE_SIZE } from '$lib/config';
+import { ALBUMS_PAGE_SIZE } from '$lib/config';
export const ALBUMS_SECTION_URL = '/library/albums';
-export async function trackIdsByAlbum(
- api: ApiClient,
- albums: Album[]
-): Promise> {
- 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))
- );
+export async function albumTrackIds(api: ApiClient, albumId: string): Promise {
+ const result = await api.GET('/albums/{id}/tracks/ids', { params: { path: { id: albumId } } });
+ return unwrapOrError(result, 'No se pudieron cargar las canciones del álbum.').trackIds;
}
export async function loadUserAlbums(api: ApiClient, userId: string) {
@@ -38,19 +18,6 @@ export async function loadUserAlbums(api: ApiClient, userId: string) {
return toPage(unwrapOrError(result, 'No se pudieron cargar tus álbumes.'), toAlbum);
}
-export async function loadLibraryTracks(api: ApiClient, userId: string) {
- const [tracksResult, albums] = await Promise.all([
- api.GET('/tracks/users/{userId}', {
- params: { path: { userId }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
- }),
- loadUserAlbums(api, userId)
- ]);
- const albumTitles = albumTitleByTrackId(albums.items, await trackIdsByAlbum(api, albums.items));
- return toPage(unwrapOrError(tracksResult, 'No se pudo cargar tu biblioteca.'), (dto) =>
- toLibraryTrack(dto, albumTitles.get(dto.id) ?? null)
- );
-}
-
export type CoverUpload = { intentId: string | null } | { failMessage: string; detail?: string };
export function uploadAlbumCover(api: ApiClient, file: File | null): Promise {
@@ -60,9 +27,10 @@ export function uploadAlbumCover(api: ApiClient, file: File | null): Promise {
- const created = await api.POST('/albums', { body: { ...body, pictureIntentId } });
+ const created = await api.POST('/albums', { body: { ...body, pictureIntentId, trackIds } });
if (created.error || !created.data) return { failMessage: 'No se pudo crear el álbum.' };
return { id: created.data.id };
}
diff --git a/web-player/src/lib/server/forms/albumForm.test.ts b/web-player/src/lib/server/forms/albumForm.test.ts
index bc3a9dd..7039768 100644
--- a/web-player/src/lib/server/forms/albumForm.test.ts
+++ b/web-player/src/lib/server/forms/albumForm.test.ts
@@ -12,11 +12,19 @@ describe('parseAlbumForm', () => {
it('parses a valid form and trims values', () => {
expect(
parseAlbumForm(form({ title: ' Title ', description: ' d ', releaseYear: '2020' }))
- ).toEqual({ body: { title: 'Title', description: 'd', releaseYear: 2020 } });
+ ).toEqual({ body: { title: 'Title', description: 'd', releaseYear: 2020, type: 'Album' } });
});
it('turns empty optional fields into null', () => {
expect(parseAlbumForm(form({ title: 'T' }))).toEqual({
- body: { title: 'T', description: null, releaseYear: null }
+ body: { title: 'T', description: null, releaseYear: null, type: 'Album' }
+ });
+ });
+ it('maps the album type', () => {
+ expect(parseAlbumForm(form({ title: 'T', type: 'ep' }))).toMatchObject({
+ body: { type: 'EP' }
+ });
+ expect(parseAlbumForm(form({ title: 'T', type: 'x' }))).toMatchObject({
+ body: { type: 'Album' }
});
});
it('rejects empty or too long titles', () => {
diff --git a/web-player/src/lib/server/forms/albumForm.ts b/web-player/src/lib/server/forms/albumForm.ts
index f3bbf9d..42db75b 100644
--- a/web-player/src/lib/server/forms/albumForm.ts
+++ b/web-player/src/lib/server/forms/albumForm.ts
@@ -5,8 +5,15 @@ export interface AlbumFormBody {
title: string;
description: string | null;
releaseYear: number | null;
+ type: 'Album' | 'EP' | 'Single';
}
+const ALBUM_TYPES: Record = {
+ album: 'Album',
+ ep: 'EP',
+ single: 'Single'
+};
+
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
const title = formString(form, 'title').trim();
if (title === '' || title.length > LIMITS.albumTitle) {
@@ -36,7 +43,8 @@ export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { fail
body: {
title,
description: description === '' ? null : description,
- releaseYear
+ releaseYear,
+ type: ALBUM_TYPES[formString(form, 'type')] ?? 'Album'
}
};
}
diff --git a/web-player/src/lib/server/libraryTracks.ts b/web-player/src/lib/server/libraryTracks.ts
new file mode 100644
index 0000000..93673cf
--- /dev/null
+++ b/web-player/src/lib/server/libraryTracks.ts
@@ -0,0 +1,39 @@
+import { toLibraryTrack, toPage, toStatusCounts } from '$lib/server/mappers';
+import { unwrapOrError, type ApiClient } from '$lib/server/api';
+import { filterQuery, type TrackFilter } from '$lib/data/libraryTracks';
+import { LIBRARY_PAGE_SIZE } from '$lib/config';
+
+export interface LibraryTracksQuery {
+ q?: string;
+ filter?: TrackFilter;
+ pageNumber?: number;
+ pageSize?: number;
+}
+
+export async function loadLibraryTracks(
+ api: ApiClient,
+ userId: string,
+ { q, filter = 'all', pageNumber = 1, pageSize = LIBRARY_PAGE_SIZE }: LibraryTracksQuery = {}
+) {
+ const { status, published } = filterQuery(filter);
+ const result = await api.GET('/tracks/users/{userId}', {
+ params: {
+ path: { userId },
+ query: {
+ name: q || undefined,
+ status: status ? [...status] : undefined,
+ published,
+ pageNumber,
+ pageSize
+ }
+ }
+ });
+ return toPage(unwrapOrError(result, 'No se pudo cargar tu biblioteca.'), toLibraryTrack);
+}
+
+export async function loadStatusCounts(api: ApiClient, userId: string) {
+ const result = await api.GET('/tracks/users/{userId}/status-counts', {
+ params: { path: { userId } }
+ });
+ return toStatusCounts(unwrapOrError(result, 'No se pudieron cargar los contadores.'));
+}
diff --git a/web-player/src/lib/server/mappers.ts b/web-player/src/lib/server/mappers.ts
index 6149532..0146fbb 100644
--- a/web-player/src/lib/server/mappers.ts
+++ b/web-player/src/lib/server/mappers.ts
@@ -2,6 +2,7 @@ import type { components } from '$lib/api/schema';
import type {
Album,
LibraryTrack,
+ TrackStatusCounts,
LikedTrack,
ListeningStats,
Mix,
@@ -11,6 +12,8 @@ import type {
} from '$lib/types';
type TrackDto = components['schemas']['TrackApplicationResponse'];
+type UserTrackDto = components['schemas']['UserTrackApplicationResponse'];
+type StatusCountsDto = components['schemas']['TrackStatusCountsResponse'];
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
type PlaylistDto = components['schemas']['PlayListApplicationResponse'];
type MixDto = components['schemas']['MixApplicationResponse'];
@@ -39,12 +42,24 @@ export function toDatedTrack(dto: TrackDto): Track & { date: string } {
return { ...toTrack(dto), date: dto.createdAt };
}
-export function toLibraryTrack(dto: TrackDto, albumTitle: string | null): LibraryTrack {
+export function toLibraryTrack(dto: UserTrackDto): LibraryTrack {
return {
...toDatedTrack(dto),
status: dto.audioStatus,
genres: dto.tags,
- albumTitle
+ albums: dto.albums,
+ albumTitle: dto.albums.length > 0 ? dto.albums.map((album) => album.title).join(', ') : null,
+ published: dto.isPublished
+ };
+}
+
+export function toStatusCounts(dto: StatusCountsDto): TrackStatusCounts {
+ return {
+ total: Number(dto.total) || 0,
+ processing: (Number(dto.pending) || 0) + (Number(dto.processing) || 0),
+ completed: Number(dto.completed) || 0,
+ failed: Number(dto.failed) || 0,
+ published: Number(dto.published) || 0
};
}
@@ -89,6 +104,7 @@ export function toAlbum(dto: AlbumDto): Album {
title: dto.title,
description: dto.description,
releaseYear: dto.releaseYear === null ? null : Number(dto.releaseYear),
+ type: dto.type,
ownerUserId: dto.ownerUserId,
trackCount: Number(dto.trackCount) || 0,
coverTrackIds: dto.coverTrackIds,
diff --git a/web-player/src/lib/server/tracks.test.ts b/web-player/src/lib/server/tracks.test.ts
index d8c62f9..e50c45c 100644
--- a/web-player/src/lib/server/tracks.test.ts
+++ b/web-player/src/lib/server/tracks.test.ts
@@ -1,19 +1,44 @@
+import { isActionFailure } from '@sveltejs/kit';
import { describe, expect, it, vi } from 'vitest';
import type { ApiClient } from '$lib/server/api';
-import { deleteTracks } from '$lib/server/tracks';
+import { deleteTracks, setTracksPublished } from '$lib/server/tracks';
describe('deleteTracks', () => {
- it('counts the deletions that failed', async () => {
- const DELETE = vi.fn(async (_path: string, init: { params: { path: { id: string } } }) =>
- init.params.path.id === 'b' ? { error: { status: 500 } } : {}
- );
+ it('counts the tracks the API could not delete', async () => {
+ const DELETE = vi.fn(async () => ({
+ data: { deletedTrackIds: ['a', 'c'], failedTrackIds: ['b'] }
+ }));
const result = await deleteTracks({ DELETE } as unknown as ApiClient, ['a', 'b', 'c']);
expect(result).toEqual({ failed: 1 });
- expect(DELETE).toHaveBeenCalledTimes(3);
});
- it('reports no failures when every deletion succeeds', async () => {
- const DELETE = vi.fn(async () => ({}));
- expect(await deleteTracks({ DELETE } as unknown as ApiClient, ['a'])).toEqual({ failed: 0 });
+ it('counts a whole batch as failed when the request errors', async () => {
+ const DELETE = vi.fn(async () => ({ error: { status: 500 } }));
+ expect(await deleteTracks({ DELETE } as unknown as ApiClient, ['a', 'b'])).toEqual({
+ failed: 2
+ });
+ });
+
+ it('splits large deletions into batches of 100', async () => {
+ const DELETE = vi.fn(async () => ({ data: { deletedTrackIds: [], failedTrackIds: [] } }));
+ const ids = Array.from({ length: 250 }, (_, i) => String(i));
+ await deleteTracks({ DELETE } as unknown as ApiClient, ids);
+ expect(DELETE).toHaveBeenCalledTimes(3);
+ });
+});
+
+describe('setTracksPublished', () => {
+ it('reports updated and skipped tracks', async () => {
+ const PUT = vi.fn(async () => ({
+ data: { updatedTrackIds: ['a'], skippedTrackIds: ['b'] }
+ }));
+ const result = await setTracksPublished({ PUT } as unknown as ApiClient, ['a', 'b'], true);
+ expect(result).toEqual({ updated: 1, skipped: 1 });
+ });
+
+ it('returns a failure when the request errors', async () => {
+ const PUT = vi.fn(async () => ({ error: { status: 400 } }));
+ const result = await setTracksPublished({ PUT } as unknown as ApiClient, ['a'], true);
+ expect(isActionFailure(result)).toBe(true);
});
});
diff --git a/web-player/src/lib/server/tracks.ts b/web-player/src/lib/server/tracks.ts
index e682818..a6cb645 100644
--- a/web-player/src/lib/server/tracks.ts
+++ b/web-player/src/lib/server/tracks.ts
@@ -1,8 +1,37 @@
-import type { ApiClient } from '$lib/server/api';
+import { failOnError, type ApiClient } from '$lib/server/api';
+
+const BULK_LIMIT = 100;
+
+function chunks(items: T[]): T[][] {
+ const result: T[][] = [];
+ for (let i = 0; i < items.length; i += BULK_LIMIT) result.push(items.slice(i, i + BULK_LIMIT));
+ return result;
+}
export async function deleteTracks(api: ApiClient, ids: string[]): Promise<{ failed: number }> {
const results = await Promise.all(
- ids.map((id) => api.DELETE('/tracks/{id}', { params: { path: { id } } }))
+ chunks(ids).map(async (batch) => {
+ const result = await api.DELETE('/tracks', { body: { trackIds: batch } });
+ return result.error || !result.data ? batch.length : result.data.failedTrackIds.length;
+ })
);
- return { failed: results.filter((result) => result.error).length };
+ return { failed: results.reduce((total, count) => total + count, 0) };
+}
+
+export async function setTracksPublished(
+ api: ApiClient,
+ ids: string[],
+ isPublished: boolean
+): Promise<{ updated: number; skipped: number } | NonNullable>> {
+ let updated = 0;
+ for (const batch of chunks(ids)) {
+ const result = await api.PUT('/tracks/published', { body: { trackIds: batch, isPublished } });
+ const failure = failOnError(
+ result,
+ isPublished ? 'No se pudieron publicar las canciones.' : 'No se pudieron despublicar.'
+ );
+ if (failure) return failure;
+ updated += result.data?.updatedTrackIds.length ?? 0;
+ }
+ return { updated, skipped: ids.length - updated };
}
diff --git a/web-player/src/lib/types.ts b/web-player/src/lib/types.ts
index a5008a5..df54b79 100644
--- a/web-player/src/lib/types.ts
+++ b/web-player/src/lib/types.ts
@@ -29,17 +29,30 @@ export interface LibraryTrack extends Track {
status: TrackStatus;
genres: string[];
albumTitle: string | null;
+ albums: { id: string; title: string }[];
+ published: boolean;
+}
+
+export interface TrackStatusCounts {
+ total: number;
+ processing: number;
+ completed: number;
+ failed: number;
+ published: number;
}
export interface LikedTrack extends Track {
likedAt: number;
}
+export type AlbumType = 'Album' | 'EP' | 'Single';
+
export interface Album {
id: string;
title: string;
description: string | null;
releaseYear: number | null;
+ type: AlbumType;
ownerUserId: string;
trackCount: number;
coverTrackIds: string[];
diff --git a/web-player/src/lib/utils/gridColumns.ts b/web-player/src/lib/utils/gridColumns.ts
index 1345789..bda48ce 100644
--- a/web-player/src/lib/utils/gridColumns.ts
+++ b/web-player/src/lib/utils/gridColumns.ts
@@ -27,6 +27,7 @@ export const COL = {
genres: 'minmax(6rem,12.5rem)',
genresWide: 'minmax(6rem,13.75rem)',
status: '7rem',
+ publish: '6.5rem',
statusQueue: '8.75rem',
statusMobile: '6rem',
access: '6rem',
diff --git a/web-player/src/routes/(app)/library/+page.server.ts b/web-player/src/routes/(app)/library/+page.server.ts
index 2f63f0f..cf203c4 100644
--- a/web-player/src/routes/(app)/library/+page.server.ts
+++ b/web-player/src/routes/(app)/library/+page.server.ts
@@ -1,16 +1,24 @@
import type { PageServerLoad, Actions } from './$types';
-import { fail } from '@sveltejs/kit';
+import { fail, isActionFailure } from '@sveltejs/kit';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
-import { formIds } from '$lib/server/forms/fields';
+import { formIds, formString } from '$lib/server/forms/fields';
import { tagged } from '$lib/server/actions/tagged';
-import { loadLibraryTracks } from '$lib/server/albums';
-import { deleteTracks } from '$lib/server/tracks';
+import { loadLibraryTracks, loadStatusCounts } from '$lib/server/libraryTracks';
+import { deleteTracks, setTracksPublished } from '$lib/server/tracks';
+import { parseTrackFilter } from '$lib/data/libraryTracks';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
- return { tracks: await loadLibraryTracks(api, user.sub) };
+ const q = url.searchParams.get('q')?.trim() ?? '';
+ const filter = parseTrackFilter(url.searchParams.get('status'));
+
+ const [tracks, counts] = await Promise.all([
+ loadLibraryTracks(api, user.sub, { q, filter }),
+ loadStatusCounts(api, user.sub)
+ ]);
+ return { tracks, counts, q, filter };
};
export const actions: Actions = {
@@ -26,5 +34,15 @@ export const actions: Actions = {
? 'No se pudo borrar la canción.'
: `No se pudieron borrar ${failed} de ${trackIds.length} canciones.`
});
+ }),
+
+ setPublished: tagged('setPublished', async ({ api, form }) => {
+ const trackIds = formIds(form, 'trackIds');
+ if (trackIds.length === 0) return fail(400, { message: 'Falta la canción.' });
+
+ const isPublished = formString(form, 'isPublished') === 'true';
+ const result = await setTracksPublished(api, trackIds, isPublished);
+ if (isActionFailure(result)) return result;
+ return { ...result, isPublished };
})
};
diff --git a/web-player/src/routes/(app)/library/+page.svelte b/web-player/src/routes/(app)/library/+page.svelte
index 342fa72..a31fc02 100644
--- a/web-player/src/routes/(app)/library/+page.svelte
+++ b/web-player/src/routes/(app)/library/+page.svelte
@@ -1,10 +1,27 @@
diff --git a/web-player/src/routes/(app)/library/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/library/albums/[id]/+page.server.ts
index 7e897fd..aedc516 100644
--- a/web-player/src/routes/(app)/library/albums/[id]/+page.server.ts
+++ b/web-player/src/routes/(app)/library/albums/[id]/+page.server.ts
@@ -2,8 +2,10 @@ import type { Actions, PageServerLoad } from './$types';
import { apiFor, unwrapOrError } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { toAlbum } from '$lib/server/mappers';
-import { loadLibraryTracks, trackIdsByAlbum } from '$lib/server/albums';
+import { albumTrackIds } from '$lib/server/albums';
+import { loadLibraryTracks } from '$lib/server/libraryTracks';
import { removeAlbum, saveAlbum } from '$lib/server/actions/libraryAlbum';
+import { LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url);
@@ -11,10 +13,10 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const albumResult = await api.GET('/albums/{id}', { params: { path: { id: params.id } } });
const album = toAlbum(unwrapOrError(albumResult, 'Álbum no encontrado.', 404));
const [tracks, memberIds] = await Promise.all([
- loadLibraryTracks(api, user.sub),
- trackIdsByAlbum(api, [album])
+ loadLibraryTracks(api, user.sub, { pageSize: LIBRARY_PICKER_PAGE_SIZE }),
+ albumTrackIds(api, album.id)
]);
- return { album, tracks, memberIds: memberIds[album.id] ?? [] };
+ return { album, tracks, memberIds };
};
export const actions: Actions = { saveAlbum, deleteAlbum: removeAlbum };
diff --git a/web-player/src/routes/(app)/library/albums/new/+page.server.ts b/web-player/src/routes/(app)/library/albums/new/+page.server.ts
index d198cf1..2e56fe4 100644
--- a/web-player/src/routes/(app)/library/albums/new/+page.server.ts
+++ b/web-player/src/routes/(app)/library/albums/new/+page.server.ts
@@ -1,13 +1,14 @@
import type { Actions, PageServerLoad } from './$types';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
-import { loadLibraryTracks } from '$lib/server/albums';
+import { loadLibraryTracks } from '$lib/server/libraryTracks';
import { saveAlbum } from '$lib/server/actions/libraryAlbum';
+import { LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
- return { tracks: await loadLibraryTracks(api, user.sub) };
+ return { tracks: await loadLibraryTracks(api, user.sub, { pageSize: LIBRARY_PICKER_PAGE_SIZE }) };
};
export const actions: Actions = { saveAlbum };
diff --git a/web-player/src/routes/api/library/tracks/+server.ts b/web-player/src/routes/api/library/tracks/+server.ts
new file mode 100644
index 0000000..4bfed9c
--- /dev/null
+++ b/web-player/src/routes/api/library/tracks/+server.ts
@@ -0,0 +1,18 @@
+import type { RequestHandler } from './$types';
+import { json } from '@sveltejs/kit';
+import { apiFor } from '$lib/server/api';
+import { requireUser } from '$lib/server/guards';
+import { loadLibraryTracks } from '$lib/server/libraryTracks';
+import { parseTrackFilter } from '$lib/data/libraryTracks';
+
+export const GET: RequestHandler = async ({ url, locals, fetch }) => {
+ const user = requireUser(locals, url);
+ const api = apiFor({ fetch, locals });
+ const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
+ const tracks = await loadLibraryTracks(api, user.sub, {
+ q: url.searchParams.get('q')?.trim() ?? '',
+ filter: parseTrackFilter(url.searchParams.get('status')),
+ pageNumber
+ });
+ return json(tracks);
+};