Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions internal/api/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}}]}`))
Expand Down Expand Up @@ -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)
}
Expand Down
21 changes: 21 additions & 0 deletions internal/meta/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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 {
Expand Down
66 changes: 66 additions & 0 deletions internal/meta/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ""
Expand Down
32 changes: 32 additions & 0 deletions internal/meta/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."}
]
}`

Expand Down Expand Up @@ -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)
Expand Down
Loading