From 69ab8e50ebc43f03c1cd57a97260b512892ef22c Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 11:12:05 +0200 Subject: [PATCH] Podcast episodes play; servers with NodeInfo2 only are described Castopod announces an episode with a Note that only links to the episode's page, which serves a PodcastEpisode. PrivaPub reads PodcastEpisode (its words in description, its sound in audio, its cover in image), and a Note that is only a link to its author's own episode takes the episode's sound, title, cover and words, so the post plays. A server that publishes no NodeInfo is described from NodeInfo2 (/.well-known/x-nodeinfo2), as Castopod publishes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- .../Federation/InboundRoutingTests.cs | 41 +++++++++++++++++++ PrivaPub.Tests/Federation/NoteParserTests.cs | 20 +++++++++ PrivaPub.Tests/Statistics/DescribeTests.cs | 15 +++++++ PrivaPub/Federation/Inbox/RemotePosts.cs | 27 +++++++++++- .../Federation/Objects/InstanceDescriber.cs | 11 +++++ .../Federation/Objects/InstanceDocuments.cs | 27 ++++++++++++ PrivaPub/Federation/Objects/NoteParser.cs | 25 ++++++++++- 7 files changed, 163 insertions(+), 3 deletions(-) diff --git a/PrivaPub.Tests/Federation/InboundRoutingTests.cs b/PrivaPub.Tests/Federation/InboundRoutingTests.cs index a8f165f..c9d6d88 100644 --- a/PrivaPub.Tests/Federation/InboundRoutingTests.cs +++ b/PrivaPub.Tests/Federation/InboundRoutingTests.cs @@ -212,6 +212,47 @@ namespace PrivaPub.Tests.Federation Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id).ExecuteAnyAsync(token)); } + // Castopod announces an episode with a note that only links to it: the post takes the episode's sound, title and cover + [Fact] + public async Task A_note_that_only_links_its_authors_episode_carries_the_episodes_sound() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var podcast = new RemoteActor(_harness.Peer, "pod"); + await DB.Default.SaveAsync(new PrivaPub.Models.Social.Following + { + AvatarId = alice.Id, TargetActorURI = podcast.Id, TargetInboxURL = podcast.Id + "/inbox", State = PrivaPub.Models.Social.FollowState.Accepted + }, token); + var origin = new Uri(podcast.Id).GetLeftPart(UriPartial.Authority); + var episodePath = $"/episodes/{Guid.NewGuid():N}"; + _harness.Peer.Serve(episodePath, new JsonObject + { + ["id"] = origin + episodePath, ["type"] = "PodcastEpisode", ["attributedTo"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), + ["description"] = new JsonObject { ["type"] = "Note", ["content"] = "the meadow", ["mediaType"] = "text/markdown" }, + ["image"] = new JsonObject { ["type"] = "Image", ["url"] = origin + "/cover.png" }, + ["audio"] = new JsonObject + { + ["id"] = origin + "/one.mp3", ["type"] = "Audio", ["name"] = "Episode one", ["duration"] = 4.0, + ["url"] = new JsonObject { ["href"] = origin + "/one.mp3", ["type"] = "Link", ["mediaType"] = "audio/mpeg" } + } + }.ToJsonString()); + var noteId = NewId(podcast, "posts"); + + await _harness.Deliver(podcast, "/human-centipede", new JsonObject + { + ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), + ["object"] = new JsonObject + { + ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), + ["content"] = $"Episode one
", ["published"] = DateTime.UtcNow.ToString("O") + } + }); + + var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token); + Assert.Equal(origin + "/one.mp3", Assert.Single(post.Media).RemoteURL); + Assert.Equal(("Episode one", origin + "/cover.png", 4.0), (post.Title, post.CoverURL, post.Audio.DurationSeconds)); + } + [Fact] public async Task A_join_of_a_local_post_is_answered_with_ignore() { diff --git a/PrivaPub.Tests/Federation/NoteParserTests.cs b/PrivaPub.Tests/Federation/NoteParserTests.cs index f77bf60..286b5ef 100644 --- a/PrivaPub.Tests/Federation/NoteParserTests.cs +++ b/PrivaPub.Tests/Federation/NoteParserTests.cs @@ -35,6 +35,26 @@ namespace PrivaPub.Tests.Federation } """; + // Castopod's episode: its words in description, its sound in audio, the sound's name for a title + [Fact] + public void A_podcast_episode_is_read_with_its_words_sound_and_cover() + { + var episode = NoteParser.Parse(JsonNode.Parse(""" + {"id":"https://castopod.test/@pod/episodes/one","type":"PodcastEpisode","published":"2026-10-06T09:08:17+00:00", + "to":["https://www.w3.org/ns/activitystreams#Public"],"attributedTo":"https://castopod.test/@pod", + "description":{"type":"Note","mediaType":"text/markdown","content":"Listen to the **meadow**","contentMap":{"en":"

Listen to the meadow

"}}, + "image":{"type":"Image","mediaType":"image/png","url":"https://castopod.test/media/cover.png"}, + "audio":{"id":"https://castopod.test/audio/one.mp3","type":"Audio","name":"Episode one","size":16791,"duration":4.02, + "url":{"href":"https://castopod.test/audio/one.mp3","type":"Link","mediaType":"audio/mpeg"}}} + """)); + + Assert.Equal(("PodcastEpisode", "Episode one", "https://castopod.test/media/cover.png"), (episode.Type, episode.Title, episode.CoverURL)); + Assert.Contains("meadow", episode.ContentHtml); + var sound = Assert.Single(episode.Attachments); + Assert.Equal(("https://castopod.test/audio/one.mp3", "audio/mpeg"), (sound.RemoteURL, sound.ContentType)); + Assert.Equal(4.02, episode.Audio.DurationSeconds); + } + [Fact] public void Reads_a_mastodon_note() { diff --git a/PrivaPub.Tests/Statistics/DescribeTests.cs b/PrivaPub.Tests/Statistics/DescribeTests.cs index a883c34..0894a2f 100644 --- a/PrivaPub.Tests/Statistics/DescribeTests.cs +++ b/PrivaPub.Tests/Statistics/DescribeTests.cs @@ -48,6 +48,21 @@ namespace PrivaPub.Tests.Statistics Assert.Equal("mobilizon", InstanceDocuments.NodeInfo(Json("""{"software":{"name":"Mobilizon"}}"""), "2.1").Software); } + // Castopod publishes NodeInfo2 only + [Fact] + public void NodeInfo2_gives_the_server_and_its_usage() + { + var facts = InstanceDocuments.NodeInfo2(Json(""" + {"version":"1.0","server":{"baseUrl":"https://castopod.test/","name":"Castopod","software":"Castopod","version":"1.15.5"}, + "protocols":["activitypub"],"openRegistrations":false,"usage":{"users":{"total":1,"activeMonth":1,"activeHalfyear":1},"localPosts":3}} + """)); + + Assert.Equal(("castopod", "1.15.5", "Castopod", "nodeinfo2-1.0"), (facts.Software, facts.SoftwareVersion, facts.NodeName, facts.Version)); + Assert.Equal(new[] { "activitypub" }, facts.Protocols); + Assert.Equal((1L, 3L), (facts.UsersTotal.Value, facts.LocalPosts.Value)); + Assert.Null(InstanceDocuments.NodeInfo2(Json("""{"version":"1.0","server":{}}"""))); + } + [Fact] public void The_newest_https_nodeinfo_link_is_chosen() { diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index 469b45c..334d77f 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -83,7 +83,7 @@ namespace PrivaPub.Federation.Inbox { var mentions = await Mentions(note, to.Concat(cc), _localActors, token); - return new PostEntity + var post = new PostEntity { ID = PrivacyIds.Arrived(note.Published), ObjectURI = note.Id, @@ -135,6 +135,31 @@ namespace PrivaPub.Federation.Inbox UpdateDate = note.Updated ?? note.Published, EditedAt = note.Updated }; + await Episode(post, note, author, token); + return post; + } + + static readonly System.Text.RegularExpressions.Regex Href = new(@"href=""([^""]+)""", System.Text.RegularExpressions.RegexOptions.Compiled); + + // a post that is only a link to a podcast episode of its own author (Castopod announces each episode so): the + // episode's sound, title and cover, read from it + async Task Episode(PostEntity post, NoteDocument note, ForeignAvatar author, CancellationToken token) + { + if (note.Type != "Note" || post.Media.Count > 0 || string.IsNullOrEmpty(note.ContentHtml)) + return; + var links = Href.Matches(note.ContentHtml).Select(m => System.Net.WebUtility.HtmlDecode(m.Groups[1].Value)).Distinct().ToList(); + if (links.Count != 1 || links[0] == note.Id || !Origin.Same(links[0], author.ActorURI)) + return; + using var scope = HttpScope.For("object"); + using var fetched = await _remoteActors.FetchObject(links[0], token); + var episode = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText())); + if (episode is not { Type: "PodcastEpisode" } || episode.AttributedTo != author.ActorURI) + return; + post.Media = episode.Attachments.ToList(); + post.Audio = episode.Audio; + post.Title ??= episode.Title; + post.CoverURL ??= episode.CoverURL; + post.Excerpt ??= ObjectShapes.Text(episode.ContentHtml, 1000); } public async Task Parent(string inReplyTo, int depth, CancellationToken token) diff --git a/PrivaPub/Federation/Objects/InstanceDescriber.cs b/PrivaPub/Federation/Objects/InstanceDescriber.cs index 8fda365..9736d57 100644 --- a/PrivaPub/Federation/Objects/InstanceDescriber.cs +++ b/PrivaPub/Federation/Objects/InstanceDescriber.cs @@ -75,6 +75,17 @@ namespace PrivaPub.Federation.Objects else error = "NodeInfo unreadable"; } + // no NodeInfo: NodeInfo2, Castopod's + if (facts == default && allowed("/.well-known/x-nodeinfo2")) + { + using var nodeInfo2 = await _http.GetJson(Address($"https://{host}/.well-known/x-nodeinfo2"), "application/json", sign: default, token); + if (nodeInfo2 != default && InstanceDocuments.NodeInfo2(nodeInfo2.Root) is { } second) + { + facts = second; + rawNodeInfo = nodeInfo2.Root.GetRawText(); + error = default; + } + } } InstanceApiFacts api = default; diff --git a/PrivaPub/Federation/Objects/InstanceDocuments.cs b/PrivaPub/Federation/Objects/InstanceDocuments.cs index ba81017..bc515e2 100644 --- a/PrivaPub/Federation/Objects/InstanceDocuments.cs +++ b/PrivaPub/Federation/Objects/InstanceDocuments.cs @@ -72,6 +72,33 @@ namespace PrivaPub.Federation.Objects Languages(metadata, "languages") ?? Languages(metadata, "langs")); } + // NodeInfo2 (`/.well-known/x-nodeinfo2`), which Castopod publishes in place of NodeInfo: the server, its software + // and its usage in one document + public static NodeInfoFacts NodeInfo2(JsonElement root) + { + var server = Section(root, "server"); + var usage = Section(root, "usage"); + var users = Section(usage, "users"); + if (Text(server, "software", 200) is not { } software) + return default; + return new NodeInfoFacts( + "nodeinfo2-" + (Text(root, "version", 20) ?? "1.0"), + software.ToLowerInvariant(), + Text(server, "version", 200), + Text(server, "name", 200), + default, + Strings(root, "protocols", 16, value => value.Length <= 40), + root.ValueKind == JsonValueKind.Object && root.TryGetProperty("openRegistrations", out var open) && open.ValueKind is JsonValueKind.True or JsonValueKind.False + ? open.GetBoolean() + : (bool?)null, + Count(users, "total"), + Count(users, "activeMonth"), + Count(users, "activeHalfyear"), + Count(usage, "localPosts"), + Count(usage, "localComments"), + default); + } + public static InstanceApiFacts InstanceApi(JsonElement root) { var registrations = root.ValueKind == JsonValueKind.Object && root.TryGetProperty("registrations", out var r) ? r : default; diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs index e7214d3..24167a4 100644 --- a/PrivaPub/Federation/Objects/NoteParser.cs +++ b/PrivaPub/Federation/Objects/NoteParser.cs @@ -56,7 +56,7 @@ namespace PrivaPub.Federation.Objects public static class NoteParser { - static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage" }; + static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage", "PodcastEpisode" }; const int MaxAttachments = 16; const int MaxTags = 64; @@ -68,6 +68,8 @@ namespace PrivaPub.Federation.Objects var id = Id(note); if (!NoteTypes.Contains(type) || Origin.Of(id) == default) return default; + if (type == "PodcastEpisode") + note = Episode(note); var (contentHtml, language) = Content(note); var summary = Value(note, "summary"); @@ -123,13 +125,32 @@ namespace PrivaPub.Federation.Objects CoverURL = ObjectShapes.Cover(note), Link = ObjectShapes.Link(note), Video = ObjectShapes.Video(note), - Audio = ObjectShapes.Audio(note), + Audio = ObjectShapes.Audio(type == "PodcastEpisode" ? note["audio"] as JsonObject : note), Event = ObjectShapes.Event(note), Place = ObjectShapes.NotePlace(note), CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue(out var enabled) ? enabled : null }; } + // Castopod's PodcastEpisode: its words in `description` (a Note of its own), its sound in `audio`, its title the + // sound's name; read as a post with those + static JsonObject Episode(JsonObject episode) + { + var note = episode.DeepClone().AsObject(); + if (note["description"] is JsonObject description) + { + note["content"] = description["content"]?.DeepClone(); + note["contentMap"] = description["contentMap"]?.DeepClone(); + note["mediaType"] = description["mediaType"]?.DeepClone(); + } + if (note["audio"] is JsonObject audio) + { + note["name"] ??= audio["name"]?.DeepClone(); + note["attachment"] = new JsonArray(audio.DeepClone()); + } + return note; + } + static (string Html, string Language) Content(JsonObject note) { var content = Value(note, "content");