diff --git a/CLAUDE.md b/CLAUDE.md
index 8f7a4f3..8ed8268 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -244,6 +244,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
revision. Delivery details reach them through `Arrival.Current`, which `InboxProcessor` sets for the handler's
duration. The raw form lives outside `Post` so timelines never load it. Read through
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`.
+- **Remote objects are parsed for every shape in `ObjectShapes`:** `url`/`icon`/`image` as a value, an object or an
+ array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed
+ `Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page
+ (that fetch is the owner's decision 1, for P6).
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything.
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
diff --git a/PrivaPub.Tests/Federation/ObjectShapesTests.cs b/PrivaPub.Tests/Federation/ObjectShapesTests.cs
new file mode 100644
index 0000000..a124365
--- /dev/null
+++ b/PrivaPub.Tests/Federation/ObjectShapesTests.cs
@@ -0,0 +1,179 @@
+using PrivaPub.Api.Mastodon.Mappers;
+using PrivaPub.Federation.Objects;
+using PrivaPub.Models.Post;
+
+using System.Text.Json.Nodes;
+
+namespace PrivaPub.Tests.Federation
+{
+ public class ObjectShapesTests
+ {
+ static NoteDocument Parse(string json) => NoteParser.Parse(JsonNode.Parse(json));
+
+ [Fact]
+ public void Keeps_a_peertube_video_with_its_files_poster_captions_and_markdown_description()
+ {
+ var note = Parse("""
+ {
+ "id": "https://tube.example/videos/watch/1", "type": "Video", "name": "A *talk*", "duration": "PT6299S",
+ "mediaType": "text/markdown", "content": "**Bold** words", "sensitive": false, "views": 14771, "state": 1,
+ "isLiveBroadcast": false, "commentsPolicy": 2, "downloadEnabled": true,
+ "licence": { "identifier": "5", "name": "Attribution - Share Alike" }, "category": { "identifier": "2", "name": "Films" },
+ "attributedTo": [{ "type": "Person", "id": "https://tube.example/accounts/ann" }, { "type": "Group", "id": "https://tube.example/video-channels/films" }],
+ "icon": [
+ { "type": "Image", "url": "https://tube.example/thumb-280.jpg", "mediaType": "image/jpeg", "width": 280, "height": 157 },
+ { "type": "Image", "url": "https://tube.example/thumb-1920.jpg", "mediaType": "image/jpeg", "width": 1920, "height": 1080 },
+ { "type": "Image", "url": "https://tube.example/thumb-850.jpg", "mediaType": "image/jpeg", "width": 850, "height": 480 }
+ ],
+ "subtitleLanguage": [{ "identifier": "en", "url": [{ "mediaType": "text/vtt", "href": "https://tube.example/en.vtt" }] }],
+ "url": [
+ { "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/abc" },
+ { "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720.mp4", "height": 720, "width": 1280, "fps": 24, "size": 942586378,
+ "attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] },
+ { "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8" },
+ { "type": "Link", "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", "href": "magnet:?xt=urn:btih:abc" }
+ ]
+ }
+ """);
+
+ Assert.Equal("https://tube.example/w/abc", note.Url);
+ Assert.Equal("A *talk*", note.Title);
+ Assert.Contains("Bold", note.ContentHtml);
+ Assert.Equal("https://tube.example/thumb-850.jpg", note.CoverURL);
+ Assert.Equal(6299, note.Video.DurationSeconds);
+ var file = Assert.Single(note.Video.Variants);
+ Assert.Equal(new[] { "audio", "video" }, file.Streams);
+ Assert.Equal(942586378, file.Size);
+ Assert.Equal("https://tube.example/master.m3u8", note.Video.PlaylistURL);
+ Assert.Equal(2, note.Video.CommentsPolicy);
+ Assert.Equal("Attribution - Share Alike", note.Video.Licence);
+ Assert.Equal("https://tube.example/video-channels/films", note.Video.ChannelURI);
+ Assert.Equal("https://tube.example/en.vtt", Assert.Single(note.Video.Captions).Url);
+ Assert.Equal("https://tube.example/accounts/ann", note.AttributedTo);
+ }
+
+ [Fact]
+ public void Keeps_a_mobilizon_event_with_its_time_place_and_online_link()
+ {
+ var note = Parse("""
+ {
+ "id": "https://events.example/events/9", "type": "Event", "name": "Repair café",
+ "summary": "
Saturday at the library
", "content": "Bring broken things.
",
+ "startTime": "2026-10-03T10:00:00+02:00", "endTime": "2026-10-03T13:00:00+02:00", "timezone": "Europe/Rome",
+ "joinMode": "free", "participantCount": 12, "maximumAttendeeCapacity": 40, "ical:status": "CONFIRMED",
+ "location": { "type": "Place", "name": "Biblioteca", "latitude": 45.07, "longitude": 7.68,
+ "address": { "type": "PostalAddress", "streetAddress": "Via Roma 1", "addressLocality": "Torino", "addressCountry": "IT" } },
+ "attachment": [{ "type": "Link", "name": "Website", "href": "https://meet.example/repair" }],
+ "attributedTo": "https://events.example/@club"
+ }
+ """);
+
+ Assert.Null(note.SpoilerText);
+ Assert.False(note.Sensitive);
+ Assert.Equal("Saturday at the library", note.Excerpt);
+ Assert.Equal(new DateTime(2026, 10, 3, 8, 0, 0, DateTimeKind.Utc), note.Event.Start);
+ Assert.Equal("Via Roma 1, Torino, IT", Assert.Single(note.Event.Places).Address);
+ Assert.Equal("https://meet.example/repair", note.Event.OnlineURL);
+ Assert.Equal(40, note.Event.Capacity);
+ Assert.Equal("CONFIRMED", note.Event.Status);
+ Assert.Equal("2026-10-03 10:00 – 13:00 (Europe/Rome) · Biblioteca", MastodonMapper.EventLine(note.Event));
+ }
+
+ [Fact]
+ public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail()
+ {
+ var note = Parse("""
+ {
+ "id": "https://lemmy.example/post/5", "type": "Page", "name": "A good read", "attributedTo": "https://lemmy.example/u/bo",
+ "attachment": [{ "type": "Link", "href": "https://news.example/story", "mediaType": "text/html" }],
+ "image": { "type": "Image", "url": "https://lemmy.example/pictrs/thumb.jpg" },
+ "language": { "identifier": "en", "name": "English" }
+ }
+ """);
+
+ Assert.Equal("https://news.example/story", note.Link.Href);
+ Assert.Equal("https://lemmy.example/pictrs/thumb.jpg", note.Link.ImageURL);
+ Assert.False(note.Link.FromPublisher);
+ Assert.Empty(note.Attachments);
+ }
+
+ [Fact]
+ public void Takes_a_publisher_supplied_preview_from_a_link_attachment()
+ {
+ var note = Parse("""
+ {
+ "id": "https://m.example/statuses/1", "type": "Note", "attributedTo": "https://m.example/users/a", "content": "look
",
+ "attachment": [{ "type": "Link", "href": "https://news.example/a",
+ "preview": { "type": "Article", "name": "Headline & more", "summary": "What happened
", "image": { "type": "Image", "url": "https://news.example/a.jpg" } } }]
+ }
+ """);
+
+ Assert.Equal("Headline & more", note.Link.Title);
+ Assert.Equal("What happened", note.Link.Description);
+ Assert.Equal("https://news.example/a.jpg", note.Link.ImageURL);
+ Assert.True(note.Link.FromPublisher);
+ }
+
+ [Fact]
+ public void Reads_media_in_every_shape_and_infers_what_is_missing()
+ {
+ var note = Parse("""
+ {
+ "id": "https://bsky.brid.gy/convert/ap/at://did:plc:x/post/1", "type": "Note", "attributedTo": "https://bsky.brid.gy/ap/did:plc:x",
+ "content": "pics
",
+ "@context": ["https://www.w3.org/ns/activitystreams", { "@language": "de" }],
+ "attachment": [
+ { "type": "Image", "url": "https://cdn.example/a.jpeg", "summary": "alt from summary" },
+ { "type": "Document", "url": [{ "type": "Link", "href": "https://cdn.example/clip", "mediaType": "video/mp4" }],
+ "icon": { "type": "Image", "url": "https://cdn.example/clip.jpg" }, "duration": "PT12.4S", "sensitive": true },
+ { "type": "Image", "url": "https://cdn.example/nameless" }
+ ]
+ }
+ """);
+
+ Assert.Equal("de", note.Language);
+ Assert.Equal("image/jpeg", note.Attachments[0].ContentType);
+ Assert.Equal("alt from summary", note.Attachments[0].Description);
+ Assert.Equal("https://cdn.example/clip", note.Attachments[1].RemoteURL);
+ Assert.Equal("video/mp4", note.Attachments[1].ContentType);
+ Assert.Equal("https://cdn.example/clip.jpg", note.Attachments[1].RemotePreviewURL);
+ Assert.Equal(12.4, note.Attachments[1].DurationSeconds);
+ Assert.True(note.Attachments[1].Sensitive);
+ Assert.Equal("image/*", note.Attachments[2].ContentType);
+ }
+
+ [Fact]
+ public void Keeps_a_funkwhale_track_and_a_loops_poster()
+ {
+ var track = Parse("""
+ {
+ "id": "https://music.example/federation/music/uploads/1", "type": "Audio", "name": "Song", "attributedTo": "https://music.example/@band",
+ "summary": "#rock #live", "duration": 241, "position": 3, "album": "https://music.example/albums/2", "license": "https://creativecommons.org/licenses/by/4.0/",
+ "url": [{ "type": "Link", "mediaType": "text/html", "href": "https://music.example/library/tracks/1" },
+ { "type": "Link", "mediaType": "audio/mpeg", "href": "https://music.example/listen/1.mp3", "bitrate": 320000, "size": 9640000 }]
+ }
+ """);
+ var loop = Parse("""
+ {
+ "id": "https://loops.example/v/1", "type": "Note", "attributedTo": "https://loops.example/@dancer", "content": "",
+ "preview": { "type": "Image", "url": { "href": "https://loops.example/poster.jpg", "mediaType": "image/jpeg" } },
+ "attachment": [{ "type": "Document", "mediaType": "video/mp4", "url": "https://loops.example/v/1.mp4", "width": 720, "height": 1280 }]
+ }
+ """);
+
+ Assert.Equal("#rock #live", track.Excerpt);
+ Assert.Null(track.SpoilerText);
+ Assert.Equal(241, track.Audio.DurationSeconds);
+ Assert.Equal(3, track.Audio.Position);
+ Assert.Equal("https://music.example/listen/1.mp3", Assert.Single(track.Audio.Variants).Url);
+ Assert.Equal("https://loops.example/poster.jpg", loop.CoverURL);
+ }
+
+ [Fact]
+ public void Normalises_hashtags_like_mastodon() =>
+ Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse("""
+ { "id": "https://x.example/1", "type": "Note", "attributedTo": "https://x.example/u", "content": "",
+ "tag": [{ "type": "Hashtag", "name": "#FEDI" }] }
+ """).Tags));
+ }
+}
diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs
index 4449160..cd055a3 100644
--- a/PrivaPub/Api/Mastodon/Entities/Entities.cs
+++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs
@@ -49,6 +49,28 @@ namespace PrivaPub.Api.Mastodon.Entities
public string AttributionDomains { get; set; }
}
+ public class PreviewCard
+ {
+ public string Url { get; set; }
+ public string Title { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public string Type { get; set; } = "link";
+ public string AuthorName { get; set; } = string.Empty;
+ public string AuthorUrl { get; set; } = string.Empty;
+ public string ProviderName { get; set; } = string.Empty;
+ public string ProviderUrl { get; set; } = string.Empty;
+ public string Html { get; set; } = string.Empty;
+ public int Width { get; set; }
+ public int Height { get; set; }
+ public string Image { get; set; }
+ public string ImageDescription { get; set; } = string.Empty;
+ public string EmbedUrl { get; set; } = string.Empty;
+ public string Blurhash { get; set; }
+ public string Language { get; set; }
+ public string PublishedAt { get; set; }
+ public List