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 ExcludedDirs []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, excluded_dirs` 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, &s.ExcludedDirs) 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) { if s.ExcludedDirs == nil { s.ExcludedDirs = []string{} } 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, excluded_dirs, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, $14, 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, excluded_dirs = EXCLUDED.excluded_dirs 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, s.ExcludedDirs)) } // 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 f.rel_path FROM media_files f JOIN library_sources s ON s.id = f.source_id WHERE f.source_id = $1 AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs) 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 f USING library_sources s WHERE f.source_id = s.id AND f.source_id = $1 AND f.missing_since < $2 AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)`, 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 f JOIN library_sources s ON s.id = f.source_id WHERE f.scan_error IS NOT NULL AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs))`). 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 f.id, f.source_id, f.rel_path, f.scan_error FROM media_files f JOIN library_sources s ON s.id = f.source_id WHERE f.scan_error IS NOT NULL AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs) AND f.rel_path > $1 ORDER BY f.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, after uuid.UUID) ([]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 AND mf.missing_since IS NULL AND mf.scan_error IS NULL AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs) ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST LIMIT 1) f WHERE al.cover_checked_at IS NULL AND al.id > $2 ORDER BY al.id LIMIT $1`, limit, after) 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 — найти недостающие обложки заново после скана источника. 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, ` WITH jobs AS (SELECT * FROM analysis_jobs WHERE NOT library_track_scan_excluded(track_id)) 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 jobs WHERE status = 'failed' AND error IS NOT NULL GROUP BY error ORDER BY count(*) DESC LIMIT 1) FROM 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 JOIN library_sources src ON src.id = mf.source_id WHERE mf.track_id = t.id AND NOT library_path_excluded(mf.rel_path, src.excluded_dirs) 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 NOT library_track_scan_excluded(t.id) 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' AND NOT library_track_scan_excluded(track_id)`) 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 NOT library_path_excluded(mf.rel_path, s.excluded_dirs) 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 JOIN library_sources src ON src.id = mf.source_id WHERE mf.source_id = $1 AND mf.missing_since IS NULL AND NOT library_path_excluded(mf.rel_path, src.excluded_dirs) 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 }) }