Files
pipu-music/services/api/internal/library/store.go
T
mrleo1nidandClaude Opus 5.5 d91ff89c64
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m5s
ci / ts / ts (packages + web) (push) Successful in 1m3s
ci / api / api (push) Successful in 29s
ci / db / db (push) Failing after 12s
feat(admin): wave metrics split by model version (M6)
The library page compares listen-through, early-skip and like rates per
impression model version, so v1 and the trained ranker can be compared online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:57:11 +03:00

795 lines
31 KiB
Go

package library
import (
"context"
"errors"
"fmt"
"path"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"pipu/api/internal/library/meta"
)
// ErrNotFound — запись не найдена.
var ErrNotFound = errors.New("library: not found")
// SourceType — тип хранилища.
type SourceType string
// Типы хранилищ.
const (
SourceNFS SourceType = "nfs"
SourceLocal SourceType = "local"
)
// Source — хранилище (library_sources).
type Source struct {
ID uuid.UUID
Name string
Type SourceType
NFSServer *string
NFSExport *string
NFSOptions string
LocalPath *string
TagsFirst bool
ScanInterval *time.Duration
Status string // pending | mounted | error | disabled
StatusMessage *string
LastScanAt *time.Time
CreatedAt time.Time
AcceptsUploads bool
WriteTemplate *string
}
// PatternRow — шаблон пути источника.
type PatternRow struct {
Regex string
IsEnabled bool
}
// FileRow — то, что сканеру нужно знать о ранее найденном файле.
type FileRow struct {
ID uuid.UUID
RelPath string
Size int64
Mtime time.Time
ContentHash []byte
TrackID *uuid.UUID
Missing bool
}
// FileData — результат чтения файла для записи в media_files.
type FileData struct {
RelPath string
Size int64
Mtime time.Time
ContentHash []byte
Probe Probe
ScanError string
}
// Store — доступ к таблицам библиотеки и каталога.
type Store struct {
pool *pgxpool.Pool
}
// NewStore создаёт Store.
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func newID() uuid.UUID { return uuid.Must(uuid.NewV7()) }
const sourceColumns = `id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority = 'tags_first',
extract(epoch from scan_interval)::bigint, status, status_message, last_scan_at, created_at, accepts_uploads,
write_template`
func scanSource(row pgx.Row) (Source, error) {
var (
s Source
typ string
interval *int64
)
err := row.Scan(&s.ID, &s.Name, &typ, &s.NFSServer, &s.NFSExport, &s.NFSOptions, &s.LocalPath, &s.TagsFirst,
&interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads, &s.WriteTemplate)
if errors.Is(err, pgx.ErrNoRows) {
return Source{}, ErrNotFound
}
if err != nil {
return Source{}, fmt.Errorf("library: scan source: %w", err)
}
s.Type = SourceType(typ)
if interval != nil {
d := time.Duration(*interval) * time.Second
s.ScanInterval = &d
}
return s, nil
}
// ListSources — все хранилища по дате создания.
func (st *Store) ListSources(ctx context.Context) ([]Source, error) {
rows, err := st.pool.Query(ctx, `SELECT `+sourceColumns+` FROM library_sources ORDER BY created_at`)
if err != nil {
return nil, fmt.Errorf("library: list sources: %w", err)
}
defer rows.Close()
var out []Source
for rows.Next() {
s, err := scanSource(rows)
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// GetSource — хранилище по id.
func (st *Store) GetSource(ctx context.Context, id uuid.UUID) (Source, error) {
return scanSource(st.pool.QueryRow(ctx, `SELECT `+sourceColumns+` FROM library_sources WHERE id = $1`, id))
}
func intervalSeconds(d *time.Duration) *int64 {
if d == nil {
return nil
}
sec := int64(*d / time.Second)
return &sec
}
func tagPriority(tagsFirst bool) string {
if tagsFirst {
return "tags_first"
}
return "path_first"
}
// SaveSource создаёт или полностью обновляет хранилище (кроме статуса и времени скана).
func (st *Store) SaveSource(ctx context.Context, s Source) (Source, error) {
return scanSource(st.pool.QueryRow(ctx, `
INSERT INTO library_sources (id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority,
scan_interval, status, status_message, accepts_uploads, write_template, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, now())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, nfs_server = EXCLUDED.nfs_server,
nfs_export = EXCLUDED.nfs_export, nfs_options = EXCLUDED.nfs_options, local_path = EXCLUDED.local_path,
tag_priority = EXCLUDED.tag_priority, scan_interval = EXCLUDED.scan_interval,
accepts_uploads = EXCLUDED.accepts_uploads, write_template = EXCLUDED.write_template
RETURNING `+sourceColumns,
s.ID, s.Name, string(s.Type), s.NFSServer, s.NFSExport, s.NFSOptions, s.LocalPath, tagPriority(s.TagsFirst),
intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage, s.AcceptsUploads, s.WriteTemplate))
}
// SetSourceStatus обновляет состояние подключения.
func (st *Store) SetSourceStatus(ctx context.Context, id uuid.UUID, status string, message *string) error {
_, err := st.pool.Exec(ctx, `UPDATE library_sources SET status = $2, status_message = $3 WHERE id = $1`,
id, status, message)
if err != nil {
return fmt.Errorf("library: set source status: %w", err)
}
return nil
}
// SetLastScan отмечает время завершения скана.
func (st *Store) SetLastScan(ctx context.Context, id uuid.UUID, at time.Time) error {
_, err := st.pool.Exec(ctx, `UPDATE library_sources SET last_scan_at = $2 WHERE id = $1`, id, at)
if err != nil {
return fmt.Errorf("library: set last scan: %w", err)
}
return nil
}
// DeleteSource удаляет хранилище; файлы удаляются каскадно, каталог чистится отдельно (CleanupOrphans).
func (st *Store) DeleteSource(ctx context.Context, id uuid.UUID) error {
tag, err := st.pool.Exec(ctx, `DELETE FROM library_sources WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("library: delete source: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Patterns — шаблоны источника по порядку.
func (st *Store) Patterns(ctx context.Context, sourceID uuid.UUID) ([]PatternRow, error) {
rows, err := st.pool.Query(ctx,
`SELECT regex, is_enabled FROM path_patterns WHERE source_id = $1 ORDER BY position`, sourceID)
if err != nil {
return nil, fmt.Errorf("library: patterns: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (PatternRow, error) {
var p PatternRow
return p, r.Scan(&p.Regex, &p.IsEnabled)
})
}
// ReplacePatterns заменяет список шаблонов источника.
func (st *Store) ReplacePatterns(ctx context.Context, sourceID uuid.UUID, patterns []PatternRow) error {
return pgx.BeginFunc(ctx, st.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `DELETE FROM path_patterns WHERE source_id = $1`, sourceID); err != nil {
return fmt.Errorf("library: replace patterns: %w", err)
}
for i, p := range patterns {
if _, err := tx.Exec(ctx, `
INSERT INTO path_patterns (id, source_id, position, regex, is_enabled) VALUES ($1, $2, $3, $4, $5)`,
newID(), sourceID, i, p.Regex, p.IsEnabled); err != nil {
return fmt.Errorf("library: replace patterns: %w", err)
}
}
return nil
})
}
// Files — все известные файлы источника по относительному пути.
func (st *Store) Files(ctx context.Context, sourceID uuid.UUID) (map[string]FileRow, error) {
rows, err := st.pool.Query(ctx, `
SELECT id, rel_path, size_bytes, mtime, content_hash, track_id, missing_since IS NOT NULL
FROM media_files WHERE source_id = $1`, sourceID)
if err != nil {
return nil, fmt.Errorf("library: files: %w", err)
}
defer rows.Close()
out := map[string]FileRow{}
for rows.Next() {
var f FileRow
if err := rows.Scan(&f.ID, &f.RelPath, &f.Size, &f.Mtime, &f.ContentHash, &f.TrackID, &f.Missing); err != nil {
return nil, fmt.Errorf("library: files: %w", err)
}
out[f.RelPath] = f
}
return out, rows.Err()
}
// FileByPath — файл источника по пути (точечный скан).
func (st *Store) FileByPath(ctx context.Context, sourceID uuid.UUID, rel string) (FileRow, bool, error) {
var f FileRow
err := st.pool.QueryRow(ctx, `
SELECT id, rel_path, size_bytes, mtime, content_hash, track_id, missing_since IS NOT NULL
FROM media_files WHERE source_id = $1 AND rel_path = $2`, sourceID, rel).
Scan(&f.ID, &f.RelPath, &f.Size, &f.Mtime, &f.ContentHash, &f.TrackID, &f.Missing)
if errors.Is(err, pgx.ErrNoRows) {
return FileRow{}, false, nil
}
if err != nil {
return FileRow{}, false, fmt.Errorf("library: file by path: %w", err)
}
return f, true, nil
}
// SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов).
func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) {
rows, err := st.pool.Query(ctx, `
SELECT rel_path FROM media_files WHERE source_id = $1 AND missing_since IS NULL ORDER BY random() LIMIT $2`,
sourceID, n)
if err != nil {
return nil, fmt.Errorf("library: sample files: %w", err)
}
return pgx.CollectRows(rows, pgx.RowTo[string])
}
// SaveFile записывает файл (новый — id = uuid.Nil) и возвращает его id.
func (st *Store) SaveFile(ctx context.Context, sourceID, id uuid.UUID, d FileData, trackID *uuid.UUID) (uuid.UUID, error) {
if id == uuid.Nil {
id = newID()
}
var scanErr *string
if d.ScanError != "" {
scanErr = &d.ScanError
}
p := d.Probe
if p.Format == "" {
p.Format = "unknown"
}
_, err := st.pool.Exec(ctx, `
INSERT INTO media_files (id, source_id, rel_path, size_bytes, mtime, content_hash, format, codec, bitrate,
sample_rate, bit_depth, channels, duration_ms, track_id, scan_error)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), NULLIF($9, 0), NULLIF($10, 0), NULLIF($11, 0),
NULLIF($12, 0), $13, $14, $15)
ON CONFLICT (id) DO UPDATE SET rel_path = EXCLUDED.rel_path, size_bytes = EXCLUDED.size_bytes,
mtime = EXCLUDED.mtime, content_hash = EXCLUDED.content_hash, format = EXCLUDED.format,
codec = EXCLUDED.codec, bitrate = EXCLUDED.bitrate, sample_rate = EXCLUDED.sample_rate,
bit_depth = EXCLUDED.bit_depth, channels = EXCLUDED.channels, duration_ms = EXCLUDED.duration_ms,
track_id = EXCLUDED.track_id, scan_error = EXCLUDED.scan_error, missing_since = NULL,
-- Содержимое изменилось — отпечатки посчитаются заново.
sha256 = CASE WHEN media_files.content_hash IS DISTINCT FROM EXCLUDED.content_hash
OR media_files.size_bytes <> EXCLUDED.size_bytes THEN NULL ELSE media_files.sha256 END,
fingerprinted_at = CASE WHEN media_files.content_hash IS DISTINCT FROM EXCLUDED.content_hash
OR media_files.size_bytes <> EXCLUDED.size_bytes THEN NULL ELSE media_files.fingerprinted_at END`,
id, sourceID, d.RelPath, d.Size, d.Mtime, d.ContentHash, p.Format, p.Codec, p.Bitrate, p.SampleRate,
p.BitDepth, p.Channels, p.DurationMs, trackID, scanErr)
if err != nil {
return uuid.Nil, fmt.Errorf("library: save file %s: %w", d.RelPath, err)
}
return id, nil
}
// MoveFile — переименование / перенос: путь меняется, трек (лайки, история) сохраняется.
func (st *Store) MoveFile(ctx context.Context, id uuid.UUID, relPath string, size int64, mtime time.Time) error {
_, err := st.pool.Exec(ctx, `
UPDATE media_files SET rel_path = $2, size_bytes = $3, mtime = $4, missing_since = NULL WHERE id = $1`,
id, relPath, size, mtime)
if err != nil {
return fmt.Errorf("library: move file: %w", err)
}
return nil
}
// SetPresent снимает отметку «пропал» с файла, который снова на месте.
func (st *Store) SetPresent(ctx context.Context, id uuid.UUID) error {
_, err := st.pool.Exec(ctx, `UPDATE media_files SET missing_since = NULL WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("library: set present: %w", err)
}
return nil
}
// MarkMissing отмечает пропавшие файлы (если ещё не отмечены).
func (st *Store) MarkMissing(ctx context.Context, ids []uuid.UUID, at time.Time) error {
if len(ids) == 0 {
return nil
}
_, err := st.pool.Exec(ctx, `
UPDATE media_files SET missing_since = $2 WHERE id = ANY($1) AND missing_since IS NULL`, ids, at)
if err != nil {
return fmt.Errorf("library: mark missing: %w", err)
}
return nil
}
// PurgeMissing удаляет файлы, пропавшие раньше before.
func (st *Store) PurgeMissing(ctx context.Context, sourceID uuid.UUID, before time.Time) (int64, error) {
tag, err := st.pool.Exec(ctx, `DELETE FROM media_files WHERE source_id = $1 AND missing_since < $2`,
sourceID, before)
if err != nil {
return 0, fmt.Errorf("library: purge missing: %w", err)
}
return tag.RowsAffected(), nil
}
// ArtistKeyExists — есть ли исполнитель с таким ключом (для разбора «A & B»).
func (st *Store) ArtistKeyExists(ctx context.Context, key string) bool {
var ok bool
_ = st.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM artists WHERE name_norm = $1)`, key).Scan(&ok)
return ok
}
// EnsureArtist находит или создаёт исполнителя по нормализованному имени.
func (st *Store) EnsureArtist(ctx context.Context, name string) (uuid.UUID, error) {
var id uuid.UUID
err := st.pool.QueryRow(ctx, `
INSERT INTO artists (id, name, sort_name, name_norm) VALUES ($1, $2, $3, $4)
ON CONFLICT (name_norm) DO UPDATE SET name_norm = EXCLUDED.name_norm
RETURNING id`, newID(), name, meta.SortName(name), meta.Key(name)).Scan(&id)
if err != nil {
return uuid.Nil, fmt.Errorf("library: ensure artist %q: %w", name, err)
}
return id, nil
}
// EnsureAlbum находит или создаёт альбом исполнителя; год заполняется, если был пуст.
func (st *Store) EnsureAlbum(ctx context.Context, artistID uuid.UUID, title string, year int) (uuid.UUID, error) {
var id uuid.UUID
err := st.pool.QueryRow(ctx, `
INSERT INTO albums (id, title, title_norm, album_artist_id, year) VALUES ($1, $2, $3, $4, NULLIF($5, 0))
ON CONFLICT (album_artist_id, title_norm) DO UPDATE SET year = COALESCE(albums.year, EXCLUDED.year)
RETURNING id`, newID(), title, meta.Key(title), artistID, year).Scan(&id)
if err != nil {
return uuid.Nil, fmt.Errorf("library: ensure album %q: %w", title, err)
}
return id, nil
}
// TrackInput — данные трека из метаданных файла.
type TrackInput struct {
AlbumID uuid.UUID
Title string
Disc int
Track int
Year int
DurationMs int
}
// FindTrack ищет дубль: тот же альбом и название, длительность ±2 с.
func (st *Store) FindTrack(ctx context.Context, in TrackInput) (uuid.UUID, bool, error) {
var id uuid.UUID
err := st.pool.QueryRow(ctx, `
SELECT id FROM tracks WHERE album_id = $1 AND title_norm = $2 AND abs(duration_ms - $3) <= 2000
ORDER BY abs(duration_ms - $3) LIMIT 1`, in.AlbumID, meta.Key(in.Title), in.DurationMs).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, false, nil
}
if err != nil {
return uuid.Nil, false, fmt.Errorf("library: find track: %w", err)
}
return id, true, nil
}
// SaveTrack создаёт (id = uuid.Nil) или обновляет трек.
func (st *Store) SaveTrack(ctx context.Context, id uuid.UUID, in TrackInput) (uuid.UUID, error) {
if id == uuid.Nil {
id = newID()
}
_, err := st.pool.Exec(ctx, `
INSERT INTO tracks (id, title, title_norm, album_id, disc_no, track_no, year, duration_ms)
VALUES ($1, $2, $3, $4, NULLIF($5, 0), NULLIF($6, 0), NULLIF($7, 0), $8)
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, title_norm = EXCLUDED.title_norm,
album_id = EXCLUDED.album_id, disc_no = EXCLUDED.disc_no, track_no = EXCLUDED.track_no,
year = EXCLUDED.year, duration_ms = EXCLUDED.duration_ms, updated_at = now()`,
id, in.Title, meta.Key(in.Title), in.AlbumID, in.Disc, in.Track, in.Year, in.DurationMs)
if err != nil {
return uuid.Nil, fmt.Errorf("library: save track: %w", err)
}
return id, nil
}
// SetTrackArtists заменяет исполнителей трека.
func (st *Store) SetTrackArtists(ctx context.Context, trackID uuid.UUID, ids []uuid.UUID, roles []string) error {
return pgx.BeginFunc(ctx, st.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `DELETE FROM track_artists WHERE track_id = $1`, trackID); err != nil {
return fmt.Errorf("library: track artists: %w", err)
}
for i, id := range ids {
if _, err := tx.Exec(ctx, `
INSERT INTO track_artists (track_id, artist_id, role, position) VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING`, trackID, id, roles[i], i); err != nil {
return fmt.Errorf("library: track artists: %w", err)
}
}
return nil
})
}
// SetTrackGenres заменяет жанры трека из тегов.
func (st *Store) SetTrackGenres(ctx context.Context, trackID uuid.UUID, genres []string) error {
return pgx.BeginFunc(ctx, st.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `DELETE FROM track_genres WHERE track_id = $1 AND source = 'tag'`, trackID); err != nil {
return fmt.Errorf("library: track genres: %w", err)
}
for _, g := range genres {
var gid int
if err := tx.QueryRow(ctx, `
INSERT INTO genres (name, name_norm) VALUES ($1, $2)
ON CONFLICT (name_norm) DO UPDATE SET name_norm = EXCLUDED.name_norm RETURNING id`,
g, meta.Key(g)).Scan(&gid); err != nil {
return fmt.Errorf("library: genre %q: %w", g, err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO track_genres (track_id, genre_id, source) VALUES ($1, $2, 'tag') ON CONFLICT DO NOTHING`,
trackID, gid); err != nil {
return fmt.Errorf("library: track genres: %w", err)
}
}
return nil
})
}
// RefreshPrimaryFile выбирает лучший доступный файл трека: lossless, затем битрейт.
func (st *Store) RefreshPrimaryFile(ctx context.Context, trackID uuid.UUID) error {
_, err := st.pool.Exec(ctx, `
UPDATE tracks SET primary_file_id = (
SELECT id FROM media_files
WHERE track_id = $1 AND missing_since IS NULL AND scan_error IS NULL
ORDER BY (codec IN ('flac', 'alac', 'wavpack', 'ape', 'tta') OR codec LIKE 'pcm_%') DESC,
bitrate DESC NULLS LAST, size_bytes DESC
LIMIT 1)
WHERE id = $1`, trackID)
if err != nil {
return fmt.Errorf("library: refresh primary file: %w", err)
}
return nil
}
// CleanupOrphans удаляет треки без файлов, затем пустые альбомы и исполнителей.
func (st *Store) CleanupOrphans(ctx context.Context) error {
for _, q := range []string{
`DELETE FROM tracks t WHERE NOT EXISTS (SELECT 1 FROM media_files f WHERE f.track_id = t.id)`,
`DELETE FROM albums a WHERE NOT EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)`,
`DELETE FROM artists a WHERE NOT EXISTS (SELECT 1 FROM albums al WHERE al.album_artist_id = a.id)
AND NOT EXISTS (SELECT 1 FROM track_artists ta WHERE ta.artist_id = a.id)`,
`DELETE FROM genres g WHERE NOT EXISTS (SELECT 1 FROM track_genres tg WHERE tg.genre_id = g.id)
AND NOT EXISTS (SELECT 1 FROM genres c WHERE c.parent_id = g.id)`,
} {
if _, err := st.pool.Exec(ctx, q); err != nil {
return fmt.Errorf("library: cleanup: %w", err)
}
}
return nil
}
// Counts — счётчики для статуса библиотеки.
type Counts struct {
Artists, Albums, Tracks, Files, Missing, Errors int64
}
// Counts считает объём библиотеки.
func (st *Store) Counts(ctx context.Context) (Counts, error) {
var c Counts
err := st.pool.QueryRow(ctx, `
SELECT (SELECT count(*) FROM artists), (SELECT count(*) FROM albums), (SELECT count(*) FROM tracks),
(SELECT count(*) FROM media_files), (SELECT count(*) FROM media_files WHERE missing_since IS NOT NULL),
(SELECT count(*) FROM media_files WHERE scan_error IS NOT NULL)`).
Scan(&c.Artists, &c.Albums, &c.Tracks, &c.Files, &c.Missing, &c.Errors)
if err != nil {
return Counts{}, fmt.Errorf("library: counts: %w", err)
}
return c, nil
}
// FileError — файл, который не удалось прочитать.
type FileError struct {
ID uuid.UUID
SourceID uuid.UUID
RelPath string
ScanError string
}
// FileErrors — страница файлов с ошибками чтения (по пути, после after).
func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
rows, err := st.pool.Query(ctx, `
SELECT id, source_id, rel_path, scan_error FROM media_files
WHERE scan_error IS NOT NULL AND rel_path > $1 ORDER BY rel_path LIMIT $2`, after, limit)
if err != nil {
return nil, fmt.Errorf("library: file errors: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (FileError, error) {
var e FileError
return e, r.Scan(&e.ID, &e.SourceID, &e.RelPath, &e.ScanError)
})
}
// AlbumsWithoutCover — альбомы, обложку которых ещё не искали, и доступный файл первого трека.
// Альбомы без доступных файлов пропускаются: искать не в чем.
func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int) ([]CoverCandidate, error) {
rows, err := st.pool.Query(ctx, `
SELECT al.id, f.source_id, f.rel_path
FROM albums al
CROSS JOIN LATERAL (
SELECT mf.source_id, mf.rel_path FROM tracks t
JOIN media_files mf ON mf.id = t.primary_file_id
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE t.album_id = al.id
ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST
LIMIT 1) f
WHERE al.cover_checked_at IS NULL
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("library: albums without cover: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (CoverCandidate, error) {
var c CoverCandidate
return c, r.Scan(&c.AlbumID, &c.SourceID, &c.RelPath)
})
}
// SetAlbumCover сохраняет найденную обложку (nil — обложки нет) и отмечает альбом проверенным.
func (st *Store) SetAlbumCover(ctx context.Context, albumID uuid.UUID, coverPath *string, colors []string) error {
_, err := st.pool.Exec(ctx, `
UPDATE albums SET cover_path = $2, cover_colors = $3, cover_checked_at = now() WHERE id = $1`,
albumID, coverPath, colors)
if err != nil {
return fmt.Errorf("library: set album cover: %w", err)
}
return nil
}
// ResetCoverChecks — найти обложки заново для альбомов источника (полный скан: могли появиться cover.jpg).
func (st *Store) ResetCoverChecks(ctx context.Context, sourceID uuid.UUID) error {
_, err := st.pool.Exec(ctx, `
UPDATE albums al SET cover_checked_at = NULL
WHERE al.cover_path IS NULL AND EXISTS (
SELECT 1 FROM tracks t JOIN media_files f ON f.track_id = t.id
WHERE t.album_id = al.id AND f.source_id = $1)`, sourceID)
if err != nil {
return fmt.Errorf("library: reset cover checks: %w", err)
}
return nil
}
// AlbumCoverPath — ключ обложки альбома (nil — обложки нет).
func (st *Store) AlbumCoverPath(ctx context.Context, albumID uuid.UUID) (*string, error) {
var p *string
err := st.pool.QueryRow(ctx, `SELECT cover_path FROM albums WHERE id = $1`, albumID).Scan(&p)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("library: album cover: %w", err)
}
return p, nil
}
// AnalysisCounts — состояние очереди аудиоанализа.
type AnalysisCounts struct {
Queued, Running, Done, Failed int
AnalyzerVer *string
}
// AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора.
func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
var c AnalysisCounts
err := st.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1)
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer)
if err != nil {
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
}
return c, nil
}
// WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех).
type WaveMetrics struct {
Served, Sessions int
WaveRates
ByModel []WaveModelMetrics
}
// WaveRates — доли по показам с известным исходом (nil — исходов ещё нет).
type WaveRates struct {
ListenThrough, EarlySkip, LikeRate *float32
}
// WaveModelMetrics — метрики показов одной версии модели.
type WaveModelMetrics struct {
ModelVer string
Served int
WaveRates
}
func rates(judged, through, early, liked int) WaveRates {
if judged == 0 {
return WaveRates{}
}
lt, es, lr := float32(through)/float32(judged), float32(early)/float32(judged), float32(liked)*100/float32(judged)
return WaveRates{ListenThrough: &lt, EarlySkip: &es, LikeRate: &lr}
}
const waveOutcomes = `count(outcome),
count(*) FILTER (WHERE outcome IN ('complete', 'like')),
count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')),
count(*) FILTER (WHERE outcome = 'like')`
// WaveMetrics считает метрики по показам с известным исходом.
func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
var (
m WaveMetrics
judged, through, early, liked int
)
err := st.pool.QueryRow(ctx, `
SELECT count(*), count(DISTINCT wave_session_id), `+waveOutcomes+`
FROM wave_impressions WHERE served_at > now() - interval '7 days'`).
Scan(&m.Served, &m.Sessions, &judged, &through, &early, &liked)
if err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics: %w", err)
}
m.WaveRates = rates(judged, through, early, liked)
rows, err := st.pool.Query(ctx, `
SELECT coalesce(model_ver, ''), count(*), `+waveOutcomes+`
FROM wave_impressions WHERE served_at > now() - interval '7 days'
GROUP BY 1 ORDER BY 2 DESC`)
if err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics by model: %w", err)
}
defer rows.Close()
m.ByModel = []WaveModelMetrics{}
for rows.Next() {
var mm WaveModelMetrics
if err := rows.Scan(&mm.ModelVer, &mm.Served, &judged, &through, &early, &liked); err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics by model: %w", err)
}
mm.WaveRates = rates(judged, through, early, liked)
m.ByModel = append(m.ByModel, mm)
}
return m, rows.Err()
}
// FingerprintTodo — файл, которому нужны отпечатки.
type FingerprintTodo struct {
ID uuid.UUID
SourceID uuid.UUID
RelPath string
SHA256 []byte // уже посчитан — повторно не читаем
}
// FilesToFingerprint — файлы смонтированных источников без SHA-256 (и без Chromaprint, если withFP), после after.
func (st *Store) FilesToFingerprint(ctx context.Context, withFP bool, after uuid.UUID, limit int) ([]FingerprintTodo, error) {
rows, err := st.pool.Query(ctx, `
SELECT mf.id, mf.source_id, mf.rel_path, mf.sha256
FROM media_files mf
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE mf.missing_since IS NULL AND mf.scan_error IS NULL
AND (mf.sha256 IS NULL OR ($1 AND mf.fingerprinted_at IS NULL)) AND mf.id > $2
ORDER BY mf.id
LIMIT $3`, withFP, after, limit)
if err != nil {
return nil, fmt.Errorf("library: files to fingerprint: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (FingerprintTodo, error) {
var f FingerprintTodo
return f, r.Scan(&f.ID, &f.SourceID, &f.RelPath, &f.SHA256)
})
}
// SaveFingerprint сохраняет SHA-256 и (если есть) отпечаток Chromaprint; chromaprintDone — fpcalc отработал
// (в том числе с ошибкой), повторять не нужно.
func (st *Store) SaveFingerprint(ctx context.Context, fileID uuid.UUID, sum []byte, fp *Fingerprint, chromaprintDone bool) error {
tx, err := st.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("library: save fingerprint: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
UPDATE media_files SET sha256 = $2, fingerprinted_at = CASE WHEN $3 THEN now() ELSE fingerprinted_at END
WHERE id = $1`, fileID, sum, chromaprintDone); err != nil {
return fmt.Errorf("library: save fingerprint: %w", err)
}
if fp != nil {
points := make([]int32, len(fp.Points))
for i, p := range fp.Points {
points[i] = int32(p)
}
if _, err := tx.Exec(ctx, `
INSERT INTO track_fingerprints (file_id, duration_ms, fingerprint, fp_prefix) VALUES ($1, $2, $3, $4)
ON CONFLICT (file_id) DO UPDATE SET duration_ms = EXCLUDED.duration_ms, fingerprint = EXCLUDED.fingerprint,
fp_prefix = EXCLUDED.fp_prefix`, fileID, fp.DurationMs, points, fp.Prefix()); err != nil {
return fmt.Errorf("library: save fingerprint: %w", err)
}
}
return tx.Commit(ctx)
}
// TemplateSamples — случайные файлы источника с метаданными каталога (для проверки шаблона записи).
func (st *Store) TemplateSamples(ctx context.Context, sourceID uuid.UUID, n int) ([]meta.TemplateSample, error) {
rows, err := st.pool.Query(ctx, `
SELECT mf.rel_path, t.title, al.title, aa.name, coalesce(t.year, al.year), t.track_no, t.disc_no,
coalesce((SELECT string_agg(a.name, '; ' ORDER BY ta.position) FROM track_artists ta
JOIN artists a ON a.id = ta.artist_id WHERE ta.track_id = t.id AND ta.role = 'main'), aa.name),
(SELECT max(t2.disc_no) FROM tracks t2 WHERE t2.album_id = al.id),
(SELECT g.name FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE tg.track_id = t.id ORDER BY tg.weight DESC LIMIT 1)
FROM media_files mf
JOIN tracks t ON t.id = mf.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists aa ON aa.id = al.album_artist_id
WHERE mf.source_id = $1 AND mf.missing_since IS NULL
ORDER BY random() LIMIT $2`, sourceID, n)
if err != nil {
return nil, fmt.Errorf("library: template samples: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (meta.TemplateSample, error) {
var (
rel, title, album, albumArtist, artist string
year, track, disc, discs *int
genre *string
)
if err := r.Scan(&rel, &title, &album, &albumArtist, &year, &track, &disc, &artist, &discs, &genre); err != nil {
return meta.TemplateSample{}, err
}
num := func(p *int) string {
if p == nil || *p <= 0 {
return ""
}
return strconv.Itoa(*p)
}
f := meta.Fields{
Artist: artist, AlbumArtist: albumArtist, Album: album, Title: title, Year: num(year), Track: num(track),
Disc: num(disc),
}
if genre != nil {
f.Genre = *genre
}
count := 1
if discs != nil {
count = *discs
}
ext := strings.TrimPrefix(path.Ext(rel), ".")
return meta.TemplateSample{RelPath: rel, Input: meta.TemplateInput{Fields: f, DiscCount: count, Ext: ext}}, nil
})
}