Files
pipu-music/services/api/internal/library/handler.go
T
mrleo1nidandClaude Opus 5.5 d91ff89c64
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m5s
ci / ts / ts (packages + web) (push) Successful in 1m3s
ci / api / api (push) Successful in 29s
ci / db / db (push) Failing after 12s
feat(admin): wave metrics split by model version (M6)
The library page compares listen-through, early-skip and like rates per
impression model version, so v1 and the trained ranker can be compared online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:57:11 +03:00

565 lines
19 KiB
Go

package library
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
"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 {
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,
}
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,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate,
})
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,
})
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
}
// 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,
},
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
}
// 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 || 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, 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
}