Add direct upload API for presigning, creating tracks and following processing
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
export interface PresignFile {
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface PresignRequest {
|
||||
cover: PresignFile | null;
|
||||
files: PresignFile[];
|
||||
}
|
||||
|
||||
export interface PresignTarget {
|
||||
uploadUrl: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export interface PresignedAudio extends PresignTarget {
|
||||
intentId: string;
|
||||
pictureIntentId: string | null;
|
||||
}
|
||||
|
||||
export interface PresignResponse {
|
||||
picture: PresignTarget | null;
|
||||
audios: PresignedAudio[];
|
||||
}
|
||||
|
||||
export interface CompleteRequest {
|
||||
title: string;
|
||||
audioIntentId: string;
|
||||
pictureIntentId: string | null;
|
||||
tags: string[];
|
||||
albumId: string | null;
|
||||
}
|
||||
|
||||
export interface CompleteResponse {
|
||||
trackId: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export type TrackProcessing = 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||
|
||||
export type StatusResponse = Record<string, TrackProcessing>;
|
||||
@@ -1,5 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
beginUpload,
|
||||
chunkStartable,
|
||||
failUpload,
|
||||
finishCreate,
|
||||
finishProcessing,
|
||||
finishUpload,
|
||||
genreConflictMessage,
|
||||
hasActiveUploads,
|
||||
reportProgress,
|
||||
retryUpload,
|
||||
summarizeQueue,
|
||||
titleFromFileName,
|
||||
toQueuedUpload,
|
||||
@@ -41,7 +51,14 @@ describe('uploadError', () => {
|
||||
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 });
|
||||
expect(summarizeQueue(items)).toEqual({
|
||||
ready: 2,
|
||||
failed: 1,
|
||||
uploading: 0,
|
||||
processing: 0,
|
||||
done: 0,
|
||||
errored: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,3 +69,94 @@ describe('uploadLimitsLabel', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload state machine', () => {
|
||||
const fresh = () => toQueuedUpload(file('a.wav'));
|
||||
|
||||
it('walks queued to done', () => {
|
||||
let item = fresh();
|
||||
expect(item.phase).toBe('queued');
|
||||
item = beginUpload(item);
|
||||
expect(item.phase).toBe('uploading');
|
||||
item = reportProgress(item, 0.4);
|
||||
expect(item.progress).toBe(0.4);
|
||||
item = finishUpload(item);
|
||||
expect(item).toMatchObject({ phase: 'creating', progress: 1 });
|
||||
item = finishCreate(item, 't1');
|
||||
expect(item).toMatchObject({ phase: 'processing', trackId: 't1' });
|
||||
item = finishProcessing(item);
|
||||
expect(item.phase).toBe('done');
|
||||
});
|
||||
|
||||
it('ignores transitions from the wrong phase', () => {
|
||||
const item = fresh();
|
||||
expect(finishUpload(item)).toBe(item);
|
||||
expect(finishCreate(item, 't1')).toBe(item);
|
||||
expect(finishProcessing(item)).toBe(item);
|
||||
expect(failUpload(item, 'x')).toBe(item);
|
||||
expect(retryUpload(item)).toBe(item);
|
||||
});
|
||||
|
||||
it('never starts an invalid file', () => {
|
||||
const item = toQueuedUpload(file('a.psd'));
|
||||
expect(beginUpload(item)).toBe(item);
|
||||
});
|
||||
|
||||
it('keeps progress monotonic and capped at 1', () => {
|
||||
let item = beginUpload(fresh());
|
||||
item = reportProgress(item, 0.6);
|
||||
expect(reportProgress(item, 0.3)).toBe(item);
|
||||
expect(reportProgress(item, 4).progress).toBe(1);
|
||||
});
|
||||
|
||||
it('fails and retries', () => {
|
||||
let item = beginUpload(fresh());
|
||||
item = failUpload(item, 'Cancelada');
|
||||
expect(item).toMatchObject({ phase: 'error', failure: 'Cancelada' });
|
||||
item = retryUpload(item);
|
||||
expect(item).toMatchObject({ phase: 'queued', progress: 0, failure: null });
|
||||
});
|
||||
|
||||
it('carries a note when the album step fails', () => {
|
||||
const item = finishCreate(finishUpload(beginUpload(fresh())), 't1', 'Sin álbum');
|
||||
expect(item.note).toBe('Sin álbum');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chunkStartable', () => {
|
||||
it('splits startable items into batches and skips the rest', () => {
|
||||
const items = ['a.wav', 'b.wav', 'c.wav', 'd.psd'].map((name) => toQueuedUpload(file(name)));
|
||||
items[1] = beginUpload(items[1]);
|
||||
const chunks = chunkStartable(items, 1);
|
||||
expect(chunks.map((chunk) => chunk.map((item) => item.file.name))).toEqual([
|
||||
['a.wav'],
|
||||
['c.wav']
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveUploads', () => {
|
||||
it('is true while uploading, creating or processing', () => {
|
||||
const item = toQueuedUpload(file('a.wav'));
|
||||
expect(hasActiveUploads([item])).toBe(false);
|
||||
expect(hasActiveUploads([beginUpload(item)])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('genreConflictMessage', () => {
|
||||
const options = [
|
||||
{ genre: 'Rock', incompatibleWith: ['Classical'] },
|
||||
{ genre: 'Classical', incompatibleWith: ['Rock'] },
|
||||
{ genre: 'Jazz', incompatibleWith: [] }
|
||||
];
|
||||
|
||||
it('names the conflicting genres', () => {
|
||||
expect(genreConflictMessage(['Rock', 'Classical'], options)).toBe(
|
||||
'Los géneros Rock y Clásica no se pueden combinar.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns nothing when compatible', () => {
|
||||
expect(genreConflictMessage(['Rock', 'Jazz'], options)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { findConflict, genreInfo } from '$lib/data/genres';
|
||||
import type { GenreOption } from '$lib/types';
|
||||
|
||||
export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||
export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
|
||||
|
||||
@@ -9,7 +12,10 @@ export function uploadLimitsLabel(): string {
|
||||
return `${list} · hasta ${UPLOAD_MAX_MB} MB por archivo · puedes soltar carpetas enteras`;
|
||||
}
|
||||
|
||||
export type Visibility = 'private' | 'public';
|
||||
export const UPLOAD_CONCURRENCY = 3;
|
||||
export const UPLOAD_BATCH_SIZE = 20;
|
||||
|
||||
export type UploadPhase = 'queued' | 'uploading' | 'creating' | 'processing' | 'done' | 'error';
|
||||
|
||||
export interface QueuedUpload {
|
||||
id: string;
|
||||
@@ -18,6 +24,11 @@ export interface QueuedUpload {
|
||||
format: string;
|
||||
error: string | null;
|
||||
duration: number | null;
|
||||
phase: UploadPhase;
|
||||
progress: number;
|
||||
failure: string | null;
|
||||
trackId: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export function fileExtension(name: string): string {
|
||||
@@ -50,13 +61,102 @@ export function toQueuedUpload(file: File): QueuedUpload {
|
||||
title: titleFromFileName(file.name),
|
||||
format: fileExtension(file.name).toUpperCase() || '?',
|
||||
error: uploadError(file),
|
||||
duration: null
|
||||
duration: null,
|
||||
phase: 'queued',
|
||||
progress: 0,
|
||||
failure: null,
|
||||
trackId: null,
|
||||
note: 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 };
|
||||
export function isStartable(item: QueuedUpload): boolean {
|
||||
return item.error === null && item.phase === 'queued';
|
||||
}
|
||||
|
||||
export function isBusy(item: QueuedUpload): boolean {
|
||||
return item.phase === 'uploading' || item.phase === 'creating';
|
||||
}
|
||||
|
||||
export function isActive(item: QueuedUpload): boolean {
|
||||
return isBusy(item) || item.phase === 'processing';
|
||||
}
|
||||
|
||||
export function beginUpload(item: QueuedUpload): QueuedUpload {
|
||||
if (!isStartable(item)) return item;
|
||||
return { ...item, phase: 'uploading', progress: 0, failure: null };
|
||||
}
|
||||
|
||||
export function reportProgress(item: QueuedUpload, fraction: number): QueuedUpload {
|
||||
if (item.phase !== 'uploading') return item;
|
||||
const progress = Math.min(1, Math.max(item.progress, fraction));
|
||||
return progress === item.progress ? item : { ...item, progress };
|
||||
}
|
||||
|
||||
export function finishUpload(item: QueuedUpload): QueuedUpload {
|
||||
if (item.phase !== 'uploading') return item;
|
||||
return { ...item, phase: 'creating', progress: 1 };
|
||||
}
|
||||
|
||||
export function finishCreate(
|
||||
item: QueuedUpload,
|
||||
trackId: string,
|
||||
note: string | null = null
|
||||
): QueuedUpload {
|
||||
if (item.phase !== 'creating') return item;
|
||||
return { ...item, phase: 'processing', trackId, note };
|
||||
}
|
||||
|
||||
export function finishProcessing(item: QueuedUpload): QueuedUpload {
|
||||
if (item.phase !== 'processing') return item;
|
||||
return { ...item, phase: 'done' };
|
||||
}
|
||||
|
||||
export function failUpload(item: QueuedUpload, reason: string): QueuedUpload {
|
||||
if (!isActive(item)) return item;
|
||||
return { ...item, phase: 'error', failure: reason };
|
||||
}
|
||||
|
||||
export function retryUpload(item: QueuedUpload): QueuedUpload {
|
||||
if (item.phase !== 'error') return item;
|
||||
return { ...item, phase: 'queued', progress: 0, failure: null, trackId: null, note: null };
|
||||
}
|
||||
|
||||
export function genreConflictMessage(tags: string[], options: GenreOption[]): string | undefined {
|
||||
const conflict = findConflict(tags, options);
|
||||
if (!conflict) return undefined;
|
||||
return `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.`;
|
||||
}
|
||||
|
||||
export function chunkStartable(items: QueuedUpload[], size = UPLOAD_BATCH_SIZE): QueuedUpload[][] {
|
||||
const startable = items.filter(isStartable);
|
||||
const chunks: QueuedUpload[][] = [];
|
||||
for (let i = 0; i < startable.length; i += size) chunks.push(startable.slice(i, i + size));
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function hasActiveUploads(items: QueuedUpload[]): boolean {
|
||||
return items.some(isActive);
|
||||
}
|
||||
|
||||
export function summarizeQueue(items: QueuedUpload[]): {
|
||||
ready: number;
|
||||
failed: number;
|
||||
uploading: number;
|
||||
processing: number;
|
||||
done: number;
|
||||
errored: number;
|
||||
} {
|
||||
const invalid = items.filter((item) => item.error !== null).length;
|
||||
const valid = items.filter((item) => item.error === null);
|
||||
return {
|
||||
ready: valid.filter((item) => item.phase === 'queued').length,
|
||||
failed: invalid,
|
||||
uploading: valid.filter(isBusy).length,
|
||||
processing: valid.filter((item) => item.phase === 'processing').length,
|
||||
done: valid.filter((item) => item.phase === 'done').length,
|
||||
errored: valid.filter((item) => item.phase === 'error').length
|
||||
};
|
||||
}
|
||||
|
||||
async function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
||||
|
||||
Reference in New Issue
Block a user