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
+984
-162
No files matched your search
@@ -34,3 +34,4 @@ Thumbs.db
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.cache/
|
||||
@@ -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`):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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 один раз) ---
|
||||
|
||||
@@ -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
@@ -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
@@ -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"];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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...)))
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user