feat(api): library sources, scanner and catalog (M2)
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s

- schema for sources, path patterns, media files, artists, albums,
  tracks, genres (migration 00003)
- path patterns (RE2 with named groups, skip), merge with tags by
  source priority, fallbacks from the file name and folder, name
  normalization and multi-artist split
- scanner: walk, skip unchanged files by size and mtime, ffprobe in 8
  workers with a single catalog writer, renames by content hash keep
  the track, duplicates fold into one track with the best file as
  primary, missing files are kept 30 days
- storage-agent mounts NFS with validated server, export and an option
  whitelist, no shell; test mount counts audio files
- admin API: sources CRUD, connection test, patterns with preview,
  scan, library status, file errors, host folder picker; scheduled
  scans and health checks; mounts are restored on start
- catalog API: artist, artist albums, album with tracks, track
- api image is alpine with ffmpeg for ffprobe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:48:37 +03:00
1 parent ab092def54
commit c583991d14
24 files changed
+7467 -84

No files matched your search

+878
View File
@@ -322,6 +322,265 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/sources": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Хранилища (admin) */
get: operations["listSources"];
put?: never;
/**
* Подключить хранилище (admin)
* @description `local` — подпапка в каталоге LIBRARY_HOST сервера; `nfs` — монтирует storage-agent.
* Хранилище сохраняется, даже если подключиться не удалось: статус и сообщение — в ответе.
*/
post: operations["createSource"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/sources/{id}": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
/** Хранилище (admin) */
get: operations["getSource"];
put?: never;
post?: never;
/**
* Отключить и удалить хранилище (admin)
* @description Треки, файлы которых были только в этом хранилище, удаляются из каталога.
*/
delete: operations["deleteSource"];
options?: never;
head?: never;
/**
* Изменить хранилище (admin)
* @description Изменение адреса NFS или папки переподключает хранилище.
*/
patch: operations["updateSource"];
trace?: never;
};
"/api/v1/admin/sources/{id}/test": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
get?: never;
put?: never;
/** Проверить подключение (admin) */
post: operations["testSource"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/sources/{id}/patterns": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
/** Шаблоны путей (admin) */
get: operations["getSourcePatterns"];
/**
* Заменить шаблоны путей (admin)
* @description Ошибка компиляции — `422 validation.pattern_invalid` с `params.index` и `params.reason`.
*/
put: operations["putSourcePatterns"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/sources/{id}/patterns/preview": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
get?: never;
put?: never;
/** Предпросмотр разбора путей без сохранения (admin) */
post: operations["previewSourcePatterns"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/sources/{id}/scan": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
get?: never;
put?: never;
/**
* Запустить скан (admin)
* @description Скан идёт в фоне; прогресс — в `GET /admin/library/status`. Уже идёт — `409 library.scan_running`.
*/
post: operations["scanSource"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/library/status": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Состояние библиотеки (admin, moderator) */
get: operations["getLibraryStatus"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/library/errors": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Файлы, которые не удалось прочитать (admin) */
get: operations["listLibraryErrors"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/library/host-dirs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Подпапки каталога LIBRARY_HOST — выбор папки для local-хранилища (admin) */
get: operations["listHostDirs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/artists/{id}": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
/** Исполнитель */
get: operations["getArtist"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/artists/{id}/albums": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
/** Альбомы исполнителя (свои и сборники с его треками) */
get: operations["listArtistAlbums"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/albums/{id}": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
/** Альбом с треками */
get: operations["getAlbum"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tracks/{id}": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
/** Трек */
get: operations["getTrack"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -491,6 +750,217 @@ export interface components {
items: components["schemas"]["AuditEntry"][];
next_cursor: string | null;
};
/** @enum {string} */
SourceType: "nfs" | "local";
/** @enum {string} */
SourceStatus: "pending" | "mounted" | "error" | "disabled";
/** @enum {string} */
TagPriority: "tags_first" | "path_first";
Source: {
/** Format: uuid */
id: string;
name: string;
type: components["schemas"]["SourceType"];
nfs_server: string | null;
nfs_export: string | null;
nfs_options: string;
/** @description Подпапка относительно LIBRARY_HOST, пусто — весь каталог */
local_path: string | null;
tag_priority: components["schemas"]["TagPriority"];
/** @description Автоматический скан, null — только вручную */
scan_interval_minutes: number | null;
status: components["schemas"]["SourceStatus"];
status_message: string | null;
/** Format: date-time */
last_scan_at: string | null;
/** Format: date-time */
created_at: string;
scan: components["schemas"]["ScanProgress"] | null;
};
SourceList: {
items: components["schemas"]["Source"][];
};
SourceInput: {
name: string;
type: components["schemas"]["SourceType"];
nfs_server?: string;
nfs_export?: string;
nfs_options?: string;
local_path?: string;
tag_priority?: components["schemas"]["TagPriority"];
scan_interval_minutes?: number | null;
};
SourcePatch: {
name?: string;
nfs_server?: string;
nfs_export?: string;
nfs_options?: string;
local_path?: string;
tag_priority?: components["schemas"]["TagPriority"];
/** @description 0 — отключить автоматический скан */
scan_interval_minutes?: number;
/** @description false — отключить хранилище (треки становятся недоступны), true — подключить снова */
enabled?: boolean;
};
SourceTestResult: {
ok: boolean;
message: string;
/** @description Сколько аудиофайлов найдено (проверка останавливается на 1000) */
audio_files: number;
};
PathPattern: {
regex: string;
is_enabled: boolean;
};
PatternList: {
patterns: components["schemas"]["PathPattern"][];
};
PatternPreviewRequest: {
patterns: components["schemas"]["PathPattern"][];
/** @default 50 */
sample: number;
};
TrackMeta: {
artist: string;
album_artist: string;
album: string;
title: string;
genres: string[];
year: number | null;
track: number | null;
disc: number | null;
};
PreviewItem: {
rel_path: string;
/** @description Номер совпавшего шаблона, null — ни один */
pattern_index: number | null;
skipped: boolean;
path_fields: {
[key: string]: string;
};
tags: {
[key: string]: string;
};
result: components["schemas"]["TrackMeta"];
error: string | null;
};
PatternPreview: {
items: components["schemas"]["PreviewItem"][];
unmatched: number;
};
ScanRequest: {
/** @description Перечитать все файлы, а не только новые и изменённые */
full?: boolean;
};
ScanProgress: {
running: boolean;
full: boolean;
/** Format: date-time */
started_at: string;
/** Format: date-time */
finished_at: string | null;
found: number;
to_process: number;
processed: number;
added: number;
updated: number;
moved: number;
missing: number;
errors: number;
error: string | null;
};
LibraryStatus: {
artists: number;
albums: number;
tracks: number;
files: number;
missing_files: number;
file_errors: number;
sources: components["schemas"]["SourceStatusItem"][];
};
SourceStatusItem: {
/** Format: uuid */
id: string;
name: string;
status: components["schemas"]["SourceStatus"];
status_message: string | null;
/** Format: date-time */
last_scan_at: string | null;
scan: components["schemas"]["ScanProgress"] | null;
};
LibraryError: {
/** Format: uuid */
file_id: string;
/** Format: uuid */
source_id: string;
rel_path: string;
error: string;
};
LibraryErrorPage: {
items: components["schemas"]["LibraryError"][];
next_cursor: string | null;
};
HostDirList: {
path: string;
dirs: string[];
};
ArtistRef: {
/** Format: uuid */
id: string;
name: string;
};
AlbumRef: {
/** Format: uuid */
id: string;
title: string;
year: number | null;
cover_url: string | null;
cover_colors: string[];
};
Track: {
/** Format: uuid */
id: string;
title: string;
duration_ms: number;
artists: components["schemas"]["ArtistRef"][];
album: components["schemas"]["AlbumRef"];
genres: string[];
disc_no: number | null;
track_no: number | null;
/** @description Есть доступный файл (хранилище подключено, файл на месте) */
available: boolean;
};
Artist: {
/** Format: uuid */
id: string;
name: string;
album_count: number;
track_count: number;
};
AlbumSummary: {
/** Format: uuid */
id: string;
title: string;
year: number | null;
cover_url: string | null;
cover_colors: string[];
artist: components["schemas"]["ArtistRef"];
track_count: number;
};
AlbumSummaryList: {
items: components["schemas"]["AlbumSummary"][];
};
Album: {
/** Format: uuid */
id: string;
title: string;
year: number | null;
cover_url: string | null;
cover_colors: string[];
artist: components["schemas"]["ArtistRef"];
duration_ms: number;
tracks: components["schemas"]["Track"][];
};
Session: {
/** Format: uuid */
id: string;
@@ -540,6 +1010,8 @@ export interface components {
};
};
parameters: {
SourceId: string;
EntityId: string;
Limit: number;
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
Cursor: string;
@@ -997,4 +1469,410 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
listSources: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Список хранилищ */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SourceList"];
};
};
default: components["responses"]["Problem"];
};
};
createSource: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SourceInput"];
};
};
responses: {
/** @description Хранилище создано */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Source"];
};
};
default: components["responses"]["Problem"];
};
};
getSource: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Хранилище */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Source"];
};
};
default: components["responses"]["Problem"];
};
};
deleteSource: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Удалено */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
updateSource: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SourcePatch"];
};
};
responses: {
/** @description Хранилище */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Source"];
};
};
default: components["responses"]["Problem"];
};
};
testSource: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Результат проверки */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SourceTestResult"];
};
};
default: components["responses"]["Problem"];
};
};
getSourcePatterns: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Шаблоны по порядку */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PatternList"];
};
};
default: components["responses"]["Problem"];
};
};
putSourcePatterns: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PatternList"];
};
};
responses: {
/** @description Сохранённые шаблоны */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PatternList"];
};
};
default: components["responses"]["Problem"];
};
};
previewSourcePatterns: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PatternPreviewRequest"];
};
};
responses: {
/** @description Разбор выборки файлов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PatternPreview"];
};
};
default: components["responses"]["Problem"];
};
};
scanSource: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["SourceId"];
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["ScanRequest"];
};
};
responses: {
/** @description Скан запущен */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ScanProgress"];
};
};
default: components["responses"]["Problem"];
};
};
getLibraryStatus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Счётчики и сканы */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LibraryStatus"];
};
};
default: components["responses"]["Problem"];
};
};
listLibraryErrors: {
parameters: {
query?: {
limit?: components["parameters"]["Limit"];
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
cursor?: components["parameters"]["Cursor"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Страница ошибок */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LibraryErrorPage"];
};
};
default: components["responses"]["Problem"];
};
};
listHostDirs: {
parameters: {
query?: {
/** @description Путь относительно корня, пусто — корень */
path?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Подпапки */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HostDirList"];
};
};
default: components["responses"]["Problem"];
};
};
getArtist: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Исполнитель */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Artist"];
};
};
default: components["responses"]["Problem"];
};
};
listArtistAlbums: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Альбомы исполнителя (свои и сборники с его треками) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlbumSummaryList"];
};
};
default: components["responses"]["Problem"];
};
};
getAlbum: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Альбом с треками */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Album"];
};
};
default: components["responses"]["Problem"];
};
};
getTrack: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Трек */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Track"];
};
};
default: components["responses"]["Problem"];
};
};
}