feat: album covers with palettes and signed links (M2)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 34s
ci / api / api (push) Successful in 26s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 34s
ci / api / api (push) Successful in 26s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
- covers are taken from the embedded picture of the first track or a
cover/folder/front image in the album folder, converted by ffmpeg to
WebP 64/300/1200 and named by content hash, so albums sharing a cover
share the files; a full scan looks again for albums without one
- a 3-colour palette (dominant, second, most saturated accent) from a
deterministic k-means feeds placeholders, page tints and the wave
- /covers/{id} is public but HMAC-signed and immutable; nginx serves
the file via X-Accel-Redirect, X_ACCEL=false serves it from api
- the web client picks the size by display size and pixel density
- install.sh and update.sh generate STREAM_SIGNING_KEY
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a685b1b7ca
commit
8b76aa3964
22 files changed
+988
-166
No files matched your search
@@ -4,21 +4,36 @@ 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 {
|
||||
svc *Service
|
||||
store *Store
|
||||
DTO
|
||||
svc *Service
|
||||
store *Store
|
||||
covers *Covers
|
||||
// xAccel — отдавать обложки через nginx (X-Accel-Redirect); false — сам api (разработка без nginx).
|
||||
xAccel bool
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик.
|
||||
func NewHandler(svc *Service, store *Store) *Handler { return &Handler{svc: svc, store: store} }
|
||||
func NewHandler(svc *Service, store *Store, signer *sign.Signer, covers *Covers, xAccel bool) *Handler {
|
||||
return &Handler{DTO: DTO{Signer: signer}, svc: svc, store: store, covers: covers, xAccel: xAccel}
|
||||
}
|
||||
|
||||
// DTO переводит строки каталога в схемы API; подписывает ссылки на обложки.
|
||||
type DTO struct{ Signer *sign.Signer }
|
||||
|
||||
func (h *Handler) sourceDTO(s Source) gen.Source {
|
||||
out := gen.Source{
|
||||
@@ -285,12 +300,19 @@ func catalogErr(err error) error {
|
||||
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)}
|
||||
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 — обложки появятся вместе с извлечением (следующий шаг M2).
|
||||
func coverURL(AlbumRow) *string { return nil }
|
||||
// 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 {
|
||||
@@ -299,8 +321,8 @@ func colors(c []string) []string {
|
||||
return c
|
||||
}
|
||||
|
||||
// TrackDTO переводит трек в схему Track.
|
||||
func TrackDTO(t TrackRow) gen.Track {
|
||||
// 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})
|
||||
@@ -310,15 +332,15 @@ func TrackDTO(t TrackRow) gen.Track {
|
||||
genres = []string{}
|
||||
}
|
||||
return gen.Track{
|
||||
Id: t.ID, Title: t.Title, DurationMs: t.DurationMs, Artists: artists, Album: albumRefDTO(t.Album),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// AlbumSummaryDTO переводит альбом в схему AlbumSummary.
|
||||
func AlbumSummaryDTO(a AlbumRow) gen.AlbumSummary {
|
||||
// AlbumSummary переводит альбом в схему AlbumSummary.
|
||||
func (d DTO) AlbumSummary(a AlbumRow) gen.AlbumSummary {
|
||||
return gen.AlbumSummary{
|
||||
Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: coverURL(a), CoverColors: colors(a.CoverColors),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -343,7 +365,7 @@ func (h *Handler) ListArtistAlbums(ctx context.Context, req gen.ListArtistAlbums
|
||||
}
|
||||
out := gen.ListArtistAlbums200JSONResponse{Items: make([]gen.AlbumSummary, 0, len(rows))}
|
||||
for _, a := range rows {
|
||||
out.Items = append(out.Items, AlbumSummaryDTO(a))
|
||||
out.Items = append(out.Items, h.AlbumSummary(a))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -359,12 +381,12 @@ func (h *Handler) GetAlbum(ctx context.Context, req gen.GetAlbumRequestObject) (
|
||||
return nil, err
|
||||
}
|
||||
out := gen.GetAlbum200JSONResponse{
|
||||
Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: coverURL(a), CoverColors: colors(a.CoverColors),
|
||||
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}, Tracks: make([]gen.Track, 0, len(tracks)),
|
||||
}
|
||||
for _, t := range tracks {
|
||||
out.DurationMs += t.DurationMs
|
||||
out.Tracks = append(out.Tracks, TrackDTO(t))
|
||||
out.Tracks = append(out.Tracks, h.Track(t))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -375,5 +397,60 @@ func (h *Handler) GetTrack(ctx context.Context, req gen.GetTrackRequestObject) (
|
||||
if err != nil {
|
||||
return nil, catalogErr(err)
|
||||
}
|
||||
return gen.GetTrack200JSONResponse(TrackDTO(t)), nil
|
||||
return gen.GetTrack200JSONResponse(h.Track(t)), 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
|
||||
}
|
||||
Reference in new issue
Block a user