diff --git a/CLAUDE.md b/CLAUDE.md index 02e89c9..89ca425 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,6 +249,11 @@ future metadata site can attach enrichment without reshaping the schema. (backfilled via `book_enrichment`) against the community metadata API (`metaserve`, meta.audiosilo.app) and returns a composed enrichment envelope (work + matched recording + series rails, each carrying its own `web_url`). + The `work` also carries the community **characters** and **recaps** (the CC + BY-SA expressive layer: spoiler-tagged, position-keyed - `reveal`/`through` + are logical work-chapter positions) when metaserve has them; both are + additive/`omitempty`, mirrored on `upstreamWorkDetail` and `MetaWork` and + passed through by `toCharacters`/`toRecaps`. Config is `metadata.{enabled,base_url}` (env `AUDIOSILO_METADATA_ENABLED` / `AUDIOSILO_METADATA_BASE_URL`; `base_url` must be an absolute http(s) URL when enabled) - one key disables ALL outbound calls. **Runtime toggle**: `meta.Service` diff --git a/internal/api/meta_test.go b/internal/api/meta_test.go index 37af437..cfe3f96 100644 --- a/internal/api/meta_test.go +++ b/internal/api/meta_test.go @@ -32,7 +32,7 @@ func (m *mockMetaserve) handler() http.Handler { _, _ = w.Write([]byte(`{"work":{"id":"the-martian","title":"The Martian","authors":[{"id":"andy-weir","name":"Andy Weir"}],"series":null,"cover_url":null,"added_at":null},"recording_id":"rec1"}`)) }) mux.HandleFunc("GET /api/v1/works/{id}", func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`{"id":"the-martian","title":"The Martian","subtitle":"","authors":[{"id":"andy-weir","name":"Andy Weir"}],"language":"en","first_published":"2011","description":"Stranded.","series":[{"id":"mars","name":"Mars","position":"1"}],"recordings":[{"id":"rec1","narrators":[{"id":"r-c-bray","name":"R. C. Bray"}],"abridged":false,"runtime_min":634,"release_date":"2013-03-22","publisher":"Podium Audio","cover_url":"https://c/1.jpg","chapter_count":12}]}`)) + _, _ = w.Write([]byte(`{"id":"the-martian","title":"The Martian","subtitle":"","authors":[{"id":"andy-weir","name":"Andy Weir"}],"language":"en","first_published":"2011","description":"Stranded.","series":[{"id":"mars","name":"Mars","position":"1"}],"recordings":[{"id":"rec1","narrators":[{"id":"r-c-bray","name":"R. C. Bray"}],"abridged":false,"runtime_min":634,"release_date":"2013-03-22","publisher":"Podium Audio","cover_url":"https://c/1.jpg","chapter_count":12}],"characters":[{"id":"mark-watney","name":"Mark Watney","role":"protagonist","reveal":{"chapter":1},"description":"Stranded astronaut."}],"recaps":[{"through":{"chapter":3},"scope":"book","text":"Watney takes stock."}]}`)) }) mux.HandleFunc("GET /api/v1/series/{id}", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"id":"mars","name":"Mars","authors":[{"id":"andy-weir","name":"Andy Weir"}],"works":[{"position":"1","work":{"id":"the-martian","title":"The Martian","authors":[{"id":"andy-weir","name":"Andy Weir"}],"series":null,"cover_url":null,"added_at":null}},{"position":"2","work":{"id":"artemis","title":"Artemis","authors":[{"id":"andy-weir","name":"Andy Weir"}],"series":null,"cover_url":null,"added_at":null}}]}`)) @@ -76,7 +76,7 @@ func TestMetaMatch(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("meta match = %d %s, want 200", resp.StatusCode, body) } - for _, want := range []string{`"matched":true`, `"the-martian"`, `"R. C. Bray"`, `"Podium Audio"`, `/work?id=the-martian`, `"artemis"`} { + for _, want := range []string{`"matched":true`, `"the-martian"`, `"R. C. Bray"`, `"Podium Audio"`, `/work?id=the-martian`, `"artemis"`, `"mark-watney"`, `"characters"`, `"recaps"`, `"reveal":{"chapter":1}`} { if !strings.Contains(body, want) { t.Fatalf("meta envelope missing %q: %s", want, body) } diff --git a/internal/meta/client.go b/internal/meta/client.go index 090adb1..9ee3917 100644 --- a/internal/meta/client.go +++ b/internal/meta/client.go @@ -143,6 +143,25 @@ type upstreamRecording struct { CoverURL string `json:"cover_url"` } +type upstreamPosition struct { + Chapter int `json:"chapter"` +} + +type upstreamCharacter struct { + ID string `json:"id"` + Name string `json:"name"` + Aliases []string `json:"aliases"` + Role string `json:"role"` + Reveal upstreamPosition `json:"reveal"` + Description string `json:"description"` +} + +type upstreamRecap struct { + Through upstreamPosition `json:"through"` + Scope string `json:"scope"` + Text string `json:"text"` +} + type upstreamWorkDetail struct { ID string `json:"id"` Title string `json:"title"` @@ -153,6 +172,8 @@ type upstreamWorkDetail struct { Description string `json:"description"` Series []upstreamSeriesRef `json:"series"` Recordings []upstreamRecording `json:"recordings"` + Characters []upstreamCharacter `json:"characters"` + Recaps []upstreamRecap `json:"recaps"` } type upstreamSeriesEntry struct { diff --git a/internal/meta/service.go b/internal/meta/service.go index 2c110ac..beb6623 100644 --- a/internal/meta/service.go +++ b/internal/meta/service.go @@ -31,6 +31,31 @@ type MetaPersonRef struct { Name string `json:"name"` } +// MetaPosition is a spoiler position on a work's own (edition-independent) +// timeline. Chapter is the logical work chapter; 0 = front matter / prior-book. +type MetaPosition struct { + Chapter int `json:"chapter"` +} + +// MetaCharacter is one community-authored, spoiler-tagged character entry +// (the CC BY-SA layer). Reveal is where it is first disclosed in the work. +type MetaCharacter struct { + ID string `json:"id"` + Name string `json:"name"` + Aliases []string `json:"aliases,omitempty"` + Role string `json:"role,omitempty"` + Reveal MetaPosition `json:"reveal"` + Description string `json:"description,omitempty"` +} + +// MetaRecap is one position-keyed "story so far" recap. Through is the position +// it is safe to show at (the listener has finished that chapter). +type MetaRecap struct { + Through MetaPosition `json:"through"` + Scope string `json:"scope,omitempty"` + Text string `json:"text"` +} + // MetaWork is the abstract book in an enrichment envelope. type MetaWork struct { ID string `json:"id"` @@ -40,6 +65,8 @@ type MetaWork struct { Language string `json:"language"` FirstPublished string `json:"first_published,omitempty"` Description string `json:"description,omitempty"` + Characters []MetaCharacter `json:"characters,omitempty"` + Recaps []MetaRecap `json:"recaps,omitempty"` } // MetaRecording is the specific narration/production matched by the lookup. @@ -220,6 +247,8 @@ func (s *Service) compose(ctx context.Context, asin, isbn string) (*Enrichment, Language: detail.Language, FirstPublished: detail.FirstPublished, Description: detail.Description, + Characters: toCharacters(detail.Characters), + Recaps: toRecaps(detail.Recaps), }, Recording: pickRecording(detail.Recordings, lookup.RecordingID), Series: rails, @@ -315,6 +344,43 @@ func toPersonRefs(in []upstreamPersonRef) []MetaPersonRef { return out } +// toCharacters maps the upstream character sidecar to the outward envelope, +// preserving upstream order. Returns nil (omitted) when there are none. +func toCharacters(in []upstreamCharacter) []MetaCharacter { + if len(in) == 0 { + return nil + } + out := make([]MetaCharacter, 0, len(in)) + for _, c := range in { + out = append(out, MetaCharacter{ + ID: c.ID, + Name: c.Name, + Aliases: c.Aliases, + Role: c.Role, + Reveal: MetaPosition(c.Reveal), + Description: c.Description, + }) + } + return out +} + +// toRecaps maps the upstream recap sidecar to the outward envelope, preserving +// upstream (position) order. Returns nil (omitted) when there are none. +func toRecaps(in []upstreamRecap) []MetaRecap { + if len(in) == 0 { + return nil + } + out := make([]MetaRecap, 0, len(in)) + for _, r := range in { + out = append(out, MetaRecap{ + Through: MetaPosition(r.Through), + Scope: r.Scope, + Text: r.Text, + }) + } + return out +} + func deref(s *string) string { if s == nil { return "" diff --git a/internal/meta/service_test.go b/internal/meta/service_test.go index 16c3e27..d7db472 100644 --- a/internal/meta/service_test.go +++ b/internal/meta/service_test.go @@ -64,6 +64,14 @@ const martianWork = `{ "recordings":[ {"id":"rec1","narrators":[{"id":"nobody","name":"Nobody"}],"abridged":false,"runtime_min":600,"release_date":"2012-01-01","publisher":"Other","cover_url":"https://c/rec1.jpg","chapter_count":10}, {"id":"rec2","narrators":[{"id":"r-c-bray","name":"R. C. Bray"}],"abridged":false,"runtime_min":634,"release_date":"2013-03-22","publisher":"Podium Audio","cover_url":"https://c/rec2.jpg","chapter_count":12} + ], + "characters":[ + {"id":"mark-watney","name":"Mark Watney","aliases":["Watney"],"role":"protagonist","reveal":{"chapter":1},"description":"An astronaut-botanist stranded alone on Mars."}, + {"id":"mission-control","name":"Venkat Kapoor","role":"supporting","reveal":{"chapter":6},"description":"A NASA director coordinating the rescue."} + ], + "recaps":[ + {"through":{"chapter":3},"scope":"book","text":"Watney is stranded and takes stock."}, + {"through":{"chapter":8},"scope":"book","text":"NASA realizes he is alive."} ] }` @@ -105,6 +113,30 @@ func TestEnrichComposition(t *testing.T) { if len(env.Work.Authors) != 1 || env.Work.Authors[0].Name != "Andy Weir" { t.Fatalf("authors wrong: %+v", env.Work.Authors) } + // Characters flow through in upstream order, with reveal position + aliases. + if len(env.Work.Characters) != 2 { + t.Fatalf("expected 2 characters, got %d: %+v", len(env.Work.Characters), env.Work.Characters) + } + c0 := env.Work.Characters[0] + if c0.ID != "mark-watney" || c0.Name != "Mark Watney" || c0.Role != "protagonist" || c0.Reveal.Chapter != 1 { + t.Fatalf("character[0] wrong: %+v", c0) + } + if len(c0.Aliases) != 1 || c0.Aliases[0] != "Watney" || c0.Description == "" { + t.Fatalf("character[0] aliases/desc wrong: %+v", c0) + } + if env.Work.Characters[1].Reveal.Chapter != 6 { + t.Fatalf("character[1] reveal wrong: %+v", env.Work.Characters[1]) + } + // Recaps flow through in upstream (position) order with scope + through. + if len(env.Work.Recaps) != 2 { + t.Fatalf("expected 2 recaps, got %d: %+v", len(env.Work.Recaps), env.Work.Recaps) + } + if env.Work.Recaps[0].Through.Chapter != 3 || env.Work.Recaps[0].Scope != "book" || env.Work.Recaps[0].Text == "" { + t.Fatalf("recap[0] wrong: %+v", env.Work.Recaps[0]) + } + if env.Work.Recaps[1].Through.Chapter != 8 { + t.Fatalf("recap[1] wrong: %+v", env.Work.Recaps[1]) + } // Recording is chosen by lookup's recording_id (rec2), not the first (rec1). if env.Recording == nil || env.Recording.ID != "rec2" || env.Recording.Publisher != "Podium Audio" { t.Fatalf("recording pick wrong: %+v", env.Recording)