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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:48:37 +03:00
1 parent ab092def54
commit c583991d14
24 files changed
+7467 -84

No files matched your search

+378
View File
@@ -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
}