package library import ( "context" "errors" "fmt" "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 } // 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` 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) 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, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, 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 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)) } // 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() } // 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`, 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 }