diff --git a/api/openapi.yaml b/api/openapi.yaml index 9ec2a5b..00326c6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4555,6 +4555,17 @@ components: type: string seedArtist: type: string + reason: + type: string + description: | + Why the DJ chose it, in a few words: "Because Sam's Radiohead → + Portishead (similar 0.82)", "A top song by Radiohead, an artist + the room loves", "Bridging Sam's Radiohead and Jo's Massive + Attack". Absent for a song a service recommended, or one picked + at random; say "Like {seedTitle}" then. + source: + type: string + description: The music knowledge the reason came from, for a credit, e.g. "Last.fm, Deezer". Palette: type: object diff --git a/docs/adr/0012-smart-dj.md b/docs/adr/0012-smart-dj.md index d5cfeb7..3482e6a 100644 --- a/docs/adr/0012-smart-dj.md +++ b/docs/adr/0012-smart-dj.md @@ -251,7 +251,7 @@ If the graph has no sources, knows nothing near the room's taste, or none of its - The similar-artists dataset name is ListenBrainz's own and may change. If it does, ListenBrainz stops contributing similar artists until the constant is updated, and the other sources carry on. - An artist's name can belong to more than one artist. Deezer takes the one with the most fans, and MusicBrainz the best search result. - The first fill after a cold start knows only the artists it fetched in time. The warmer catches up from the songs being queued. -- `AutopilotInfo.reason` is stored with each song; it isn't in the API until MAD-760. +- `AutopilotInfo.reason` is stored with each song. The API carries a short summary of it and its sources (`AutopilotPick.reason` and `source`, MAD-760), shown on now playing, up next and the big screen. The summary names members as they were called when the song was picked. - A room's long-term taste is the room's: it's deleted with the room. It names members by ID, and keeps an artist only while its likes or skips are above 0.005, about 15 nights of silence for one play. - Suggestions (ADR 0010) come from the DJ too (MAD-759): the asker's taste for "your vibe", the room's for "group vibe", found only on the asker's links. Without a graph they fall back on the services' recommendations, turning artists away by the DJ's rule (`suggest.TurnedAway` reads `dj.Profile.Avoid`), so a skip fades there too (MAD-762). - Tempo is often unknown: Deezer has a BPM for some songs and 0 for many, and few libraries tag it. Set flow treats 0 as unknown, so a room with little known about its songs plays as it did before; it flows better as the cache warms. diff --git a/server/internal/autopilot/autopilot.go b/server/internal/autopilot/autopilot.go index 1a287e3..241eb03 100644 --- a/server/internal/autopilot/autopilot.go +++ b/server/internal/autopilot/autopilot.go @@ -539,9 +539,11 @@ func (f *fill) fromGraph(ctx context.Context, users []string) []pick { if len(users) > 0 { fallback = users[0] } + name := f.s.names(ctx) var out []pick for _, p := range picks { - info := queue.AutopilotInfo{Reason: &queue.AutopilotReason{ + summary, source := explain(p, name) + info := queue.AutopilotInfo{Summary: summary, Source: source, Reason: &queue.AutopilotReason{ Kind: p.Why.Kind, Via: p.Why.Via, Similarity: p.Why.Similarity, Popularity: p.Why.Popularity, Novelty: p.Why.Novelty, Prior: p.Why.Prior, Score: p.Why.Score, DeepCut: p.Why.DeepCut, Sources: p.Why.Sources, }} diff --git a/server/internal/autopilot/dj_test.go b/server/internal/autopilot/dj_test.go index d53d652..15761c8 100644 --- a/server/internal/autopilot/dj_test.go +++ b/server/internal/autopilot/dj_test.go @@ -5,6 +5,7 @@ package autopilot_test import ( "context" "fmt" + "strings" "testing" "github.com/madeofpendletonwool/syncphony/server/internal/musicgraph" @@ -78,6 +79,10 @@ func TestDJPicksFromTheGraph(t *testing.T) { if r == nil || r.Kind != "similar-artist" || r.Via != "The Test Patterns" || r.Popularity != 1 || r.Score <= 0 { t.Fatalf("reason = %+v", r) } + // It's explained in words (MAD-760). + if !strings.HasPrefix(info.Summary, "Because alice's The Test Patterns → Null Island (similar") || info.Source != "test" { + t.Errorf("summary = %q, source %q", info.Summary, info.Source) + } // It planned what might follow, and the room's energy curve is on. if r.Flow == nil || len(r.Flow.Ahead) == 0 || r.Flow.Target == nil { t.Errorf("flow = %+v: want a plan ahead, and the curve's target", r.Flow) diff --git a/server/internal/autopilot/explain.go b/server/internal/autopilot/explain.go new file mode 100644 index 0000000..b49d822 --- /dev/null +++ b/server/internal/autopilot/explain.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package autopilot + +import ( + "context" + "fmt" + "strings" + + "github.com/madeofpendletonwool/syncphony/server/internal/dj" +) + +// Explaining the DJ's picks (MAD-760): people forgive picks they +// understand. Each pick gets a short reason, in words the room can read on +// now playing, up next and the big screen, and the knowledge sources it +// came from. + +// sourceNames are how music knowledge sources are shown. +var sourceNames = map[string]string{ + "lastfm": "Last.fm", + "listenbrainz": "ListenBrainz", + "deezer": "Deezer", + "musicbrainz": "MusicBrainz", +} + +// explain returns why the DJ chose p, as a short reason, and where the +// knowledge came from ("" if it didn't say). name returns a member's +// display name. +func explain(p dj.Pick, name func(userID string) string) (reason, source string) { + w := p.Why + artist := "" + if len(p.Track.Artists) > 0 { + artist = p.Track.Artists[0].Name + } + // whose is a member's artist: "Sam's Radiohead", or the room's. + whose := func(userID, a string) string { + if n := name(userID); n != "" { + return n + "'s " + a + } + return "the room's " + a + } + // The member's song it leads from, if any, names the artist it's + // like: the via artist, or for a member's turn, theirs. + seedUser, via := "", w.Via + if p.Seed.ID != "" { + seedUser = p.Seed.AddedBy + if t := dj.TrackOf(p.Seed); len(t.Artists) > 0 { + via = t.Artists[0].Name + } + } + switch { + case w.Bridge != nil: + b := w.Bridge + reason = fmt.Sprintf("Bridging %s and %s", whose(b.Users[0], b.Artists[0]), whose(b.Users[1], b.Artists[1])) + case w.Kind == dj.KindThrowback: + reason = fmt.Sprintf("A throwback to %s, a favorite from past nights", w.Via) + case w.Kind == dj.KindArtist && w.DeepCut: + reason = fmt.Sprintf("A deep cut by %s, an artist the room loves", artist) + case w.Kind == dj.KindArtist: + reason = fmt.Sprintf("A top song by %s, an artist the room loves", artist) + case w.Kind == dj.KindSimilarSong && p.Seed.ID != "": + reason = fmt.Sprintf("Like %s", whose(seedUser, dj.TrackOf(p.Seed).Title)) + case w.Kind == dj.KindSimilarSong: + reason = fmt.Sprintf("Like a song by %s", w.Via) + case w.Kind == dj.KindTwoSteps: + reason = fmt.Sprintf("Further out from %s → %s", whose(seedUser, via), artist) + default: + reason = fmt.Sprintf("Because %s → %s (similar %.2f)", whose(seedUser, via), artist, w.Similarity) + } + var names []string + for _, s := range w.Sources { + if n, ok := sourceNames[s]; ok { + names = append(names, n) + } else { + names = append(names, s) + } + } + return reason, strings.Join(names, ", ") +} + +// names returns a member's display name, looked up once, or "" if they're +// gone. +func (s *Service) names(ctx context.Context) func(userID string) string { + seen := map[string]string{} + return func(id string) string { + if id == "" { + return "" + } + if n, ok := seen[id]; ok { + return n + } + u, err := s.db.GetUser(ctx, id) + if err == nil { + seen[id] = u.DisplayName + } else { + seen[id] = "" + } + return seen[id] + } +} diff --git a/server/internal/autopilot/explain_test.go b/server/internal/autopilot/explain_test.go new file mode 100644 index 0000000..750c0ba --- /dev/null +++ b/server/internal/autopilot/explain_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package autopilot + +import ( + "encoding/json" + "testing" + + "github.com/madeofpendletonwool/syncphony/server/internal/dj" + "github.com/madeofpendletonwool/syncphony/server/internal/provider" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +func TestExplain(t *testing.T) { + names := map[string]string{"sam": "Sam", "jo": "Jo"} + name := func(id string) string { return names[id] } + song := func(artist, title string) provider.Track { + return provider.Track{Title: title, Artists: []provider.ArtistCredit{{Name: artist}}} + } + seed := func(user, artist, title string) store.QueueItem { + meta, _ := json.Marshal(song(artist, title)) + return store.QueueItem{ID: "i1", AddedBy: user, Metadata: string(meta)} + } + cases := []struct { + name string + pick dj.Pick + want, source string + }{ + { + "similar artist", + dj.Pick{Track: song("Portishead", "Roads"), Seed: seed("sam", "Radiohead", "Creep"), Why: dj.Why{ + Kind: dj.KindSimilarArtist, Via: "Radiohead", Similarity: 0.8234, Sources: []string{"lastfm", "deezer"}, + }}, + "Because Sam's Radiohead → Portishead (similar 0.82)", "Last.fm, Deezer", + }, + { + "autopilot's own taste", + dj.Pick{Track: song("Portishead", "Roads"), Why: dj.Why{Kind: dj.KindSimilarArtist, Via: "Radiohead", Similarity: 0.5}}, + "Because the room's Radiohead → Portishead (similar 0.50)", "", + }, + { + "top song", + dj.Pick{Track: song("Radiohead", "Creep"), Why: dj.Why{Kind: dj.KindArtist, Via: "Radiohead"}}, + "A top song by Radiohead, an artist the room loves", "", + }, + { + "deep cut", + dj.Pick{Track: song("Radiohead", "Nude"), Why: dj.Why{Kind: dj.KindArtist, DeepCut: true}}, + "A deep cut by Radiohead, an artist the room loves", "", + }, + { + "bridge", + dj.Pick{Track: song("Portishead", "Roads"), Why: dj.Why{Kind: dj.KindSimilarArtist, Bridge: &dj.Bridge{ + Users: [2]string{"sam", "jo"}, Artists: [2]string{"Radiohead", "Massive Attack"}, + }}}, + "Bridging Sam's Radiohead and Jo's Massive Attack", "", + }, + { + "similar song", + dj.Pick{Track: song("Muse", "Unintended"), Seed: seed("jo", "Radiohead", "Creep"), Why: dj.Why{Kind: dj.KindSimilarSong, Sources: []string{"lastfm"}}}, + "Like Jo's Creep", "Last.fm", + }, + { + "throwback", + dj.Pick{Track: song("Blur", "Song 2"), Why: dj.Why{Kind: dj.KindThrowback, Via: "Blur"}}, + "A throwback to Blur, a favorite from past nights", "", + }, + } + for _, c := range cases { + got, source := explain(c.pick, name) + if got != c.want || source != c.source { + t.Errorf("%s: explained %q (%q), want %q (%q)", c.name, got, source, c.want, c.source) + } + } +} diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go index 1843947..e4e090c 100644 --- a/server/internal/httpapi/api.gen.go +++ b/server/internal/httpapi/api.gen.go @@ -839,11 +839,20 @@ type ArtistResult struct { // Label it "Autopilot", not as anyone's. It plays after every // member's song, takes nobody's turn, and anyone may remove it. type AutopilotPick struct { + // Reason Why the DJ chose it, in a few words: "Because Sam's Radiohead → + // Portishead (similar 0.82)", "A top song by Radiohead, an artist + // the room loves", "Bridging Sam's Radiohead and Jo's Massive + // Attack". Absent for a song a service recommended, or one picked + // at random; say "Like {seedTitle}" then. + Reason *string `json:"reason,omitempty"` SeedArtist *string `json:"seedArtist,omitempty"` // SeedItemId The item whose song it's like. Absent for a song picked at random. SeedItemId *string `json:"seedItemId,omitempty"` SeedTitle *string `json:"seedTitle,omitempty"` + + // Source The music knowledge the reason came from, for a credit, e.g. "Last.fm, Deezer". + Source *string `json:"source,omitempty"` } // Backup defines model for Backup. diff --git a/server/internal/httpapi/realtime.go b/server/internal/httpapi/realtime.go index e98b7e7..5daeec4 100644 --- a/server/internal/httpapi/realtime.go +++ b/server/internal/httpapi/realtime.go @@ -383,7 +383,10 @@ func toQueueItem(it store.QueueItem) QueueItem { out.Via = &PlaysVia{Provider: it.ViaProvider.String, LinkId: it.ViaLinkID.String, TrackId: it.ViaTrackID.String} } if info, ok := queue.ParseAutopilot(it); ok { - out.Autopilot = &AutopilotPick{SeedItemId: nonEmpty(info.SeedItemID), SeedTitle: nonEmpty(info.SeedTitle), SeedArtist: nonEmpty(info.SeedArtist)} + out.Autopilot = &AutopilotPick{ + SeedItemId: nonEmpty(info.SeedItemID), SeedTitle: nonEmpty(info.SeedTitle), SeedArtist: nonEmpty(info.SeedArtist), + Reason: nonEmpty(info.Summary), Source: nonEmpty(info.Source), + } } if it.Palette.Valid { var p palette.Palette diff --git a/server/internal/queue/queue.go b/server/internal/queue/queue.go index ed98329..49c3114 100644 --- a/server/internal/queue/queue.go +++ b/server/internal/queue/queue.go @@ -296,6 +296,11 @@ type AutopilotInfo struct { // Reason is the DJ's reasoning (ADR 0012). It's nil for a song a // service recommended, or one picked at random. Reason *AutopilotReason `json:"reason,omitempty"` + // Summary is the DJ's reasoning in a few words, for people (MAD-760): + // "Because Sam's Radiohead → Portishead (similar 0.82)". Source is the + // music knowledge it came from: "Last.fm, Deezer". + Summary string `json:"summary,omitempty"` + Source string `json:"source,omitempty"` } // AutopilotReason is why the DJ chose a song: how it relates to the room diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index c33d23f..a2f0ee2 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -3076,6 +3076,16 @@ export interface components { seedItemId?: string; seedTitle?: string; seedArtist?: string; + /** + * @description Why the DJ chose it, in a few words: "Because Sam's Radiohead → + * Portishead (similar 0.82)", "A top song by Radiohead, an artist + * the room loves", "Bridging Sam's Radiohead and Jo's Massive + * Attack". Absent for a song a service recommended, or one picked + * at random; say "Like {seedTitle}" then. + */ + reason?: string; + /** @description The music knowledge the reason came from, for a credit, e.g. "Last.fm, Deezer". */ + source?: string; }; /** * @description The colors of a song's artwork, worked out once on the server so diff --git a/web/src/components/room/autopilot-badge.tsx b/web/src/components/room/autopilot-badge.tsx index 72a6251..48597cc 100644 --- a/web/src/components/room/autopilot-badge.tsx +++ b/web/src/components/room/autopilot-badge.tsx @@ -4,7 +4,7 @@ import { api } from '@/api/client' import { errorMessage, unwrap } from '@/api/errors' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { autopilotReason, type AutopilotPick } from '@/lib/autopilot' +import { autopilotReason, autopilotSource, type AutopilotPick } from '@/lib/autopilot' import { tap } from '@/lib/haptics' import type { QueueItem } from '@/lib/playback' import { queueQuery } from '@/lib/room' @@ -21,6 +21,17 @@ export function AutopilotBadge({ pick, className }: { pick: AutopilotPick; class ) } +/** Why autopilot chose a song, and where the DJ's knowledge came from. */ +export function AutopilotWhy({ pick, className }: { pick: AutopilotPick; className?: string }) { + const source = autopilotSource(pick) + return ( +

+ {autopilotReason(pick)} + {source && · {source}} +

+ ) +} + /** Autopilot's stand-in for a user avatar, in queue rows. */ export function AutopilotMark({ className }: { className?: string }) { return ( diff --git a/web/src/components/shell/now-playing-sheet.tsx b/web/src/components/shell/now-playing-sheet.tsx index 5cc90cc..2838231 100644 --- a/web/src/components/shell/now-playing-sheet.tsx +++ b/web/src/components/shell/now-playing-sheet.tsx @@ -4,7 +4,7 @@ import { Dialog } from 'radix-ui' import { useRef, useState } from 'react' import { Artwork } from '@/components/artwork' import { LyricsView } from '@/components/lyrics/lyrics-view' -import { AutopilotBadge } from '@/components/room/autopilot-badge' +import { AutopilotBadge, AutopilotWhy } from '@/components/room/autopilot-badge' import { SourceTag } from '@/components/service-tag' import { AlbumLink, ArtistLinks } from '@/components/track-credits' import { Badge } from '@/components/ui/badge' @@ -136,6 +136,7 @@ export function NowPlayingSheet({ open, onOpenChange }: { open: boolean; onOpenC {np.autopilot && } + {np.autopilot && }
diff --git a/web/src/components/tv/tv-stage.tsx b/web/src/components/tv/tv-stage.tsx index ef3e50e..fc57bd0 100644 --- a/web/src/components/tv/tv-stage.tsx +++ b/web/src/components/tv/tv-stage.tsx @@ -10,7 +10,7 @@ import { AlbumBackdrop } from '@/components/shell/album-backdrop' import { UserAvatar } from '@/components/user-avatar' import { useAlbumPalette } from '@/hooks/use-album-palette' import { usePosition } from '@/hooks/use-position' -import { autopilotReason } from '@/lib/autopilot' +import { autopilotReason, autopilotSource } from '@/lib/autopilot' import { guestPassQuery } from '@/lib/guests' import { laneStyle } from '@/lib/lane' import { linerCards, linerNotesQuery } from '@/lib/liner-notes' @@ -148,6 +148,9 @@ function NowPlayingColumn({ np }: { np: NowPlaying }) {

Autopilot · {autopilotReason(np.autopilot)} + {autopilotSource(np.autopilot) && ( + · {autopilotSource(np.autopilot)} + )}

)} @@ -308,6 +311,11 @@ function UpNext({ {item.autopilot ? 'Autopilot' : (u?.displayName ?? 'Someone')}

+ {item.autopilot && ( +

+ {autopilotReason(item.autopilot)} +

+ )} ) diff --git a/web/src/lib/autopilot.test.ts b/web/src/lib/autopilot.test.ts index 9d38d42..38635bc 100644 --- a/web/src/lib/autopilot.test.ts +++ b/web/src/lib/autopilot.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { autopilotReason, isMine } from './autopilot' +import { autopilotReason, autopilotSource, isMine } from './autopilot' describe('isMine', () => { it("counts your songs, not autopilot's", () => { @@ -14,4 +14,12 @@ describe('autopilotReason', () => { expect(autopilotReason({ seedItemId: 'i1', seedTitle: 'Heroes', seedArtist: 'David Bowie' })).toBe('Like Heroes') expect(autopilotReason({})).toBe('To keep the music going') }) + + it("prefers the DJ's reason, and credits its source", () => { + const pick = { seedTitle: 'Creep', reason: "Because Sam's Radiohead → Portishead (similar 0.82)", source: 'Last.fm, Deezer' } + expect(autopilotReason(pick)).toBe("Because Sam's Radiohead → Portishead (similar 0.82)") + expect(autopilotSource(pick)).toBe('via Last.fm, Deezer') + expect(autopilotSource({ seedTitle: 'Creep', source: 'Last.fm' })).toBeUndefined() + expect(autopilotSource({ reason: 'A top song by Radiohead, an artist the room loves' })).toBeUndefined() + }) }) diff --git a/web/src/lib/autopilot.ts b/web/src/lib/autopilot.ts index f57c606..92281d3 100644 --- a/web/src/lib/autopilot.ts +++ b/web/src/lib/autopilot.ts @@ -12,8 +12,17 @@ export function isMine(item: Item, userId: string) { return item.addedBy === userId && !item.autopilot } -/** Why autopilot chose a song, for a byline: "Like Heroes". */ +/** + * Why autopilot chose a song, for a byline: the DJ's reason ("Because + * Sam's Radiohead → Portishead (similar 0.82)"), else "Like Heroes". + */ export function autopilotReason(pick: AutopilotPick) { + if (pick.reason) return pick.reason if (!pick.seedTitle) return 'To keep the music going' return `Like ${pick.seedTitle}` } + +/** Where the DJ's reason came from, for a credit: "via Last.fm, Deezer". */ +export function autopilotSource(pick: AutopilotPick) { + return pick.reason && pick.source ? `via ${pick.source}` : undefined +}