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

+766
View File
@@ -12,6 +12,8 @@ tags:
- name: auth
- name: me
- name: admin
- name: library
- name: catalog
security:
- bearerAuth: []
paths:
@@ -369,8 +371,316 @@ paths:
$ref: "#/components/schemas/AuditPage"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/sources:
get:
operationId: listSources
tags: [library]
summary: Хранилища (admin)
responses:
"200":
description: Список хранилищ
content:
application/json:
schema:
$ref: "#/components/schemas/SourceList"
default:
$ref: "#/components/responses/Problem"
post:
operationId: createSource
tags: [library]
summary: Подключить хранилище (admin)
description: |
`local` — подпапка в каталоге LIBRARY_HOST сервера; `nfs` — монтирует storage-agent.
Хранилище сохраняется, даже если подключиться не удалось: статус и сообщение — в ответе.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SourceInput"
responses:
"201":
description: Хранилище создано
content:
application/json:
schema:
$ref: "#/components/schemas/Source"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/sources/{id}:
parameters:
- $ref: "#/components/parameters/SourceId"
get:
operationId: getSource
tags: [library]
summary: Хранилище (admin)
responses:
"200":
description: Хранилище
content:
application/json:
schema:
$ref: "#/components/schemas/Source"
default:
$ref: "#/components/responses/Problem"
patch:
operationId: updateSource
tags: [library]
summary: Изменить хранилище (admin)
description: Изменение адреса NFS или папки переподключает хранилище.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SourcePatch"
responses:
"200":
description: Хранилище
content:
application/json:
schema:
$ref: "#/components/schemas/Source"
default:
$ref: "#/components/responses/Problem"
delete:
operationId: deleteSource
tags: [library]
summary: Отключить и удалить хранилище (admin)
description: Треки, файлы которых были только в этом хранилище, удаляются из каталога.
responses:
"204":
description: Удалено
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/sources/{id}/test:
parameters:
- $ref: "#/components/parameters/SourceId"
post:
operationId: testSource
tags: [library]
summary: Проверить подключение (admin)
responses:
"200":
description: Результат проверки
content:
application/json:
schema:
$ref: "#/components/schemas/SourceTestResult"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/sources/{id}/patterns:
parameters:
- $ref: "#/components/parameters/SourceId"
get:
operationId: getSourcePatterns
tags: [library]
summary: Шаблоны путей (admin)
responses:
"200":
description: Шаблоны по порядку
content:
application/json:
schema:
$ref: "#/components/schemas/PatternList"
default:
$ref: "#/components/responses/Problem"
put:
operationId: putSourcePatterns
tags: [library]
summary: Заменить шаблоны путей (admin)
description: Ошибка компиляции — `422 validation.pattern_invalid` с `params.index` и `params.reason`.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PatternList"
responses:
"200":
description: Сохранённые шаблоны
content:
application/json:
schema:
$ref: "#/components/schemas/PatternList"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/sources/{id}/patterns/preview:
parameters:
- $ref: "#/components/parameters/SourceId"
post:
operationId: previewSourcePatterns
tags: [library]
summary: Предпросмотр разбора путей без сохранения (admin)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PatternPreviewRequest"
responses:
"200":
description: Разбор выборки файлов
content:
application/json:
schema:
$ref: "#/components/schemas/PatternPreview"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/sources/{id}/scan:
parameters:
- $ref: "#/components/parameters/SourceId"
post:
operationId: scanSource
tags: [library]
summary: Запустить скан (admin)
description: Скан идёт в фоне; прогресс — в `GET /admin/library/status`. Уже идёт — `409 library.scan_running`.
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/ScanRequest"
responses:
"202":
description: Скан запущен
content:
application/json:
schema:
$ref: "#/components/schemas/ScanProgress"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/library/status:
get:
operationId: getLibraryStatus
tags: [library]
summary: Состояние библиотеки (admin, moderator)
responses:
"200":
description: Счётчики и сканы
content:
application/json:
schema:
$ref: "#/components/schemas/LibraryStatus"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/library/errors:
get:
operationId: listLibraryErrors
tags: [library]
summary: Файлы, которые не удалось прочитать (admin)
parameters:
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor"
responses:
"200":
description: Страница ошибок
content:
application/json:
schema:
$ref: "#/components/schemas/LibraryErrorPage"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/library/host-dirs:
get:
operationId: listHostDirs
tags: [library]
summary: Подпапки каталога LIBRARY_HOST — выбор папки для local-хранилища (admin)
parameters:
- name: path
in: query
description: Путь относительно корня, пусто — корень
schema:
type: string
responses:
"200":
description: Подпапки
content:
application/json:
schema:
$ref: "#/components/schemas/HostDirList"
default:
$ref: "#/components/responses/Problem"
/api/v1/artists/{id}:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: getArtist
tags: [catalog]
summary: Исполнитель
responses:
"200":
description: Исполнитель
content:
application/json:
schema:
$ref: "#/components/schemas/Artist"
default:
$ref: "#/components/responses/Problem"
/api/v1/artists/{id}/albums:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: listArtistAlbums
tags: [catalog]
summary: Альбомы исполнителя (свои и сборники с его треками)
responses:
"200":
description: Альбомы исполнителя (свои и сборники с его треками)
content:
application/json:
schema:
$ref: "#/components/schemas/AlbumSummaryList"
default:
$ref: "#/components/responses/Problem"
/api/v1/albums/{id}:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: getAlbum
tags: [catalog]
summary: Альбом с треками
responses:
"200":
description: Альбом с треками
content:
application/json:
schema:
$ref: "#/components/schemas/Album"
default:
$ref: "#/components/responses/Problem"
/api/v1/tracks/{id}:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: getTrack
tags: [catalog]
summary: Трек
responses:
"200":
description: Трек
content:
application/json:
schema:
$ref: "#/components/schemas/Track"
default:
$ref: "#/components/responses/Problem"
components:
parameters:
SourceId:
name: id
in: path
required: true
schema:
type: string
format: uuid
EntityId:
name: id
in: path
required: true
schema:
type: string
format: uuid
Limit:
name: limit
in: query
@@ -695,6 +1005,462 @@ components:
next_cursor:
type: string
nullable: true
SourceType:
type: string
enum: [nfs, local]
SourceStatus:
type: string
enum: [pending, mounted, error, disabled]
TagPriority:
type: string
enum: [tags_first, path_first]
Source:
type: object
required: [id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority, scan_interval_minutes, status, status_message, last_scan_at, created_at, scan]
properties:
id:
type: string
format: uuid
name:
type: string
type:
$ref: "#/components/schemas/SourceType"
nfs_server:
type: string
nullable: true
nfs_export:
type: string
nullable: true
nfs_options:
type: string
local_path:
type: string
nullable: true
description: Подпапка относительно LIBRARY_HOST, пусто — весь каталог
tag_priority:
$ref: "#/components/schemas/TagPriority"
scan_interval_minutes:
type: integer
nullable: true
description: Автоматический скан, null — только вручную
status:
$ref: "#/components/schemas/SourceStatus"
status_message:
type: string
nullable: true
last_scan_at:
type: string
format: date-time
nullable: true
created_at:
type: string
format: date-time
scan:
nullable: true
allOf:
- $ref: "#/components/schemas/ScanProgress"
SourceList:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/Source"
SourceInput:
type: object
required: [name, type]
properties:
name:
type: string
maxLength: 100
type:
$ref: "#/components/schemas/SourceType"
nfs_server:
type: string
nfs_export:
type: string
nfs_options:
type: string
local_path:
type: string
tag_priority:
$ref: "#/components/schemas/TagPriority"
scan_interval_minutes:
type: integer
minimum: 5
nullable: true
SourcePatch:
type: object
properties:
name:
type: string
maxLength: 100
nfs_server:
type: string
nfs_export:
type: string
nfs_options:
type: string
local_path:
type: string
tag_priority:
$ref: "#/components/schemas/TagPriority"
scan_interval_minutes:
type: integer
description: 0 — отключить автоматический скан
enabled:
type: boolean
description: false — отключить хранилище (треки становятся недоступны), true — подключить снова
SourceTestResult:
type: object
required: [ok, message, audio_files]
properties:
ok:
type: boolean
message:
type: string
audio_files:
type: integer
description: Сколько аудиофайлов найдено (проверка останавливается на 1000)
PathPattern:
type: object
required: [regex, is_enabled]
properties:
regex:
type: string
maxLength: 1000
is_enabled:
type: boolean
PatternList:
type: object
required: [patterns]
properties:
patterns:
type: array
maxItems: 50
items:
$ref: "#/components/schemas/PathPattern"
PatternPreviewRequest:
type: object
required: [patterns]
properties:
patterns:
type: array
maxItems: 50
items:
$ref: "#/components/schemas/PathPattern"
sample:
type: integer
minimum: 1
maximum: 200
default: 50
TrackMeta:
type: object
required: [artist, album_artist, album, title, genres, year, track, disc]
properties:
artist:
type: string
album_artist:
type: string
album:
type: string
title:
type: string
genres:
type: array
items:
type: string
year:
type: integer
nullable: true
track:
type: integer
nullable: true
disc:
type: integer
nullable: true
PreviewItem:
type: object
required: [rel_path, pattern_index, skipped, path_fields, tags, result, error]
properties:
rel_path:
type: string
pattern_index:
type: integer
nullable: true
description: Номер совпавшего шаблона, null — ни один
skipped:
type: boolean
path_fields:
type: object
additionalProperties:
type: string
tags:
type: object
additionalProperties:
type: string
result:
$ref: "#/components/schemas/TrackMeta"
error:
type: string
nullable: true
PatternPreview:
type: object
required: [items, unmatched]
properties:
items:
type: array
items:
$ref: "#/components/schemas/PreviewItem"
unmatched:
type: integer
ScanRequest:
type: object
properties:
full:
type: boolean
description: Перечитать все файлы, а не только новые и изменённые
ScanProgress:
type: object
required: [running, full, started_at, finished_at, found, to_process, processed, added, updated, moved, missing, errors, error]
properties:
running:
type: boolean
full:
type: boolean
started_at:
type: string
format: date-time
finished_at:
type: string
format: date-time
nullable: true
found:
type: integer
to_process:
type: integer
processed:
type: integer
added:
type: integer
updated:
type: integer
moved:
type: integer
missing:
type: integer
errors:
type: integer
error:
type: string
nullable: true
LibraryStatus:
type: object
required: [artists, albums, tracks, files, missing_files, file_errors, sources]
properties:
artists:
type: integer
albums:
type: integer
tracks:
type: integer
files:
type: integer
missing_files:
type: integer
file_errors:
type: integer
sources:
type: array
items:
$ref: "#/components/schemas/SourceStatusItem"
SourceStatusItem:
type: object
required: [id, name, status, status_message, last_scan_at, scan]
properties:
id:
type: string
format: uuid
name:
type: string
status:
$ref: "#/components/schemas/SourceStatus"
status_message:
type: string
nullable: true
last_scan_at:
type: string
format: date-time
nullable: true
scan:
nullable: true
allOf:
- $ref: "#/components/schemas/ScanProgress"
LibraryError:
type: object
required: [file_id, source_id, rel_path, error]
properties:
file_id:
type: string
format: uuid
source_id:
type: string
format: uuid
rel_path:
type: string
error:
type: string
LibraryErrorPage:
type: object
required: [items, next_cursor]
properties:
items:
type: array
items:
$ref: "#/components/schemas/LibraryError"
next_cursor:
type: string
nullable: true
HostDirList:
type: object
required: [path, dirs]
properties:
path:
type: string
dirs:
type: array
items:
type: string
ArtistRef:
type: object
required: [id, name]
properties:
id:
type: string
format: uuid
name:
type: string
AlbumRef:
type: object
required: [id, title, year, cover_url, cover_colors]
properties:
id:
type: string
format: uuid
title:
type: string
year:
type: integer
nullable: true
cover_url:
type: string
nullable: true
cover_colors:
type: array
items:
type: string
Track:
type: object
required: [id, title, duration_ms, artists, album, genres, disc_no, track_no, available]
properties:
id:
type: string
format: uuid
title:
type: string
duration_ms:
type: integer
artists:
type: array
items:
$ref: "#/components/schemas/ArtistRef"
album:
$ref: "#/components/schemas/AlbumRef"
genres:
type: array
items:
type: string
disc_no:
type: integer
nullable: true
track_no:
type: integer
nullable: true
available:
type: boolean
description: Есть доступный файл (хранилище подключено, файл на месте)
Artist:
type: object
required: [id, name, album_count, track_count]
properties:
id:
type: string
format: uuid
name:
type: string
album_count:
type: integer
track_count:
type: integer
AlbumSummary:
type: object
required: [id, title, year, cover_url, cover_colors, artist, track_count]
properties:
id:
type: string
format: uuid
title:
type: string
year:
type: integer
nullable: true
cover_url:
type: string
nullable: true
cover_colors:
type: array
items:
type: string
artist:
$ref: "#/components/schemas/ArtistRef"
track_count:
type: integer
AlbumSummaryList:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/AlbumSummary"
Album:
type: object
required: [id, title, year, cover_url, cover_colors, artist, duration_ms, tracks]
properties:
id:
type: string
format: uuid
title:
type: string
year:
type: integer
nullable: true
cover_url:
type: string
nullable: true
cover_colors:
type: array
items:
type: string
artist:
$ref: "#/components/schemas/ArtistRef"
duration_ms:
type: integer
tracks:
type: array
items:
$ref: "#/components/schemas/Track"
Session:
type: object
required: [id, device_name, client_type, created_at, last_used_at, current]