clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat(podcasts): publish native F4 shows with RSS views

Dami committed 8ee7b2f
Support NIP-F4 show metadata, native audio episodes and reciprocal
show authorship throughout Social, profiles and conversations.

Refresh the podcasts relay view after publication and deletion.
Render show RSS with stable episode IDs, artwork, show notes and
player-compatible audio enclosures from the same native events.

Add show and episode publishing controls, ordinary regression tests
and podcast subscription documentation.

Validation: full Go suite, podcast race checks, 291 JavaScript tests,
and local browser playback, byte ranges and no-JavaScript profiles.
commit
8ee7b2f4332de23ecfc58ba95d1ef95086f96ae6
parent
ca0878fb21756636b0f54ac31ca938c40e901b51
author
Dami <dami.etoile@gmail.com>
38 files changed, +1286 -95
Mdocs/social.md24++-
Mdocs/views.md2+
Minternal/daemon/event_commit.go6+-
Minternal/daemon/mcp_http.go4+-
Minternal/daemon/replication_services.go10+-
Minternal/daemon/services.go4+-
Minternal/daemon/social_browse.go194++++++++++++++++++++++++++-
Minternal/daemon/social_browse_test.go5+-
Minternal/daemon/social_categories.go10+-
Minternal/daemon/social_categories_test.go8+-
Ainternal/daemon/social_podcasts_test.go163+++++++++++++++++++++++
Ainternal/podcasts/podcasts.go217++++++++++++++++++++++++++++++
Ainternal/podcasts/podcasts_test.go66+++++++++
Ainternal/records/podcasts.go81+++++++++++
Minternal/records/records.go9+-
Minternal/records/records_test.go6+-
Minternal/records/succession.go2+-
Minternal/records/views.go27+++-
Minternal/records/views_test.go64+++++++++
Minternal/templates/templates.go2+-
Minternal/templates/templates_test.go2+-
Mscripts/social-compose.test.mjs35+++++
Mtinyclient/components.js60+++++++-
Mtinyclient/podcast_rss.go93+++++++++----
Atinyclient/podcast_rss_native_test.go67+++++++++
Mtinyclient/podcast_rss_test.go17+-
Mtinyclient/remote.go2+-
Mtinyclient/social.html46++++++-
Mtinyclient/social_content.go2+-
Mtinyclient/social_navigation.go2+-
Mtinyclient/social_pages.go2+-
Atinyclient/social_podcasts.go42++++++
Atinyclient/social_podcasts_test.go86++++++++++++
Mtinyclient/social_profile.go3+-
Mtinyclient/social_profile.html2+-
Mtinyclient/social_reference_routes.go2+-
Mtinyclient/style.css10+
Mtinyclient/templates.go4+
M docs/social.md +17 -7
@@ -2,7 +2,7 @@
22 33 **Social** brings notes, longer posts and media into one chronological feed, with replies and reaction counts folded into each post. Use the Social navigation to browse Feed, Notes, Posts, Photos, Videos, Podcasts or Profile.44 5-Feed shows everything together. Notes collects short updates, while Posts collects long-form articles. Photos, Videos and Podcasts show posts with matching media attachments or links. A post can appear in more than one media view.5+Feed shows everything together. Notes collects short updates, while Posts collects long-form articles. Photos and Videos show posts with matching media. Podcasts collects native Nostr shows and episodes. An audio attachment on a note remains a note.66 77 ## Profiles88
@@ -10,11 +10,21 @@ Choose **Profile** to see your own profile and posts. Select an author's name on
1010 1111 You can read profiles without signing in when the relay allows public reads. Sign in to open your own profile or use **Edit profile** to change your name, picture and other details.1212 13-## Podcast subscriptions13+## Podcasts1414 15-Choose **Subscribe with RSS** in Podcasts to copy the feed address into a podcast app or RSS reader. Each profile also offers a feed for that person's audio posts. Episodes include an audio enclosure so readers can stream or download the recording.15+Podcasts follow [NIP-F4](https://github.com/nostr-protocol/nips/blob/master/F4.md). Each show has its own Nostr key. Sign in with that key, open **Podcasts**, then choose **Your podcast**. Use **Podcast details** to set the show's title, description, artwork and website. Use **Publish an episode** to upload audio or enter its URL, add a title and write show notes in Markdown.1616 17-The relay-wide feed is `/social/podcasts.rss`. Add `?author=<pubkey>` to subscribe to one person. Public subscriptions work without signing in; private relays retain their normal access requirements.17+The show information is a replaceable kind 10154 event. Episodes are kind 54 events signed by the show key, with `title`, `description`, optional `image` and one or more `audio` tags. Each audio tag contains its URL and optional media type. Uploaded audio also includes NIP-92 metadata with its type, size and hash.18+19+A show's author credits appear when both sides agree: the show names the person in a `p` tag, and that person's [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) authored-podcasts list, kind 10064, names the show. Favorite-podcast lists use kind 10054. These lists can be published by other Nostr clients.20+21+### Listen in a podcast app22+23+Open a show's profile and choose **Subscribe with RSS**. Copy that address into your podcast app's option to add a show by URL. The feed exposes the same native episodes, with show details, artwork, show notes and audio enclosures. Episode IDs remain stable between refreshes.24+25+The feed for one show is `/social/podcasts.rss?author=<show-pubkey>`. The address also accepts an npub. `/social/podcasts.rss` combines the newest 100 episodes on the relay; a show feed contains that show's newest 100 episodes. Public feeds need no sign-in. Private relay feeds retain their normal access requirements, so a conventional player must support the relay's authentication to read them.26+27+The built-in `podcasts` [relay view](views.md) refreshes after show and episode publication. RSS is an XML presentation of the current native events: show edits appear on refresh, and deleted or expired episodes disappear. Audio streams from its published URL. For broad player support, use MP3 or M4A audio hosted with support for HEAD and byte-range requests. The relay's own file hosting supports both.1828 1929 ## Notes and articles2030
@@ -24,7 +34,7 @@ The web page supports writing either form, previewing article Markdown, and addi
2434 2535 ## Conversations2636 27-Replies to notes use kind 1 with the usual [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md) thread references. Replies to articles use kind 1111 comments with [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md): `A`, `K` and `P` identify the article address, while lowercase tags identify the immediate parent. Older kind 1 article replies remain readable when they carry an article `a` tag.37+Replies to notes use kind 1 with the usual [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md) thread references. Articles and podcast episodes use kind 1111 comments with [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md). Article roots use `A`, `K` and `P`; podcast episode roots use `E`, `K` and `P`. Lowercase tags identify the immediate parent. Older kind 1 article replies remain readable when they carry an article `a` tag.2838 2939 Open a post to see its conversation. Replies are folded into the post's count in the feed, while the thread view lists comments in order and shows reaction totals with the same author and profile information.3040
@@ -36,7 +46,7 @@ Reactions are kind 7 events following [NIP-25](https://github.com/nostr-protocol
3646 3747 Attach images, video or audio from the composer, or paste a media URL. Uploaded media is stored through the relay's public Blossom file service and referenced in the event with [NIP-92](https://github.com/nostr-protocol/nips/blob/master/92.md) `imeta` tags. Alt text is included with the attachment metadata. Images, video and audio are shown inline when the browser can play them; unsupported media is offered as a direct link.3848 39-Articles use Markdown with the relay's safe text renderer. Notes and comments remain plaintext, with line breaks and safe links preserved. Supported media references become native browser elements; unsafe HTML and unsupported link targets remain text.49+Articles and podcast show notes use Markdown with the relay's safe text renderer. Notes and comments remain plaintext, with line breaks and safe links preserved. Supported media references become native browser elements; unsafe HTML and unsupported link targets remain text.4050 4151 ## Routes and feeds4252
@@ -46,6 +56,6 @@ The browser keeps unfinished composer text as a local draft for the signed-in ac
4656 4757 ## Relay template4858 49-The **Social** tenant template enables member publishing for notes, comments, reactions and articles while keeping reads public. It also allows profiles and deletion events needed by common Nostr clients. Choose it when the relay should provide a shared publishing feed rather than an article-only site.59+The **Social** tenant template enables member publishing for notes, comments, reactions, articles and native podcasts while keeping reads public. It also allows profiles, podcast lists and deletion events. Existing relays with a custom kind allowlist must include 54, 10154, 10064 and 10054 to accept all podcast events.5060 5161 Add the relay directly to any Nostr client that supports NIP-01 subscriptions and the event kinds above. The same events can therefore be viewed in the relay, Jumble, Primal and other Nostr clients.
M docs/views.md +2 -0
M internal/daemon/event_commit.go +5 -1
M internal/daemon/mcp_http.go +2 -2
@@ -112,8 +112,8 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
112112 "- Chat: "+base+"/chat combines NIP-29 group rooms and NIP-17 one-to-one messages. Existing /rooms URLs and room MCP tool names remain supported. Group messages use kind 9; kind 11 threads and kind 12 replies remain supported. Kind 9 replies use NIP-10 root and reply markers. The web UI shows one level of replies under the original root; follow-ups stay in that thread. Unsigned reply_in_thread templates resolve reply IDs to the original root. Room attachments use NIP-92 metadata. The chat guide documents 0xchat, Flotilla and Buzz compatibility boundaries.",113113 "- Direct messages use NIP-44 encryption and NIP-59 gift wraps with kind 10050 inbox relay lists. Decryption and recipient delivery happen in the client; room MCP tools do not read or send decrypted direct messages.",114114 "- Presence (kind 20001) and typing (kind 20002) are ephemeral. People opt in through "+base+"/account, which also holds personal relay lists and a profile link. Account preferences are separate from relay management. Agent labels identify the grant operator.",115- "- Social: "+base+"/social is one chronological feed of kind 1 notes and kind 30023 articles. Note replies use NIP-10; article comments use NIP-22 kind 1111; reactions use NIP-25 kind 7, including NIP-30 emoji. Articles render Markdown; notes and comments keep plain text with links and media embeds. Media uses NIP-92. Use publish_event for signed Social events; the Social guide covers formats and Jumble/Primal interoperability.",116- "- /articles redirects to Social. Mixed feeds are at "+base+"/social.json and "+base+"/social.xml; existing /articles.json and /feed.xml article feeds remain available.",115+ "- Social: "+base+"/social combines kind 1 notes, kind 30023 articles and NIP-F4 kind 54 podcast episodes. Podcast shows use kind 10154 metadata. Note replies use NIP-10; article and podcast comments use NIP-22 kind 1111; reactions use NIP-25 kind 7, including NIP-30 emoji. Articles and episode notes render Markdown; notes and comments keep plain text with links and media embeds. Media uses NIP-92. Use publish_event for signed Social events; the Social guide covers formats and interoperability.",116+ "- /articles redirects to Social. Mixed feeds are at "+base+"/social.json and "+base+"/social.xml; existing /articles.json and /feed.xml article feeds remain available. Native podcast RSS is at "+base+"/social/podcasts.rss?author=<show-pubkey>; the podcasts relay view refreshes when shows or episodes change.",117117 "",118118 "## Files",119119 "",
M internal/daemon/replication_services.go +7 -3
@@ -22,13 +22,13 @@ import (
2222 2323 "github.com/FelineStateMachine/tinyrelay/internal/configport"2424 "github.com/FelineStateMachine/tinyrelay/internal/event"25- gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"2625 "github.com/FelineStateMachine/tinyrelay/internal/policy"2726 "github.com/FelineStateMachine/tinyrelay/internal/records"2827 "github.com/FelineStateMachine/tinyrelay/internal/relay"2928 "github.com/FelineStateMachine/tinyrelay/internal/replication"3029 "github.com/FelineStateMachine/tinyrelay/internal/storage"3130 "github.com/FelineStateMachine/tinyrelay/internal/work"31+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"3232 )3333 3434 type tenantReplicationProvider struct{ tenant *Tenant }
@@ -199,7 +199,9 @@ func (p tenantReplicationProvider) Restore(ctx context.Context, state replicatio
199199 if _, err := p.tenant.config.ApplyWithOptions(ctx, parsed, configport.ApplyOptions{MigrationOwner: identity.Owner}); err != nil {200200 return fmt.Errorf("daemon: legacy config apply: %w", err)201201 }202- fresh, err := records.New(ctx, records.Config{Store: p.tenant.store, Community: p.tenant.community, Policy: p.tenant.Policy, RelayURL: p.tenant.RelayURL(), GroupID: p.tenant.meta.Name, OnGenerated: p.tenant.generatedRecord, DeliverNotification: p.tenant.deliverNotification, PushNotification: p.tenant.enqueuePush, SetPolicy: func(next policy.Policy) error { return p.tenant.applyPolicy(context.Background(), next) }})202+ fresh, err := records.New(ctx, records.Config{Store: p.tenant.store, Community: p.tenant.community, Policy: p.tenant.Policy, RelayURL: p.tenant.RelayURL(), GroupID: p.tenant.meta.Name, OnGenerated: p.tenant.generatedRecord, DeliverNotification: p.tenant.deliverNotification, PushNotification: p.tenant.enqueuePush, EventVisible: func(ctx context.Context, e event.Event, a policy.Access) bool {203+ return p.tenant.gate.CanSee(ctx, e, relay.Session{PubKeys: a.PubKeys, RelayURL: p.tenant.RelayURL()}, nil)204+ }, SetPolicy: func(next policy.Policy) error { return p.tenant.applyPolicy(context.Background(), next) }})203205 if err != nil {204206 return fmt.Errorf("daemon: refresh records after legacy config: %w", err)205207 }
@@ -234,7 +236,9 @@ func (p tenantReplicationProvider) Restore(ctx context.Context, state replicatio
234236 p.tenant.policy = next235237 p.tenant.mu.Unlock()236238 }237- fresh, err := records.New(ctx, records.Config{Store: p.tenant.store, Community: p.tenant.community, Policy: p.tenant.Policy, RelayURL: p.tenant.RelayURL(), GroupID: p.tenant.meta.Name, OnGenerated: p.tenant.generatedRecord, DeliverNotification: p.tenant.deliverNotification, PushNotification: p.tenant.enqueuePush, SetPolicy: func(next policy.Policy) error { return p.tenant.applyPolicy(context.Background(), next) }})239+ fresh, err := records.New(ctx, records.Config{Store: p.tenant.store, Community: p.tenant.community, Policy: p.tenant.Policy, RelayURL: p.tenant.RelayURL(), GroupID: p.tenant.meta.Name, OnGenerated: p.tenant.generatedRecord, DeliverNotification: p.tenant.deliverNotification, PushNotification: p.tenant.enqueuePush, EventVisible: func(ctx context.Context, e event.Event, a policy.Access) bool {240+ return p.tenant.gate.CanSee(ctx, e, relay.Session{PubKeys: a.PubKeys, RelayURL: p.tenant.RelayURL()}, nil)241+ }, SetPolicy: func(next policy.Policy) error { return p.tenant.applyPolicy(context.Background(), next) }})238242 if err != nil {239243 return fmt.Errorf("daemon: refresh records identity: %w", err)240244 }
M internal/daemon/services.go +3 -1
M internal/daemon/social_browse.go +186 -8
@@ -12,6 +12,7 @@ import (
1212 "time"1313 1414 "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/FelineStateMachine/tinyrelay/internal/podcasts"1516 "github.com/FelineStateMachine/tinyrelay/internal/storage"1617 )1718
@@ -53,6 +54,7 @@ type socialItem struct {
5354 RootAddress string `json:"root_address,omitempty"`5455 ReadMinutes int `json:"read_minutes,omitempty"`5556 Event event.Event `json:"event"`57+ Podcast *podcasts.Show `json:"podcast,omitempty"`5658 }5759 5860 type socialReaction struct {
@@ -63,7 +65,7 @@ type socialReaction struct {
6365 }6466 6567 func socialBrowseMethod(method string) bool {66- return method == "browsesocial" || method == "browsesocialthread"68+ return method == "browsesocial" || method == "browsesocialthread" || method == "browsepodcasts"6769 }6870 6971 func (t *Tenant) executeSocial(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
@@ -85,6 +87,9 @@ func (t *Tenant) executeSocial(ctx context.Context, actor, method string, params
8587 if method == "browsesocialthread" {8688 return t.browseSocialThread(ctx, actor, q)8789 }90+ if method == "browsepodcasts" || q.Kind == "podcasts" {91+ return t.browsePodcasts(ctx, actor, q)92+ }8893 return t.browseSocial(ctx, actor, q)8994 }9095
@@ -108,14 +113,14 @@ func socialOlder(a, b event.Event) bool {
108113 }109114 110115 func (t *Tenant) browseSocial(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {111- kinds := []int{1, 30023}116+ kinds := []int{1, 30023, podcasts.KindEpisode}112117 switch q.Kind {113118 case "", "all", "feed":114119 case "notes":115120 kinds = []int{1}116121 case "articles", "posts":117122 kinds = []int{30023}118- case "photos", "videos", "podcasts":123+ case "photos", "videos":119124 default:120125 return nil, errors.New("invalid: social kind")121126 }
@@ -160,10 +165,15 @@ func (t *Tenant) browseSocial(ctx context.Context, actor string, q socialBrowseR
160165 if err != nil {161166 return nil, err162167 }168+ if err := t.decoratePodcastItems(ctx, actor, items); err != nil {169+ return nil, err170+ }163171 stats := map[string]int{"notes": 0, "articles": 0, "comments": 0}164172 for _, item := range items {165173 if item.Kind == 30023 {166174 stats["articles"]++175+ } else if item.Kind == podcasts.KindEpisode {176+ stats["podcasts"]++167177 } else {168178 stats["notes"]++169179 }
@@ -172,10 +182,161 @@ func (t *Tenant) browseSocial(ctx context.Context, actor string, q socialBrowseR
172182 return map[string]any{"items": items, "next_cursor": next, "stats": stats}, nil173183 }174184 185+func (t *Tenant) decoratePodcastItems(ctx context.Context, actor string, items []socialItem) error {186+ authors := make([]string, 0)187+ seen := map[string]bool{}188+ for _, item := range items {189+ if item.Kind == podcasts.KindEpisode && !seen[item.Pubkey] {190+ seen[item.Pubkey] = true191+ authors = append(authors, item.Pubkey)192+ }193+ }194+ if len(authors) == 0 {195+ return nil196+ }197+ shows, err := t.socialShows(ctx, actor, authors)198+ if err != nil {199+ return err200+ }201+ for i := range items {202+ if items[i].Kind != podcasts.KindEpisode {203+ continue204+ }205+ show := shows[items[i].Pubkey]206+ items[i].Podcast = &show207+ if show.Title != "" {208+ items[i].AuthorName, items[i].AuthorPicture = show.Title, show.Image209+ }210+ }211+ return nil212+}213+214+func (t *Tenant) browsePodcasts(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {215+ cursor, err := parseCollaborationCursor(q.Cursor)216+ if err != nil {217+ return nil, err218+ }219+ filter := event.Filter{Kinds: []int{podcasts.KindEpisode}}220+ if q.Author != "" {221+ filter.Authors = []string{q.Author}222+ }223+ session := browseSession(t, actor)224+ next := ""225+ rows := make([]event.Event, 0, q.Limit)226+ for scan := 0; scan < 20 && len(rows) <= q.Limit; scan++ {227+ page, queryErr := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 100, Before: cursor})228+ if queryErr != nil {229+ return nil, queryErr230+ }231+ for _, row := range page.Events {232+ cursor = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}233+ if t.gate.CanSee(ctx, row, session, &filter) {234+ if _, ok := podcasts.ParseEpisode(row); ok && socialMatches(row, q.Query) {235+ rows = append(rows, row)236+ }237+ }238+ if len(rows) > q.Limit {239+ break240+ }241+ }242+ if len(rows) > q.Limit {243+ next = socialCursor(rows[q.Limit-1])244+ rows = rows[:q.Limit]245+ break246+ }247+ if !page.More || len(page.Events) == 0 {248+ break249+ }250+ if scan == 19 && cursor != nil {251+ next = collaborationCursor(collaborationItem{CreatedAt: cursor.CreatedAt, ID: cursor.ID})252+ }253+ }254+ items, err := t.socialItems(ctx, actor, rows)255+ if err != nil {256+ return nil, err257+ }258+ keys := make([]string, 0, len(rows))259+ seen := map[string]bool{}260+ for _, row := range rows {261+ if !seen[row.PubKey] {262+ seen[row.PubKey] = true263+ keys = append(keys, row.PubKey)264+ }265+ }266+ if q.Author != "" && !seen[q.Author] {267+ keys = append(keys, q.Author)268+ }269+ shows, err := t.socialShows(ctx, actor, keys)270+ if err != nil {271+ return nil, err272+ }273+ for i := range items {274+ show := shows[items[i].Pubkey]275+ items[i].Podcast = &show276+ if show.Title != "" {277+ items[i].AuthorName, items[i].AuthorPicture = show.Title, show.Image278+ }279+ }280+ result := map[string]any{"items": items, "next_cursor": next, "shows": shows}281+ if q.Author != "" {282+ show := shows[q.Author]283+ result["show"] = show284+ }285+ return result, nil286+}287+288+func (t *Tenant) socialShows(ctx context.Context, actor string, authors []string) (map[string]podcasts.Show, error) {289+ if len(authors) == 0 {290+ return map[string]podcasts.Show{}, nil291+ }292+ metadataFilter := event.Filter{Kinds: []int{podcasts.KindMetadata}, Authors: authors}293+ metadata, err := t.socialEvents(ctx, actor, []event.Filter{metadataFilter})294+ if err != nil {295+ return nil, err296+ }297+ declared := map[string]bool{}298+ for _, row := range metadata {299+ for _, tag := range row.Tags {300+ if len(tag) > 1 && tag[0] == "p" {301+ declared[tag[1]] = true302+ }303+ }304+ }305+ if len(declared) == 0 {306+ shows := podcasts.Shows(metadata)307+ for _, author := range authors {308+ if _, ok := shows[author]; !ok {309+ shows[author] = podcasts.Show{Pubkey: author}310+ }311+ }312+ return shows, nil313+ }314+ authorKeys := make([]string, 0, len(declared))315+ for key := range declared {316+ authorKeys = append(authorKeys, key)317+ }318+ authored, err := t.socialEvents(ctx, actor, []event.Filter{{Kinds: []int{podcasts.KindAuthored}, Authors: authorKeys}})319+ if err != nil {320+ return nil, err321+ }322+ rows := append(metadata, authored...)323+ shows := podcasts.Shows(rows)324+ for _, author := range authors {325+ if _, ok := shows[author]; !ok {326+ shows[author] = podcasts.Show{Pubkey: author}327+ }328+ }329+ return shows, nil330+}331+175332 func socialFeedRoot(row event.Event) bool {176333 if row.Kind == 30023 {177334 return true178335 }336+ if row.Kind == podcasts.KindEpisode {337+ _, ok := podcasts.ParseEpisode(row)338+ return ok && event.Tag(row, "h") == ""339+ }179340 if row.Kind != 1 || event.Tag(row, "h") != "" {180341 return false181342 }
@@ -184,7 +345,7 @@ func socialFeedRoot(row event.Event) bool {
184345 }185346 func socialMatches(row event.Event, query string) bool {186347 needle := strings.ToLower(strings.TrimSpace(query))187- return needle == "" || strings.Contains(strings.ToLower(row.Content+" "+event.Tag(row, "title")+" "+event.Tag(row, "summary")), needle)348+ return needle == "" || strings.Contains(strings.ToLower(row.Content+" "+event.Tag(row, "title")+" "+event.Tag(row, "summary")+" "+event.Tag(row, "description")), needle)188349 }189350 190351 // socialEvents scans only the requested references and applies the same per-event
@@ -268,6 +429,9 @@ func (t *Tenant) socialItems(ctx context.Context, actor string, rows []event.Eve
268429 for i := range items {269430 items[i].AuthorName, items[i].AuthorPicture = names[items[i].Pubkey][0], names[items[i].Pubkey][1]270431 }432+ if err := t.decoratePodcastItems(ctx, actor, items); err != nil {433+ return nil, err434+ }271435 return items, nil272436 }273437
@@ -477,14 +641,20 @@ func socialCommentFor(comment, post event.Event) bool {
477641 if comment.Kind == 1 && post.Kind == 1 {478642 return ref.parent == post.ID || ref.root == post.ID479643 }644+ if post.Kind == podcasts.KindEpisode {645+ if comment.Kind != 1111 {646+ return false647+ }648+ return ref.rootKind == podcasts.KindEpisode && ref.root == post.ID649+ }480650 if post.Kind == 30023 {481651 address := socialAddress(post)482652 if comment.Kind == 1111 {483- return ref.rootKind == 30023 && (ref.rootAddress == address || ref.root == post.ID)653+ return ref.rootKind == post.Kind && ((address != "" && ref.rootAddress == address) || ref.root == post.ID)484654 }485655 // Older long-form clients used kind 1 with an article a tag. Read these486656 // comments for compatibility, but new comments always use NIP-22.487- return comment.Kind == 1 && ref.rootAddress == address657+ return comment.Kind == 1 && address != "" && ref.rootAddress == address488658 }489659 if post.Kind == 1111 {490660 return comment.Kind == 1111 && ref.parent == post.ID && ref.parentKind == 1111
@@ -493,6 +663,9 @@ func socialCommentFor(comment, post event.Event) bool {
493663 }494664 func socialItemFrom(row event.Event) socialItem {495665 item := socialItem{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Pubkey: row.PubKey, Tags: row.Tags, CreatedAt: row.CreatedAt, Content: row.Content, Title: event.Tag(row, "title"), Summary: event.Tag(row, "summary"), Image: event.Tag(row, "image"), Event: row, Address: socialAddress(row)}666+ if item.Kind == podcasts.KindEpisode && item.Summary == "" {667+ item.Summary = event.Tag(row, "description")668+ }496669 item.ReadPath = "/social/" + row.ID497670 if item.Address != "" {498671 item.ReadPath = "/social?address=" + url.QueryEscape(item.Address)
@@ -531,10 +704,15 @@ func (t *Tenant) socialPost(ctx context.Context, actor, identifier string) (even
531704 if err != nil {532705 return event.Event{}, err533706 }534- if len(rows) != 1 || (rows[0].Kind != 1 && rows[0].Kind != 30023 && rows[0].Kind != 1111) || event.Tag(rows[0], "h") != "" {707+ if len(rows) != 1 || (rows[0].Kind != 1 && rows[0].Kind != 30023 && rows[0].Kind != podcasts.KindEpisode && rows[0].Kind != 1111) || event.Tag(rows[0], "h") != "" {535708 return event.Event{}, errors.New("not found: social post")536709 }537- if rows[0].Kind == 1111 && socialReference(rows[0]).rootKind != 30023 {710+ if rows[0].Kind == podcasts.KindEpisode {711+ if _, ok := podcasts.ParseEpisode(rows[0]); !ok {712+ return event.Event{}, errors.New("not found: podcast episode")713+ }714+ }715+ if rows[0].Kind == 1111 && socialReference(rows[0]).rootKind != 30023 && socialReference(rows[0]).rootKind != podcasts.KindEpisode {538716 return event.Event{}, errors.New("not found: social comment")539717 }540718 return rows[0], nil
M internal/daemon/social_browse_test.go +3 -2
M internal/daemon/social_categories.go +8 -2
M internal/daemon/social_categories_test.go +6 -2
A internal/daemon/social_podcasts_test.go +163 -0
@@ -0,0 +1,163 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "encoding/xml"7+ "net/http"8+ "net/http/httptest"9+ "strconv"10+ "strings"11+ "testing"12+ "time"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/FelineStateMachine/tinyrelay/internal/podcasts"16+ "github.com/FelineStateMachine/tinyrelay/internal/relay"17+ "github.com/FelineStateMachine/tinyrelay/internal/storage"18+)19+20+func TestSocialBrowseNativePodcastsAndMixedFeed(t *testing.T) {21+ _, tenant := testTenant(t)22+ owner := tenant.Policy().Owner23+ secret := testOwnerSecret24+ show := signedEvent(t, secret, podcasts.KindMetadata, 100, [][]string{{"title", "Relay Radio"}, {"image", "https://example.test/cover.jpg"}}, "")25+ episode := signedEvent(t, secret, podcasts.KindEpisode, 101, [][]string{{"title", "First episode"}, {"audio", "https://example.test/first.mp3", "audio/mpeg"}}, "The show notes")26+ for _, row := range []event.Event{show, episode} {27+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {28+ t.Fatal(err)29+ }30+ }31+ value, err := tenant.Execute(context.Background(), owner, "browsepodcasts", []json.RawMessage{rawJSON(map[string]any{"author": episode.PubKey})})32+ if err != nil {33+ t.Fatal(err)34+ }35+ result := value.(map[string]any)36+ items := result["items"].([]socialItem)37+ if len(items) != 1 || items[0].Kind != podcasts.KindEpisode || items[0].Podcast == nil || items[0].Podcast.Title != "Relay Radio" {38+ t.Fatalf("podcast browse = %#v", result)39+ }40+ mixed, err := tenant.Execute(context.Background(), owner, "browsesocial", nil)41+ if err != nil {42+ t.Fatal(err)43+ }44+ if items := mixed.(map[string]any)["items"].([]socialItem); len(items) != 1 || items[0].Kind != podcasts.KindEpisode {45+ t.Fatalf("mixed feed = %#v", items)46+ }47+}48+49+func TestSocialPodcastThreadRequiresNativeAudio(t *testing.T) {50+ _, tenant := testTenant(t)51+ row := signedEvent(t, testOwnerSecret, 54, time.Now().Unix(), [][]string{{"title", "Unrelated kind 54 event"}}, "No podcast audio")52+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt}); err != nil {53+ t.Fatal(err)54+ }55+ if _, err := tenant.socialPost(context.Background(), tenant.Policy().Owner, row.ID); err == nil {56+ t.Fatal("kind 54 without native audio should not open as a podcast")57+ }58+}59+60+func TestSocialPodcastSearchPaginatesPastOtherEvents(t *testing.T) {61+ _, tenant := testTenant(t)62+ ctx := context.Background()63+ var wanted []string64+ for i := 1; i <= 8; i++ {65+ tags := [][]string{{"title", "Other"}}66+ if i%2 == 0 {67+ tags = append(tags, []string{"audio", "https://example.test/" + strconv.Itoa(i) + ".mp3"})68+ }69+ if i == 2 || i == 6 {70+ tags = append(tags, []string{"description", "walking"})71+ }72+ row := signedEvent(t, testOwnerSecret, 54, int64(i), tags, "show notes")73+ if i == 2 || i == 6 {74+ wanted = append([]string{row.ID}, wanted...)75+ }76+ if _, err := tenant.store.Save(ctx, row, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {77+ t.Fatal(err)78+ }79+ }80+ params := map[string]any{"author": tenant.Policy().Owner, "q": "walking", "limit": 1}81+ for i, want := range wanted {82+ value, err := tenant.Execute(ctx, tenant.Policy().Owner, "browsepodcasts", []json.RawMessage{rawJSON(params)})83+ if err != nil {84+ t.Fatal(err)85+ }86+ page := value.(map[string]any)87+ items := page["items"].([]socialItem)88+ if len(items) != 1 || items[0].ID != want {89+ t.Fatalf("page %d: %#v", i, page)90+ }91+ params["cursor"] = page["next_cursor"]92+ if i == 0 && params["cursor"] == "" {93+ t.Fatal("missing continuation after filtered events")94+ }95+ }96+}97+98+func TestPodcastPublicationViewAndRSSFollowNativeEvents(t *testing.T) {99+ app, tenant := testTenant(t)100+ ctx, now := context.Background(), time.Now().Unix()101+ show := signedEvent(t, testOwnerSecret, 10154, now-5, [][]string{{"title", "First show name"}, {"description", "Our show"}}, "")102+ episode := signedEvent(t, testOwnerSecret, 54, now-4, [][]string{{"title", "A native episode"}, {"audio", "https://example.test/episode.mp3", "audio/mpeg"}}, "## Show notes")103+ for _, row := range []event.Event{show, episode} {104+ if _, err := tenant.router.Publish(ctx, row, relay.Session{PubKeys: []string{row.PubKey}}); err != nil {105+ t.Fatal(err)106+ }107+ }108+ var intents int109+ if err := tenant.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM work_intents WHERE kind='view-publish' AND target='podcasts' AND event_id IN (?,?)`, show.ID, episode.ID).Scan(&intents); err != nil || intents != 2 {110+ t.Fatalf("publication view intents=%d: %v", intents, err)111+ }112+ readRSS := func() string {113+ t.Helper()114+ w := httptest.NewRecorder()115+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://relay.test/r/main/social/podcasts.rss?author="+show.PubKey, nil))116+ if w.Code != 200 {117+ t.Fatalf("RSS status %d: %s", w.Code, w.Body.String())118+ }119+ var root struct{ XMLName xml.Name }120+ if err := xml.Unmarshal(w.Body.Bytes(), &root); err != nil || root.XMLName.Local != "rss" {121+ t.Fatalf("RSS: %v %s", err, w.Body.String())122+ }123+ return w.Body.String()124+ }125+ if rss := readRSS(); !strings.Contains(rss, "First show name") || !strings.Contains(rss, episode.ID) {126+ t.Fatalf("published RSS: %s", rss)127+ }128+ replacement := signedEvent(t, testOwnerSecret, 10154, now-3, [][]string{{"title", "Updated show name"}, {"description", "Our show"}}, "")129+ if _, err := tenant.router.Publish(ctx, replacement, relay.Session{PubKeys: []string{show.PubKey}}); err != nil {130+ t.Fatal(err)131+ }132+ if rss := readRSS(); !strings.Contains(rss, "Updated show name") || strings.Contains(rss, "First show name") || !strings.Contains(rss, episode.ID) {133+ t.Fatalf("updated RSS: %s", rss)134+ }135+ deletion := signedEvent(t, testOwnerSecret, 5, now-2, [][]string{{"e", episode.ID}}, "")136+ if _, err := tenant.router.Publish(ctx, deletion, relay.Session{PubKeys: []string{show.PubKey}}); err != nil {137+ t.Fatal(err)138+ }139+ if rss := readRSS(); strings.Contains(rss, "<item>") || !strings.Contains(rss, "Updated show name") {140+ t.Fatalf("deleted episode RSS: %s", rss)141+ }142+ w := httptest.NewRecorder()143+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://relay.test/r/main/view/podcasts", nil))144+ if w.Code != 200 || strings.Contains(w.Body.String(), episode.ID) || !strings.Contains(w.Body.String(), "Updated show name") {145+ t.Fatalf("signed view after deletion: %d %s", w.Code, w.Body.String())146+ }147+}148+149+func TestSocialEpisodeCommentsRequireNativeRoot(t *testing.T) {150+ root := event.Event{ID: "episode", PubKey: "show", Kind: podcasts.KindEpisode}151+ unrelated := event.Event{ID: "other", PubKey: "show", Kind: podcasts.KindEpisode}152+ comment := event.Event{Kind: 1111, Tags: [][]string{{"E", root.ID}, {"K", "54"}}}153+ if !socialCommentFor(comment, root) {154+ t.Fatal("native episode comment not associated")155+ }156+ if socialCommentFor(comment, unrelated) {157+ t.Fatal("native episode comment associated with unrelated episode")158+ }159+ comment.Tags = [][]string{{"E", root.ID}, {"K", "30023"}}160+ if socialCommentFor(comment, root) {161+ t.Fatal("non-native root kind accepted for episode")162+ }163+}
A internal/podcasts/podcasts.go +217 -0
@@ -0,0 +1,217 @@
1+// Package podcasts reads the native show and episode events defined by NIP-F4.2+package podcasts3+4+import (5+ "mime"6+ "net/url"7+ "path"8+ "strconv"9+ "strings"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+)13+14+const (15+ KindEpisode = 5416+ KindMetadata = 1015417+ KindAuthored = 1006418+ KindFavorites = 1005419+)20+21+type Audio struct {22+ URL string `json:"url"`23+ MIME string `json:"mime"`24+ Length int64 `json:"length"`25+}26+27+type Episode struct {28+ ID string `json:"id"`29+ Pubkey string `json:"pubkey"`30+ CreatedAt int64 `json:"created_at"`31+ Title string `json:"title"`32+ Description string `json:"description"`33+ Image string `json:"image,omitempty"`34+ Content string `json:"content"`35+ Audio []Audio `json:"audio"`36+}37+38+type Author struct {39+ Pubkey string `json:"pubkey"`40+ Role string `json:"role,omitempty"`41+}42+43+type Show struct {44+ ID string `json:"id,omitempty"`45+ Pubkey string `json:"pubkey"`46+ Tags [][]string `json:"tags,omitempty"`47+ Title string `json:"title"`48+ Description string `json:"description"`49+ Image string `json:"image,omitempty"`50+ Websites []string `json:"websites,omitempty"`51+ Authors []Author `json:"authors,omitempty"`52+}53+54+// ParseEpisode keeps native podcast audio separate from audio attached to notes.55+// NIP-92 metadata may supplement an audio tag with a size or media type.56+func ParseEpisode(e event.Event) (Episode, bool) {57+ if e.Kind != KindEpisode || event.Tag(e, "h") != "" {58+ return Episode{}, false59+ }60+ result := Episode{ID: e.ID, Pubkey: e.PubKey, CreatedAt: e.CreatedAt,61+ Title: event.Tag(e, "title"), Description: event.Tag(e, "description"),62+ Image: HTTPURL(event.Tag(e, "image")), Content: e.Content}63+ metadata, seen := audioMetadata(e), map[string]bool{}64+ for _, tag := range e.Tags {65+ if len(tag) < 2 || tag[0] != "audio" {66+ continue67+ }68+ raw := HTTPURL(tag[1])69+ if raw == "" || seen[raw] {70+ continue71+ }72+ audio := metadata[raw]73+ audio.URL = raw74+ if len(tag) > 2 && strings.TrimSpace(tag[2]) != "" {75+ audio.MIME = tag[2]76+ }77+ audio.MIME = AudioMIME(raw, audio.MIME)78+ if audio.MIME == "" {79+ continue80+ }81+ seen[raw] = true82+ result.Audio = append(result.Audio, audio)83+ }84+ return result, len(result.Audio) > 085+}86+87+func audioMetadata(e event.Event) map[string]Audio {88+ metadata := map[string]Audio{}89+ for _, tag := range e.Tags {90+ if len(tag) < 2 || tag[0] != "imeta" {91+ continue92+ }93+ audio := Audio{}94+ for _, field := range tag[1:] {95+ name, value, _ := strings.Cut(field, " ")96+ switch name {97+ case "url":98+ audio.URL = HTTPURL(value)99+ case "m":100+ audio.MIME = value101+ case "size":102+ if size, err := strconv.ParseInt(value, 10, 64); err == nil && size >= 0 {103+ audio.Length = size104+ }105+ }106+ }107+ if audio.URL != "" {108+ metadata[audio.URL] = audio109+ }110+ }111+ return metadata112+}113+114+// AudioMIME retains explicit audio types, otherwise inferring familiar formats.115+// An opaque URL with no type remains a generic enclosure for the player to read.116+func AudioMIME(raw, declared string) string {117+ if strings.TrimSpace(declared) != "" {118+ typ, _, err := mime.ParseMediaType(strings.TrimSpace(declared))119+ if err != nil {120+ return ""121+ }122+ if typ == "application/ogg" {123+ return "audio/ogg"124+ }125+ if strings.HasPrefix(typ, "audio/") || typ == "application/octet-stream" {126+ return typ127+ }128+ return ""129+ }130+ parsed, err := url.Parse(raw)131+ if err != nil {132+ return ""133+ }134+ switch strings.ToLower(path.Ext(parsed.Path)) {135+ case ".mp3":136+ return "audio/mpeg"137+ case ".m4a", ".mp4":138+ return "audio/mp4"139+ case ".ogg", ".oga", ".opus":140+ return "audio/ogg"141+ case ".wav":142+ return "audio/wav"143+ case ".flac":144+ return "audio/flac"145+ case ".aac":146+ return "audio/aac"147+ default:148+ return "application/octet-stream"149+ }150+}151+152+func HTTPURL(raw string) string {153+ raw = strings.TrimSpace(raw)154+ parsed, err := url.Parse(raw)155+ if err != nil || parsed.User != nil || parsed.Hostname() == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {156+ return ""157+ }158+ return raw159+}160+161+// Shows folds replaceable show metadata and checks the latest reciprocal author162+// list. NIP-51 assigns authored podcasts kind 10064, also used in F4's example.163+func Shows(events []event.Event) map[string]Show {164+ metadata, authored := latest(events, KindMetadata), latest(events, KindAuthored)165+ out := make(map[string]Show, len(metadata))166+ for key, row := range metadata {167+ show := Show{ID: row.ID, Pubkey: key, Tags: row.Tags, Title: event.Tag(row, "title"),168+ Description: event.Tag(row, "description"), Image: HTTPURL(event.Tag(row, "image"))}169+ seen := map[string]bool{}170+ for _, tag := range row.Tags {171+ if len(tag) < 2 {172+ continue173+ }174+ if tag[0] == "website" {175+ if raw := HTTPURL(tag[1]); raw != "" && !seen[raw] {176+ show.Websites = append(show.Websites, raw)177+ seen[raw] = true178+ }179+ }180+ if tag[0] == "p" && !seen[tag[1]] && listsShow(authored[tag[1]], key) {181+ role := ""182+ if len(tag) > 2 {183+ role = tag[2]184+ }185+ if role == "" || role == "host" || role == "cohost" || role == "editor" {186+ show.Authors = append(show.Authors, Author{Pubkey: tag[1], Role: role})187+ seen[tag[1]] = true188+ }189+ }190+ }191+ out[key] = show192+ }193+ return out194+}195+196+func latest(events []event.Event, kind int) map[string]event.Event {197+ out := map[string]event.Event{}198+ for _, row := range events {199+ if row.Kind != kind || event.Tag(row, "h") != "" {200+ continue201+ }202+ old, exists := out[row.PubKey]203+ if !exists || row.CreatedAt > old.CreatedAt || row.CreatedAt == old.CreatedAt && row.ID < old.ID {204+ out[row.PubKey] = row205+ }206+ }207+ return out208+}209+210+func listsShow(list event.Event, show string) bool {211+ for _, tag := range list.Tags {212+ if len(tag) > 1 && tag[0] == "p" && tag[1] == show {213+ return true214+ }215+ }216+ return false217+}
A internal/podcasts/podcasts_test.go +66 -0
@@ -0,0 +1,66 @@
1+package podcasts2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/tinyrelay/internal/event"8+)9+10+func TestParseEpisodeUsesNativeAudioTracks(t *testing.T) {11+ e := event.Event{ID: "episode", PubKey: strings.Repeat("a", 64), Kind: 54, CreatedAt: 123, Content: "## Show notes", Tags: [][]string{12+ {"title", "Episode one"}, {"description", "A conversation"}, {"image", "https://example.test/cover.png"},13+ {"audio", "javascript:alert(1)"}, {"audio", "https://example.test/one", "audio/mpeg"},14+ {"audio", "https://example.test/one", "audio/mpeg"}, {"audio", "https://example.test/one.ogg"},15+ {"imeta", "url https://example.test/one", "size 1234", "m audio/mpeg"},16+ }}17+ got, ok := ParseEpisode(e)18+ if !ok || got.Title != "Episode one" || got.Description != "A conversation" || got.Content != e.Content || len(got.Audio) != 2 {19+ t.Fatalf("episode: %#v, %v", got, ok)20+ }21+ if got.Audio[0].Length != 1234 || got.Audio[0].MIME != "audio/mpeg" || got.Audio[1].MIME != "audio/ogg" {22+ t.Fatalf("audio: %#v", got.Audio)23+ }24+ for _, kind := range []int{1, 30023} {25+ e.Kind = kind26+ if _, ok := ParseEpisode(e); ok {27+ t.Fatalf("kind %d treated as native podcast", kind)28+ }29+ }30+ e.Kind, e.Tags = 54, [][]string{{"imeta", "url https://example.test/one.mp3", "m audio/mpeg"}}31+ if _, ok := ParseEpisode(e); ok {32+ t.Fatal("kind 54 without an F4 audio track treated as podcast")33+ }34+}35+36+func TestAudioMetadataIsOptional(t *testing.T) {37+ e := event.Event{Kind: 54, Tags: [][]string{{"audio", "https://example.test/audio"}, {"imeta", "url https://example.test/audio", "size -20"}}}38+ got, ok := ParseEpisode(e)39+ if !ok || got.Audio[0].Length != 0 || got.Audio[0].MIME != "application/octet-stream" {40+ t.Fatalf("optional metadata: %#v, %v", got, ok)41+ }42+ e.Tags = append(e.Tags, []string{"audio", "https://example.test/audio.mp3", "video/mp4"})43+ got, _ = ParseEpisode(e)44+ if len(got.Audio) != 1 {45+ t.Fatal("non-audio declared type accepted")46+ }47+}48+49+func TestShowsUseLatestMetadataAndReciprocalAuthorship(t *testing.T) {50+ show, host, fake := strings.Repeat("a", 64), strings.Repeat("b", 64), strings.Repeat("c", 64)51+ rows := []event.Event{52+ {ID: "z", Kind: 10154, PubKey: show, CreatedAt: 20, Tags: [][]string{{"title", "Latest"}, {"image", "https://example.test/cover.jpg"}, {"website", "https://example.test"}, {"p", host, "host"}, {"p", fake, "cohost"}}},53+ {ID: "old", Kind: 10154, PubKey: show, CreatedAt: 10, Tags: [][]string{{"title", "Old"}}},54+ {ID: "host", Kind: 10064, PubKey: host, CreatedAt: 10, Tags: [][]string{{"p", show}}},55+ {ID: "fake-old", Kind: 10064, PubKey: fake, CreatedAt: 10, Tags: [][]string{{"p", show}}},56+ {ID: "fake-new", Kind: 10064, PubKey: fake, CreatedAt: 20},57+ }58+ got := Shows(rows)[show]59+ if got.Title != "Latest" || len(got.Websites) != 1 || len(got.Authors) != 1 || got.Authors[0].Pubkey != host || got.Authors[0].Role != "host" {60+ t.Fatalf("show: %#v", got)61+ }62+ rows = append(rows, event.Event{ID: "a", Kind: 10154, PubKey: show, CreatedAt: 20, Tags: [][]string{{"title", "Tie winner"}}})63+ if got := Shows(rows)[show]; got.Title != "Tie winner" || len(got.Authors) != 0 {64+ t.Fatalf("replacement tie: %#v", got)65+ }66+}
A internal/records/podcasts.go +81 -0
@@ -0,0 +1,81 @@
1+package records2+3+import (4+ "encoding/json"5+ "sort"6+7+ "github.com/FelineStateMachine/tinyrelay/internal/event"8+ "github.com/FelineStateMachine/tinyrelay/internal/podcasts"9+)10+11+type podcastViewShow struct {12+ ID string `json:"id,omitempty"`13+ Pubkey string `json:"pubkey"`14+ Title string `json:"title,omitempty"`15+ Description string `json:"description,omitempty"`16+ Image string `json:"image,omitempty"`17+ Websites []string `json:"websites,omitempty"`18+ Authors []podcasts.Author `json:"authors,omitempty"`19+}20+21+type podcastViewEpisode struct {22+ ID string `json:"id"`23+ Pubkey string `json:"pubkey"`24+ CreatedAt int64 `json:"created_at"`25+ Title string `json:"title,omitempty"`26+ Description string `json:"description,omitempty"`27+ Image string `json:"image,omitempty"`28+ Audio []podcasts.Audio `json:"audio,omitempty"`29+}30+31+// foldPodcasts creates the compact, signed summary used by the built-in32+// podcast view. The full source events remain available to Nostr clients; the33+// view only carries enough information for a relay directory or RSS adapter34+// to find the current shows and episodes.35+func foldPodcasts(events []event.Event) ([][]string, string) {36+ shows := podcasts.Shows(events)37+ episodes := make([]podcasts.Episode, 0)38+ for _, row := range events {39+ if episode, ok := podcasts.ParseEpisode(row); ok {40+ episodes = append(episodes, episode)41+ }42+ }43+ sort.Slice(episodes, func(i, j int) bool {44+ if episodes[i].CreatedAt != episodes[j].CreatedAt {45+ return episodes[i].CreatedAt > episodes[j].CreatedAt46+ }47+ return episodes[i].ID < episodes[j].ID48+ })49+ if len(episodes) > 100 {50+ episodes = episodes[:100]51+ }52+ showKeys := make([]string, 0, len(shows))53+ for key := range shows {54+ showKeys = append(showKeys, key)55+ }56+ sort.Strings(showKeys)57+ if len(showKeys) > 100 {58+ showKeys = showKeys[:100]59+ }60+ tags := make([][]string, 0, len(showKeys)+len(episodes))61+ viewShows := make([]podcastViewShow, 0, len(showKeys))62+ for _, key := range showKeys {63+ show := shows[key]64+ tags = append(tags, []string{"p", show.Pubkey, show.Title, show.Image})65+ viewShows = append(viewShows, podcastViewShow{ID: show.ID, Pubkey: show.Pubkey, Title: show.Title, Description: show.Description, Image: show.Image, Websites: append([]string(nil), show.Websites...), Authors: append([]podcasts.Author(nil), show.Authors...)})66+ }67+ viewEpisodes := make([]podcastViewEpisode, 0, len(episodes))68+ for _, episode := range episodes {69+ audio := ""70+ if len(episode.Audio) > 0 {71+ audio = episode.Audio[0].URL72+ }73+ tags = append(tags, []string{"e", episode.ID, episode.Pubkey, episode.Title, audio})74+ viewEpisodes = append(viewEpisodes, podcastViewEpisode{ID: episode.ID, Pubkey: episode.Pubkey, CreatedAt: episode.CreatedAt, Title: episode.Title, Description: episode.Description, Image: episode.Image, Audio: append([]podcasts.Audio(nil), episode.Audio...)})75+ }76+ content, _ := json.Marshal(struct {77+ Shows []podcastViewShow `json:"shows"`78+ Episodes []podcastViewEpisode `json:"episodes"`79+ }{Shows: viewShows, Episodes: viewEpisodes})80+ return tags, string(content)81+}
M internal/records/records.go +7 -2
@@ -33,6 +33,10 @@ type Config struct {
3333 // device delivery. The gift wrap itself is opaque to the relay once sealed.3434 PushNotification func(context.Context, string, string, string, string) error3535 OnTransfer func(context.Context, string, string) error36+ // EventVisible optionally applies the host relay's live visibility rules37+ // while building the podcast view. Other built-in views retain their38+ // existing projection behavior.39+ EventVisible func(context.Context, event.Event, policy.Access) bool3640 // Community supplies membership and moderation projections through the3741 // CommunityReader boundary.3842 Community CommunityReader
@@ -62,6 +66,7 @@ type Service struct {
6266 pushNotification func(context.Context, string, string, string, string) error6367 onTransfer func(context.Context, string, string) error6468 community CommunityReader69+ eventVisible func(context.Context, event.Event, policy.Access) bool6570 mu sync.Mutex6671 }6772
@@ -106,7 +111,7 @@ func New(ctx context.Context, cfg Config) (*Service, error) {
106111 if groupID == "" {107112 groupID = cfg.RelayURL108113 }109- return &Service{store: cfg.Store, policy: cfg.Policy, setPolicy: cfg.SetPolicy, relayURL: cfg.RelayURL, groupID: groupID, secret: secret, onGenerated: cfg.OnGenerated, deliverNotification: cfg.DeliverNotification, pushNotification: cfg.PushNotification, onTransfer: cfg.OnTransfer, community: cfg.Community}, nil114+ return &Service{store: cfg.Store, policy: cfg.Policy, setPolicy: cfg.SetPolicy, relayURL: cfg.RelayURL, groupID: groupID, secret: secret, onGenerated: cfg.OnGenerated, deliverNotification: cfg.DeliverNotification, pushNotification: cfg.PushNotification, onTransfer: cfg.OnTransfer, community: cfg.Community, eventVisible: cfg.EventVisible}, nil110115 }111116 112117 var roleOrder = []string{"owner", "moderator", "member", "agent"}
@@ -128,7 +133,7 @@ type ViewRun struct {
128133 Rows int `json:"rows"`129134 }130135 131-var viewNames = []string{"profiles", "relays", "calendar", "moderation", "articles", "zaps", "presence"}136+var viewNames = []string{"profiles", "relays", "calendar", "moderation", "articles", "podcasts", "zaps", "presence"}132137 133138 func calendarStart(e event.Event) int64 {134139 s := event.Tag(e, "start")
M internal/records/records_test.go +3 -3
@@ -137,7 +137,7 @@ func TestViewAudienceAndMemberFoldAreNotStored(t *testing.T) {
137137 p := policy.Defaults(owner)138138 p.DirectoryPublic = false139139 p.Reads = "open"140- p.Views = map[string]string{"profiles": "daily", "relays": "off", "calendar": "daily", "moderation": "off", "articles": "off", "zaps": "off", "presence": "off"}140+ p.Views = map[string]string{"profiles": "daily", "relays": "off", "calendar": "daily", "moderation": "off", "articles": "off", "podcasts": "off", "zaps": "off", "presence": "off"}141141 generated := []event.Event{}142142 r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, RelayURL: "wss://relay.example", OnGenerated: func(context.Context, event.Event) error {143143 generated = append(generated, event.Event{})
@@ -195,7 +195,7 @@ func TestHourlyViewFingerprintSkipsUnchangedRun(t *testing.T) {
195195 s, ctx := testStore(t)196196 owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"197197 p := policy.Defaults(owner)198- p.Views = map[string]string{"profiles": "off", "relays": "off", "calendar": "hourly", "moderation": "off", "articles": "off", "zaps": "off", "presence": "off"}198+ p.Views = map[string]string{"profiles": "off", "relays": "off", "calendar": "hourly", "moderation": "off", "articles": "off", "podcasts": "off", "zaps": "off", "presence": "off"}199199 generated := 0200200 r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, OnGenerated: func(context.Context, event.Event) error {201201 generated++
@@ -226,7 +226,7 @@ func TestDefaultArticleWriteTriggerCoalescesDurably(t *testing.T) {
226226 s, ctx := testStore(t)227227 owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"228228 p := policy.Defaults(owner)229- for _, name := range []string{"profiles", "relays", "calendar", "moderation", "zaps", "presence"} {229+ for _, name := range []string{"profiles", "relays", "calendar", "moderation", "podcasts", "zaps", "presence"} {230230 p.Views[name] = "off"231231 }232232 generated := 0
M internal/records/succession.go +1 -1
M internal/records/views.go +22 -5
@@ -26,7 +26,7 @@ func effectiveViewTrigger(p policy.Policy, name string) string {
2626 }2727 2828 func defaultViewTrigger(name string) string {29- return map[string]string{"profiles": "daily", "relays": "daily", "calendar": "hourly", "moderation": "daily", "articles": "write", "zaps": "hourly", "presence": "live"}[name]29+ return map[string]string{"profiles": "daily", "relays": "daily", "calendar": "hourly", "moderation": "daily", "articles": "write", "podcasts": "write", "zaps": "hourly", "presence": "live"}[name]3030 }3131 3232 // viewAudience applies directory and relay read policy to a view.
@@ -124,7 +124,11 @@ func containsView(name string) bool {
124124 }125125 126126 func (s *Service) view(ctx context.Context, name string, caller policy.Access, now int64) (event.Event, error) {127- rows, err := s.store.Query(ctx, event.Filter{}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 0})127+ filter := event.Filter{}128+ if name == "podcasts" {129+ filter.Kinds = []int{54, 10154, 10064}130+ }131+ rows, err := s.store.Query(ctx, filter, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 0})128132 if err != nil {129133 return event.Event{}, err130134 }
@@ -141,12 +145,25 @@ func (s *Service) view(ctx context.Context, name string, caller policy.Access, n
141145 } else if name == "relays" {142146 people, _ := s.viewPeople(ctx)143147 tags, content = fold(name, onlyAuthors(rows.Events, people), s.relayURL, now)148+ } else if name == "podcasts" {149+ visible := rows.Events[:0]150+ access := caller151+ if access.Owner && len(access.PubKeys) == 0 && s.policy().Owner != "" {152+ access.PubKeys = []string{s.policy().Owner}153+ }154+ for _, row := range rows.Events {155+ if policy.CanRead(s.policy(), row, access) && (s.eventVisible == nil || s.eventVisible(ctx, row, access)) {156+ visible = append(visible, row)157+ }158+ }159+ rows.Events = visible160+ tags, content = foldPodcasts(rows.Events)144161 } else {145162 tags, content = fold(name, rows.Events, s.relayURL, now)146163 }147164 trigger := s.policy().Views[name]148165 if trigger == "" {149- trigger = map[string]string{"profiles": "daily", "relays": "daily", "calendar": "hourly", "moderation": "daily", "articles": "write", "zaps": "hourly"}[name]166+ trigger = map[string]string{"profiles": "daily", "relays": "daily", "calendar": "hourly", "moderation": "daily", "articles": "write", "podcasts": "write", "zaps": "hourly"}[name]150167 }151168 tags = append([][]string{{"-"}, {"d", "bind.ws/view/" + name}, {"trigger", trigger}}, tags...)152169 if viewStored(s.policy(), name) {
@@ -527,7 +544,7 @@ func (s *Service) MarkView(ctx context.Context, name string, now int64) error {
527544 func (s *Service) NextDue(ctx context.Context, now int64) (int64, error) {528545 p := s.policy()529546 next := int64(0)530- periods := map[string]int64{"profiles": 86400, "relays": 86400, "calendar": 3600, "moderation": 86400, "articles": 86400, "zaps": 3600}547+ periods := map[string]int64{"profiles": 86400, "relays": 86400, "calendar": 3600, "moderation": 86400, "articles": 86400, "podcasts": 86400, "zaps": 3600}531548 for name, period := range periods {532549 mode := effectiveViewTrigger(p, name)533550 if mode == "off" || !viewStored(p, name) {
@@ -646,7 +663,7 @@ func (s *Service) ViewSummaries(ctx context.Context) ([]map[string]any, error) {
646663 if name == "presence" {647664 choices = []string{"off"}648665 }649- about := map[string]string{"profiles": "every member's newest profile in one record", "relays": "where the members are: the union of their relay lists", "calendar": "what is on: calendar events starting in the next 30 days, with RSVPs counted", "moderation": "this month's moderation counts, no ids", "articles": "the newest hundred articles, by address", "zaps": "zap totals for the top notes and authors here", "presence": "who is connected now and who wrote in the last 15 minutes"}[name]666+ about := map[string]string{"profiles": "every member's newest profile in one record", "relays": "where the members are: the union of their relay lists", "calendar": "what is on: calendar events starting in the next 30 days, with RSVPs counted", "moderation": "this month's moderation counts, no ids", "articles": "the newest hundred articles, by address", "podcasts": "the newest hundred NIP-F4 episodes and their shows", "zaps": "zap totals for the top notes and authors here", "presence": "who is connected now and who wrote in the last 15 minutes"}[name]650667 out = append(out, map[string]any{651668 "name": name, "about": about, "trigger": trigger, "default": defaultViewTrigger(name), "choices": choices,652669 "audience": viewAudience(p, name), "on": trigger != "off", "stored": viewStored(p, name),
M internal/records/views_test.go +64 -0
@@ -1,10 +1,15 @@
11 package records22 33 import (4+ "context"5+ "fmt"46 "strconv"7+ "strings"58 "testing"69 710 "github.com/FelineStateMachine/tinyrelay/internal/event"11+ "github.com/FelineStateMachine/tinyrelay/internal/policy"12+ "github.com/FelineStateMachine/tinyrelay/internal/storage"813 )914 1015 func TestFoldKeepsProfileAndRelayForSameAuthor(t *testing.T) {
@@ -38,3 +43,62 @@ func TestFoldCalendarWindowAndArticlesUsePublishedAt(t *testing.T) {
3843 t.Fatalf("articles = %#v", articles)3944 }4045 }46+47+func TestFoldPodcastsSummarizesNativeEpisodesAndShows(t *testing.T) {48+ show := "podcast"49+ author := "author"50+ events := []event.Event{51+ {ID: "meta", PubKey: show, Kind: 10154, CreatedAt: 2, Tags: [][]string{{"title", "The Show"}, {"p", author, "host"}}},52+ {PubKey: author, Kind: 10064, CreatedAt: 3, Tags: [][]string{{"p", show}}},53+ {ID: "episode", PubKey: author, Kind: 54, CreatedAt: 4, Content: "An episode", Tags: [][]string{{"title", "Episode one"}, {"audio", "https://cdn.example/one.mp3", "audio/mpeg", "42"}}},54+ }55+ tags, content := foldPodcasts(events)56+ if len(tags) != 2 || tags[0][0] != "p" || tags[0][1] != show || tags[1][0] != "e" || tags[1][1] != "episode" {57+ t.Fatalf("podcast tags = %#v", tags)58+ }59+ if !strings.Contains(content, `"The Show"`) || !strings.Contains(content, `"Episode one"`) || strings.Contains(content, "An episode") {60+ t.Fatalf("podcast content = %s", content)61+ }62+}63+64+func TestFoldPodcastsBoundsShowsAndEpisodes(t *testing.T) {65+ events := make([]event.Event, 0, 205)66+ for i := 0; i < 105; i++ {67+ key := fmt.Sprintf("show-%03d", i)68+ events = append(events, event.Event{ID: key, PubKey: key, Kind: 10154, CreatedAt: int64(i), Tags: [][]string{{"title", key}}})69+ }70+ for i := 0; i < 105; i++ {71+ id := fmt.Sprintf("episode-%03d", i)72+ events = append(events, event.Event{ID: id, PubKey: "author", Kind: 54, CreatedAt: int64(i), Tags: [][]string{{"title", id}, {"audio", "https://cdn.example/episode.mp3", "audio/mpeg"}}})73+ }74+ tags, content := foldPodcasts(events)75+ if len(tags) != 200 || strings.Contains(content, "episode-000") || strings.Contains(content, "show-104") {76+ t.Fatalf("bounded podcast view has %d tags and content %d bytes", len(tags), len(content))77+ }78+}79+80+func TestPodcastViewAppliesEventVisibilityCallback(t *testing.T) {81+ s, ctx := testStore(t)82+ owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"83+ p := policy.Defaults(owner)84+ visible := map[string]bool{"keep": true}85+ r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, RelayURL: "wss://relay.example", EventVisible: func(_ context.Context, e event.Event, _ policy.Access) bool { return visible[e.ID] }})86+ if err != nil {87+ t.Fatal(err)88+ }89+ for _, e := range []event.Event{90+ {ID: "keep", PubKey: owner, Kind: 54, CreatedAt: 2, Tags: [][]string{{"title", "Kept"}, {"audio", "https://cdn.example/keep.mp3", "audio/mpeg"}}},91+ {ID: "drop", PubKey: owner, Kind: 54, CreatedAt: 3, Tags: [][]string{{"title", "Blocked"}, {"audio", "https://cdn.example/drop.mp3", "audio/mpeg"}}},92+ } {93+ if _, err := s.Save(ctx, e, storage.SaveOptions{Now: e.CreatedAt}); err != nil {94+ t.Fatal(err)95+ }96+ }97+ view, err := r.View(ctx, "podcasts", policy.Access{Owner: true}, 10)98+ if err != nil {99+ t.Fatal(err)100+ }101+ if len(view.Event.Tags) != 4 || view.Event.Tags[3][1] != "keep" {102+ t.Fatalf("filtered podcast view = %#v", view.Event.Tags)103+ }104+}
M internal/templates/templates.go +1 -1
@@ -51,7 +51,7 @@ var templateCatalog = []Template{
5151 {Name: "media", Title: "Media", About: "A media host. Members upload files; the only events accepted are profiles and Blossom server lists. Anyone reads.", Policy: map[string]any{"writes": "allowlist", "reads": "open", "directoryPublic": true}, AllowKinds: []int{0, 10063}, Connections: []string{"files", "photos"}},5252 {Name: "search", Title: "Search replica", About: "A read-only copy of another relay's notes, threads, comments, highlights, articles and wiki pages, refreshed every six hours. Anyone reads.", Source: "required", EveryHours: 6, Policy: map[string]any{"writes": "owner", "reads": "open", "directoryPublic": true}, AllowKinds: []int{0, 1, 11, 1111, 9802, 30023, 30818}, Connections: []string{"notes"}},5353 {Name: "articles", Title: "Articles", About: "Long-form articles and the profiles behind them, nothing else. Only you write, anyone reads. Give a source relay to mirror its articles daily.", Source: "optional", EveryHours: 24, Policy: map[string]any{"writes": "owner", "reads": "open", "directoryPublic": true}, AllowKinds: []int{0, 30023}, Connections: []string{"notes"}},54- {Name: "social", Title: "Social", About: "A chronological social feed for members' notes, comments, reactions and long-form articles. Members write; anyone reads.", Policy: map[string]any{"writes": "allowlist", "reads": "open", "directoryPublic": true}, AllowKinds: []int{0, 1, 5, 6, 7, 1111, 30023}, Connections: []string{"notes", "blog"}},54+ {Name: "social", Title: "Social", About: "A chronological social feed for members' notes, comments, reactions, long-form articles and podcasts. Members write; anyone reads.", Policy: map[string]any{"writes": "allowlist", "reads": "open", "directoryPublic": true}, AllowKinds: []int{0, 1, 5, 6, 7, 54, 1111, 30023, 10054, 10064, 10154}, Connections: []string{"notes", "blog"}},5555 {Name: "dm", Title: "DM inbox", About: "An inbox for private messages. Anyone drops gift wraps, only members read them. Nothing else is accepted.", Policy: map[string]any{"writes": "open", "reads": "members", "directoryPublic": false}, AllowKinds: []int{0, 1059}, Connections: []string{"dm"}},5656 {Name: "quiet", Title: "Quiet", About: "A small private relay with nothing that costs or announces. Members write and read; optional features are off.", Policy: map[string]any{"writes": "allowlist", "reads": "members", "directoryPublic": false, "features": map[string]any{"search": "off", "sync": false, "count": false, "discovery": false, "names": false, "files": false, "pages": false, "signer": false, "sites": false, "marmot": false, "grasp": false}}, Connections: []string{}},5757 {Name: "site", Title: "Sites", About: "A static website host. Members publish manifests and files; the relay mirrors missing files into its own store.", Policy: map[string]any{"writes": "allowlist", "reads": "open", "directoryPublic": true, "features": map[string]any{"files": true, "sites": map[string]any{"enabled": true, "mirror": true}}}, Connections: []string{"notes"}},
M internal/templates/templates_test.go +1 -1
M scripts/social-compose.test.mjs +35 -0
@@ -116,3 +116,38 @@ test("legacy replies omit an unknown optional root author rather than misattribu
116116 await s.component.submit(s.form);117117 assert.deepEqual(s.tags(),[["e",root,hint,"root"],["e",parent,hint,"reply",parentAuthor],["p",parentAuthor,hint]]);118118 });119+120+test("podcast episodes publish native kind 54 audio and imeta tags", async()=>{121+ const file={name:"episode.mp3",type:"audio/mpeg",size:3,arrayBuffer:async()=>new Uint8Array([1,2,3]).buffer};122+ const s=setup({mode:"podcast"},{title:"Episode",summary:"Show notes",cover:"https://cdn.test/cover.jpg",content:"Notes", "media-file":{files:[file]}});123+ await s.component.submit(s.form);124+ assert.equal(s.signed[0].kind,54);assert.equal(s.signed[0].content,"Notes");125+ assert.ok(s.tags().some(t=>t[0]==="audio"&&t[1]==="https://relay.test/file.jpg"&&t[2]==="audio/mpeg"));126+ assert.ok(s.tags().some(t=>t[0]==="imeta"&&t.includes("m audio/mpeg")));127+});128+129+test("podcast retry reuses the same signed event and keeps audio URLs out of notes", async()=>{130+ const s=setup({mode:"podcast"},{title:"Episode",content:"Notes",media:"https://cdn.test/episode.mp3"},{fail:1});131+ await assert.rejects(s.component.submit(s.form),/interrupted/);await s.component.submit(s.form);132+ assert.equal(s.signed.length,1);assert.equal(s.calls.length,2);assert.equal(s.calls[0].body,s.calls[1].body);assert.equal(s.signed[0].content,"Notes");133+});134+135+test("new podcast shows tolerate null metadata and preserve existing tags and websites", async()=>{136+ const tags=[["p",parentAuthor],["website","https://old.example/one"],["website","https://old.example/two"],["custom","keep"]];137+ const s=setup({mode:"podcast-show","show-tags":JSON.stringify(tags)},{title:"Series",website:"https://new.example"});138+ await s.component.submit(s.form);assert.equal(s.signed[0].kind,10154);139+ assert.deepEqual(s.tags(),[["p",parentAuthor],["custom","keep"],["title","Series"],["website","https://new.example/"],["website","https://old.example/two"]]);140+ const fresh=setup({mode:"podcast-show","show-tags":"null"},{title:"Fresh"});await fresh.component.submit(fresh.form);assert.equal(fresh.signed[0].kind,10154);assert.ok(!fresh.tags().some(t=>t[0]==="d"));141+});142+143+test("podcast requires audio and rejects non-audio attachments", async()=>{144+ const missing=setup({mode:"podcast"},{title:"Episode",content:"Notes"});await assert.rejects(missing.component.submit(missing.form),/audio track/);assert.equal(missing.signed.length,0);145+ const image=setup({mode:"podcast"},{title:"Episode",content:"Notes",media:"https://cdn.test/image.jpg","media-type":"image/jpeg"});await assert.rejects(image.component.submit(image.form),/audio track/);assert.equal(image.signed.length,0);146+});147+148+test("direct and nested podcast replies use NIP-22 root and parent tags", async()=>{149+ const direct=setup({mode:"comment",target:root,"target-kind":"54","target-pubkey":author},{content:"Reply"});await direct.component.submit(direct.form);150+ assert.equal(direct.signed[0].kind,1111);assert.deepEqual(direct.tags(),[["E",root,hint,author],["K","54"],["P",author,hint],["e",root,hint,author],["k","54"],["p",author,hint]]);151+ const nested=setup({mode:"comment",target:parent,"target-kind":"1111","target-pubkey":parentAuthor,root,"root-kind":"54","root-pubkey":author},{content:"Nested"});await nested.component.submit(nested.form);152+ assert.deepEqual(nested.tags(),[["E",root,hint,author],["K","54"],["P",author,hint],["e",parent,hint,parentAuthor],["k","1111"],["p",parentAuthor,hint]]);153+});
M tinyclient/components.js +51 -9
@@ -1057,7 +1057,7 @@
10571057 const pushCategories = [["messages", "private messages"], ["replies", "replies to you"], ["mentions", "mentions"], ["approvals", "requests for a decision"], ["relay", "relay notices"]];10581058 // Social follows NIP-10 notes, NIP-23 articles and NIP-22 comments.10591059 const socialValue = (form, name) => String(form?.elements?.[name]?.value || "").trim();1060- const socialDraftFields = ["title", "summary", "cover", "content", "tags", "media", "alt"];1060+ const socialDraftFields = ["title", "summary", "cover", "website", "content", "tags", "media", "media-type", "alt"];10611061 const socialDraftKey = node => {10621062 const actor = node.getAttribute("actor");10631063 if (!isHex64(actor)) return "";
@@ -1069,6 +1069,10 @@
10691069 if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw Error("Media URLs must use HTTP or HTTPS without credentials.");10701070 return url.href;10711071 };1072+ const socialAudioType = value => {1073+ const path = (() => { try { return new URL(value).pathname.toLowerCase(); } catch { return ""; } })();1074+ return path.endsWith(".mp3") ? "audio/mpeg" : path.endsWith(".m4a") || path.endsWith(".mp4") ? "audio/mp4" : path.endsWith(".aac") ? "audio/aac" : path.endsWith(".ogg") || path.endsWith(".oga") ? "audio/ogg" : path.endsWith(".opus") ? "audio/opus" : path.endsWith(".wav") ? "audio/wav" : path.endsWith(".flac") ? "audio/flac" : "";1075+ };10721076 const socialRelay = node => {10731077 const value = node.getAttribute("relay") || "";10741078 return /^wss?:\/\//.test(value) ? value : "";
@@ -1116,10 +1120,12 @@
11161120 const mode = this.getAttribute("mode") || "note";11171121 if (mode === "article") return 30023;11181122 if (mode === "note") return 1;1123+ if (mode === "podcast") return 54;1124+ if (mode === "podcast-show") return 10154;11191125 if (mode !== "comment") throw Error("Choose a valid post format.");11201126 const parent = this.getAttribute("target-kind") || "1";11211127 if (parent === "1") return 1;1122- if (parent === "30023" || parent === "1111") return 1111;1128+ if (["54", "30023", "1111"].includes(parent)) return 1111;11231129 throw Error("This post does not support replies here.");11241130 }11251131 replyTags(kind) {
@@ -1137,6 +1143,13 @@
11371143 }11381144 const address = this.getAttribute("address") || "";11391145 const parentKind = this.getAttribute("target-kind") || "30023";1146+ const rootKind = this.getAttribute("root-kind") || parentKind;1147+ if (rootKind === "54") {1148+ if (!isHex64(rootAuthor)) throw Error("The podcast address is incomplete. Reload the conversation.");1149+ const tags = [["E", root, hint, rootAuthor], ["K", "54"], ["P", rootAuthor, hint]];1150+ tags.push(["e", target, hint, author], ["k", parentKind], ["p", author, hint]);1151+ return tags;1152+ }11401153 if (!isHex64(rootAuthor) || !address.startsWith("30023:" + rootAuthor + ":") || !["30023", "1111"].includes(parentKind)) throw Error("The article address is incomplete. Reload the conversation.");11411154 const tags = [["A", address, hint], ["K", "30023"], ["P", rootAuthor, hint]];11421155 if (parentKind === "30023") tags.push(["a", address, hint]);
@@ -1145,8 +1158,10 @@
11451158 }11461159 tags(form, kind) {11471160 const tags = this.getAttribute("mode") === "comment" ? this.replyTags(kind) : [];1161+ if (kind === 30023 || kind === 54 || kind === 10154) {1162+ if (!socialValue(form, "title")) throw Error(kind === 54 ? "Give your podcast episode a title." : kind === 10154 ? "Give your podcast show a title." : "Give your article a title.");1163+ }11481164 if (kind === 30023) {1149- if (!socialValue(form, "title")) throw Error("Give your article a title.");11501165 this.articleID ||= crypto.randomUUID();11511166 this.publishedAt ||= unixNow();11521167 tags.push(["d", this.articleID], ["title", socialValue(form, "title")], ["published_at", String(this.publishedAt)]);
@@ -1154,6 +1169,26 @@
11541169 const cover = socialHTTPURL(socialValue(form, "cover"));11551170 if (cover) tags.push(["image", cover]);11561171 }1172+ if (kind === 54) {1173+ tags.push(["title", socialValue(form, "title")]);1174+ if (socialValue(form, "summary")) tags.push(["description", socialValue(form, "summary")]);1175+ const cover = socialHTTPURL(socialValue(form, "cover"));1176+ if (cover) tags.push(["image", cover]);1177+ }1178+ if (kind === 10154) {1179+ let original = [];1180+ try { const parsed = JSON.parse(this.getAttribute("show-tags") || "[]"); if (Array.isArray(parsed)) original = parsed; } catch {}1181+ const preserved = original.filter(tag => Array.isArray(tag) && !["title", "description", "image", "summary", "website"].includes(tag[0]));1182+ const existingWebsites = original.filter(tag => Array.isArray(tag) && tag[0] === "website");1183+ tags.push(...preserved);1184+ tags.push(["title", socialValue(form, "title")]);1185+ if (socialValue(form, "summary")) tags.push(["description", socialValue(form, "summary")]);1186+ const cover = socialHTTPURL(socialValue(form, "cover"));1187+ if (cover) tags.push(["image", cover]);1188+ const website = socialHTTPURL(socialValue(form, "website"));1189+ if (website) tags.push(["website", website], ...existingWebsites.slice(1));1190+ else tags.push(...existingWebsites);1191+ }11571192 const topics = new Set(socialValue(form, "tags").split(",").map(value => value.trim().replace(/^#/, "").toLowerCase()).filter(Boolean));11581193 for (const topic of topics) tags.push(["t", topic]);11591194 return tags;
@@ -1161,7 +1196,8 @@
11611196 async attachments(form) {11621197 const files = [...(form.elements["media-file"]?.files || [])];11631198 if (files.length > 10 || files.some(file => file.size > 256 * 1024 * 1024)) throw Error("Choose up to 10 attachments, each at most 256 MiB.");1164- if (files.some(file => !/^(image|video|audio)\//.test(file.type))) throw Error("Choose an image, video or audio file.");1199+ const podcast = this.kind() === 54;1200+ if (files.some(file => podcast ? !/^audio\//.test(file.type) : !/^(image|video|audio)\//.test(file.type))) throw Error(podcast ? "Choose an audio file." : "Choose an image, video or audio file.");11651201 this.mediaUploads ||= new WeakMap();11661202 const items = [];11671203 for (const file of files) {
@@ -1180,7 +1216,7 @@
11801216 items.push({...item, alt: socialValue(form, "alt") || file.name});11811217 }11821218 const media = socialHTTPURL(socialValue(form, "media"));1183- if (media && !items.some(item => item.url === media)) items.push({url: media, alt: socialValue(form, "alt")});1219+ if (media && !items.some(item => item.url === media)) items.push({url: media, type: socialValue(form, "media-type") || socialAudioType(media), alt: socialValue(form, "alt")});11841220 return items;11851221 }11861222 async preview() {
@@ -1198,13 +1234,19 @@
11981234 this.sending = true;11991235 try {12001236 const kind = this.kind(), content = form.elements.content?.value || "";1201- if (!content.trim() && !(form.elements["media-file"]?.files?.length) && !socialValue(form, "media")) throw Error("Write something or attach media first.");1237+ const podcast = kind === 54, show = kind === 10154;1238+ if (!show && !content.trim() && !(form.elements["media-file"]?.files?.length) && !socialValue(form, "media")) throw Error("Write something or attach media first.");12021239 const tags = this.tags(form, kind);12031240 this.saveDraft();12041241 const attachments = await this.attachments(form);1205- const urls = attachments.map(item => item.url).filter(url => !content.includes(url));1206- const body = [content, ...urls].filter(Boolean).join("\n\n");1207- for (const item of attachments) tags.push(["imeta", "url " + item.url, ...(item.type ? ["m " + item.type] : []), ...(item.hash ? ["x " + item.hash, "size " + item.size] : []), ...(item.alt ? ["alt " + item.alt] : [])]);1242+ if (podcast && (!attachments.length || attachments.some(item => item.type && !/^audio\//.test(item.type)))) throw Error("Add at least one audio track.");1243+ const urls = podcast || show ? [] : attachments.map(item => item.url).filter(url => !content.includes(url));1244+ const body = show ? "" : [content, ...urls].filter(Boolean).join("\n\n");1245+ for (const item of attachments) {1246+ const imeta = ["imeta", "url " + item.url, ...(item.type ? ["m " + item.type] : []), ...(item.hash ? ["x " + item.hash, "size " + item.size] : []), ...(item.alt ? ["alt " + item.alt] : [])];1247+ if (podcast) tags.push(["audio", item.url, ...(item.type ? [item.type] : [])]);1248+ tags.push(...(podcast || !show ? [imeta] : []));1249+ }12081250 const fingerprint = JSON.stringify({kind, tags, content: body});12091251 if (this.prepared?.fingerprint !== fingerprint) this.prepared = {fingerprint, unsigned: {kind, created_at: unixNow(), tags, content: body}};12101252 this.report("Signing and publishing…");
M tinyclient/podcast_rss.go +66 -27
@@ -8,12 +8,16 @@ import (
88 "strconv"99 "strings"1010 "time"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/podcasts"1113 )1214 1315 type podcastRSS struct {1416 XMLName xml.Name `xml:"rss"`1517 Version string `xml:"version,attr"`1618 Atom string `xml:"xmlns:atom,attr"`19+ ITunes string `xml:"xmlns:itunes,attr"`20+ Content string `xml:"xmlns:content,attr"`1721 Channel podcastChannel `xml:"channel"`1822 }1923
@@ -23,6 +27,8 @@ type podcastChannel struct {
2327 Description string `xml:"description"`2428 Self podcastAtomLink `xml:"atom:link"`2529 Items []podcastItem `xml:"item"`30+ Author string `xml:"itunes:author,omitempty"`31+ Image *podcastImage `xml:"itunes:image,omitempty"`2632 }2733 2834 type podcastAtomLink struct {
@@ -38,6 +44,15 @@ type podcastItem struct {
3844 GUID podcastGUID `xml:"guid"`3945 PubDate string `xml:"pubDate,omitempty"`4046 Enclosure podcastEnclosure `xml:"enclosure"`47+ Author string `xml:"itunes:author,omitempty"`48+ Subtitle string `xml:"itunes:subtitle,omitempty"`49+ Summary string `xml:"itunes:summary,omitempty"`50+ Content string `xml:"content:encoded,omitempty"`51+ Image *podcastImage `xml:"itunes:image,omitempty"`52+}53+54+type podcastImage struct {55+ Href string `xml:"href,attr"`4156 }4257 4358 type podcastGUID struct {
@@ -51,7 +66,7 @@ type podcastEnclosure struct {
5166 Type string `xml:"type,attr"`5267 }5368 54-// podcastRSS exposes Social audio posts to conventional podcast readers.69+// podcastRSS renders the native podcast projection for conventional players.5570 func (a *App) podcastRSS(w http.ResponseWriter, r *http.Request) {5671 w.Header().Set("Cache-Control", "private, no-store")5772 actor, err := a.resolveActor(r)
@@ -71,19 +86,21 @@ func (a *App) podcastRSS(w http.ResponseWriter, r *http.Request) {
7186 http.Error(w, "invalid social query", http.StatusBadRequest)7287 return7388 }74- result, err := a.backend.Query(r.Context(), "browsesocial", []json.RawMessage{raw}, actor)89+ result, err := a.backend.Query(r.Context(), "browsepodcasts", []json.RawMessage{raw}, actor)7590 if err != nil {7691 http.Error(w, err.Error(), socialErrorStatus(err))7792 return7893 }7994 base := strings.TrimSuffix(a.backend.URL(), "/")8095 channel := podcastFeedChannel(base, a.backend.Slug(), query)96+ podcastChannelShow(&channel, valueMap(result)["show"])97+ channel.Author = channel.Title8198 for _, row := range browseRows(result) {8299 if item, ok := podcastFeedItem(base, valueMap(row)); ok {83100 channel.Items = append(channel.Items, item)84101 }85102 }86- doc, err := xml.Marshal(podcastRSS{Version: "2.0", Atom: "http://www.w3.org/2005/Atom", Channel: channel})103+ doc, err := xml.Marshal(podcastRSS{Version: "2.0", Atom: "http://www.w3.org/2005/Atom", ITunes: "http://www.itunes.com/dtds/podcast-1.0.dtd", Content: "http://purl.org/rss/1.0/modules/content/", Channel: channel})87104 if err != nil {88105 http.Error(w, "could not encode feed", http.StatusInternalServerError)89106 return
@@ -95,6 +112,25 @@ func (a *App) podcastRSS(w http.ResponseWriter, r *http.Request) {
95112 }96113 }97114 115+func podcastChannelShow(channel *podcastChannel, value any) {116+ show := valueMap(value)117+ if title := plainString(show["title"]); title != "" {118+ channel.Title = title119+ }120+ if description := plainString(show["description"]); description != "" {121+ channel.Description = description122+ }123+ if image := socialMediaURL(plainString(show["image"])); image != "" {124+ channel.Image = &podcastImage{Href: image}125+ }126+ for _, raw := range stringValues(show["websites"]) {127+ if website := socialMediaURL(raw); website != "" {128+ channel.Link = website129+ break130+ }131+ }132+}133+98134 func podcastFeedQuery(query url.Values) (url.Values, error) {99135 values := url.Values{}100136 if raw := strings.TrimSpace(query.Get("author")); raw != "" {
@@ -114,7 +150,7 @@ func podcastFeedChannel(base, slug string, query url.Values) podcastChannel {
114150 channel := podcastChannel{115151 Title: slug + " Podcasts",116152 Link: base + "/social?kind=podcasts",117- Description: "Audio posts shared on " + slug + ".",153+ Description: "Podcast episodes shared on " + slug + ".",118154 Self: podcastAtomLink{Href: base + podcastFeedURL(query), Rel: "self", Type: "application/rss+xml"},119155 }120156 if author := query.Get("author"); author != "" {
@@ -125,30 +161,33 @@ func podcastFeedChannel(base, slug string, query url.Values) podcastChannel {
125161 }126162 127163 func podcastFeedItem(base string, row map[string]any) (podcastItem, bool) {128- for _, audio := range socialAttachments(row) {129- if !strings.HasPrefix(strings.ToLower(audio.MIME), "audio/") || socialMediaURL(audio.URL) == "" {130- continue131- }132- itemURL := base + socialURL(row)133- item := podcastItem{134- Title: podcastEpisodeTitle(row, audio.Name),135- Link: itemURL,136- Description: plainString(row["content"]),137- GUID: podcastGUID{Value: plainString(row["id"]), IsPermaLink: "false"},138- Enclosure: podcastEnclosure{URL: audio.URL, Type: audio.MIME, Length: audio.Size},139- }140- if item.GUID.Value == "" {141- item.GUID.Value = itemURL142- }143- if address := plainString(row["address"]); address != "" {144- item.GUID.Value = address145- }146- if seconds := unixSeconds(row["created_at"]); seconds > 0 {147- item.PubDate = time.Unix(seconds, 0).UTC().Format(time.RFC1123Z)148- }149- return item, true164+ episode, ok := podcasts.ParseEpisode(socialPodcastEvent(row))165+ if !ok {166+ return podcastItem{}, false167+ }168+ audio := episode.Audio[0]169+ itemURL := base + socialURL(row)170+ description := episode.Description171+ if description == "" {172+ description = episode.Content173+ }174+ item := podcastItem{Title: episode.Title, Link: itemURL, Description: description, GUID: podcastGUID{Value: episode.ID, IsPermaLink: "false"}, Enclosure: podcastEnclosure{URL: audio.URL, Type: audio.MIME, Length: audio.Length}, Author: plainString(valueMap(row["podcast"])["title"]), Subtitle: description, Summary: description}175+ if episode.Content != "" {176+ item.Content = string(renderMarkdownWith(episode.Content, nil))177+ }178+ if episode.Image != "" {179+ item.Image = &podcastImage{Href: episode.Image}180+ }181+ if item.Title == "" {182+ item.Title = podcastEpisodeTitle(row, "")183+ }184+ if item.GUID.Value == "" {185+ item.GUID.Value = itemURL186+ }187+ if seconds := episode.CreatedAt; seconds > 0 {188+ item.PubDate = time.Unix(seconds, 0).UTC().Format(time.RFC1123Z)150189 }151- return podcastItem{}, false190+ return item, true152191 }153192 154193 func podcastEpisodeTitle(row map[string]any, filename string) string {
A tinyclient/podcast_rss_native_test.go +67 -0
@@ -0,0 +1,67 @@
1+package tinyclient2+3+import (4+ "encoding/xml"5+ "net/http"6+ "net/http/httptest"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/event"11+ "github.com/FelineStateMachine/tinyrelay/internal/podcasts"12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+)14+15+func TestPodcastRSSUsesShowMetadataAndNativeShowNotes(t *testing.T) {16+ key := strings.Repeat("a", 64)17+ episode := event.Event{ID: strings.Repeat("b", 64), PubKey: key, Kind: 54, CreatedAt: 1700000000, Content: "## Links\n\nA **good** conversation.", Tags: [][]string{{"title", "Episode & one"}, {"description", "An <introduction>"}, {"audio", "https://example.test/one.mp3", "audio/mpeg"}, {"audio", "https://example.test/one.ogg", "audio/ogg"}}}18+ show := podcasts.Show{ID: "metadata", Pubkey: key, Title: "Trail & Radio", Description: "A show about walking", Image: "https://example.test/cover.png", Websites: []string{"https://example.test/podcast"}}19+ b := &podcastRSSBackend{fakeBackend: fakeBackend{policy: policy.Defaults(key)}, result: map[string]any{"show": show, "items": []any{map[string]any{"id": episode.ID, "kind": 54, "pubkey": key, "event": episode, "podcast": show}}}}20+ a := &App{backend: b}21+ w := httptest.NewRecorder()22+ a.podcastRSS(w, httptest.NewRequest(http.MethodGet, "/social/podcasts.rss?author="+key, nil))23+ var feed struct {24+ Channel struct {25+ Title, Description, Link string26+ Items []struct {27+ GUID string `xml:"guid"`28+ Enclosures []struct {29+ URL string `xml:"url,attr"`30+ } `xml:"enclosure"`31+ Content string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`32+ } `xml:"item"`33+ } `xml:"channel"`34+ }35+ // Lowercase RSS element names are required.36+ var document podcastRSS37+ if err := xml.Unmarshal(w.Body.Bytes(), &document); err != nil {38+ t.Fatal(err)39+ }40+ if document.Channel.Title != show.Title || document.Channel.Description != show.Description || !strings.Contains(w.Body.String(), "<link>"+show.Websites[0]+"</link>") {41+ t.Fatalf("show metadata: %#v", document.Channel)42+ }43+ if err := xml.Unmarshal(w.Body.Bytes(), &feed); err != nil {44+ t.Fatal(err)45+ }46+ if len(feed.Channel.Items) != 1 || len(feed.Channel.Items[0].Enclosures) != 1 || feed.Channel.Items[0].Enclosures[0].URL != "https://example.test/one.mp3" || feed.Channel.Items[0].GUID != episode.ID {47+ t.Fatalf("RSS episode: %s", w.Body.String())48+ }49+ if !strings.Contains(feed.Channel.Items[0].Content, "<strong>good</strong>") || !strings.Contains(w.Body.String(), `itunes:image href="https://example.test/cover.png"`) {50+ t.Fatalf("artwork/show notes: %s", w.Body.String())51+ }52+}53+54+func TestPodcastRSSRejectsAudioNotesAndOmitsEmptyArtwork(t *testing.T) {55+ for _, kind := range []int{1, 30023} {56+ _, ok := podcastFeedItem("https://relay.test", map[string]any{"id": "ordinary", "kind": kind, "tags": [][]string{{"audio", "https://example.test/one.mp3"}, {"imeta", "url https://example.test/one.mp3", "m audio/mpeg"}}})57+ if ok {58+ t.Fatalf("kind %d became a podcast", kind)59+ }60+ }61+ b := &podcastRSSBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}, result: map[string]any{"show": podcasts.Show{Pubkey: strings.Repeat("a", 64)}}}62+ w := httptest.NewRecorder()63+ (&App{backend: b}).podcastRSS(w, httptest.NewRequest(http.MethodGet, "/social/podcasts.rss", nil))64+ if strings.Contains(w.Body.String(), "itunes:image") || strings.Contains(w.Body.String(), "<description></description>") {65+ t.Fatalf("empty optional metadata: %s", w.Body.String())66+ }67+}
M tinyclient/podcast_rss_test.go +8 -9
@@ -43,8 +43,8 @@ func TestPodcastRSSPageBuildsAudioEnclosures(t *testing.T) {
4343 b := &podcastRSSBackend{4444 fakeBackend: fakeBackend{policy: policy.Defaults(author)},4545 result: map[string]any{"items": []any{46- map[string]any{"id": strings.Repeat("b", 64), "kind": 1, "title": `A & <episode>`, "content": "A description https://cdn.example/audio.mp3?a=1&b=2", "created_at": 1700000000, "tags": [][]string{{"imeta", "url https://cdn.example/audio.mp3?a=1&b=2", "m audio/mpeg", "size 42"}, {"imeta", "url https://cdn.example/photo.jpg", "m image/jpeg"}}},47- map[string]any{"id": strings.Repeat("c", 64), "kind": 30023, "content": "no audio", "tags": [][]string{{"imeta", "url https://cdn.example/photo.jpg", "m image/jpeg"}}},46+ map[string]any{"id": strings.Repeat("b", 64), "pubkey": strings.Repeat("a", 64), "kind": 54, "title": `A & <episode>`, "content": "A description", "created_at": 1700000000, "tags": [][]string{{"audio", "https://cdn.example/audio.mp3?a=1&b=2"}, {"imeta", "url https://cdn.example/audio.mp3?a=1&b=2", "m audio/mpeg", "size 42"}, {"imeta", "url https://cdn.example/photo.jpg", "m image/jpeg"}}},47+ map[string]any{"id": strings.Repeat("c", 64), "kind": 1, "content": "no audio", "tags": [][]string{{"imeta", "url https://cdn.example/photo.jpg", "m image/jpeg"}}},4848 }},4949 }5050 a := &App{backend: b}
@@ -74,8 +74,8 @@ func TestPodcastRSSPageUsesUnknownLengthAndPropagatesErrors(t *testing.T) {
7474 fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))},7575 result: map[string]any{7676 "items": []any{map[string]any{77- "id": "episode", "content": "no body reference",78- "tags": [][]string{{"imeta", "url https://cdn.example/episode.ogg", "m audio/ogg"}},77+ "id": "episode", "pubkey": strings.Repeat("a", 64), "content": "no body reference", "kind": 54,78+ "tags": [][]string{{"audio", "https://cdn.example/episode.ogg", "audio/ogg"}},7979 }},8080 },8181 }
@@ -123,16 +123,15 @@ func TestPodcastRSSRouteNormalizesSubscriptionAndSupportsHead(t *testing.T) {
123123 }124124 }125125 126-func TestPodcastArticleGUIDSurvivesAnEdit(t *testing.T) {127- address := "30023:" + strings.Repeat("a", 64) + ":episode"128- row := map[string]any{"id": "first", "address": address, "kind": 30023, "content": "Episode one", "tags": [][]string{{"imeta", "url https://cdn.example/episode", "m audio/mpeg", "size 100"}}}126+func TestPodcastNativeEpisodeUsesEventGUID(t *testing.T) {127+ row := map[string]any{"id": "episode-id", "pubkey": strings.Repeat("a", 64), "kind": 54, "content": "Episode one", "tags": [][]string{{"audio", "https://cdn.example/episode", "audio/mpeg", "100"}}}129128 first, ok := podcastFeedItem("https://relay.example", row)130129 if !ok {131130 t.Fatal("episode omitted")132131 }133- row["id"] = "second"132+ row["id"] = "changed-row-id"134133 second, ok := podcastFeedItem("https://relay.example", row)135- if !ok || first.GUID != second.GUID || second.GUID.Value != address || second.Title != "Episode one" {134+ if !ok || first.GUID.Value != "episode-id" || second.GUID.Value != "changed-row-id" || second.Title != "Episode one" {136135 t.Fatalf("edited episode identity: %#v %#v", first, second)137136 }138137 }
M tinyclient/remote.go +1 -1
M tinyclient/social.html +44 -2
@@ -1,7 +1,8 @@
11 {{define "social"}}{{$view := socialView .Query}}<section id="social">22 <header id="social-head"><h1>{{$view.Label}}</h1><p>{{$view.Summary}}</p>{{if eq $view.Key "podcasts"}}<p id="podcast-subscribe"><a href="{{podcastFeedURL .Query}}">Subscribe with RSS</a></p>{{end}}</header>33 {{if .Error}}{{template "alert" .Error}}{{else}}4-{{if .Actor}}{{template "socialComposer" .}}{{else}}<p data-social-welcome><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to join the conversation.</p>{{end}}4+{{if eq $view.Key "podcasts"}}{{template "podcastShow" .}}{{end}}5+{{if .Actor}}{{if eq $view.Key "podcasts"}}{{template "podcastComposer" .}}{{else}}{{template "socialComposer" .}}{{end}}{{else}}<p data-social-welcome><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to join the conversation.</p>{{end}}56 {{with .Query.Get "author"}}<p id="social-author-filter">Posts by {{template "nostrName" .}} <a href="/social/profile?author={{.}}">View profile →</a></p>{{end}}67 {{template "socialFeed" .}}78 {{end}}<noscript><p>Reading works without JavaScript. Publishing needs JavaScript and a connected signer.</p></noscript>
@@ -34,11 +35,19 @@
3435 {{if index $p "detail"}}<div data-article-content>{{socialBody $p}}</div>{{end}}3536 {{with index $p "read_minutes"}}<small data-read-time>{{.}} min read{{if not (index $p "detail")}} <a href="{{socialURL $p}}">Read article →</a>{{end}}</small>{{end}}3637 </article>38+{{else if eq (str (index $p "kind")) "54"}}{{$episode := podcastEpisode $p}}<article data-post-body="podcast">39+{{if index $p "detail"}}<h1>{{or $episode.Title "Untitled episode"}}</h1>{{else}}<h2><a href="{{socialURL $p}}">{{or $episode.Title "Untitled episode"}}</a></h2>{{end}}40+{{with $episode.Image}}<img data-podcast-cover src="{{.}}" alt="" loading="lazy" referrerpolicy="no-referrer">{{end}}41+{{with $episode.Description}}<p>{{.}}</p>{{end}}42+{{range $episode.Audio}}<audio controls preload="none" src="{{.URL}}"><a href="{{.URL}}">Download audio</a></audio>{{end}}43+{{if index $p "detail"}}<div data-article-content>{{podcastNotes $p}}</div>{{end}}44+<p data-podcast-subscribe><a href="/social/podcasts.rss?author={{index $p "pubkey"}}">Subscribe to this podcast</a></p>45+</article>3746 {{else}}<article data-post-body="note">{{socialBody $p}}</article>{{end}}3847 {{template "socialActions" $p}}3948 </li>{{end}}4049 41-{{define "socialByline"}}{{$p := asMap .}}<header data-social-byline><span data-social-avatar><nostr-avatar pubkey="{{index $p "pubkey"}}" aria-hidden="true">{{with socialMediaURL (str (index $p "author_picture"))}}<img src="{{.}}" alt="" loading="lazy" width="36" height="36" referrerpolicy="no-referrer">{{else}}<img src="{{avatarURL (str (index $p "pubkey"))}}" alt="" width="36" height="36" loading="lazy">{{end}}</nostr-avatar></span><span><a href="/social/profile?author={{str (index $p "pubkey")}}"><b>{{if index $p "author_name"}}{{str (index $p "author_name")}}{{else}}{{template "nostrName" (str (index $p "pubkey"))}}{{end}}</b></a> <a href="{{socialURL $p}}"><time datetime="{{datetime (index $p "created_at")}}">{{when (index $p "created_at")}}</time></a>{{if eq (str (index $p "kind")) "30023"}} <small>Article</small>{{end}}{{with index $p "parent_url"}}<small data-social-parent>↳ <a href="{{.}}">Replying{{if index $p "parent_author"}} to {{template "nostrName" (str (index $p "parent_author"))}}{{else}} to a post{{end}}</a></small>{{end}}</span></header>{{end}}50+{{define "socialByline"}}{{$p := asMap .}}<header data-social-byline><span data-social-avatar><nostr-avatar pubkey="{{index $p "pubkey"}}" aria-hidden="true">{{with socialMediaURL (str (index $p "author_picture"))}}<img src="{{.}}" alt="" loading="lazy" width="36" height="36" referrerpolicy="no-referrer">{{else}}<img src="{{avatarURL (str (index $p "pubkey"))}}" alt="" width="36" height="36" loading="lazy">{{end}}</nostr-avatar></span><span><a href="/social/profile?author={{str (index $p "pubkey")}}{{if eq (str (index $p "kind")) "54"}}&amp;kind=podcasts{{end}}"><b>{{if index $p "author_name"}}{{str (index $p "author_name")}}{{else}}{{template "nostrName" (str (index $p "pubkey"))}}{{end}}</b></a> <a href="{{socialURL $p}}"><time datetime="{{datetime (index $p "created_at")}}">{{when (index $p "created_at")}}</time></a>{{if eq (str (index $p "kind")) "30023"}} <small>Article</small>{{end}}{{with index $p "parent_url"}}<small data-social-parent>↳ <a href="{{.}}">Replying{{if index $p "parent_author"}} to {{template "nostrName" (str (index $p "parent_author"))}}{{else}} to a post{{end}}</a></small>{{end}}</span></header>{{end}}4251 4352 {{define "socialActions"}}{{$p := asMap .}}<footer data-social-actions>4453 <a href="{{socialURL $p}}#reply">{{if index $p "reply_count"}}{{index $p "reply_count"}} {{if eq (str (index $p "reply_count")) "1"}}reply{{else}}replies{{end}}{{else}}Reply{{end}}</a>
@@ -61,6 +70,39 @@
6170 6271 {{define "socialMediaFields"}}<details data-post-options><summary>Attach media</summary><label>Images, video or audio <input type="file" name="media-file" accept="image/*,video/*,audio/*" multiple></label><label>Or paste a media URL <input name="media" type="url" placeholder="https://"></label><label>Description <input name="alt" maxlength="1000" placeholder="Describe the media for readers"></label><small>Attachments are published with your post.</small></details>{{end}}6372 73+{{define "podcastShow"}}{{with index (asMap .Event) "show"}}{{$show := asMap .}}{{if index $show "id"}}<header id="podcast-show">74+{{with socialMediaURL (str (index $show "image"))}}<img src="{{.}}" alt="" width="96" height="96" loading="lazy" referrerpolicy="no-referrer">{{end}}75+<h2>{{or (str (index $show "title")) "Untitled podcast"}}</h2><p>{{str (index $show "description")}}</p>76+{{range index $show "websites"}}<p><a href="{{socialMediaURL (str .)}}" rel="noopener noreferrer">{{.}}</a></p>{{end}}77+{{with index $show "authors"}}<p>{{range .}}{{$author := asMap .}}<a href="/social/profile?author={{index $author "pubkey"}}">{{template "nostrName" (str (index $author "pubkey"))}}</a>{{with index $author "role"}} <small>{{.}}</small>{{end}} {{end}}</p>{{end}}78+</header>{{end}}{{end}}{{end}}79+80+{{define "podcastComposer"}}81+<p id="podcast-own"><a href="/social?kind=podcasts&amp;author={{.Actor}}">Your podcast</a></p>82+{{if or (not (.Query.Get "author")) (eq (.Query.Get "author") .Actor)}}83+<details id="podcast-publish"><summary>Publish an episode</summary>84+<p>Episodes publish under your connected podcast identity.</p>85+<social-compose mode="podcast" actor="{{.Actor}}" relay="{{wsURL .URL}}" refresh="/social?kind=podcasts&amp;author={{.Actor}}">86+<label>Episode title <input name="title" required maxlength="200"></label>87+<label>Description <input name="summary" maxlength="1000"></label>88+<label>Audio file <input type="file" name="media-file" accept="audio/*"></label>89+<label>Or use an audio URL <input name="media" type="url" placeholder="https://"></label>90+<label>Audio format <select name="media-type"><option value="">Detect from file or URL</option><option value="audio/mpeg">MP3</option><option value="audio/mp4">M4A</option><option value="audio/ogg">Ogg</option><option value="audio/wav">WAV</option><option value="audio/flac">FLAC</option></select></label>91+<label>Episode artwork URL <input name="cover" type="url" placeholder="https://"></label>92+<label>Show notes <textarea name="content" rows="6" maxlength="65000" placeholder="Links and notes for this episode. Markdown is supported."></textarea></label>93+<p data-compose-actions><button type="submit">Publish episode</button> <button type="button" data-preview>Preview notes</button></p><div data-preview-output hidden></div>94+</social-compose></details>95+{{if eq (.Query.Get "author") .Actor}}{{$show := asMap (index (asMap .Event) "show")}}96+<details id="podcast-settings"><summary>Podcast details</summary>97+<social-compose mode="podcast-show" actor="{{.Actor}}" relay="{{wsURL .URL}}" refresh="/social?kind=podcasts&amp;author={{.Actor}}" show-tags="{{json (index $show "tags")}}">98+<label>Podcast title <input name="title" value="{{index $show "title"}}" required maxlength="200"></label>99+<label>Description <textarea name="summary" rows="3" maxlength="4000">{{index $show "description"}}</textarea></label>100+<label>Artwork URL <input name="cover" type="url" value="{{index $show "image"}}" placeholder="https://"></label>101+<label>Website <input name="website" type="url" value="{{with index $show "websites"}}{{index . 0}}{{end}}" placeholder="https://"></label>102+<button type="submit">Save podcast details</button>103+</social-compose></details>{{end}}104+{{end}}{{end}}105+64106 {{define "socialCommentComposer"}}{{$p := asMap .}}<social-compose id="reply" mode="comment" actor="{{index $p "viewer"}}" relay="{{index $p "relay"}}" target="{{index $p "id"}}" target-kind="{{index $p "kind"}}" target-pubkey="{{index $p "pubkey"}}" target-tags="{{json (index $p "tags")}}" address="{{or (str (index $p "root_address")) (str (index $p "address"))}}" root="{{or (str (index $p "root_id")) (str (index $p "id"))}}" root-pubkey="{{str (index $p "root_author")}}" root-kind="{{or (str (index $p "root_kind")) (str (index $p "kind"))}}" refresh="{{socialURL $p}}">65107 <label>Reply <textarea name="content" rows="3" maxlength="20000" placeholder="Add to the conversation"></textarea></label>{{template "socialMediaFields" .}}<p data-compose-actions><button type="submit">Post reply</button> <button type="button" data-preview>Preview</button></p><div data-preview-output hidden></div>66108 </social-compose>{{end}}
M tinyclient/social_content.go +1 -1
M tinyclient/social_navigation.go +1 -1
M tinyclient/social_pages.go +1 -1
A tinyclient/social_podcasts.go +42 -0
A tinyclient/social_podcasts_test.go +86 -0
@@ -0,0 +1,86 @@
1+package tinyclient2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+ "github.com/FelineStateMachine/tinyrelay/internal/podcasts"13+ "github.com/FelineStateMachine/tinyrelay/internal/policy"14+)15+16+type nativePodcastBackend struct{ fakeBackend }17+18+func (b *nativePodcastBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {19+ key := strings.Repeat("a", 64)20+ show := podcasts.Show{ID: "metadata", Pubkey: key, Title: "Mountain Radio", Description: "Stories from the trail", Image: "https://example.test/show.jpg", Websites: []string{"https://example.test"}, Tags: [][]string{{"title", "Mountain Radio"}}}21+ post := map[string]any{"id": strings.Repeat("b", 64), "kind": 54, "pubkey": key, "title": "First light", "created_at": 1789000000, "content": "## Field notes\n\nA **quiet** morning.", "tags": [][]string{{"title", "First light"}, {"description", "An early start"}, {"audio", "https://example.test/first.mp3", "audio/mpeg"}}, "podcast": show, "author_name": show.Title}22+ switch method {23+ case "browsesocial", "browsepodcasts":24+ return map[string]any{"show": show, "items": []any{post}}, nil25+ case "browsesocialthread":26+ return map[string]any{"post": post}, nil27+ case "browseprofile":28+ return map[string]any{"profile": map[string]any{"name": "Personal profile"}}, nil29+ default:30+ return b.fakeBackend.Query(ctx, method, params, actor)31+ }32+}33+34+func TestNativePodcastPagesRenderAudioAndShowIdentity(t *testing.T) {35+ key := strings.Repeat("a", 64)36+ b := &nativePodcastBackend{fakeBackend{policy: policy.Defaults(key)}}37+ a, err := New(b, Options{Actor: func(*http.Request) (string, error) { return key, nil }})38+ if err != nil {39+ t.Fatal(err)40+ }41+ for _, path := range []string{"/social?kind=podcasts&author=" + key, "/social/profile?kind=podcasts&author=" + key, "/social/" + strings.Repeat("b", 64)} {42+ w := httptest.NewRecorder()43+ a.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))44+ body := w.Body.String()45+ for _, want := range []string{"Mountain Radio", "First light", `<audio controls`, `src="https://example.test/first.mp3"`, `data-post-body="podcast"`} {46+ if w.Code != 200 || !strings.Contains(body, want) {47+ t.Errorf("%s missing %q: %d", path, want, w.Code)48+ }49+ }50+ if strings.Contains(path, "/social/"+strings.Repeat("b", 64)) && (!strings.Contains(body, "Field notes</h2>") || !strings.Contains(body, "<strong>quiet</strong>")) {51+ t.Error("Podcast show notes are not Markdown")52+ }53+ if strings.Contains(path, "profile?") && strings.Contains(body, "Personal profile") {54+ t.Error("Podcast profile used personal metadata instead of show")55+ }56+ }57+}58+59+func TestPodcastComposerUsesNativeEpisodeAndShowModes(t *testing.T) {60+ key := strings.Repeat("a", 64)61+ a, err := New(&nativePodcastBackend{fakeBackend{policy: policy.Defaults(key)}}, Options{Actor: func(*http.Request) (string, error) { return key, nil }})62+ if err != nil {63+ t.Fatal(err)64+ }65+ w := httptest.NewRecorder()66+ a.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/social?kind=podcasts&author="+key, nil))67+ for _, want := range []string{`mode="podcast"`, `mode="podcast-show"`, `show-tags=`, `accept="audio/*"`, `Publish episode`, `Save podcast details`} {68+ if !strings.Contains(w.Body.String(), want) {69+ t.Errorf("missing %s", want)70+ }71+ }72+}73+74+func TestSocialPodcastEventKeepsTheNativeKind(t *testing.T) {75+ for _, kind := range []int{1, 30023, 54} {76+ e := socialPodcastEvent(map[string]any{"kind": kind, "tags": [][]string{{"audio", "https://example.test/ep.mp3"}}})77+ _, ok := podcasts.ParseEpisode(e)78+ if ok != (kind == 54) {79+ t.Errorf("kind %d parsed as podcast: %v", kind, ok)80+ }81+ }82+ native := event.Event{Kind: 54, ID: "native", Tags: [][]string{{"audio", "https://example.test/ep.mp3"}}}83+ if got := socialPodcastEvent(map[string]any{"event": native}); got.ID != "native" {84+ t.Fatalf("nested event: %#v", got)85+ }86+}
M tinyclient/social_profile.go +2 -1
M tinyclient/social_profile.html +1 -1
M tinyclient/social_reference_routes.go +1 -1
M tinyclient/style.css +10 -0
M tinyclient/templates.go +4 -0