Add library manager with tracks, albums and uploads sections

This commit is contained in:
2026-09-25 01:07:15 +02:00
parent 4ac73d8fa3
commit 9928840577
28 changed files with 1622 additions and 118 deletions
@@ -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);
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { LibraryTrack, TrackStatus } from '$lib/types';
export type TrackTone = 'success' | 'neutral' | 'danger';
export const TRACK_STATUS: Record<TrackStatus, { label: string; tone: TrackTone }> = {
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))
);
}
@@ -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 });
});
});
+98
View File
@@ -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<FileSystemEntry[]> {
const all: FileSystemEntry[] = [];
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
reader.readEntries(resolve, reject)
);
if (batch.length === 0) return all;
all.push(...batch);
}
}
async function filesFromEntry(entry: FileSystemEntry): Promise<File[]> {
if (entry.isFile) {
const file = await new Promise<File>((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<File[]> {
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<number | null> {
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;
});
}