diff --git a/.gitignore b/.gitignore
index 4a54af7..223099f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,4 @@ Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json
+.cache/
diff --git a/README.md b/README.md
index 4e91b95..909ee5f 100644
--- a/README.md
+++ b/README.md
@@ -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`):
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 2738860..26c6aff 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -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:
diff --git a/apps/web/src/entities/catalog.tsx b/apps/web/src/entities/catalog.tsx
index ba11ede..f5b75cd 100644
--- a/apps/web/src/entities/catalog.tsx
+++ b/apps/web/src/entities/catalog.tsx
@@ -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 ? (
-
+
) : (
)}
diff --git a/deploy/.env.example b/deploy/.env.example
index 920ca2f..1635db2 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -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 один раз) ---
diff --git a/deploy/compose.dev.yaml b/deploy/compose.dev.yaml
index 9d4f2d4..ea5681f 100644
--- a/deploy/compose.dev.yaml
+++ b/deploy/compose.dev.yaml
@@ -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:
diff --git a/deploy/install.sh b/deploy/install.sh
index 567d339..4aa9f4f 100755
--- a/deploy/install.sh
+++ b/deploy/install.sh
@@ -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"
-fi
+# Ключи подписи (токены — 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
diff --git a/deploy/update.sh b/deploy/update.sh
index b35042c..e1e7c4b 100755
--- a/deploy/update.sh
+++ b/deploy/update.sh
@@ -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"
-fi
+# Ключи подписи (токены — 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
diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts
index 86c35f4..5366606 100644
--- a/packages/api-client/src/generated/schema.ts
+++ b/packages/api-client/src/generated/schema.ts
@@ -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;
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"];
+ };
+ };
}
diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go
index 526e76a..36558ac 100644
--- a/services/api/cmd/api/main.go
+++ b/services/api/cmd/api/main.go
@@ -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)
diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go
index bbad1fc..feed4d1 100644
--- a/services/api/internal/api/gen/api.gen.go
+++ b/services/api/internal/api/gen/api.gen.go
@@ -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(), ¶ms.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(), ¶ms.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(), ¶ms.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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",
+ "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",
}
// GetSwagger returns the content of the embedded swagger specification file
diff --git a/services/api/internal/auth/middleware.go b/services/api/internal/auth/middleware.go
index 657fd60..c1558ac 100644
--- a/services/api/internal/auth/middleware.go
+++ b/services/api/internal/auth/middleware.go
@@ -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] {
+ // Обложки открывает
без заголовка 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
}
diff --git a/services/api/internal/library/covers.go b/services/api/internal/library/covers.go
new file mode 100644
index 0000000..73c6b1d
--- /dev/null
+++ b/services/api/internal/library/covers.go
@@ -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
+}
diff --git a/services/api/internal/library/handler.go b/services/api/internal/library/handler.go
index 1a847e4..c070637 100644
--- a/services/api/internal/library/handler.go
+++ b/services/api/internal/library/handler.go
@@ -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
}
diff --git a/services/api/internal/library/palette.go b/services/api/internal/library/palette.go
new file mode 100644
index 0000000..56b7f67
--- /dev/null
+++ b/services/api/internal/library/palette.go
@@ -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]))
+}
diff --git a/services/api/internal/library/palette_test.go b/services/api/internal/library/palette_test.go
new file mode 100644
index 0000000..479dbdb
--- /dev/null
+++ b/services/api/internal/library/palette_test.go
@@ -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))
+}
diff --git a/services/api/internal/library/scanner.go b/services/api/internal/library/scanner.go
index a7b4730..b0193e3 100644
--- a/services/api/internal/library/scanner.go
+++ b/services/api/internal/library/scanner.go
@@ -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
diff --git a/services/api/internal/library/store.go b/services/api/internal/library/store.go
index e6dc4d7..3d81be5 100644
--- a/services/api/internal/library/store.go
+++ b/services/api/internal/library/store.go
@@ -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
+}
diff --git a/services/api/internal/platform/config/config.go b/services/api/internal/platform/config/config.go
index a5a7767..ac723c8 100644
--- a/services/api/internal/platform/config/config.go
+++ b/services/api/internal/platform/config/config.go
@@ -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"`
}
diff --git a/services/api/internal/platform/sign/sign.go b/services/api/internal/platform/sign/sign.go
new file mode 100644
index 0000000..a9828f9
--- /dev/null
+++ b/services/api/internal/platform/sign/sign.go
@@ -0,0 +1,46 @@
+// Package sign — HMAC-подписи ссылок, которые клиент открывает без заголовка Authorization
+// (обложки в
, стрим в