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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 11:12:05 +02:00
1 parent 3df3c333bf
commit 69ab8e50eb
7 files changed
+163 -3

No files matched your search

+26 -1
View File
@@ -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<PostEntity> Parent(string inReplyTo, int depth, CancellationToken token)
@@ -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;
@@ -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;
+23 -2
View File
@@ -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<bool>(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");