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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 07:06:15 +03:00
1 parent a685b1b7ca
commit 8b76aa3964
22 files changed
+988 -166

No files matched your search

+94 -17
View File
@@ -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
}