From 3908814cda0544ddc75db9543205789a55a296ae Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:23:17 +0300 Subject: [PATCH] feat(api): My Wave sessions with a Go fallback and wave metrics (M4) - /wave/options, POST /wave/sessions (activity, mood, seed), /next with reactions, PATCH to switch the context on the fly, DELETE - the recommender is called with an 800 ms budget; on failure the api serves a fallback wave of likes, favourite artists and random available tracks with the same dislike / recent / served filters - every served track is an impression with its source and model; wave events (context_type wave) set its outcome, reactions win - library status shows the analysis queue and 7-day wave metrics: listen-through, early skip and like rate Co-Authored-By: Claude Opus 5.5 --- api/openapi.yaml | 216 +++- packages/api-client/src/generated/schema.ts | 254 +++++ services/api/cmd/api/main.go | 4 + services/api/internal/api/gen/api.gen.go | 932 +++++++++++++++--- services/api/internal/library/handler.go | 8 + services/api/internal/library/service.go | 16 +- services/api/internal/library/store.go | 49 + .../api/internal/platform/config/config.go | 2 + services/api/internal/playback/handler.go | 26 +- services/api/internal/playback/store.go | 14 + services/api/internal/server/router.go | 7 +- services/api/internal/wave/util.go | 16 + services/api/internal/wave/wave.go | 429 ++++++++ 13 files changed, 1811 insertions(+), 162 deletions(-) create mode 100644 services/api/internal/wave/util.go create mode 100644 services/api/internal/wave/wave.go diff --git a/api/openapi.yaml b/api/openapi.yaml index f92fcd2..a3b9f2f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -17,6 +17,7 @@ tags: - name: search - name: playback - name: collection + - name: wave security: - bearerAuth: [] paths: @@ -974,6 +975,93 @@ paths: description: Снято default: $ref: "#/components/responses/Problem" + /api/v1/wave/options: + get: + operationId: getWaveOptions + tags: [wave] + summary: Занятия и настроения (ключи для i18n и иконки) + responses: + "200": + description: Варианты + content: + application/json: + schema: + $ref: "#/components/schemas/WaveOptions" + default: + $ref: "#/components/responses/Problem" + /api/v1/wave/sessions: + post: + operationId: startWave + tags: [wave] + summary: Начать «Мою волну» + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/WaveStart" + responses: + "201": + description: Сессия и первая пачка + content: + application/json: + schema: + $ref: "#/components/schemas/WaveBatch" + default: + $ref: "#/components/responses/Problem" + /api/v1/wave/sessions/{id}: + parameters: + - $ref: "#/components/parameters/EntityId" + patch: + operationId: updateWave + tags: [wave] + summary: Сменить занятие / настроение на лету — пачка пересобирается + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WaveContext" + responses: + "200": + description: Новая пачка + content: + application/json: + schema: + $ref: "#/components/schemas/WaveBatch" + default: + $ref: "#/components/responses/Problem" + delete: + operationId: endWave + tags: [wave] + summary: Завершить сессию волны + responses: + "204": + description: Завершена + default: + $ref: "#/components/responses/Problem" + /api/v1/wave/sessions/{id}/next: + parameters: + - $ref: "#/components/parameters/EntityId" + post: + operationId: nextWave + tags: [wave] + summary: Следующая пачка с учётом реакций на выданные треки + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/WaveNext" + responses: + "200": + description: Пачка + content: + application/json: + schema: + $ref: "#/components/schemas/WaveBatch" + default: + $ref: "#/components/responses/Problem" components: parameters: SourceId: @@ -1566,8 +1654,12 @@ components: nullable: true LibraryStatus: type: object - required: [artists, albums, tracks, files, missing_files, file_errors, sources] + required: [artists, albums, tracks, files, missing_files, file_errors, sources, analysis, wave] properties: + analysis: + $ref: "#/components/schemas/AnalysisStatus" + wave: + $ref: "#/components/schemas/WaveMetrics" artists: type: integer albums: @@ -2022,6 +2114,128 @@ components: properties: reaction: $ref: "#/components/schemas/Reaction" + WaveOption: + type: object + required: [key, icon] + properties: + key: + type: string + example: running + icon: + type: string + description: Имя иконки Material Symbols + WaveOptions: + type: object + required: [activities, moods] + properties: + activities: + type: array + items: + $ref: "#/components/schemas/WaveOption" + moods: + type: array + items: + $ref: "#/components/schemas/WaveOption" + WaveContext: + type: object + properties: + activity: + type: string + nullable: true + mood: + type: string + nullable: true + WaveStart: + type: object + properties: + activity: + type: string + nullable: true + mood: + type: string + nullable: true + seed: + type: object + required: [type, id] + properties: + type: + type: string + enum: [track, album, artist] + id: + type: string + format: uuid + WaveFeedback: + type: object + required: [track_id, outcome] + properties: + track_id: + type: string + format: uuid + outcome: + type: string + enum: [complete, skip, skip_early, like, dislike] + WaveNext: + type: object + properties: + feedback: + type: array + maxItems: 50 + items: + $ref: "#/components/schemas/WaveFeedback" + WaveBatch: + type: object + required: [session_id, activity, mood, tracks, fallback] + properties: + session_id: + type: string + format: uuid + activity: + type: string + nullable: true + mood: + type: string + nullable: true + tracks: + type: array + items: + $ref: "#/components/schemas/Track" + fallback: + type: boolean + description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители) + AnalysisStatus: + type: object + required: [queued, running, done, failed, analyzer_ver] + properties: + queued: + type: integer + running: + type: integer + done: + type: integer + failed: + type: integer + analyzer_ver: + type: string + nullable: true + WaveMetrics: + type: object + required: [served, listen_through_rate, early_skip_rate, like_rate, sessions] + description: Метрики волны за 7 дней + properties: + served: + type: integer + listen_through_rate: + type: number + nullable: true + early_skip_rate: + type: number + nullable: true + like_rate: + type: number + nullable: true + description: Лайков на 100 треков + sessions: + type: integer Session: type: object required: [id, device_name, client_type, created_at, last_used_at, current] diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index 5644562..8099f2a 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -809,6 +809,79 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/wave/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Занятия и настроения (ключи для i18n и иконки) */ + get: operations["getWaveOptions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/wave/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Начать «Мою волну» */ + post: operations["startWave"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/wave/sessions/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Завершить сессию волны */ + delete: operations["endWave"]; + options?: never; + head?: never; + /** Сменить занятие / настроение на лету — пачка пересобирается */ + patch: operations["updateWave"]; + trace?: never; + }; + "/api/v1/wave/sessions/{id}/next": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Следующая пачка с учётом реакций на выданные треки */ + post: operations["nextWave"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1098,6 +1171,8 @@ export interface components { error: string | null; }; LibraryStatus: { + analysis: components["schemas"]["AnalysisStatus"]; + wave: components["schemas"]["WaveMetrics"]; artists: number; albums: number; tracks: number; @@ -1306,6 +1381,64 @@ export interface components { ReactionInput: { reaction: components["schemas"]["Reaction"]; }; + WaveOption: { + /** @example running */ + key: string; + /** @description Имя иконки Material Symbols */ + icon: string; + }; + WaveOptions: { + activities: components["schemas"]["WaveOption"][]; + moods: components["schemas"]["WaveOption"][]; + }; + WaveContext: { + activity?: string | null; + mood?: string | null; + }; + WaveStart: { + activity?: string | null; + mood?: string | null; + seed?: { + /** @enum {string} */ + type: "track" | "album" | "artist"; + /** Format: uuid */ + id: string; + }; + }; + WaveFeedback: { + /** Format: uuid */ + track_id: string; + /** @enum {string} */ + outcome: "complete" | "skip" | "skip_early" | "like" | "dislike"; + }; + WaveNext: { + feedback?: components["schemas"]["WaveFeedback"][]; + }; + WaveBatch: { + /** Format: uuid */ + session_id: string; + activity: string | null; + mood: string | null; + tracks: components["schemas"]["Track"][]; + /** @description Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители) */ + fallback: boolean; + }; + AnalysisStatus: { + queued: number; + running: number; + done: number; + failed: number; + analyzer_ver: string | null; + }; + /** @description Метрики волны за 7 дней */ + WaveMetrics: { + served: number; + listen_through_rate: number | null; + early_skip_rate: number | null; + /** @description Лайков на 100 треков */ + like_rate: number | null; + sessions: number; + }; Session: { /** Format: uuid */ id: string; @@ -2580,4 +2713,125 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + getWaveOptions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Варианты */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WaveOptions"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + startWave: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["WaveStart"]; + }; + }; + responses: { + /** @description Сессия и первая пачка */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WaveBatch"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + endWave: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Завершена */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + updateWave: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WaveContext"]; + }; + }; + responses: { + /** @description Новая пачка */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WaveBatch"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + nextWave: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["WaveNext"]; + }; + }; + responses: { + /** @description Пачка */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WaveBatch"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; } diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index f7b01ab..b0867d7 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -6,6 +6,7 @@ import ( "crypto/ed25519" "fmt" "log/slog" + "net/http" "os" "os/signal" "path/filepath" @@ -28,6 +29,7 @@ import ( "pipu/api/internal/search" "pipu/api/internal/server" "pipu/api/internal/system" + "pipu/api/internal/wave" ) func main() { @@ -140,6 +142,8 @@ func run() error { Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel), Search: search.NewHandler(meili, libStore, indexer, dto, log), Playback: pb, + Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken, + Client: &http.Client{}}, libStore, func() library.DTO { return dto }, 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 18dbb40..b0a2147 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -156,6 +156,22 @@ const ( Island UiPrefsWebPlayer = "island" ) +// Defines values for WaveFeedbackOutcome. +const ( + Complete WaveFeedbackOutcome = "complete" + Dislike WaveFeedbackOutcome = "dislike" + Like WaveFeedbackOutcome = "like" + Skip WaveFeedbackOutcome = "skip" + SkipEarly WaveFeedbackOutcome = "skip_early" +) + +// Defines values for WaveStartSeedType. +const ( + WaveStartSeedTypeAlbum WaveStartSeedType = "album" + WaveStartSeedTypeArtist WaveStartSeedType = "artist" + WaveStartSeedTypeTrack WaveStartSeedType = "track" +) + // Defines values for GetCoverParamsSize. const ( N1200 GetCoverParamsSize = 1200 @@ -228,6 +244,15 @@ type AlbumSummaryList struct { Items []AlbumSummary `json:"items"` } +// AnalysisStatus defines model for AnalysisStatus. +type AnalysisStatus struct { + AnalyzerVer *string `json:"analyzer_ver"` + Done int `json:"done"` + Failed int `json:"failed"` + Queued int `json:"queued"` + Running int `json:"running"` +} + // Artist defines model for Artist. type Artist struct { AlbumCount int `json:"album_count"` @@ -376,12 +401,16 @@ type LibraryErrorPage struct { // LibraryStatus defines model for LibraryStatus. type LibraryStatus struct { Albums int `json:"albums"` + Analysis AnalysisStatus `json:"analysis"` Artists int `json:"artists"` FileErrors int `json:"file_errors"` Files int `json:"files"` MissingFiles int `json:"missing_files"` Sources []SourceStatusItem `json:"sources"` Tracks int `json:"tracks"` + + // Wave Метрики волны за 7 дней + Wave WaveMetrics `json:"wave"` } // LoginRequest defines model for LoginRequest. @@ -807,6 +836,74 @@ type UpdateUserRequest struct { Role *Role `json:"role,omitempty"` } +// WaveBatch defines model for WaveBatch. +type WaveBatch struct { + Activity *string `json:"activity"` + + // Fallback Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители) + Fallback bool `json:"fallback"` + Mood *string `json:"mood"` + SessionId openapi_types.UUID `json:"session_id"` + Tracks []Track `json:"tracks"` +} + +// WaveContext defines model for WaveContext. +type WaveContext struct { + Activity *string `json:"activity"` + Mood *string `json:"mood"` +} + +// WaveFeedback defines model for WaveFeedback. +type WaveFeedback struct { + Outcome WaveFeedbackOutcome `json:"outcome"` + TrackId openapi_types.UUID `json:"track_id"` +} + +// WaveFeedbackOutcome defines model for WaveFeedback.Outcome. +type WaveFeedbackOutcome string + +// WaveMetrics Метрики волны за 7 дней +type WaveMetrics struct { + EarlySkipRate *float32 `json:"early_skip_rate"` + + // LikeRate Лайков на 100 треков + LikeRate *float32 `json:"like_rate"` + ListenThroughRate *float32 `json:"listen_through_rate"` + Served int `json:"served"` + Sessions int `json:"sessions"` +} + +// WaveNext defines model for WaveNext. +type WaveNext struct { + Feedback *[]WaveFeedback `json:"feedback,omitempty"` +} + +// WaveOption defines model for WaveOption. +type WaveOption struct { + // Icon Имя иконки Material Symbols + Icon string `json:"icon"` + Key string `json:"key"` +} + +// WaveOptions defines model for WaveOptions. +type WaveOptions struct { + Activities []WaveOption `json:"activities"` + Moods []WaveOption `json:"moods"` +} + +// WaveStart defines model for WaveStart. +type WaveStart struct { + Activity *string `json:"activity"` + Mood *string `json:"mood"` + Seed *struct { + Id openapi_types.UUID `json:"id"` + Type WaveStartSeedType `json:"type"` + } `json:"seed,omitempty"` +} + +// WaveStartSeedType defines model for WaveStart.Seed.Type. +type WaveStartSeedType string + // Cursor defines model for Cursor. type Cursor = string @@ -967,6 +1064,15 @@ type SetTrackReactionJSONRequestBody = ReactionInput // GetTracksBatchJSONRequestBody defines body for GetTracksBatch for application/json ContentType. type GetTracksBatchJSONRequestBody = TrackIds +// StartWaveJSONRequestBody defines body for StartWave for application/json ContentType. +type StartWaveJSONRequestBody = WaveStart + +// UpdateWaveJSONRequestBody defines body for UpdateWave for application/json ContentType. +type UpdateWaveJSONRequestBody = WaveContext + +// NextWaveJSONRequestBody defines body for NextWave for application/json ContentType. +type NextWaveJSONRequestBody = WaveNext + // ServerInterface represents all server handlers. type ServerInterface interface { // Журнал действий (admin), новые сверху @@ -1116,6 +1222,21 @@ type ServerInterface interface { // Трек // (GET /api/v1/tracks/{id}) GetTrack(w http.ResponseWriter, r *http.Request, id EntityId) + // Занятия и настроения (ключи для i18n и иконки) + // (GET /api/v1/wave/options) + GetWaveOptions(w http.ResponseWriter, r *http.Request) + // Начать «Мою волну» + // (POST /api/v1/wave/sessions) + StartWave(w http.ResponseWriter, r *http.Request) + // Завершить сессию волны + // (DELETE /api/v1/wave/sessions/{id}) + EndWave(w http.ResponseWriter, r *http.Request, id EntityId) + // Сменить занятие / настроение на лету — пачка пересобирается + // (PATCH /api/v1/wave/sessions/{id}) + UpdateWave(w http.ResponseWriter, r *http.Request, id EntityId) + // Следующая пачка с учётом реакций на выданные треки + // (POST /api/v1/wave/sessions/{id}/next) + NextWave(w http.ResponseWriter, r *http.Request, id EntityId) // Живость процесса // (GET /healthz) GetHealthz(w http.ResponseWriter, r *http.Request) @@ -1422,6 +1543,36 @@ func (_ Unimplemented) GetTrack(w http.ResponseWriter, r *http.Request, id Entit w.WriteHeader(http.StatusNotImplemented) } +// Занятия и настроения (ключи для i18n и иконки) +// (GET /api/v1/wave/options) +func (_ Unimplemented) GetWaveOptions(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Начать «Мою волну» +// (POST /api/v1/wave/sessions) +func (_ Unimplemented) StartWave(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Завершить сессию волны +// (DELETE /api/v1/wave/sessions/{id}) +func (_ Unimplemented) EndWave(w http.ResponseWriter, r *http.Request, id EntityId) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Сменить занятие / настроение на лету — пачка пересобирается +// (PATCH /api/v1/wave/sessions/{id}) +func (_ Unimplemented) UpdateWave(w http.ResponseWriter, r *http.Request, id EntityId) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Следующая пачка с учётом реакций на выданные треки +// (POST /api/v1/wave/sessions/{id}/next) +func (_ Unimplemented) NextWave(w http.ResponseWriter, r *http.Request, id EntityId) { + w.WriteHeader(http.StatusNotImplemented) +} + // Живость процесса // (GET /healthz) func (_ Unimplemented) GetHealthz(w http.ResponseWriter, r *http.Request) { @@ -2949,6 +3100,139 @@ func (siw *ServerInterfaceWrapper) GetTrack(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } +// GetWaveOptions operation middleware +func (siw *ServerInterfaceWrapper) GetWaveOptions(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetWaveOptions(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// StartWave operation middleware +func (siw *ServerInterfaceWrapper) StartWave(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StartWave(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// EndWave operation middleware +func (siw *ServerInterfaceWrapper) EndWave(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 + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.EndWave(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateWave operation middleware +func (siw *ServerInterfaceWrapper) UpdateWave(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 + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateWave(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// NextWave operation middleware +func (siw *ServerInterfaceWrapper) NextWave(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 + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.NextWave(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetHealthz operation middleware func (siw *ServerInterfaceWrapper) GetHealthz(w http.ResponseWriter, r *http.Request) { @@ -3237,6 +3521,21 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/tracks/{id}", wrapper.GetTrack) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/wave/options", wrapper.GetWaveOptions) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/wave/sessions", wrapper.StartWave) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/api/v1/wave/sessions/{id}", wrapper.EndWave) + }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/api/v1/wave/sessions/{id}", wrapper.UpdateWave) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/wave/sessions/{id}/next", wrapper.NextWave) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/healthz", wrapper.GetHealthz) }) @@ -4681,6 +4980,151 @@ func (response GetTrackdefaultApplicationProblemPlusJSONResponse) VisitGetTrackR return json.NewEncoder(w).Encode(response.Body) } +type GetWaveOptionsRequestObject struct { +} + +type GetWaveOptionsResponseObject interface { + VisitGetWaveOptionsResponse(w http.ResponseWriter) error +} + +type GetWaveOptions200JSONResponse WaveOptions + +func (response GetWaveOptions200JSONResponse) VisitGetWaveOptionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetWaveOptionsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response GetWaveOptionsdefaultApplicationProblemPlusJSONResponse) VisitGetWaveOptionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type StartWaveRequestObject struct { + Body *StartWaveJSONRequestBody +} + +type StartWaveResponseObject interface { + VisitStartWaveResponse(w http.ResponseWriter) error +} + +type StartWave201JSONResponse WaveBatch + +func (response StartWave201JSONResponse) VisitStartWaveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + + return json.NewEncoder(w).Encode(response) +} + +type StartWavedefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response StartWavedefaultApplicationProblemPlusJSONResponse) VisitStartWaveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type EndWaveRequestObject struct { + Id EntityId `json:"id"` +} + +type EndWaveResponseObject interface { + VisitEndWaveResponse(w http.ResponseWriter) error +} + +type EndWave204Response struct { +} + +func (response EndWave204Response) VisitEndWaveResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type EndWavedefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response EndWavedefaultApplicationProblemPlusJSONResponse) VisitEndWaveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type UpdateWaveRequestObject struct { + Id EntityId `json:"id"` + Body *UpdateWaveJSONRequestBody +} + +type UpdateWaveResponseObject interface { + VisitUpdateWaveResponse(w http.ResponseWriter) error +} + +type UpdateWave200JSONResponse WaveBatch + +func (response UpdateWave200JSONResponse) VisitUpdateWaveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateWavedefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response UpdateWavedefaultApplicationProblemPlusJSONResponse) VisitUpdateWaveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type NextWaveRequestObject struct { + Id EntityId `json:"id"` + Body *NextWaveJSONRequestBody +} + +type NextWaveResponseObject interface { + VisitNextWaveResponse(w http.ResponseWriter) error +} + +type NextWave200JSONResponse WaveBatch + +func (response NextWave200JSONResponse) VisitNextWaveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type NextWavedefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response NextWavedefaultApplicationProblemPlusJSONResponse) VisitNextWaveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type GetHealthzRequestObject struct { } @@ -4871,6 +5315,21 @@ type StrictServerInterface interface { // Трек // (GET /api/v1/tracks/{id}) GetTrack(ctx context.Context, request GetTrackRequestObject) (GetTrackResponseObject, error) + // Занятия и настроения (ключи для i18n и иконки) + // (GET /api/v1/wave/options) + GetWaveOptions(ctx context.Context, request GetWaveOptionsRequestObject) (GetWaveOptionsResponseObject, error) + // Начать «Мою волну» + // (POST /api/v1/wave/sessions) + StartWave(ctx context.Context, request StartWaveRequestObject) (StartWaveResponseObject, error) + // Завершить сессию волны + // (DELETE /api/v1/wave/sessions/{id}) + EndWave(ctx context.Context, request EndWaveRequestObject) (EndWaveResponseObject, error) + // Сменить занятие / настроение на лету — пачка пересобирается + // (PATCH /api/v1/wave/sessions/{id}) + UpdateWave(ctx context.Context, request UpdateWaveRequestObject) (UpdateWaveResponseObject, error) + // Следующая пачка с учётом реакций на выданные треки + // (POST /api/v1/wave/sessions/{id}/next) + NextWave(ctx context.Context, request NextWaveRequestObject) (NextWaveResponseObject, error) // Живость процесса // (GET /healthz) GetHealthz(ctx context.Context, request GetHealthzRequestObject) (GetHealthzResponseObject, error) @@ -6258,6 +6717,153 @@ func (sh *strictHandler) GetTrack(w http.ResponseWriter, r *http.Request, id Ent } } +// GetWaveOptions operation middleware +func (sh *strictHandler) GetWaveOptions(w http.ResponseWriter, r *http.Request) { + var request GetWaveOptionsRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetWaveOptions(ctx, request.(GetWaveOptionsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetWaveOptions") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetWaveOptionsResponseObject); ok { + if err := validResponse.VisitGetWaveOptionsResponse(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)) + } +} + +// StartWave operation middleware +func (sh *strictHandler) StartWave(w http.ResponseWriter, r *http.Request) { + var request StartWaveRequestObject + + var body StartWaveJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.StartWave(ctx, request.(StartWaveRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "StartWave") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(StartWaveResponseObject); ok { + if err := validResponse.VisitStartWaveResponse(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)) + } +} + +// EndWave operation middleware +func (sh *strictHandler) EndWave(w http.ResponseWriter, r *http.Request, id EntityId) { + var request EndWaveRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.EndWave(ctx, request.(EndWaveRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "EndWave") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(EndWaveResponseObject); ok { + if err := validResponse.VisitEndWaveResponse(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)) + } +} + +// UpdateWave operation middleware +func (sh *strictHandler) UpdateWave(w http.ResponseWriter, r *http.Request, id EntityId) { + var request UpdateWaveRequestObject + + request.Id = id + + var body UpdateWaveJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UpdateWave(ctx, request.(UpdateWaveRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateWave") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateWaveResponseObject); ok { + if err := validResponse.VisitUpdateWaveResponse(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)) + } +} + +// NextWave operation middleware +func (sh *strictHandler) NextWave(w http.ResponseWriter, r *http.Request, id EntityId) { + var request NextWaveRequestObject + + request.Id = id + + var body NextWaveJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.NextWave(ctx, request.(NextWaveRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "NextWave") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(NextWaveResponseObject); ok { + if err := validResponse.VisitNextWaveResponse(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)) + } +} + // GetHealthz operation middleware func (sh *strictHandler) GetHealthz(w http.ResponseWriter, r *http.Request) { var request GetHealthzRequestObject @@ -6309,162 +6915,176 @@ 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/library/handler.go b/services/api/internal/library/handler.go index 390b271..b09a91f 100644 --- a/services/api/internal/library/handler.go +++ b/services/api/internal/library/handler.go @@ -261,6 +261,14 @@ func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRe out := gen.GetLibraryStatus200JSONResponse{ Artists: int(c.Artists), Albums: int(c.Albums), Tracks: int(c.Tracks), Files: int(c.Files), MissingFiles: int(c.Missing), FileErrors: int(c.Errors), Sources: []gen.SourceStatusItem{}, + Analysis: gen.AnalysisStatus{ + Queued: st.Analysis.Queued, Running: st.Analysis.Running, Done: st.Analysis.Done, + Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer, + }, + Wave: gen.WaveMetrics{ + Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough, + EarlySkipRate: st.Wave.EarlySkip, LikeRate: st.Wave.LikeRate, + }, } for _, s := range st.Sources { dto := h.sourceDTO(s) diff --git a/services/api/internal/library/service.go b/services/api/internal/library/service.go index 37d2fcf..13af5ad 100644 --- a/services/api/internal/library/service.go +++ b/services/api/internal/library/service.go @@ -576,8 +576,10 @@ func (s *Service) startScan(src Source, full bool) error { // Status — состояние библиотеки (admin, moderator). type Status struct { - Counts Counts - Sources []Source + Counts Counts + Sources []Source + Analysis AnalysisCounts + Wave WaveMetrics } // Status собирает счётчики и состояние источников. @@ -593,7 +595,15 @@ func (s *Service) Status(ctx context.Context) (Status, error) { if err != nil { return Status{}, err } - return Status{Counts: c, Sources: sources}, nil + analysis, err := s.store.AnalysisCounts(ctx) + if err != nil { + return Status{}, err + } + wave, err := s.store.WaveMetrics(ctx) + if err != nil { + return Status{}, err + } + return Status{Counts: c, Sources: sources, Analysis: analysis, Wave: wave}, nil } // FileErrors — файлы с ошибками чтения. diff --git a/services/api/internal/library/store.go b/services/api/internal/library/store.go index 3d81be5..beacb86 100644 --- a/services/api/internal/library/store.go +++ b/services/api/internal/library/store.go @@ -574,3 +574,52 @@ func (st *Store) AlbumCoverPath(ctx context.Context, albumID uuid.UUID) (*string } return p, nil } + +// AnalysisCounts — состояние очереди аудиоанализа. +type AnalysisCounts struct { + Queued, Running, Done, Failed int + AnalyzerVer *string +} + +// AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора. +func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) { + var c AnalysisCounts + err := st.pool.QueryRow(ctx, ` + SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'), + count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'), + (SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1) + FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer) + if err != nil { + return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err) + } + return c, nil +} + +// WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех). +type WaveMetrics struct { + Served, Sessions int + ListenThrough, EarlySkip, LikeRate *float32 +} + +// WaveMetrics считает метрики по показам с известным исходом. +func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) { + var ( + m WaveMetrics + judged, through, early, liked int + ) + err := st.pool.QueryRow(ctx, ` + SELECT count(*), count(DISTINCT wave_session_id), count(outcome), + count(*) FILTER (WHERE outcome IN ('complete', 'like')), + count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')), + count(*) FILTER (WHERE outcome = 'like') + FROM wave_impressions WHERE served_at > now() - interval '7 days'`). + Scan(&m.Served, &m.Sessions, &judged, &through, &early, &liked) + if err != nil { + return WaveMetrics{}, fmt.Errorf("library: wave metrics: %w", err) + } + if judged > 0 { + lt, es, lr := float32(through)/float32(judged), float32(early)/float32(judged), float32(liked)*100/float32(judged) + m.ListenThrough, m.EarlySkip, m.LikeRate = <, &es, &lr + } + return m, nil +} diff --git a/services/api/internal/platform/config/config.go b/services/api/internal/platform/config/config.go index 5adcbc5..55e04bb 100644 --- a/services/api/internal/platform/config/config.go +++ b/services/api/internal/platform/config/config.go @@ -50,6 +50,8 @@ type API struct { TranscodeConcurrency int `env:"TRANSCODE_CONCURRENCY" envDefault:"0"` TranscodeCacheLimitGB int `env:"TRANSCODE_CACHE_LIMIT_GB" envDefault:"50"` + RecommenderURL string `env:"RECOMMENDER_URL" envDefault:"http://recommender:8000"` + MeiliURL string `env:"MEILI_URL" envDefault:"http://meilisearch:7700"` MeiliMasterKey string `env:"MEILI_MASTER_KEY"` } diff --git a/services/api/internal/playback/handler.go b/services/api/internal/playback/handler.go index a3a320c..7d581c3 100644 --- a/services/api/internal/playback/handler.go +++ b/services/api/internal/playback/handler.go @@ -268,11 +268,20 @@ func (h *Handler) PostEvents(ctx context.Context, req gen.PostEventsRequestObjec if e.PlayedMs != nil { played = *e.PlayedMs } - if o := Judge(ev.Type, played, dur); o != (Outcome{}) { + o := Judge(ev.Type, played, dur) + if o != (Outcome{}) { if err := h.store.ApplyOutcome(ctx, tx, p.UserID, ev, o); err != nil { return nil, err } } + if outcome := impressionOutcome(ev.Type, o); outcome != "" && ev.ContextType != nil && *ev.ContextType == "wave" && + ev.ContextID != nil { + if sid, err := uuid.Parse(*ev.ContextID); err == nil { + if err := h.store.MarkImpression(ctx, tx, sid, ev.TrackID, outcome); err != nil { + return nil, err + } + } + } } if err := tx.Commit(ctx); err != nil { return nil, err @@ -551,3 +560,18 @@ func (h *Handler) ClearTrackReaction(ctx context.Context, req gen.ClearTrackReac } var _ library.Personalizer = (*Handler)(nil) + +// impressionOutcome — исход показа волны по событию: complete | skip_early | skip | like | dislike. +func impressionOutcome(eventType string, o Outcome) string { + switch { + case eventType == "like" || eventType == "dislike": + return eventType + case o.Complete: + return "complete" + case o.EarlySkip: + return "skip_early" + case o.Skip: + return "skip" + } + return "" +} diff --git a/services/api/internal/playback/store.go b/services/api/internal/playback/store.go index 2d1b5c3..2e22522 100644 --- a/services/api/internal/playback/store.go +++ b/services/api/internal/playback/store.go @@ -348,3 +348,17 @@ func (st *Store) PutState(ctx context.Context, userID uuid.UUID, s State) (State // Begin открывает транзакцию. func (st *Store) Begin(ctx context.Context) (pgx.Tx, error) { return st.pool.Begin(ctx) } + +// MarkImpression записывает исход показа трека в сессии волны (для метрик и обучения). +// Реакция (like / dislike) важнее исхода прослушивания и перезаписывает его. +func (st *Store) MarkImpression(ctx context.Context, tx pgx.Tx, sessionID, trackID uuid.UUID, outcome string) error { + _, err := tx.Exec(ctx, ` + UPDATE wave_impressions SET outcome = $3 + WHERE wave_session_id = $1 AND track_id = $2 + AND (outcome IS NULL OR $3 IN ('like', 'dislike') OR outcome NOT IN ('like', 'dislike'))`, + sessionID, trackID, outcome) + if err != nil { + return fmt.Errorf("playback: impression: %w", err) + } + return nil +} diff --git a/services/api/internal/server/router.go b/services/api/internal/server/router.go index abf93fc..93919fe 100644 --- a/services/api/internal/server/router.go +++ b/services/api/internal/server/router.go @@ -15,6 +15,7 @@ import ( "pipu/api/internal/playback" "pipu/api/internal/search" "pipu/api/internal/system" + "pipu/api/internal/wave" ) // Deps — модули, реализующие части контракта API. @@ -27,6 +28,8 @@ type Deps struct { Search *search.Handler // Playback — стрим, события, история, реакции, состояние плеера. Playback *playback.Handler + // Wave — «Моя волна». + Wave *wave.Handler // AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов. AuthService *auth.Service } @@ -40,6 +43,7 @@ type ( libraryHandler = library.Handler searchHandler = search.Handler playbackHandler = playback.Handler + waveHandler = wave.Handler ) type handlers struct { @@ -48,6 +52,7 @@ type handlers struct { *libraryHandler *searchHandler *playbackHandler + *waveHandler } var _ gen.StrictServerInterface = handlers{} @@ -67,7 +72,7 @@ func NewRouter(d Deps) http.Handler { r.NotFound(httpx.NotFound) r.MethodNotAllowed(httpx.MethodNotAllowed) - strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback}, + strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave}, []gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{ RequestErrorHandlerFunc: httpx.RequestError, ResponseErrorHandlerFunc: httpx.ResponseError(log), diff --git a/services/api/internal/wave/util.go b/services/api/internal/wave/util.go new file mode 100644 index 0000000..1907993 --- /dev/null +++ b/services/api/internal/wave/util.go @@ -0,0 +1,16 @@ +package wave + +import ( + "math/rand/v2" + "sort" +) + +func shuffle[T any](in []T) []T { + out := append([]T(nil), in...) + rand.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] }) + return out +} + +func stableSort[T any](s []T, less func(a, b T) bool) { + sort.SliceStable(s, func(i, j int) bool { return less(s[i], s[j]) }) +} diff --git a/services/api/internal/wave/wave.go b/services/api/internal/wave/wave.go new file mode 100644 index 0000000..991aae7 --- /dev/null +++ b/services/api/internal/wave/wave.go @@ -0,0 +1,429 @@ +// Package wave — «Моя волна»: сессии, вызов рекомендателя и запасная волна на Go (docs/recommendations.md). +package wave + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "pipu/api/internal/api/gen" + "pipu/api/internal/auth" + "pipu/api/internal/library" + "pipu/api/internal/platform/apperr" +) + +// BatchSize — треков в пачке волны. +const BatchSize = 5 + +// RecommenderTimeout — сколько ждать рекомендатель, прежде чем отдать запасную волну. +const RecommenderTimeout = 800 * time.Millisecond + +// Занятия и настроения — ключи совпадают с contexts.yaml рекомендателя, тексты — в i18n клиента. +var ( + activities = []gen.WaveOption{ + {Key: "running", Icon: "directions_run"}, {Key: "workout", Icon: "fitness_center"}, + {Key: "waking_up", Icon: "wb_sunny"}, {Key: "focus", Icon: "headphones"}, + {Key: "commute", Icon: "directions_car"}, {Key: "chill", Icon: "weekend"}, {Key: "sleep", Icon: "bedtime"}, + } + moods = []gen.WaveOption{ + {Key: "energetic", Icon: "bolt"}, {Key: "happy", Icon: "sentiment_satisfied"}, + {Key: "calm", Icon: "spa"}, {Key: "sad", Icon: "rainy"}, + } +) + +func known(list []gen.WaveOption, key *string) bool { + if key == nil || *key == "" { + return true + } + for _, o := range list { + if o.Key == *key { + return true + } + } + return false +} + +// Pick — трек, выбранный рекомендателем. +type Pick struct { + ID string `json:"id"` + Source string `json:"source"` + Score float64 `json:"score"` +} + +// Feedback — реакция на выданный трек. +type Feedback struct { + TrackID string `json:"track_id"` + Outcome string `json:"outcome"` +} + +// BatchRequest — запрос к рекомендателю. +type BatchRequest struct { + UserID string `json:"user_id"` + SessionID string `json:"session_id"` + Activity *string `json:"activity"` + Mood *string `json:"mood"` + Seed *Seed `json:"seed,omitempty"` + ExcludeIDs []string `json:"exclude_ids"` + Feedback []Feedback `json:"feedback"` + N int `json:"n"` +} + +// Seed — от чего запущена волна. +type Seed struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// Recommender — клиент внутреннего API рекомендаций. +type Recommender struct { + URL string + Token string + Client *http.Client +} + +// Batch запрашивает пачку. +func (r *Recommender) Batch(ctx context.Context, req BatchRequest) ([]Pick, string, error) { + raw, err := json.Marshal(req) + if err != nil { + return nil, "", err + } + ctx, cancel := context.WithTimeout(ctx, RecommenderTimeout) + defer cancel() + hr, err := http.NewRequestWithContext(ctx, http.MethodPost, r.URL+"/internal/wave/batch", bytes.NewReader(raw)) + if err != nil { + return nil, "", err + } + hr.Header.Set("Authorization", "Bearer "+r.Token) + hr.Header.Set("Content-Type", "application/json") + res, err := r.Client.Do(hr) + if err != nil { + return nil, "", fmt.Errorf("recommender: %w", err) + } + defer func() { _ = res.Body.Close() }() + if res.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("recommender: status %d", res.StatusCode) + } + var out struct { + Tracks []Pick `json:"tracks"` + ModelVer string `json:"model_ver"` + } + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + return nil, "", fmt.Errorf("recommender: %w", err) + } + return out.Tracks, out.ModelVer, nil +} + +// session — строка wave_sessions. +type session struct { + ID uuid.UUID + UserID uuid.UUID + Activity *string + Mood *string + SeedType *string + SeedID *uuid.UUID + Served int + Ended bool +} + +// Handler реализует wave-часть gen.StrictServerInterface. +type Handler struct { + pool *pgxpool.Pool + rec *Recommender + catalog *library.Store + dto func() library.DTO + log *slog.Logger +} + +// NewHandler создаёт обработчик. +func NewHandler(pool *pgxpool.Pool, rec *Recommender, catalog *library.Store, dto func() library.DTO, log *slog.Logger) *Handler { + return &Handler{pool: pool, rec: rec, catalog: catalog, dto: dto, log: log} +} + +func principal(ctx context.Context) (auth.Principal, error) { + p, ok := auth.PrincipalFrom(ctx) + if !ok { + return auth.Principal{}, apperr.Unauthorized(auth.CodeUnauthorized, "not authenticated") + } + return p, nil +} + +// GetWaveOptions реализует gen.StrictServerInterface. +func (h *Handler) GetWaveOptions(context.Context, gen.GetWaveOptionsRequestObject) (gen.GetWaveOptionsResponseObject, error) { + return gen.GetWaveOptions200JSONResponse{Activities: activities, Moods: moods}, nil +} + +func validateContext(activity, mood *string) error { + if !known(activities, activity) { + return apperr.Validation("activity", "validation.wave_activity_invalid", "unknown activity", nil) + } + if !known(moods, mood) { + return apperr.Validation("mood", "validation.wave_mood_invalid", "unknown mood", nil) + } + return nil +} + +func emptyToNil(s *string) *string { + if s == nil || *s == "" { + return nil + } + return s +} + +// StartWave реализует gen.StrictServerInterface. +func (h *Handler) StartWave(ctx context.Context, req gen.StartWaveRequestObject) (gen.StartWaveResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + s := session{ID: uuid.Must(uuid.NewV7()), UserID: p.UserID} + if b := req.Body; b != nil { + s.Activity, s.Mood = emptyToNil(b.Activity), emptyToNil(b.Mood) + if b.Seed != nil { + t, id := string(b.Seed.Type), b.Seed.Id + s.SeedType, s.SeedID = &t, &id + } + } + if err := validateContext(s.Activity, s.Mood); err != nil { + return nil, err + } + if _, err := h.pool.Exec(ctx, ` + INSERT INTO wave_sessions (id, user_id, activity, mood, seed_type, seed_id) VALUES ($1, $2, $3, $4, $5, $6)`, + s.ID, s.UserID, s.Activity, s.Mood, s.SeedType, s.SeedID); err != nil { + return nil, fmt.Errorf("wave: start: %w", err) + } + out, err := h.serve(ctx, s, nil) + if err != nil { + return nil, err + } + return gen.StartWave201JSONResponse(out), nil +} + +func (h *Handler) load(ctx context.Context, p auth.Principal, id uuid.UUID) (session, error) { + var s session + err := h.pool.QueryRow(ctx, ` + SELECT id, user_id, activity, mood, seed_type, seed_id, tracks_served, ended_at IS NOT NULL + FROM wave_sessions WHERE id = $1 AND user_id = $2`, id, p.UserID). + Scan(&s.ID, &s.UserID, &s.Activity, &s.Mood, &s.SeedType, &s.SeedID, &s.Served, &s.Ended) + if errors.Is(err, pgx.ErrNoRows) { + return s, apperr.NotFound("wave.session_not_found", "wave session not found") + } + if err != nil { + return s, fmt.Errorf("wave: load: %w", err) + } + return s, nil +} + +// NextWave реализует gen.StrictServerInterface. +func (h *Handler) NextWave(ctx context.Context, req gen.NextWaveRequestObject) (gen.NextWaveResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + s, err := h.load(ctx, p, req.Id) + if err != nil { + return nil, err + } + var fb []Feedback + if req.Body != nil && req.Body.Feedback != nil { + for _, f := range *req.Body.Feedback { + fb = append(fb, Feedback{TrackID: f.TrackId.String(), Outcome: string(f.Outcome)}) + } + } + out, err := h.serve(ctx, s, fb) + if err != nil { + return nil, err + } + return gen.NextWave200JSONResponse(out), nil +} + +// UpdateWave реализует gen.StrictServerInterface. +func (h *Handler) UpdateWave(ctx context.Context, req gen.UpdateWaveRequestObject) (gen.UpdateWaveResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + s, err := h.load(ctx, p, req.Id) + if err != nil { + return nil, err + } + s.Activity, s.Mood = emptyToNil(req.Body.Activity), emptyToNil(req.Body.Mood) + if err := validateContext(s.Activity, s.Mood); err != nil { + return nil, err + } + if _, err := h.pool.Exec(ctx, `UPDATE wave_sessions SET activity = $2, mood = $3 WHERE id = $1`, + s.ID, s.Activity, s.Mood); err != nil { + return nil, fmt.Errorf("wave: update: %w", err) + } + out, err := h.serve(ctx, s, nil) + if err != nil { + return nil, err + } + return gen.UpdateWave200JSONResponse(out), nil +} + +// EndWave реализует gen.StrictServerInterface. +func (h *Handler) EndWave(ctx context.Context, req gen.EndWaveRequestObject) (gen.EndWaveResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + if _, err := h.pool.Exec(ctx, `UPDATE wave_sessions SET ended_at = now() WHERE id = $1 AND user_id = $2 AND ended_at IS NULL`, + req.Id, p.UserID); err != nil { + return nil, fmt.Errorf("wave: end: %w", err) + } + return gen.EndWave204Response{}, nil +} + +// serve собирает пачку (рекомендатель или запасная волна), пишет показы и отдаёт треки. +func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.WaveBatch, error) { + served, err := h.servedIDs(ctx, s.ID) + if err != nil { + return gen.WaveBatch{}, err + } + req := BatchRequest{ + UserID: s.UserID.String(), SessionID: s.ID.String(), Activity: s.Activity, Mood: s.Mood, + ExcludeIDs: served, Feedback: fb, N: BatchSize, + } + if s.SeedType != nil && s.SeedID != nil { + req.Seed = &Seed{Type: *s.SeedType, ID: s.SeedID.String()} + } + if req.Feedback == nil { + req.Feedback = []Feedback{} + } + picks, modelVer, err := h.rec.Batch(ctx, req) + fallback := false + if err != nil || len(picks) == 0 { + if err != nil { + h.log.WarnContext(ctx, "recommender unavailable, serving fallback wave", "err", err) + } + fallback, modelVer = true, "fallback" + if picks, err = h.fallback(ctx, s, served); err != nil { + return gen.WaveBatch{}, err + } + } + + ids := make([]uuid.UUID, 0, len(picks)) + for _, p := range picks { + if id, err := uuid.Parse(p.ID); err == nil { + ids = append(ids, id) + } + } + rows, err := h.catalog.TracksByIDs(ctx, ids) + if err != nil { + return gen.WaveBatch{}, err + } + tracks, err := h.dto().Tracks(ctx, rows) + if err != nil { + return gen.WaveBatch{}, err + } + if err := h.record(ctx, s, picks, modelVer); err != nil { + return gen.WaveBatch{}, err + } + return gen.WaveBatch{SessionId: s.ID, Activity: s.Activity, Mood: s.Mood, Tracks: tracks, Fallback: fallback}, nil +} + +func (h *Handler) servedIDs(ctx context.Context, sessionID uuid.UUID) ([]string, error) { + rows, err := h.pool.Query(ctx, `SELECT track_id::text FROM wave_impressions WHERE wave_session_id = $1`, sessionID) + if err != nil { + return nil, fmt.Errorf("wave: served: %w", err) + } + ids, err := pgx.CollectRows(rows, pgx.RowTo[string]) + if ids == nil { + ids = []string{} + } + return ids, err +} + +// record пишет показы (для метрик и будущего обучения ранжировщика). +func (h *Handler) record(ctx context.Context, s session, picks []Pick, modelVer string) error { + return pgx.BeginFunc(ctx, h.pool, func(tx pgx.Tx) error { + var pos int + if err := tx.QueryRow(ctx, ` + UPDATE wave_sessions SET tracks_served = tracks_served + $2 WHERE id = $1 RETURNING tracks_served - $2`, + s.ID, len(picks)).Scan(&pos); err != nil { + return fmt.Errorf("wave: impressions: %w", err) + } + for i, p := range picks { + if _, err := tx.Exec(ctx, ` + INSERT INTO wave_impressions (wave_session_id, position, track_id, candidate_source, score, model_ver) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING`, + s.ID, pos+i, p.ID, p.Source, p.Score, modelVer); err != nil { + return fmt.Errorf("wave: impressions: %w", err) + } + } + return nil + }) +} + +// fallback — запасная волна без рекомендателя: лайки, любимые исполнители и случайные доступные треки, +// без дизлайков, недавно игравших и уже выданных; один трек исполнителя на пачку. +func (h *Handler) fallback(ctx context.Context, s session, served []string) ([]Pick, error) { + rows, err := h.pool.Query(ctx, ` + WITH liked AS ( + SELECT track_id FROM track_reactions WHERE user_id = $1 AND reaction = 'like' + ), fav_artists AS ( + SELECT DISTINCT ta.artist_id FROM track_artists ta JOIN liked l ON l.track_id = ta.track_id + ), pool AS ( + SELECT t.id, + COALESCE((SELECT ta.artist_id FROM track_artists ta WHERE ta.track_id = t.id ORDER BY ta.position LIMIT 1), + al.album_artist_id) AS artist_id, + CASE WHEN t.id IN (SELECT track_id FROM liked) THEN 'liked' + WHEN EXISTS (SELECT 1 FROM track_artists ta WHERE ta.track_id = t.id + AND ta.artist_id IN (SELECT artist_id FROM fav_artists)) THEN 'artist' + ELSE 'random' END AS source + FROM tracks t + JOIN albums al ON al.id = t.album_id + JOIN media_files f ON f.id = t.primary_file_id AND f.missing_since IS NULL + JOIN library_sources src ON src.id = f.source_id AND src.status = 'mounted' + WHERE NOT EXISTS (SELECT 1 FROM track_reactions r WHERE r.user_id = $1 AND r.track_id = t.id AND r.reaction = 'dislike') + AND NOT (t.id::text = ANY($2)) + AND NOT EXISTS (SELECT 1 FROM play_events e WHERE e.user_id = $1 AND e.track_id = t.id + AND e.type = 'play_start' AND e.occurred_at > now() - interval '3 hours') + ) + SELECT DISTINCT ON (artist_id) id::text, source FROM ( + SELECT * FROM pool ORDER BY CASE source WHEN 'liked' THEN 0 WHEN 'artist' THEN 1 ELSE 2 END, random() LIMIT 200 + ) c + ORDER BY artist_id, random() + LIMIT 50`, s.UserID, served) + if err != nil { + return nil, fmt.Errorf("wave: fallback: %w", err) + } + picks, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (Pick, error) { + var p Pick + return p, r.Scan(&p.ID, &p.Source) + }) + if err != nil { + return nil, fmt.Errorf("wave: fallback: %w", err) + } + // DISTINCT ON упорядочил по исполнителю — перемешиваем, предпочитая лайки и любимых исполнителей. + rank := map[string]int{"liked": 0, "artist": 1, "random": 2} + shuffled := shuffle(picks) + stableSort(shuffled, func(a, b Pick) bool { return rank[a.Source] < rank[b.Source] }) + // Половина пачки — любимое, половина — случайное: запасная волна не должна быть одними лайками. + var out []Pick + var fav, rnd []Pick + for _, p := range shuffled { + if p.Source == "random" { + rnd = append(rnd, p) + } else { + fav = append(fav, p) + } + } + for len(out) < BatchSize && (len(fav) > 0 || len(rnd) > 0) { + if len(fav) > 0 && (len(out)%2 == 0 || len(rnd) == 0) { + out, fav = append(out, fav[0]), fav[1:] + } else if len(rnd) > 0 { + out, rnd = append(out, rnd[0]), rnd[1:] + } + } + return out, nil +}