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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", } // 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 +// (обложки в , стрим в