feat(api): library sources, scanner and catalog (M2)
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s
- schema for sources, path patterns, media files, artists, albums, tracks, genres (migration 00003) - path patterns (RE2 with named groups, skip), merge with tags by source priority, fallbacks from the file name and folder, name normalization and multi-artist split - scanner: walk, skip unchanged files by size and mtime, ffprobe in 8 workers with a single catalog writer, renames by content hash keep the track, duplicates fold into one track with the best file as primary, missing files are kept 30 days - storage-agent mounts NFS with validated server, export and an option whitelist, no shell; test mount counts audio files - admin API: sources CRUD, connection test, patterns with preview, scan, library status, file errors, host folder picker; scheduled scans and health checks; mounts are restored on start - catalog API: artist, artist albums, album with tracks, track - api image is alpine with ffmpeg for ffprobe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab092def54
commit
c583991d14
24 files changed
+7467
-84
No files matched your search
@@ -0,0 +1,105 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/storageagent"
|
||||
)
|
||||
|
||||
// Agent — клиент storage-agent (монтирование NFS).
|
||||
type Agent interface {
|
||||
Mount(ctx context.Context, sourceID uuid.UUID, spec storageagent.NFSSpec) (storageagent.MountResult, error)
|
||||
Unmount(ctx context.Context, sourceID uuid.UUID) error
|
||||
Test(ctx context.Context, spec storageagent.NFSSpec) (storageagent.MountResult, error)
|
||||
}
|
||||
|
||||
// HTTPAgent ходит в storage-agent по внутренней сети с общим секретом.
|
||||
type HTTPAgent struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPAgent создаёт клиента агента.
|
||||
func NewHTTPAgent(baseURL, token string) *HTTPAgent {
|
||||
return &HTTPAgent{BaseURL: baseURL, Token: token, Client: &http.Client{Timeout: 40 * time.Second}}
|
||||
}
|
||||
|
||||
// AgentError — агент отклонил запрос (например, недопустимые параметры NFS).
|
||||
type AgentError struct {
|
||||
Status int
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *AgentError) Error() string {
|
||||
return fmt.Sprintf("storage-agent: %d %s %s", e.Status, e.Field, e.Reason)
|
||||
}
|
||||
|
||||
func (a *HTTPAgent) do(ctx context.Context, method, path string, body any, out any) error {
|
||||
var rd *bytes.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(raw)
|
||||
} else {
|
||||
rd = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.BaseURL+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := a.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage-agent: %w", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode >= 400 {
|
||||
var p struct {
|
||||
Detail string `json:"detail"`
|
||||
Errors []struct {
|
||||
Field string `json:"field"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
_ = json.NewDecoder(res.Body).Decode(&p)
|
||||
ae := &AgentError{Status: res.StatusCode, Reason: p.Detail}
|
||||
if len(p.Errors) > 0 {
|
||||
ae.Field = p.Errors[0].Field
|
||||
}
|
||||
return ae
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(res.Body).Decode(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mount реализует Agent.
|
||||
func (a *HTTPAgent) Mount(ctx context.Context, id uuid.UUID, spec storageagent.NFSSpec) (storageagent.MountResult, error) {
|
||||
var res storageagent.MountResult
|
||||
err := a.do(ctx, http.MethodPost, "/internal/mounts", storageagent.MountRequest{SourceID: id.String(), NFSSpec: spec}, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Unmount реализует Agent.
|
||||
func (a *HTTPAgent) Unmount(ctx context.Context, id uuid.UUID) error {
|
||||
return a.do(ctx, http.MethodDelete, "/internal/mounts/"+id.String(), nil, nil)
|
||||
}
|
||||
|
||||
// Test реализует Agent.
|
||||
func (a *HTTPAgent) Test(ctx context.Context, spec storageagent.NFSSpec) (storageagent.MountResult, error) {
|
||||
var res storageagent.MountResult
|
||||
err := a.do(ctx, http.MethodPost, "/internal/mounts/test", spec, &res)
|
||||
return res, err
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Ref — ссылка на исполнителя.
|
||||
type Ref struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AlbumRow — альбом для карточек и страниц.
|
||||
type AlbumRow struct {
|
||||
ID uuid.UUID
|
||||
Title string
|
||||
Year *int
|
||||
CoverPath *string
|
||||
CoverColors []string
|
||||
Artist Ref
|
||||
TrackCount int
|
||||
}
|
||||
|
||||
// TrackRow — трек с исполнителями, альбомом и жанрами.
|
||||
type TrackRow struct {
|
||||
ID uuid.UUID
|
||||
Title string
|
||||
DurationMs int
|
||||
DiscNo *int
|
||||
TrackNo *int
|
||||
Artists []Ref
|
||||
Album AlbumRow
|
||||
Genres []string
|
||||
Available bool
|
||||
}
|
||||
|
||||
// ArtistRow — исполнитель со счётчиками.
|
||||
type ArtistRow struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
AlbumCount int
|
||||
TrackCount int
|
||||
}
|
||||
|
||||
// trackSelect — общий SELECT треков; доступен трек, у которого есть основной файл
|
||||
// в подключённом хранилище.
|
||||
const trackSelect = `
|
||||
SELECT t.id, t.title, t.duration_ms, t.disc_no, t.track_no,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', a.id, 'name', a.name) ORDER BY ta.role DESC, ta.position)
|
||||
FROM track_artists ta JOIN artists a ON a.id = ta.artist_id WHERE ta.track_id = t.id), '[]'),
|
||||
al.id, al.title, al.year, al.cover_path, COALESCE(al.cover_colors, '{}'), aa.id, aa.name,
|
||||
COALESCE((SELECT array_agg(g.name ORDER BY g.name) FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
|
||||
WHERE tg.track_id = t.id), '{}'),
|
||||
EXISTS (SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id
|
||||
WHERE f.id = t.primary_file_id AND s.status = 'mounted')
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN artists aa ON aa.id = al.album_artist_id`
|
||||
|
||||
func scanTrack(row pgx.Row) (TrackRow, error) {
|
||||
var (
|
||||
t TrackRow
|
||||
artists []byte
|
||||
)
|
||||
err := row.Scan(&t.ID, &t.Title, &t.DurationMs, &t.DiscNo, &t.TrackNo, &artists,
|
||||
&t.Album.ID, &t.Album.Title, &t.Album.Year, &t.Album.CoverPath, &t.Album.CoverColors,
|
||||
&t.Album.Artist.ID, &t.Album.Artist.Name, &t.Genres, &t.Available)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return TrackRow{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return TrackRow{}, fmt.Errorf("library: scan track: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(artists, &t.Artists); err != nil {
|
||||
return TrackRow{}, fmt.Errorf("library: track artists: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (st *Store) queryTracks(ctx context.Context, where string, args ...any) ([]TrackRow, error) {
|
||||
rows, err := st.pool.Query(ctx, trackSelect+" "+where, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("library: tracks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TrackRow
|
||||
for rows.Next() {
|
||||
t, err := scanTrack(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Track — трек по id.
|
||||
func (st *Store) Track(ctx context.Context, id uuid.UUID) (TrackRow, error) {
|
||||
return scanTrack(st.pool.QueryRow(ctx, trackSelect+" WHERE t.id = $1", id))
|
||||
}
|
||||
|
||||
// TracksByIDs — треки в порядке ids (недостающие пропускаются).
|
||||
func (st *Store) TracksByIDs(ctx context.Context, ids []uuid.UUID) ([]TrackRow, error) {
|
||||
rows, err := st.queryTracks(ctx, "WHERE t.id = ANY($1)", ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[uuid.UUID]TrackRow, len(rows))
|
||||
for _, t := range rows {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
out := make([]TrackRow, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if t, ok := byID[id]; ok {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AlbumTracks — треки альбома по дискам и номерам.
|
||||
func (st *Store) AlbumTracks(ctx context.Context, albumID uuid.UUID) ([]TrackRow, error) {
|
||||
return st.queryTracks(ctx, "WHERE t.album_id = $1 ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST, t.title", albumID)
|
||||
}
|
||||
|
||||
const albumSelect = `
|
||||
SELECT al.id, al.title, al.year, al.cover_path, COALESCE(al.cover_colors, '{}'), aa.id, aa.name,
|
||||
(SELECT count(*) FROM tracks t WHERE t.album_id = al.id)
|
||||
FROM albums al JOIN artists aa ON aa.id = al.album_artist_id`
|
||||
|
||||
func scanAlbum(row pgx.Row) (AlbumRow, error) {
|
||||
var a AlbumRow
|
||||
err := row.Scan(&a.ID, &a.Title, &a.Year, &a.CoverPath, &a.CoverColors, &a.Artist.ID, &a.Artist.Name, &a.TrackCount)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return AlbumRow{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return AlbumRow{}, fmt.Errorf("library: scan album: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Album — альбом по id.
|
||||
func (st *Store) Album(ctx context.Context, id uuid.UUID) (AlbumRow, error) {
|
||||
return scanAlbum(st.pool.QueryRow(ctx, albumSelect+" WHERE al.id = $1", id))
|
||||
}
|
||||
|
||||
// ArtistAlbums — альбомы исполнителя и сборники, где есть его треки; новые сверху.
|
||||
func (st *Store) ArtistAlbums(ctx context.Context, artistID uuid.UUID) ([]AlbumRow, error) {
|
||||
rows, err := st.pool.Query(ctx, albumSelect+`
|
||||
WHERE al.album_artist_id = $1 OR EXISTS (
|
||||
SELECT 1 FROM tracks t JOIN track_artists ta ON ta.track_id = t.id
|
||||
WHERE t.album_id = al.id AND ta.artist_id = $1)
|
||||
ORDER BY (al.album_artist_id = $1) DESC, al.year DESC NULLS LAST, al.title`, artistID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("library: artist albums: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AlbumRow
|
||||
for rows.Next() {
|
||||
a, err := scanAlbum(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Artist — исполнитель со счётчиками альбомов и треков.
|
||||
func (st *Store) Artist(ctx context.Context, id uuid.UUID) (ArtistRow, error) {
|
||||
var a ArtistRow
|
||||
err := st.pool.QueryRow(ctx, `
|
||||
SELECT a.id, a.name,
|
||||
(SELECT count(*) FROM albums al WHERE al.album_artist_id = a.id),
|
||||
(SELECT count(DISTINCT ta.track_id) FROM track_artists ta WHERE ta.artist_id = a.id)
|
||||
FROM artists a WHERE a.id = $1`, id).Scan(&a.ID, &a.Name, &a.AlbumCount, &a.TrackCount)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ArtistRow{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ArtistRow{}, fmt.Errorf("library: artist: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/library/meta"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
// Handler реализует library- и catalog-части gen.StrictServerInterface.
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
store *Store
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик.
|
||||
func NewHandler(svc *Service, store *Store) *Handler { return &Handler{svc: svc, store: store} }
|
||||
|
||||
func (h *Handler) sourceDTO(s Source) gen.Source {
|
||||
out := gen.Source{
|
||||
Id: s.ID, Name: s.Name, Type: gen.SourceType(s.Type), NfsServer: s.NFSServer, NfsExport: s.NFSExport,
|
||||
NfsOptions: s.NFSOptions, LocalPath: s.LocalPath, TagPriority: gen.TagsFirst,
|
||||
Status: gen.SourceStatus(s.Status), StatusMessage: s.StatusMessage, LastScanAt: s.LastScanAt,
|
||||
CreatedAt: s.CreatedAt,
|
||||
}
|
||||
if !s.TagsFirst {
|
||||
out.TagPriority = gen.PathFirst
|
||||
}
|
||||
if s.ScanInterval != nil {
|
||||
m := int(*s.ScanInterval / time.Minute)
|
||||
out.ScanIntervalMinutes = &m
|
||||
}
|
||||
if p, ok := h.svc.Progress(s.ID); ok {
|
||||
dto := progressDTO(p)
|
||||
out.Scan = &dto
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func progressDTO(p Progress) gen.ScanProgress {
|
||||
out := gen.ScanProgress{
|
||||
Running: p.Running, Full: p.Full, StartedAt: p.StartedAt, FinishedAt: p.FinishedAt, Found: p.Found,
|
||||
ToProcess: p.ToProcess, Processed: p.Processed, Added: p.Added, Updated: p.Updated, Moved: p.Moved,
|
||||
Missing: p.Missing, Errors: p.Errors,
|
||||
}
|
||||
if p.Error != "" {
|
||||
out.Error = &p.Error
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListSources реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ListSources(ctx context.Context, _ gen.ListSourcesRequestObject) (gen.ListSourcesResponseObject, error) {
|
||||
sources, err := h.svc.ListSources(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]gen.Source, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
items = append(items, h.sourceDTO(s))
|
||||
}
|
||||
return gen.ListSources200JSONResponse{Items: items}, nil
|
||||
}
|
||||
|
||||
func tagsFirst(p *gen.TagPriority) *bool {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := *p == gen.TagsFirst
|
||||
return &v
|
||||
}
|
||||
|
||||
// CreateSource реализует gen.StrictServerInterface.
|
||||
func (h *Handler) CreateSource(ctx context.Context, req gen.CreateSourceRequestObject) (gen.CreateSourceResponseObject, error) {
|
||||
b := req.Body
|
||||
src, err := h.svc.CreateSource(ctx, SourceInput{
|
||||
Name: &b.Name, Type: SourceType(b.Type), NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions,
|
||||
LocalPath: b.LocalPath, TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.CreateSource201JSONResponse(h.sourceDTO(src)), nil
|
||||
}
|
||||
|
||||
// GetSource реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetSource(ctx context.Context, req gen.GetSourceRequestObject) (gen.GetSourceResponseObject, error) {
|
||||
src, err := h.svc.GetSource(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.GetSource200JSONResponse(h.sourceDTO(src)), nil
|
||||
}
|
||||
|
||||
// UpdateSource реализует gen.StrictServerInterface.
|
||||
func (h *Handler) UpdateSource(ctx context.Context, req gen.UpdateSourceRequestObject) (gen.UpdateSourceResponseObject, error) {
|
||||
b := req.Body
|
||||
src, err := h.svc.UpdateSource(ctx, req.Id, SourceInput{
|
||||
Name: b.Name, NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions, LocalPath: b.LocalPath,
|
||||
TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes, Enabled: b.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.UpdateSource200JSONResponse(h.sourceDTO(src)), nil
|
||||
}
|
||||
|
||||
// DeleteSource реализует gen.StrictServerInterface.
|
||||
func (h *Handler) DeleteSource(ctx context.Context, req gen.DeleteSourceRequestObject) (gen.DeleteSourceResponseObject, error) {
|
||||
if err := h.svc.DeleteSource(ctx, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.DeleteSource204Response{}, nil
|
||||
}
|
||||
|
||||
// TestSource реализует gen.StrictServerInterface.
|
||||
func (h *Handler) TestSource(ctx context.Context, req gen.TestSourceRequestObject) (gen.TestSourceResponseObject, error) {
|
||||
r, err := h.svc.TestSource(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.TestSource200JSONResponse{Ok: r.OK, Message: r.Message, AudioFiles: r.AudioFiles}, nil
|
||||
}
|
||||
|
||||
func patternsDTO(rows []PatternRow) gen.PatternList {
|
||||
out := gen.PatternList{Patterns: make([]gen.PathPattern, 0, len(rows))}
|
||||
for _, r := range rows {
|
||||
out.Patterns = append(out.Patterns, gen.PathPattern{Regex: r.Regex, IsEnabled: r.IsEnabled})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func patternRows(in []gen.PathPattern) []PatternRow {
|
||||
out := make([]PatternRow, 0, len(in))
|
||||
for _, p := range in {
|
||||
out = append(out, PatternRow{Regex: p.Regex, IsEnabled: p.IsEnabled})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetSourcePatterns реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetSourcePatterns(ctx context.Context, req gen.GetSourcePatternsRequestObject) (gen.GetSourcePatternsResponseObject, error) {
|
||||
rows, err := h.svc.Patterns(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.GetSourcePatterns200JSONResponse(patternsDTO(rows)), nil
|
||||
}
|
||||
|
||||
// PutSourcePatterns реализует gen.StrictServerInterface.
|
||||
func (h *Handler) PutSourcePatterns(ctx context.Context, req gen.PutSourcePatternsRequestObject) (gen.PutSourcePatternsResponseObject, error) {
|
||||
rows, err := h.svc.PutPatterns(ctx, req.Id, patternRows(req.Body.Patterns))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.PutSourcePatterns200JSONResponse(patternsDTO(rows)), nil
|
||||
}
|
||||
|
||||
func intPtr(n int) *int {
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
func metaDTO(m meta.Meta) gen.TrackMeta {
|
||||
genres := m.Genres
|
||||
if genres == nil {
|
||||
genres = []string{}
|
||||
}
|
||||
return gen.TrackMeta{
|
||||
Artist: m.Artist, AlbumArtist: m.AlbumArtist, Album: m.Album, Title: m.Title, Genres: genres,
|
||||
Year: intPtr(m.Year), Track: intPtr(m.Track), Disc: intPtr(m.Disc),
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewSourcePatterns реализует gen.StrictServerInterface.
|
||||
func (h *Handler) PreviewSourcePatterns(ctx context.Context, req gen.PreviewSourcePatternsRequestObject) (gen.PreviewSourcePatternsResponseObject, error) {
|
||||
sample := 50
|
||||
if req.Body.Sample != nil {
|
||||
sample = *req.Body.Sample
|
||||
}
|
||||
items, unmatched, err := h.svc.Preview(ctx, req.Id, patternRows(req.Body.Patterns), sample)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := gen.PreviewSourcePatterns200JSONResponse{Items: make([]gen.PreviewItem, 0, len(items)), Unmatched: unmatched}
|
||||
for _, it := range items {
|
||||
dto := gen.PreviewItem{
|
||||
RelPath: it.RelPath, PatternIndex: it.PatternIndex, Skipped: it.Skipped, PathFields: it.PathFields,
|
||||
Tags: it.Tags, Result: metaDTO(it.Result),
|
||||
}
|
||||
if it.Error != "" {
|
||||
e := it.Error
|
||||
dto.Error = &e
|
||||
}
|
||||
out.Items = append(out.Items, dto)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ScanSource реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ScanSource(ctx context.Context, req gen.ScanSourceRequestObject) (gen.ScanSourceResponseObject, error) {
|
||||
full := req.Body != nil && req.Body.Full != nil && *req.Body.Full
|
||||
p, err := h.svc.StartScan(ctx, req.Id, full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ScanSource202JSONResponse(progressDTO(p)), nil
|
||||
}
|
||||
|
||||
// GetLibraryStatus реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRequestObject) (gen.GetLibraryStatusResponseObject, error) {
|
||||
st, err := h.svc.Status(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := st.Counts
|
||||
out := gen.GetLibraryStatus200JSONResponse{
|
||||
Artists: int(c.Artists), Albums: int(c.Albums), Tracks: int(c.Tracks), Files: int(c.Files),
|
||||
MissingFiles: int(c.Missing), FileErrors: int(c.Errors), Sources: []gen.SourceStatusItem{},
|
||||
}
|
||||
for _, s := range st.Sources {
|
||||
dto := h.sourceDTO(s)
|
||||
out.Sources = append(out.Sources, gen.SourceStatusItem{
|
||||
Id: s.ID, Name: s.Name, Status: dto.Status, StatusMessage: s.StatusMessage, LastScanAt: s.LastScanAt, Scan: dto.Scan,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListLibraryErrors реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ListLibraryErrors(ctx context.Context, req gen.ListLibraryErrorsRequestObject) (gen.ListLibraryErrorsResponseObject, error) {
|
||||
limit := 50
|
||||
if req.Params.Limit != nil {
|
||||
limit = min(max(*req.Params.Limit, 1), 200)
|
||||
}
|
||||
after := ""
|
||||
if req.Params.Cursor != nil {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(*req.Params.Cursor)
|
||||
if err != nil {
|
||||
return nil, apperr.Validation("cursor", "validation.cursor_invalid", "malformed cursor", nil)
|
||||
}
|
||||
after = string(raw)
|
||||
}
|
||||
rows, err := h.svc.FileErrors(ctx, after, limit+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := gen.ListLibraryErrors200JSONResponse{Items: []gen.LibraryError{}}
|
||||
if len(rows) > limit {
|
||||
rows = rows[:limit]
|
||||
next := base64.RawURLEncoding.EncodeToString([]byte(rows[limit-1].RelPath))
|
||||
out.NextCursor = &next
|
||||
}
|
||||
for _, r := range rows {
|
||||
out.Items = append(out.Items, gen.LibraryError{FileId: r.ID, SourceId: r.SourceID, RelPath: r.RelPath, Error: r.ScanError})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListHostDirs реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ListHostDirs(ctx context.Context, req gen.ListHostDirsRequestObject) (gen.ListHostDirsResponseObject, error) {
|
||||
rel := ""
|
||||
if req.Params.Path != nil {
|
||||
rel = *req.Params.Path
|
||||
}
|
||||
p, dirs, err := h.svc.HostDirs(ctx, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ListHostDirs200JSONResponse{Path: p, Dirs: dirs}, nil
|
||||
}
|
||||
|
||||
// --- каталог ---
|
||||
|
||||
func catalogErr(err error) error {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return apperr.NotFound(CodeNotFound, "not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func albumRefDTO(a AlbumRow) gen.AlbumRef {
|
||||
return gen.AlbumRef{Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: coverURL(a), CoverColors: colors(a.CoverColors)}
|
||||
}
|
||||
|
||||
// coverURL — обложки появятся вместе с извлечением (следующий шаг M2).
|
||||
func coverURL(AlbumRow) *string { return nil }
|
||||
|
||||
func colors(c []string) []string {
|
||||
if c == nil {
|
||||
return []string{}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TrackDTO переводит трек в схему Track.
|
||||
func TrackDTO(t TrackRow) gen.Track {
|
||||
artists := make([]gen.ArtistRef, 0, len(t.Artists))
|
||||
for _, a := range t.Artists {
|
||||
artists = append(artists, gen.ArtistRef{Id: a.ID, Name: a.Name})
|
||||
}
|
||||
genres := t.Genres
|
||||
if genres == nil {
|
||||
genres = []string{}
|
||||
}
|
||||
return gen.Track{
|
||||
Id: t.ID, Title: t.Title, DurationMs: t.DurationMs, Artists: artists, Album: albumRefDTO(t.Album),
|
||||
Genres: genres, DiscNo: t.DiscNo, TrackNo: t.TrackNo, Available: t.Available,
|
||||
}
|
||||
}
|
||||
|
||||
func albumSummaryDTO(a AlbumRow) gen.AlbumSummary {
|
||||
return gen.AlbumSummary{
|
||||
Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: coverURL(a), CoverColors: colors(a.CoverColors),
|
||||
Artist: gen.ArtistRef{Id: a.Artist.ID, Name: a.Artist.Name}, TrackCount: a.TrackCount,
|
||||
}
|
||||
}
|
||||
|
||||
// GetArtist реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetArtist(ctx context.Context, req gen.GetArtistRequestObject) (gen.GetArtistResponseObject, error) {
|
||||
a, err := h.store.Artist(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, catalogErr(err)
|
||||
}
|
||||
return gen.GetArtist200JSONResponse{Id: a.ID, Name: a.Name, AlbumCount: a.AlbumCount, TrackCount: a.TrackCount}, nil
|
||||
}
|
||||
|
||||
// ListArtistAlbums реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ListArtistAlbums(ctx context.Context, req gen.ListArtistAlbumsRequestObject) (gen.ListArtistAlbumsResponseObject, error) {
|
||||
if _, err := h.store.Artist(ctx, req.Id); err != nil {
|
||||
return nil, catalogErr(err)
|
||||
}
|
||||
rows, err := h.store.ArtistAlbums(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := gen.ListArtistAlbums200JSONResponse{Items: make([]gen.AlbumSummary, 0, len(rows))}
|
||||
for _, a := range rows {
|
||||
out.Items = append(out.Items, albumSummaryDTO(a))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetAlbum реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetAlbum(ctx context.Context, req gen.GetAlbumRequestObject) (gen.GetAlbumResponseObject, error) {
|
||||
a, err := h.store.Album(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, catalogErr(err)
|
||||
}
|
||||
tracks, err := h.store.AlbumTracks(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := gen.GetAlbum200JSONResponse{
|
||||
Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: coverURL(a), CoverColors: colors(a.CoverColors),
|
||||
Artist: gen.ArtistRef{Id: a.Artist.ID, Name: a.Artist.Name}, Tracks: make([]gen.Track, 0, len(tracks)),
|
||||
}
|
||||
for _, t := range tracks {
|
||||
out.DurationMs += t.DurationMs
|
||||
out.Tracks = append(out.Tracks, TrackDTO(t))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetTrack реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetTrack(ctx context.Context, req gen.GetTrackRequestObject) (gen.GetTrackResponseObject, error) {
|
||||
t, err := h.store.Track(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, catalogErr(err)
|
||||
}
|
||||
return gen.GetTrack200JSONResponse(TrackDTO(t)), nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package meta
|
||||
|
||||
import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnknownArtist — исполнитель, если его нет ни в тегах, ни в пути.
|
||||
const UnknownArtist = "Unknown artist"
|
||||
|
||||
// Fields — значения метаданных из одного источника (теги файла или шаблон пути). Пусто — нет значения.
|
||||
type Fields struct {
|
||||
Artist string
|
||||
AlbumArtist string
|
||||
Album string
|
||||
Title string
|
||||
Genre string
|
||||
Year string
|
||||
Track string
|
||||
Disc string
|
||||
}
|
||||
|
||||
// FromPath собирает Fields из групп шаблона.
|
||||
func FromPath(m map[string]string) Fields {
|
||||
return Fields{
|
||||
Artist: m["artist"], AlbumArtist: m["albumartist"], Album: m["album"], Title: m["title"],
|
||||
Genre: m["genre"], Year: m["year"], Track: m["track"], Disc: m["disc"],
|
||||
}
|
||||
}
|
||||
|
||||
// Meta — итоговые метаданные файла.
|
||||
type Meta struct {
|
||||
Artist string // поле исполнителя целиком (разбивается SplitArtists)
|
||||
AlbumArtist string
|
||||
Album string
|
||||
Title string
|
||||
Genres []string
|
||||
Year int
|
||||
Track int
|
||||
Disc int
|
||||
}
|
||||
|
||||
// Merge сводит теги и поля из пути. tagsFirst: тег важнее пути (по умолчанию), иначе наоборот.
|
||||
// Без обоих значений: title — имя файла без номера и расширения, artist — UnknownArtist,
|
||||
// album — имя папки, albumartist — artist.
|
||||
func Merge(tags, fromPath Fields, tagsFirst bool, relPath string) Meta {
|
||||
first, second := tags, fromPath
|
||||
if !tagsFirst {
|
||||
first, second = fromPath, tags
|
||||
}
|
||||
pick := func(a, b string) string {
|
||||
if v := Clean(a); v != "" {
|
||||
return v
|
||||
}
|
||||
return Clean(b)
|
||||
}
|
||||
|
||||
m := Meta{
|
||||
Artist: pick(first.Artist, second.Artist),
|
||||
AlbumArtist: pick(first.AlbumArtist, second.AlbumArtist),
|
||||
Album: pick(first.Album, second.Album),
|
||||
Title: pick(first.Title, second.Title),
|
||||
Year: parseYear(pick(first.Year, second.Year)),
|
||||
Track: parseNumber(pick(first.Track, second.Track)),
|
||||
Disc: parseNumber(pick(first.Disc, second.Disc)),
|
||||
}
|
||||
if g := pick(first.Genre, second.Genre); g != "" {
|
||||
m.Genres = splitGenres(g)
|
||||
}
|
||||
|
||||
if m.Title == "" {
|
||||
m.Title = titleFromFile(relPath)
|
||||
}
|
||||
if m.Artist == "" {
|
||||
m.Artist = m.AlbumArtist
|
||||
}
|
||||
if m.Artist == "" {
|
||||
m.Artist = UnknownArtist
|
||||
}
|
||||
if m.AlbumArtist == "" {
|
||||
m.AlbumArtist = m.Artist
|
||||
}
|
||||
if m.Album == "" {
|
||||
if dir := path.Base(path.Dir(relPath)); dir != "." && dir != "/" {
|
||||
m.Album = Clean(dir)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
var yearRe = regexp.MustCompile(`\b(1[89]\d\d|20\d\d)\b`)
|
||||
|
||||
// parseYear достаёт год из «2019», «2019-05-01», «(1999)».
|
||||
func parseYear(s string) int {
|
||||
if y := yearRe.FindString(s); y != "" {
|
||||
n, _ := strconv.Atoi(y)
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseNumber разбирает «3», «03», «3/12».
|
||||
func parseNumber(s string) int {
|
||||
s, _, _ = strings.Cut(s, "/")
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n < 0 || n > 9999 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func splitGenres(s string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, g := range splitAny(s, []string{";", "/", ","}) {
|
||||
g = Clean(g)
|
||||
if k := Key(g); k != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var leadingNumber = regexp.MustCompile(`^\s*(?:\d{1,2}[-.])?\d{1,3}\s*[-._)]*\s*`)
|
||||
|
||||
// titleFromFile: «03 - Song name.flac» → «Song name».
|
||||
func titleFromFile(relPath string) string {
|
||||
name := path.Base(relPath)
|
||||
name = strings.TrimSuffix(name, path.Ext(name))
|
||||
if t := Clean(leadingNumber.ReplaceAllString(name, "")); t != "" {
|
||||
return t
|
||||
}
|
||||
return Clean(name)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package meta
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKey(t *testing.T) {
|
||||
assert.Equal(t, "the beatles", Key(" The_Beatles! "))
|
||||
assert.Equal(t, Key("Ёлка"), Key("елка"))
|
||||
assert.Equal(t, "ac dc", Key("AC/DC"))
|
||||
assert.Equal(t, "", Key(" -- "))
|
||||
}
|
||||
|
||||
func TestClean(t *testing.T) {
|
||||
assert.Equal(t, "Hello World", Clean(" Hello__World "))
|
||||
// «й» из двух кодовых точек (и + кратка) становится одной.
|
||||
assert.Equal(t, "й", Clean("й"))
|
||||
}
|
||||
|
||||
func TestSplitArtists(t *testing.T) {
|
||||
known := func(k string) bool { return k == "simon garfunkel" }
|
||||
names := func(cs []ArtistCredit) []string {
|
||||
var out []string
|
||||
for _, c := range cs {
|
||||
out = append(out, c.Role+":"+c.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
assert.Equal(t, []string{"main:Simon & Garfunkel"}, names(SplitArtists("Simon & Garfunkel", known)))
|
||||
assert.Equal(t, []string{"main:Tom", "main:Jerry"}, names(SplitArtists("Tom & Jerry", known)))
|
||||
assert.Equal(t, []string{"main:A", "main:B"}, names(SplitArtists("A; B", nil)))
|
||||
assert.Equal(t, []string{"main:A", "feat:B", "feat:C"}, names(SplitArtists("A feat. B & C", nil)))
|
||||
assert.Equal(t, []string{"main:A"}, names(SplitArtists("A / a", nil)), "duplicates by key are dropped")
|
||||
assert.Empty(t, SplitArtists(" ", nil))
|
||||
}
|
||||
|
||||
func TestPatterns(t *testing.T) {
|
||||
patterns, err := CompilePatterns([]string{
|
||||
`^(?P<skip>_incoming)/`,
|
||||
`^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$`,
|
||||
`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
m := MatchPath(patterns, "Кино/1988 - Группа крови/1-01 - Группа крови.flac")
|
||||
assert.Equal(t, 1, m.Index)
|
||||
assert.Equal(t, map[string]string{
|
||||
"artist": "Кино", "year": "1988", "album": "Группа крови", "disc": "1", "track": "01", "title": "Группа крови",
|
||||
}, m.Fields)
|
||||
|
||||
m = MatchPath(patterns, "Rock/Queen/Jazz (1978)/03. Jealousy.mp3")
|
||||
assert.Equal(t, 2, m.Index)
|
||||
assert.Equal(t, "Rock", m.Fields["genre"])
|
||||
|
||||
assert.True(t, MatchPath(patterns, "_incoming/x.mp3").Skip)
|
||||
assert.Equal(t, -1, MatchPath(patterns, "loose.mp3").Index)
|
||||
}
|
||||
|
||||
func TestCompilePatternErrors(t *testing.T) {
|
||||
for _, bad := range []string{`(?P<artist>[`, `^(?P<band>.+)$`, `^.+$`} {
|
||||
_, err := CompilePatterns([]string{`^(?P<title>.+)$`, bad})
|
||||
var pe *PatternError
|
||||
require.ErrorAs(t, err, &pe, bad)
|
||||
assert.Equal(t, 1, pe.Index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMerge(t *testing.T) {
|
||||
tags := Fields{Title: "Tag title", Artist: "", Album: "Tag album", Year: "2019-05-01", Track: "3/12"}
|
||||
path := Fields{Title: "Path title", Artist: "Path artist", Album: "Path album", Year: "2018", Track: "04"}
|
||||
|
||||
m := Merge(tags, path, true, "x/y.flac")
|
||||
assert.Equal(t, "Tag title", m.Title)
|
||||
assert.Equal(t, "Path artist", m.Artist, "empty tag falls back to the path")
|
||||
assert.Equal(t, "Path artist", m.AlbumArtist, "album artist defaults to the artist")
|
||||
assert.Equal(t, 2019, m.Year)
|
||||
assert.Equal(t, 3, m.Track)
|
||||
|
||||
m = Merge(tags, path, false, "x/y.flac")
|
||||
assert.Equal(t, "Path title", m.Title)
|
||||
assert.Equal(t, 2018, m.Year)
|
||||
assert.Equal(t, 4, m.Track)
|
||||
|
||||
m = Merge(Fields{Genre: "Rock; Pop/rock"}, Fields{}, true, "Some Folder/07 - My_Song.mp3")
|
||||
assert.Equal(t, "My Song", m.Title)
|
||||
assert.Equal(t, UnknownArtist, m.Artist)
|
||||
assert.Equal(t, "Some Folder", m.Album)
|
||||
assert.Equal(t, []string{"Rock", "Pop", "rock"}[:2], m.Genres[:2])
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package meta — метаданные треков: нормализация имён, шаблоны путей, слияние с тегами
|
||||
// (docs/library-and-storage.md).
|
||||
package meta
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// Clean приводит значение к виду для показа: NFC, `_` → пробел, без лишних пробелов.
|
||||
func Clean(s string) string {
|
||||
s = norm.NFC.String(s)
|
||||
s = strings.ReplaceAll(s, "_", " ")
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// Key — ключ сопоставления (name_norm): нижний регистр, ё → е, без пунктуации, пробелы схлопнуты.
|
||||
// «The Beatles», «the beatles!» и «THE BEATLES» дают один ключ.
|
||||
func Key(s string) string {
|
||||
s = norm.NFKC.String(s)
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
space := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case r == 'ё':
|
||||
r = 'е'
|
||||
case unicode.IsPunct(r) || unicode.IsSymbol(r) || unicode.IsSpace(r) || r == '_':
|
||||
space = b.Len() > 0
|
||||
continue
|
||||
}
|
||||
if space {
|
||||
b.WriteByte(' ')
|
||||
space = false
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SortName — имя для сортировки исполнителей: без ведущего артикля «The ».
|
||||
func SortName(name string) string {
|
||||
if rest, ok := strings.CutPrefix(name, "The "); ok && rest != "" {
|
||||
return rest
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// Разделители нескольких исполнителей в одном поле, кроме « & ».
|
||||
var artistSeparators = []string{";", " / ", " feat. ", " feat ", " ft. ", " Feat. ", " Ft. ", " featuring "}
|
||||
|
||||
// ArtistCredit — исполнитель трека и его роль.
|
||||
type ArtistCredit struct {
|
||||
Name string
|
||||
Role string // main | feat
|
||||
}
|
||||
|
||||
// SplitArtists разбирает поле исполнителя на отдельных. « & » делит имя, только если составного
|
||||
// исполнителя нет в библиотеке (known), — иначе «Simon & Garfunkel» распался бы на двоих.
|
||||
// Всё после feat./ft. — приглашённые исполнители.
|
||||
func SplitArtists(s string, known func(key string) bool) []ArtistCredit {
|
||||
s = Clean(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []ArtistCredit
|
||||
seen := map[string]bool{}
|
||||
add := func(name, role string) {
|
||||
name = Clean(name)
|
||||
k := Key(name)
|
||||
if k == "" || seen[k] {
|
||||
return
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, ArtistCredit{Name: name, Role: role})
|
||||
}
|
||||
|
||||
main, feat := s, ""
|
||||
for _, sep := range []string{" feat. ", " feat ", " ft. ", " Feat. ", " Ft. ", " featuring "} {
|
||||
if i := strings.Index(main, sep); i >= 0 {
|
||||
main, feat = main[:i], main[i+len(sep):]
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, part := range splitAny(main, artistSeparators) {
|
||||
if strings.Contains(part, " & ") && (known == nil || !known(Key(part))) {
|
||||
for _, p := range strings.Split(part, " & ") {
|
||||
add(p, "main")
|
||||
}
|
||||
continue
|
||||
}
|
||||
add(part, "main")
|
||||
}
|
||||
for _, part := range splitAny(feat, append(artistSeparators, " & ", ", ")) {
|
||||
add(part, "feat")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitAny(s string, seps []string) []string {
|
||||
parts := []string{s}
|
||||
for _, sep := range seps {
|
||||
var next []string
|
||||
for _, p := range parts {
|
||||
next = append(next, strings.Split(p, sep)...)
|
||||
}
|
||||
parts = next
|
||||
}
|
||||
return parts
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package meta
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxPatternLen — предел длины выражения (RE2 линеен, но огромные выражения бессмысленны).
|
||||
const MaxPatternLen = 1000
|
||||
|
||||
// Группы шаблона и поля, которые они заполняют.
|
||||
var knownGroups = map[string]bool{
|
||||
"artist": true, "albumartist": true, "album": true, "year": true, "disc": true,
|
||||
"track": true, "title": true, "genre": true, "skip": true,
|
||||
}
|
||||
|
||||
// PatternError — ошибка компиляции выражения (для показа под полем ввода).
|
||||
type PatternError struct {
|
||||
Index int
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *PatternError) Error() string { return fmt.Sprintf("pattern %d: %s", e.Index, e.Reason) }
|
||||
|
||||
// Pattern — скомпилированный шаблон пути.
|
||||
type Pattern struct {
|
||||
Source string
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
// CompilePatterns проверяет и компилирует выражения по порядку.
|
||||
func CompilePatterns(exprs []string) ([]Pattern, error) {
|
||||
out := make([]Pattern, 0, len(exprs))
|
||||
for i, e := range exprs {
|
||||
if len(e) > MaxPatternLen {
|
||||
return nil, &PatternError{Index: i, Reason: fmt.Sprintf("longer than %d characters", MaxPatternLen)}
|
||||
}
|
||||
re, err := regexp.Compile(e)
|
||||
if err != nil {
|
||||
return nil, &PatternError{Index: i, Reason: err.Error()}
|
||||
}
|
||||
named := 0
|
||||
for _, name := range re.SubexpNames() {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !knownGroups[name] {
|
||||
return nil, &PatternError{Index: i, Reason: fmt.Sprintf("unknown group %q", name)}
|
||||
}
|
||||
named++
|
||||
}
|
||||
if named == 0 {
|
||||
return nil, &PatternError{Index: i, Reason: "no named groups"}
|
||||
}
|
||||
out = append(out, Pattern{Source: e, re: re})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PathMatch — результат применения шаблонов к пути.
|
||||
type PathMatch struct {
|
||||
Index int // номер совпавшего выражения, -1 — ни одно
|
||||
Fields map[string]string // извлечённые группы (непустые)
|
||||
Skip bool // совпала группа skip — файл игнорируется
|
||||
}
|
||||
|
||||
// MatchPath применяет шаблоны к пути относительно корня источника (разделитель `/`).
|
||||
// Побеждает первое совпавшее выражение.
|
||||
func MatchPath(patterns []Pattern, relPath string) PathMatch {
|
||||
for i, p := range patterns {
|
||||
m := p.re.FindStringSubmatch(relPath)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
// Выражение с группой skip описывает игнорируемые файлы: совпало — пропускаем.
|
||||
if p.re.SubexpIndex("skip") >= 0 {
|
||||
return PathMatch{Index: i, Skip: true}
|
||||
}
|
||||
fields := map[string]string{}
|
||||
for gi, name := range p.re.SubexpNames() {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if v := strings.TrimSpace(m[gi]); v != "" {
|
||||
fields[name] = v
|
||||
}
|
||||
}
|
||||
return PathMatch{Index: i, Fields: fields}
|
||||
}
|
||||
return PathMatch{Index: -1}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Package library — хранилища, сканер файлов и каталог (docs/library-and-storage.md).
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pipu/api/internal/library/meta"
|
||||
)
|
||||
|
||||
// AudioExtensions — расширения, которые сканер считает аудио.
|
||||
var AudioExtensions = map[string]bool{
|
||||
".flac": true, ".mp3": true, ".m4a": true, ".aac": true, ".ogg": true, ".opus": true,
|
||||
".wav": true, ".aiff": true, ".aif": true, ".wv": true, ".ape": true,
|
||||
}
|
||||
|
||||
// IsAudio сообщает, похоже ли имя файла на аудио.
|
||||
func IsAudio(name string) bool { return AudioExtensions[strings.ToLower(path.Ext(name))] }
|
||||
|
||||
// Probe — технические данные и теги файла.
|
||||
type Probe struct {
|
||||
Format string // по расширению: flac, mp3, ...
|
||||
Codec string
|
||||
Bitrate int // бит/с
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
DurationMs int
|
||||
Tags meta.Fields
|
||||
}
|
||||
|
||||
// Lossless сообщает, без потерь ли кодек.
|
||||
func (p Probe) Lossless() bool {
|
||||
switch p.Codec {
|
||||
case "flac", "alac", "wavpack", "ape", "tta":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(p.Codec, "pcm_")
|
||||
}
|
||||
|
||||
// Prober читает данные аудиофайла.
|
||||
type Prober interface {
|
||||
Probe(ctx context.Context, absPath string) (Probe, error)
|
||||
}
|
||||
|
||||
// FFprobe — Prober на основе ffprobe (тот же бинарник нужен для транскодинга).
|
||||
type FFprobe struct {
|
||||
Bin string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type ffprobeOutput struct {
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"format"`
|
||||
Streams []struct {
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
Channels int `json:"channels"`
|
||||
BitsPerRawSample string `json:"bits_per_raw_sample"`
|
||||
BitsPerSample int `json:"bits_per_sample"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
Duration string `json:"duration"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
// Probe реализует Prober. Путь передаётся отдельным аргументом, без shell.
|
||||
func (f FFprobe) Probe(ctx context.Context, absPath string) (Probe, error) {
|
||||
bin := f.Bin
|
||||
if bin == "" {
|
||||
bin = "ffprobe"
|
||||
}
|
||||
if f.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, f.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, bin, "-v", "error", "-print_format", "json",
|
||||
"-show_format", "-show_streams", "--", absPath).Output()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return Probe{}, fmt.Errorf("ffprobe: %s", strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return Probe{}, fmt.Errorf("ffprobe: %w", err)
|
||||
}
|
||||
return parseFFprobe(out, absPath)
|
||||
}
|
||||
|
||||
func parseFFprobe(raw []byte, name string) (Probe, error) {
|
||||
var o ffprobeOutput
|
||||
if err := json.Unmarshal(raw, &o); err != nil {
|
||||
return Probe{}, fmt.Errorf("ffprobe: decode: %w", err)
|
||||
}
|
||||
p := Probe{Format: strings.TrimPrefix(strings.ToLower(path.Ext(name)), ".")}
|
||||
tags := map[string]string{}
|
||||
addTags := func(m map[string]string) {
|
||||
for k, v := range m {
|
||||
k = strings.ToLower(k)
|
||||
if _, ok := tags[k]; !ok && strings.TrimSpace(v) != "" {
|
||||
tags[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
addTags(o.Format.Tags)
|
||||
found := false
|
||||
for _, s := range o.Streams {
|
||||
if s.CodecType != "audio" || found {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
p.Codec = s.CodecName
|
||||
p.SampleRate = atoi(s.SampleRate)
|
||||
p.Channels = s.Channels
|
||||
p.BitDepth = atoi(s.BitsPerRawSample)
|
||||
if p.BitDepth == 0 {
|
||||
p.BitDepth = s.BitsPerSample
|
||||
}
|
||||
p.Bitrate = atoi(s.BitRate)
|
||||
if p.DurationMs == 0 {
|
||||
p.DurationMs = seconds(s.Duration)
|
||||
}
|
||||
// Ogg / Opus хранят теги в потоке, а не в контейнере.
|
||||
addTags(s.Tags)
|
||||
}
|
||||
if !found {
|
||||
return Probe{}, fmt.Errorf("ffprobe: no audio stream")
|
||||
}
|
||||
if d := seconds(o.Format.Duration); d > 0 {
|
||||
p.DurationMs = d
|
||||
}
|
||||
if p.Bitrate == 0 {
|
||||
p.Bitrate = atoi(o.Format.BitRate)
|
||||
}
|
||||
first := func(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := tags[k]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
p.Tags = meta.Fields{
|
||||
Title: first("title"),
|
||||
Artist: first("artist"),
|
||||
AlbumArtist: first("album_artist", "albumartist", "album artist", "band"),
|
||||
Album: first("album"),
|
||||
Genre: first("genre"),
|
||||
Year: first("date", "year", "originaldate", "tdrc"),
|
||||
Track: first("track", "tracknumber"),
|
||||
Disc: first("disc", "discnumber"),
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||
return n
|
||||
}
|
||||
|
||||
func seconds(s string) int {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil || f <= 0 || math.IsInf(f, 0) {
|
||||
return 0
|
||||
}
|
||||
return int(math.Round(f * 1000))
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/library/meta"
|
||||
)
|
||||
|
||||
// UnknownAlbum — альбом файла без тегов в корне источника.
|
||||
const UnknownAlbum = "Unknown album"
|
||||
|
||||
// MissingRetention — сколько пропавший файл хранится, прежде чем трек удалится.
|
||||
const MissingRetention = 30 * 24 * time.Hour
|
||||
|
||||
// Progress — состояние скана одного источника (для админки).
|
||||
type Progress struct {
|
||||
SourceID uuid.UUID
|
||||
Running bool
|
||||
Full bool
|
||||
StartedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
Found int // аудиофайлов найдено обходом
|
||||
ToProcess int // требуют чтения
|
||||
Processed int
|
||||
Added int
|
||||
Updated int
|
||||
Moved int
|
||||
Missing int
|
||||
Errors int
|
||||
Error string // фатальная ошибка скана
|
||||
}
|
||||
|
||||
// ProgressBoard хранит прогресс сканов в памяти процесса.
|
||||
type ProgressBoard struct {
|
||||
mu sync.Mutex
|
||||
m map[uuid.UUID]*Progress
|
||||
}
|
||||
|
||||
// NewProgressBoard создаёт пустую доску.
|
||||
func NewProgressBoard() *ProgressBoard { return &ProgressBoard{m: map[uuid.UUID]*Progress{}} }
|
||||
|
||||
// Get — копия прогресса источника.
|
||||
func (b *ProgressBoard) Get(id uuid.UUID) (Progress, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
p, ok := b.m[id]
|
||||
if !ok {
|
||||
return Progress{}, false
|
||||
}
|
||||
return *p, true
|
||||
}
|
||||
|
||||
// All — копии прогресса всех источников.
|
||||
func (b *ProgressBoard) All() []Progress {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
out := make([]Progress, 0, len(b.m))
|
||||
for _, p := range b.m {
|
||||
out = append(out, *p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// start регистрирует новый скан; false — скан этого источника уже идёт.
|
||||
func (b *ProgressBoard) start(id uuid.UUID, full bool, now time.Time) bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if p, ok := b.m[id]; ok && p.Running {
|
||||
return false
|
||||
}
|
||||
b.m[id] = &Progress{SourceID: id, Running: true, Full: full, StartedAt: now}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *ProgressBoard) update(id uuid.UUID, fn func(*Progress)) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if p, ok := b.m[id]; ok {
|
||||
fn(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Scanner сканирует источники и обновляет каталог.
|
||||
type Scanner struct {
|
||||
store *Store
|
||||
prober Prober
|
||||
log *slog.Logger
|
||||
board *ProgressBoard
|
||||
workers int
|
||||
now func() time.Time
|
||||
// AfterScan вызывается после каждого успешного скана (переиндексация поиска).
|
||||
AfterScan func(ctx context.Context)
|
||||
}
|
||||
|
||||
// NewScanner создаёт сканер с 8 воркерами чтения.
|
||||
func NewScanner(store *Store, prober Prober, board *ProgressBoard, log *slog.Logger) *Scanner {
|
||||
return &Scanner{store: store, prober: prober, log: log, board: board, workers: 8, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// walked — аудиофайл, найденный обходом.
|
||||
type walked struct {
|
||||
rel string
|
||||
size int64
|
||||
mtime time.Time
|
||||
hash []byte
|
||||
}
|
||||
|
||||
// job — файл, который нужно прочитать.
|
||||
type job struct {
|
||||
walked
|
||||
fileID uuid.UUID // Nil — новый
|
||||
oldTrack *uuid.UUID // трек, к которому файл был привязан
|
||||
}
|
||||
|
||||
type probed struct {
|
||||
job
|
||||
probe Probe
|
||||
err error
|
||||
}
|
||||
|
||||
// ErrScanRunning — скан источника уже идёт.
|
||||
var ErrScanRunning = errors.New("library: scan already running")
|
||||
|
||||
// Start запускает скан в фоне. root — каталог источника в файловой системе api.
|
||||
func (sc *Scanner) Start(src Source, root string, full bool) error {
|
||||
if !sc.board.start(src.ID, full, sc.now()) {
|
||||
return ErrScanRunning
|
||||
}
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
err := sc.run(ctx, src, root, full)
|
||||
finished := sc.now()
|
||||
sc.board.update(src.ID, func(p *Progress) {
|
||||
p.Running, p.FinishedAt = false, &finished
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
sc.log.Error("scan failed", "source_id", src.ID, "err", err)
|
||||
return
|
||||
}
|
||||
if sc.AfterScan != nil {
|
||||
sc.AfterScan(ctx)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool) error {
|
||||
start := sc.now()
|
||||
rows, err := sc.store.Patterns(ctx, src.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var exprs []string
|
||||
for _, r := range rows {
|
||||
if r.IsEnabled {
|
||||
exprs = append(exprs, r.Regex)
|
||||
}
|
||||
}
|
||||
patterns, err := meta.CompilePatterns(exprs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("library: patterns: %w", err)
|
||||
}
|
||||
|
||||
existing, err := sc.store.Files(ctx, src.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found, err := walk(root, func(rel string) bool { return meta.MatchPath(patterns, rel).Skip })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
|
||||
|
||||
var (
|
||||
jobs []job
|
||||
newFiles []walked
|
||||
seen = make(map[string]bool, len(found))
|
||||
)
|
||||
for _, w := range found {
|
||||
seen[w.rel] = true
|
||||
f, ok := existing[w.rel]
|
||||
switch {
|
||||
case !ok:
|
||||
newFiles = append(newFiles, w)
|
||||
case !full && f.Size == w.size && f.Mtime.Equal(w.mtime):
|
||||
if f.Missing {
|
||||
if err := sc.store.SetPresent(ctx, f.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
jobs = append(jobs, job{walked: w, fileID: f.ID, oldTrack: f.TrackID})
|
||||
}
|
||||
}
|
||||
var gone []FileRow
|
||||
for rel, f := range existing {
|
||||
if !seen[rel] {
|
||||
gone = append(gone, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Переименования: новый путь с тем же содержимым, что у пропавшего файла.
|
||||
if len(gone) > 0 {
|
||||
for i := range newFiles {
|
||||
if h, err := contentHash(filepath.Join(root, filepath.FromSlash(newFiles[i].rel)), newFiles[i].size); err == nil {
|
||||
newFiles[i].hash = h
|
||||
}
|
||||
}
|
||||
}
|
||||
moves, rest, stillGone := matchRenames(newFiles, gone)
|
||||
for _, mv := range moves {
|
||||
if err := sc.store.MoveFile(ctx, mv.from.ID, mv.to.rel, mv.to.size, mv.to.mtime); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, w := range rest {
|
||||
jobs = append(jobs, job{walked: w})
|
||||
}
|
||||
sc.board.update(src.ID, func(p *Progress) { p.ToProcess, p.Moved = len(jobs), len(moves) })
|
||||
|
||||
if err := sc.process(ctx, src, root, patterns, jobs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var goneIDs []uuid.UUID
|
||||
affected := map[uuid.UUID]bool{}
|
||||
for _, f := range stillGone {
|
||||
goneIDs = append(goneIDs, f.ID)
|
||||
if f.TrackID != nil {
|
||||
affected[*f.TrackID] = true
|
||||
}
|
||||
}
|
||||
if err := sc.store.MarkMissing(ctx, goneIDs, start); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sc.store.PurgeMissing(ctx, src.ID, start.Add(-MissingRetention)); err != nil {
|
||||
return err
|
||||
}
|
||||
for id := range affected {
|
||||
if err := sc.store.RefreshPrimaryFile(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
sc.board.update(src.ID, func(p *Progress) { p.Missing = len(goneIDs) })
|
||||
if err := sc.store.CleanupOrphans(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.store.SetLastScan(ctx, src.ID, sc.now())
|
||||
}
|
||||
|
||||
// process читает файлы параллельно, а каталог обновляет один писатель —
|
||||
// так дубли и новые исполнители не создаются дважды в гонке.
|
||||
func (sc *Scanner) process(ctx context.Context, src Source, root string, patterns []meta.Pattern, jobs []job) error {
|
||||
in := make(chan job)
|
||||
out := make(chan probed)
|
||||
var wg sync.WaitGroup
|
||||
for range max(1, sc.workers) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := range in {
|
||||
abs := filepath.Join(root, filepath.FromSlash(j.rel))
|
||||
p, err := sc.prober.Probe(ctx, abs)
|
||||
if j.hash == nil {
|
||||
j.hash, _ = contentHash(abs, j.size)
|
||||
}
|
||||
out <- probed{job: j, probe: p, err: err}
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
for _, j := range jobs {
|
||||
in <- j
|
||||
}
|
||||
close(in)
|
||||
wg.Wait()
|
||||
close(out)
|
||||
}()
|
||||
|
||||
var firstErr error
|
||||
for r := range out {
|
||||
if firstErr != nil {
|
||||
continue // дочитываем канал, чтобы воркеры завершились
|
||||
}
|
||||
added, err := sc.apply(ctx, src, patterns, r)
|
||||
if err != nil {
|
||||
firstErr = err
|
||||
continue
|
||||
}
|
||||
sc.board.update(src.ID, func(p *Progress) {
|
||||
p.Processed++
|
||||
switch {
|
||||
case r.err != nil:
|
||||
p.Errors++
|
||||
case added:
|
||||
p.Added++
|
||||
default:
|
||||
p.Updated++
|
||||
}
|
||||
})
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// apply записывает прочитанный файл в каталог. added — файл новый.
|
||||
func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Pattern, r probed) (bool, error) {
|
||||
data := FileData{RelPath: r.rel, Size: r.size, Mtime: r.mtime, ContentHash: r.hash, Probe: r.probe}
|
||||
added := r.fileID == uuid.Nil
|
||||
if r.err != nil {
|
||||
data.Probe = Probe{Format: strings.TrimPrefix(strings.ToLower(filepath.Ext(r.rel)), ".")}
|
||||
data.ScanError = r.err.Error()
|
||||
if _, err := sc.store.SaveFile(ctx, src.ID, r.fileID, data, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if r.oldTrack != nil {
|
||||
return added, sc.store.RefreshPrimaryFile(ctx, *r.oldTrack)
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
pm := meta.MatchPath(patterns, r.rel)
|
||||
m := meta.Merge(r.probe.Tags, meta.FromPath(pm.Fields), src.TagsFirst, r.rel)
|
||||
if m.Album == "" {
|
||||
m.Album = UnknownAlbum
|
||||
}
|
||||
albumArtist, err := sc.store.EnsureArtist(ctx, m.AlbumArtist)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
albumID, err := sc.store.EnsureAlbum(ctx, albumArtist, m.Album, m.Year)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
in := TrackInput{
|
||||
AlbumID: albumID, Title: m.Title, Disc: m.Disc, Track: m.Track, Year: m.Year, DurationMs: r.probe.DurationMs,
|
||||
}
|
||||
trackID, dup, err := sc.store.FindTrack(ctx, in)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !dup {
|
||||
// Перечитанный файл сохраняет свой трек (лайки, история); новый — получает новый.
|
||||
if r.oldTrack != nil {
|
||||
trackID = *r.oldTrack
|
||||
}
|
||||
if trackID, err = sc.store.SaveTrack(ctx, trackID, in); err != nil {
|
||||
return false, err
|
||||
}
|
||||
credits := meta.SplitArtists(m.Artist, func(k string) bool { return sc.store.ArtistKeyExists(ctx, k) })
|
||||
ids := make([]uuid.UUID, 0, len(credits))
|
||||
roles := make([]string, 0, len(credits))
|
||||
for _, c := range credits {
|
||||
id, err := sc.store.EnsureArtist(ctx, c.Name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
ids, roles = append(ids, id), append(roles, c.Role)
|
||||
}
|
||||
if err := sc.store.SetTrackArtists(ctx, trackID, ids, roles); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := sc.store.SetTrackGenres(ctx, trackID, m.Genres); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if _, err := sc.store.SaveFile(ctx, src.ID, r.fileID, data, &trackID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := sc.store.RefreshPrimaryFile(ctx, trackID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if r.oldTrack != nil && *r.oldTrack != trackID {
|
||||
if err := sc.store.RefreshPrimaryFile(ctx, *r.oldTrack); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются;
|
||||
// skip — пути, которые шаблоны велят игнорировать. Ошибки отдельных каталогов не прерывают обход.
|
||||
func walk(root string, skip func(rel string) bool) ([]walked, error) {
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return nil, fmt.Errorf("library: source root: %w", err)
|
||||
}
|
||||
var out []walked
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if p == root {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if p != root && strings.HasPrefix(name, ".") {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() || !IsAudio(name) {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if skip(rel) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// PostgreSQL хранит время с точностью до микросекунд.
|
||||
out = append(out, walked{rel: rel, size: info.Size(), mtime: info.ModTime().UTC().Truncate(time.Microsecond)})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("library: walk: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const hashChunk = 1 << 20
|
||||
|
||||
// contentHash — xxhash64 от размера, первого и последнего мегабайта: быстро даже по NFS
|
||||
// и не меняется при переименовании.
|
||||
func contentHash(abs string, size int64) ([]byte, error) {
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
h := xxhash.New()
|
||||
var sz [8]byte
|
||||
binary.LittleEndian.PutUint64(sz[:], uint64(size))
|
||||
_, _ = h.Write(sz[:])
|
||||
if _, err := io.CopyN(h, f, hashChunk); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
if size > 2*hashChunk {
|
||||
if _, err := f.Seek(-hashChunk, io.SeekEnd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.CopyN(h, f, hashChunk); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
type move struct {
|
||||
from FileRow
|
||||
to walked
|
||||
}
|
||||
|
||||
// matchRenames сопоставляет новые пути с пропавшими файлами по содержимому (хэш и размер).
|
||||
// Каждый пропавший файл используется не больше одного раза.
|
||||
func matchRenames(newFiles []walked, gone []FileRow) (moves []move, rest []walked, stillGone []FileRow) {
|
||||
byHash := map[string][]int{}
|
||||
for i, f := range gone {
|
||||
if len(f.ContentHash) > 0 {
|
||||
k := fmt.Sprintf("%x:%d", f.ContentHash, f.Size)
|
||||
byHash[k] = append(byHash[k], i)
|
||||
}
|
||||
}
|
||||
used := make([]bool, len(gone))
|
||||
for _, w := range newFiles {
|
||||
k := fmt.Sprintf("%x:%d", w.hash, w.size)
|
||||
if len(w.hash) > 0 && len(byHash[k]) > 0 {
|
||||
i := byHash[k][0]
|
||||
byHash[k] = byHash[k][1:]
|
||||
used[i] = true
|
||||
moves = append(moves, move{from: gone[i], to: w})
|
||||
continue
|
||||
}
|
||||
rest = append(rest, w)
|
||||
}
|
||||
for i, f := range gone {
|
||||
if !used[i] {
|
||||
stillGone = append(stillGone, f)
|
||||
}
|
||||
}
|
||||
return moves, rest, stillGone
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMatchRenames(t *testing.T) {
|
||||
a := FileRow{ID: uuid.New(), RelPath: "old/a.flac", Size: 10, ContentHash: []byte{1}}
|
||||
b := FileRow{ID: uuid.New(), RelPath: "old/b.flac", Size: 10, ContentHash: []byte{2}}
|
||||
noHash := FileRow{ID: uuid.New(), RelPath: "old/c.flac", Size: 10}
|
||||
newFiles := []walked{
|
||||
{rel: "new/a.flac", size: 10, hash: []byte{1}},
|
||||
{rel: "new/a-copy.flac", size: 10, hash: []byte{1}}, // второй файл с тем же содержимым — новый
|
||||
{rel: "new/x.flac", size: 11, hash: []byte{2}}, // другой размер — не переименование
|
||||
{rel: "new/y.flac", size: 10},
|
||||
}
|
||||
moves, rest, gone := matchRenames(newFiles, []FileRow{a, b, noHash})
|
||||
|
||||
require.Len(t, moves, 1)
|
||||
assert.Equal(t, a.ID, moves[0].from.ID)
|
||||
assert.Equal(t, "new/a.flac", moves[0].to.rel)
|
||||
assert.Equal(t, []string{"new/a-copy.flac", "new/x.flac", "new/y.flac"}, rels(rest))
|
||||
assert.Equal(t, []uuid.UUID{b.ID, noHash.ID}, []uuid.UUID{gone[0].ID, gone[1].ID})
|
||||
}
|
||||
|
||||
func rels(ws []walked) []string {
|
||||
var out []string
|
||||
for _, w := range ws {
|
||||
out = append(out, w.rel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestWalkAndHash(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(rel string, size int) {
|
||||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
|
||||
data := make([]byte, size)
|
||||
for i := range data {
|
||||
data[i] = byte(i)
|
||||
}
|
||||
require.NoError(t, os.WriteFile(p, data, 0o644))
|
||||
}
|
||||
write("Artist/Album/01 - Song.FLAC", 100)
|
||||
write("Artist/Album/cover.jpg", 10)
|
||||
write("Artist/.hidden/02.mp3", 10)
|
||||
write("_incoming/new.mp3", 10)
|
||||
write("big.mp3", 3<<20)
|
||||
|
||||
found, err := walk(root, func(rel string) bool { return rel == "_incoming/new.mp3" })
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found))
|
||||
for _, w := range found {
|
||||
assert.Equal(t, w.mtime, w.mtime.Truncate(time.Microsecond))
|
||||
}
|
||||
|
||||
h1, err := contentHash(filepath.Join(root, "big.mp3"), 3<<20)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Rename(filepath.Join(root, "big.mp3"), filepath.Join(root, "moved.mp3")))
|
||||
h2, err := contentHash(filepath.Join(root, "moved.mp3"), 3<<20)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, h1, h2, "hash survives a rename")
|
||||
|
||||
_, err = walk(filepath.Join(root, "nope"), func(string) bool { return false })
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseFFprobe(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "mjpeg"},
|
||||
{"codec_type": "audio", "codec_name": "opus", "sample_rate": "48000", "channels": 2,
|
||||
"tags": {"TITLE": "Song", "ARTIST": "Band feat. Guest", "ALBUM": "Record", "DATE": "2020", "track": "3/10"}}
|
||||
],
|
||||
"format": {"duration": "215.004", "bit_rate": "128000", "tags": {"album_artist": "Band"}}
|
||||
}`)
|
||||
p, err := parseFFprobe(raw, "/x/Song.OPUS")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "opus", p.Format)
|
||||
assert.Equal(t, "opus", p.Codec)
|
||||
assert.Equal(t, 215004, p.DurationMs)
|
||||
assert.Equal(t, 128000, p.Bitrate)
|
||||
assert.Equal(t, 48000, p.SampleRate)
|
||||
assert.Equal(t, "Song", p.Tags.Title)
|
||||
assert.Equal(t, "Band", p.Tags.AlbumArtist)
|
||||
assert.Equal(t, "3/10", p.Tags.Track)
|
||||
assert.False(t, p.Lossless())
|
||||
|
||||
_, err = parseFFprobe([]byte(`{"streams": [], "format": {}}`), "x.mp3")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/library/meta"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"pipu/api/internal/storageagent"
|
||||
)
|
||||
|
||||
// Коды ошибок библиотеки.
|
||||
const (
|
||||
CodeSourceNotFound = "library.source_not_found"
|
||||
CodeScanRunning = "library.scan_running"
|
||||
CodeSourceOffline = "library.source_offline"
|
||||
CodePatternInvalid = "validation.pattern_invalid"
|
||||
CodeNFSInvalid = "validation.nfs_invalid"
|
||||
CodeLocalPathInvalid = "validation.local_path_invalid"
|
||||
CodeNameInvalid = "validation.name_invalid"
|
||||
CodeIntervalInvalid = "validation.scan_interval_invalid"
|
||||
CodeNotFound = "catalog.not_found"
|
||||
)
|
||||
|
||||
// Config — пути к хранилищам в файловой системе api.
|
||||
type Config struct {
|
||||
LibraryRoot string // точки монтирования NFS: <root>/<source_id>
|
||||
HostRoot string // LIBRARY_HOST внутри контейнера, для local-источников
|
||||
}
|
||||
|
||||
// Service управляет хранилищами и сканами.
|
||||
type Service struct {
|
||||
store *Store
|
||||
scanner *Scanner
|
||||
board *ProgressBoard
|
||||
agent Agent
|
||||
prober Prober
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
mu sync.Mutex // сериализует переподключение одного и того же источника
|
||||
}
|
||||
|
||||
// NewService создаёт сервис.
|
||||
func NewService(store *Store, scanner *Scanner, board *ProgressBoard, agent Agent, prober Prober, cfg Config, log *slog.Logger) *Service {
|
||||
return &Service{store: store, scanner: scanner, board: board, agent: agent, prober: prober, cfg: cfg, log: log}
|
||||
}
|
||||
|
||||
func forbidden() error { return apperr.Forbidden(auth.CodeForbidden, "not allowed") }
|
||||
|
||||
func requirePerm(ctx context.Context, perm auth.Permission) error {
|
||||
p, ok := auth.PrincipalFrom(ctx)
|
||||
if !ok {
|
||||
return apperr.Unauthorized(auth.CodeUnauthorized, "not authenticated")
|
||||
}
|
||||
if !auth.Can(p.Role, perm) {
|
||||
return forbidden()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func notFound(err error) error {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return apperr.NotFound(CodeSourceNotFound, "source not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Root — каталог источника в файловой системе api.
|
||||
func (s *Service) Root(src Source) (string, error) {
|
||||
switch src.Type {
|
||||
case SourceLocal:
|
||||
rel := ""
|
||||
if src.LocalPath != nil {
|
||||
rel = *src.LocalPath
|
||||
}
|
||||
return s.hostPath(rel)
|
||||
case SourceNFS:
|
||||
return filepath.Join(s.cfg.LibraryRoot, src.ID.String()), nil
|
||||
}
|
||||
return "", fmt.Errorf("library: unknown source type %q", src.Type)
|
||||
}
|
||||
|
||||
// hostPath — путь внутри LIBRARY_HOST; выйти за его пределы нельзя.
|
||||
func (s *Service) hostPath(rel string) (string, error) {
|
||||
rel = strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/")
|
||||
clean := path.Clean("/" + rel)
|
||||
if clean != "/"+rel && rel != "" || strings.Contains(rel, "..") {
|
||||
return "", apperr.Validation("local_path", CodeLocalPathInvalid, "path must be relative without ..", nil)
|
||||
}
|
||||
return filepath.Join(s.cfg.HostRoot, filepath.FromSlash(strings.TrimPrefix(clean, "/"))), nil
|
||||
}
|
||||
|
||||
// ListSources — хранилища с прогрессом сканов.
|
||||
func (s *Service) ListSources(ctx context.Context) ([]Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListSources(ctx)
|
||||
}
|
||||
|
||||
// GetSource — хранилище по id.
|
||||
func (s *Service) GetSource(ctx context.Context, id uuid.UUID) (Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
return src, notFound(err)
|
||||
}
|
||||
|
||||
// Progress — прогресс скана источника, если он был.
|
||||
func (s *Service) Progress(id uuid.UUID) (Progress, bool) { return s.board.Get(id) }
|
||||
|
||||
// SourceInput — поля создания / изменения хранилища. nil — не задано.
|
||||
type SourceInput struct {
|
||||
Name *string
|
||||
Type SourceType
|
||||
NFSServer *string
|
||||
NFSExport *string
|
||||
NFSOptions *string
|
||||
LocalPath *string
|
||||
TagsFirst *bool
|
||||
ScanIntervalMinutes *int // 0 — выключить
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
func (s *Service) apply(src *Source, in SourceInput) error {
|
||||
if in.Name != nil {
|
||||
name := strings.TrimSpace(*in.Name)
|
||||
if name == "" || utf8.RuneCountInString(name) > 100 {
|
||||
return apperr.Validation("name", CodeNameInvalid, "name must be 1-100 characters", map[string]any{"max": 100})
|
||||
}
|
||||
src.Name = name
|
||||
}
|
||||
if in.TagsFirst != nil {
|
||||
src.TagsFirst = *in.TagsFirst
|
||||
}
|
||||
if in.ScanIntervalMinutes != nil {
|
||||
switch m := *in.ScanIntervalMinutes; {
|
||||
case m == 0:
|
||||
src.ScanInterval = nil
|
||||
case m < 5 || m > 60*24*30:
|
||||
return apperr.Validation("scan_interval_minutes", CodeIntervalInvalid, "interval must be 5 minutes .. 30 days",
|
||||
map[string]any{"min": 5})
|
||||
default:
|
||||
d := time.Duration(m) * time.Minute
|
||||
src.ScanInterval = &d
|
||||
}
|
||||
}
|
||||
switch src.Type {
|
||||
case SourceLocal:
|
||||
if in.LocalPath != nil {
|
||||
rel := strings.Trim(strings.ReplaceAll(*in.LocalPath, "\\", "/"), "/")
|
||||
if _, err := s.hostPath(rel); err != nil {
|
||||
return err
|
||||
}
|
||||
src.LocalPath = &rel
|
||||
}
|
||||
if src.LocalPath == nil {
|
||||
empty := ""
|
||||
src.LocalPath = &empty
|
||||
}
|
||||
case SourceNFS:
|
||||
if in.NFSServer != nil {
|
||||
src.NFSServer = in.NFSServer
|
||||
}
|
||||
if in.NFSExport != nil {
|
||||
src.NFSExport = in.NFSExport
|
||||
}
|
||||
if in.NFSOptions != nil {
|
||||
src.NFSOptions = *in.NFSOptions
|
||||
}
|
||||
spec, err := nfsSpec(*src).Normalize(false)
|
||||
if err != nil {
|
||||
var ve *storageagent.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
return apperr.Validation(ve.Field, CodeNFSInvalid, ve.Reason, nil)
|
||||
}
|
||||
return err
|
||||
}
|
||||
src.NFSServer, src.NFSExport, src.NFSOptions = &spec.Server, &spec.Export, spec.Options
|
||||
default:
|
||||
return apperr.Validation("type", "validation.source_type_invalid", "unknown source type", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nfsSpec(src Source) storageagent.NFSSpec {
|
||||
var spec storageagent.NFSSpec
|
||||
if src.NFSServer != nil {
|
||||
spec.Server = *src.NFSServer
|
||||
}
|
||||
if src.NFSExport != nil {
|
||||
spec.Export = *src.NFSExport
|
||||
}
|
||||
spec.Options = src.NFSOptions
|
||||
return spec
|
||||
}
|
||||
|
||||
// CreateSource сохраняет хранилище и сразу пытается подключить его.
|
||||
func (s *Service) CreateSource(ctx context.Context, in SourceInput) (Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src := Source{ID: newID(), Type: in.Type, TagsFirst: true, Status: "pending", NFSOptions: "ro,nfsvers=4.1,soft,timeo=150,retrans=3"}
|
||||
if in.Name == nil {
|
||||
in.Name = new(string)
|
||||
}
|
||||
if err := s.apply(&src, in); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src, err := s.store.SaveSource(ctx, src)
|
||||
if err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
return s.connect(ctx, src)
|
||||
}
|
||||
|
||||
// UpdateSource меняет хранилище; смена адреса или папки переподключает его.
|
||||
func (s *Service) UpdateSource(ctx context.Context, id uuid.UUID, in SourceInput) (Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return Source{}, notFound(err)
|
||||
}
|
||||
before := src
|
||||
in.Type = src.Type
|
||||
if err := s.apply(&src, in); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
if src, err = s.store.SaveSource(ctx, src); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
location := func(x Source) string {
|
||||
return fmt.Sprint(x.LocalPath != nil && *x.LocalPath != "", deref(x.LocalPath), deref(x.NFSServer), deref(x.NFSExport), x.NFSOptions)
|
||||
}
|
||||
switch {
|
||||
case in.Enabled != nil && !*in.Enabled:
|
||||
if src.Type == SourceNFS {
|
||||
_ = s.agent.Unmount(ctx, src.ID)
|
||||
}
|
||||
if err := s.store.SetSourceStatus(ctx, src.ID, "disabled", nil); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
return s.store.GetSource(ctx, src.ID)
|
||||
case in.Enabled != nil && *in.Enabled, location(before) != location(src), src.Status == "error":
|
||||
return s.connect(ctx, src)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// connect подключает хранилище и записывает статус: local — проверка каталога, nfs — монтирование агентом.
|
||||
func (s *Service) connect(ctx context.Context, src Source) (Source, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
status, message := s.check(ctx, src, true)
|
||||
if err := s.store.SetSourceStatus(ctx, src.ID, status, message); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
return s.store.GetSource(ctx, src.ID)
|
||||
}
|
||||
|
||||
// check проверяет доступность; mount — смонтировать NFS (иначе только проверить точку).
|
||||
func (s *Service) check(ctx context.Context, src Source, mount bool) (string, *string) {
|
||||
msg := func(m string) *string { return &m }
|
||||
switch src.Type {
|
||||
case SourceLocal:
|
||||
root, err := s.Root(src)
|
||||
if err != nil {
|
||||
return "error", msg(err.Error())
|
||||
}
|
||||
info, err := os.Stat(root)
|
||||
if err != nil || !info.IsDir() {
|
||||
return "error", msg("folder not found: " + deref(src.LocalPath))
|
||||
}
|
||||
return "mounted", nil
|
||||
case SourceNFS:
|
||||
if !mount {
|
||||
root, _ := s.Root(src)
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return "error", msg(err.Error())
|
||||
}
|
||||
return "mounted", nil
|
||||
}
|
||||
res, err := s.agent.Mount(ctx, src.ID, nfsSpec(src))
|
||||
if err != nil {
|
||||
return "error", msg(err.Error())
|
||||
}
|
||||
if res.Status != "mounted" {
|
||||
return "error", msg(res.Message)
|
||||
}
|
||||
return "mounted", nil
|
||||
}
|
||||
return "error", msg("unknown type")
|
||||
}
|
||||
|
||||
// DeleteSource отключает хранилище и удаляет его файлы из каталога.
|
||||
func (s *Service) DeleteSource(ctx context.Context, id uuid.UUID) error {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return notFound(err)
|
||||
}
|
||||
if p, ok := s.board.Get(id); ok && p.Running {
|
||||
return apperr.Conflict(CodeScanRunning, "scan is running")
|
||||
}
|
||||
if src.Type == SourceNFS {
|
||||
if err := s.agent.Unmount(ctx, id); err != nil {
|
||||
s.log.WarnContext(ctx, "unmount failed", "source_id", id, "err", err)
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteSource(ctx, id); err != nil {
|
||||
return notFound(err)
|
||||
}
|
||||
return s.store.CleanupOrphans(ctx)
|
||||
}
|
||||
|
||||
// TestResult — итог проверки подключения.
|
||||
type TestResult struct {
|
||||
OK bool
|
||||
Message string
|
||||
AudioFiles int
|
||||
}
|
||||
|
||||
// TestSource проверяет доступ к хранилищу и считает аудиофайлы (до 1000).
|
||||
func (s *Service) TestSource(ctx context.Context, id uuid.UUID) (TestResult, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return TestResult{}, notFound(err)
|
||||
}
|
||||
if src.Type == SourceNFS {
|
||||
res, err := s.agent.Test(ctx, nfsSpec(src))
|
||||
if err != nil {
|
||||
return TestResult{Message: err.Error()}, nil
|
||||
}
|
||||
return TestResult{OK: res.Status == "mounted", Message: res.Message, AudioFiles: res.AudioFiles}, nil
|
||||
}
|
||||
root, err := s.Root(src)
|
||||
if err != nil {
|
||||
return TestResult{Message: err.Error()}, nil
|
||||
}
|
||||
n := 0
|
||||
err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && IsAudio(d.Name()) {
|
||||
n++
|
||||
if n >= 1000 {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return TestResult{Message: err.Error(), AudioFiles: n}, nil
|
||||
}
|
||||
return TestResult{OK: true, Message: fmt.Sprintf("found %d audio files", n), AudioFiles: n}, nil
|
||||
}
|
||||
|
||||
// Patterns — шаблоны путей источника.
|
||||
func (s *Service) Patterns(ctx context.Context, id uuid.UUID) ([]PatternRow, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.store.GetSource(ctx, id); err != nil {
|
||||
return nil, notFound(err)
|
||||
}
|
||||
return s.store.Patterns(ctx, id)
|
||||
}
|
||||
|
||||
func compile(patterns []PatternRow) ([]meta.Pattern, error) {
|
||||
var exprs []string
|
||||
idx := []int{}
|
||||
for i, p := range patterns {
|
||||
if p.IsEnabled {
|
||||
exprs = append(exprs, p.Regex)
|
||||
idx = append(idx, i)
|
||||
}
|
||||
}
|
||||
compiled, err := meta.CompilePatterns(exprs)
|
||||
var pe *meta.PatternError
|
||||
if errors.As(err, &pe) {
|
||||
at := idx[pe.Index]
|
||||
return nil, &apperr.Error{
|
||||
Status: http.StatusUnprocessableEntity, Code: CodePatternInvalid, Detail: pe.Reason,
|
||||
Params: map[string]any{"index": at, "reason": pe.Reason},
|
||||
Fields: []apperr.FieldError{{Field: fmt.Sprintf("patterns[%d].regex", at), Code: CodePatternInvalid}},
|
||||
}
|
||||
}
|
||||
return compiled, err
|
||||
}
|
||||
|
||||
// PutPatterns проверяет и сохраняет шаблоны.
|
||||
func (s *Service) PutPatterns(ctx context.Context, id uuid.UUID, patterns []PatternRow) ([]PatternRow, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.store.GetSource(ctx, id); err != nil {
|
||||
return nil, notFound(err)
|
||||
}
|
||||
if _, err := compile(patterns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.ReplacePatterns(ctx, id, patterns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.Patterns(ctx, id)
|
||||
}
|
||||
|
||||
// PreviewItem — разбор одного файла.
|
||||
type PreviewItem struct {
|
||||
RelPath string
|
||||
PatternIndex *int
|
||||
Skipped bool
|
||||
PathFields map[string]string
|
||||
Tags map[string]string
|
||||
Result meta.Meta
|
||||
Error string
|
||||
}
|
||||
|
||||
// Preview применяет шаблоны к выборке файлов без сохранения: из каталога, а до первого скана — из обхода.
|
||||
func (s *Service) Preview(ctx context.Context, id uuid.UUID, patterns []PatternRow, sample int) ([]PreviewItem, int, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return nil, 0, notFound(err)
|
||||
}
|
||||
compiled, err := compile(patterns)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
sample = min(max(sample, 1), 200)
|
||||
root, err := s.Root(src)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
paths, err := s.store.SampleFiles(ctx, id, sample)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
found, err := walk(root, func(string) bool { return false })
|
||||
if err != nil {
|
||||
return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error())
|
||||
}
|
||||
rand.Shuffle(len(found), func(i, j int) { found[i], found[j] = found[j], found[i] })
|
||||
for _, w := range found[:min(sample, len(found))] {
|
||||
paths = append(paths, w.rel)
|
||||
}
|
||||
}
|
||||
slices.Sort(paths)
|
||||
|
||||
items := make([]PreviewItem, len(paths))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 8)
|
||||
for i, rel := range paths {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
items[i] = s.previewOne(ctx, root, rel, compiled, src.TagsFirst)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
unmatched := 0
|
||||
for _, it := range items {
|
||||
if it.PatternIndex == nil {
|
||||
unmatched++
|
||||
}
|
||||
}
|
||||
return items, unmatched, nil
|
||||
}
|
||||
|
||||
func (s *Service) previewOne(ctx context.Context, root, rel string, patterns []meta.Pattern, tagsFirst bool) PreviewItem {
|
||||
it := PreviewItem{RelPath: rel, PathFields: map[string]string{}, Tags: map[string]string{}}
|
||||
m := meta.MatchPath(patterns, rel)
|
||||
if m.Index >= 0 {
|
||||
idx := m.Index
|
||||
it.PatternIndex = &idx
|
||||
}
|
||||
it.Skipped = m.Skip
|
||||
for k, v := range m.Fields {
|
||||
it.PathFields[k] = v
|
||||
}
|
||||
p, err := s.prober.Probe(ctx, filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
it.Error = err.Error()
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
"artist": p.Tags.Artist, "albumartist": p.Tags.AlbumArtist, "album": p.Tags.Album, "title": p.Tags.Title,
|
||||
"genre": p.Tags.Genre, "year": p.Tags.Year, "track": p.Tags.Track, "disc": p.Tags.Disc,
|
||||
} {
|
||||
if v != "" {
|
||||
it.Tags[k] = v
|
||||
}
|
||||
}
|
||||
it.Result = meta.Merge(p.Tags, meta.FromPath(m.Fields), tagsFirst, rel)
|
||||
if it.Result.Album == "" {
|
||||
it.Result.Album = UnknownAlbum
|
||||
}
|
||||
return it
|
||||
}
|
||||
|
||||
// StartScan запускает скан источника в фоне.
|
||||
func (s *Service) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Progress{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return Progress{}, notFound(err)
|
||||
}
|
||||
if err := s.startScan(src, full); err != nil {
|
||||
return Progress{}, err
|
||||
}
|
||||
p, _ := s.board.Get(id)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) startScan(src Source, full bool) error {
|
||||
if src.Status != "mounted" {
|
||||
return apperr.New(http.StatusConflict, CodeSourceOffline, "source is not connected")
|
||||
}
|
||||
root, err := s.Root(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.scanner.Start(src, root, full); errors.Is(err, ErrScanRunning) {
|
||||
return apperr.Conflict(CodeScanRunning, "scan is already running")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status — состояние библиотеки (admin, moderator).
|
||||
type Status struct {
|
||||
Counts Counts
|
||||
Sources []Source
|
||||
}
|
||||
|
||||
// Status собирает счётчики и состояние источников.
|
||||
func (s *Service) Status(ctx context.Context) (Status, error) {
|
||||
if err := requirePerm(ctx, auth.PermViewLibraryStatus); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
c, err := s.store.Counts(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
sources, err := s.store.ListSources(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return Status{Counts: c, Sources: sources}, nil
|
||||
}
|
||||
|
||||
// FileErrors — файлы с ошибками чтения.
|
||||
func (s *Service) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.FileErrors(ctx, after, limit)
|
||||
}
|
||||
|
||||
// HostDirs — подпапки каталога LIBRARY_HOST для выбора local-хранилища.
|
||||
func (s *Service) HostDirs(ctx context.Context, rel string) (string, []string, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
dir, err := s.hostPath(rel)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", nil, apperr.Validation("path", CodeLocalPathInvalid, "folder not found", nil)
|
||||
}
|
||||
dirs := []string{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||||
dirs = append(dirs, e.Name())
|
||||
}
|
||||
}
|
||||
slices.Sort(dirs)
|
||||
return strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/"), dirs, nil
|
||||
}
|
||||
|
||||
// Restore при старте api переподключает NFS-хранилища и проверяет local (после перезапуска агента).
|
||||
func (s *Service) Restore(ctx context.Context) {
|
||||
sources, err := s.store.ListSources(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("restore sources", "err", err)
|
||||
return
|
||||
}
|
||||
for _, src := range sources {
|
||||
if src.Status == "disabled" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.connect(ctx, src); err != nil {
|
||||
s.log.Error("restore source", "source_id", src.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunSchedule раз в минуту проверяет доступность хранилищ и запускает сканы по расписанию.
|
||||
func (s *Service) RunSchedule(ctx context.Context) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
sources, err := s.store.ListSources(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("schedule: list sources", "err", err)
|
||||
continue
|
||||
}
|
||||
for _, src := range sources {
|
||||
if src.Status == "disabled" {
|
||||
continue
|
||||
}
|
||||
s.healthCheck(ctx, src)
|
||||
if src.ScanInterval == nil || src.Status != "mounted" {
|
||||
continue
|
||||
}
|
||||
if src.LastScanAt == nil || time.Since(*src.LastScanAt) >= *src.ScanInterval {
|
||||
if err := s.startScan(src, false); err != nil && !errors.Is(err, apperr.Conflict(CodeScanRunning, "")) {
|
||||
s.log.Warn("scheduled scan", "source_id", src.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// healthCheck: недоступное хранилище — status=error (треки серые), вернулось — снова mounted.
|
||||
func (s *Service) healthCheck(ctx context.Context, src Source) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
var status string
|
||||
var message *string
|
||||
go func() {
|
||||
status, message = s.check(ctx, src, false)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
m := "storage does not respond"
|
||||
status, message = "error", &m
|
||||
}
|
||||
if status == "error" && src.Type == SourceNFS && src.Status == "error" {
|
||||
// Попробовать перемонтировать, как при старте.
|
||||
if _, err := s.connect(ctx, src); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if status != src.Status || deref(message) != deref(src.StatusMessage) {
|
||||
if err := s.store.SetSourceStatus(ctx, src.ID, status, message); err != nil {
|
||||
s.log.Warn("health check", "source_id", src.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user