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
+984 -162

No files matched your search

+1
View File
@@ -34,3 +34,4 @@ Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json
.cache/
+1 -1
View File
@@ -34,7 +34,7 @@ docker compose -f deploy/compose.dev.yaml up -d
API (из `services/api`):
```bash
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" MEILI_URL="http://localhost:7700" MEILI_MASTER_KEY="dev-master-key-change-me" LIBRARY_HOST_ROOT="$PWD/../../tests/fixtures/music/out" go run ./cmd/api
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" MEILI_URL="http://localhost:7700" MEILI_MASTER_KEY="dev-master-key-change-me" LIBRARY_HOST_ROOT="$PWD/../../tests/fixtures/music/out" X_ACCEL=false CACHE_DIR="$PWD/.cache" go run ./cmd/api
```
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
+37
View File
@@ -732,6 +732,43 @@ paths:
description: Переиндексация запущена
default:
$ref: "#/components/responses/Problem"
/api/v1/covers/{id}:
get:
operationId: getCover
tags: [catalog]
security: []
summary: Обложка альбома (WebP)
description: |
Ссылку целиком даёт `cover_url` альбома (подписана, без срока: обложка с данным `v` неизменна);
клиент добавляет `size`. Файл отдаёт nginx через X-Accel-Redirect.
parameters:
- $ref: "#/components/parameters/EntityId"
- name: v
in: query
required: true
schema:
type: string
- name: sig
in: query
required: true
schema:
type: string
- name: size
in: query
schema:
type: integer
enum: [64, 300, 1200]
default: 300
responses:
"200":
description: Картинка
content:
image/webp:
schema:
type: string
format: binary
default:
$ref: "#/components/responses/Problem"
components:
parameters:
SourceId:
+8 -1
View File
@@ -22,6 +22,13 @@ function fallbackColors(id: string): string[] {
return [`hsl(${base} 70% 55%)`, `hsl(${(base + 50) % 360} 65% 45%)`, `hsl(${(base + 110) % 360} 70% 60%)`];
}
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
export function coverSrc(url: string, cssPx: number): string {
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`;
}
/** Обложка альбома; без картинки — градиент из палитры обложки или из id. */
export function Cover({
album,
@@ -47,7 +54,7 @@ export function Cover({
aria-hidden
>
{album.cover_url ? (
<img src={album.cover_url} alt="" loading="lazy" />
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
) : (
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
)}
+2
View File
@@ -24,6 +24,8 @@ INTERNAL_TOKEN=
# создают его, если строки нет. Смена ключа разлогинит всех на ≤15 мин — старый можно оставить
# в JWT_PREVIOUS_KEYS (через запятую), пока не истекут выданные им токены.
JWT_PRIVATE_KEY=
# Подпись ссылок на обложки и аудио (base64 от 32 байт); создают install.sh / update.sh.
STREAM_SIGNING_KEY=
#JWT_PREVIOUS_KEYS=
# --- Первый администратор (необязательно; иначе пароль генерируется и выводится в лог api один раз) ---
+2
View File
@@ -49,6 +49,8 @@ services:
MEILI_MASTER_KEY: dev-master-key-change-me
INTERNAL_TOKEN: dev-internal-token
COOKIE_SECURE: "false"
X_ACCEL: "false"
CACHE_DIR: /tmp/pipu-cache
LOG_LEVEL: debug
ports: ["127.0.0.1:8080:8080"]
depends_on:
+8 -5
View File
@@ -66,12 +66,15 @@ if [ ! -f .env ]; then
echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)"
fi
# Ключ подписи токенов (M1): создаётся один раз, в том числе в .env, сделанном до его появления.
if ! grep -q '^JWT_PRIVATE_KEY=.' .env; then
sed -i '/^JWT_PRIVATE_KEY=$/d' .env
echo "JWT_PRIVATE_KEY=$(head -c 32 /dev/urandom | base64)" >> .env
echo "→ в .env добавлен JWT_PRIVATE_KEY"
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
# в том числе в .env, сделанном до их появления.
for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
if ! grep -q "^$key=." .env; then
sed -i "/^$key=\$/d" .env
echo "$key=$(head -c 32 /dev/urandom | base64)" >> .env
echo "→ в .env добавлен $key"
fi
done
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
if [ -n "$LIBRARY_HOST" ]; then
+8 -5
View File
@@ -16,12 +16,15 @@ cd "$PIPU_DEPLOY_DIR"
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
# Ключ подписи токенов (M1): создаётся один раз, в том числе в .env, сделанном до его появления.
if ! grep -q '^JWT_PRIVATE_KEY=.' .env; then
sed -i '/^JWT_PRIVATE_KEY=$/d' .env
echo "JWT_PRIVATE_KEY=$(head -c 32 /dev/urandom | base64)" >> .env
echo "→ в .env добавлен JWT_PRIVATE_KEY"
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
# в том числе в .env, сделанном до их появления.
for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
if ! grep -q "^$key=." .env; then
sed -i "/^$key=\$/d" .env
echo "$key=$(head -c 32 /dev/urandom | base64)" >> .env
echo "→ в .env добавлен $key"
fi
done
REF="${1:-}"
git fetch --tags --prune --quiet origin
@@ -640,6 +640,27 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/covers/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Обложка альбома (WebP)
* @description Ссылку целиком даёт `cover_url` альбома (подписана, без срока: обложка с данным `v` неизменна);
* клиент добавляет `size`. Файл отдаёт nginx через X-Accel-Redirect.
*/
get: operations["getCover"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -2017,4 +2038,31 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
getCover: {
parameters: {
query: {
v: string;
sig: string;
size?: 64 | 300 | 1200;
};
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Картинка */
200: {
headers: {
[name: string]: unknown;
};
content: {
"image/webp": string;
};
};
default: components["responses"]["Problem"];
};
};
}
+17 -3
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
@@ -20,6 +21,7 @@ import (
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/db"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/platform/sign"
"pipu/api/internal/search"
"pipu/api/internal/server"
"pipu/api/internal/system"
@@ -90,7 +92,19 @@ func run() error {
prober, library.Config{LibraryRoot: cfg.LibraryRoot, HostRoot: cfg.LibraryHostRoot}, log)
meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey)
indexer := search.NewIndexer(pool, meili, log)
scanner.AfterScan = func(context.Context) { indexer.Trigger() }
signer, err := sign.New(cfg.SigningKey)
if err != nil {
return err
}
if cfg.SigningKey == "" {
log.Warn("STREAM_SIGNING_KEY is not set: cover and stream links will break on restart")
}
covers := library.NewCovers(libStore, libSvc, cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "covers"), log)
scanner.AfterScan = func(ctx context.Context) {
covers.Run(ctx)
indexer.Trigger()
}
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
libSvc.OnCatalogChanged = indexer.Trigger
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
go libSvc.Restore(ctx)
@@ -101,8 +115,8 @@ func run() error {
System: sys,
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
AuthService: authSvc,
Library: library.NewHandler(libSvc, libStore),
Search: search.NewHandler(meili, libStore, indexer, log),
Library: library.NewHandler(libSvc, libStore, signer, covers, cfg.XAccel),
Search: search.NewHandler(meili, libStore, indexer, library.DTO{Signer: signer}, log),
}))
log.Info("starting", "version", buildinfo.Version)
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
+297 -127
View File
@@ -10,6 +10,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
@@ -101,6 +102,13 @@ const (
Island UiPrefsWebPlayer = "island"
)
// Defines values for GetCoverParamsSize.
const (
N1200 GetCoverParamsSize = 1200
N300 GetCoverParamsSize = 300
N64 GetCoverParamsSize = 64
)
// Defines values for SearchParamsType.
const (
Albums SearchParamsType = "albums"
@@ -668,6 +676,16 @@ type RefreshTokensParams struct {
PipuRt *string `form:"pipu_rt,omitempty" json:"pipu_rt,omitempty"`
}
// GetCoverParams defines parameters for GetCover.
type GetCoverParams struct {
V string `form:"v" json:"v"`
Sig string `form:"sig" json:"sig"`
Size *GetCoverParamsSize `form:"size,omitempty" json:"size,omitempty"`
}
// GetCoverParamsSize defines parameters for GetCover.
type GetCoverParamsSize int
// SearchParams defines parameters for Search.
type SearchParams struct {
Q string `form:"q" json:"q"`
@@ -805,6 +823,9 @@ type ServerInterface interface {
// Ротация refresh-токена
// (POST /api/v1/auth/refresh)
RefreshTokens(w http.ResponseWriter, r *http.Request, params RefreshTokensParams)
// Обложка альбома (WebP)
// (GET /api/v1/covers/{id})
GetCover(w http.ResponseWriter, r *http.Request, id EntityId, params GetCoverParams)
// Текущий пользователь
// (GET /api/v1/me)
GetMe(w http.ResponseWriter, r *http.Request)
@@ -1009,6 +1030,12 @@ func (_ Unimplemented) RefreshTokens(w http.ResponseWriter, r *http.Request, par
w.WriteHeader(http.StatusNotImplemented)
}
// Обложка альбома (WebP)
// (GET /api/v1/covers/{id})
func (_ Unimplemented) GetCover(w http.ResponseWriter, r *http.Request, id EntityId, params GetCoverParams) {
w.WriteHeader(http.StatusNotImplemented)
}
// Текущий пользователь
// (GET /api/v1/me)
func (_ Unimplemented) GetMe(w http.ResponseWriter, r *http.Request) {
@@ -1884,6 +1911,72 @@ func (siw *ServerInterfaceWrapper) RefreshTokens(w http.ResponseWriter, r *http.
handler.ServeHTTP(w, r)
}
// GetCover operation middleware
func (siw *ServerInterfaceWrapper) GetCover(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "id" -------------
var id EntityId
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
// Parameter object where we will unmarshal all parameters from the context
var params GetCoverParams
// ------------- Required query parameter "v" -------------
if paramValue := r.URL.Query().Get("v"); paramValue != "" {
} else {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "v"})
return
}
err = runtime.BindQueryParameter("form", true, true, "v", r.URL.Query(), &params.V)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "v", Err: err})
return
}
// ------------- Required query parameter "sig" -------------
if paramValue := r.URL.Query().Get("sig"); paramValue != "" {
} else {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "sig"})
return
}
err = runtime.BindQueryParameter("form", true, true, "sig", r.URL.Query(), &params.Sig)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sig", Err: err})
return
}
// ------------- Optional query parameter "size" -------------
err = runtime.BindQueryParameter("form", true, false, "size", r.URL.Query(), &params.Size)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "size", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetCover(w, r, id, params)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetMe operation middleware
func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) {
@@ -2386,6 +2479,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/auth/refresh", wrapper.RefreshTokens)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/covers/{id}", wrapper.GetCover)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/me", wrapper.GetMe)
})
@@ -3207,6 +3303,46 @@ func (response RefreshTokensdefaultApplicationProblemPlusJSONResponse) VisitRefr
return json.NewEncoder(w).Encode(response.Body)
}
type GetCoverRequestObject struct {
Id EntityId `json:"id"`
Params GetCoverParams
}
type GetCoverResponseObject interface {
VisitGetCoverResponse(w http.ResponseWriter) error
}
type GetCover200ImagewebpResponse struct {
Body io.Reader
ContentLength int64
}
func (response GetCover200ImagewebpResponse) VisitGetCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "image/webp")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.ReadCloser); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
type GetCoverdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response GetCoverdefaultApplicationProblemPlusJSONResponse) VisitGetCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type GetMeRequestObject struct {
}
@@ -3617,6 +3753,9 @@ type StrictServerInterface interface {
// Ротация refresh-токена
// (POST /api/v1/auth/refresh)
RefreshTokens(ctx context.Context, request RefreshTokensRequestObject) (RefreshTokensResponseObject, error)
// Обложка альбома (WebP)
// (GET /api/v1/covers/{id})
GetCover(ctx context.Context, request GetCoverRequestObject) (GetCoverResponseObject, error)
// Текущий пользователь
// (GET /api/v1/me)
GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error)
@@ -4433,6 +4572,33 @@ func (sh *strictHandler) RefreshTokens(w http.ResponseWriter, r *http.Request, p
}
}
// GetCover operation middleware
func (sh *strictHandler) GetCover(w http.ResponseWriter, r *http.Request, id EntityId, params GetCoverParams) {
var request GetCoverRequestObject
request.Id = id
request.Params = params
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetCover(ctx, request.(GetCoverRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetCover")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetCoverResponseObject); ok {
if err := validResponse.VisitGetCoverResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// GetMe operation middleware
func (sh *strictHandler) GetMe(w http.ResponseWriter, r *http.Request) {
var request GetMeRequestObject
@@ -4753,133 +4919,137 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
}
// GetSwagger returns the content of the embedded swagger specification file
+3 -1
View File
@@ -87,7 +87,9 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
r.Body = io.NopCloser(strings.NewReader("{}"))
r.ContentLength = 2
}
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] {
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
cover := r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/covers/")
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
+202
View File
@@ -0,0 +1,202 @@
package library
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
// CoverSizes — размеры WebP-обложек (docs/library-and-storage.md#обложки).
var CoverSizes = []int{64, 300, 1200}
// maxCoverBytes — больше этого обложку из папки не читаем.
const maxCoverBytes = 20 << 20
// Имена файлов обложек в папке альбома по приоритету (без расширения).
var coverNames = []string{"cover", "folder", "front", "album", "albumart"}
var coverExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".webp": true}
// Covers ищет обложки новых альбомов: встроенная в файл → картинка в папке альбома.
// Готовит WebP 64/300/1200 в Dir и палитру из трёх цветов. Альбом без обложки отмечается
// проверенным — клиент рисует плейсхолдер.
type Covers struct {
store *Store
svc *Service
FFmpeg string
Dir string // /cache/covers
log *slog.Logger
}
// NewCovers создаёт обработчик обложек.
func NewCovers(store *Store, svc *Service, ffmpeg, dir string, log *slog.Logger) *Covers {
return &Covers{store: store, svc: svc, FFmpeg: ffmpeg, Dir: dir, log: log}
}
// CoverFile — путь к файлу обложки размера size.
func (c *Covers) CoverFile(coverPath string, size int) string {
return filepath.Join(c.Dir, fmt.Sprintf("%s-%d.webp", coverPath, size))
}
// Run обрабатывает все альбомы без проверенной обложки.
func (c *Covers) Run(ctx context.Context) {
if err := os.MkdirAll(c.Dir, 0o755); err != nil {
c.log.Error("covers: cache dir", "err", err)
return
}
pending, err := c.store.AlbumsWithoutCover(ctx, 500)
for err == nil && len(pending) > 0 {
for _, a := range pending {
if ctx.Err() != nil {
return
}
c.process(ctx, a)
}
pending, err = c.store.AlbumsWithoutCover(ctx, 500)
}
if err != nil {
c.log.Error("covers: list albums", "err", err)
}
}
func (c *Covers) process(ctx context.Context, a CoverCandidate) {
var (
coverPath *string
colors []string
)
img, err := c.find(ctx, a)
if err == nil && len(img) > 0 {
sum := sha256.Sum256(img)
key := hex.EncodeToString(sum[:8])
if colors, err = c.render(ctx, img, key); err != nil {
c.log.Warn("covers: render", "album_id", a.AlbumID, "err", err)
} else {
coverPath = &key
}
}
if err := c.store.SetAlbumCover(ctx, a.AlbumID, coverPath, colors); err != nil {
c.log.Error("covers: save", "album_id", a.AlbumID, "err", err)
}
}
// find возвращает байты исходной картинки или nil, если обложки нет.
func (c *Covers) find(ctx context.Context, a CoverCandidate) ([]byte, error) {
src, err := c.store.GetSource(ctx, a.SourceID)
if err != nil {
return nil, err
}
root, err := c.svc.Root(src)
if err != nil {
return nil, err
}
audio := filepath.Join(root, filepath.FromSlash(a.RelPath))
// Встроенная обложка: первый видеопоток (attached picture) одним кадром в JPEG.
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
out, err := exec.CommandContext(cctx, c.FFmpeg, "-v", "error", "-i", audio, "-an", "-map", "0:v:0",
"-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-").Output()
cancel()
if err == nil && len(out) > 0 {
return out, nil
}
entries, err := os.ReadDir(filepath.Dir(audio))
if err != nil {
return nil, err
}
for _, want := range coverNames {
for _, e := range entries {
name := e.Name()
ext := strings.ToLower(filepath.Ext(name))
if e.IsDir() || !coverExts[ext] || strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name))) != want {
continue
}
return readLimited(filepath.Join(filepath.Dir(audio), name))
}
}
return nil, nil
}
func readLimited(p string) ([]byte, error) {
f, err := os.Open(p)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
b, err := io.ReadAll(io.LimitReader(f, maxCoverBytes+1))
if err != nil {
return nil, err
}
if len(b) > maxCoverBytes {
return nil, errors.New("cover image is too large")
}
return b, nil
}
// render пишет WebP всех размеров (если их ещё нет — одинаковые обложки альбомов переиспользуются)
// и считает палитру.
func (c *Covers) render(ctx context.Context, img []byte, key string) ([]string, error) {
tmp, err := os.CreateTemp(c.Dir, ".src-*")
if err != nil {
return nil, err
}
defer func() { _ = os.Remove(tmp.Name()) }()
if _, err := tmp.Write(img); err != nil {
_ = tmp.Close()
return nil, err
}
if err := tmp.Close(); err != nil {
return nil, err
}
for _, size := range CoverSizes {
dst := c.CoverFile(key, size)
if _, err := os.Stat(dst); err == nil {
continue
}
part := dst + ".part"
filter := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d", size, size, size, size)
cctx, cancel := context.WithTimeout(ctx, time.Minute)
out, err := exec.CommandContext(cctx, c.FFmpeg, "-v", "error", "-y", "-i", tmp.Name(), "-vf", filter,
"-c:v", "libwebp", "-quality", "82", "-f", "webp", part).CombinedOutput()
cancel()
if err != nil {
_ = os.Remove(part)
return nil, fmt.Errorf("ffmpeg webp %d: %s", size, strings.TrimSpace(string(out)))
}
if err := os.Rename(part, dst); err != nil {
return nil, err
}
}
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var stderr bytes.Buffer
cmd := exec.CommandContext(cctx, c.FFmpeg, "-v", "error", "-i", tmp.Name(), "-vf", "scale=32:32",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-")
cmd.Stderr = &stderr
raw, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("ffmpeg palette: %s", strings.TrimSpace(stderr.String()))
}
return Palette(raw), nil
}
// CoverCandidate — альбом без проверенной обложки и файл, из которого её брать.
type CoverCandidate struct {
AlbumID uuid.UUID
SourceID uuid.UUID
RelPath string
}
+92 -15
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 {
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
}
+100
View File
@@ -0,0 +1,100 @@
package library
import (
"fmt"
"math"
"sort"
)
// Palette выбирает 3 цвета обложки по пикселям RGB (rgb24): доминирующий, второй и акцент —
// самый насыщенный из оставшихся. k-means с детерминированной инициализацией по яркости,
// чтобы одна и та же обложка всегда давала одну палитру.
func Palette(rgb []byte) []string {
n := len(rgb) / 3
if n == 0 {
return nil
}
px := make([][3]float64, n)
for i := range n {
px[i] = [3]float64{float64(rgb[3*i]), float64(rgb[3*i+1]), float64(rgb[3*i+2])}
}
const k = 4
// Начальные центры — квантили по яркости.
order := make([]int, n)
for i := range order {
order[i] = i
}
luma := func(p [3]float64) float64 { return 0.299*p[0] + 0.587*p[1] + 0.114*p[2] }
sort.SliceStable(order, func(a, b int) bool { return luma(px[order[a]]) < luma(px[order[b]]) })
centers := make([][3]float64, k)
for c := range k {
centers[c] = px[order[(2*c+1)*n/(2*k)]]
}
assign := make([]int, n)
for range 12 {
var sum [k][3]float64
var cnt [k]int
for i, p := range px {
best, bestD := 0, math.MaxFloat64
for c, ctr := range centers {
d := sq(p[0]-ctr[0]) + sq(p[1]-ctr[1]) + sq(p[2]-ctr[2])
if d < bestD {
best, bestD = c, d
}
}
assign[i] = best
cnt[best]++
for j := range 3 {
sum[best][j] += p[j]
}
}
for c := range k {
if cnt[c] > 0 {
for j := range 3 {
centers[c][j] = sum[c][j] / float64(cnt[c])
}
}
}
}
var size [k]int
for _, a := range assign {
size[a]++
}
idx := []int{}
for c := range k {
if size[c] > 0 {
idx = append(idx, c)
}
}
sort.SliceStable(idx, func(a, b int) bool { return size[idx[a]] > size[idx[b]] })
for len(idx) < 3 {
idx = append(idx, idx[len(idx)-1])
}
dominant, second, rest := idx[0], idx[1], idx[2:]
accent := rest[0]
for _, c := range rest {
if saturation(centers[c]) > saturation(centers[accent]) {
accent = c
}
}
return []string{hexColor(centers[dominant]), hexColor(centers[second]), hexColor(centers[accent])}
}
func sq(x float64) float64 { return x * x }
func saturation(p [3]float64) float64 {
hi := math.Max(p[0], math.Max(p[1], p[2]))
lo := math.Min(p[0], math.Min(p[1], p[2]))
if hi == 0 {
return 0
}
return (hi - lo) / hi
}
func hexColor(p [3]float64) string {
c := func(v float64) int { return int(math.Round(math.Max(0, math.Min(255, v)))) }
return fmt.Sprintf("#%02X%02X%02X", c(p[0]), c(p[1]), c(p[2]))
}
@@ -0,0 +1,27 @@
package library
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPalette(t *testing.T) {
// 60% тёмно-синего, 30% белого, 10% ярко-оранжевого: оранжевый — акцент, хотя его меньше всего.
var px []byte
add := func(n int, r, g, b byte) {
for range n {
px = append(px, r, g, b)
}
}
add(600, 20, 30, 90)
add(300, 250, 250, 250)
add(100, 250, 140, 20)
p := Palette(px)
assert.Equal(t, []string{"#141E5A", "#FAFAFA", "#FA8C14"}, p)
assert.Equal(t, p, Palette(px), "deterministic")
assert.Len(t, Palette([]byte{1, 2, 3}), 3, "single colour fills all three slots")
assert.Nil(t, Palette(nil))
}
+6
View File
@@ -178,6 +178,12 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
return fmt.Errorf("library: patterns: %w", err)
}
if full {
// Полный скан — повод поискать обложки заново: в папках могли появиться cover.jpg.
if err := sc.store.ResetCoverChecks(ctx, src.ID); err != nil {
return err
}
}
existing, err := sc.store.Files(ctx, src.ID)
if err != nil {
return err
+61
View File
@@ -513,3 +513,64 @@ func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]Fil
return e, r.Scan(&e.ID, &e.SourceID, &e.RelPath, &e.ScanError)
})
}
// AlbumsWithoutCover — альбомы, обложку которых ещё не искали, и доступный файл первого трека.
// Альбомы без доступных файлов пропускаются: искать не в чем.
func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int) ([]CoverCandidate, error) {
rows, err := st.pool.Query(ctx, `
SELECT al.id, f.source_id, f.rel_path
FROM albums al
CROSS JOIN LATERAL (
SELECT mf.source_id, mf.rel_path FROM tracks t
JOIN media_files mf ON mf.id = t.primary_file_id
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE t.album_id = al.id
ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST
LIMIT 1) f
WHERE al.cover_checked_at IS NULL
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("library: albums without cover: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (CoverCandidate, error) {
var c CoverCandidate
return c, r.Scan(&c.AlbumID, &c.SourceID, &c.RelPath)
})
}
// SetAlbumCover сохраняет найденную обложку (nil — обложки нет) и отмечает альбом проверенным.
func (st *Store) SetAlbumCover(ctx context.Context, albumID uuid.UUID, coverPath *string, colors []string) error {
_, err := st.pool.Exec(ctx, `
UPDATE albums SET cover_path = $2, cover_colors = $3, cover_checked_at = now() WHERE id = $1`,
albumID, coverPath, colors)
if err != nil {
return fmt.Errorf("library: set album cover: %w", err)
}
return nil
}
// ResetCoverChecks — найти обложки заново для альбомов источника (полный скан: могли появиться cover.jpg).
func (st *Store) ResetCoverChecks(ctx context.Context, sourceID uuid.UUID) error {
_, err := st.pool.Exec(ctx, `
UPDATE albums al SET cover_checked_at = NULL
WHERE al.cover_path IS NULL AND EXISTS (
SELECT 1 FROM tracks t JOIN media_files f ON f.track_id = t.id
WHERE t.album_id = al.id AND f.source_id = $1)`, sourceID)
if err != nil {
return fmt.Errorf("library: reset cover checks: %w", err)
}
return nil
}
// AlbumCoverPath — ключ обложки альбома (nil — обложки нет).
func (st *Store) AlbumCoverPath(ctx context.Context, albumID uuid.UUID) (*string, error) {
var p *string
err := st.pool.QueryRow(ctx, `SELECT cover_path FROM albums WHERE id = $1`, albumID).Scan(&p)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("library: album cover: %w", err)
}
return p, nil
}
@@ -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...)))
}
+5 -4
View File
@@ -24,6 +24,7 @@ const (
// Handler реализует search-часть gen.StrictServerInterface.
type Handler struct {
dto library.DTO
meili *Meili
catalog *library.Store
indexer *Indexer
@@ -31,8 +32,8 @@ type Handler struct {
}
// NewHandler создаёт обработчик поиска.
func NewHandler(meili *Meili, catalog *library.Store, indexer *Indexer, log *slog.Logger) *Handler {
return &Handler{meili: meili, catalog: catalog, indexer: indexer, log: log}
func NewHandler(meili *Meili, catalog *library.Store, indexer *Indexer, dto library.DTO, log *slog.Logger) *Handler {
return &Handler{meili: meili, catalog: catalog, indexer: indexer, dto: dto, log: log}
}
type limits struct{ artists, albums, tracks, offset int }
@@ -132,7 +133,7 @@ func (h *Handler) hydrate(ctx context.Context, f found, corrected string) (gen.S
return out, err
}
for _, a := range rows {
out.Albums = append(out.Albums, library.AlbumSummaryDTO(a))
out.Albums = append(out.Albums, h.dto.AlbumSummary(a))
}
}
if len(f.tracks) > 0 {
@@ -141,7 +142,7 @@ func (h *Handler) hydrate(ctx context.Context, f found, corrected string) (gen.S
return out, err
}
for _, t := range rows {
out.Tracks = append(out.Tracks, library.TrackDTO(t))
out.Tracks = append(out.Tracks, h.dto.Track(t))
}
}
return out, nil
@@ -0,0 +1,6 @@
-- +goose Up
-- Когда искали обложку альбома: NULL — ещё не искали (новый альбом или запрошен повторный поиск).
ALTER TABLE albums ADD COLUMN cover_checked_at timestamptz;
-- +goose Down
ALTER TABLE albums DROP COLUMN cover_checked_at;