Filter library tracks on the server and add track publishing, bulk delete and album types

This commit is contained in:
2026-09-25 01:49:44 +02:00
parent 93d7a23c53
commit 9f592cfdb8
21 changed files with 505 additions and 204 deletions
+40 -35
View File
@@ -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 });
});
});
+52 -26
View File
@@ -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<TrackStatus, { label: string; tone: TrackTone }> = {
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<LibraryTrack, 'status' | 'published'>): {
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<LibraryTrack, 'status'>): 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<TrackFilter, number> {
return {
all: counts.total,
published: counts.published,
unpublished: Math.max(0, counts.completed - counts.published),
processing: counts.processing,
failed: counts.failed
};
}