ci / api / api (push) Successful in 55s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / db / db (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
release / android (APK в релиз) (push) Successful in 3m50s
ci / ts / ts (packages + web) (push) Successful in 3m20s
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 44m45s
release / prune (старые файлы и прогоны) (push) Successful in 7s
839 lines
33 KiB
Go
839 lines
33 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 AND missing_since IS 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 missing_since IS 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, TopError *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),
|
|
(SELECT error FROM analysis_jobs WHERE status = 'failed' AND error IS NOT NULL
|
|
GROUP BY error ORDER BY count(*) DESC LIMIT 1)
|
|
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError)
|
|
if err != nil {
|
|
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// AnalysisError is a failed analysis job with a known file path, when available.
|
|
type AnalysisError struct {
|
|
TrackID uuid.UUID
|
|
Title string
|
|
SourceID *uuid.UUID
|
|
SourceName *string
|
|
RelPath *string
|
|
Error string
|
|
}
|
|
|
|
// AnalysisErrors includes failed jobs even when their file is no longer available.
|
|
func (st *Store) AnalysisErrors(ctx context.Context, after uuid.UUID, limit int) ([]AnalysisError, error) {
|
|
rows, err := st.pool.Query(ctx, `
|
|
SELECT t.id, t.title, f.source_id, s.name, f.rel_path, COALESCE(a.error, '')
|
|
FROM analysis_jobs a JOIN tracks t ON t.id = a.track_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT mf.source_id, mf.rel_path FROM media_files mf WHERE mf.track_id = t.id
|
|
ORDER BY (mf.id = t.primary_file_id) DESC NULLS LAST, (mf.missing_since IS NULL) DESC, mf.id
|
|
LIMIT 1
|
|
) f ON true
|
|
LEFT JOIN library_sources s ON s.id = f.source_id
|
|
WHERE a.status = 'failed' AND t.id > $1 ORDER BY t.id LIMIT $2`, after, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("library: analysis errors: %w", err)
|
|
}
|
|
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (AnalysisError, error) {
|
|
var e AnalysisError
|
|
return e, r.Scan(&e.TrackID, &e.Title, &e.SourceID, &e.SourceName, &e.RelPath, &e.Error)
|
|
})
|
|
}
|
|
|
|
// RetryFailedAnalysis returns failed jobs to the queue with attempts reset.
|
|
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
|
|
tag, err := st.pool.Exec(ctx, `
|
|
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
|
|
WHERE status = 'failed'`)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("library: retry analysis: %w", err)
|
|
}
|
|
return int(tag.RowsAffected()), 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: <, 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
|
|
})
|
|
}
|