From 9f3127f54a801a2426af4627d5455ab5be910057 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 14 Oct 2026 08:42:19 -0500 Subject: [PATCH 1/2] Personal discovery: Want list, sets that learn, rooms as a source (MAD-824..826) - Want list: a thumbs-up or "Want" on a release keeps it in want_items until it turns up in the library. library.Service's Built hook runs CheckWants after every index build, marking matches got (auto) and counting what landed. Sets report what they yielded (wanted, got). - Sets that learn: thumbs-down lower an artist in that set's feed; two thumbs-up suggest adding an artist (or add them, with grow_auto); three thumbs-down suggest blocking. Dismissed suggestions are remembered. Starred import lists Navidrome stars via a new provider.Starrer, and only the picked artists are added. - Room taste: a room's or a past night's favorite artists to pick from, and a live link with a weight that the feed walks from. Read only: dj.Peek reads the DJ's rows without folding nights. Migration 00032; ADR 0017 updated. Co-Authored-By: Claude Opus 5.5 --- api/openapi.yaml | 453 +++++- docs/adr/0017-personal-discovery.md | 36 +- server/cmd/syncphony/main.go | 15 +- server/internal/discover/discover.go | 95 +- server/internal/discover/discover_test.go | 4 +- server/internal/discover/feed.go | 12 +- server/internal/discover/learning.go | 334 ++++ server/internal/discover/map_test.go | 2 +- server/internal/discover/rooms.go | 273 ++++ server/internal/discover/stage4_test.go | 381 +++++ server/internal/discover/want.go | 191 +++ server/internal/dj/memory.go | 33 +- server/internal/httpapi/accounts_test.go | 1 + server/internal/httpapi/api.gen.go | 1337 ++++++++++++++++- server/internal/httpapi/discover.go | 184 ++- server/internal/httpapi/discover_test.go | 93 +- server/internal/httpapi/server.go | 1 + server/internal/library/library.go | 55 + server/internal/library/library_test.go | 47 + server/internal/provider/navidrome/catalog.go | 5 +- server/internal/provider/navidrome/library.go | 19 + server/internal/provider/session.go | 15 + server/internal/store/discovery.sql.gen.go | 262 +++- .../migrations/00032_discovery_learning.sql | 41 + server/internal/store/models.gen.go | 30 +- server/internal/store/nights.sql.gen.go | 27 + server/internal/store/queries/discovery.sql | 42 +- server/internal/store/queries/nights.sql | 3 + 28 files changed, 3834 insertions(+), 157 deletions(-) create mode 100644 server/internal/discover/learning.go create mode 100644 server/internal/discover/rooms.go create mode 100644 server/internal/discover/stage4_test.go create mode 100644 server/internal/discover/want.go create mode 100644 server/internal/store/migrations/00032_discovery_learning.sql diff --git a/api/openapi.yaml b/api/openapi.yaml index b182b38..0d07860 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3209,6 +3209,209 @@ paths: default: $ref: "#/components/responses/Error" + /me/discover/sets/{setId}/suggestions: + parameters: + - $ref: "#/components/parameters/DiscoverySetId" + get: + operationId: getDiscoverySuggestions + summary: What a set's thumbs suggest + description: | + Artists you thumbed up twice from the set's feed, to add to it, and + artists you thumbed down three times in any feed, to block. Ones in + the set, blocked, or that you said no to are left out. + tags: [discover] + responses: + "200": + description: Suggestions + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoverySuggestions" + default: + $ref: "#/components/responses/Error" + + /me/discover/sets/{setId}/suggestions/dismiss: + parameters: + - $ref: "#/components/parameters/DiscoverySetId" + post: + operationId: dismissDiscoverySuggestion + summary: Say no to a suggestion + description: It isn't made again. A block suggestion is dismissed for every set. + tags: [discover] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DismissDiscoverySuggestionRequest" + responses: + "204": + description: Dismissed + default: + $ref: "#/components/responses/Error" + + /me/discover/sets/{setId}/import: + parameters: + - $ref: "#/components/parameters/DiscoverySetId" + post: + operationId: importDiscoverySetArtists + summary: Add artists you picked to a set at once + description: | + From a suggestion, your Navidrome stars, or a room's taste, noted as + coming from there. Artists in the set already are skipped. More than + the set has room for is `too_many`, and adds none. + tags: [discover] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ImportDiscoveryArtistsRequest" + responses: + "200": + description: The set + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoverySet" + default: + $ref: "#/components/responses/Error" + + /me/discover/starred: + get: + operationId: listDiscoveryStarred + summary: Artists you starred on your Navidrome, to pick seeds from + description: | + Starred artists, and the artists of starred albums and songs, from + your own links: starred artists first. Only a list to pick from; + nothing joins a set unless you pick it. + tags: [discover] + responses: + "200": + description: Artists + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DiscoveryStarredArtist" + default: + $ref: "#/components/responses/Error" + + /me/discover/room-taste: + get: + operationId: getDiscoveryRoomTaste + summary: A room's favorite artists, to make a set from + description: | + The room's taste as its DJ reads it, over past nights and lately, + or one past night's (`nightId`). Only rooms you can open. It only + reads the room. + tags: [discover] + parameters: + - name: roomId + in: query + required: true + schema: + type: string + - name: nightId + in: query + description: One of the room's past nights. + schema: + type: string + - name: mine + in: query + description: Only from songs you picked. + schema: + type: boolean + responses: + "200": + description: Artists, most liked first + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DiscoveryTasteArtist" + default: + $ref: "#/components/responses/Error" + + /me/discover/want: + get: + operationId: listDiscoveryWants + summary: Your Want list + description: | + Songs and releases you liked in Discover, newest first: wanted, + got (they turned up in your library, or you said so) and + dismissed. `landed` is how many turned up since you last looked. + tags: [discover] + responses: + "200": + description: The list + content: + application/json: + schema: + $ref: "#/components/schemas/WantList" + default: + $ref: "#/components/responses/Error" + post: + operationId: addDiscoveryWant + summary: Want a song or release + description: Wanting one that's on the list already returns it; one dismissed is wanted again. + tags: [discover] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AddWantRequest" + responses: + "200": + description: It, on the list + content: + application/json: + schema: + $ref: "#/components/schemas/WantItem" + default: + $ref: "#/components/responses/Error" + + /me/discover/want/seen: + post: + operationId: seeDiscoveryLanded + summary: You saw what landed in your library + tags: [discover] + responses: + "204": + description: Noted + default: + $ref: "#/components/responses/Error" + + /me/discover/want/{wantId}: + parameters: + - name: wantId + in: path + required: true + schema: + type: string + patch: + operationId: updateDiscoveryWant + summary: Mark a thing wanted, got or dismissed + tags: [discover] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateWantRequest" + responses: + "200": + description: It + content: + application/json: + schema: + $ref: "#/components/schemas/WantItem" + default: + $ref: "#/components/responses/Error" + /me/discover/release-tracks: get: operationId: listDiscoveryReleaseTracks @@ -3291,8 +3494,11 @@ paths: operationId: postDiscoveryFeedback summary: Thumbs up or down on a song description: | - A thumbs-down hides the song from all your feeds, for good; a later - thumbs-up replaces it. Thumbs here never touch a room's DJ. + A thumbs-down hides the song from all your feeds, for good, and + lowers its artist in the set's feed; a later thumbs-up replaces it. + A thumbs-up puts the song on your Want list, and in a set that + grows by itself, adds an artist you thumbed up twice from its feed. + Thumbs here never touch a room's DJ. tags: [discover] requestBody: required: true @@ -3301,8 +3507,12 @@ paths: schema: $ref: "#/components/schemas/DiscoveryFeedbackRequest" responses: - "204": - description: Kept + "200": + description: Kept, and what it did + content: + application/json: + schema: + $ref: "#/components/schemas/DiscoveryThumbResult" default: $ref: "#/components/responses/Error" @@ -8615,7 +8825,7 @@ components: DiscoverySetSummary: type: object - required: [id, name, explore, mode, freshDays, artistCount, createdAt, updatedAt] + required: [id, name, explore, mode, freshDays, artistCount, wanted, got, createdAt, updatedAt] properties: id: type: string @@ -8631,6 +8841,15 @@ components: $ref: "#/components/schemas/DiscoveryFreshDays" artistCount: type: integer + wanted: + type: integer + description: Songs and releases wanted from its feeds (not dismissed). + got: + type: integer + description: Of those, how many you got. + roomId: + type: string + description: The room whose taste it mixes in, if linked. createdAt: type: string format: date-time @@ -8640,7 +8859,7 @@ components: DiscoverySet: type: object - required: [id, name, explore, mode, freshDays, artists, createdAt, updatedAt] + required: [id, name, explore, mode, freshDays, growAuto, roomWeight, artists, createdAt, updatedAt] properties: id: type: string @@ -8655,6 +8874,14 @@ components: $ref: "#/components/schemas/DiscoveryMode" freshDays: $ref: "#/components/schemas/DiscoveryFreshDays" + growAuto: + type: boolean + description: Artists you thumb up twice from its feed join it without asking. + roomId: + type: string + description: A room whose taste its feed mixes in (read only), if linked. + roomWeight: + $ref: "#/components/schemas/DiscoveryRoomWeight" artists: type: array items: @@ -8724,6 +8951,20 @@ components: $ref: "#/components/schemas/DiscoveryMode" freshDays: $ref: "#/components/schemas/DiscoveryFreshDays" + growAuto: + type: boolean + roomId: + type: string + description: Link a room you can open, or "" to unlink. + maxLength: 64 + roomWeight: + $ref: "#/components/schemas/DiscoveryRoomWeight" + + DiscoveryRoomWeight: + type: string + description: How much a linked room's taste counts against the set's own artists. + enum: [low, medium, high] + x-enum-varnames: [RoomWeightLow, RoomWeightMedium, RoomWeightHigh] DiscoveryFreshDays: type: integer @@ -8995,6 +9236,206 @@ components: deezerId: type: integer format: int64 + album: + type: string + description: Its album, for the Want list. + cover: + type: string + description: Its Deezer cover, for the Want list. + + DiscoveryThumbResult: + type: object + properties: + want: + $ref: "#/components/schemas/WantItem" + grew: + type: string + description: The artist who joined the set by themselves, if any. + + DiscoveryOffer: + type: object + required: [key, name, thumbs] + properties: + key: + type: string + name: + type: string + thumbs: + type: integer + + DiscoverySuggestions: + type: object + required: [grow, block] + properties: + grow: + type: array + description: Artists to add to the set. + items: + $ref: "#/components/schemas/DiscoveryOffer" + block: + type: array + description: Artists to block. + items: + $ref: "#/components/schemas/DiscoveryOffer" + + DismissDiscoverySuggestionRequest: + type: object + required: [kind, artist] + properties: + kind: + type: string + enum: [grow, block] + x-enum-varnames: [SuggestGrow, SuggestBlock] + artist: + type: string + minLength: 1 + maxLength: 200 + + ImportDiscoveryArtistsRequest: + type: object + required: [from, artists] + properties: + from: + type: string + enum: [manual, thumb, starred, room] + x-enum-varnames: [ImportManual, ImportThumb, ImportStarred, ImportRoom] + artists: + type: array + maxItems: 50 + items: + $ref: "#/components/schemas/DiscoverySeed" + + DiscoveryStarredArtist: + type: object + required: [name, artist, albums, songs] + properties: + name: + type: string + artist: + type: boolean + description: You starred the artist. + albums: + type: integer + description: How many of their albums you starred. + songs: + type: integer + description: How many of their songs you starred. + + DiscoveryTasteArtist: + type: object + required: [name, weight, plays] + properties: + name: + type: string + weight: + type: number + description: How much the room likes them, 0 to 1 against its favorite. + plays: + type: integer + description: Their songs played lately, or that night. + + WantItem: + type: object + required: [id, kind, artist, title, status, addedAt] + properties: + id: + type: string + kind: + $ref: "#/components/schemas/WantKind" + artist: + type: string + title: + type: string + description: The song's title, or the release's. + release: + type: string + description: A song's album, if known. + mbid: + type: string + description: A song's recording, or a release's release group. + isrc: + type: string + deezerId: + type: integer + format: int64 + description: The song or release on Deezer, for a preview or its songs. + cover: + type: string + description: Its Deezer cover's image ID. + setId: + type: string + description: The set whose feed it came from, if it's still there. + status: + $ref: "#/components/schemas/WantStatus" + gotAt: + type: string + format: date-time + gotBy: + type: string + enum: [auto, manual] + x-enum-varnames: [WantGotAuto, WantGotManual] + description: It turned up in your library (auto), or you said you got it. + addedAt: + type: string + format: date-time + + WantList: + type: object + required: [items, landed] + properties: + items: + type: array + items: + $ref: "#/components/schemas/WantItem" + landed: + type: integer + description: How many turned up in your library since you last looked. + + AddWantRequest: + type: object + required: [kind, artist, title] + properties: + kind: + $ref: "#/components/schemas/WantKind" + artist: + type: string + minLength: 1 + maxLength: 200 + title: + type: string + minLength: 1 + maxLength: 300 + release: + type: string + maxLength: 300 + mbid: + type: string + isrc: + type: string + deezerId: + type: integer + format: int64 + cover: + type: string + setId: + type: string + + UpdateWantRequest: + type: object + required: [status] + properties: + status: + $ref: "#/components/schemas/WantStatus" + + WantStatus: + type: string + enum: [wanted, got, dismissed] + x-enum-varnames: [WantWanted, WantGot, WantDismissed] + + WantKind: + type: string + enum: [track, release] + x-enum-varnames: [WantTrack, WantRelease] DiscoveryBlock: type: object diff --git a/docs/adr/0017-personal-discovery.md b/docs/adr/0017-personal-discovery.md index a7ebd56..f143b84 100644 --- a/docs/adr/0017-personal-discovery.md +++ b/docs/adr/0017-personal-discovery.md @@ -2,11 +2,11 @@ - **Status:** accepted - **Date:** 2026-10-13 -- **Issues:** MAD-814 (Phase 12), stage 1: MAD-815..819, stage 2: MAD-820, 821, stage 3: MAD-822, 823 +- **Issues:** MAD-814 (Phase 12), stage 1: MAD-815..819, stage 2: MAD-820, 821, stage 3: MAD-822, 823, stage 4: MAD-824..826 ## Context -Phase 12 adds a way to find music you **don't have yet**, the way Google Play Music's "new releases for you" did. Everything stays on the server. You hear a 30-second preview, follow a link out to YouTube for the whole song, and later (stage 4) keep what you like on a Want list until it turns up in your library. +Phase 12 adds a way to find music you **don't have yet**, the way Google Play Music's "new releases for you" did. Everything stays on the server. You hear a 30-second preview, follow a link out to YouTube for the whole song, and keep what you like on a Want list until it turns up in your library. The obvious seed is your library. It's the wrong one. A Navidrome library holds plenty of music its owner doesn't love, so recommending from it recommends the wrong things. Syncphony's own history is still short, too. @@ -81,9 +81,39 @@ A release card shows its cover (Deezer's, else the Cover Art Archive's through ` The unread dot on the Discover tab, for releases you haven't seen yet, isn't built. Knowing what's unseen would mean working out every set's Fresh feed for the tab bar. +### The Want list + +A thumbs-up on a song, or **Want** on a release, puts it on the Want list (`want_items`), noting the set whose feed it came from (MAD-824). Wanted things are left out of every feed and Fresh feed. Syncphony never gets them for you. You follow the YouTube link, add what you like to your Navidrome, and Syncphony notices. + +- **Automatic got-it.** `library.Service` calls its `Built` hook after every index build of a link, with the link's user. That includes a build skipped because Navidrome hadn't rescanned, since something wanted since then may already be there. `discover.CheckWants` then matches each wanted thing against the user's index: a song with `Has` (ISRC or MBID first, then artist and title), a release with `HasRelease` (artist and title, so a deluxe edition counts). A match is marked `got`, `got_by = auto`, with `got_at`. Discover then says "3 wanted things landed in your library" until you open the Want list (`got_seen`). Marking something got yourself (`got_by = manual`) doesn't count as landing. +- **The screen** (`/discover/want`) groups by artist, with Wanted, Got and Dismissed. A wanted song plays its preview and links to YouTube, and anything can be marked got or dismissed. Got releases link to the album in Syncphony (the linked-names lookup, ADR 0013 and MAD-837), and got songs to a song search, to queue or play right away. There are no store links. Wanting a dismissed thing again brings it back. +- **What a set yields.** The set list counts what each set's feeds led you to want, and how much of that you got (`wanted`, `got` on `listDiscoverySets`). That shows which sets pay off. The counts follow `want_items.set_id`, which is cleared when a set is deleted. + +Migration 00032 adds `want_items.cover`, `got_by` and `got_seen`. + +### Sets that learn + +Thumbs still never reach a room (MAD-825). Within discovery, they now do three things: + +- **Growing.** An artist you thumbed up twice from a set's feed is offered for the set ("You liked 2 songs by Failure. Add them?"). A set with **Grow by itself** on (`grow_auto`, off by default) adds them at the second thumbs-up, and the thumb's answer says who joined (`grew`). Either way they're stored with `added_from = thumb`. Offers you turn down are kept in `discovery_dismissed` and aren't made again. +- **Thumbs-down.** Each thumbs-down from a set's feed lowers that artist's songs in that feed by 0.6×, so three take them to about a fifth. That happens when the feed is next built, within 10 minutes or on refresh, not on every thumb, because a rebuild mid-scroll would restart the pages. The song turned down still leaves every feed at once. Three thumbs-down for one artist, in any feeds, offers to block them. +- **Not for me** is the global block list. It and the library's status now live on a Discover settings screen (`/discover/settings`), where you can unblock. +- **Starred import** is a one-time, opt-in pick list. `GET /me/discover/starred` lists the artists you starred on your own Navidrome links, and the artists of the albums and songs you starred, through a new optional `provider.Starrer` (Navidrome's `getStarred2`). Starred artists come first, then by how much of theirs you starred. Only the ones you check are added (`importDiscoverySetArtists`, `added_from = starred`). It's never automatic: starring something doesn't make it a set's taste. + +`importDiscoverySetArtists` adds the artists you picked at once, from a suggestion, your stars or a room. Ones in the set already are skipped. More than the set has room for is `too_many`, and adds none. + +### A room's taste as a source + +A set can draw on a room you can open (`rooms.CanEnter`: open rooms, yours, or ones you're a member of) in two ways (MAD-826): + +- **Snapshot.** `GET /me/discover/room-taste` lists a room's favorite artists, or one past night's (`nightId`), optionally from only your own picks (`mine`). You check the ones to keep, into a set or a new one, with `added_from = room`. A room's taste is its long-term taste as the DJ keeps it (`dj.Peek`) merged with a profile of its latest 200 plays (`dj.NewProfile`), whichever likes an artist more. Artists the room turned away lately are left out. A night's taste is that night's plays alone (`dj.NightInput`). With `mine`, the long-term rows are narrowed to your own (`dj_affinities.member`), and the profile to your share of each artist (`ByMember`). +- **Live.** A set can link to one room (`discovery_sets.room_id`) with a weight: low 0.3, medium 0.55 or high 0.85. Building the feed then walks out from the room's 12 favorite artists that aren't seeds, as from seeds that pull that much less, times how much the room likes each one. As the room's taste moves, so does the feed, at its next build. A set linked to a room can have no seeds of its own. Fresh feeds and maps still follow only the seeds. + +Both are read only. `dj.Peek` reads the DJ's stored rows without folding in nights that ended since, which `Memory.Load` would do, so discovery never writes a room's DJ memory. Room skips don't affect a set. A room you can no longer open is dropped from the walk, and `room_id` is cleared if the room is deleted. + ### Feedback stays out of rooms -Discovery thumbs and blocks never reach a room's DJ (`dj_affinity`), and a room's skips never touch a set. Stage 1 stores thumbs-up. Stage 4 turns them into Want-list entries and growing sets. +Discovery thumbs and blocks never reach a room's DJ (`dj_affinity`), and a room's skips never touch a set. Reading a room's taste for a set doesn't change it either. ## Consequences diff --git a/server/cmd/syncphony/main.go b/server/cmd/syncphony/main.go index b249622..73a53ef 100644 --- a/server/cmd/syncphony/main.go +++ b/server/cmd/syncphony/main.go @@ -382,13 +382,24 @@ func run() error { } // Personal discovery (ADR 0017): seed sets, minus what's in your library. lib := library.New(db, a.links) - go lib.Run(ctx) api.Library = lib api.Discover = discover.New(db) - api.Discover.Library = lib + api.Discover.Library, api.Discover.Stars = lib, lib + // Each index built marks what its user wanted and now has as got + // (MAD-824). + lib.Built = func(ctx context.Context, userID string) { + if _, err := api.Discover.CheckWants(ctx, userID); err != nil && ctx.Err() == nil { + slog.Warn("discover: checking the Want list", "user", userID, "err", err) + } + } + go lib.Run(ctx) + // A room's taste as a set's source (MAD-826): read only. + roomTaste := &discover.RoomSource{DB: db, CanEnter: roomSvc.CanEnter} if len(graph.Sources()) > 0 { api.Discover.Graph = graph + roomTaste.Graph = graph } + api.Discover.Rooms = roomTaste if deezer != nil { api.Deezer, api.Discover.Deezer = deezer, deezer } else { diff --git a/server/internal/discover/discover.go b/server/internal/discover/discover.go index db7e369..d5f3b24 100644 --- a/server/internal/discover/discover.go +++ b/server/internal/discover/discover.go @@ -105,6 +105,12 @@ type Service struct { // Releases finds artists' recent releases, for Fresh feeds. Nil // makes every Fresh feed empty. Releases Releases + // Stars lists what users starred on their own links, to pick seeds + // from. Nil lists nothing. + Stars Stars + // Rooms reads rooms' taste, for sets made from one or linked to one. + // Nil means no room can be a source. + Rooms RoomTaste // Now is the clock. Default store.Now. Now func() time.Time // BuildFor bounds building a feed; what's found by then is the feed. @@ -237,6 +243,12 @@ type Changes struct { Explore *int Mode *string FreshDays *int + // GrowAuto is whether artists thumbed up twice join by themselves. + GrowAuto *bool + // RoomID links the set to a room whose taste it mixes in; "" unlinks + // it. RoomWeight is how much: RoomLow, RoomMedium or RoomHigh. + RoomID *string + RoomWeight *string } // Update renames a set, or changes its explore, mode or Fresh window. @@ -246,7 +258,8 @@ func (s *Service) Update(ctx context.Context, userID, setID string, c Changes) ( return Set{}, err } p := store.UpdateDiscoverySetParams{ - ID: setID, UserID: userID, Name: set.Name, Explore: set.Explore, Mode: set.Mode, FreshDays: set.FreshDays, UpdatedAt: s.Now(), + ID: setID, UserID: userID, Name: set.Name, Explore: set.Explore, Mode: set.Mode, FreshDays: set.FreshDays, + GrowAuto: set.GrowAuto, RoomID: set.RoomID, RoomWeight: set.RoomWeight, UpdatedAt: s.Now(), } if c.Name != nil { if p.Name, err = cleanName(*c.Name); err != nil { @@ -271,6 +284,29 @@ func (s *Service) Update(ctx context.Context, userID, setID string, c Changes) ( } p.FreshDays = int64(*c.FreshDays) } + if c.GrowAuto != nil { + p.GrowAuto = 0 + if *c.GrowAuto { + p.GrowAuto = 1 + } + } + if c.RoomWeight != nil { + if !slices.Contains([]string{RoomLow, RoomMedium, RoomHigh}, *c.RoomWeight) { + return Set{}, &InvalidInputError{"a room's weight is low, medium or high"} + } + p.RoomWeight = *c.RoomWeight + } + if c.RoomID != nil { + p.RoomID = sql.NullString{String: *c.RoomID, Valid: *c.RoomID != ""} + if p.RoomID.Valid { + if s.Rooms == nil { + return Set{}, &InvalidInputError{"rooms can't be a set's source on this server"} + } + if err := s.Rooms.MayRead(ctx, userID, *c.RoomID); err != nil { + return Set{}, err + } + } + } if err := s.db.UpdateDiscoverySet(ctx, p); err != nil { return Set{}, err } @@ -310,13 +346,17 @@ func (s *Service) AddArtist(ctx context.Context, userID, setID string, a Seed) ( } func (s *Service) addArtist(ctx context.Context, setID string, a Seed, from string) error { + return s.addArtistWith(ctx, s.db.Queries, setID, a, from) +} + +func (s *Service) addArtistWith(ctx context.Context, q *store.Queries, setID string, a Seed, from string) error { name := strings.Join(strings.Fields(a.Name), " ") key := ArtistKey(name, a.MBID) if name == "" || key == "" || utf8.RuneCountInString(name) > MaxName*2 { return &InvalidInputError{"an artist's name is needed"} } mbid := strings.ToLower(strings.TrimSpace(a.MBID)) - return s.db.AddDiscoverySetArtist(ctx, store.AddDiscoverySetArtistParams{ + return q.AddDiscoverySetArtist(ctx, store.AddDiscoverySetArtistParams{ SetID: setID, ArtistKey: key, Name: name, Mbid: sql.NullString{String: mbid, Valid: mbid != ""}, AddedFrom: from, AddedAt: s.Now(), }) @@ -348,26 +388,61 @@ func (s *Service) touch(ctx context.Context, setID string) error { // Song is a song given a thumb. type Song struct { Title, Artist string - MBID, ISRC string - DeezerID int64 + // Album and Cover are its album's title and Deezer cover, if known, + // for the Want list. + Album, Cover string + MBID, ISRC string + DeezerID int64 +} + +// Thumbed is what a thumb did besides being kept. +type Thumbed struct { + // Want is the song on the Want list, for a thumbs-up. + Want *store.WantItem + // Grew is the artist who joined the set, if the set grows by itself + // and this was their second thumbs-up in it. + Grew string } // Thumb records a thumbs-up or -down on a song, from a set's feed if // setID is given. A thumbs-down hides the song from every one of the -// user's feeds, for good; a later thumbs-up replaces it. -func (s *Service) Thumb(ctx context.Context, userID, setID, kind string, song Song) error { +// user's feeds, for good, and lowers its artist in that set's feed; a +// later thumbs-up replaces it. A thumbs-up puts the song on the Want +// list, and may grow the set (see Suggestions). +func (s *Service) Thumb(ctx context.Context, userID, setID, kind string, song Song) (Thumbed, error) { if kind != ThumbUp && kind != ThumbDown { - return &InvalidInputError{"a thumb is up or down"} + return Thumbed{}, &InvalidInputError{"a thumb is up or down"} } key := SongKey(song.Artist, song.Title) if strings.TrimSpace(song.Title) == "" || strings.TrimSpace(song.Artist) == "" || strings.HasPrefix(key, "\x00") { - return &InvalidInputError{"a song's title and artist are needed"} + return Thumbed{}, &InvalidInputError{"a song's title and artist are needed"} } + var set Set if setID != "" { - if _, err := s.Get(ctx, userID, setID); err != nil { - return err + var err error + if set, err = s.Get(ctx, userID, setID); err != nil { + return Thumbed{}, err } } + if err := s.putThumb(ctx, userID, setID, kind, key, song); err != nil { + return Thumbed{}, err + } + var out Thumbed + if kind != ThumbUp { + return out, nil + } + w, err := s.AddWant(ctx, userID, wantFromSong(setID, song)) + if err != nil { + return out, err + } + out.Want = &w + if setID != "" && set.GrowAuto != 0 { + out.Grew, err = s.growBy(ctx, userID, set, song.Artist) + } + return out, err +} + +func (s *Service) putThumb(ctx context.Context, userID, setID, kind, key string, song Song) error { return s.db.PutDiscoveryFeedback(ctx, store.PutDiscoveryFeedbackParams{ ID: store.NewID(), UserID: userID, SetID: sql.NullString{String: setID, Valid: setID != ""}, Kind: kind, SongKey: key, Title: song.Title, Artist: song.Artist, diff --git a/server/internal/discover/discover_test.go b/server/internal/discover/discover_test.go index 2d6b8c0..33e4bcf 100644 --- a/server/internal/discover/discover_test.go +++ b/server/internal/discover/discover_test.go @@ -243,7 +243,7 @@ func TestThumbsDownIsForGood(t *testing.T) { other := e.set(t, "Duster") f, _ := e.svc.Feed(t.Context(), e.alice, set.ID, "", false) down := f.Items[0] - if err := e.svc.Thumb(t.Context(), e.alice, set.ID, discover.ThumbDown, discover.Song{Title: down.Title, Artist: down.Artist}); err != nil { + if _, err := e.svc.Thumb(t.Context(), e.alice, set.ID, discover.ThumbDown, discover.Song{Title: down.Title, Artist: down.Artist}); err != nil { t.Fatal(err) } for _, id := range []string{set.ID, other.ID} { @@ -373,7 +373,7 @@ func TestSets(t *testing.T) { if sets, _ := e.svc.Sets(ctx, e.alice); len(sets) != 0 { t.Errorf("sets after deleting: %+v", sets) } - if err := e.svc.Thumb(ctx, e.alice, "", "sideways", discover.Song{Title: "x", Artist: "y"}); !isInvalid(err) { + if _, err := e.svc.Thumb(ctx, e.alice, "", "sideways", discover.Song{Title: "x", Artist: "y"}); !isInvalid(err) { t.Errorf("a sideways thumb: %v", err) } } diff --git a/server/internal/discover/feed.go b/server/internal/discover/feed.go index 35bfc05..75cb75a 100644 --- a/server/internal/discover/feed.go +++ b/server/internal/discover/feed.go @@ -263,7 +263,7 @@ func (n *node) because(names map[string]string) []string { // pages, best first and taking turns between the seeds, without songs the // user has or turned down, within BuildFor. func (s *Service) build(ctx context.Context, userID string, set Set) [][]Item { - if s.Graph == nil || len(set.Artists) == 0 { + if s.Graph == nil || (len(set.Artists) == 0 && !set.RoomID.Valid) { return nil } ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.BuildFor) @@ -275,6 +275,12 @@ func (s *Service) build(ctx context.Context, userID string, set Set) [][]Item { keep = func(Item) bool { return true } } + // Thumbs-down from this set's feed lower their artists in it. + var setDowns map[string]int + if tc, err := s.thumbCounts(ctx, userID, set.ID); err == nil { + setDowns = tc.setDowns + } + lk := &looker{g: s.Graph, ctx: ctx, budget: maxFetches} lookup := func(ref musicgraph.ArtistRef) (musicgraph.Artist, bool) { return lk.get(ref, true) } @@ -309,6 +315,8 @@ func (s *Service) build(ctx context.Context, userID string, set Set) [][]Item { reach(sim.Artist, k, sim.Score, 1) } } + // A linked room's favorite artists pull too, less than the seeds. + walkRoom(s.roomSeeds(ctx, userID, set), lookup, names, reach) for _, n := range seeds { delete(nodes, library.ArtistKey(n.ref.Name)) // a seed isn't a new artist } @@ -365,7 +373,7 @@ func (s *Service) build(ctx context.Context, userID string, set Set) [][]Item { if so.Rank > 0 { pop = 0.6*so.Rank + 0.4*fame } - it.Score = score(pull, pop, so.Score, explore) + it.Score = score(pull, pop, so.Score, explore) * downWeight(setDowns[library.ArtistKey(artist)]) items = append(items, it) } } diff --git a/server/internal/discover/learning.go b/server/internal/discover/learning.go new file mode 100644 index 0000000..326c579 --- /dev/null +++ b/server/internal/discover/learning.go @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package discover + +import ( + "cmp" + "context" + "math" + "slices" + "strings" + + "github.com/madeofpendletonwool/syncphony/server/internal/library" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// Sets that learn (MAD-825): thumbs grow a set and turn its feed away +// from an artist, artists turned down often enough are offered for +// blocking, and a set can take artists you starred on Navidrome, when +// you pick them. None of it reaches a room. +const ( + // growAfter is how many thumbs-up an artist gets from a set's feed + // before they're offered for the set, or join it by themselves. + growAfter = 2 + // blockAfter is how many thumbs-down an artist gets, in any feed, + // before they're offered for blocking. + blockAfter = 3 + // downPenalty is how much each thumbs-down from a set's feed lowers + // its artist's songs in that feed: three take them to about a fifth. + downPenalty = 0.6 +) + +// Kinds of suggestion. +const ( + SuggestGrow = "grow" + SuggestBlock = "block" +) + +// Offer is an artist suggested for a set, or for blocking. +type Offer struct { + // Key is the artist's key (library.ArtistKey), Name how they're + // named. + Key, Name string + // Thumbs is how many thumbs-up (to grow) or down (to block) led to it. + Thumbs int +} + +// Suggestions are what a set's thumbs suggest. +type Suggestions struct { + // Grow are artists thumbed up from the set's feed growAfter times, to + // add to it. + Grow []Offer + // Block are artists thumbed down blockAfter times, in any feed. + Block []Offer +} + +// thumbCounts are the user's thumbs by artist: ups from a set's feed, +// and downs from every feed. +type thumbCounts struct { + ups, downs map[string]*Offer + // setDowns are the thumbs-down from the set's feed, for its penalty. + setDowns map[string]int +} + +func (s *Service) thumbCounts(ctx context.Context, userID, setID string) (thumbCounts, error) { + fb, err := s.db.ListDiscoveryFeedback(ctx, userID) + if err != nil { + return thumbCounts{}, err + } + tc := thumbCounts{ups: map[string]*Offer{}, downs: map[string]*Offer{}, setDowns: map[string]int{}} + count := func(m map[string]*Offer, f store.DiscoveryFeedback) { + k := library.ArtistKey(f.Artist) + if k == "" { + return + } + o, ok := m[k] + if !ok { + o = &Offer{Key: k, Name: f.Artist} + m[k] = o + } + o.Thumbs++ + } + for _, f := range fb { + inSet := setID != "" && f.SetID.String == setID + switch { + case f.Kind == ThumbUp && inSet: + count(tc.ups, f) + case f.Kind == ThumbDown: + count(tc.downs, f) + if inSet { + tc.setDowns[library.ArtistKey(f.Artist)]++ + } + } + } + return tc, nil +} + +// downWeight is how much a set's thumbs-down on an artist lower their songs. +func downWeight(downs int) float64 { return math.Pow(downPenalty, float64(downs)) } + +// isSeed reports whether an artist (by library.ArtistKey) is one of a set's seeds. +func (set Set) isSeed(key string) bool { + return slices.ContainsFunc(set.Artists, func(a store.DiscoverySetArtist) bool { return library.ArtistKey(a.Name) == key }) +} + +// Suggestions returns the artists a set's thumbs suggest adding to it, and +// the ones the user's thumbs suggest blocking, most thumbs first. Ones +// already in the set, blocked, or that the user said no to are left out. +func (s *Service) Suggestions(ctx context.Context, userID, setID string) (Suggestions, error) { + set, err := s.Get(ctx, userID, setID) + if err != nil { + return Suggestions{}, err + } + tc, err := s.thumbCounts(ctx, userID, setID) + if err != nil { + return Suggestions{}, err + } + no, err := s.said(ctx, userID) + if err != nil { + return Suggestions{}, err + } + out := Suggestions{Grow: []Offer{}, Block: []Offer{}} + for k, o := range tc.ups { + if o.Thumbs >= growAfter && !set.isSeed(k) && !no.blocked[k] && !no.grow[setID+"\x00"+k] { + out.Grow = append(out.Grow, *o) + } + } + for k, o := range tc.downs { + if o.Thumbs >= blockAfter && !set.isSeed(k) && !no.blocked[k] && !no.block[k] { + out.Block = append(out.Block, *o) + } + } + byThumbs := func(a, b Offer) int { return cmp.Or(cmp.Compare(b.Thumbs, a.Thumbs), strings.Compare(a.Key, b.Key)) } + slices.SortFunc(out.Grow, byThumbs) + slices.SortFunc(out.Block, byThumbs) + return out, nil +} + +// saidNo is what a user turned away: artists they blocked, and +// suggestions they dismissed (grow by set and artist, block by artist). +type saidNo struct { + blocked, grow, block map[string]bool +} + +func (s *Service) said(ctx context.Context, userID string) (saidNo, error) { + out := saidNo{blocked: map[string]bool{}, grow: map[string]bool{}, block: map[string]bool{}} + bs, err := s.db.ListDiscoveryBlocks(ctx, userID) + if err != nil { + return out, err + } + for _, b := range bs { + out.blocked[b.ArtistKey] = true + } + ds, err := s.db.ListDismissedDiscoverySuggestions(ctx, userID) + if err != nil { + return out, err + } + for _, d := range ds { + if d.Kind == SuggestGrow { + out.grow[d.Scope+"\x00"+d.ArtistKey] = true + } else { + out.block[d.ArtistKey] = true + } + } + return out, nil +} + +// Dismiss says no to a suggestion, so it isn't made again: an artist for +// a set (SuggestGrow), or for blocking (SuggestBlock). +func (s *Service) Dismiss(ctx context.Context, userID, setID, kind, artist string) error { + if _, err := s.Get(ctx, userID, setID); err != nil { + return err + } + scope := setID + switch kind { + case SuggestGrow: + case SuggestBlock: + scope = "" + default: + return &InvalidInputError{"a suggestion is grow or block"} + } + key := library.ArtistKey(artist) + if key == "" { + return &InvalidInputError{"an artist's name is needed"} + } + return s.db.DismissDiscoverySuggestion(ctx, store.DismissDiscoverySuggestionParams{ + UserID: userID, Kind: kind, Scope: scope, ArtistKey: key, CreatedAt: s.Now(), + }) +} + +// growBy adds an artist to a set that grows by itself, if they've had +// growAfter thumbs-up from its feed and the user hasn't turned them away. +// It returns their name if they joined. +func (s *Service) growBy(ctx context.Context, userID string, set Set, artist string) (string, error) { + sg, err := s.Suggestions(ctx, userID, set.ID) + if err != nil || len(set.Artists) >= MaxArtists { + return "", err + } + k := library.ArtistKey(artist) + i := slices.IndexFunc(sg.Grow, func(o Offer) bool { return o.Key == k }) + if i < 0 { + return "", nil + } + if err := s.addArtist(ctx, set.ID, Seed{Name: sg.Grow[i].Name}, FromThumb); err != nil { + return "", err + } + return sg.Grow[i].Name, s.touch(ctx, set.ID) +} + +// Import adds artists to a set at once: ones the user picked from a +// suggestion, their Navidrome stars or a room's taste, noted as coming +// from there. Ones in the set already are skipped; more than the set has +// room for is ErrTooMany, and adds none. +func (s *Service) Import(ctx context.Context, userID, setID, from string, seeds []Seed) (Set, error) { + switch from { + case FromManual, FromThumb, FromStarred, FromRoom: + default: + return Set{}, &InvalidInputError{"artists come from manual, thumb, starred or room"} + } + set, err := s.Get(ctx, userID, setID) + if err != nil { + return Set{}, err + } + have := map[string]bool{} + for _, a := range set.Artists { + have[a.ArtistKey] = true + } + var add []Seed + for _, a := range seeds { + k := ArtistKey(a.Name, a.MBID) + if k == "" || have[k] { + continue + } + have[k] = true + add = append(add, a) + } + if len(set.Artists)+len(add) > MaxArtists { + return Set{}, ErrTooMany + } + if len(add) == 0 { + return set, nil + } + err = s.db.Tx(ctx, func(q *store.Queries) error { + for _, a := range add { + if err := s.addArtistWith(ctx, q, setID, a, from); err != nil { + return err + } + } + return q.TouchDiscoverySet(ctx, store.TouchDiscoverySetParams{UpdatedAt: s.Now(), ID: setID}) + }) + if err != nil { + return Set{}, err + } + return s.Get(ctx, userID, setID) +} + +// StarredArtist is an artist the user starred on Navidrome, or whose +// albums or songs they starred: one to pick for a set. +type StarredArtist struct { + Name string + // Artist is whether they starred the artist; Albums and Songs how many + // of their albums and songs they starred. + Artist bool + Albums, Songs int +} + +// Stars lists what users starred on their own links. library.Service is one. +type Stars interface { + Starred(ctx context.Context, userID string) (provider.Starred, error) +} + +// Starred lists the artists the user starred on their own Navidrome, and +// the artists of the albums and songs they starred: starred artists +// first, then by how much of theirs they starred. It's only ever a list +// to pick from: nothing joins a set unless picked (library ≠ taste). +func (s *Service) Starred(ctx context.Context, userID string) ([]StarredArtist, error) { + out := []StarredArtist{} + if s.Stars == nil { + return out, nil + } + st, err := s.Stars.Starred(ctx, userID) + if err != nil { + return nil, err + } + by := map[string]*StarredArtist{} + var order []string + of := func(name string) *StarredArtist { + name = strings.Join(strings.Fields(name), " ") + k := library.ArtistKey(name) + if k == "" { + return nil + } + a, ok := by[k] + if !ok { + a = &StarredArtist{Name: name} + by[k] = a + order = append(order, k) + } + return a + } + for _, a := range st.Artists { + if sa := of(a.Name); sa != nil { + sa.Artist = true + } + } + for _, al := range st.Albums { + for _, a := range al.Artists { + if sa := of(a.Name); sa != nil { + sa.Albums++ + } + } + } + for _, t := range st.Songs { + for _, a := range t.Artists { + if sa := of(a.Name); sa != nil { + sa.Songs++ + } + } + } + for _, k := range order { + out = append(out, *by[k]) + } + weight := func(a StarredArtist) int { + w := a.Albums*3 + a.Songs + if a.Artist { + w += 1000 + } + return w + } + slices.SortStableFunc(out, func(a, b StarredArtist) int { + return cmp.Or(cmp.Compare(weight(b), weight(a)), strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))) + }) + return out, nil +} diff --git a/server/internal/discover/map_test.go b/server/internal/discover/map_test.go index 3460f65..7bce65b 100644 --- a/server/internal/discover/map_test.go +++ b/server/internal/discover/map_test.go @@ -156,7 +156,7 @@ func TestArtistSongs(t *testing.T) { e := newEnv(t) credit := []provider.ArtistCredit{{Name: "Hum"}} e.svc.Library = owned{library.Holding(provider.Track{Title: "Hum song 1", Artists: credit})} - if err := e.svc.Thumb(t.Context(), e.alice, "", discover.ThumbDown, discover.Song{Title: "Hum song 2", Artist: "Hum"}); err != nil { + if _, err := e.svc.Thumb(t.Context(), e.alice, "", discover.ThumbDown, discover.Song{Title: "Hum song 2", Artist: "Hum"}); err != nil { t.Fatal(err) } got, err := e.svc.ArtistSongs(t.Context(), e.alice, "hum", "") diff --git a/server/internal/discover/rooms.go b/server/internal/discover/rooms.go new file mode 100644 index 0000000..8e874aa --- /dev/null +++ b/server/internal/discover/rooms.go @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package discover + +import ( + "cmp" + "context" + "errors" + "log/slog" + "slices" + "strings" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/dj" + "github.com/madeofpendletonwool/syncphony/server/internal/library" + "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// A room's taste as a set's source (MAD-826). A set can be made from a +// room's or a night's favorite artists (a snapshot: you pick from them), +// or linked to a room, whose taste its feed then mixes in (live). Either +// way it only reads the room: discovery never writes to the room's DJ +// memory, and the room's skips never reach a set. + +// How much a linked room's taste counts, against the set's own seeds. +const ( + RoomLow = "low" + RoomMedium = "medium" + RoomHigh = "high" +) + +var roomWeights = map[string]float64{RoomLow: 0.3, RoomMedium: 0.55, RoomHigh: 0.85} + +const ( + // roomArtists is how many of a linked room's favorite artists a feed + // walks out from, and similarPerRoom how many of each one's similar + // artists. + roomArtists = 12 + similarPerRoom = 20 + // tasteTop is how many artists a room's taste lists. + tasteTop = 40 + // roomHistory is how many of a room's latest plays its taste reads. + roomHistory = 200 +) + +// ErrRoomNotFound is a room that isn't there, or that the user can't open. +var ErrRoomNotFound = errors.New("room not found") + +// TasteArtist is an artist a room likes. +type TasteArtist struct { + Name string + // Weight is how much, 0 to 1 against the artist it likes most. + Weight float64 + // Plays is how many of their songs played lately, or that night; + // 0 when the liking is all from past nights. + Plays int +} + +// RoomTaste reads rooms' taste for a user, only rooms they can open. +// RoomSource is one. +type RoomTaste interface { + // MayRead is ErrRoomNotFound unless the user can open the room. + MayRead(ctx context.Context, userID, roomID string) error + // Taste is the room's taste, most liked first: over past nights, and + // lately. Mine narrows it to the user's own songs. + Taste(ctx context.Context, userID, roomID string, mine bool) ([]TasteArtist, error) + // NightTaste is the taste of one of the room's past nights. + NightTaste(ctx context.Context, userID, roomID, nightID string, mine bool) ([]TasteArtist, error) +} + +// RoomSource reads rooms' taste from the DJ's memory and the rooms' +// plays, read only. +type RoomSource struct { + DB *store.Store + // CanEnter reports whether a user may open a room (rooms.Service's). + CanEnter func(ctx context.Context, userID string, r store.Room) (bool, error) + // Graph, if set, says which artists are alike, so a song queued like + // another counts for it. + Graph dj.Graph + // Now is the clock. Default store.Now. + Now func() time.Time +} + +// MayRead implements RoomTaste. +func (r *RoomSource) MayRead(ctx context.Context, userID, roomID string) error { + room, err := r.DB.GetRoom(ctx, roomID) + if store.IsNotFound(err) { + return ErrRoomNotFound + } else if err != nil { + return err + } + ok, err := r.CanEnter(ctx, userID, room) + if err != nil { + return err + } + if !ok { + return ErrRoomNotFound + } + return nil +} + +// Taste implements RoomTaste: the room's long-term taste as the DJ keeps +// it, and its latest plays, whichever likes an artist more. +func (r *RoomSource) Taste(ctx context.Context, userID, roomID string, mine bool) ([]TasteArtist, error) { + if err := r.MayRead(ctx, userID, roomID); err != nil { + return nil, err + } + member := "" + if mine { + member = userID + } + lt, err := dj.Peek(ctx, r.DB, roomID, member) + if err != nil { + return nil, err + } + history, err := r.DB.ListHistory(ctx, store.ListHistoryParams{RoomID: roomID, Limit: roomHistory}) + if err != nil { + return nil, err + } + now := store.Now + if r.Now != nil { + now = r.Now + } + in := dj.Input{History: history, Hearts: map[string]int{}, Now: now()} + if len(history) > 0 { + counts, err := r.DB.HeartCountsSince(ctx, store.HeartCountsSinceParams{RoomID: roomID, Since: history[len(history)-1].PlayHistory.StartedAt}) + if err != nil { + return nil, err + } + for _, c := range counts { + in.Hearts[c.QueueItemID] = int(c.Hearts) + } + } + if r.Graph != nil { + in.Related = dj.Related(ctx, r.Graph) + } + return tasteOf(lt, dj.NewProfile(in), member), nil +} + +// NightTaste implements RoomTaste. +func (r *RoomSource) NightTaste(ctx context.Context, userID, roomID, nightID string, mine bool) ([]TasteArtist, error) { + if err := r.MayRead(ctx, userID, roomID); err != nil { + return nil, err + } + n, err := r.DB.GetNight(ctx, store.GetNightParams{ID: nightID, RoomID: roomID}) + if store.IsNotFound(err) { + return nil, ErrNotFound + } else if err != nil { + return nil, err + } + in, err := dj.NightInput(ctx, r.DB, r.Graph, n) + if err != nil { + return nil, err + } + member := "" + if mine { + member = userID + } + return tasteOf(dj.LongTerm{}, dj.NewProfile(in), member), nil +} + +// tasteOf merges a room's long-term taste and a profile of its plays: +// each artist as much as either likes them. Artists the room turned away +// lately are left out. member, if set, counts only their songs in the +// profile (the long-term taste is narrowed already). +func tasteOf(lt dj.LongTerm, p dj.Profile, member string) []TasteArtist { + by := map[string]*TasteArtist{} + put := func(k, name string, w float64, plays int) { + if w <= 0 || k == "" { + return + } + a, ok := by[k] + if !ok { + a = &TasteArtist{Name: name} + by[k] = a + } + a.Weight = max(a.Weight, w) + a.Plays += plays + } + for k, l := range lt.Artists { + put(k, l.Name, l.Weight*(1-l.Veto), 0) + } + most := 0.0 + for _, t := range p.Artists { + if member != "" { + most = max(most, t.ByMember[member]) + } + } + for k, t := range p.Artists { + if p.Avoid[k] { + continue + } + w := t.Weight + if member != "" { + if most == 0 { + continue + } + w = t.ByMember[member] / most + } + put(k, t.Artist.Name, w, t.Plays) + } + out := make([]TasteArtist, 0, len(by)) + for _, a := range by { + out = append(out, *a) + } + slices.SortFunc(out, func(a, b TasteArtist) int { + return cmp.Or(cmp.Compare(b.Weight, a.Weight), cmp.Compare(b.Plays, a.Plays), strings.Compare(a.Name, b.Name)) + }) + return out[:min(len(out), tasteTop)] +} + +// RoomTaste returns a room's taste, or a past night's, for the user to +// make a set from. +func (s *Service) RoomTaste(ctx context.Context, userID, roomID, nightID string, mine bool) ([]TasteArtist, error) { + if s.Rooms == nil { + return []TasteArtist{}, nil + } + if nightID != "" { + return s.Rooms.NightTaste(ctx, userID, roomID, nightID, mine) + } + return s.Rooms.Taste(ctx, userID, roomID, mine) +} + +// roomSeeds are the favorite artists of the room a set is linked to, with +// how near they pull: their liking times the set's room weight. None if +// the set isn't linked, or the user can't open the room any more. +func (s *Service) roomSeeds(ctx context.Context, userID string, set Set) []TasteArtist { + if s.Rooms == nil || !set.RoomID.Valid { + return nil + } + taste, err := s.Rooms.Taste(ctx, userID, set.RoomID.String, false) + if err != nil { + if !errors.Is(err, ErrRoomNotFound) && ctx.Err() == nil { + slog.Warn("discover: reading a linked room's taste", "set", set.ID, "err", err) + } + return nil + } + w := roomWeights[cmp.Or(set.RoomWeight, RoomMedium)] + var out []TasteArtist + for _, a := range taste { + if set.isSeed(library.ArtistKey(a.Name)) { + continue + } + a.Weight *= w + out = append(out, a) + if len(out) == roomArtists { + break + } + } + return out +} + +// walkRoom reaches out from a linked room's artists, as from seeds that +// pull less. +func walkRoom(seeds []TasteArtist, lookup func(musicgraph.ArtistRef) (musicgraph.Artist, bool), names map[string]string, + reach func(ref musicgraph.ArtistRef, seed string, near float64, hop int), +) { + for _, ra := range seeds { + k := library.ArtistKey(ra.Name) + if _, isSeed := names[k]; isSeed || k == "" { + continue + } + names[k] = ra.Name + a, ok := lookup(musicgraph.ArtistRef{Name: ra.Name}) + if !ok { + continue + } + for _, sim := range a.Similar[:min(len(a.Similar), similarPerRoom)] { + reach(sim.Artist, k, sim.Score*ra.Weight, 1) + } + } +} diff --git a/server/internal/discover/stage4_test.go b/server/internal/discover/stage4_test.go new file mode 100644 index 0000000..649b9b3 --- /dev/null +++ b/server/internal/discover/stage4_test.go @@ -0,0 +1,381 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package discover_test + +import ( + "context" + "database/sql" + "errors" + "slices" + "testing" + + "github.com/madeofpendletonwool/syncphony/server/internal/discover" + "github.com/madeofpendletonwool/syncphony/server/internal/library" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// shelf is a library whose holdings a test changes. +type shelf struct{ o *library.Owned } + +func (l *shelf) For(context.Context, string) (*library.Owned, error) { return l.o, nil } + +func track(artist, title, album string) provider.Track { + return provider.Track{Title: title, Artists: []provider.ArtistCredit{{Name: artist}}, Album: provider.AlbumCredit{Title: album}} +} + +func (e *env) thumb(t *testing.T, setID, kind string, it discover.Item) discover.Thumbed { + t.Helper() + did, err := e.svc.Thumb(t.Context(), e.alice, setID, kind, discover.Song{Title: it.Title, Artist: it.Artist, Album: it.Album, Cover: it.Cover, DeezerID: it.DeezerID}) + if err != nil { + t.Fatal(err) + } + return did +} + +func songOf(t *testing.T, items []discover.Item, title string) discover.Item { + t.Helper() + i := slices.IndexFunc(items, func(it discover.Item) bool { return it.Title == title }) + if i < 0 { + t.Fatalf("%q isn't in the feed", title) + } + return items[i] +} + +func TestWantListGotIt(t *testing.T) { + e := newEnv(t) + lib := &shelf{o: library.Holding()} + e.svc.Library = lib + set := e.set(t, "Slowdive", "Hum") + all := e.all(t, set.ID) + it := songOf(t, all, "Swirlies song 1") + + // A thumbs-up wants the song, from the set, and the feed leaves it out. + did := e.thumb(t, set.ID, discover.ThumbUp, it) + if did.Want == nil || did.Want.Status != discover.Wanted || did.Want.SetID.String != set.ID || did.Want.Kind != discover.WantTrack { + t.Fatalf("thumbs-up want: %+v", did.Want) + } + if slices.ContainsFunc(e.all(t, set.ID), func(x discover.Item) bool { return x.Key == it.Key }) { + t.Error("a wanted song is still in the feed") + } + // A release, wanted from Fresh. + rel, err := e.svc.AddWant(t.Context(), e.alice, discover.Want{Kind: discover.WantRelease, Artist: "Ride", Title: "Interplay", SetID: set.ID}) + if err != nil { + t.Fatal(err) + } + // Nothing is in the library yet: nothing is got. + if n, err := e.svc.CheckWants(t.Context(), e.alice); err != nil || n != 0 { + t.Fatalf("got %d before it was there (%v)", n, err) + } + // The song and the album turn up in Navidrome: the next index marks + // them got, and they've landed. + lib.o = library.Holding(track("Swirlies", "Swirlies Song 1", "Something"), track("Ride", "Monaco", "Interplay (Deluxe Edition)")) + if n, err := e.svc.CheckWants(t.Context(), e.alice); err != nil || n != 2 { + t.Fatalf("got %d of 2 (%v)", n, err) + } + l, err := e.svc.Wants(t.Context(), e.alice) + if err != nil { + t.Fatal(err) + } + if l.Landed != 2 { + t.Errorf("landed %d, want 2", l.Landed) + } + for _, w := range l.Items { + if w.Status != discover.Got || w.GotBy.String != discover.GotAuto || !w.GotAt.Valid { + t.Errorf("%s: %s by %q", w.Title, w.Status, w.GotBy.String) + } + } + if err := e.svc.SeeLanded(t.Context(), e.alice); err != nil { + t.Fatal(err) + } + if l, _ := e.svc.Wants(t.Context(), e.alice); l.Landed != 0 { + t.Errorf("still %d landed after seeing them", l.Landed) + } + // The set's stats say it paid off. + sums, _ := e.svc.Sets(t.Context(), e.alice) + i := slices.IndexFunc(sums, func(s discover.Summary) bool { return s.ID == set.ID }) + if sums[i].Wanted != 2 || sums[i].Got != 2 { + t.Errorf("set yielded %d wanted, %d got; want 2 and 2", sums[i].Wanted, sums[i].Got) + } + + // Saying you got it doesn't count as landed; dismissing, then wanting + // again, wants it again. + other, _ := e.svc.AddWant(t.Context(), e.alice, discover.Want{Kind: discover.WantTrack, Artist: "Lush", Title: "Sweetness and Light"}) + if got, err := e.svc.SetWant(t.Context(), e.alice, other.ID, discover.Got); err != nil || got.GotBy.String != discover.GotManual { + t.Fatalf("manual got: %+v %v", got, err) + } + if l, _ := e.svc.Wants(t.Context(), e.alice); l.Landed != 0 { + t.Errorf("a manual got landed") + } + if _, err := e.svc.SetWant(t.Context(), e.alice, rel.ID, discover.Dismissed); err != nil { + t.Fatal(err) + } + again, err := e.svc.AddWant(t.Context(), e.alice, discover.Want{Kind: discover.WantRelease, Artist: "ride", Title: "Interplay"}) + if err != nil || again.ID != rel.ID || again.Status != discover.Wanted { + t.Errorf("wanting a dismissed release again: %+v %v", again, err) + } + // Bob can't touch alice's list. + if _, err := e.svc.SetWant(t.Context(), e.bob, rel.ID, discover.Got); !errors.Is(err, discover.ErrNotFound) { + t.Errorf("bob marked alice's want: %v", err) + } + if _, err := e.svc.SetWant(t.Context(), e.alice, rel.ID, "maybe"); err == nil { + t.Error("a status that isn't one") + } +} + +func TestThumbsGrowASet(t *testing.T) { + e := newEnv(t) + set := e.set(t, "Slowdive", "Hum") + all := e.all(t, set.ID) + + // One thumbs-up suggests nothing; two do. + e.thumb(t, set.ID, discover.ThumbUp, songOf(t, all, "Swirlies song 1")) + if sg, _ := e.svc.Suggestions(t.Context(), e.alice, set.ID); len(sg.Grow) != 0 { + t.Fatalf("suggested after one thumb: %+v", sg.Grow) + } + e.thumb(t, set.ID, discover.ThumbUp, songOf(t, all, "Swirlies song 2")) + sg, err := e.svc.Suggestions(t.Context(), e.alice, set.ID) + if err != nil || len(sg.Grow) != 1 || sg.Grow[0].Name != "Swirlies" || sg.Grow[0].Thumbs != 2 { + t.Fatalf("suggestions: %+v %v", sg, err) + } + // Thumbs from another set's feed don't count for this one. + other := e.set(t, "Duster") + if sg, _ := e.svc.Suggestions(t.Context(), e.alice, other.ID); len(sg.Grow) != 0 { + t.Errorf("another set's thumbs suggested %+v", sg.Grow) + } + // Saying no keeps it from coming back. + if err := e.svc.Dismiss(t.Context(), e.alice, set.ID, discover.SuggestGrow, "Swirlies"); err != nil { + t.Fatal(err) + } + if sg, _ := e.svc.Suggestions(t.Context(), e.alice, set.ID); len(sg.Grow) != 0 { + t.Errorf("a dismissed suggestion: %+v", sg.Grow) + } + + // A set that grows by itself adds an artist at their second thumbs-up. + on := true + if _, err := e.svc.Update(t.Context(), e.alice, set.ID, discover.Changes{GrowAuto: &on}); err != nil { + t.Fatal(err) + } + if did := e.thumb(t, set.ID, discover.ThumbUp, songOf(t, all, "Ride song 1")); did.Grew != "" { + t.Fatalf("grew after one thumb: %s", did.Grew) + } + if did := e.thumb(t, set.ID, discover.ThumbUp, songOf(t, all, "Ride song 2")); did.Grew != "Ride" { + t.Fatalf("grew %q, want Ride", did.Grew) + } + got, _ := e.svc.Get(t.Context(), e.alice, set.ID) + i := slices.IndexFunc(got.Artists, func(a store.DiscoverySetArtist) bool { return a.Name == "Ride" }) + if i < 0 || got.Artists[i].AddedFrom != discover.FromThumb { + t.Errorf("Ride in the set: %+v", got.Artists) + } + if got.GrowAuto != 1 || got.Name != set.Name || got.Explore != set.Explore { + t.Errorf("turning grow on changed the set: %+v", got.DiscoverySet) + } +} + +func TestThumbsDownLowerAnArtist(t *testing.T) { + e := newEnv(t) + set := e.set(t, "Slowdive", "Hum") + all := e.all(t, set.ID) + before := songOf(t, all, "Swirlies song 3").Score + keep := songOf(t, all, "Ride song 1").Score + + e.thumb(t, set.ID, discover.ThumbDown, songOf(t, all, "Swirlies song 1")) + e.thumb(t, set.ID, discover.ThumbDown, songOf(t, all, "Swirlies song 2")) + if _, err := e.svc.Feed(t.Context(), e.alice, set.ID, "", true); err != nil { + t.Fatal(err) + } + all = e.all(t, set.ID) + if after := songOf(t, all, "Swirlies song 3").Score; after >= before*0.5 { + t.Errorf("Swirlies scored %.3f after two thumbs-down, from %.3f", after, before) + } + if after := songOf(t, all, "Ride song 1").Score; after != keep { + t.Errorf("Ride's songs moved too: %.3f from %.3f", after, keep) + } + // A third thumbs-down suggests blocking them, in any set. + if sg, _ := e.svc.Suggestions(t.Context(), e.alice, set.ID); len(sg.Block) != 0 { + t.Fatalf("suggested a block after two: %+v", sg.Block) + } + e.thumb(t, "", discover.ThumbDown, songOf(t, all, "Swirlies song 3")) + sg, _ := e.svc.Suggestions(t.Context(), e.alice, set.ID) + if len(sg.Block) != 1 || sg.Block[0].Name != "Swirlies" || sg.Block[0].Thumbs != 3 { + t.Fatalf("block suggestions: %+v", sg.Block) + } + if _, err := e.svc.Block(t.Context(), e.alice, "Swirlies"); err != nil { + t.Fatal(err) + } + if sg, _ := e.svc.Suggestions(t.Context(), e.alice, set.ID); len(sg.Block) != 0 { + t.Errorf("still suggests blocking someone blocked: %+v", sg.Block) + } +} + +// stars stands in for what alice starred on Navidrome. +type stars struct{ st provider.Starred } + +func (s stars) Starred(context.Context, string) (provider.Starred, error) { return s.st, nil } + +func TestImportStarred(t *testing.T) { + e := newEnv(t) + e.svc.Stars = stars{provider.Starred{ + Artists: []provider.Artist{{Name: "Low"}}, + Albums: []provider.Album{ + {Title: "Souvlaki", Artists: []provider.ArtistCredit{{Name: "Slowdive"}}}, + {Title: "Pygmalion", Artists: []provider.ArtistCredit{{Name: "Slowdive"}}}, + }, + Songs: []provider.Track{track("Codeine", "D", "Frigid Stars"), track("Slowdive", "Alison", "Souvlaki"), track("The Cure", "Pictures", "Disintegration")}, + }} + got, err := e.svc.Starred(t.Context(), e.alice) + if err != nil { + t.Fatal(err) + } + var names []string + for _, a := range got { + names = append(names, a.Name) + } + if want := []string{"Low", "Slowdive", "Codeine", "The Cure"}; !slices.Equal(names, want) { + t.Fatalf("starred %v, want %v", names, want) + } + if got[1].Albums != 2 || got[1].Songs != 1 || got[1].Artist { + t.Errorf("Slowdive: %+v", got[1]) + } + + // Only the ones picked join, as starred; ones there already are skipped. + set := e.set(t, "Slowdive") + set, err = e.svc.Import(t.Context(), e.alice, set.ID, discover.FromStarred, []discover.Seed{{Name: "Slowdive"}, {Name: "Codeine"}}) + if err != nil { + t.Fatal(err) + } + if len(set.Artists) != 2 || set.Artists[1].Name != "Codeine" || set.Artists[1].AddedFrom != discover.FromStarred { + t.Errorf("after import: %+v", set.Artists) + } + // More than fit is too many, and adds none. + var lots []discover.Seed + for i := range discover.MaxArtists { + lots = append(lots, discover.Seed{Name: "Artist " + string(rune('A'+i%26)) + string(rune('a'+i/26))}) + } + if _, err := e.svc.Import(t.Context(), e.alice, set.ID, discover.FromStarred, lots); !errors.Is(err, discover.ErrTooMany) { + t.Errorf("importing too many: %v", err) + } + if s, _ := e.svc.Get(t.Context(), e.alice, set.ID); len(s.Artists) != 2 { + t.Errorf("a refused import added %d", len(s.Artists)-2) + } + if _, err := e.svc.Import(t.Context(), e.alice, set.ID, "spotify", nil); err == nil { + t.Error("imported from somewhere that isn't") + } +} + +// room makes a room of bob's with a taste over past nights: Failure and +// Codeine (bob's), and Bedhead (alice's). +func (e *env) room(t *testing.T, visibility string) store.Room { + t.Helper() + ctx := t.Context() + r, err := e.db.CreateRoom(ctx, store.CreateRoomParams{ID: store.NewID(), Name: "Den", OwnerID: e.bob, FairnessMode: "round_robin", Settings: "{}", CreatedAt: store.Now()}) + if err != nil { + t.Fatal(err) + } + if _, err := e.db.SetRoomVisibility(ctx, store.SetRoomVisibilityParams{Visibility: visibility, ID: r.ID}); err != nil { + t.Fatal(err) + } + for _, a := range []struct { + member, name string + likes float64 + }{{e.bob, "Failure", 6}, {e.bob, "Codeine", 3}, {e.alice, "Bedhead", 4}} { + if err := e.db.AddDJAffinity(ctx, store.AddDJAffinityParams{ + RoomID: r.ID, Kind: "artist", Key: a.name, Member: a.member, Name: a.name, Likes: a.likes, LastNight: 1, + LastAt: sql.NullTime{Time: store.Now(), Valid: true}, + }); err != nil { + t.Fatal(err) + } + } + return r +} + +func (e *env) roomSource() *discover.RoomSource { + rs := &discover.RoomSource{DB: e.db} + rs.CanEnter = func(ctx context.Context, userID string, r store.Room) (bool, error) { + if r.Visibility == store.VisibilityOpen || r.OwnerID == userID { + return true, nil + } + _, err := e.db.GetRoomMember(ctx, store.GetRoomMemberParams{RoomID: r.ID, UserID: userID}) + return err == nil, nil + } + return rs +} + +func TestRoomTaste(t *testing.T) { + e := newEnv(t) + e.svc.Rooms = e.roomSource() + open := e.room(t, store.VisibilityOpen) + + taste, err := e.svc.RoomTaste(t.Context(), e.alice, open.ID, "", false) + if err != nil { + t.Fatal(err) + } + var names []string + for _, a := range taste { + names = append(names, a.Name) + } + // Each member's likes count the same: alice's one artist is as liked + // as bob's favorite. + if len(names) != 3 || names[2] != "Codeine" || !slices.Contains(names, "Bedhead") || !slices.Contains(names, "Failure") { + t.Fatalf("taste %v", names) + } + mine, err := e.svc.RoomTaste(t.Context(), e.alice, open.ID, "", true) + if err != nil || len(mine) != 1 || mine[0].Name != "Bedhead" { + t.Fatalf("alice's own picks: %+v %v", mine, err) + } + + // Reading it changes nothing of the room's. + room, err := e.db.GetDJRoom(t.Context(), open.ID) + if !store.IsNotFound(err) { + t.Errorf("reading the taste wrote the room's DJ memory: %+v %v", room, err) + } + + // A private room alice isn't in is not found, to link or to read. + closed := e.room(t, store.VisibilityPrivate) + if _, err := e.svc.RoomTaste(t.Context(), e.alice, closed.ID, "", false); !errors.Is(err, discover.ErrRoomNotFound) { + t.Errorf("read a room alice can't open: %v", err) + } + set := e.set(t, "Slowdive") + if _, err := e.svc.Update(t.Context(), e.alice, set.ID, discover.Changes{RoomID: &closed.ID}); !errors.Is(err, discover.ErrRoomNotFound) { + t.Errorf("linked a room alice can't open: %v", err) + } +} + +func TestLinkedRoomShiftsTheFeed(t *testing.T) { + e := newEnv(t) + e.svc.Rooms = e.roomSource() + r := e.room(t, store.VisibilityOpen) + // Failure's neighbors, which none of the seeds lead to. + e.graph.artist("Failure", 100_000, "Hum", 0.6, "Jawbox", 0.8, "Shiner", 0.5) + e.graph.artist("Jawbox", 100_000) + set := e.set(t, "Slowdive") + has := func(artist string) bool { + return slices.ContainsFunc(e.all(t, set.ID), func(it discover.Item) bool { return it.Artist == artist }) + } + if has("Jawbox") { + t.Fatal("Jawbox before the room was linked") + } + weight := discover.RoomHigh + set, err := e.svc.Update(t.Context(), e.alice, set.ID, discover.Changes{RoomID: &r.ID, RoomWeight: &weight}) + if err != nil { + t.Fatal(err) + } + if !set.RoomID.Valid || set.RoomWeight != discover.RoomHigh { + t.Fatalf("linked: %+v", set.DiscoverySet) + } + if !has("Jawbox") { + t.Error("the room's taste didn't reach the feed") + } + // Unlinking takes it out again. + none := "" + if _, err := e.svc.Update(t.Context(), e.alice, set.ID, discover.Changes{RoomID: &none}); err != nil { + t.Fatal(err) + } + if has("Jawbox") { + t.Error("still walking from the room after unlinking it") + } + bad := "loud" + if _, err := e.svc.Update(t.Context(), e.alice, set.ID, discover.Changes{RoomWeight: &bad}); err == nil { + t.Error("a weight that isn't one") + } +} diff --git a/server/internal/discover/want.go b/server/internal/discover/want.go new file mode 100644 index 0000000..700470b --- /dev/null +++ b/server/internal/discover/want.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package discover + +import ( + "context" + "database/sql" + "log/slog" + "strings" + + "github.com/madeofpendletonwool/syncphony/server/internal/library" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// The Want list (MAD-824): songs and releases you liked in Discover, kept +// until they turn up in your library. Syncphony never gets them for you; +// once your Navidrome has them, the next index marks them got. + +// Kinds of things wanted. +const ( + WantTrack = "track" + WantRelease = "release" +) + +// What became of a thing wanted. +const ( + Wanted = "wanted" + Got = "got" + Dismissed = "dismissed" +) + +// How a thing came to be got. +const ( + GotAuto = "auto" + GotManual = "manual" +) + +// Want is a song or release to want. +type Want struct { + // Kind is WantTrack or WantRelease. + Kind string + // Artist and Title name it; for a song, Release is its album's title, + // if known. + Artist, Title, Release string + // MBID is a song's recording or a release's release group, ISRC a + // song's; DeezerID and Cover are its ID and cover on Deezer. + MBID, ISRC string + DeezerID int64 + Cover string + // SetID is the set whose feed it was in, if any. + SetID string +} + +// WantList is everything a user wants, or wanted. +type WantList struct { + // Items are newest first. + Items []store.WantItem + // Landed is how many turned up in the library since the user last + // looked (SeeLanded). + Landed int +} + +// wantKey is how a thing wanted is kept: its simplified artist and title. +func wantKey(kind, artist, title string) string { + if kind == WantRelease { + return library.ReleaseKey(artist, title) + } + return SongKey(artist, title) +} + +// AddWant puts a song or release on the user's Want list. Wanting one +// that's there already returns it as it is, unless it was dismissed: then +// it's wanted again. +func (s *Service) AddWant(ctx context.Context, userID string, w Want) (store.WantItem, error) { + if w.Kind != WantTrack && w.Kind != WantRelease { + return store.WantItem{}, &InvalidInputError{"a want is a track or a release"} + } + w.Artist, w.Title = strings.Join(strings.Fields(w.Artist), " "), strings.Join(strings.Fields(w.Title), " ") + key := wantKey(w.Kind, w.Artist, w.Title) + if w.Artist == "" || w.Title == "" || strings.HasPrefix(key, "\x00") { + return store.WantItem{}, &InvalidInputError{"an artist and a title are needed"} + } + if w.SetID != "" { + if _, err := s.Get(ctx, userID, w.SetID); err != nil { + return store.WantItem{}, err + } + } + err := s.db.AddWantItem(ctx, store.AddWantItemParams{ + ID: store.NewID(), UserID: userID, SetID: sql.NullString{String: w.SetID, Valid: w.SetID != ""}, + Kind: w.Kind, ItemKey: key, Artist: w.Artist, Title: w.Title, ReleaseTitle: strings.TrimSpace(w.Release), + Mbid: sql.NullString{String: w.MBID, Valid: w.MBID != ""}, + Isrc: sql.NullString{String: w.ISRC, Valid: w.ISRC != ""}, + DeezerID: sql.NullInt64{Int64: w.DeezerID, Valid: w.DeezerID != 0}, + Cover: w.Cover, AddedAt: s.Now(), + }) + if err != nil { + return store.WantItem{}, err + } + it, err := s.db.GetWantItemByKey(ctx, store.GetWantItemByKeyParams{UserID: userID, Kind: w.Kind, ItemKey: key}) + if err != nil || it.Status != Dismissed { + return it, err + } + return s.SetWant(ctx, userID, it.ID, Wanted) +} + +// Wants lists the user's Want list, and how many of it landed since they +// last looked. +func (s *Service) Wants(ctx context.Context, userID string) (WantList, error) { + items, err := s.db.ListWantItems(ctx, userID) + if err != nil { + return WantList{}, err + } + landed, err := s.db.CountLandedWantItems(ctx, userID) + return WantList{Items: items, Landed: int(landed)}, err +} + +// SetWant marks a thing wanted, got (you say so) or dismissed. +func (s *Service) SetWant(ctx context.Context, userID, id, status string) (store.WantItem, error) { + p := store.SetWantItemStatusParams{Status: status, ID: id, UserID: userID} + switch status { + case Wanted, Dismissed: + case Got: + p.GotAt = sql.NullTime{Time: s.Now(), Valid: true} + p.GotBy = sql.NullString{String: GotManual, Valid: true} + p.GotSeen = 1 // you know: you said so + default: + return store.WantItem{}, &InvalidInputError{"a want is wanted, got or dismissed"} + } + n, err := s.db.SetWantItemStatus(ctx, p) + if err != nil { + return store.WantItem{}, err + } + if n == 0 { + return store.WantItem{}, ErrNotFound + } + return s.db.GetWantItem(ctx, store.GetWantItemParams{ID: id, UserID: userID}) +} + +// SeeLanded notes that the user saw what landed in their library. +func (s *Service) SeeLanded(ctx context.Context, userID string) error { + return s.db.SeeLandedWantItems(ctx, userID) +} + +// CheckWants marks what the user wants got once it's in their library: +// a song by ISRC or MBID first, then by artist and title; a release by +// artist and title. The library calls it after each index it builds. It +// returns how many it marked. +func (s *Service) CheckWants(ctx context.Context, userID string) (int, error) { + if s.Library == nil { + return 0, nil + } + owned, err := s.Library.For(ctx, userID) + if err != nil { + return 0, err + } + items, err := s.db.ListWantItems(ctx, userID) + if err != nil { + return 0, err + } + got := 0 + for _, it := range items { + if it.Status != Wanted || !hasWant(owned, it) { + continue + } + n, err := s.db.MarkWantItemGot(ctx, store.MarkWantItemGotParams{GotAt: sql.NullTime{Time: s.Now(), Valid: true}, ID: it.ID}) + if err != nil { + return got, err + } + got += int(n) + } + if got > 0 { + slog.Debug("discover: wanted things landed in a library", "user", userID, "got", got) + } + return got, nil +} + +// hasWant reports whether a library has a thing wanted. +func hasWant(o *library.Owned, it store.WantItem) bool { + if it.Kind == WantRelease { + return o.HasRelease(it.Artist, it.Title) + } + return o.Has(library.Song{Artist: it.Artist, Title: it.Title, ISRC: it.Isrc.String, MBID: it.Mbid.String}) +} + +// wantFromSong is the Want of a song given a thumbs-up. +func wantFromSong(setID string, song Song) Want { + return Want{ + Kind: WantTrack, Artist: song.Artist, Title: song.Title, Release: song.Album, + MBID: song.MBID, ISRC: song.ISRC, DeezerID: song.DeezerID, Cover: song.Cover, SetID: setID, + } +} diff --git a/server/internal/dj/memory.go b/server/internal/dj/memory.go index 85bbc32..04d46f5 100644 --- a/server/internal/dj/memory.go +++ b/server/internal/dj/memory.go @@ -79,16 +79,37 @@ func (m *Memory) Load(ctx context.Context, roomID string) (LongTerm, error) { return readLongTerm(room, rows), err } -// night reads what the room did in a night: its plays, newest first, and -// their hearts. +// Peek returns a room's long-term taste as it's kept, without folding in +// nights since: it never writes, for readers outside the room (personal +// discovery). member, if set, narrows it to that member's own likes. +func Peek(ctx context.Context, db *store.Store, roomID, member string) (LongTerm, error) { + room, err := db.GetDJRoom(ctx, roomID) + if err != nil && !store.IsNotFound(err) { + return LongTerm{}, err + } + room.RoomID = roomID + rows, err := db.ListDJAffinities(ctx, roomID) + if member != "" { + rows = slices.DeleteFunc(rows, func(r store.DjAffinity) bool { return r.Member != member }) + } + return readLongTerm(room, rows), err +} + +// night reads what the room did in a night. func (m *Memory) night(ctx context.Context, n store.Night) (Input, error) { - plays, err := m.DB.ListPlaysBetween(ctx, store.ListPlaysBetweenParams{ + return NightInput(ctx, m.DB, m.Graph, n) +} + +// NightInput reads what a room did in a night: its plays, newest first, +// and their hearts. graph, if set, says which artists are alike. +func NightInput(ctx context.Context, db *store.Store, graph Graph, n store.Night) (Input, error) { + plays, err := db.ListPlaysBetween(ctx, store.ListPlaysBetweenParams{ RoomID: n.RoomID, FromTime: n.StartedAt, ToTime: n.EndedAt.Add(time.Microsecond), Limit: maxNightPlays, }) if err != nil { return Input{}, err } - counts, err := m.DB.HeartCountsSince(ctx, store.HeartCountsSinceParams{RoomID: n.RoomID, Since: n.StartedAt}) + counts, err := db.HeartCountsSince(ctx, store.HeartCountsSinceParams{RoomID: n.RoomID, Since: n.StartedAt}) if err != nil { return Input{}, err } @@ -99,8 +120,8 @@ func (m *Memory) night(ctx context.Context, n store.Night) (Input, error) { for _, c := range counts { in.Hearts[c.QueueItemID] = int(c.Hearts) } - if m.Graph != nil { - in.Related = Related(ctx, m.Graph) + if graph != nil { + in.Related = Related(ctx, graph) } return in, nil } diff --git a/server/internal/httpapi/accounts_test.go b/server/internal/httpapi/accounts_test.go index 239ad19..55469f6 100644 --- a/server/internal/httpapi/accounts_test.go +++ b/server/internal/httpapi/accounts_test.go @@ -156,6 +156,7 @@ func newEnv(t *testing.T) *env { api.Clips = clips.New(e.links, ff, clips.Config{}) t.Cleanup(api.Clips.Close) } + api.Discover.Rooms = &discover.RoomSource{DB: db, CanEnter: e.rooms.CanEnter, Now: e.clock} e.api = api mux := http.NewServeMux() mux.Handle("/api/", api.Handler()) diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go index 8e78ba6..652221b 100644 --- a/server/internal/httpapi/api.gen.go +++ b/server/internal/httpapi/api.gen.go @@ -267,6 +267,27 @@ func (e DiscoveryReleaseKind) Valid() bool { } } +// Defines values for DiscoveryRoomWeight. +const ( + RoomWeightHigh DiscoveryRoomWeight = "high" + RoomWeightLow DiscoveryRoomWeight = "low" + RoomWeightMedium DiscoveryRoomWeight = "medium" +) + +// Valid indicates whether the value is a known member of the DiscoveryRoomWeight enum. +func (e DiscoveryRoomWeight) Valid() bool { + switch e { + case RoomWeightHigh: + return true + case RoomWeightLow: + return true + case RoomWeightMedium: + return true + default: + return false + } +} + // Defines values for DiscoverySetArtistAddedFrom. const ( DiscoverySetArtistAddedFromManual DiscoverySetArtistAddedFrom = "manual" @@ -291,6 +312,24 @@ func (e DiscoverySetArtistAddedFrom) Valid() bool { } } +// Defines values for DismissDiscoverySuggestionRequestKind. +const ( + SuggestBlock DismissDiscoverySuggestionRequestKind = "block" + SuggestGrow DismissDiscoverySuggestionRequestKind = "grow" +) + +// Valid indicates whether the value is a known member of the DismissDiscoverySuggestionRequestKind enum. +func (e DismissDiscoverySuggestionRequestKind) Valid() bool { + switch e { + case SuggestBlock: + return true + case SuggestGrow: + return true + default: + return false + } +} + // Defines values for DisplayPairingInfoKind. const ( DisplayPairingInfoKindBox DisplayPairingInfoKind = "box" @@ -573,6 +612,30 @@ func (e HealthStatus) Valid() bool { } } +// Defines values for ImportDiscoveryArtistsRequestFrom. +const ( + ImportManual ImportDiscoveryArtistsRequestFrom = "manual" + ImportRoom ImportDiscoveryArtistsRequestFrom = "room" + ImportStarred ImportDiscoveryArtistsRequestFrom = "starred" + ImportThumb ImportDiscoveryArtistsRequestFrom = "thumb" +) + +// Valid indicates whether the value is a known member of the ImportDiscoveryArtistsRequestFrom enum. +func (e ImportDiscoveryArtistsRequestFrom) Valid() bool { + switch e { + case ImportManual: + return true + case ImportRoom: + return true + case ImportStarred: + return true + case ImportThumb: + return true + default: + return false + } +} + // Defines values for LinerNotesFactKind. const ( LinerNotesFactKindCover LinerNotesFactKind = "cover" @@ -1581,6 +1644,63 @@ func (e UserAuditEntryAction) Valid() bool { } } +// Defines values for WantItemGotBy. +const ( + WantGotAuto WantItemGotBy = "auto" + WantGotManual WantItemGotBy = "manual" +) + +// Valid indicates whether the value is a known member of the WantItemGotBy enum. +func (e WantItemGotBy) Valid() bool { + switch e { + case WantGotAuto: + return true + case WantGotManual: + return true + default: + return false + } +} + +// Defines values for WantKind. +const ( + WantRelease WantKind = "release" + WantTrack WantKind = "track" +) + +// Valid indicates whether the value is a known member of the WantKind enum. +func (e WantKind) Valid() bool { + switch e { + case WantRelease: + return true + case WantTrack: + return true + default: + return false + } +} + +// Defines values for WantStatus. +const ( + WantDismissed WantStatus = "dismissed" + WantGot WantStatus = "got" + WantWanted WantStatus = "wanted" +) + +// Valid indicates whether the value is a known member of the WantStatus enum. +func (e WantStatus) Valid() bool { + switch e { + case WantDismissed: + return true + case WantGot: + return true + case WantWanted: + return true + default: + return false + } +} + // Defines values for ListLinksParamsInclude. const ( Shared ListLinksParamsInclude = "shared" @@ -1665,6 +1785,19 @@ type AddToQueueRequest struct { WarnDuplicates *bool `json:"warnDuplicates,omitempty"` } +// AddWantRequest defines model for AddWantRequest. +type AddWantRequest struct { + Artist string `json:"artist"` + Cover *string `json:"cover,omitempty"` + DeezerId *int64 `json:"deezerId,omitempty"` + Isrc *string `json:"isrc,omitempty"` + Kind WantKind `json:"kind"` + Mbid *string `json:"mbid,omitempty"` + Release *string `json:"release,omitempty"` + SetId *string `json:"setId,omitempty"` + Title string `json:"title"` +} + // AlbumAbout defines model for AlbumAbout. type AlbumAbout struct { // About A short summary from Wikipedia. @@ -2168,7 +2301,12 @@ type DiscoveryFeed struct { // DiscoveryFeedbackRequest defines model for DiscoveryFeedbackRequest. type DiscoveryFeedbackRequest struct { - Artist string `json:"artist"` + // Album Its album, for the Want list. + Album *string `json:"album,omitempty"` + Artist string `json:"artist"` + + // Cover Its Deezer cover, for the Want list. + Cover *string `json:"cover,omitempty"` DeezerId *int64 `json:"deezerId,omitempty"` Isrc *string `json:"isrc,omitempty"` Kind DiscoveryFeedbackRequestKind `json:"kind"` @@ -2305,6 +2443,13 @@ type DiscoveryMapNode struct { // releases (recent albums, EPs and singles), or `both`. type DiscoveryMode string +// DiscoveryOffer defines model for DiscoveryOffer. +type DiscoveryOffer struct { + Key string `json:"key"` + Name string `json:"name"` + Thumbs int `json:"thumbs"` +} + // DiscoveryRelease A recent release near a set's seeds, that you don't have. type DiscoveryRelease struct { // Artist Who it's credited to. @@ -2344,6 +2489,9 @@ type DiscoveryReleaseTracks struct { Items []DiscoveryItem `json:"items"` } +// DiscoveryRoomWeight How much a linked room's taste counts against the set's own artists. +type DiscoveryRoomWeight string + // DiscoverySeed defines model for DiscoverySeed. type DiscoverySeed struct { Mbid *string `json:"mbid,omitempty"` @@ -2360,13 +2508,22 @@ type DiscoverySet struct { // FreshDays How far back a set's Fresh feed looks, in days. FreshDays DiscoveryFreshDays `json:"freshDays"` - Id string `json:"id"` + + // GrowAuto Artists you thumb up twice from its feed join it without asking. + GrowAuto bool `json:"growAuto"` + Id string `json:"id"` // Mode Which feeds a set shows: `deep` cuts (anything new to you), `fresh` // releases (recent albums, EPs and singles), or `both`. - Mode DiscoveryMode `json:"mode"` - Name string `json:"name"` - UpdatedAt time.Time `json:"updatedAt"` + Mode DiscoveryMode `json:"mode"` + Name string `json:"name"` + + // RoomId A room whose taste its feed mixes in (read only), if linked. + RoomId *string `json:"roomId,omitempty"` + + // RoomWeight How much a linked room's taste counts against the set's own artists. + RoomWeight DiscoveryRoomWeight `json:"roomWeight"` + UpdatedAt time.Time `json:"updatedAt"` } // DiscoverySetArtist defines model for DiscoverySetArtist. @@ -2391,13 +2548,22 @@ type DiscoverySetSummary struct { // FreshDays How far back a set's Fresh feed looks, in days. FreshDays DiscoveryFreshDays `json:"freshDays"` - Id string `json:"id"` + + // Got Of those, how many you got. + Got int `json:"got"` + Id string `json:"id"` // Mode Which feeds a set shows: `deep` cuts (anything new to you), `fresh` // releases (recent albums, EPs and singles), or `both`. - Mode DiscoveryMode `json:"mode"` - Name string `json:"name"` - UpdatedAt time.Time `json:"updatedAt"` + Mode DiscoveryMode `json:"mode"` + Name string `json:"name"` + + // RoomId The room whose taste it mixes in, if linked. + RoomId *string `json:"roomId,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` + + // Wanted Songs and releases wanted from its feeds (not dismissed). + Wanted int `json:"wanted"` } // DiscoverySongs defines model for DiscoverySongs. @@ -2409,6 +2575,55 @@ type DiscoverySongs struct { NoGraph bool `json:"noGraph"` } +// DiscoveryStarredArtist defines model for DiscoveryStarredArtist. +type DiscoveryStarredArtist struct { + // Albums How many of their albums you starred. + Albums int `json:"albums"` + + // Artist You starred the artist. + Artist bool `json:"artist"` + Name string `json:"name"` + + // Songs How many of their songs you starred. + Songs int `json:"songs"` +} + +// DiscoverySuggestions defines model for DiscoverySuggestions. +type DiscoverySuggestions struct { + // Block Artists to block. + Block []DiscoveryOffer `json:"block"` + + // Grow Artists to add to the set. + Grow []DiscoveryOffer `json:"grow"` +} + +// DiscoveryTasteArtist defines model for DiscoveryTasteArtist. +type DiscoveryTasteArtist struct { + Name string `json:"name"` + + // Plays Their songs played lately, or that night. + Plays int `json:"plays"` + + // Weight How much the room likes them, 0 to 1 against its favorite. + Weight float32 `json:"weight"` +} + +// DiscoveryThumbResult defines model for DiscoveryThumbResult. +type DiscoveryThumbResult struct { + // Grew The artist who joined the set by themselves, if any. + Grew *string `json:"grew,omitempty"` + Want *WantItem `json:"want,omitempty"` +} + +// DismissDiscoverySuggestionRequest defines model for DismissDiscoverySuggestionRequest. +type DismissDiscoverySuggestionRequest struct { + Artist string `json:"artist"` + Kind DismissDiscoverySuggestionRequestKind `json:"kind"` +} + +// DismissDiscoverySuggestionRequestKind defines model for DismissDiscoverySuggestionRequest.Kind. +type DismissDiscoverySuggestionRequestKind string + // Display defines model for Display. type Display struct { // Audio It may play the room's audio, as the speaker. @@ -2912,6 +3127,15 @@ type Hearts struct { UserIds []string `json:"userIds"` } +// ImportDiscoveryArtistsRequest defines model for ImportDiscoveryArtistsRequest. +type ImportDiscoveryArtistsRequest struct { + Artists []DiscoverySeed `json:"artists"` + From ImportDiscoveryArtistsRequestFrom `json:"from"` +} + +// ImportDiscoveryArtistsRequestFrom defines model for ImportDiscoveryArtistsRequest.From. +type ImportDiscoveryArtistsRequestFrom string + // Invite defines model for Invite. type Invite struct { Code string `json:"code"` @@ -4719,11 +4943,18 @@ type UpdateDiscoverySetRequest struct { // FreshDays How far back a set's Fresh feed looks, in days. FreshDays *DiscoveryFreshDays `json:"freshDays,omitempty"` + GrowAuto *bool `json:"growAuto,omitempty"` // Mode Which feeds a set shows: `deep` cuts (anything new to you), `fresh` // releases (recent albums, EPs and singles), or `both`. Mode *DiscoveryMode `json:"mode,omitempty"` Name *string `json:"name,omitempty"` + + // RoomId Link a room you can open, or "" to unlink. + RoomId *string `json:"roomId,omitempty"` + + // RoomWeight How much a linked room's taste counts against the set's own artists. + RoomWeight *DiscoveryRoomWeight `json:"roomWeight,omitempty"` } // UpdateDisplayRequest defines model for UpdateDisplayRequest. @@ -4796,6 +5027,11 @@ type UpdateUserRequest struct { Role *Role `json:"role,omitempty"` } +// UpdateWantRequest defines model for UpdateWantRequest. +type UpdateWantRequest struct { + Status WantStatus `json:"status"` +} + // User defines model for User. type User struct { // Avatar Image URL, or `icon:` for a built-in icon drawn on `color`. Absent means show initials on `color`. @@ -4872,6 +5108,55 @@ type UserSession struct { // Username 2 to 32 characters: lowercase letters, digits, `.`, `_` and `-`. type Username = string +// WantItem defines model for WantItem. +type WantItem struct { + AddedAt time.Time `json:"addedAt"` + Artist string `json:"artist"` + + // Cover Its Deezer cover's image ID. + Cover *string `json:"cover,omitempty"` + + // DeezerId The song or release on Deezer, for a preview or its songs. + DeezerId *int64 `json:"deezerId,omitempty"` + GotAt *time.Time `json:"gotAt,omitempty"` + + // GotBy It turned up in your library (auto), or you said you got it. + GotBy *WantItemGotBy `json:"gotBy,omitempty"` + Id string `json:"id"` + Isrc *string `json:"isrc,omitempty"` + Kind WantKind `json:"kind"` + + // Mbid A song's recording, or a release's release group. + Mbid *string `json:"mbid,omitempty"` + + // Release A song's album, if known. + Release *string `json:"release,omitempty"` + + // SetId The set whose feed it came from, if it's still there. + SetId *string `json:"setId,omitempty"` + Status WantStatus `json:"status"` + + // Title The song's title, or the release's. + Title string `json:"title"` +} + +// WantItemGotBy It turned up in your library (auto), or you said you got it. +type WantItemGotBy string + +// WantKind defines model for WantKind. +type WantKind string + +// WantList defines model for WantList. +type WantList struct { + Items []WantItem `json:"items"` + + // Landed How many turned up in your library since you last looked. + Landed int `json:"landed"` +} + +// WantStatus defines model for WantStatus. +type WantStatus string + // DiscoverySetId defines model for DiscoverySetId. type DiscoverySetId = string @@ -5000,6 +5285,17 @@ type ListDiscoveryReleaseTracksParams struct { DeezerId *int64 `form:"deezerId,omitempty" json:"deezerId,omitempty"` } +// GetDiscoveryRoomTasteParams defines parameters for GetDiscoveryRoomTaste. +type GetDiscoveryRoomTasteParams struct { + RoomId string `form:"roomId" json:"roomId"` + + // NightId One of the room's past nights. + NightId *string `form:"nightId,omitempty" json:"nightId,omitempty"` + + // Mine Only from songs you picked. + Mine *bool `form:"mine,omitempty" json:"mine,omitempty"` +} + // GetDiscoveryFeedParams defines parameters for GetDiscoveryFeed. type GetDiscoveryFeedParams struct { // Cursor The previous page's `next`. @@ -5211,6 +5507,18 @@ type UpdateDiscoverySetJSONRequestBody = UpdateDiscoverySetRequest // AddDiscoverySetArtistJSONRequestBody defines body for AddDiscoverySetArtist for application/json ContentType. type AddDiscoverySetArtistJSONRequestBody = DiscoverySeed +// ImportDiscoverySetArtistsJSONRequestBody defines body for ImportDiscoverySetArtists for application/json ContentType. +type ImportDiscoverySetArtistsJSONRequestBody = ImportDiscoveryArtistsRequest + +// DismissDiscoverySuggestionJSONRequestBody defines body for DismissDiscoverySuggestion for application/json ContentType. +type DismissDiscoverySuggestionJSONRequestBody = DismissDiscoverySuggestionRequest + +// AddDiscoveryWantJSONRequestBody defines body for AddDiscoveryWant for application/json ContentType. +type AddDiscoveryWantJSONRequestBody = AddWantRequest + +// UpdateDiscoveryWantJSONRequestBody defines body for UpdateDiscoveryWant for application/json ContentType. +type UpdateDiscoveryWantJSONRequestBody = UpdateWantRequest + // FinishAddPasskeyJSONRequestBody defines body for FinishAddPasskey for application/json ContentType. type FinishAddPasskeyJSONRequestBody = FinishCeremony @@ -5516,6 +5824,9 @@ type ServerInterface interface { // ListDiscoveryReleaseTracks A release's songs, with previews // (GET /me/discover/release-tracks) ListDiscoveryReleaseTracks(w http.ResponseWriter, r *http.Request, params ListDiscoveryReleaseTracksParams) + // GetDiscoveryRoomTaste A room's favorite artists, to make a set from + // (GET /me/discover/room-taste) + GetDiscoveryRoomTaste(w http.ResponseWriter, r *http.Request, params GetDiscoveryRoomTasteParams) // ListDiscoverySets Your discovery sets // (GET /me/discover/sets) ListDiscoverySets(w http.ResponseWriter, r *http.Request) @@ -5543,9 +5854,33 @@ type ServerInterface interface { // GetDiscoveryFresh A set's Fresh feed // (GET /me/discover/sets/{setId}/fresh) GetDiscoveryFresh(w http.ResponseWriter, r *http.Request, setId DiscoverySetId) + // ImportDiscoverySetArtists Add artists you picked to a set at once + // (POST /me/discover/sets/{setId}/import) + ImportDiscoverySetArtists(w http.ResponseWriter, r *http.Request, setId DiscoverySetId) + // GetDiscoverySuggestions What a set's thumbs suggest + // (GET /me/discover/sets/{setId}/suggestions) + GetDiscoverySuggestions(w http.ResponseWriter, r *http.Request, setId DiscoverySetId) + // DismissDiscoverySuggestion Say no to a suggestion + // (POST /me/discover/sets/{setId}/suggestions/dismiss) + DismissDiscoverySuggestion(w http.ResponseWriter, r *http.Request, setId DiscoverySetId) // ListDiscoveryArtistSongs An artist's songs you don't have // (GET /me/discover/songs) ListDiscoveryArtistSongs(w http.ResponseWriter, r *http.Request, params ListDiscoveryArtistSongsParams) + // ListDiscoveryStarred Artists you starred on your Navidrome, to pick seeds from + // (GET /me/discover/starred) + ListDiscoveryStarred(w http.ResponseWriter, r *http.Request) + // ListDiscoveryWants Your Want list + // (GET /me/discover/want) + ListDiscoveryWants(w http.ResponseWriter, r *http.Request) + // AddDiscoveryWant Want a song or release + // (POST /me/discover/want) + AddDiscoveryWant(w http.ResponseWriter, r *http.Request) + // SeeDiscoveryLanded You saw what landed in your library + // (POST /me/discover/want/seen) + SeeDiscoveryLanded(w http.ResponseWriter, r *http.Request) + // UpdateDiscoveryWant Mark a thing wanted, got or dismissed + // (PATCH /me/discover/want/{wantId}) + UpdateDiscoveryWant(w http.ResponseWriter, r *http.Request, wantId string) // ListPasskeys Your passkeys // (GET /me/passkeys) ListPasskeys(w http.ResponseWriter, r *http.Request) @@ -7653,6 +7988,65 @@ func (siw *ServerInterfaceWrapper) ListDiscoveryReleaseTracks(w http.ResponseWri handler.ServeHTTP(w, r) } +// GetDiscoveryRoomTaste operation middleware +func (siw *ServerInterfaceWrapper) GetDiscoveryRoomTaste(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params GetDiscoveryRoomTasteParams + + // ------------- Required query parameter "roomId" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "roomId", r.URL.Query(), ¶ms.RoomId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "roomId"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roomId", Err: err}) + } + return + } + + // ------------- Optional query parameter "nightId" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "nightId", r.URL.Query(), ¶ms.NightId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "nightId"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "nightId", Err: err}) + } + return + } + + // ------------- Optional query parameter "mine" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "mine", r.URL.Query(), ¶ms.Mine, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "mine"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "mine", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetDiscoveryRoomTaste(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListDiscoverySets operation middleware func (siw *ServerInterfaceWrapper) ListDiscoverySets(w http.ResponseWriter, r *http.Request) { @@ -7901,6 +8295,84 @@ func (siw *ServerInterfaceWrapper) GetDiscoveryFresh(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } +// ImportDiscoverySetArtists operation middleware +func (siw *ServerInterfaceWrapper) ImportDiscoverySetArtists(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "setId" ------------- + var setId DiscoverySetId + + err = runtime.BindStyledParameterWithOptions("simple", "setId", r.PathValue("setId"), &setId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "setId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ImportDiscoverySetArtists(w, r, setId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetDiscoverySuggestions operation middleware +func (siw *ServerInterfaceWrapper) GetDiscoverySuggestions(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "setId" ------------- + var setId DiscoverySetId + + err = runtime.BindStyledParameterWithOptions("simple", "setId", r.PathValue("setId"), &setId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "setId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetDiscoverySuggestions(w, r, setId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DismissDiscoverySuggestion operation middleware +func (siw *ServerInterfaceWrapper) DismissDiscoverySuggestion(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "setId" ------------- + var setId DiscoverySetId + + err = runtime.BindStyledParameterWithOptions("simple", "setId", r.PathValue("setId"), &setId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "setId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DismissDiscoverySuggestion(w, r, setId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListDiscoveryArtistSongs operation middleware func (siw *ServerInterfaceWrapper) ListDiscoveryArtistSongs(w http.ResponseWriter, r *http.Request) { @@ -7947,8 +8419,90 @@ func (siw *ServerInterfaceWrapper) ListDiscoveryArtistSongs(w http.ResponseWrite handler.ServeHTTP(w, r) } -// ListPasskeys operation middleware -func (siw *ServerInterfaceWrapper) ListPasskeys(w http.ResponseWriter, r *http.Request) { +// ListDiscoveryStarred operation middleware +func (siw *ServerInterfaceWrapper) ListDiscoveryStarred(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListDiscoveryStarred(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListDiscoveryWants operation middleware +func (siw *ServerInterfaceWrapper) ListDiscoveryWants(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListDiscoveryWants(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AddDiscoveryWant operation middleware +func (siw *ServerInterfaceWrapper) AddDiscoveryWant(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AddDiscoveryWant(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SeeDiscoveryLanded operation middleware +func (siw *ServerInterfaceWrapper) SeeDiscoveryLanded(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SeeDiscoveryLanded(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateDiscoveryWant operation middleware +func (siw *ServerInterfaceWrapper) UpdateDiscoveryWant(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "wantId" ------------- + var wantId string + + err = runtime.BindStyledParameterWithOptions("simple", "wantId", r.PathValue("wantId"), &wantId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "wantId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateDiscoveryWant(w, r, wantId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListPasskeys operation middleware +func (siw *ServerInterfaceWrapper) ListPasskeys(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.ListPasskeys(w, r) @@ -11560,6 +12114,15 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/me/discover/sets/{setId}/artists/{artistKey}", wrapper.RemoveDiscoverySetArtist) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/sets/{setId}/feed", wrapper.GetDiscoveryFeed) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/sets/{setId}/fresh", wrapper.GetDiscoveryFresh) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/sets/{setId}/suggestions", wrapper.GetDiscoverySuggestions) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/discover/sets/{setId}/suggestions/dismiss", wrapper.DismissDiscoverySuggestion) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/discover/sets/{setId}/import", wrapper.ImportDiscoverySetArtists) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/starred", wrapper.ListDiscoveryStarred) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/room-taste", wrapper.GetDiscoveryRoomTaste) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/want", wrapper.ListDiscoveryWants) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/discover/want", wrapper.AddDiscoveryWant) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/discover/want/seen", wrapper.SeeDiscoveryLanded) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/me/discover/want/{wantId}", wrapper.UpdateDiscoveryWant) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/release-tracks", wrapper.ListDiscoveryReleaseTracks) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/me/discover/release-art/{mbid}", wrapper.GetDiscoveryReleaseArt) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/me/discover/feedback", wrapper.PostDiscoveryFeedback) @@ -13941,12 +14504,18 @@ type PostDiscoveryFeedbackResponseObject interface { VisitPostDiscoveryFeedbackResponse(w http.ResponseWriter) error } -type PostDiscoveryFeedback204Response struct { -} +type PostDiscoveryFeedback200JSONResponse DiscoveryThumbResult -func (response PostDiscoveryFeedback204Response) VisitPostDiscoveryFeedbackResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil +func (response PostDiscoveryFeedback200JSONResponse) VisitPostDiscoveryFeedbackResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err } type PostDiscoveryFeedbackdefaultJSONResponse struct { @@ -14283,6 +14852,45 @@ func (response ListDiscoveryReleaseTracksdefaultJSONResponse) VisitListDiscovery return err } +type GetDiscoveryRoomTasteRequestObject struct { + Params GetDiscoveryRoomTasteParams +} + +type GetDiscoveryRoomTasteResponseObject interface { + VisitGetDiscoveryRoomTasteResponse(w http.ResponseWriter) error +} + +type GetDiscoveryRoomTaste200JSONResponse []DiscoveryTasteArtist + +func (response GetDiscoveryRoomTaste200JSONResponse) VisitGetDiscoveryRoomTasteResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetDiscoveryRoomTastedefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetDiscoveryRoomTastedefaultJSONResponse) VisitGetDiscoveryRoomTasteResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + type ListDiscoverySetsRequestObject struct { } @@ -14437,13 +15045,324 @@ type UpdateDiscoverySetRequestObject struct { Body *UpdateDiscoverySetJSONRequestBody } -type UpdateDiscoverySetResponseObject interface { - VisitUpdateDiscoverySetResponse(w http.ResponseWriter) error +type UpdateDiscoverySetResponseObject interface { + VisitUpdateDiscoverySetResponse(w http.ResponseWriter) error +} + +type UpdateDiscoverySet200JSONResponse DiscoverySet + +func (response UpdateDiscoverySet200JSONResponse) VisitUpdateDiscoverySetResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type UpdateDiscoverySetdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response UpdateDiscoverySetdefaultJSONResponse) VisitUpdateDiscoverySetResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type AddDiscoverySetArtistRequestObject struct { + SetId DiscoverySetId `json:"setId"` + Body *AddDiscoverySetArtistJSONRequestBody +} + +type AddDiscoverySetArtistResponseObject interface { + VisitAddDiscoverySetArtistResponse(w http.ResponseWriter) error +} + +type AddDiscoverySetArtist200JSONResponse DiscoverySet + +func (response AddDiscoverySetArtist200JSONResponse) VisitAddDiscoverySetArtistResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type AddDiscoverySetArtistdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response AddDiscoverySetArtistdefaultJSONResponse) VisitAddDiscoverySetArtistResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type RemoveDiscoverySetArtistRequestObject struct { + SetId DiscoverySetId `json:"setId"` + ArtistKey string `json:"artistKey"` +} + +type RemoveDiscoverySetArtistResponseObject interface { + VisitRemoveDiscoverySetArtistResponse(w http.ResponseWriter) error +} + +type RemoveDiscoverySetArtist200JSONResponse DiscoverySet + +func (response RemoveDiscoverySetArtist200JSONResponse) VisitRemoveDiscoverySetArtistResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type RemoveDiscoverySetArtistdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response RemoveDiscoverySetArtistdefaultJSONResponse) VisitRemoveDiscoverySetArtistResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type GetDiscoveryFeedRequestObject struct { + SetId DiscoverySetId `json:"setId"` + Params GetDiscoveryFeedParams +} + +type GetDiscoveryFeedResponseObject interface { + VisitGetDiscoveryFeedResponse(w http.ResponseWriter) error +} + +type GetDiscoveryFeed200JSONResponse DiscoveryFeed + +func (response GetDiscoveryFeed200JSONResponse) VisitGetDiscoveryFeedResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetDiscoveryFeeddefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetDiscoveryFeeddefaultJSONResponse) VisitGetDiscoveryFeedResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type GetDiscoveryFreshRequestObject struct { + SetId DiscoverySetId `json:"setId"` +} + +type GetDiscoveryFreshResponseObject interface { + VisitGetDiscoveryFreshResponse(w http.ResponseWriter) error +} + +type GetDiscoveryFresh200JSONResponse DiscoveryFresh + +func (response GetDiscoveryFresh200JSONResponse) VisitGetDiscoveryFreshResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetDiscoveryFreshdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetDiscoveryFreshdefaultJSONResponse) VisitGetDiscoveryFreshResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type ImportDiscoverySetArtistsRequestObject struct { + SetId DiscoverySetId `json:"setId"` + Body *ImportDiscoverySetArtistsJSONRequestBody +} + +type ImportDiscoverySetArtistsResponseObject interface { + VisitImportDiscoverySetArtistsResponse(w http.ResponseWriter) error +} + +type ImportDiscoverySetArtists200JSONResponse DiscoverySet + +func (response ImportDiscoverySetArtists200JSONResponse) VisitImportDiscoverySetArtistsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type ImportDiscoverySetArtistsdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ImportDiscoverySetArtistsdefaultJSONResponse) VisitImportDiscoverySetArtistsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type GetDiscoverySuggestionsRequestObject struct { + SetId DiscoverySetId `json:"setId"` +} + +type GetDiscoverySuggestionsResponseObject interface { + VisitGetDiscoverySuggestionsResponse(w http.ResponseWriter) error +} + +type GetDiscoverySuggestions200JSONResponse DiscoverySuggestions + +func (response GetDiscoverySuggestions200JSONResponse) VisitGetDiscoverySuggestionsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetDiscoverySuggestionsdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response GetDiscoverySuggestionsdefaultJSONResponse) VisitGetDiscoverySuggestionsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type DismissDiscoverySuggestionRequestObject struct { + SetId DiscoverySetId `json:"setId"` + Body *DismissDiscoverySuggestionJSONRequestBody +} + +type DismissDiscoverySuggestionResponseObject interface { + VisitDismissDiscoverySuggestionResponse(w http.ResponseWriter) error +} + +type DismissDiscoverySuggestion204Response struct { +} + +func (response DismissDiscoverySuggestion204Response) VisitDismissDiscoverySuggestionResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type DismissDiscoverySuggestiondefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response DismissDiscoverySuggestiondefaultJSONResponse) VisitDismissDiscoverySuggestionResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type ListDiscoveryArtistSongsRequestObject struct { + Params ListDiscoveryArtistSongsParams +} + +type ListDiscoveryArtistSongsResponseObject interface { + VisitListDiscoveryArtistSongsResponse(w http.ResponseWriter) error } -type UpdateDiscoverySet200JSONResponse DiscoverySet +type ListDiscoveryArtistSongs200JSONResponse DiscoverySongs -func (response UpdateDiscoverySet200JSONResponse) VisitUpdateDiscoverySetResponse(w http.ResponseWriter) error { +func (response ListDiscoveryArtistSongs200JSONResponse) VisitListDiscoveryArtistSongsResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response); err != nil { @@ -14455,12 +15374,12 @@ func (response UpdateDiscoverySet200JSONResponse) VisitUpdateDiscoverySetRespons return err } -type UpdateDiscoverySetdefaultJSONResponse struct { +type ListDiscoveryArtistSongsdefaultJSONResponse struct { Body Error StatusCode int } -func (response UpdateDiscoverySetdefaultJSONResponse) VisitUpdateDiscoverySetResponse(w http.ResponseWriter) error { +func (response ListDiscoveryArtistSongsdefaultJSONResponse) VisitListDiscoveryArtistSongsResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { @@ -14472,18 +15391,16 @@ func (response UpdateDiscoverySetdefaultJSONResponse) VisitUpdateDiscoverySetRes return err } -type AddDiscoverySetArtistRequestObject struct { - SetId DiscoverySetId `json:"setId"` - Body *AddDiscoverySetArtistJSONRequestBody +type ListDiscoveryStarredRequestObject struct { } -type AddDiscoverySetArtistResponseObject interface { - VisitAddDiscoverySetArtistResponse(w http.ResponseWriter) error +type ListDiscoveryStarredResponseObject interface { + VisitListDiscoveryStarredResponse(w http.ResponseWriter) error } -type AddDiscoverySetArtist200JSONResponse DiscoverySet +type ListDiscoveryStarred200JSONResponse []DiscoveryStarredArtist -func (response AddDiscoverySetArtist200JSONResponse) VisitAddDiscoverySetArtistResponse(w http.ResponseWriter) error { +func (response ListDiscoveryStarred200JSONResponse) VisitListDiscoveryStarredResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response); err != nil { @@ -14495,12 +15412,12 @@ func (response AddDiscoverySetArtist200JSONResponse) VisitAddDiscoverySetArtistR return err } -type AddDiscoverySetArtistdefaultJSONResponse struct { +type ListDiscoveryStarreddefaultJSONResponse struct { Body Error StatusCode int } -func (response AddDiscoverySetArtistdefaultJSONResponse) VisitAddDiscoverySetArtistResponse(w http.ResponseWriter) error { +func (response ListDiscoveryStarreddefaultJSONResponse) VisitListDiscoveryStarredResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { @@ -14512,18 +15429,16 @@ func (response AddDiscoverySetArtistdefaultJSONResponse) VisitAddDiscoverySetArt return err } -type RemoveDiscoverySetArtistRequestObject struct { - SetId DiscoverySetId `json:"setId"` - ArtistKey string `json:"artistKey"` +type ListDiscoveryWantsRequestObject struct { } -type RemoveDiscoverySetArtistResponseObject interface { - VisitRemoveDiscoverySetArtistResponse(w http.ResponseWriter) error +type ListDiscoveryWantsResponseObject interface { + VisitListDiscoveryWantsResponse(w http.ResponseWriter) error } -type RemoveDiscoverySetArtist200JSONResponse DiscoverySet +type ListDiscoveryWants200JSONResponse WantList -func (response RemoveDiscoverySetArtist200JSONResponse) VisitRemoveDiscoverySetArtistResponse(w http.ResponseWriter) error { +func (response ListDiscoveryWants200JSONResponse) VisitListDiscoveryWantsResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response); err != nil { @@ -14535,12 +15450,12 @@ func (response RemoveDiscoverySetArtist200JSONResponse) VisitRemoveDiscoverySetA return err } -type RemoveDiscoverySetArtistdefaultJSONResponse struct { +type ListDiscoveryWantsdefaultJSONResponse struct { Body Error StatusCode int } -func (response RemoveDiscoverySetArtistdefaultJSONResponse) VisitRemoveDiscoverySetArtistResponse(w http.ResponseWriter) error { +func (response ListDiscoveryWantsdefaultJSONResponse) VisitListDiscoveryWantsResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { @@ -14552,18 +15467,17 @@ func (response RemoveDiscoverySetArtistdefaultJSONResponse) VisitRemoveDiscovery return err } -type GetDiscoveryFeedRequestObject struct { - SetId DiscoverySetId `json:"setId"` - Params GetDiscoveryFeedParams +type AddDiscoveryWantRequestObject struct { + Body *AddDiscoveryWantJSONRequestBody } -type GetDiscoveryFeedResponseObject interface { - VisitGetDiscoveryFeedResponse(w http.ResponseWriter) error +type AddDiscoveryWantResponseObject interface { + VisitAddDiscoveryWantResponse(w http.ResponseWriter) error } -type GetDiscoveryFeed200JSONResponse DiscoveryFeed +type AddDiscoveryWant200JSONResponse WantItem -func (response GetDiscoveryFeed200JSONResponse) VisitGetDiscoveryFeedResponse(w http.ResponseWriter) error { +func (response AddDiscoveryWant200JSONResponse) VisitAddDiscoveryWantResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response); err != nil { @@ -14575,12 +15489,12 @@ func (response GetDiscoveryFeed200JSONResponse) VisitGetDiscoveryFeedResponse(w return err } -type GetDiscoveryFeeddefaultJSONResponse struct { +type AddDiscoveryWantdefaultJSONResponse struct { Body Error StatusCode int } -func (response GetDiscoveryFeeddefaultJSONResponse) VisitGetDiscoveryFeedResponse(w http.ResponseWriter) error { +func (response AddDiscoveryWantdefaultJSONResponse) VisitAddDiscoveryWantResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { @@ -14592,34 +15506,27 @@ func (response GetDiscoveryFeeddefaultJSONResponse) VisitGetDiscoveryFeedRespons return err } -type GetDiscoveryFreshRequestObject struct { - SetId DiscoverySetId `json:"setId"` +type SeeDiscoveryLandedRequestObject struct { } -type GetDiscoveryFreshResponseObject interface { - VisitGetDiscoveryFreshResponse(w http.ResponseWriter) error +type SeeDiscoveryLandedResponseObject interface { + VisitSeeDiscoveryLandedResponse(w http.ResponseWriter) error } -type GetDiscoveryFresh200JSONResponse DiscoveryFresh - -func (response GetDiscoveryFresh200JSONResponse) VisitGetDiscoveryFreshResponse(w http.ResponseWriter) error { +type SeeDiscoveryLanded204Response struct { +} - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - _, err := buf.WriteTo(w) - return err +func (response SeeDiscoveryLanded204Response) VisitSeeDiscoveryLandedResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil } -type GetDiscoveryFreshdefaultJSONResponse struct { +type SeeDiscoveryLandeddefaultJSONResponse struct { Body Error StatusCode int } -func (response GetDiscoveryFreshdefaultJSONResponse) VisitGetDiscoveryFreshResponse(w http.ResponseWriter) error { +func (response SeeDiscoveryLandeddefaultJSONResponse) VisitSeeDiscoveryLandedResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { @@ -14631,17 +15538,18 @@ func (response GetDiscoveryFreshdefaultJSONResponse) VisitGetDiscoveryFreshRespo return err } -type ListDiscoveryArtistSongsRequestObject struct { - Params ListDiscoveryArtistSongsParams +type UpdateDiscoveryWantRequestObject struct { + WantId string `json:"wantId"` + Body *UpdateDiscoveryWantJSONRequestBody } -type ListDiscoveryArtistSongsResponseObject interface { - VisitListDiscoveryArtistSongsResponse(w http.ResponseWriter) error +type UpdateDiscoveryWantResponseObject interface { + VisitUpdateDiscoveryWantResponse(w http.ResponseWriter) error } -type ListDiscoveryArtistSongs200JSONResponse DiscoverySongs +type UpdateDiscoveryWant200JSONResponse WantItem -func (response ListDiscoveryArtistSongs200JSONResponse) VisitListDiscoveryArtistSongsResponse(w http.ResponseWriter) error { +func (response UpdateDiscoveryWant200JSONResponse) VisitUpdateDiscoveryWantResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response); err != nil { @@ -14653,12 +15561,12 @@ func (response ListDiscoveryArtistSongs200JSONResponse) VisitListDiscoveryArtist return err } -type ListDiscoveryArtistSongsdefaultJSONResponse struct { +type UpdateDiscoveryWantdefaultJSONResponse struct { Body Error StatusCode int } -func (response ListDiscoveryArtistSongsdefaultJSONResponse) VisitListDiscoveryArtistSongsResponse(w http.ResponseWriter) error { +func (response UpdateDiscoveryWantdefaultJSONResponse) VisitUpdateDiscoveryWantResponse(w http.ResponseWriter) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { @@ -19301,6 +20209,9 @@ type StrictServerInterface interface { // ListDiscoveryReleaseTracks A release's songs, with previews // (GET /me/discover/release-tracks) ListDiscoveryReleaseTracks(ctx context.Context, request ListDiscoveryReleaseTracksRequestObject) (ListDiscoveryReleaseTracksResponseObject, error) + // GetDiscoveryRoomTaste A room's favorite artists, to make a set from + // (GET /me/discover/room-taste) + GetDiscoveryRoomTaste(ctx context.Context, request GetDiscoveryRoomTasteRequestObject) (GetDiscoveryRoomTasteResponseObject, error) // ListDiscoverySets Your discovery sets // (GET /me/discover/sets) ListDiscoverySets(ctx context.Context, request ListDiscoverySetsRequestObject) (ListDiscoverySetsResponseObject, error) @@ -19328,9 +20239,33 @@ type StrictServerInterface interface { // GetDiscoveryFresh A set's Fresh feed // (GET /me/discover/sets/{setId}/fresh) GetDiscoveryFresh(ctx context.Context, request GetDiscoveryFreshRequestObject) (GetDiscoveryFreshResponseObject, error) + // ImportDiscoverySetArtists Add artists you picked to a set at once + // (POST /me/discover/sets/{setId}/import) + ImportDiscoverySetArtists(ctx context.Context, request ImportDiscoverySetArtistsRequestObject) (ImportDiscoverySetArtistsResponseObject, error) + // GetDiscoverySuggestions What a set's thumbs suggest + // (GET /me/discover/sets/{setId}/suggestions) + GetDiscoverySuggestions(ctx context.Context, request GetDiscoverySuggestionsRequestObject) (GetDiscoverySuggestionsResponseObject, error) + // DismissDiscoverySuggestion Say no to a suggestion + // (POST /me/discover/sets/{setId}/suggestions/dismiss) + DismissDiscoverySuggestion(ctx context.Context, request DismissDiscoverySuggestionRequestObject) (DismissDiscoverySuggestionResponseObject, error) // ListDiscoveryArtistSongs An artist's songs you don't have // (GET /me/discover/songs) ListDiscoveryArtistSongs(ctx context.Context, request ListDiscoveryArtistSongsRequestObject) (ListDiscoveryArtistSongsResponseObject, error) + // ListDiscoveryStarred Artists you starred on your Navidrome, to pick seeds from + // (GET /me/discover/starred) + ListDiscoveryStarred(ctx context.Context, request ListDiscoveryStarredRequestObject) (ListDiscoveryStarredResponseObject, error) + // ListDiscoveryWants Your Want list + // (GET /me/discover/want) + ListDiscoveryWants(ctx context.Context, request ListDiscoveryWantsRequestObject) (ListDiscoveryWantsResponseObject, error) + // AddDiscoveryWant Want a song or release + // (POST /me/discover/want) + AddDiscoveryWant(ctx context.Context, request AddDiscoveryWantRequestObject) (AddDiscoveryWantResponseObject, error) + // SeeDiscoveryLanded You saw what landed in your library + // (POST /me/discover/want/seen) + SeeDiscoveryLanded(ctx context.Context, request SeeDiscoveryLandedRequestObject) (SeeDiscoveryLandedResponseObject, error) + // UpdateDiscoveryWant Mark a thing wanted, got or dismissed + // (PATCH /me/discover/want/{wantId}) + UpdateDiscoveryWant(ctx context.Context, request UpdateDiscoveryWantRequestObject) (UpdateDiscoveryWantResponseObject, error) // ListPasskeys Your passkeys // (GET /me/passkeys) ListPasskeys(ctx context.Context, request ListPasskeysRequestObject) (ListPasskeysResponseObject, error) @@ -21560,6 +22495,32 @@ func (sh *strictHandler) ListDiscoveryReleaseTracks(w http.ResponseWriter, r *ht } } +// GetDiscoveryRoomTaste operation middleware +func (sh *strictHandler) GetDiscoveryRoomTaste(w http.ResponseWriter, r *http.Request, params GetDiscoveryRoomTasteParams) { + var request GetDiscoveryRoomTasteRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetDiscoveryRoomTaste(ctx, request.(GetDiscoveryRoomTasteRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetDiscoveryRoomTaste") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetDiscoveryRoomTasteResponseObject); ok { + if err := validResponse.VisitGetDiscoveryRoomTasteResponse(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)) + } +} + // ListDiscoverySets operation middleware func (sh *strictHandler) ListDiscoverySets(w http.ResponseWriter, r *http.Request) { var request ListDiscoverySetsRequestObject @@ -21813,6 +22774,98 @@ func (sh *strictHandler) GetDiscoveryFresh(w http.ResponseWriter, r *http.Reques } } +// ImportDiscoverySetArtists operation middleware +func (sh *strictHandler) ImportDiscoverySetArtists(w http.ResponseWriter, r *http.Request, setId DiscoverySetId) { + var request ImportDiscoverySetArtistsRequestObject + + request.SetId = setId + + var body ImportDiscoverySetArtistsJSONRequestBody + 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.ImportDiscoverySetArtists(ctx, request.(ImportDiscoverySetArtistsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ImportDiscoverySetArtists") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ImportDiscoverySetArtistsResponseObject); ok { + if err := validResponse.VisitImportDiscoverySetArtistsResponse(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)) + } +} + +// GetDiscoverySuggestions operation middleware +func (sh *strictHandler) GetDiscoverySuggestions(w http.ResponseWriter, r *http.Request, setId DiscoverySetId) { + var request GetDiscoverySuggestionsRequestObject + + request.SetId = setId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetDiscoverySuggestions(ctx, request.(GetDiscoverySuggestionsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetDiscoverySuggestions") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetDiscoverySuggestionsResponseObject); ok { + if err := validResponse.VisitGetDiscoverySuggestionsResponse(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)) + } +} + +// DismissDiscoverySuggestion operation middleware +func (sh *strictHandler) DismissDiscoverySuggestion(w http.ResponseWriter, r *http.Request, setId DiscoverySetId) { + var request DismissDiscoverySuggestionRequestObject + + request.SetId = setId + + var body DismissDiscoverySuggestionJSONRequestBody + 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.DismissDiscoverySuggestion(ctx, request.(DismissDiscoverySuggestionRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DismissDiscoverySuggestion") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DismissDiscoverySuggestionResponseObject); ok { + if err := validResponse.VisitDismissDiscoverySuggestionResponse(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)) + } +} + // ListDiscoveryArtistSongs operation middleware func (sh *strictHandler) ListDiscoveryArtistSongs(w http.ResponseWriter, r *http.Request, params ListDiscoveryArtistSongsParams) { var request ListDiscoveryArtistSongsRequestObject @@ -21839,6 +22892,142 @@ func (sh *strictHandler) ListDiscoveryArtistSongs(w http.ResponseWriter, r *http } } +// ListDiscoveryStarred operation middleware +func (sh *strictHandler) ListDiscoveryStarred(w http.ResponseWriter, r *http.Request) { + var request ListDiscoveryStarredRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListDiscoveryStarred(ctx, request.(ListDiscoveryStarredRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListDiscoveryStarred") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListDiscoveryStarredResponseObject); ok { + if err := validResponse.VisitListDiscoveryStarredResponse(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)) + } +} + +// ListDiscoveryWants operation middleware +func (sh *strictHandler) ListDiscoveryWants(w http.ResponseWriter, r *http.Request) { + var request ListDiscoveryWantsRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListDiscoveryWants(ctx, request.(ListDiscoveryWantsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListDiscoveryWants") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListDiscoveryWantsResponseObject); ok { + if err := validResponse.VisitListDiscoveryWantsResponse(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)) + } +} + +// AddDiscoveryWant operation middleware +func (sh *strictHandler) AddDiscoveryWant(w http.ResponseWriter, r *http.Request) { + var request AddDiscoveryWantRequestObject + + var body AddDiscoveryWantJSONRequestBody + 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.AddDiscoveryWant(ctx, request.(AddDiscoveryWantRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "AddDiscoveryWant") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(AddDiscoveryWantResponseObject); ok { + if err := validResponse.VisitAddDiscoveryWantResponse(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)) + } +} + +// SeeDiscoveryLanded operation middleware +func (sh *strictHandler) SeeDiscoveryLanded(w http.ResponseWriter, r *http.Request) { + var request SeeDiscoveryLandedRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SeeDiscoveryLanded(ctx, request.(SeeDiscoveryLandedRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SeeDiscoveryLanded") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SeeDiscoveryLandedResponseObject); ok { + if err := validResponse.VisitSeeDiscoveryLandedResponse(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)) + } +} + +// UpdateDiscoveryWant operation middleware +func (sh *strictHandler) UpdateDiscoveryWant(w http.ResponseWriter, r *http.Request, wantId string) { + var request UpdateDiscoveryWantRequestObject + + request.WantId = wantId + + var body UpdateDiscoveryWantJSONRequestBody + 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.UpdateDiscoveryWant(ctx, request.(UpdateDiscoveryWantRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateDiscoveryWant") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateDiscoveryWantResponseObject); ok { + if err := validResponse.VisitUpdateDiscoveryWantResponse(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)) + } +} + // ListPasskeys operation middleware func (sh *strictHandler) ListPasskeys(w http.ResponseWriter, r *http.Request) { var request ListPasskeysRequestObject diff --git a/server/internal/httpapi/discover.go b/server/internal/httpapi/discover.go index 0c8529f..76c4b7f 100644 --- a/server/internal/httpapi/discover.go +++ b/server/internal/httpapi/discover.go @@ -22,6 +22,10 @@ func toDiscoverySet(set discover.Set) DiscoverySet { Id: set.ID, Name: set.Name, Explore: int(set.Explore), Mode: DiscoveryMode(set.Mode), FreshDays: DiscoveryFreshDays(set.FreshDays), Artists: make([]DiscoverySetArtist, len(set.Artists)), CreatedAt: set.CreatedAt, UpdatedAt: set.UpdatedAt, } + out.GrowAuto, out.RoomWeight = set.GrowAuto != 0, DiscoveryRoomWeight(set.RoomWeight) + if set.RoomID.Valid { + out.RoomId = &set.RoomID.String + } for i, a := range set.Artists { out.Artists[i] = DiscoverySetArtist{ Key: a.ArtistKey, Name: a.Name, Mbid: nonEmpty(a.Mbid.String), AddedFrom: DiscoverySetArtistAddedFrom(a.AddedFrom), AddedAt: a.AddedAt, @@ -60,7 +64,10 @@ func (s *Server) ListDiscoverySets(ctx context.Context, _ ListDiscoverySetsReque for i, set := range sets { out[i] = DiscoverySetSummary{ Id: set.ID, Name: set.Name, Explore: int(set.Explore), Mode: DiscoveryMode(set.Mode), FreshDays: DiscoveryFreshDays(set.FreshDays), ArtistCount: int(set.Artists), - CreatedAt: set.CreatedAt, UpdatedAt: set.UpdatedAt, + Wanted: int(set.Wanted), Got: int(set.Got), CreatedAt: set.CreatedAt, UpdatedAt: set.UpdatedAt, + } + if set.RoomID.Valid { + out[i].RoomId = &set.RoomID.String } } return out, nil @@ -102,7 +109,10 @@ func (s *Server) UpdateDiscoverySet(ctx context.Context, req UpdateDiscoverySetR if err != nil { return nil, err } - c := discover.Changes{Name: req.Body.Name, Explore: req.Body.Explore} + c := discover.Changes{Name: req.Body.Name, Explore: req.Body.Explore, GrowAuto: req.Body.GrowAuto, RoomID: req.Body.RoomId} + if req.Body.RoomWeight != nil { + c.RoomWeight = ptr(string(*req.Body.RoomWeight)) + } if req.Body.Mode != nil { c.Mode = ptr(string(*req.Body.Mode)) } @@ -318,14 +328,19 @@ func (s *Server) PostDiscoveryFeedback(ctx context.Context, req PostDiscoveryFee return nil, err } b := req.Body - song := discover.Song{Title: b.Title, Artist: b.Artist, MBID: deref(b.Mbid), ISRC: deref(b.Isrc)} + song := discover.Song{Title: b.Title, Artist: b.Artist, Album: deref(b.Album), Cover: deref(b.Cover), MBID: deref(b.Mbid), ISRC: deref(b.Isrc)} if b.DeezerId != nil { song.DeezerID = *b.DeezerId } - if err := s.Discover.Thumb(ctx, u.ID, deref(b.SetId), string(b.Kind), song); err != nil { + did, err := s.Discover.Thumb(ctx, u.ID, deref(b.SetId), string(b.Kind), song) + if err != nil { return nil, err } - return PostDiscoveryFeedback204Response{}, nil + out := DiscoveryThumbResult{Grew: nonEmpty(did.Grew)} + if did.Want != nil { + out.Want = ptr(toWantItem(*did.Want)) + } + return PostDiscoveryFeedback200JSONResponse(out), nil } // ListDiscoveryBlocks lists the artists the caller blocked. @@ -468,3 +483,162 @@ func (s *Server) GetDiscoveryImage(ctx context.Context, req GetDiscoveryImageReq func (r artworkResponse) VisitGetDiscoveryImageResponse(w http.ResponseWriter) error { return r.write(w) } + +// Stage 4 (MAD-824..826): the Want list, sets that learn, and rooms as a +// set's source. + +func toWantItem(w store.WantItem) WantItem { + out := WantItem{ + Id: w.ID, Kind: WantKind(w.Kind), Artist: w.Artist, Title: w.Title, Release: nonEmpty(w.ReleaseTitle), + Mbid: nonEmpty(w.Mbid.String), Isrc: nonEmpty(w.Isrc.String), DeezerId: nonZero(w.DeezerID.Int64), Cover: nonEmpty(w.Cover), + SetId: nonEmpty(w.SetID.String), Status: WantStatus(w.Status), GotAt: timePtr(w.GotAt.Time, w.GotAt.Valid), AddedAt: w.AddedAt, + } + if w.GotBy.Valid { + out.GotBy = ptr(WantItemGotBy(w.GotBy.String)) + } + return out +} + +// ListDiscoveryWants returns the caller's Want list. +func (s *Server) ListDiscoveryWants(ctx context.Context, _ ListDiscoveryWantsRequestObject) (ListDiscoveryWantsResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + l, err := s.Discover.Wants(ctx, u.ID) + if err != nil { + return nil, err + } + out := WantList{Items: make([]WantItem, len(l.Items)), Landed: l.Landed} + for i, w := range l.Items { + out.Items[i] = toWantItem(w) + } + return ListDiscoveryWants200JSONResponse(out), nil +} + +// AddDiscoveryWant puts a song or release on the caller's Want list. +func (s *Server) AddDiscoveryWant(ctx context.Context, req AddDiscoveryWantRequestObject) (AddDiscoveryWantResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + b := req.Body + w := discover.Want{ + Kind: string(b.Kind), Artist: b.Artist, Title: b.Title, Release: deref(b.Release), + MBID: deref(b.Mbid), ISRC: deref(b.Isrc), Cover: deref(b.Cover), SetID: deref(b.SetId), + } + if b.DeezerId != nil { + w.DeezerID = *b.DeezerId + } + it, err := s.Discover.AddWant(ctx, u.ID, w) + if err != nil { + return nil, err + } + return AddDiscoveryWant200JSONResponse(toWantItem(it)), nil +} + +// UpdateDiscoveryWant marks a thing wanted, got or dismissed. +func (s *Server) UpdateDiscoveryWant(ctx context.Context, req UpdateDiscoveryWantRequestObject) (UpdateDiscoveryWantResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + it, err := s.Discover.SetWant(ctx, u.ID, req.WantId, string(req.Body.Status)) + if err != nil { + return nil, err + } + return UpdateDiscoveryWant200JSONResponse(toWantItem(it)), nil +} + +// SeeDiscoveryLanded notes the caller saw what landed in their library. +func (s *Server) SeeDiscoveryLanded(ctx context.Context, _ SeeDiscoveryLandedRequestObject) (SeeDiscoveryLandedResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + if err := s.Discover.SeeLanded(ctx, u.ID); err != nil { + return nil, err + } + return SeeDiscoveryLanded204Response{}, nil +} + +func toOffers(os []discover.Offer) []DiscoveryOffer { + out := make([]DiscoveryOffer, len(os)) + for i, o := range os { + out[i] = DiscoveryOffer{Key: o.Key, Name: o.Name, Thumbs: o.Thumbs} + } + return out +} + +// GetDiscoverySuggestions returns what a set's thumbs suggest. +func (s *Server) GetDiscoverySuggestions(ctx context.Context, req GetDiscoverySuggestionsRequestObject) (GetDiscoverySuggestionsResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + sg, err := s.Discover.Suggestions(ctx, u.ID, req.SetId) + if err != nil { + return nil, err + } + return GetDiscoverySuggestions200JSONResponse(DiscoverySuggestions{Grow: toOffers(sg.Grow), Block: toOffers(sg.Block)}), nil +} + +// DismissDiscoverySuggestion says no to a suggestion. +func (s *Server) DismissDiscoverySuggestion(ctx context.Context, req DismissDiscoverySuggestionRequestObject) (DismissDiscoverySuggestionResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + if err := s.Discover.Dismiss(ctx, u.ID, req.SetId, string(req.Body.Kind), req.Body.Artist); err != nil { + return nil, err + } + return DismissDiscoverySuggestion204Response{}, nil +} + +// ImportDiscoverySetArtists adds the artists the caller picked to a set. +func (s *Server) ImportDiscoverySetArtists(ctx context.Context, req ImportDiscoverySetArtistsRequestObject) (ImportDiscoverySetArtistsResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + set, err := s.Discover.Import(ctx, u.ID, req.SetId, string(req.Body.From), toSeeds(req.Body.Artists)) + if err != nil { + return nil, err + } + return ImportDiscoverySetArtists200JSONResponse(toDiscoverySet(set)), nil +} + +// ListDiscoveryStarred lists the artists the caller starred on Navidrome. +func (s *Server) ListDiscoveryStarred(ctx context.Context, _ ListDiscoveryStarredRequestObject) (ListDiscoveryStarredResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + as, err := s.Discover.Starred(ctx, u.ID) + if err != nil { + return nil, err + } + out := make(ListDiscoveryStarred200JSONResponse, len(as)) + for i, a := range as { + out[i] = DiscoveryStarredArtist{Name: a.Name, Artist: a.Artist, Albums: a.Albums, Songs: a.Songs} + } + return out, nil +} + +// GetDiscoveryRoomTaste returns a room's favorite artists, or a night's. +func (s *Server) GetDiscoveryRoomTaste(ctx context.Context, req GetDiscoveryRoomTasteRequestObject) (GetDiscoveryRoomTasteResponseObject, error) { + u, err := member(ctx) + if err != nil { + return nil, err + } + p := req.Params + as, err := s.Discover.RoomTaste(ctx, u.ID, p.RoomId, deref(p.NightId), p.Mine != nil && *p.Mine) + if err != nil { + return nil, err + } + out := make(GetDiscoveryRoomTaste200JSONResponse, len(as)) + for i, a := range as { + out[i] = DiscoveryTasteArtist{Name: a.Name, Weight: float32(a.Weight), Plays: a.Plays} + } + return out, nil +} diff --git a/server/internal/httpapi/discover_test.go b/server/internal/httpapi/discover_test.go index e18b3a1..3613541 100644 --- a/server/internal/httpapi/discover_test.go +++ b/server/internal/httpapi/discover_test.go @@ -100,7 +100,7 @@ func TestDiscover(t *testing.T) { t.Fatalf("feed: %+v", feed) } first := feed.Items[0] - alice.want(http.StatusNoContent, "POST", "/me/discover/feedback", httpapi.DiscoveryFeedbackRequest{ + alice.want(http.StatusOK, "POST", "/me/discover/feedback", httpapi.DiscoveryFeedbackRequest{ Kind: httpapi.DiscoveryFeedbackRequestKindDown, SetId: &set.Id, Title: first.Title, Artist: first.Artist, }) alice.want(http.StatusOK, "GET", base+"/feed", nil).decode(t, &feed) @@ -268,3 +268,94 @@ func TestDiscoverFresh(t *testing.T) { t.Errorf("bob's look at alice's fresh feed: %d", r.status) } } + +func TestDiscoverWantsAndLearning(t *testing.T) { + e := newEnv(t) + alice := e.admin() + bob := e.member(alice, "bob") + e.api.Discover.Graph = discoverGraph{} + var set httpapi.DiscoverySet + alice.want(http.StatusCreated, "POST", "/me/discover/sets", httpapi.CreateDiscoverySetRequest{ + Name: "90s alt", Artists: &[]httpapi.DiscoverySeed{{Name: "Hum"}}, + }).decode(t, &set) + if set.GrowAuto || set.RoomWeight != httpapi.RoomWeightMedium || set.RoomId != nil { + t.Fatalf("a new set: %+v", set) + } + base := "/me/discover/sets/" + set.Id + + // Two thumbs-up on Failure: both wanted, and Failure suggested. + var did httpapi.DiscoveryThumbResult + for _, title := range []string{"Failure 1", "Failure 2"} { + alice.want(http.StatusOK, "POST", "/me/discover/feedback", httpapi.DiscoveryFeedbackRequest{ + Kind: httpapi.DiscoveryFeedbackRequestKindUp, SetId: &set.Id, Title: title, Artist: "Failure", Album: ptr("Fantastic Planet"), + }).decode(t, &did) + } + if did.Want == nil || did.Want.Status != httpapi.WantWanted || did.Want.Release == nil || *did.Want.Release != "Fantastic Planet" || did.Grew != nil { + t.Fatalf("thumbs-up: %+v", did) + } + var sg httpapi.DiscoverySuggestions + alice.want(http.StatusOK, "GET", base+"/suggestions", nil).decode(t, &sg) + if len(sg.Grow) != 1 || sg.Grow[0].Name != "Failure" || sg.Grow[0].Thumbs != 2 || len(sg.Block) != 0 { + t.Fatalf("suggestions: %+v", sg) + } + alice.want(http.StatusOK, "POST", base+"/import", httpapi.ImportDiscoveryArtistsRequest{ + From: httpapi.ImportThumb, Artists: []httpapi.DiscoverySeed{{Name: "Failure"}}, + }).decode(t, &set) + if len(set.Artists) != 2 || set.Artists[1].AddedFrom != httpapi.DiscoverySetArtistAddedFromThumb { + t.Fatalf("after taking the suggestion: %+v", set.Artists) + } + alice.want(http.StatusNoContent, "POST", base+"/suggestions/dismiss", httpapi.DismissDiscoverySuggestionRequest{Kind: httpapi.SuggestBlock, Artist: "Shiner"}) + + // The Want list, and a release wanted from Fresh. + var w httpapi.WantItem + alice.want(http.StatusOK, "POST", "/me/discover/want", httpapi.AddWantRequest{ + Kind: httpapi.WantRelease, Artist: "Shiner", Title: "The Egg", Cover: ptr("abc"), SetId: &set.Id, + }).decode(t, &w) + var list httpapi.WantList + alice.want(http.StatusOK, "GET", "/me/discover/want", nil).decode(t, &list) + if len(list.Items) != 3 || list.Items[0].Title != "The Egg" || list.Landed != 0 { + t.Fatalf("want list: %+v", list) + } + alice.want(http.StatusOK, "PATCH", "/me/discover/want/"+w.Id, httpapi.UpdateWantRequest{Status: httpapi.WantGot}).decode(t, &w) + if w.Status != httpapi.WantGot || w.GotBy == nil || *w.GotBy != httpapi.WantGotManual || w.GotAt == nil { + t.Fatalf("got: %+v", w) + } + alice.want(http.StatusNoContent, "POST", "/me/discover/want/seen", nil) + var sums []httpapi.DiscoverySetSummary + alice.want(http.StatusOK, "GET", "/me/discover/sets", nil).decode(t, &sums) + if len(sums) != 1 || sums[0].Wanted != 3 || sums[0].Got != 1 { + t.Errorf("set stats: %+v", sums) + } + if r := bob.do("PATCH", "/me/discover/want/"+w.Id, httpapi.UpdateWantRequest{Status: httpapi.WantDismissed}); r.status != http.StatusNotFound { + t.Errorf("bob changed alice's want: %d", r.status) + } + var starred []httpapi.DiscoveryStarredArtist + alice.want(http.StatusOK, "GET", "/me/discover/starred", nil).decode(t, &starred) + + // A room's taste: a room alice can open, and link to; bob's private + // room, neither. + var room httpapi.Room + alice.want(http.StatusCreated, "POST", "/rooms", httpapi.CreateRoomRequest{Name: "Living room"}).decode(t, &room) + var taste []httpapi.DiscoveryTasteArtist + alice.want(http.StatusOK, "GET", "/me/discover/room-taste?roomId="+room.Id, nil).decode(t, &taste) + weight := httpapi.RoomWeightHigh + alice.want(http.StatusOK, "PATCH", base, httpapi.UpdateDiscoverySetRequest{RoomId: &room.Id, RoomWeight: &weight}).decode(t, &set) + if set.RoomId == nil || *set.RoomId != room.Id || set.RoomWeight != httpapi.RoomWeightHigh { + t.Fatalf("linked: %+v", set) + } + var private httpapi.Room + bob.want(http.StatusCreated, "POST", "/rooms", httpapi.CreateRoomRequest{Name: "Bob's"}).decode(t, &private) + vis := httpapi.RoomVisibilityPrivate + bob.want(http.StatusOK, "PATCH", "/rooms/"+private.Id, httpapi.UpdateRoomRequest{Visibility: &vis}) + if r := alice.do("GET", "/me/discover/room-taste?roomId="+private.Id, nil); r.status != http.StatusNotFound { + t.Errorf("alice read a private room's taste: %d %s", r.status, r.body) + } + if r := alice.do("PATCH", base, httpapi.UpdateDiscoverySetRequest{RoomId: &private.Id}); r.status != http.StatusNotFound { + t.Errorf("alice linked a private room: %d %s", r.status, r.body) + } + set = httpapi.DiscoverySet{} + alice.want(http.StatusOK, "PATCH", base, httpapi.UpdateDiscoverySetRequest{RoomId: ptr("")}).decode(t, &set) + if set.RoomId != nil { + t.Errorf("still linked: %+v", set.RoomId) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 843def2..aa95c0d 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -439,6 +439,7 @@ var errorCodes = []struct { {playlists.ErrForbidden, http.StatusForbidden, "forbidden"}, {playlists.ErrNothingPlayed, http.StatusConflict, "nothing_played"}, {discover.ErrNotFound, http.StatusNotFound, "not_found"}, + {discover.ErrRoomNotFound, http.StatusNotFound, "not_found"}, {savedartists.ErrNotFound, http.StatusNotFound, "not_found"}, {discover.ErrTooMany, http.StatusConflict, "too_many"}, {games.ErrNoRound, http.StatusConflict, "round_closed"}, diff --git a/server/internal/library/library.go b/server/internal/library/library.go index d635a2e..f9e2fdb 100644 --- a/server/internal/library/library.go +++ b/server/internal/library/library.go @@ -56,6 +56,10 @@ type Service struct { Every time.Duration // Now is the clock. Default store.Now. Now func() time.Time + // Built, if set, is called after each index build, with the link's + // user, so what waits on their library (the Want list) can look + // again. + Built func(ctx context.Context, userID string) // build runs one build at a time, so indexing a big library never // takes more than one request's worth of the server at once. @@ -385,6 +389,18 @@ func (s *Service) refreshStale(ctx context.Context) { // it only notes the time when the service says the library hasn't been // scanned since the last build. func (s *Service) Build(ctx context.Context, linkID string, force bool) error { + if err := s.buildIndex(ctx, linkID, force); err != nil || s.Built == nil { + return err + } + l, err := s.db.GetServiceLink(ctx, linkID) + if err != nil { + return err + } + s.Built(ctx, l.UserID) + return nil +} + +func (s *Service) buildIndex(ctx context.Context, linkID string, force bool) error { s.build.Lock() defer s.build.Unlock() sess, err := s.links.Open(ctx, linkID) @@ -457,6 +473,45 @@ func (s *Service) Build(ctx context.Context, linkID string, force bool) error { return nil } +// Starred lists what a user starred on their own links that can say: +// artists, albums and songs, from each link in turn. A link that fails is +// skipped, unless they all do. +func (s *Service) Starred(ctx context.Context, userID string) (provider.Starred, error) { + rows, err := s.libraryLinks(ctx, userID) + if err != nil { + return provider.Starred{}, err + } + var out provider.Starred + var errs []error + for _, l := range rows { + st, err := s.starred(ctx, l.ID) + if err != nil { + errs = append(errs, err) + continue + } + out.Artists = append(out.Artists, st.Artists...) + out.Albums = append(out.Albums, st.Albums...) + out.Songs = append(out.Songs, st.Songs...) + } + if len(errs) > 0 && len(errs) == len(rows) { + return out, errors.Join(errs...) + } + return out, nil +} + +func (s *Service) starred(ctx context.Context, linkID string) (provider.Starred, error) { + sess, err := s.links.Open(ctx, linkID) + if err != nil { + return provider.Starred{}, err + } + defer sess.Close() + st, ok := sess.(provider.Starrer) + if !ok { + return provider.Starred{}, nil + } + return st.Starred(ctx) +} + func (s *Service) cannotList(linkID string) { s.mu.Lock() defer s.mu.Unlock() diff --git a/server/internal/library/library_test.go b/server/internal/library/library_test.go index 035b2fb..1441176 100644 --- a/server/internal/library/library_test.go +++ b/server/internal/library/library_test.go @@ -59,6 +59,9 @@ func (s *subsonic) serve(w http.ResponseWriter, r *http.Request) { off, _ := strconv.Atoi(q.Get("songOffset")) page := s.songs[min(off, len(s.songs)):min(off+n, len(s.songs))] ok(`"searchResult3":{"song":[` + strings.Join(page, ",") + `]}`) + case "getStarred2": + ok(`"starred2":{"artist":[{"id":"ar1","name":"Low"}],"album":[{"id":"al1","name":"Souvlaki","artist":"Slowdive","artists":[{"name":"Slowdive"}]}],"song":[` + + song("9", "Codeine", "D", "Frigid Stars", "") + `]}`) default: http.NotFound(w, r) } @@ -259,3 +262,47 @@ func TestBigLibrary(t *testing.T) { t.Errorf("a lookup takes %v, want under a millisecond", per) } } + +func TestBuiltTellsWhoseLibraryChanged(t *testing.T) { + e := newEnv(t) + srv := newSubsonic(t, []string{song("1", "Slowdive", "Alison", "Souvlaki", "")}) + link := e.link(t, srv) + var mu sync.Mutex + var users []string + e.svc.Built = func(_ context.Context, userID string) { + mu.Lock() + defer mu.Unlock() + users = append(users, userID) + } + if err := e.svc.Build(t.Context(), link, false); err != nil { + t.Fatal(err) + } + // Even a build that finds nothing changed: something wanted since may + // be there. + if err := e.svc.Build(t.Context(), link, false); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(users) != 2 || users[0] != e.user || users[1] != e.user { + t.Errorf("built for %v", users) + } +} + +func TestStarred(t *testing.T) { + e := newEnv(t) + e.link(t, newSubsonic(t, nil)) + st, err := e.svc.Starred(t.Context(), e.user) + if err != nil { + t.Fatal(err) + } + if len(st.Artists) != 1 || st.Artists[0].Name != "Low" { + t.Errorf("artists: %+v", st.Artists) + } + if len(st.Albums) != 1 || st.Albums[0].Title != "Souvlaki" || len(st.Albums[0].Artists) == 0 || st.Albums[0].Artists[0].Name != "Slowdive" { + t.Errorf("albums: %+v", st.Albums) + } + if len(st.Songs) != 1 || st.Songs[0].Artists[0].Name != "Codeine" { + t.Errorf("songs: %+v", st.Songs) + } +} diff --git a/server/internal/provider/navidrome/catalog.go b/server/internal/provider/navidrome/catalog.go index 25170d6..1a20ba4 100644 --- a/server/internal/provider/navidrome/catalog.go +++ b/server/internal/provider/navidrome/catalog.go @@ -12,7 +12,10 @@ import ( "github.com/madeofpendletonwool/syncphony/server/internal/provider" ) -var _ provider.Cataloger = (*session)(nil) +var ( + _ provider.Cataloger = (*session)(nil) + _ provider.Starrer = (*session)(nil) +) // catalogPage is how many songs a page of the catalog holds: search3's // most, so a big library takes few requests. diff --git a/server/internal/provider/navidrome/library.go b/server/internal/provider/navidrome/library.go index d23835f..20f77f9 100644 --- a/server/internal/provider/navidrome/library.go +++ b/server/internal/provider/navidrome/library.go @@ -128,6 +128,25 @@ func (s *session) AlbumList(ctx context.Context, kind provider.AlbumListKind, li return out, nil } +// Starred implements provider.Starrer with getStarred2. +func (s *session) Starred(ctx context.Context) (provider.Starred, error) { + starred, err := s.starred(ctx) + if err != nil { + return provider.Starred{}, err + } + var out provider.Starred + for _, ar := range starred.Artist { + out.Artists = append(out.Artists, toArtist(ar)) + } + for _, al := range starred.Album { + out.Albums = append(out.Albums, toAlbum(al)) + } + for _, so := range starred.Song { + out.Songs = append(out.Songs, s.track(so)) + } + return out, nil +} + // starred returns what the account has starred. func (s *session) starred(ctx context.Context) (starred, error) { r, err := s.api.call(ctx, "getStarred2", nil) diff --git a/server/internal/provider/session.go b/server/internal/provider/session.go index 43ad880..92ef984 100644 --- a/server/internal/provider/session.go +++ b/server/internal/provider/session.go @@ -193,6 +193,21 @@ type Cataloger interface { LibraryChanged(ctx context.Context) (time.Time, error) } +// Starrer is implemented by sessions that can list everything the account +// starred: artists, albums and songs. Discovery offers their artists as +// seeds to pick from, only when asked (ADR 0017). Like Cataloger, it needs +// no capability. +type Starrer interface { + Starred(ctx context.Context) (Starred, error) +} + +// Starred is what an account starred. +type Starred struct { + Artists []Artist + Albums []Album + Songs []Track +} + // PlayChecker is implemented by sessions that can tell ahead of time that a // track won't play, so the queue can refuse it instead of skipping it later. type PlayChecker interface { diff --git a/server/internal/store/discovery.sql.gen.go b/server/internal/store/discovery.sql.gen.go index de81d40..8b77b9e 100644 --- a/server/internal/store/discovery.sql.gen.go +++ b/server/internal/store/discovery.sql.gen.go @@ -39,8 +39,8 @@ func (q *Queries) AddDiscoverySetArtist(ctx context.Context, arg AddDiscoverySet } const addWantItem = `-- name: AddWantItem :exec -INSERT INTO want_items (id, user_id, set_id, kind, item_key, artist, title, release_title, mbid, isrc, deezer_id, added_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO want_items (id, user_id, set_id, kind, item_key, artist, title, release_title, mbid, isrc, deezer_id, cover, added_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, kind, item_key) DO NOTHING ` @@ -56,6 +56,7 @@ type AddWantItemParams struct { Mbid sql.NullString Isrc sql.NullString DeezerID sql.NullInt64 + Cover string AddedAt time.Time } @@ -72,6 +73,7 @@ func (q *Queries) AddWantItem(ctx context.Context, arg AddWantItemParams) error arg.Mbid, arg.Isrc, arg.DeezerID, + arg.Cover, arg.AddedAt, ) return err @@ -100,11 +102,22 @@ func (q *Queries) BlockDiscoveryArtist(ctx context.Context, arg BlockDiscoveryAr return err } +const countLandedWantItems = `-- name: CountLandedWantItems :one +SELECT count(*) FROM want_items WHERE user_id = ? AND status = 'got' AND got_by = 'auto' AND got_seen = 0 +` + +func (q *Queries) CountLandedWantItems(ctx context.Context, userID string) (int64, error) { + row := q.db.QueryRowContext(ctx, countLandedWantItems, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + const createDiscoverySet = `-- name: CreateDiscoverySet :one INSERT INTO discovery_sets (id, user_id, name, explore, mode, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?6, ?6) -RETURNING id, user_id, name, explore, mode, created_at, updated_at, fresh_days +RETURNING id, user_id, name, explore, mode, created_at, updated_at, fresh_days, grow_auto, room_id, room_weight ` type CreateDiscoverySetParams struct { @@ -137,6 +150,9 @@ func (q *Queries) CreateDiscoverySet(ctx context.Context, arg CreateDiscoverySet &i.CreatedAt, &i.UpdatedAt, &i.FreshDays, + &i.GrowAuto, + &i.RoomID, + &i.RoomWeight, ) return i, err } @@ -172,6 +188,31 @@ func (q *Queries) DeleteDiscoverySet(ctx context.Context, arg DeleteDiscoverySet return result.RowsAffected() } +const dismissDiscoverySuggestion = `-- name: DismissDiscoverySuggestion :exec +INSERT INTO discovery_dismissed (user_id, kind, scope, artist_key, created_at) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT (user_id, kind, scope, artist_key) DO NOTHING +` + +type DismissDiscoverySuggestionParams struct { + UserID string + Kind string + Scope string + ArtistKey string + CreatedAt time.Time +} + +func (q *Queries) DismissDiscoverySuggestion(ctx context.Context, arg DismissDiscoverySuggestionParams) error { + _, err := q.db.ExecContext(ctx, dismissDiscoverySuggestion, + arg.UserID, + arg.Kind, + arg.Scope, + arg.ArtistKey, + arg.CreatedAt, + ) + return err +} + const getDeezerSong = `-- name: GetDeezerSong :one SELECT song_key, deezer_id, album, cover, checked_at FROM deezer_songs WHERE song_key = ? ` @@ -190,7 +231,7 @@ func (q *Queries) GetDeezerSong(ctx context.Context, songKey string) (DeezerSong } const getDiscoverySet = `-- name: GetDiscoverySet :one -SELECT id, user_id, name, explore, mode, created_at, updated_at, fresh_days FROM discovery_sets WHERE id = ? AND user_id = ? +SELECT id, user_id, name, explore, mode, created_at, updated_at, fresh_days, grow_auto, room_id, room_weight FROM discovery_sets WHERE id = ? AND user_id = ? ` type GetDiscoverySetParams struct { @@ -210,6 +251,9 @@ func (q *Queries) GetDiscoverySet(ctx context.Context, arg GetDiscoverySetParams &i.CreatedAt, &i.UpdatedAt, &i.FreshDays, + &i.GrowAuto, + &i.RoomID, + &i.RoomWeight, ) return i, err } @@ -231,6 +275,75 @@ func (q *Queries) GetLibraryIndex(ctx context.Context, linkID string) (LibraryIn return i, err } +const getWantItem = `-- name: GetWantItem :one +SELECT id, user_id, set_id, kind, item_key, artist, title, release_title, mbid, isrc, deezer_id, status, got_at, added_at, cover, got_by, got_seen FROM want_items WHERE id = ? AND user_id = ? +` + +type GetWantItemParams struct { + ID string + UserID string +} + +func (q *Queries) GetWantItem(ctx context.Context, arg GetWantItemParams) (WantItem, error) { + row := q.db.QueryRowContext(ctx, getWantItem, arg.ID, arg.UserID) + var i WantItem + err := row.Scan( + &i.ID, + &i.UserID, + &i.SetID, + &i.Kind, + &i.ItemKey, + &i.Artist, + &i.Title, + &i.ReleaseTitle, + &i.Mbid, + &i.Isrc, + &i.DeezerID, + &i.Status, + &i.GotAt, + &i.AddedAt, + &i.Cover, + &i.GotBy, + &i.GotSeen, + ) + return i, err +} + +const getWantItemByKey = `-- name: GetWantItemByKey :one +SELECT id, user_id, set_id, kind, item_key, artist, title, release_title, mbid, isrc, deezer_id, status, got_at, added_at, cover, got_by, got_seen FROM want_items WHERE user_id = ? AND kind = ? AND item_key = ? +` + +type GetWantItemByKeyParams struct { + UserID string + Kind string + ItemKey string +} + +func (q *Queries) GetWantItemByKey(ctx context.Context, arg GetWantItemByKeyParams) (WantItem, error) { + row := q.db.QueryRowContext(ctx, getWantItemByKey, arg.UserID, arg.Kind, arg.ItemKey) + var i WantItem + err := row.Scan( + &i.ID, + &i.UserID, + &i.SetID, + &i.Kind, + &i.ItemKey, + &i.Artist, + &i.Title, + &i.ReleaseTitle, + &i.Mbid, + &i.Isrc, + &i.DeezerID, + &i.Status, + &i.GotAt, + &i.AddedAt, + &i.Cover, + &i.GotBy, + &i.GotSeen, + ) + return i, err +} + const listAllDiscoverySetArtists = `-- name: ListAllDiscoverySetArtists :many SELECT name, CAST(max(coalesce(mbid, '')) AS TEXT) AS mbid FROM discovery_set_artists @@ -374,23 +487,32 @@ func (q *Queries) ListDiscoverySetArtists(ctx context.Context, setID string) ([] } const listDiscoverySets = `-- name: ListDiscoverySets :many -SELECT discovery_sets.id, discovery_sets.user_id, discovery_sets.name, discovery_sets.explore, discovery_sets.mode, discovery_sets.created_at, discovery_sets.updated_at, discovery_sets.fresh_days, - CAST((SELECT count(*) FROM discovery_set_artists a WHERE a.set_id = discovery_sets.id) AS INTEGER) AS artists +SELECT discovery_sets.id, discovery_sets.user_id, discovery_sets.name, discovery_sets.explore, discovery_sets.mode, discovery_sets.created_at, discovery_sets.updated_at, discovery_sets.fresh_days, discovery_sets.grow_auto, discovery_sets.room_id, discovery_sets.room_weight, + CAST((SELECT count(*) FROM discovery_set_artists a WHERE a.set_id = discovery_sets.id) AS INTEGER) AS artists, + -- What the set has yielded: things wanted from its feeds, and of + -- those, how many you got. + CAST((SELECT count(*) FROM want_items w WHERE w.set_id = discovery_sets.id AND w.status != 'dismissed') AS INTEGER) AS wanted, + CAST((SELECT count(*) FROM want_items w WHERE w.set_id = discovery_sets.id AND w.status = 'got') AS INTEGER) AS got FROM discovery_sets -WHERE user_id = ? +WHERE discovery_sets.user_id = ? ORDER BY updated_at DESC, id DESC ` type ListDiscoverySetsRow struct { - ID string - UserID string - Name string - Explore int64 - Mode string - CreatedAt time.Time - UpdatedAt time.Time - FreshDays int64 - Artists int64 + ID string + UserID string + Name string + Explore int64 + Mode string + CreatedAt time.Time + UpdatedAt time.Time + FreshDays int64 + GrowAuto int64 + RoomID sql.NullString + RoomWeight string + Artists int64 + Wanted int64 + Got int64 } func (q *Queries) ListDiscoverySets(ctx context.Context, userID string) ([]ListDiscoverySetsRow, error) { @@ -411,7 +533,45 @@ func (q *Queries) ListDiscoverySets(ctx context.Context, userID string) ([]ListD &i.CreatedAt, &i.UpdatedAt, &i.FreshDays, + &i.GrowAuto, + &i.RoomID, + &i.RoomWeight, &i.Artists, + &i.Wanted, + &i.Got, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDismissedDiscoverySuggestions = `-- name: ListDismissedDiscoverySuggestions :many +SELECT user_id, kind, scope, artist_key, created_at FROM discovery_dismissed WHERE user_id = ? +` + +func (q *Queries) ListDismissedDiscoverySuggestions(ctx context.Context, userID string) ([]DiscoveryDismissed, error) { + rows, err := q.db.QueryContext(ctx, listDismissedDiscoverySuggestions, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DiscoveryDismissed{} + for rows.Next() { + var i DiscoveryDismissed + if err := rows.Scan( + &i.UserID, + &i.Kind, + &i.Scope, + &i.ArtistKey, + &i.CreatedAt, ); err != nil { return nil, err } @@ -427,7 +587,7 @@ func (q *Queries) ListDiscoverySets(ctx context.Context, userID string) ([]ListD } const listWantItems = `-- name: ListWantItems :many -SELECT id, user_id, set_id, kind, item_key, artist, title, release_title, mbid, isrc, deezer_id, status, got_at, added_at FROM want_items WHERE user_id = ? ORDER BY added_at DESC, id +SELECT id, user_id, set_id, kind, item_key, artist, title, release_title, mbid, isrc, deezer_id, status, got_at, added_at, cover, got_by, got_seen FROM want_items WHERE user_id = ? ORDER BY added_at DESC, id ` func (q *Queries) ListWantItems(ctx context.Context, userID string) ([]WantItem, error) { @@ -454,6 +614,9 @@ func (q *Queries) ListWantItems(ctx context.Context, userID string) ([]WantItem, &i.Status, &i.GotAt, &i.AddedAt, + &i.Cover, + &i.GotBy, + &i.GotSeen, ); err != nil { return nil, err } @@ -468,6 +631,25 @@ func (q *Queries) ListWantItems(ctx context.Context, userID string) ([]WantItem, return items, nil } +const markWantItemGot = `-- name: MarkWantItemGot :execrows +UPDATE want_items SET status = 'got', got_at = ?, got_by = 'auto', got_seen = 0 WHERE id = ? AND status = 'wanted' +` + +type MarkWantItemGotParams struct { + GotAt sql.NullTime + ID string +} + +// MarkWantItemGot marks a wanted item got because it turned up in the +// library; one dismissed or got already stays as it is. +func (q *Queries) MarkWantItemGot(ctx context.Context, arg MarkWantItemGotParams) (int64, error) { + result, err := q.db.ExecContext(ctx, markWantItemGot, arg.GotAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const putDeezerSong = `-- name: PutDeezerSong :exec INSERT INTO deezer_songs (song_key, deezer_id, album, cover, checked_at) VALUES (?, ?, ?, ?, ?) @@ -578,21 +760,34 @@ func (q *Queries) RemoveDiscoverySetArtist(ctx context.Context, arg RemoveDiscov return result.RowsAffected() } +const seeLandedWantItems = `-- name: SeeLandedWantItems :exec +UPDATE want_items SET got_seen = 1 WHERE user_id = ? AND status = 'got' AND got_seen = 0 +` + +func (q *Queries) SeeLandedWantItems(ctx context.Context, userID string) error { + _, err := q.db.ExecContext(ctx, seeLandedWantItems, userID) + return err +} + const setWantItemStatus = `-- name: SetWantItemStatus :execrows -UPDATE want_items SET status = ?, got_at = ? WHERE id = ? AND user_id = ? +UPDATE want_items SET status = ?, got_at = ?, got_by = ?, got_seen = ? WHERE id = ? AND user_id = ? ` type SetWantItemStatusParams struct { - Status string - GotAt sql.NullTime - ID string - UserID string + Status string + GotAt sql.NullTime + GotBy sql.NullString + GotSeen int64 + ID string + UserID string } func (q *Queries) SetWantItemStatus(ctx context.Context, arg SetWantItemStatusParams) (int64, error) { result, err := q.db.ExecContext(ctx, setWantItemStatus, arg.Status, arg.GotAt, + arg.GotBy, + arg.GotSeen, arg.ID, arg.UserID, ) @@ -648,17 +843,21 @@ func (q *Queries) UnblockDiscoveryArtist(ctx context.Context, arg UnblockDiscove } const updateDiscoverySet = `-- name: UpdateDiscoverySet :exec -UPDATE discovery_sets SET name = ?, explore = ?, mode = ?, fresh_days = ?, updated_at = ? WHERE id = ? AND user_id = ? +UPDATE discovery_sets SET name = ?, explore = ?, mode = ?, fresh_days = ?, grow_auto = ?, room_id = ?, room_weight = ?, updated_at = ? +WHERE id = ? AND user_id = ? ` type UpdateDiscoverySetParams struct { - Name string - Explore int64 - Mode string - FreshDays int64 - UpdatedAt time.Time - ID string - UserID string + Name string + Explore int64 + Mode string + FreshDays int64 + GrowAuto int64 + RoomID sql.NullString + RoomWeight string + UpdatedAt time.Time + ID string + UserID string } func (q *Queries) UpdateDiscoverySet(ctx context.Context, arg UpdateDiscoverySetParams) error { @@ -667,6 +866,9 @@ func (q *Queries) UpdateDiscoverySet(ctx context.Context, arg UpdateDiscoverySet arg.Explore, arg.Mode, arg.FreshDays, + arg.GrowAuto, + arg.RoomID, + arg.RoomWeight, arg.UpdatedAt, arg.ID, arg.UserID, diff --git a/server/internal/store/migrations/00032_discovery_learning.sql b/server/internal/store/migrations/00032_discovery_learning.sql new file mode 100644 index 0000000..de2214c --- /dev/null +++ b/server/internal/store/migrations/00032_discovery_learning.sql @@ -0,0 +1,41 @@ +-- Personal discovery, stage 4 (MAD-824..826, ADR 0017): the Want list +-- with automatic got-it, sets that learn from thumbs, and a room's taste +-- as a set's source. + +-- +goose Up +-- A Want-list item's cover (Deezer's image ID), how it came to be got +-- ('auto' when it turned up in the library, 'manual' when you said so), +-- and whether you've seen that it landed. +ALTER TABLE want_items ADD COLUMN cover TEXT NOT NULL DEFAULT ''; +ALTER TABLE want_items ADD COLUMN got_by TEXT CHECK (got_by IN ('auto', 'manual')); +ALTER TABLE want_items ADD COLUMN got_seen INTEGER NOT NULL DEFAULT 0; +CREATE INDEX want_items_set ON want_items (set_id, status); + +-- Whether artists you thumb up twice from a set's feed join it by +-- themselves, without asking. +ALTER TABLE discovery_sets ADD COLUMN grow_auto INTEGER NOT NULL DEFAULT 0; +-- A room whose taste the set mixes in (live), and how much: 'low', +-- 'medium' or 'high'. Read only: discovery never writes to the room. +ALTER TABLE discovery_sets ADD COLUMN room_id TEXT REFERENCES rooms (id) ON DELETE SET NULL; +ALTER TABLE discovery_sets ADD COLUMN room_weight TEXT NOT NULL DEFAULT 'medium' CHECK (room_weight IN ('low', 'medium', 'high')); + +-- Suggestions you said no to: an artist to add to a set ('grow', scope is +-- the set) or to block ('block', scope is ''). They aren't offered again. +CREATE TABLE discovery_dismissed ( + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('grow', 'block')), + scope TEXT NOT NULL DEFAULT '', + artist_key TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (user_id, kind, scope, artist_key) +); + +-- +goose Down +DROP TABLE discovery_dismissed; +ALTER TABLE discovery_sets DROP COLUMN room_weight; +ALTER TABLE discovery_sets DROP COLUMN room_id; +ALTER TABLE discovery_sets DROP COLUMN grow_auto; +DROP INDEX want_items_set; +ALTER TABLE want_items DROP COLUMN got_seen; +ALTER TABLE want_items DROP COLUMN got_by; +ALTER TABLE want_items DROP COLUMN cover; diff --git a/server/internal/store/models.gen.go b/server/internal/store/models.gen.go index 82c0473..5d659a3 100644 --- a/server/internal/store/models.gen.go +++ b/server/internal/store/models.gen.go @@ -64,6 +64,14 @@ type DiscoveryBlock struct { CreatedAt time.Time } +type DiscoveryDismissed struct { + UserID string + Kind string + Scope string + ArtistKey string + CreatedAt time.Time +} + type DiscoveryFeedback struct { ID string UserID string @@ -79,14 +87,17 @@ type DiscoveryFeedback struct { } type DiscoverySet struct { - ID string - UserID string - Name string - Explore int64 - Mode string - CreatedAt time.Time - UpdatedAt time.Time - FreshDays int64 + ID string + UserID string + Name string + Explore int64 + Mode string + CreatedAt time.Time + UpdatedAt time.Time + FreshDays int64 + GrowAuto int64 + RoomID sql.NullString + RoomWeight string } type DiscoverySetArtist struct { @@ -451,4 +462,7 @@ type WantItem struct { Status string GotAt sql.NullTime AddedAt time.Time + Cover string + GotBy sql.NullString + GotSeen int64 } diff --git a/server/internal/store/nights.sql.gen.go b/server/internal/store/nights.sql.gen.go index 6d00720..4054764 100644 --- a/server/internal/store/nights.sql.gen.go +++ b/server/internal/store/nights.sql.gen.go @@ -90,6 +90,33 @@ func (q *Queries) FirstPlayOf(ctx context.Context, queueItemID string) (PlayHist return i, err } +const getNight = `-- name: GetNight :one +SELECT id, room_id, started_at, ended_at, ended_by, plays, queue_item_id, hearts, awards, bracket FROM nights WHERE id = ? AND room_id = ? +` + +type GetNightParams struct { + ID string + RoomID string +} + +func (q *Queries) GetNight(ctx context.Context, arg GetNightParams) (Night, error) { + row := q.db.QueryRowContext(ctx, getNight, arg.ID, arg.RoomID) + var i Night + err := row.Scan( + &i.ID, + &i.RoomID, + &i.StartedAt, + &i.EndedAt, + &i.EndedBy, + &i.Plays, + &i.QueueItemID, + &i.Hearts, + &i.Awards, + &i.Bracket, + ) + return i, err +} + const heartCountsSince = `-- name: HeartCountsSince :many SELECT hearts.queue_item_id, count(*) AS hearts FROM hearts diff --git a/server/internal/store/queries/discovery.sql b/server/internal/store/queries/discovery.sql index 6701dd3..4904aec 100644 --- a/server/internal/store/queries/discovery.sql +++ b/server/internal/store/queries/discovery.sql @@ -11,13 +11,18 @@ SELECT * FROM discovery_sets WHERE id = ? AND user_id = ?; -- name: ListDiscoverySets :many SELECT discovery_sets.*, - CAST((SELECT count(*) FROM discovery_set_artists a WHERE a.set_id = discovery_sets.id) AS INTEGER) AS artists + CAST((SELECT count(*) FROM discovery_set_artists a WHERE a.set_id = discovery_sets.id) AS INTEGER) AS artists, + -- What the set has yielded: things wanted from its feeds, and of + -- those, how many you got. + CAST((SELECT count(*) FROM want_items w WHERE w.set_id = discovery_sets.id AND w.status != 'dismissed') AS INTEGER) AS wanted, + CAST((SELECT count(*) FROM want_items w WHERE w.set_id = discovery_sets.id AND w.status = 'got') AS INTEGER) AS got FROM discovery_sets -WHERE user_id = ? +WHERE discovery_sets.user_id = ? ORDER BY updated_at DESC, id DESC; -- name: UpdateDiscoverySet :exec -UPDATE discovery_sets SET name = ?, explore = ?, mode = ?, fresh_days = ?, updated_at = ? WHERE id = ? AND user_id = ?; +UPDATE discovery_sets SET name = ?, explore = ?, mode = ?, fresh_days = ?, grow_auto = ?, room_id = ?, room_weight = ?, updated_at = ? +WHERE id = ? AND user_id = ?; -- name: TouchDiscoverySet :exec UPDATE discovery_sets SET updated_at = ? WHERE id = ?; @@ -71,15 +76,40 @@ DELETE FROM discovery_blocks WHERE user_id = ? AND artist_key = ?; SELECT * FROM discovery_blocks WHERE user_id = ? ORDER BY name, artist_key; -- name: AddWantItem :exec -INSERT INTO want_items (id, user_id, set_id, kind, item_key, artist, title, release_title, mbid, isrc, deezer_id, added_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO want_items (id, user_id, set_id, kind, item_key, artist, title, release_title, mbid, isrc, deezer_id, cover, added_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, kind, item_key) DO NOTHING; +-- name: GetWantItem :one +SELECT * FROM want_items WHERE id = ? AND user_id = ?; + +-- name: GetWantItemByKey :one +SELECT * FROM want_items WHERE user_id = ? AND kind = ? AND item_key = ?; + -- name: ListWantItems :many SELECT * FROM want_items WHERE user_id = ? ORDER BY added_at DESC, id; -- name: SetWantItemStatus :execrows -UPDATE want_items SET status = ?, got_at = ? WHERE id = ? AND user_id = ?; +UPDATE want_items SET status = ?, got_at = ?, got_by = ?, got_seen = ? WHERE id = ? AND user_id = ?; + +-- MarkWantItemGot marks a wanted item got because it turned up in the +-- library; one dismissed or got already stays as it is. +-- name: MarkWantItemGot :execrows +UPDATE want_items SET status = 'got', got_at = ?, got_by = 'auto', got_seen = 0 WHERE id = ? AND status = 'wanted'; + +-- name: CountLandedWantItems :one +SELECT count(*) FROM want_items WHERE user_id = ? AND status = 'got' AND got_by = 'auto' AND got_seen = 0; + +-- name: SeeLandedWantItems :exec +UPDATE want_items SET got_seen = 1 WHERE user_id = ? AND status = 'got' AND got_seen = 0; + +-- name: DismissDiscoverySuggestion :exec +INSERT INTO discovery_dismissed (user_id, kind, scope, artist_key, created_at) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT (user_id, kind, scope, artist_key) DO NOTHING; + +-- name: ListDismissedDiscoverySuggestions :many +SELECT * FROM discovery_dismissed WHERE user_id = ?; -- name: PutLibraryIndex :exec INSERT INTO library_indexes (link_id, scanned_at, built_at, songs, data) diff --git a/server/internal/store/queries/nights.sql b/server/internal/store/queries/nights.sql index 7eb292a..04d6b6a 100644 --- a/server/internal/store/queries/nights.sql +++ b/server/internal/store/queries/nights.sql @@ -39,6 +39,9 @@ RETURNING *; -- name: LastNight :one SELECT * FROM nights WHERE room_id = ? ORDER BY ended_at DESC, id DESC LIMIT 1; +-- name: GetNight :one +SELECT * FROM nights WHERE id = ? AND room_id = ?; + -- name: ListNights :many SELECT * FROM nights WHERE room_id = ? ORDER BY ended_at DESC, id DESC LIMIT ?; From 1dcc5a41bdb9d723f028f74ffdb8ba3a95225b0c Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 14 Oct 2026 08:42:19 -0500 Subject: [PATCH 2/2] Personal discovery: the Want list, suggestions and room sources on the web (MAD-824..826) - /discover/want: wanted, got and dismissed, grouped by artist, with previews, YouTube, got it and dismiss; got things link back into Syncphony. Discover says what landed in your library. - A set's page offers what its thumbs suggest, imports from Navidrome stars or a room's taste through a checklist, and can grow by itself or mix in a linked room. Releases can be wanted. - /discover/settings holds the library status and the "Not for me" list. Co-Authored-By: Claude Opus 5.5 --- web/src/api/schema.gen.ts | 527 +++++++++++++++++- web/src/components/discover/import-sheets.tsx | 215 +++++++ .../components/discover/pick-list.test.tsx | 39 ++ web/src/components/discover/pick-list.tsx | 73 +++ .../components/discover/release-card.test.tsx | 22 + web/src/components/discover/release-card.tsx | 30 +- web/src/components/discover/suggestions.tsx | 97 ++++ web/src/lib/discover.test.ts | 37 ++ web/src/lib/discover.ts | 156 +++++- web/src/routeTree.gen.ts | 43 ++ .../routes/_app/_authed/discover.$setId.tsx | 141 ++++- .../routes/_app/_authed/discover.index.tsx | 149 +++-- .../routes/_app/_authed/discover.settings.tsx | 95 ++++ web/src/routes/_app/_authed/discover.want.tsx | 230 ++++++++ 14 files changed, 1759 insertions(+), 95 deletions(-) create mode 100644 web/src/components/discover/import-sheets.tsx create mode 100644 web/src/components/discover/pick-list.test.tsx create mode 100644 web/src/components/discover/pick-list.tsx create mode 100644 web/src/components/discover/suggestions.tsx create mode 100644 web/src/routes/_app/_authed/discover.settings.tsx create mode 100644 web/src/routes/_app/_authed/discover.want.tsx diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index db66ef7..f630415 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -2453,6 +2453,182 @@ export interface paths { patch?: never; trace?: never; }; + "/me/discover/sets/{setId}/suggestions": { + parameters: { + query?: never; + header?: never; + path: { + setId: components["parameters"]["DiscoverySetId"]; + }; + cookie?: never; + }; + /** + * What a set's thumbs suggest + * @description Artists you thumbed up twice from the set's feed, to add to it, and + * artists you thumbed down three times in any feed, to block. Ones in + * the set, blocked, or that you said no to are left out. + */ + get: operations["getDiscoverySuggestions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/discover/sets/{setId}/suggestions/dismiss": { + parameters: { + query?: never; + header?: never; + path: { + setId: components["parameters"]["DiscoverySetId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Say no to a suggestion + * @description It isn't made again. A block suggestion is dismissed for every set. + */ + post: operations["dismissDiscoverySuggestion"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/discover/sets/{setId}/import": { + parameters: { + query?: never; + header?: never; + path: { + setId: components["parameters"]["DiscoverySetId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add artists you picked to a set at once + * @description From a suggestion, your Navidrome stars, or a room's taste, noted as + * coming from there. Artists in the set already are skipped. More than + * the set has room for is `too_many`, and adds none. + */ + post: operations["importDiscoverySetArtists"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/discover/starred": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Artists you starred on your Navidrome, to pick seeds from + * @description Starred artists, and the artists of starred albums and songs, from + * your own links: starred artists first. Only a list to pick from; + * nothing joins a set unless you pick it. + */ + get: operations["listDiscoveryStarred"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/discover/room-taste": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * A room's favorite artists, to make a set from + * @description The room's taste as its DJ reads it, over past nights and lately, + * or one past night's (`nightId`). Only rooms you can open. It only + * reads the room. + */ + get: operations["getDiscoveryRoomTaste"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/discover/want": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Your Want list + * @description Songs and releases you liked in Discover, newest first: wanted, + * got (they turned up in your library, or you said so) and + * dismissed. `landed` is how many turned up since you last looked. + */ + get: operations["listDiscoveryWants"]; + put?: never; + /** + * Want a song or release + * @description Wanting one that's on the list already returns it; one dismissed is wanted again. + */ + post: operations["addDiscoveryWant"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/discover/want/seen": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** You saw what landed in your library */ + post: operations["seeDiscoveryLanded"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/discover/want/{wantId}": { + parameters: { + query?: never; + header?: never; + path: { + wantId: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Mark a thing wanted, got or dismissed */ + patch: operations["updateDiscoveryWant"]; + trace?: never; + }; "/me/discover/release-tracks": { parameters: { query?: never; @@ -2508,8 +2684,11 @@ export interface paths { put?: never; /** * Thumbs up or down on a song - * @description A thumbs-down hides the song from all your feeds, for good; a later - * thumbs-up replaces it. Thumbs here never touch a room's DJ. + * @description A thumbs-down hides the song from all your feeds, for good, and + * lowers its artist in the set's feed; a later thumbs-up replaces it. + * A thumbs-up puts the song on your Want list, and in a set that + * grows by itself, adds an artist you thumbed up twice from its feed. + * Thumbs here never touch a room's DJ. */ post: operations["postDiscoveryFeedback"]; delete?: never; @@ -5791,6 +5970,12 @@ export interface components { mode: components["schemas"]["DiscoveryMode"]; freshDays: components["schemas"]["DiscoveryFreshDays"]; artistCount: number; + /** @description Songs and releases wanted from its feeds (not dismissed). */ + wanted: number; + /** @description Of those, how many you got. */ + got: number; + /** @description The room whose taste it mixes in, if linked. */ + roomId?: string; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -5803,6 +5988,11 @@ export interface components { explore: number; mode: components["schemas"]["DiscoveryMode"]; freshDays: components["schemas"]["DiscoveryFreshDays"]; + /** @description Artists you thumb up twice from its feed join it without asking. */ + growAuto: boolean; + /** @description A room whose taste its feed mixes in (read only), if linked. */ + roomId?: string; + roomWeight: components["schemas"]["DiscoveryRoomWeight"]; artists: components["schemas"]["DiscoverySetArtist"][]; /** Format: date-time */ createdAt: string; @@ -5832,7 +6022,16 @@ export interface components { explore?: number; mode?: components["schemas"]["DiscoveryMode"]; freshDays?: components["schemas"]["DiscoveryFreshDays"]; + growAuto?: boolean; + /** @description Link a room you can open, or "" to unlink. */ + roomId?: string; + roomWeight?: components["schemas"]["DiscoveryRoomWeight"]; }; + /** + * @description How much a linked room's taste counts against the set's own artists. + * @enum {string} + */ + DiscoveryRoomWeight: "low" | "medium" | "high"; /** * @description How far back a set's Fresh feed looks, in days. * @enum {integer} @@ -5980,7 +6179,108 @@ export interface components { isrc?: string; /** Format: int64 */ deezerId?: number; + /** @description Its album, for the Want list. */ + album?: string; + /** @description Its Deezer cover, for the Want list. */ + cover?: string; + }; + DiscoveryThumbResult: { + want?: components["schemas"]["WantItem"]; + /** @description The artist who joined the set by themselves, if any. */ + grew?: string; + }; + DiscoveryOffer: { + key: string; + name: string; + thumbs: number; + }; + DiscoverySuggestions: { + /** @description Artists to add to the set. */ + grow: components["schemas"]["DiscoveryOffer"][]; + /** @description Artists to block. */ + block: components["schemas"]["DiscoveryOffer"][]; + }; + DismissDiscoverySuggestionRequest: { + /** @enum {string} */ + kind: "grow" | "block"; + artist: string; + }; + ImportDiscoveryArtistsRequest: { + /** @enum {string} */ + from: "manual" | "thumb" | "starred" | "room"; + artists: components["schemas"]["DiscoverySeed"][]; + }; + DiscoveryStarredArtist: { + name: string; + /** @description You starred the artist. */ + artist: boolean; + /** @description How many of their albums you starred. */ + albums: number; + /** @description How many of their songs you starred. */ + songs: number; + }; + DiscoveryTasteArtist: { + name: string; + /** @description How much the room likes them, 0 to 1 against its favorite. */ + weight: number; + /** @description Their songs played lately, or that night. */ + plays: number; + }; + WantItem: { + id: string; + kind: components["schemas"]["WantKind"]; + artist: string; + /** @description The song's title, or the release's. */ + title: string; + /** @description A song's album, if known. */ + release?: string; + /** @description A song's recording, or a release's release group. */ + mbid?: string; + isrc?: string; + /** + * Format: int64 + * @description The song or release on Deezer, for a preview or its songs. + */ + deezerId?: number; + /** @description Its Deezer cover's image ID. */ + cover?: string; + /** @description The set whose feed it came from, if it's still there. */ + setId?: string; + status: components["schemas"]["WantStatus"]; + /** Format: date-time */ + gotAt?: string; + /** + * @description It turned up in your library (auto), or you said you got it. + * @enum {string} + */ + gotBy?: "auto" | "manual"; + /** Format: date-time */ + addedAt: string; + }; + WantList: { + items: components["schemas"]["WantItem"][]; + /** @description How many turned up in your library since you last looked. */ + landed: number; + }; + AddWantRequest: { + kind: components["schemas"]["WantKind"]; + artist: string; + title: string; + release?: string; + mbid?: string; + isrc?: string; + /** Format: int64 */ + deezerId?: number; + cover?: string; + setId?: string; + }; + UpdateWantRequest: { + status: components["schemas"]["WantStatus"]; }; + /** @enum {string} */ + WantStatus: "wanted" | "got" | "dismissed"; + /** @enum {string} */ + WantKind: "track" | "release"; DiscoveryBlock: { key: string; name: string; @@ -9254,6 +9554,221 @@ export interface operations { default: components["responses"]["Error"]; }; }; + getDiscoverySuggestions: { + parameters: { + query?: never; + header?: never; + path: { + setId: components["parameters"]["DiscoverySetId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suggestions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoverySuggestions"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + dismissDiscoverySuggestion: { + parameters: { + query?: never; + header?: never; + path: { + setId: components["parameters"]["DiscoverySetId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DismissDiscoverySuggestionRequest"]; + }; + }; + responses: { + /** @description Dismissed */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Error"]; + }; + }; + importDiscoverySetArtists: { + parameters: { + query?: never; + header?: never; + path: { + setId: components["parameters"]["DiscoverySetId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ImportDiscoveryArtistsRequest"]; + }; + }; + responses: { + /** @description The set */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoverySet"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + listDiscoveryStarred: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Artists */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoveryStarredArtist"][]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + getDiscoveryRoomTaste: { + parameters: { + query: { + roomId: string; + /** @description One of the room's past nights. */ + nightId?: string; + /** @description Only from songs you picked. */ + mine?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Artists, most liked first */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoveryTasteArtist"][]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + listDiscoveryWants: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WantList"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + addDiscoveryWant: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddWantRequest"]; + }; + }; + responses: { + /** @description It, on the list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WantItem"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + seeDiscoveryLanded: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Noted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Error"]; + }; + }; + updateDiscoveryWant: { + parameters: { + query?: never; + header?: never; + path: { + wantId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWantRequest"]; + }; + }; + responses: { + /** @description It */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WantItem"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; listDiscoveryReleaseTracks: { parameters: { query: { @@ -9321,12 +9836,14 @@ export interface operations { }; }; responses: { - /** @description Kept */ - 204: { + /** @description Kept, and what it did */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["DiscoveryThumbResult"]; + }; }; default: components["responses"]["Error"]; }; diff --git a/web/src/components/discover/import-sheets.tsx b/web/src/components/discover/import-sheets.tsx new file mode 100644 index 0000000..670d9ed --- /dev/null +++ b/web/src/components/discover/import-sheets.tsx @@ -0,0 +1,215 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { LoaderCircle } from 'lucide-react' +import { useMemo, useState } from 'react' +import { errorMessage } from '@/api/errors' +import { Notice } from '@/components/notice' +import { Sheet } from '@/components/sheet' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { Switch } from '@/components/ui/switch' +import { + createSet, + importArtists, + roomTasteQuery, + setChanged, + starredQuery, + starredText, + type DiscoverySet, + type DiscoveryTasteArtist, +} from '@/lib/discover' +import { nightsQuery } from '@/lib/nights' +import { roomsQuery } from '@/lib/room' +import { toast } from '@/lib/toast' +import { PickList } from './pick-list' + +const selectClass = + 'h-10 w-full rounded-xl border border-input bg-transparent px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50' + +/** Artists you starred on your Navidrome, to pick from: a one-time import, never automatic. */ +export function StarredSheet({ set, open, onOpenChange }: { set: DiscoverySet; open: boolean; onOpenChange: (open: boolean) => void }) { + const qc = useQueryClient() + const starred = useQuery({ ...starredQuery, enabled: open }) + const [picked, setPicked] = useState>(new Set()) + const have = useMemo(() => new Set(set.artists.map((a) => a.name.toLowerCase())), [set.artists]) + const add = useMutation({ + mutationFn: () => importArtists(set.id, 'starred', [...picked]), + onSuccess: (s) => { + setChanged(qc, s) + toast({ message: `Added ${picked.size} artist${picked.size === 1 ? '' : 's'} to ${s.name}` }) + setPicked(new Set()) + onOpenChange(false) + }, + onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), + }) + return ( + + {starred.isPending ? ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ) : starred.isError ? ( + {errorMessage(starred.error)} + ) : starred.data.length === 0 ? ( +

