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,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
|
||||
}
|
||||
Reference in new issue
Block a user