feat: finish the upload, playback, and test gaps from the docs
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s

Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-21 09:37:53 +03:00
1 parent 2b76060ea3
commit 066ba24834
61 files changed
+2818 -463

No files matched your search

+190 -1
View File
@@ -404,6 +404,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/uploads/unseen": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Сколько решений по своим предложениям с прошлого визита (moderator, admin) */
get: operations["unseenUploadDecisions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/uploads/seen": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Отметить свои предложения просмотренными */
post: operations["markUploadsSeen"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/uploads/files/{id}": {
parameters: {
query?: never;
@@ -484,6 +518,48 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/uploads/files/{id}/stream": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Прослушать файл из staging (подпись, как у стрима трека)
* @description Ссылку даёт `stream_url` файла очереди. Range поддерживается.
*/
get: operations["streamUpload"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/uploads/batches/{id}": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Правка метаданных и обложки на всю пачку (admin)
* @description Исполнитель, альбом и год применяются к файлам в pending_review и import_failed. Обложка встраивается при импорте.
*/
patch: operations["updateUploadBatch"];
trace?: never;
};
"/api/v1/admin/uploads/review": {
parameters: {
query?: never;
@@ -1307,6 +1383,7 @@ export interface components {
artist?: string;
title?: string;
duration_ms?: number;
album?: string;
}[];
};
UploadPrecheckResult: {
@@ -1399,6 +1476,8 @@ export interface components {
/** @enum {string|null} */
duplicate_level: "sha256" | "metadata" | "fingerprint" | null;
possible_duplicate: boolean;
/** @description Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade */
quality_upgrade: boolean;
/** Format: uuid */
target_source_id: string | null;
/** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */
@@ -1412,6 +1491,8 @@ export interface components {
updated_at: string;
/** @description Только автору и пока файл в статусе uploading */
tus_url: string | null;
/** @description Подписанная ссылка прослушивания из staging; null, если файла уже нет */
stream_url: string | null;
};
UploadFilePage: {
items: components["schemas"]["UploadFile"][];
@@ -1422,6 +1503,14 @@ export interface components {
/** Format: uuid */
target_source_id?: string;
};
/** @description Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки. */
UploadBatchPatch: {
artist?: string;
album?: string;
year?: number | null;
/** @description JPEG, PNG или WebP, не больше 8 МБ после декодирования */
cover_base64?: string | null;
};
UploadReviewRequest: {
file_ids: string[];
/** @enum {string} */
@@ -1446,6 +1535,8 @@ export interface components {
max_file_bytes: number;
/** Format: uuid */
default_source_id: string | null;
/** @description Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли */
allow_quality_upgrade: boolean;
};
/** @description Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). */
UploadSettingsPatch: {
@@ -1463,6 +1554,7 @@ export interface components {
* @description null — без хранилища по умолчанию
*/
default_source_id: string | null;
allow_quality_upgrade?: boolean;
};
HealthStatus: {
/** @enum {string} */
@@ -1538,7 +1630,7 @@ export interface components {
* @enum {string}
*/
search_position?: "center" | "left" | "right";
/** @description Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию true) */
/** @description Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false) */
search_collapse?: boolean;
/** @description Показывать на главной веба выбор занятия (по умолчанию true) */
home_activities?: boolean;
@@ -2702,6 +2794,48 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
unseenUploadDecisions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Число */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
count: number;
};
};
};
default: components["responses"]["Problem"];
};
};
markUploadsSeen: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Отмечено */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
withdrawUpload: {
parameters: {
query?: never;
@@ -2803,6 +2937,61 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
streamUpload: {
parameters: {
query: {
exp: number;
sig: string;
};
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Аудио из staging */
200: {
headers: {
[name: string]: unknown;
};
content: {
"audio/*": string;
};
};
default: components["responses"]["Problem"];
};
};
updateUploadBatch: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UploadBatchPatch"];
};
};
responses: {
/** @description Обновлённые файлы пачки */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
items: components["schemas"]["UploadFile"][];
};
};
};
default: components["responses"]["Problem"];
};
};
reviewUploads: {
parameters: {
query?: never;
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { normalizeGain } from './gain';
describe('normalizeGain', () => {
it('lifts a quiet track toward -14 LUFS and clamps the extremes', () => {
expect(normalizeGain(-14, true)).toBeCloseTo(1);
expect(normalizeGain(-20, true)).toBeGreaterThan(1);
expect(normalizeGain(-40, true)).toBe(4);
expect(normalizeGain(0, true)).toBe(0.25);
expect(normalizeGain(-20, false)).toBe(1);
expect(normalizeGain(null, true)).toBe(1);
});
});
+9
View File
@@ -0,0 +1,9 @@
/** Целевая громкость выравнивания (docs/playback.md). */
export const TARGET_LUFS = -14;
/** Множитель громкости к −14 LUFS. Выключено или нет замера — 1. Ограничен, чтобы тихий трек не орал. */
export function normalizeGain(lufs: number | null | undefined, enabled: boolean): number {
if (!enabled || lufs == null || Number.isNaN(lufs)) return 1;
const gain = 10 ** ((TARGET_LUFS - lufs) / 20);
return Math.min(4, Math.max(0.25, gain));
}
+2 -1
View File
@@ -21,5 +21,6 @@ export {
type RepeatMode,
type Track,
} from './player';
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
export { normalizeGain, TARGET_LUFS } from './gain';
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
+15 -1
View File
@@ -203,6 +203,11 @@
"skipped_few": "{{count}} files skipped — not audio",
"skipped_many": "{{count}} files skipped — not audio",
"skipped_other": "{{count}} files skipped — not audio",
"resume_hint_one": "{{count}} file was not finished. Pick it again to resume.",
"resume_hint_few": "{{count}} files were not finished. Pick them again to resume.",
"resume_hint_many": "{{count}} files were not finished. Pick them again to resume.",
"resume_hint_other": "{{count}} files were not finished. Pick them again to resume.",
"resume_pick": "Pick files and resume",
"comment": "Comment for the administrator",
"comment_hint": "For example: “2011 remaster”, “replacing broken files”",
"not_enough_space": "The queue is short of space: {{need}} needed, {{free}} free — some files will be refused.",
@@ -275,6 +280,14 @@
"disc": "disc {{n}}",
"edited": "edited",
"flag_possible": "possible duplicate",
"flag_quality": "better quality",
"listen": "Listen to {{name}}",
"edit_batch": "Whole batch",
"batch_edit_title": "Edit batch",
"batch_edit_hint": "Empty fields stay as they are. The cover is written into the files on import.",
"batch_cover": "Batch cover",
"batch_cover_clear": "Remove cover",
"set_upgrade": "Accept the same track in better quality",
"flag_existing_album": "existing album",
"flag_new_album": "new album",
"dup_level_sha256": "exact copy of a file",
@@ -332,7 +345,8 @@
"uploading": "Uploading",
"done": "Uploaded, processing",
"rejected": "Not accepted",
"error": "Error"
"error": "Error",
"resume": "Waiting for the file to resume"
}
},
"server_settings": {
+3 -1
View File
@@ -122,7 +122,9 @@
"search_right": "Right",
"search_collapse": "Collapse search",
"home_activities": "Activity picker on home",
"home_moods": "Mood picker on home"
"home_moods": "Mood picker on home",
"normalize": "Loudness normalization",
"crossfade": "Crossfade, {{seconds}} s"
},
"form": {
"show_password": "Show password",
+15 -1
View File
@@ -203,6 +203,11 @@
"skipped_few": "Пропущено {{count}} файла — не аудио",
"skipped_many": "Пропущено {{count}} файлов — не аудио",
"skipped_other": "Пропущено {{count}} файла — не аудио",
"resume_hint_one": "Не докачан {{count}} файл. Выберите его снова — загрузка продолжится.",
"resume_hint_few": "Не докачано {{count}} файла. Выберите их снова — загрузка продолжится.",
"resume_hint_many": "Не докачано {{count}} файлов. Выберите их снова — загрузка продолжится.",
"resume_hint_other": "Не докачано {{count}} файла. Выберите их снова — загрузка продолжится.",
"resume_pick": "Выбрать файлы и продолжить",
"comment": "Комментарий для администратора",
"comment_hint": "Например: «ремастер 2011», «замена битых файлов»",
"not_enough_space": "В очереди не хватает места: нужно {{need}}, свободно {{free}} — часть файлов не примут.",
@@ -275,6 +280,14 @@
"disc": "диск {{n}}",
"edited": "правлено",
"flag_possible": "возможный дубль",
"flag_quality": "лучше по качеству",
"listen": "Слушать {{name}}",
"edit_batch": "На всю пачку",
"batch_edit_title": "Правка пачки",
"batch_edit_hint": "Пустые поля не меняются. Обложка запишется в файлы при импорте.",
"batch_cover": "Обложка пачки",
"batch_cover_clear": "Убрать обложку",
"set_upgrade": "Принимать тот же трек в лучшем качестве",
"flag_existing_album": "в существующий альбом",
"flag_new_album": "новый альбом",
"dup_level_sha256": "точная копия файла",
@@ -332,7 +345,8 @@
"uploading": "Загружается",
"done": "Загружен, обрабатывается",
"rejected": "Не принят",
"error": "Ошибка"
"error": "Ошибка",
"resume": "Ждёт файл, чтобы продолжить"
}
},
"server_settings": {
+3 -1
View File
@@ -122,7 +122,9 @@
"search_right": "Справа",
"search_collapse": "Сворачивать поиск",
"home_activities": "Выбор занятия на главной",
"home_moods": "Выбор настроения на главной"
"home_moods": "Выбор настроения на главной",
"normalize": "Выравнивание громкости",
"crossfade": "Кроссфейд, {{seconds}} с"
},
"form": {
"show_password": "Показать пароль",