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>
414 lines
20 KiB
Markdown
414 lines
20 KiB
Markdown
# Модель данных (PostgreSQL 17)
|
||
|
||
Соглашения:
|
||
- первичные ключи — `uuid` (UUIDv7, генерируются в приложении — сортируемы по времени);
|
||
- время — `timestamptz`, UTC;
|
||
- мягкое удаление только там, где явно указано (`deleted_at`);
|
||
- миграции — `services/api/migrations`, goose, только вперёд (down-миграции для dev).
|
||
- Расширения: `pgvector`, `pg_trgm`, `citext`.
|
||
|
||
## Пользователи и доступ
|
||
|
||
```sql
|
||
CREATE TYPE user_role AS ENUM ('admin', 'moderator', 'user');
|
||
|
||
CREATE TABLE users (
|
||
id uuid PRIMARY KEY,
|
||
username citext NOT NULL UNIQUE, -- 3..32, [a-z0-9._-]
|
||
display_name text,
|
||
password_hash text NOT NULL, -- argon2id PHC string
|
||
role user_role NOT NULL DEFAULT 'user',
|
||
locale text NOT NULL DEFAULT 'ru', -- BCP 47: ru, en, ...
|
||
ui_prefs jsonb NOT NULL DEFAULT '{}', -- {theme: dark|light|system, wave_variant: orb|liquid,
|
||
-- web_player: bar|island, mobile_player: cover|wave,
|
||
-- ambient: bool, crossfade_s: 0..6, normalize: bool}
|
||
must_change_password boolean NOT NULL DEFAULT false,
|
||
must_change_credentials boolean NOT NULL DEFAULT false, -- только для bootstrap-админа: логин + пароль
|
||
is_blocked boolean NOT NULL DEFAULT false,
|
||
token_version int NOT NULL DEFAULT 0, -- инкремент = мгновенный отзыв access-токенов
|
||
failed_login_count int NOT NULL DEFAULT 0,
|
||
locked_until timestamptz,
|
||
created_by uuid REFERENCES users(id),
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||
last_login_at timestamptz
|
||
);
|
||
|
||
CREATE TABLE refresh_tokens (
|
||
id uuid PRIMARY KEY,
|
||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
family_id uuid NOT NULL, -- цепочка ротаций одного входа
|
||
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
|
||
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
|
||
device_name text, -- "Chrome / Windows", "Pixel 8"
|
||
client_type text NOT NULL, -- 'web' | 'android'
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
expires_at timestamptz NOT NULL,
|
||
used_at timestamptz, -- ротирован
|
||
revoked_at timestamptz
|
||
);
|
||
CREATE INDEX ON refresh_tokens (user_id) WHERE revoked_at IS NULL;
|
||
|
||
CREATE TABLE audit_log (
|
||
id uuid PRIMARY KEY,
|
||
actor_id uuid REFERENCES users(id),
|
||
action text NOT NULL, -- 'user.create', 'user.role_change', 'source.create', ...
|
||
target_type text,
|
||
target_id uuid,
|
||
payload jsonb,
|
||
created_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
```
|
||
|
||
## Хранилища и файлы
|
||
|
||
```sql
|
||
CREATE TYPE source_type AS ENUM ('nfs', 'local');
|
||
CREATE TYPE source_status AS ENUM ('pending', 'mounted', 'error', 'disabled');
|
||
CREATE TYPE tag_priority AS ENUM ('tags_first', 'path_first');
|
||
|
||
CREATE TABLE library_sources (
|
||
id uuid PRIMARY KEY,
|
||
name text NOT NULL,
|
||
type source_type NOT NULL,
|
||
nfs_server text, -- host / IP (для type=nfs)
|
||
nfs_export text, -- /export/music
|
||
nfs_options text NOT NULL DEFAULT 'ro,nfsvers=4.1,soft,timeo=150,retrans=3',
|
||
local_path text, -- для type=local (внутри /library-host)
|
||
tag_priority tag_priority NOT NULL DEFAULT 'tags_first',
|
||
accepts_uploads boolean NOT NULL DEFAULT false, -- монтируется на запись, принимает одобренные загрузки
|
||
write_template text, -- шаблон раскладки: '{albumartist}/{year} - {album}/{track:02} - {title}.{ext}'
|
||
scan_interval interval, -- NULL = только вручную
|
||
status source_status NOT NULL DEFAULT 'pending',
|
||
status_message text,
|
||
last_scan_at timestamptz,
|
||
created_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE path_patterns ( -- упорядоченный список регэкспов источника
|
||
id uuid PRIMARY KEY,
|
||
source_id uuid NOT NULL REFERENCES library_sources(id) ON DELETE CASCADE,
|
||
position int NOT NULL, -- порядок применения, первый совпавший побеждает
|
||
regex text NOT NULL, -- RE2 с именованными группами
|
||
is_enabled boolean NOT NULL DEFAULT true,
|
||
UNIQUE (source_id, position)
|
||
);
|
||
|
||
CREATE TABLE media_files (
|
||
id uuid PRIMARY KEY,
|
||
source_id uuid NOT NULL REFERENCES library_sources(id) ON DELETE CASCADE,
|
||
rel_path text NOT NULL, -- путь относительно корня источника
|
||
size_bytes bigint NOT NULL,
|
||
mtime timestamptz NOT NULL,
|
||
content_hash bytea, -- xxhash первых/последних 1 МБ + размер: быстро и устойчиво к переименованию
|
||
sha256 bytea, -- полный хэш файла (для проверки дублей при загрузке; считается фоном)
|
||
upload_file_id uuid, -- если файл попал через очередь на добавление
|
||
format text NOT NULL, -- flac, mp3, m4a, ogg, opus, wav
|
||
codec text, bitrate int, sample_rate int, bit_depth int, channels int,
|
||
duration_ms int NOT NULL,
|
||
track_id uuid, -- FK ставится после создания tracks
|
||
missing_since timestamptz, -- файл пропал при скане
|
||
scan_error text,
|
||
UNIQUE (source_id, rel_path)
|
||
);
|
||
CREATE INDEX ON media_files (content_hash);
|
||
CREATE INDEX ON media_files (sha256);
|
||
|
||
CREATE TABLE track_fingerprints ( -- Chromaprint, для поиска дублей при загрузке
|
||
file_id uuid PRIMARY KEY REFERENCES media_files(id) ON DELETE CASCADE,
|
||
track_id uuid,
|
||
duration_ms int NOT NULL,
|
||
fingerprint int[] NOT NULL, -- raw-отпечаток fpcalc
|
||
fp_prefix bigint NOT NULL -- первые 64 бита — грубый индекс для отбора кандидатов
|
||
);
|
||
CREATE INDEX ON track_fingerprints (fp_prefix);
|
||
CREATE INDEX ON track_fingerprints (duration_ms);
|
||
```
|
||
|
||
## Очередь на добавление (загрузки)
|
||
|
||
```sql
|
||
CREATE TYPE upload_status AS ENUM (
|
||
'uploading', 'processing', 'pending_review', 'approved', 'importing', 'imported',
|
||
'import_failed', 'duplicate', 'invalid', 'rejected', 'withdrawn', 'expired');
|
||
|
||
CREATE TABLE upload_batches ( -- одна «пачка» загрузки (обычно альбом / папка)
|
||
id uuid PRIMARY KEY,
|
||
author_id uuid NOT NULL REFERENCES users(id),
|
||
comment text, -- комментарий автора для админа
|
||
created_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE upload_files (
|
||
id uuid PRIMARY KEY,
|
||
batch_id uuid NOT NULL REFERENCES upload_batches(id) ON DELETE CASCADE,
|
||
author_id uuid NOT NULL REFERENCES users(id),
|
||
status upload_status NOT NULL,
|
||
original_name text NOT NULL,
|
||
client_rel_path text, -- относительный путь из перетащенной папки
|
||
size_bytes bigint NOT NULL, -- заявленный, затем фактический
|
||
sha256 bytea,
|
||
staging_path text,
|
||
format text, codec text, bitrate int, sample_rate int, bit_depth int,
|
||
duration_ms int,
|
||
tags jsonb, -- исходные теги файла
|
||
metadata jsonb, -- итоговые поля {artist, albumartist, album, year, disc, track, title, genre}
|
||
metadata_edited boolean NOT NULL DEFAULT false,
|
||
matched_artist_id uuid REFERENCES artists(id),
|
||
matched_album_id uuid REFERENCES albums(id),
|
||
cover_upload_id uuid, -- отдельная картинка обложки из той же пачки
|
||
duplicate_of_track_id uuid REFERENCES tracks(id),
|
||
duplicate_level text, -- sha256 | metadata | fingerprint
|
||
possible_duplicate boolean NOT NULL DEFAULT false,
|
||
target_source_id uuid REFERENCES library_sources(id),
|
||
target_rel_path text,
|
||
status_reason text, -- код причины: uploads.duplicate, uploads.invalid_audio, текст отклонения
|
||
reviewed_by uuid REFERENCES users(id),
|
||
reviewed_at timestamptz,
|
||
imported_file_id uuid REFERENCES media_files(id),
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX ON upload_files (status);
|
||
CREATE INDEX ON upload_files (author_id, created_at DESC);
|
||
CREATE INDEX ON upload_files (sha256);
|
||
|
||
-- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса;
|
||
-- ночная задача сверяет его с SUM(size_bytes) по «занимающим» статусам.
|
||
CREATE TABLE upload_quota (
|
||
id boolean PRIMARY KEY DEFAULT true CHECK (id),
|
||
used_bytes bigint NOT NULL DEFAULT 0
|
||
);
|
||
```
|
||
|
||
## Каталог
|
||
|
||
```sql
|
||
CREATE TABLE artists (
|
||
id uuid PRIMARY KEY,
|
||
name text NOT NULL,
|
||
sort_name text NOT NULL,
|
||
name_norm text NOT NULL UNIQUE, -- нормализованное имя: lower, ё→е, без пунктуации
|
||
image_path text, -- кэш изображения
|
||
created_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE albums (
|
||
id uuid PRIMARY KEY,
|
||
title text NOT NULL,
|
||
title_norm text NOT NULL,
|
||
album_artist_id uuid NOT NULL REFERENCES artists(id), -- без тегов — исполнитель трека или «Unknown artist»
|
||
year int,
|
||
cover_path text, -- путь в кэше обложек (/cache/covers/<hash>.webp)
|
||
cover_colors text[], -- палитра обложки (3 цвета: доминирующий, второй, акцент) — для ambient-фона,
|
||
-- цветов кнопки волны и плейсхолдера обложки
|
||
UNIQUE (album_artist_id, title_norm)
|
||
);
|
||
|
||
CREATE TABLE tracks (
|
||
id uuid PRIMARY KEY,
|
||
title text NOT NULL,
|
||
title_norm text NOT NULL, -- ключ поиска дублей: (album_id, title_norm, duration ±2 с)
|
||
album_id uuid NOT NULL REFERENCES albums(id),
|
||
disc_no int, track_no int,
|
||
year int,
|
||
duration_ms int NOT NULL,
|
||
primary_file_id uuid REFERENCES media_files(id), -- лучший по качеству файл, если дубли
|
||
explicit boolean,
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
ALTER TABLE media_files ADD FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL;
|
||
|
||
CREATE TABLE track_artists (
|
||
track_id uuid REFERENCES tracks(id) ON DELETE CASCADE,
|
||
artist_id uuid REFERENCES artists(id) ON DELETE CASCADE,
|
||
role text NOT NULL DEFAULT 'main', -- main | feat
|
||
position int NOT NULL,
|
||
PRIMARY KEY (track_id, artist_id, role)
|
||
);
|
||
|
||
CREATE TABLE genres (
|
||
id serial PRIMARY KEY,
|
||
name text NOT NULL,
|
||
name_norm text NOT NULL UNIQUE,
|
||
parent_id int REFERENCES genres(id) -- иерархия: "metal" → "death metal"
|
||
);
|
||
CREATE TABLE track_genres (
|
||
track_id uuid REFERENCES tracks(id) ON DELETE CASCADE,
|
||
genre_id int REFERENCES genres(id),
|
||
source text NOT NULL, -- 'tag' | 'analysis'
|
||
weight real NOT NULL DEFAULT 1, -- для analysis — вероятность модели
|
||
PRIMARY KEY (track_id, genre_id, source)
|
||
);
|
||
```
|
||
|
||
## Аудиопризнаки (заполняет analyzer)
|
||
|
||
```sql
|
||
CREATE TABLE track_features (
|
||
track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
|
||
analyzer_ver text NOT NULL, -- версия пайплайна, для пересчёта
|
||
bpm real, bpm_confidence real,
|
||
key text, scale text, -- 'A', 'minor'
|
||
loudness_lufs real,
|
||
energy real, -- 0..1
|
||
danceability real, -- 0..1
|
||
valence real, -- 0..1 (грустно → весело)
|
||
arousal real, -- 0..1 (спокойно → возбуждённо)
|
||
acousticness real, instrumentalness real,
|
||
mood_happy real, mood_sad real, mood_relaxed real, mood_aggressive real, mood_party real,
|
||
intro_ms int, -- для мягкого старта / кроссфейда
|
||
analyzed_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE track_embeddings (
|
||
track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
|
||
audio vector(256), -- аудио-эмбеддинг (Discogs-EffNet → PCA 256)
|
||
cf vector(64), -- item-фактор ALS (обновляется ночью)
|
||
updated_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX ON track_embeddings USING hnsw (audio vector_cosine_ops);
|
||
CREATE INDEX ON track_embeddings USING hnsw (cf vector_cosine_ops);
|
||
|
||
CREATE TABLE analysis_jobs (
|
||
track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
|
||
status text NOT NULL DEFAULT 'queued', -- queued | running | done | failed
|
||
attempts int NOT NULL DEFAULT 0,
|
||
error text,
|
||
locked_at timestamptz,
|
||
updated_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
```
|
||
|
||
## Пользовательские данные
|
||
|
||
```sql
|
||
CREATE TYPE reaction AS ENUM ('like', 'dislike');
|
||
|
||
CREATE TABLE track_reactions ( -- Коллекция → Лайки / Дизлайки
|
||
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||
track_id uuid REFERENCES tracks(id) ON DELETE CASCADE,
|
||
reaction reaction NOT NULL,
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (user_id, track_id)
|
||
);
|
||
CREATE INDEX ON track_reactions (user_id, reaction, created_at DESC);
|
||
|
||
CREATE TABLE artist_reactions ( -- «не рекомендовать исполнителя» и т.п.
|
||
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||
artist_id uuid REFERENCES artists(id) ON DELETE CASCADE,
|
||
reaction reaction NOT NULL,
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (user_id, artist_id)
|
||
);
|
||
|
||
-- Сырые события — источник истины для истории и обучения. Партиционирование по месяцам.
|
||
CREATE TABLE play_events (
|
||
id uuid NOT NULL, -- event_id от клиента (идемпотентность)
|
||
user_id uuid NOT NULL,
|
||
track_id uuid NOT NULL,
|
||
type text NOT NULL, -- play_start | play_end | skip | seek | like | unlike | dislike | undislike
|
||
occurred_at timestamptz NOT NULL, -- время на клиенте (важно для офлайна)
|
||
received_at timestamptz NOT NULL DEFAULT now(),
|
||
position_ms int, -- где произошло
|
||
played_ms int, -- реально прослушано в этой попытке (для play_end/skip)
|
||
context_type text, -- wave | album | artist | search | collection | history | queue
|
||
context_id text, -- id альбома / wave_session и т.п.
|
||
wave_session_id uuid,
|
||
client_type text NOT NULL, -- web | android
|
||
offline boolean NOT NULL DEFAULT false,
|
||
PRIMARY KEY (id, occurred_at)
|
||
) PARTITION BY RANGE (occurred_at);
|
||
CREATE INDEX ON play_events (user_id, occurred_at DESC);
|
||
|
||
-- Агрегаты для быстрого доступа (обновляются триггерно в api и пересчитываются ночью)
|
||
CREATE TABLE user_track_stats (
|
||
user_id uuid, track_id uuid,
|
||
play_count int NOT NULL DEFAULT 0, -- засчитанные прослушивания (≥ 50% или ≥ 4 мин)
|
||
complete_count int NOT NULL DEFAULT 0,
|
||
skip_count int NOT NULL DEFAULT 0,
|
||
early_skip_count int NOT NULL DEFAULT 0, -- скип < 30 с
|
||
total_played_ms bigint NOT NULL DEFAULT 0,
|
||
last_played_at timestamptz,
|
||
implicit_score real NOT NULL DEFAULT 0, -- см. recommendations.md
|
||
PRIMARY KEY (user_id, track_id)
|
||
);
|
||
|
||
CREATE TABLE playback_state ( -- «запомнить трек и момент»
|
||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
|
||
position_ms int NOT NULL DEFAULT 0,
|
||
queue jsonb NOT NULL DEFAULT '[]', -- [{track_id, source}] до 500 элементов
|
||
queue_index int NOT NULL DEFAULT 0,
|
||
context jsonb, -- {type:'wave', wave_session_id, activity, mood}
|
||
shuffle boolean NOT NULL DEFAULT false,
|
||
repeat_mode text NOT NULL DEFAULT 'off', -- off | one | all
|
||
volume real NOT NULL DEFAULT 1,
|
||
device_id text, -- кто записал последним
|
||
updated_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
```
|
||
|
||
## Рекомендации
|
||
|
||
```sql
|
||
CREATE TABLE wave_sessions (
|
||
id uuid PRIMARY KEY,
|
||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
activity text, -- running | waking_up | focus | workout | commute | chill | sleep
|
||
mood text, -- energetic | happy | calm | sad
|
||
seed_type text, -- null | artist | track | album
|
||
seed_id uuid,
|
||
started_at timestamptz NOT NULL DEFAULT now(),
|
||
ended_at timestamptz,
|
||
tracks_served int NOT NULL DEFAULT 0
|
||
);
|
||
|
||
CREATE TABLE wave_impressions ( -- что выдали и откуда (для обучения ранжировщика и бандита)
|
||
wave_session_id uuid REFERENCES wave_sessions(id) ON DELETE CASCADE,
|
||
position int NOT NULL,
|
||
track_id uuid NOT NULL,
|
||
candidate_source text NOT NULL, -- audio_sim | cf | artist | genre | fresh | rediscover | popular
|
||
score real,
|
||
model_ver text,
|
||
served_at timestamptz NOT NULL DEFAULT now(),
|
||
outcome text, -- complete | skip_early | skip | like | dislike (заполняется по событиям)
|
||
PRIMARY KEY (wave_session_id, position)
|
||
);
|
||
|
||
CREATE TABLE user_profiles_rec ( -- векторы вкуса и параметры бандита
|
||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||
taste_audio vector(256), -- взвешенное среднее эмбеддингов положительных треков
|
||
taste_cf vector(64), -- user-фактор ALS
|
||
genre_prefs jsonb NOT NULL DEFAULT '{}',-- {genre_id: weight}
|
||
bandit jsonb NOT NULL DEFAULT '{}',-- {source: {alpha, beta}}
|
||
exploration real NOT NULL DEFAULT 0.15,
|
||
updated_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE model_versions (
|
||
id text PRIMARY KEY, -- 'ranker-2026-10-19T03:00'
|
||
kind text NOT NULL, -- als | ranker
|
||
metrics jsonb, -- ndcg@10, recall@50, ...
|
||
is_active boolean NOT NULL DEFAULT false,
|
||
created_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
```
|
||
|
||
## Прочее
|
||
|
||
```sql
|
||
CREATE TABLE app_settings (key text PRIMARY KEY, value jsonb NOT NULL);
|
||
-- 'bootstrap.completed', 'transcode.cache_limit_gb', 'update.gitea',
|
||
-- 'uploads.queue_limit_bytes' (10 ГБ), 'uploads.per_user_limit_bytes' (null), 'uploads.max_file_bytes' (2 ГБ),
|
||
-- 'uploads.default_source_id', 'uploads.allow_quality_upgrade' (false), ...
|
||
```
|
||
|
||
## Очистка и хранение
|
||
|
||
- `play_events` — хранить бессрочно (объём маленький: 100 польз. × ~300 событий/день ≈ 11 млн/год, ~2 ГБ),
|
||
партиции старше 2 лет можно архивировать.
|
||
- `wave_impressions` — 180 дней.
|
||
- `refresh_tokens` — удалять истёкшие через 7 дней после `expires_at`.
|