Add library manager with tracks, albums and uploads sections
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user