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); +};