ci / api / api (push) Successful in 55s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / db / db (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
release / android (APK в релиз) (push) Successful in 3m50s
ci / ts / ts (packages + web) (push) Successful in 3m20s
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 44m45s
release / prune (старые файлы и прогоны) (push) Successful in 7s
613 lines
21 KiB
Go
613 lines
21 KiB
Go
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 {
|
|
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 {
|
|
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,
|
|
})
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|