package library import ( "context" "encoding/base64" "errors" "fmt" "io" "net/http" "net/url" "os" "time" "github.com/google/uuid" "pipu/api/internal/api/gen" "pipu/api/internal/library/meta" "pipu/api/internal/platform/apperr" "pipu/api/internal/platform/sign" ) // Handler реализует library- и catalog-части gen.StrictServerInterface. type Handler struct { DTO svc *Service store *Store covers *Covers // xAccel — отдавать обложки через nginx (X-Accel-Redirect); false — сам api (разработка без nginx). xAccel bool } // NewHandler создаёт обработчик. func NewHandler(svc *Service, store *Store, dto DTO, covers *Covers, xAccel bool) *Handler { return &Handler{DTO: dto, svc: svc, store: store, covers: covers, xAccel: xAccel} } // DTO переводит строки каталога в схемы API; подписывает ссылки на обложки. type DTO struct { Signer *sign.Signer // Personal дополняет треки данными текущего пользователя (ссылка на стрим, реакция). Personal Personalizer } // Personalizer заполняет персональные поля треков для пользователя из контекста запроса. type Personalizer interface { Personalize(ctx context.Context, rows []TrackRow, out []gen.Track) error } // Tracks переводит треки в схему и добавляет персональные поля. func (d DTO) Tracks(ctx context.Context, rows []TrackRow) ([]gen.Track, error) { out := make([]gen.Track, 0, len(rows)) for _, t := range rows { out = append(out, d.Track(t)) } if d.Personal != nil && len(out) > 0 { if err := d.Personal.Personalize(ctx, rows, out); err != nil { return nil, err } } return out, nil } func (h *Handler) sourceDTO(s Source) gen.Source { if s.ExcludedDirs == nil { s.ExcludedDirs = []string{} } 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, AcceptsUploads: s.AcceptsUploads, WriteTemplate: s.WriteTemplate, ExcludedDirs: &s.ExcludedDirs, } 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 { phase := gen.ScanProgressPhase(p.Phase) out := gen.ScanProgress{ Phase: &phase, WalkedDirs: &p.WalkedDirs, CurrentDir: &p.CurrentDir, 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, AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs, }) 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, AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs, }) 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 } out := gen.TestSource200JSONResponse{Ok: r.OK, Message: r.Message, AudioFiles: r.AudioFiles} if r.Write != nil { out.WriteOk, out.WriteMessage = &r.Write.OK, &r.Write.Message } return out, nil } // SuggestWriteTemplate реализует gen.StrictServerInterface. func (h *Handler) SuggestWriteTemplate(ctx context.Context, req gen.SuggestWriteTemplateRequestObject) (gen.SuggestWriteTemplateResponseObject, error) { tpl, err := h.svc.SuggestWriteTemplate(ctx, req.Id) if err != nil { return nil, err } return gen.SuggestWriteTemplate200JSONResponse{Template: tpl}, nil } // CheckWriteTemplate реализует gen.StrictServerInterface. func (h *Handler) CheckWriteTemplate(ctx context.Context, req gen.CheckWriteTemplateRequestObject) (gen.CheckWriteTemplateResponseObject, error) { res, err := h.svc.CheckWriteTemplate(ctx, req.Id, req.Body.Template) if err != nil { return nil, err } out := gen.CheckWriteTemplate200JSONResponse{ Ok: res.OK, Total: res.Total, RoundTrip: res.RoundTrip, SamePath: res.SamePath, Mismatches: make([]gen.WriteTemplateMismatch, 0, len(res.Mismatches)), } for _, m := range res.Mismatches { dto := gen.WriteTemplateMismatch{RelPath: m.RelPath, Fields: m.Fields} if dto.Fields == nil { dto.Fields = []string{} } if m.Rendered != "" { dto.Rendered = &m.Rendered } if m.Error != "" { dto.Error = &m.Error } out.Mismatches = append(out.Mismatches, dto) } return out, 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 } // RetryAnalysis реализует gen.StrictServerInterface. func (h *Handler) RetryAnalysis(ctx context.Context, _ gen.RetryAnalysisRequestObject) (gen.RetryAnalysisResponseObject, error) { n, err := h.svc.RetryAnalysis(ctx) if err != nil { return nil, err } return gen.RetryAnalysis200JSONResponse{Requeued: n}, 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{}, Analysis: gen.AnalysisStatus{ Queued: st.Analysis.Queued, Running: st.Analysis.Running, Done: st.Analysis.Done, Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer, TopError: st.Analysis.TopError, }, Wave: gen.WaveMetrics{ Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough, EarlySkipRate: st.Wave.EarlySkip, LikeRate: st.Wave.LikeRate, ByModel: []gen.WaveModelMetrics{}, }, } for _, m := range st.Wave.ByModel { out.Wave.ByModel = append(out.Wave.ByModel, gen.WaveModelMetrics{ ModelVer: m.ModelVer, Served: m.Served, ListenThroughRate: m.ListenThrough, EarlySkipRate: m.EarlySkip, LikeRate: m.LikeRate, }) } 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 } // ListAnalysisErrors implements the paginated analysis diagnostics endpoint. func (h *Handler) ListAnalysisErrors(ctx context.Context, req gen.ListAnalysisErrorsRequestObject) (gen.ListAnalysisErrorsResponseObject, error) { limit := 50 if req.Params.Limit != nil { limit = min(max(*req.Params.Limit, 1), 200) } after := uuid.Nil if req.Params.Cursor != nil { raw, err := base64.RawURLEncoding.DecodeString(*req.Params.Cursor) if err == nil { after, err = uuid.Parse(string(raw)) } if err != nil { return nil, apperr.Validation("cursor", "validation.cursor_invalid", "malformed cursor", nil) } } rows, err := h.svc.AnalysisErrors(ctx, after, limit+1) if err != nil { return nil, err } out := gen.ListAnalysisErrors200JSONResponse{Items: []gen.AnalysisError{}} if len(rows) > limit { rows = rows[:limit] next := base64.RawURLEncoding.EncodeToString([]byte(rows[limit-1].TrackID.String())) out.NextCursor = &next } for _, r := range rows { out.Items = append(out.Items, gen.AnalysisError{TrackId: r.TrackID, Title: r.Title, SourceId: r.SourceID, SourceName: r.SourceName, RelPath: r.RelPath, Error: r.Error}) } 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 (d DTO) albumRef(a AlbumRow) gen.AlbumRef { return gen.AlbumRef{Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: d.coverURL(a), CoverColors: colors(a.CoverColors)} } // coverURL — подписанная ссылка на обложку без размера (клиент добавляет &size=); nil — обложки нет. func (d DTO) coverURL(a AlbumRow) *string { if a.CoverPath == nil { return a.ExternalCoverURL } if a.CoverPath == nil || d.Signer == nil { return nil } u := "/api/v1/covers/" + a.ID.String() + "?v=" + url.QueryEscape(*a.CoverPath) + "&sig=" + d.Signer.Sign("cover", a.ID.String(), *a.CoverPath) return &u } func colors(c []string) []string { if c == nil { return []string{} } return c } // Track переводит трек в схему Track. func (d DTO) Track(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{} } out := gen.Track{ Id: t.ID, Title: t.Title, DurationMs: t.DurationMs, Artists: artists, Album: d.albumRef(t.Album), Genres: genres, DiscNo: t.DiscNo, TrackNo: t.TrackNo, Available: t.Available, } if t.Analyzed { out.Features = &gen.TrackFeatures{Bpm: t.BPM, Energy: t.Energy, LoudnessLufs: t.LoudnessLUFS} } return out } // AlbumSummary переводит альбом в схему AlbumSummary. func (d DTO) AlbumSummary(a AlbumRow) gen.AlbumSummary { return gen.AlbumSummary{ Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: d.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, ImageUrl: a.ImageURL, 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, h.AlbumSummary(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: h.coverURL(a), CoverColors: colors(a.CoverColors), Artist: gen.ArtistRef{Id: a.Artist.ID, Name: a.Artist.Name}, } for _, t := range tracks { out.DurationMs += t.DurationMs } if out.Tracks, err = h.Tracks(ctx, tracks); err != nil { return nil, err } return out, nil } // ListArtistTopTracks реализует gen.StrictServerInterface. func (h *Handler) ListArtistTopTracks(ctx context.Context, req gen.ListArtistTopTracksRequestObject) (gen.ListArtistTopTracksResponseObject, error) { if _, err := h.store.Artist(ctx, req.Id); err != nil { return nil, catalogErr(err) } limit := 10 if req.Params.Limit != nil { limit = min(max(*req.Params.Limit, 1), 50) } rows, err := h.store.ArtistTopTracks(ctx, req.Id, limit) if err != nil { return nil, err } items, err := h.Tracks(ctx, rows) if err != nil { return nil, err } return gen.ListArtistTopTracks200JSONResponse{Items: items}, 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) } dto, err := h.Tracks(ctx, []TrackRow{t}) if err != nil { return nil, err } return gen.GetTrack200JSONResponse(dto[0]), nil } // GetCover реализует gen.StrictServerInterface: проверяет подпись и отдаёт WebP нужного размера. func (h *Handler) GetCover(_ context.Context, req gen.GetCoverRequestObject) (gen.GetCoverResponseObject, error) { if h.Signer == nil || !h.Signer.Verify(req.Params.Sig, "cover", req.Id.String(), req.Params.V) { return nil, apperr.Forbidden("catalog.signature_invalid", "bad signature") } size := 300 if req.Params.Size != nil { size = int(*req.Params.Size) } if !validCoverSize(size) { return nil, apperr.Validation("size", "validation.cover_size_invalid", "unsupported size", nil) } // Подпись связывает v с альбомом, а v — имя файла в кэше: БД не нужна. file := h.covers.CoverFile(req.Params.V, size) if _, err := os.Stat(file); err != nil { return nil, apperr.NotFound(CodeNotFound, "cover not found") } return coverResponse{file: file, name: fmt.Sprintf("%s-%d.webp", req.Params.V, size), xAccel: h.xAccel}, nil } func validCoverSize(n int) bool { for _, s := range CoverSizes { if s == n { return true } } return false } // coverResponse — ответ с картинкой: заголовок X-Accel-Redirect для nginx или сам файл. type coverResponse struct { file string name string xAccel bool } // VisitGetCoverResponse реализует gen.GetCoverResponseObject. func (c coverResponse) VisitGetCoverResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "image/webp") w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") if c.xAccel { w.Header().Set("X-Accel-Redirect", "/_internal/covers/"+c.name) w.WriteHeader(http.StatusOK) return nil } f, err := os.Open(c.file) if err != nil { return err } defer func() { _ = f.Close() }() w.WriteHeader(http.StatusOK) _, err = io.Copy(w, f) return err }