Nothing starred on a Navidrome of yours. Star artists, albums or songs there and they show up here.

+ ) : ( + { + const inSet = have.has(a.name.toLowerCase()) + return { name: a.name, detail: inSet ? 'In this set' : starredText(a), disabled: inSet } + })} + /> + )} + +
+ ) +} + +/** "Bedhead · 4 plays lately", or how much the room likes them. */ +function tasteText(a: DiscoveryTasteArtist) { + if (a.plays > 0) return `${a.plays} play${a.plays === 1 ? '' : 's'}` + return a.weight >= 0.66 ? 'A favorite' : a.weight >= 0.33 ? 'Liked' : 'Now and then' +} + +/** How many of a room's favorites are picked to start with. */ +const prePick = 8 + +/** + * Makes a set from a room's taste, or a past night's: its favorite + * artists as a checklist. With `set`, the ones you keep join it; + * without, they make a new set. It only reads the room. + */ +export function RoomSheet({ set, open, onOpenChange }: { set?: DiscoverySet; open: boolean; onOpenChange: (open: boolean) => void }) { + const qc = useQueryClient() + const navigate = useNavigate() + const rooms = useQuery({ ...roomsQuery, enabled: open }) + const [chosenRoom, setRoomId] = useState('') + // The first room, until you pick one. + const roomId = chosenRoom || rooms.data?.[0]?.id || '' + const [nightId, setNightId] = useState('') + const [mine, setMine] = useState(false) + const [name, setName] = useState('') + const nights = useQuery({ ...nightsQuery(roomId), enabled: open && !!roomId }) + const taste = useQuery({ ...roomTasteQuery({ roomId, nightId, mine }), enabled: open && !!roomId }) + const have = useMemo(() => new Set(set?.artists.map((a) => a.name.toLowerCase())), [set?.artists]) + // Each taste starts with its favorites picked, until you change them. + const [choice, setChoice] = useState<{ of: string; picked: Set }>() + const of = `${roomId}/${nightId}/${mine}` + const favorites = useMemo( + () => new Set((taste.data ?? []).filter((a) => !have.has(a.name.toLowerCase())).slice(0, prePick).map((a) => a.name)), + [taste.data, have], + ) + const picked = choice?.of === of ? choice.picked : favorites + const setPicked = (p: Set) => setChoice({ of, picked: p }) + + const room = rooms.data?.find((r) => r.id === roomId) + const night = nights.data?.find((n) => n.id === nightId) + const defaultName = room ? (night ? `${room.name}, ${new Date(night.endedAt).toLocaleDateString()}` : room.name) : '' + + const add = useMutation({ + mutationFn: async () => { + const target = set ?? (await createSet(name.trim() || defaultName || 'From a room')) + return importArtists(target.id, 'room', [...picked]) + }, + onSuccess: (s) => { + setChanged(qc, s) + toast({ message: set ? `Added ${picked.size} artist${picked.size === 1 ? '' : 's'} to ${s.name}` : `Made ${s.name}` }) + onOpenChange(false) + if (!set) void navigate({ to: '/discover/$setId', params: { setId: s.id } }) + }, + onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), + }) + + return ( + + {rooms.data?.length === 0 ? ( +

You're not in any rooms yet.

+ ) : ( +
+ + +
+ Only songs I picked + +
+
+ )} + {!set && ( + setName(e.target.value)} placeholder={defaultName || "The new set's name"} aria-label="The new set's name" maxLength={100} /> + )} + {roomId && + (taste.isPending ? ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ) : taste.isError ? ( + {errorMessage(taste.error)} + ) : taste.data.length === 0 ? ( +

+ {mine ? "Nothing you picked there yet." : "This room hasn't played enough to have a taste yet."} +

+ ) : ( + { + const inSet = have.has(a.name.toLowerCase()) + return { name: a.name, detail: inSet ? 'In this set' : tasteText(a), disabled: inSet } + })} + /> + ))} + +
+ ) +} diff --git a/web/src/components/discover/pick-list.test.tsx b/web/src/components/discover/pick-list.test.tsx new file mode 100644 index 0000000..4aa6ba1 --- /dev/null +++ b/web/src/components/discover/pick-list.test.tsx @@ -0,0 +1,39 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import { PickList, type Pickable } from './pick-list' + +const items: Pickable[] = [ + { name: 'Low', detail: 'Starred' }, + { name: 'Slowdive', detail: '2 albums' }, + { name: 'Hum', detail: 'In this set', disabled: true }, +] + +function Harness() { + const [picked, setPicked] = useState>(new Set(['Low'])) + return ( + <> + + {[...picked].sort().join(',')} + + ) +} + +afterEach(cleanup) + +describe('PickList', () => { + it('picks only what you check, and never one that is in already', () => { + render() + expect(screen.getByText('1 of 2 picked')).toBeTruthy() + const low = screen.getByRole('checkbox', { name: /Low/ }) + expect(low.getAttribute('aria-checked')).toBe('true') + fireEvent.click(screen.getByRole('checkbox', { name: /Slowdive/ })) + expect(screen.getByRole('status').textContent).toBe('Low,Slowdive') + expect((screen.getByRole('checkbox', { name: /Hum/ }) as HTMLButtonElement).disabled).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'None' })) + expect(screen.getByRole('status').textContent).toBe('') + fireEvent.click(screen.getByRole('button', { name: 'All' })) + expect(screen.getByRole('status').textContent).toBe('Low,Slowdive') + }) +}) diff --git a/web/src/components/discover/pick-list.tsx b/web/src/components/discover/pick-list.tsx new file mode 100644 index 0000000..e874c52 --- /dev/null +++ b/web/src/components/discover/pick-list.tsx @@ -0,0 +1,73 @@ +import { Check } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +/** An artist to pick, with a line about why it's offered. */ +export type Pickable = { name: string; detail?: string; disabled?: boolean } + +/** + * A checklist of artists to pick from: nothing joins a set unless you + * check it (library ≠ taste). + */ +export function PickList({ + items, + picked, + onChange, + label, +}: { + items: readonly Pickable[] + picked: ReadonlySet + onChange: (picked: Set) => void + label: string +}) { + const open = items.filter((it) => !it.disabled) + const all = open.length > 0 && open.every((it) => picked.has(it.name)) + const toggle = (name: string) => { + const next = new Set(picked) + if (next.has(name)) next.delete(name) + else next.add(name) + onChange(next) + } + return ( +
+
+ + {picked.size} of {open.length} picked + + +
+
    + {items.map((it) => { + const on = picked.has(it.name) + return ( +
  • + +
  • + ) + })} +
+
+ ) +} diff --git a/web/src/components/discover/release-card.test.tsx b/web/src/components/discover/release-card.test.tsx index 3193e83..6c0668e 100644 --- a/web/src/components/discover/release-card.test.tsx +++ b/web/src/components/discover/release-card.test.tsx @@ -64,4 +64,26 @@ describe('ReleaseCard', () => { fireEvent.click(screen.getByRole('button', { name: "Hum isn't for me" })) expect(onBlock).toHaveBeenCalled() }) + + it('can be wanted, once', async () => { + const client = new QueryClient() + const onWant = vi.fn() + const card = (wanted: boolean) => ( + + +
    + +
+
+
+ ) + render(card(false)) + fireEvent.click(await screen.findByRole('button', { name: 'Want Inlet' })) + expect(onWant).toHaveBeenCalledTimes(1) + cleanup() + render(card(true)) + const on = await screen.findByRole('button', { name: 'Inlet is on your Want list' }) + expect(on.getAttribute('aria-pressed')).toBe('true') + expect((on as HTMLButtonElement).disabled).toBe(true) + }) }) diff --git a/web/src/components/discover/release-card.tsx b/web/src/components/discover/release-card.tsx index 69fbc48..0a245dd 100644 --- a/web/src/components/discover/release-card.tsx +++ b/web/src/components/discover/release-card.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { ChevronDown, ExternalLink, UserX } from 'lucide-react' +import { ChevronDown, ExternalLink, Heart, UserX } from 'lucide-react' import { motion } from 'motion/react' import { useId, useState } from 'react' import { errorMessage } from '@/api/errors' @@ -18,7 +18,19 @@ import { SongRow } from './song-row' * it's here. Open it for its songs, each with a preview, or search * YouTube for the whole thing. */ -export function ReleaseCard({ release, isNew, onBlock }: { release: DiscoveryRelease; isNew?: boolean; onBlock: () => void }) { +export function ReleaseCard({ + release, + isNew, + wanted, + onWant, + onBlock, +}: { + release: DiscoveryRelease + isNew?: boolean + wanted?: boolean + onWant?: () => void + onBlock: () => void +}) { const [open, setOpen] = useState(false) const songsId = useId() const why = release.seed ? '' : becauseText(release) @@ -54,6 +66,20 @@ export function ReleaseCard({ release, isNew, onBlock }: { release: DiscoveryRel YouTube + {onWant && ( + + )} diff --git a/web/src/components/discover/suggestions.tsx b/web/src/components/discover/suggestions.tsx new file mode 100644 index 0000000..089b422 --- /dev/null +++ b/web/src/components/discover/suggestions.tsx @@ -0,0 +1,97 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Sparkles, UserX } from 'lucide-react' +import { AnimatePresence, motion } from 'motion/react' +import { errorMessage } from '@/api/errors' +import { Button } from '@/components/ui/button' +import { + block, + blocksQuery, + dismissSuggestion, + feedQuery, + freshQuery, + importArtists, + setChanged, + suggestionsQuery, + type DiscoveryOffer, + type DiscoverySet, +} from '@/lib/discover' +import { fadeUp } from '@/lib/motion' +import { toast } from '@/lib/toast' + +/** + * What your thumbs suggest for a set: an artist you keep liking, to add + * to it, and one you keep turning down, to block. + */ +export function Suggestions({ set }: { set: DiscoverySet }) { + const qc = useQueryClient() + const sg = useQuery(suggestionsQuery(set.id)) + const done = () => void qc.invalidateQueries({ queryKey: suggestionsQuery(set.id).queryKey }) + const onError = (e: unknown) => toast({ message: errorMessage(e), tone: 'error' }) + + const grow = useMutation({ + mutationFn: (o: DiscoveryOffer) => importArtists(set.id, 'thumb', [o.name]), + onSuccess: (s, o) => { + setChanged(qc, s) + done() + toast({ message: `Added ${o.name} to ${s.name}` }) + }, + onError, + }) + const blockIt = useMutation({ + mutationFn: (o: DiscoveryOffer) => block(o.name), + onSuccess: (b) => { + done() + void qc.invalidateQueries({ queryKey: blocksQuery.queryKey }) + void qc.invalidateQueries({ queryKey: feedQuery(set.id).queryKey }) + void qc.invalidateQueries({ queryKey: freshQuery(set.id).queryKey }) + toast({ message: `No more ${b.name}` }) + }, + onError, + }) + const no = useMutation({ + mutationFn: ({ kind, o }: { kind: 'grow' | 'block'; o: DiscoveryOffer }) => dismissSuggestion(set.id, kind, o.name), + onSuccess: done, + onError, + }) + + const offers = [ + ...(sg.data?.grow ?? []).map((o) => ({ kind: 'grow' as const, o })), + ...(sg.data?.block ?? []).map((o) => ({ kind: 'block' as const, o })), + ].slice(0, 3) + const busy = grow.isPending || blockIt.isPending || no.isPending + + return ( + + {offers.length > 0 && ( + + {offers.map(({ kind, o }) => ( +
  • + + {kind === 'grow' ? : } + +

    + {kind === 'grow' ? ( + <> + You liked {o.thumbs} songs by {o.name}. Add them to this set? + + ) : ( + <> + You turned down {o.thumbs} songs by {o.name}. Not for you? + + )} +

    +
    + + +
    +
  • + ))} +
    + )} +
    + ) +} diff --git a/web/src/lib/discover.test.ts b/web/src/lib/discover.test.ts index 6268de7..e96ba0a 100644 --- a/web/src/lib/discover.test.ts +++ b/web/src/lib/discover.test.ts @@ -3,15 +3,20 @@ import { becauseText, fansText, feedItems, + groupWants, imageUrl, kindText, + landedText, mixFresh, releaseCoverUrl, releaseDateText, releaseYoutubeUrl, + starredText, + yieldText, youtubeUrl, type DiscoveryFeed, type DiscoveryRelease, + type WantItem, } from './discover' const item = (key: string) => ({ key, title: key, artist: 'A', because: ['Slowdive'], seed: false }) @@ -132,3 +137,35 @@ describe('kindText', () => { expect(kindText('live')).toBe('Live album') }) }) + +describe('the Want list', () => { + const want = (id: string, artist: string, status: WantItem['status']): WantItem => ({ + id, + kind: 'track', + artist, + title: id, + status, + addedAt: '2026-10-14T00:00:00Z', + }) + + it('groups by artist, A to Z, each keeping its order', () => { + const items = [want('a', 'slowdive', 'wanted'), want('b', 'Hum', 'wanted'), want('c', 'Slowdive', 'wanted'), want('d', 'Hum', 'got')] + const groups = groupWants(items, 'wanted') + expect(groups.map((g) => g.artist)).toEqual(['Hum', 'slowdive']) + expect(groups[1].items.map((w) => w.id)).toEqual(['a', 'c']) + expect(groupWants(items, 'got').map((g) => g.items.length)).toEqual([1]) + expect(groupWants(items, 'dismissed')).toEqual([]) + }) + + it('says what landed and what a set yielded', () => { + expect(landedText(1)).toBe('1 wanted thing landed in your library') + expect(landedText(3)).toBe('3 wanted things landed in your library') + expect(yieldText({ wanted: 0, got: 0 })).toBe('') + expect(yieldText({ wanted: 5, got: 2 })).toBe('2 of 5 wanted got') + }) + + it('says what of an artist you starred', () => { + expect(starredText({ name: 'Low', artist: true, albums: 0, songs: 0 })).toBe('Starred') + expect(starredText({ name: 'Slowdive', artist: false, albums: 2, songs: 1 })).toBe('2 albums · 1 song') + }) +}) diff --git a/web/src/lib/discover.ts b/web/src/lib/discover.ts index 58b9a31..8fdcad9 100644 --- a/web/src/lib/discover.ts +++ b/web/src/lib/discover.ts @@ -20,6 +20,15 @@ export type DiscoveryMapEdge = components['schemas']['DiscoveryMapEdge'] export type DiscoveryRelease = components['schemas']['DiscoveryRelease'] export type DiscoveryFresh = components['schemas']['DiscoveryFresh'] export type DiscoveryFreshDays = components['schemas']['DiscoveryFreshDays'] +export type DiscoveryRoomWeight = components['schemas']['DiscoveryRoomWeight'] +export type DiscoverySuggestions = components['schemas']['DiscoverySuggestions'] +export type DiscoveryOffer = components['schemas']['DiscoveryOffer'] +export type DiscoveryStarredArtist = components['schemas']['DiscoveryStarredArtist'] +export type DiscoveryTasteArtist = components['schemas']['DiscoveryTasteArtist'] +export type DiscoveryThumbResult = components['schemas']['DiscoveryThumbResult'] +export type WantItem = components['schemas']['WantItem'] +export type WantList = components['schemas']['WantList'] +export type WantStatus = components['schemas']['WantStatus'] /** How far back a Fresh feed can look. */ export const freshWindows: DiscoveryFreshDays[] = [30, 60, 90] @@ -128,13 +137,25 @@ export function setChanged(qc: QueryClient, set: DiscoverySet) { void qc.resetQueries({ queryKey: feedQuery(set.id).queryKey }) void qc.invalidateQueries({ queryKey: mapQuery({ setId: set.id }).queryKey }) void qc.invalidateQueries({ queryKey: freshQuery(set.id).queryKey }) + void qc.invalidateQueries({ queryKey: ['discover', 'suggestions', set.id] }) } export function createSet(name: string, artists: { name: string; mbid?: string }[] = []) { return unwrap(api.POST('/me/discover/sets', { body: { name, artists } })) } -export function updateSet(id: string, body: { name?: string; explore?: number; mode?: DiscoveryMode; freshDays?: DiscoveryFreshDays }) { +export function updateSet( + id: string, + body: { + name?: string + explore?: number + mode?: DiscoveryMode + freshDays?: DiscoveryFreshDays + growAuto?: boolean + roomId?: string + roomWeight?: DiscoveryRoomWeight + }, +) { return unwrap(api.PATCH('/me/discover/sets/{setId}', { params: { path: { setId: id } }, body })) } @@ -150,10 +171,21 @@ export function removeArtist(id: string, key: string) { return unwrap(api.DELETE('/me/discover/sets/{setId}/artists/{artistKey}', { params: { path: { setId: id, artistKey: key } } })) } +/** A thumb on a song. A thumbs-up wants it, and may grow the set (`grew`). */ export function thumb(kind: 'up' | 'down', item: DiscoveryItem, setId?: string) { return unwrap( api.POST('/me/discover/feedback', { - body: { kind, setId, title: item.title, artist: item.artist, mbid: item.mbid, isrc: item.isrc, deezerId: item.deezerId }, + body: { + kind, + setId, + title: item.title, + artist: item.artist, + album: item.album, + cover: item.cover, + mbid: item.mbid, + isrc: item.isrc, + deezerId: item.deezerId, + }, }), ) } @@ -170,6 +202,126 @@ export function refreshLibrary() { return unwrap(api.POST('/me/discover/library')) } +// --- The Want list (MAD-824) ------------------------------------------------- + +export const wantQuery = queryOptions({ + queryKey: ['discover', 'want'], + queryFn: () => unwrap(api.GET('/me/discover/want')), +}) + +/** Wants a release from a Fresh feed. */ +export function wantRelease(r: DiscoveryRelease, setId?: string) { + return unwrap( + api.POST('/me/discover/want', { + body: { kind: 'release', artist: r.artist, title: r.title, mbid: r.mbid, deezerId: r.deezerId, cover: r.cover, setId }, + }), + ) +} + +export function setWant(id: string, status: WantStatus) { + return unwrap(api.PATCH('/me/discover/want/{wantId}', { params: { path: { wantId: id } }, body: { status } })) +} + +/** Notes that you saw what landed in your library. */ +export function seeLanded() { + return unwrap(api.POST('/me/discover/want/seen')) +} + +/** A changed Want-list item, in the cached list. */ +export function wantChanged(qc: QueryClient, item: WantItem) { + qc.setQueryData(wantQuery.queryKey, (l) => { + if (!l) return l + const i = l.items.findIndex((w) => w.id === item.id) + return { ...l, items: i < 0 ? [item, ...l.items] : l.items.map((w) => (w.id === item.id ? item : w)) } + }) + void qc.invalidateQueries({ queryKey: setsQuery.queryKey, exact: true }) +} + +/** A Want list's items with a status, grouped by artist: artists A to Z, each newest first. */ +export function groupWants(items: readonly WantItem[], status: WantStatus) { + const by = new Map() + for (const it of items) { + if (it.status !== status) continue + const k = it.artist.toLowerCase() + const g = by.get(k) ?? { artist: it.artist, items: [] } + g.items.push(it) + by.set(k, g) + } + return [...by.values()].sort((a, b) => a.artist.localeCompare(b.artist, undefined, { sensitivity: 'base' })) +} + +/** "3 wanted things landed in your library". */ +export function landedText(n: number) { + return `${n} wanted thing${n === 1 ? '' : 's'} landed in your library` +} + +/** What a set has yielded: "2 of 5 wanted got", or "" if nothing yet. */ +export function yieldText(s: Pick) { + if (s.wanted === 0) return '' + return `${s.got} of ${s.wanted} wanted got` +} + +// --- Sets that learn (MAD-825) and rooms as a source (MAD-826) -------------- + +export const suggestionsQuery = (setId: string) => + queryOptions({ + queryKey: ['discover', 'suggestions', setId], + queryFn: () => unwrap(api.GET('/me/discover/sets/{setId}/suggestions', { params: { path: { setId } } })), + }) + +export function dismissSuggestion(setId: string, kind: 'grow' | 'block', artist: string) { + return unwrap(api.POST('/me/discover/sets/{setId}/suggestions/dismiss', { params: { path: { setId } }, body: { kind, artist } })) +} + +export type ArtistSource = 'manual' | 'thumb' | 'starred' | 'room' + +/** Adds the artists you picked to a set, noted as coming from where you picked them. */ +export function importArtists(setId: string, from: ArtistSource, names: string[]) { + return unwrap( + api.POST('/me/discover/sets/{setId}/import', { params: { path: { setId } }, body: { from, artists: names.map((name) => ({ name })) } }), + ) +} + +export const starredQuery = queryOptions({ + queryKey: ['discover', 'starred'], + queryFn: () => unwrap(api.GET('/me/discover/starred')), + staleTime: 5 * 60_000, +}) + +export const roomTasteQuery = (q: { roomId: string; nightId?: string; mine?: boolean }) => + queryOptions({ + queryKey: ['discover', 'room-taste', q], + queryFn: () => + unwrap( + api.GET('/me/discover/room-taste', { + params: { query: { roomId: q.roomId, ...(q.nightId ? { nightId: q.nightId } : {}), ...(q.mine ? { mine: true } : {}) } }, + }), + ), + enabled: !!q.roomId, + staleTime: 60_000, + }) + +/** "Starred · 2 albums · 1 song". */ +export function starredText(a: DiscoveryStarredArtist) { + const parts: string[] = [] + if (a.artist) parts.push('Starred') + if (a.albums) parts.push(`${a.albums} album${a.albums === 1 ? '' : 's'}`) + if (a.songs) parts.push(`${a.songs} song${a.songs === 1 ? '' : 's'}`) + return parts.join(' · ') +} + +const fromText: Record = { + manual: '', + thumb: 'Joined from your thumbs-up', + starred: 'From your Navidrome stars', + room: "From a room's taste", +} + +/** Where a seed artist came from, for a hint; "" for ones you added. */ +export function sourceText(from: ArtistSource) { + return fromText[from] ?? '' +} + /** A feed's songs, once each: a feed rebuilt between pages can repeat one. */ export function feedItems(pages: readonly DiscoveryFeed[] | undefined): DiscoveryItem[] { const seen = new Set() diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 24c4661..8b1516e 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -26,6 +26,8 @@ import { Route as AppAuthedSearchRouteImport } from './routes/_app/_authed/searc import { Route as AppAuthedDiscoverIndexRouteImport } from './routes/_app/_authed/discover.index' import { Route as AppAuthedDiscoverSetIdRouteImport } from './routes/_app/_authed/discover.$setId' import { Route as AppAuthedDiscoverMapRouteImport } from './routes/_app/_authed/discover.map' +import { Route as AppAuthedDiscoverSettingsRouteImport } from './routes/_app/_authed/discover.settings' +import { Route as AppAuthedDiscoverWantRouteImport } from './routes/_app/_authed/discover.want' import { Route as AppAuthedGenreLinkIdRouteImport } from './routes/_app/_authed/genre.$linkId' import { Route as AppAuthedLibraryIndexRouteImport } from './routes/_app/_authed/library.index' import { Route as AppAuthedLibraryPlaylistIdRouteImport } from './routes/_app/_authed/library.$playlistId' @@ -121,6 +123,17 @@ const AppAuthedDiscoverMapRoute = AppAuthedDiscoverMapRouteImport.update({ path: '/discover/map', getParentRoute: () => AppAuthedRoute, } as any) +const AppAuthedDiscoverSettingsRoute = + AppAuthedDiscoverSettingsRouteImport.update({ + id: '/discover/settings', + path: '/discover/settings', + getParentRoute: () => AppAuthedRoute, + } as any) +const AppAuthedDiscoverWantRoute = AppAuthedDiscoverWantRouteImport.update({ + id: '/discover/want', + path: '/discover/want', + getParentRoute: () => AppAuthedRoute, +} as any) const AppAuthedGenreLinkIdRoute = AppAuthedGenreLinkIdRouteImport.update({ id: '/genre/$linkId', path: '/genre/$linkId', @@ -199,6 +212,8 @@ export interface FileRoutesByFullPath { '/search': typeof AppAuthedSearchRoute '/discover/$setId': typeof AppAuthedDiscoverSetIdRoute '/discover/map': typeof AppAuthedDiscoverMapRoute + '/discover/settings': typeof AppAuthedDiscoverSettingsRoute + '/discover/want': typeof AppAuthedDiscoverWantRoute '/genre/$linkId': typeof AppAuthedGenreLinkIdRoute '/library/$playlistId': typeof AppAuthedLibraryPlaylistIdRoute '/settings/people': typeof AppAuthedSettingsPeopleRoute @@ -227,6 +242,8 @@ export interface FileRoutesByTo { '/search': typeof AppAuthedSearchRoute '/discover/$setId': typeof AppAuthedDiscoverSetIdRoute '/discover/map': typeof AppAuthedDiscoverMapRoute + '/discover/settings': typeof AppAuthedDiscoverSettingsRoute + '/discover/want': typeof AppAuthedDiscoverWantRoute '/genre/$linkId': typeof AppAuthedGenreLinkIdRoute '/library/$playlistId': typeof AppAuthedLibraryPlaylistIdRoute '/settings/people': typeof AppAuthedSettingsPeopleRoute @@ -258,6 +275,8 @@ export interface FileRoutesById { '/_app/_authed/': typeof AppAuthedIndexRoute '/_app/_authed/discover/$setId': typeof AppAuthedDiscoverSetIdRoute '/_app/_authed/discover/map': typeof AppAuthedDiscoverMapRoute + '/_app/_authed/discover/settings': typeof AppAuthedDiscoverSettingsRoute + '/_app/_authed/discover/want': typeof AppAuthedDiscoverWantRoute '/_app/_authed/genre/$linkId': typeof AppAuthedGenreLinkIdRoute '/_app/_authed/library/$playlistId': typeof AppAuthedLibraryPlaylistIdRoute '/_app/_authed/settings/people': typeof AppAuthedSettingsPeopleRoute @@ -288,6 +307,8 @@ export interface FileRouteTypes { | '/search' | '/discover/$setId' | '/discover/map' + | '/discover/settings' + | '/discover/want' | '/genre/$linkId' | '/library/$playlistId' | '/settings/people' @@ -316,6 +337,8 @@ export interface FileRouteTypes { | '/search' | '/discover/$setId' | '/discover/map' + | '/discover/settings' + | '/discover/want' | '/genre/$linkId' | '/library/$playlistId' | '/settings/people' @@ -346,6 +369,8 @@ export interface FileRouteTypes { | '/_app/_authed/' | '/_app/_authed/discover/$setId' | '/_app/_authed/discover/map' + | '/_app/_authed/discover/settings' + | '/_app/_authed/discover/want' | '/_app/_authed/genre/$linkId' | '/_app/_authed/library/$playlistId' | '/_app/_authed/settings/people' @@ -491,6 +516,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAuthedDiscoverMapRouteImport parentRoute: typeof AppAuthedRoute } + '/_app/_authed/discover/settings': { + id: '/_app/_authed/discover/settings' + path: '/discover/settings' + fullPath: '/discover/settings' + preLoaderRoute: typeof AppAuthedDiscoverSettingsRouteImport + parentRoute: typeof AppAuthedRoute + } + '/_app/_authed/discover/want': { + id: '/_app/_authed/discover/want' + path: '/discover/want' + fullPath: '/discover/want' + preLoaderRoute: typeof AppAuthedDiscoverWantRouteImport + parentRoute: typeof AppAuthedRoute + } '/_app/_authed/genre/$linkId': { id: '/_app/_authed/genre/$linkId' path: '/genre/$linkId' @@ -579,6 +618,8 @@ interface AppAuthedRouteChildren { AppAuthedIndexRoute: typeof AppAuthedIndexRoute AppAuthedDiscoverSetIdRoute: typeof AppAuthedDiscoverSetIdRoute AppAuthedDiscoverMapRoute: typeof AppAuthedDiscoverMapRoute + AppAuthedDiscoverSettingsRoute: typeof AppAuthedDiscoverSettingsRoute + AppAuthedDiscoverWantRoute: typeof AppAuthedDiscoverWantRoute AppAuthedGenreLinkIdRoute: typeof AppAuthedGenreLinkIdRoute AppAuthedLibraryPlaylistIdRoute: typeof AppAuthedLibraryPlaylistIdRoute AppAuthedSettingsPeopleRoute: typeof AppAuthedSettingsPeopleRoute @@ -601,6 +642,8 @@ const AppAuthedRouteChildren: AppAuthedRouteChildren = { AppAuthedIndexRoute: AppAuthedIndexRoute, AppAuthedDiscoverSetIdRoute: AppAuthedDiscoverSetIdRoute, AppAuthedDiscoverMapRoute: AppAuthedDiscoverMapRoute, + AppAuthedDiscoverSettingsRoute: AppAuthedDiscoverSettingsRoute, + AppAuthedDiscoverWantRoute: AppAuthedDiscoverWantRoute, AppAuthedGenreLinkIdRoute: AppAuthedGenreLinkIdRoute, AppAuthedLibraryPlaylistIdRoute: AppAuthedLibraryPlaylistIdRoute, AppAuthedSettingsPeopleRoute: AppAuthedSettingsPeopleRoute, diff --git a/web/src/routes/_app/_authed/discover.$setId.tsx b/web/src/routes/_app/_authed/discover.$setId.tsx index dd22f36..70a167d 100644 --- a/web/src/routes/_app/_authed/discover.$setId.tsx +++ b/web/src/routes/_app/_authed/discover.$setId.tsx @@ -1,6 +1,6 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query' import { createFileRoute, useNavigate } from '@tanstack/react-router' -import { Check, LoaderCircle, Pencil, RefreshCw, Trash2, X } from 'lucide-react' +import { Check, LoaderCircle, Pencil, RefreshCw, Star, Trash2, Users, X } from 'lucide-react' import { motion } from 'motion/react' import { useEffect, useMemo, useRef, useState } from 'react' import { errorMessage } from '@/api/errors' @@ -8,13 +8,16 @@ import { BackButton } from '@/components/back-button' import { ConfirmButton } from '@/components/confirm-button' import { ArtistSearch } from '@/components/discover/artist-search' import { FeedCard } from '@/components/discover/feed-card' +import { RoomSheet, StarredSheet } from '@/components/discover/import-sheets' import { ReleaseCard } from '@/components/discover/release-card' import { SetTabs } from '@/components/discover/set-tabs' +import { Suggestions } from '@/components/discover/suggestions' import { Notice } from '@/components/notice' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { Slider } from '@/components/ui/slider' +import { Switch } from '@/components/ui/switch' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { addArtist, @@ -31,17 +34,24 @@ import { setChanged, setQuery, setsQuery, + sourceText, stopPreview, + suggestionsQuery, thumb, unblock, updateSet, + wantChanged, + wantRelease, type DiscoveryFeed, type DiscoveryFresh, type DiscoveryFreshDays, type DiscoveryItem, type DiscoveryMode, + type DiscoveryRelease, + type DiscoveryRoomWeight, type DiscoverySet, } from '@/lib/discover' +import { roomsQuery } from '@/lib/room' import { tap } from '@/lib/haptics' import { stagger } from '@/lib/motion' import { toast } from '@/lib/toast' @@ -80,7 +90,8 @@ function SetPage() {
    - {set.data.artists.length === 0 ? ( + + {set.data.artists.length === 0 && !set.data.roomId ? (

    Add an artist or two, and music near them shows up here.

    ) : set.data.mode === 'fresh' ? ( @@ -160,12 +171,17 @@ function Seeds({ set }: { set: DiscoverySet }) { const add = useSetMutation((name: string) => addArtist(set.id, name)) const remove = useSetMutation((key: string) => removeArtist(set.id, key)) const have = useMemo(() => new Set(set.artists.map((a) => a.name.toLowerCase())), [set.artists]) + const [sheet, setSheet] = useState<'starred' | 'room'>() return (
    {set.artists.length > 0 && (
      {set.artists.map((a) => ( -
    • +
    • {a.name} + + + setSheet(o ? 'starred' : undefined)} /> + setSheet(o ? 'room' : undefined)} />
    ) } @@ -196,7 +224,11 @@ function Tuning({ set }: { set: DiscoverySet }) { // While dragging, and until the change is saved. const [dragged, setDragged] = useState() const explore = dragged ?? set.explore - const update = useSetMutation((body: { explore?: number; mode?: DiscoveryMode; freshDays?: DiscoveryFreshDays }) => updateSet(set.id, body)) + const update = useSetMutation( + (body: { explore?: number; mode?: DiscoveryMode; freshDays?: DiscoveryFreshDays; growAuto?: boolean; roomId?: string; roomWeight?: DiscoveryRoomWeight }) => + updateSet(set.id, body), + ) + const rooms = useQuery(roomsQuery) return (
    ) } @@ -324,10 +397,22 @@ function Feed({ set, withFresh }: { set: DiscoverySet; withFresh: boolean }) { if (kind === 'down') drop((it) => it.key !== item.key) else setThumbed((s) => new Set(s).add(item.key)) }, - onSuccess: (_, { kind, item }) => - toast({ message: kind === 'up' ? `Glad you like “${item.title}”` : `You won't see “${item.title}” again` }), + onSuccess: (did, { kind, item }) => { + void qc.invalidateQueries({ queryKey: suggestionsQuery(set.id).queryKey }) + if (did.want) wantChanged(qc, did.want) + if (did.grew) void qc.invalidateQueries({ queryKey: setQuery(set.id).queryKey }) + toast({ + message: + kind === 'down' + ? `You won't see “${item.title}” again` + : did.grew + ? `“${item.title}” is on your Want list, and ${did.grew} joined this set` + : `“${item.title}” is on your Want list`, + }) + }, onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), }) + const want = useWantRelease(set.id) const notForMe = useNotForMe(set.id, (artist) => drop((it) => it.artist.toLowerCase() !== artist.toLowerCase())) return ( @@ -361,7 +446,14 @@ function Feed({ set, withFresh }: { set: DiscoverySet; withFresh: boolean }) { {entries.map((e) => e.kind === 'release' ? ( - notForMe.mutate(e.release.artist)} /> + want.mutate(e.release)} + onBlock={() => notForMe.mutate(e.release.artist)} + /> ) : ( {}) + const want = useWantRelease(set.id) const data = fresh.data return (
    @@ -428,10 +521,42 @@ function FreshFeed({ set }: { set: DiscoverySet }) { ) : ( {data.items.map((r) => ( - notForMe.mutate(r.artist)} /> + want.mutate(r)} + onBlock={() => notForMe.mutate(r.artist)} + /> ))} )}
    ) } + +/** "Want" on a release: onto the Want list, until it's in your library. */ +function useWantRelease(setId: string) { + const qc = useQueryClient() + const [wanted, setWanted] = useState>(new Set()) + const m = useMutation({ + mutationFn: (r: DiscoveryRelease) => wantRelease(r, setId), + onMutate: (r) => { + tap() + setWanted((s) => new Set(s).add(r.key)) + }, + onSuccess: (item) => { + wantChanged(qc, item) + toast({ message: `“${item.title}” is on your Want list` }) + }, + onError: (e, r) => { + setWanted((s) => { + const next = new Set(s) + next.delete(r.key) + return next + }) + toast({ message: errorMessage(e), tone: 'error' }) + }, + }) + return { mutate: m.mutate, wanted } +} diff --git a/web/src/routes/_app/_authed/discover.index.tsx b/web/src/routes/_app/_authed/discover.index.tsx index 9a69c21..8c66905 100644 --- a/web/src/routes/_app/_authed/discover.index.tsx +++ b/web/src/routes/_app/_authed/discover.index.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' -import { Compass, Crosshair, LoaderCircle, Plus, RefreshCw, X } from 'lucide-react' +import { Compass, Crosshair, Heart, Plus, Settings2, Users } from 'lucide-react' import { motion } from 'motion/react' import { useState } from 'react' import { errorMessage } from '@/api/errors' @@ -10,19 +10,11 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { useMe } from '@/lib/auth' -import { - blocksQuery, - createSet, - libraryQuery, - refreshLibrary, - setChanged, - setsQuery, - unblock, - type DiscoveryLibrary, - type DiscoverySetSummary, -} from '@/lib/discover' +import { RoomSheet } from '@/components/discover/import-sheets' +import { Notice } from '@/components/notice' +import { createSet, landedText, setChanged, setsQuery, wantQuery, yieldText, type DiscoverySetSummary } from '@/lib/discover' import { fadeUp, stagger } from '@/lib/motion' -import { relativeTime } from '@/lib/time' +import { roomsQuery } from '@/lib/room' import { toast } from '@/lib/toast' export const Route = createFileRoute('/_app/_authed/discover/')({ @@ -37,6 +29,7 @@ function Discover() { const me = useMe() const sets = useQuery({ ...setsQuery, enabled: !me.guest }) const [naming, setNaming] = useState(false) + const [fromRoom, setFromRoom] = useState(false) if (me.guest) { return ( @@ -53,12 +46,22 @@ function Discover() { title="Discover" subtitle="Music you don't have yet, near artists you pick" actions={ - + <> + + + } /> - {naming && setNaming(false)} />} + + {naming && setNaming(false)} onFromRoom={() => setFromRoom(true)} />} + + {sets.isPending ? (
    {[0, 1, 2].map((i) => ( @@ -88,13 +91,12 @@ function Discover() { )} - - ) } -function NewSet({ onDone }: { onDone: () => void }) { +function NewSet({ onDone, onFromRoom }: { onDone: () => void; onFromRoom: () => void }) { + const rooms = useQuery(roomsQuery) const qc = useQueryClient() const navigate = useNavigate() const [name, setName] = useState('') @@ -121,6 +123,21 @@ function NewSet({ onDone }: { onDone: () => void }) { + {!!rooms.data?.length && ( + + )} ) } @@ -138,8 +155,10 @@ function SetCard({ set: s }: { set: DiscoverySetSummary }) { {s.name} - {s.artistCount} artist{s.artistCount === 1 ? '' : 's'} · {modeLabel[s.mode]} + {s.artistCount} artist{s.artistCount === 1 ? '' : 's'} + {s.roomId ? ' and a room' : ''} · {modeLabel[s.mode]} + {yieldText(s) && {yieldText(s)}} ) @@ -162,67 +181,41 @@ function MapStart() { ) } -/** What's left out: the songs on your own Navidrome, indexed in the background. */ -function LibraryStatus() { - const qc = useQueryClient() - const lib = useQuery(libraryQuery) - const again = useMutation({ - mutationFn: refreshLibrary, - onSuccess: (l) => qc.setQueryData(libraryQuery.queryKey, l), - onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), - }) - if (!lib.data) return null +/** "3 wanted things landed in your library": the Want list's news. */ +function Landed() { + const want = useQuery(wantQuery) + const n = want.data?.landed ?? 0 return ( -
    -

    Your library

    -
    -

    {libraryText(lib.data)}

    - {lib.data.links > 0 || lib.data.building ? ( - - ) : null} -
    -
    + + {n > 0 && ( + + {landedText(n)}. See them + + )} + ) } -function libraryText(l: DiscoveryLibrary) { - if (l.links === 0 && !l.building) { - return "No Navidrome of yours is linked, so nothing is left out. Link one in Settings → Services and Discover skips what's on it." - } - if (l.links === 0) return 'Indexing your library, so Discover can skip what you have.' - const when = l.builtAt ? `, as of ${relativeTime(l.builtAt)}` : '' - return `Skipping the ${l.songs.toLocaleString()} songs on your Navidrome${when}.` -} - -/** Artists you said aren't for you. */ -function Blocks() { - const qc = useQueryClient() - const blocks = useQuery(blocksQuery) - const undo = useMutation({ - mutationFn: unblock, - onSuccess: () => { - void qc.invalidateQueries({ queryKey: blocksQuery.queryKey }) - qc.removeQueries({ queryKey: ['discover', 'feed'] }) - }, - onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), - }) - if (!blocks.data || blocks.data.length === 0) return null +/** The Want list: what you liked, until it's in your library. */ +function WantCard() { + const want = useQuery(wantQuery) + const wanted = want.data?.items.filter((w) => w.status === 'wanted').length ?? 0 + const got = want.data?.items.filter((w) => w.status === 'got').length ?? 0 + if (!want.data || want.data.items.length === 0) return null return ( -
    -

    Not for me

    -
      - {blocks.data.map((b) => ( -
    • - {b.name} - -
    • - ))} -
    -
    + + + + + + Want list + + {wanted} wanted · {got} got + + + ) } diff --git a/web/src/routes/_app/_authed/discover.settings.tsx b/web/src/routes/_app/_authed/discover.settings.tsx new file mode 100644 index 0000000..84ce7c5 --- /dev/null +++ b/web/src/routes/_app/_authed/discover.settings.tsx @@ -0,0 +1,95 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { LoaderCircle, RefreshCw, X } from 'lucide-react' +import { errorMessage } from '@/api/errors' +import { BackButton } from '@/components/back-button' +import { PageHeader } from '@/components/page-header' +import { Button } from '@/components/ui/button' +import { blocksQuery, libraryQuery, refreshLibrary, unblock, type DiscoveryLibrary } from '@/lib/discover' +import { relativeTime } from '@/lib/time' +import { toast } from '@/lib/toast' + +export const Route = createFileRoute('/_app/_authed/discover/settings')({ + component: DiscoverSettings, +}) + +/** What Discover leaves out: your library, and artists you said aren't for you. */ +function DiscoverSettings() { + const blocks = useQuery(blocksQuery) + return ( + <> + + + + + + ) +} + +/** What's left out: the songs on your own Navidrome, indexed in the background. */ +function LibraryStatus() { + const qc = useQueryClient() + const lib = useQuery(libraryQuery) + const again = useMutation({ + mutationFn: refreshLibrary, + onSuccess: (l) => qc.setQueryData(libraryQuery.queryKey, l), + onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), + }) + if (!lib.data) return null + return ( +
    +

    Your library

    +
    +

    {libraryText(lib.data)}

    + {lib.data.links > 0 || lib.data.building ? ( + + ) : null} +
    +
    + ) +} + +function libraryText(l: DiscoveryLibrary) { + if (l.links === 0 && !l.building) { + return "No Navidrome of yours is linked, so nothing is left out. Link one in Settings → Services and Discover skips what's on it." + } + if (l.links === 0) return 'Indexing your library, so Discover can skip what you have.' + const when = l.builtAt ? `, as of ${relativeTime(l.builtAt)}` : '' + return `Skipping the ${l.songs.toLocaleString()} songs on your Navidrome${when}.` +} + +/** Artists you said aren't for you: blocked from every feed, until you let them back. */ +function Blocks({ empty }: { empty: boolean }) { + const qc = useQueryClient() + const blocks = useQuery(blocksQuery) + const undo = useMutation({ + mutationFn: unblock, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: blocksQuery.queryKey }) + qc.removeQueries({ queryKey: ['discover', 'feed'] }) + }, + onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), + }) + if (!blocks.data) return null + return ( +
    +

    Not for me

    +

    + {empty ? "Nobody's blocked. Tap the not-for-me button on a song, and its artist stays out of all your feeds." : 'Kept out of all your feeds. Let one back in with ×.'} +

    +
      + {blocks.data.map((b) => ( +
    • + {b.name} + +
    • + ))} +
    +
    + ) +} diff --git a/web/src/routes/_app/_authed/discover.want.tsx b/web/src/routes/_app/_authed/discover.want.tsx new file mode 100644 index 0000000..8e9462b --- /dev/null +++ b/web/src/routes/_app/_authed/discover.want.tsx @@ -0,0 +1,230 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createFileRoute, Link } from '@tanstack/react-router' +import { Check, ExternalLink, LoaderCircle, Pause, Play, Search, Undo2, X } from 'lucide-react' +import { motion } from 'motion/react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { errorMessage } from '@/api/errors' +import { Artwork } from '@/components/artwork' +import { BackButton } from '@/components/back-button' +import { AlbumLink, ArtistLink } from '@/components/linked-names' +import { Notice } from '@/components/notice' +import { PageHeader } from '@/components/page-header' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' +import { + groupWants, + imageUrl, + landedText, + releaseCoverUrl, + releaseYoutubeUrl, + seeLanded, + setsQuery, + setWant, + stopPreview, + togglePreview, + usePreview, + wantChanged, + wantQuery, + youtubeUrl, + type WantItem, + type WantStatus, +} from '@/lib/discover' +import { tap } from '@/lib/haptics' +import { fadeUp, stagger } from '@/lib/motion' +import { relativeTime } from '@/lib/time' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' + +const shows = ['got', 'dismissed'] as const +type Show = (typeof shows)[number] + +export const Route = createFileRoute('/_app/_authed/discover/want')({ + validateSearch: (search: Record): { show?: Show } => + shows.includes(search.show as Show) ? { show: search.show as Show } : {}, + loader: ({ context }) => context.queryClient.prefetchQuery(wantQuery), + component: WantPage, +}) + +const empty: Record = { + wanted: "Nothing wanted yet. Thumb up a song in Discover, or want a release from Fresh, and it waits here until it's in your library.", + got: 'Nothing got yet. Add something you wanted to your Navidrome, and it shows up here after the next index.', + dismissed: 'Nothing dismissed.', +} + +/** Songs and releases you liked in Discover, until they turn up in your library (MAD-824). */ +function WantPage() { + const qc = useQueryClient() + const navigate = Route.useNavigate() + const { show } = Route.useSearch() + const status: WantStatus = show ?? 'wanted' + const list = useQuery(wantQuery) + const sets = useQuery(setsQuery) + const setNames = useMemo(() => new Map(sets.data?.map((s) => [s.id, s.name])), [sets.data]) + + // What landed since you last looked: shown once, then noted as seen. + const [landed, setLanded] = useState(0) + const seen = useRef(false) + useEffect(() => { + const n = list.data?.landed ?? 0 + if (n === 0 || seen.current) return + seen.current = true + setLanded(n) + void seeLanded().then(() => qc.setQueryData(wantQuery.queryKey, (l: typeof list.data) => (l ? { ...l, landed: 0 } : l))) + }, [list.data, qc]) + useEffect(() => stopPreview, []) + + const change = useMutation({ + mutationFn: ({ item, to }: { item: WantItem; to: WantStatus }) => setWant(item.id, to), + onMutate: () => tap(), + onSuccess: (item, { to }) => { + wantChanged(qc, item) + if (to === 'got') toast({ message: `Got “${item.title}”` }) + if (to === 'dismissed') { + toast({ message: `Dismissed “${item.title}”`, action: { label: 'Undo', onClick: () => change.mutate({ item, to: 'wanted' }) } }) + } + }, + onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), + }) + + const items = list.data?.items ?? [] + const counts = { wanted: 0, got: 0, dismissed: 0 } + for (const it of items) counts[it.status]++ + const groups = groupWants(items, status) + + return ( + <> + + + {landed > 0 && ( + + {landedText(landed)}. They're under Got, ready to play. + + )} + v && void navigate({ search: v === 'wanted' ? {} : { show: v as Show }, replace: true })} + aria-label="Show" + className="mb-5" + > + Wanted {counts.wanted > 0 && {counts.wanted}} + Got {counts.got > 0 && {counts.got}} + Dismissed + + {list.isPending ? ( +
    + {[0, 1, 2].map((i) => ( + + ))} +
    + ) : list.isError ? ( + {errorMessage(list.error)} + ) : groups.length === 0 ? ( +

    {empty[status]}

    + ) : ( + + {groups.map((g) => ( + +

    + +

    +
      + {g.items.map((it) => ( + change.mutate({ item: it, to })} + /> + ))} +
    +
    + ))} +
    + )} + + ) +} + +function WantRow({ item, setName, busy, onChange }: { item: WantItem; setName?: string; busy: boolean; onChange: (to: WantStatus) => void }) { + const p = usePreview(item.id) + const isTrack = item.kind === 'track' + const canPlay = isTrack && !!item.deezerId && !p.failed && item.status !== 'got' + const cover = isTrack ? imageUrl('cover', item.cover, 120) : releaseCoverUrl({ cover: item.cover, mbid: item.mbid }, 120) + const youtube = isTrack ? youtubeUrl(item) : releaseYoutubeUrl({ artist: item.artist, title: item.title, kind: 'album' }) + const detail = [isTrack ? item.release : 'Release', setName && `from ${setName}`].filter(Boolean).join(' · ') + return ( +
  • +
    + +
    +

    + {item.status === 'got' && !isTrack ? : item.title} +

    +

    + {item.status === 'got' && item.gotAt + ? `${item.gotBy === 'auto' ? 'Landed in your library' : 'Got'} ${relativeTime(item.gotAt)}` + : detail} +

    +
    +
    +
    + {item.status === 'got' ? ( + <> + {isTrack && ( + + )} + + + ) : item.status === 'dismissed' ? ( + + ) : ( + <> + + + + + )} +
    +
  • + ) +}