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

@@ -40,6 +40,13 @@ type API struct {
InternalToken string `env:"INTERNAL_TOKEN"`
FFprobePath string `env:"FFPROBE_PATH" envDefault:"ffprobe"`
// SigningKey — ключ подписи ссылок на обложки и стрим (base64, ≥ 16 байт). Пусто — случайный до перезапуска.
SigningKey string `env:"STREAM_SIGNING_KEY"`
CacheDir string `env:"CACHE_DIR" envDefault:"/cache"`
FFmpegPath string `env:"FFMPEG_PATH" envDefault:"ffmpeg"`
// XAccel — файлы отдаёт nginx по X-Accel-Redirect; false — для запуска api без nginx.
XAccel bool `env:"X_ACCEL" envDefault:"true"`
MeiliURL string `env:"MEILI_URL" envDefault:"http://meilisearch:7700"`
MeiliMasterKey string `env:"MEILI_MASTER_KEY"`
}
@@ -0,0 +1,46 @@
// Package sign — HMAC-подписи ссылок, которые клиент открывает без заголовка Authorization
// (обложки в <img>, стрим в <audio>; docs/decisions.md, ADR-006).
package sign
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
)
// Signer подписывает наборы строк ключом сервера.
type Signer struct{ key []byte }
// New создаёт Signer. Пустой ключ — случайный: ссылки перестанут действовать после перезапуска.
func New(keyBase64 string) (*Signer, error) {
if strings.TrimSpace(keyBase64) == "" {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
return &Signer{key: key}, nil
}
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(keyBase64))
if err != nil || len(key) < 16 {
return nil, fmt.Errorf("sign: key must be base64 of at least 16 bytes")
}
return &Signer{key: key}, nil
}
// Sign возвращает подпись частей (base64url, 22 символа — первые 16 байт HMAC-SHA256).
func (s *Signer) Sign(parts ...string) string {
m := hmac.New(sha256.New, s.key)
for _, p := range parts {
_, _ = m.Write([]byte(p))
_, _ = m.Write([]byte{0}) // разделитель: ("ab","c") и ("a","bc") подписываются по-разному
}
return base64.RawURLEncoding.EncodeToString(m.Sum(nil)[:16])
}
// Verify проверяет подпись за постоянное время.
func (s *Signer) Verify(sig string, parts ...string) bool {
return hmac.Equal([]byte(sig), []byte(s.Sign(parts...)))
}