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:
1 parent
3df3c333bf
commit
69ab8e50eb
7 files changed
+163
-3
No files matched your search
@@ -212,6 +212,47 @@ namespace PrivaPub.Tests.Federation
|
||||
Assert.False(await DB.Default.Find<PrivaPub.Models.Federation.Follower>().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"] = $"<a href=\"{origin + episodePath}\">Episode one</a><br/>", ["published"] = DateTime.UtcNow.ToString("O")
|
||||
}
|
||||
});
|
||||
|
||||
var post = await DB.Default.Find<Post>().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()
|
||||
{
|
||||
|
||||
@@ -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":"<p>Listen to the meadow</p>"}},
|
||||
"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("<strong>meadow</strong>", 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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user