Filter library tracks on the server and add track publishing, bulk delete and album types
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Record<string, string[]>> {
|
||||
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<string, string[]>) {
|
||||
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<string[]> {
|
||||
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<CoverUpload> {
|
||||
@@ -60,9 +27,10 @@ export function uploadAlbumCover(api: ApiClient, file: File | null): Promise<Cov
|
||||
export async function createAlbum(
|
||||
api: ApiClient,
|
||||
body: AlbumFormBody,
|
||||
pictureIntentId: string
|
||||
pictureIntentId: string,
|
||||
trackIds: string[] = []
|
||||
): Promise<{ id: string } | { failMessage: string }> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -5,8 +5,15 @@ export interface AlbumFormBody {
|
||||
title: string;
|
||||
description: string | null;
|
||||
releaseYear: number | null;
|
||||
type: 'Album' | 'EP' | 'Single';
|
||||
}
|
||||
|
||||
const ALBUM_TYPES: Record<string, AlbumFormBody['type']> = {
|
||||
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'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T>(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<ReturnType<typeof failOnError>>> {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user