ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile. Co-authored-by: Cursor <cursoragent@cursor.com>
577 lines
19 KiB
Go
577 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|