P5: remote objects are read in every shape, and videos, events, audio and links keep their own details
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 52s

- ObjectShapes reads `url`, `icon` and `image` as a value, a Link or an array; Markdown `content` (PeerTube) is
  rendered; a missing `mediaType` is inferred from the extension or the attachment type (Bridgy); alt text comes from
  `name`, `summary` or their language maps; thumbnails come from attachment `icon`, object `icon[]`, `image` or a Loops
  `preview`; durations are ISO 8601 or seconds; per-attachment `sensitive` is kept; the language falls back to
  `@context` `@language` (Pleroma); titles and excerpts are plain text; hashtags normalise with NFKC like Mastodon.
- Typed details on Post: Link (Lemmy link posts, Mbin `source` URLs, Mastodon 4.7 Link attachments with an FEP-8967
  publisher preview), Video (PeerTube files with their streams, HLS playlist, poster, captions, chapters, licence,
  channel, live state, comment policy), Audio (Funkwhale), Event (Mobilizon, Gancio, Hubzilla: times, zone, floating
  time, places and addresses, online link, capacity, status), the cover image, and the author's own source text.
- Mastodon API: a card built from those details without fetching anything, an event's "when · where" line, attachment
  thumbnails and durations, and no media file offered as a preview image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 17:53:59 +02:00
1 parent dcd024100a
commit fc111d8c46
12 files changed
+751 -38

No files matched your search

+47 -26
View File
@@ -1,6 +1,7 @@
using PrivaPub.Models.Post;
using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
@@ -31,6 +32,12 @@ namespace PrivaPub.Federation.Objects
public IReadOnlyList<PostMention> Mentions { get; init; } = Array.Empty<PostMention>();
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
public PostSource Source { get; init; }
public string CoverURL { get; init; }
public PostLink Link { get; init; }
public VideoDetails Video { get; init; }
public AudioDetails Audio { get; init; }
public EventDetails Event { get; init; }
}
public static class NoteParser
@@ -50,6 +57,8 @@ namespace PrivaPub.Federation.Objects
var (contentHtml, language) = Content(note);
var summary = Value(note, "summary");
var excerpt = ObjectShapes.Text(summary, 1000)
?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Note" ? ObjectShapes.Text(Value(preview, "content"), 1000) : default);
var sensitive = note["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) && marked;
var summaryWarns = type is "Note" or "Question" || sensitive;
return new NoteDocument
@@ -59,9 +68,9 @@ namespace PrivaPub.Federation.Objects
Raw = note,
AttributedTo = Attribution(note["attributedTo"]),
ContentHtml = ContentSanitizer.Html(contentHtml),
Title = Plain(Value(note, "name"), 500),
Title = ObjectShapes.Text(Value(note, "name"), 500),
SpoilerText = summaryWarns ? Plain(summary, 500) : default,
Excerpt = summaryWarns ? default : Plain(summary, 1000),
Excerpt = summaryWarns ? default : excerpt,
Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary),
Language = language,
Url = UrlOf(note["url"]) ?? id,
@@ -78,12 +87,18 @@ namespace PrivaPub.Federation.Objects
.DistinctBy(m => m.ActorURI)
.Take(MaxTags)
.ToList(),
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').ToLowerInvariant())
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant())
.Where(t => !string.IsNullOrEmpty(t))
.Distinct()
.Take(MaxTags)
.ToList(),
Attachments = Attachments(note)
Attachments = Attachments(note),
Source = ObjectShapes.Source(note),
CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note),
Video = ObjectShapes.Video(note),
Audio = ObjectShapes.Audio(note),
Event = ObjectShapes.Event(note)
};
}
@@ -100,7 +115,9 @@ namespace PrivaPub.Federation.Objects
}
if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey)
content = System.Net.WebUtility.HtmlEncode(misskey).Replace("\n", "<br>");
return (content, language);
if (!string.IsNullOrEmpty(content) && Value(note, "mediaType")?.StartsWith("text/markdown", StringComparison.OrdinalIgnoreCase) == true)
content = Rendering.ActivityPubRenderer.Html(content);
return (content, language ?? ObjectShapes.Language(note));
}
static string Attribution(JsonNode node) => node switch
@@ -120,33 +137,37 @@ namespace PrivaPub.Federation.Objects
_ => Enumerable.Empty<JsonObject>()
}).Where(t => Value(t, "type") == type);
static List<PostMedia> Attachments(JsonObject note)
{
var items = note["attachment"] switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
};
return items
static List<PostMedia> Attachments(JsonObject note) =>
ObjectShapes.Objects(note["attachment"])
.Where(a => Value(a, "type") is "Document" or "Image" or "Video" or "Audio")
.Select(a => new PostMedia
{
RemoteURL = UrlOf(a["url"]),
ContentType = Value(a, "mediaType"),
Description = Plain(Value(a, "name") ?? Value(a, "summary"), 1500),
Blurhash = Value(a, "blurhash"),
Width = Int(a["width"]),
Height = Int(a["height"]),
Focus = a["focalPoint"] is JsonArray focus && focus.Count == 2
? focus.Select(f => f is JsonValue v && v.TryGetValue<double>(out var d) ? (float)Math.Clamp(d, -1, 1) : 0f).ToArray()
: default
})
.Select(Attachment)
.Where(m => Origin.Of(m.RemoteURL) != default)
.Take(MaxAttachments)
.ToList();
static PostMedia Attachment(JsonObject attachment)
{
var (url, linkMediaType) = ObjectShapes.MediaLink(attachment["url"] ?? attachment["href"]);
return new PostMedia
{
RemoteURL = url,
ContentType = ObjectShapes.InferMediaType(Value(attachment, "mediaType") ?? linkMediaType, url, Value(attachment, "type")),
RemotePreviewURL = ObjectShapes.Image(attachment["icon"]),
DurationSeconds = ObjectShapes.Seconds(attachment["duration"]),
Sensitive = attachment["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) ? marked : default(bool?),
Description = Plain(Value(attachment, "name") ?? Value(attachment, "summary") ?? FirstOf(attachment["nameMap"]) ?? FirstOf(attachment["summaryMap"]), 1500),
Blurhash = Value(attachment, "blurhash"),
Width = Int(attachment["width"]),
Height = Int(attachment["height"]),
Focus = attachment["focalPoint"] is JsonArray focus && focus.Count == 2
? focus.Select(f => f is JsonValue v && v.TryGetValue<double>(out var d) ? (float)Math.Clamp(d, -1, 1) : 0f).ToArray()
: default
};
}
static string FirstOf(JsonNode map) =>
map is JsonObject languages ? languages.Select(l => l.Value is JsonValue v && v.TryGetValue<string>(out var text) ? text : default).FirstOrDefault(t => t != default) : default;
static string UrlOf(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
+293
View File
@@ -0,0 +1,293 @@
using PrivaPub.Models.Post;
using System.Globalization;
using System.Net;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Xml;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Objects
{
public static partial class ObjectShapes
{
const int MaxVariants = 20;
const int MaxCaptions = 20;
const int MaxPlaces = 4;
const int CoverMaxWidth = 1280;
static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
[".avif"] = "image/avif", [".heic"] = "image/heic", [".svg"] = "image/svg+xml",
[".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".webm"] = "video/webm", [".mov"] = "video/quicktime", [".mkv"] = "video/x-matroska",
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/opus", [".m4a"] = "audio/mp4",
[".flac"] = "audio/flac", [".wav"] = "audio/wav", [".m3u8"] = "application/x-mpegURL"
};
public static IEnumerable<JsonObject> Objects(JsonNode node) => node switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
};
public static (string Url, string MediaType) MediaLink(JsonNode node)
{
switch (node)
{
case JsonValue value when value.TryGetValue<string>(out var text):
return (text, default);
case JsonObject link when Value(link, "href") is { } href:
return (href, Value(link, "mediaType"));
case JsonObject wrapper when wrapper["url"] is { } inner:
var (url, mediaType) = MediaLink(inner);
return (url, mediaType ?? Value(wrapper, "mediaType"));
case JsonArray array:
var links = array.Select(MediaLink).Where(l => l.Url != default).ToList();
return links.FirstOrDefault(l => l.MediaType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) != true);
default:
return default;
}
}
public static string InferMediaType(string declared, string url, string objectType)
{
if (!string.IsNullOrWhiteSpace(declared) && declared.Contains('/'))
return declared.Trim();
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && MediaTypesByExtension.TryGetValue(Path.GetExtension(uri.AbsolutePath), out var byExtension))
return byExtension;
return objectType switch
{
"Image" => "image/*",
"Video" => "video/*",
"Audio" => "audio/*",
_ => default
};
}
public static string Image(JsonNode node)
{
var candidates = node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => new[] { (Url: text, Width: (int?)default) },
JsonArray array => array.Select(item => (Url: MediaLink(item).Url, Width: Int(item as JsonObject, "width"))).ToArray(),
JsonObject single => new[] { (Url: MediaLink(single).Url, Width: Int(single, "width")) },
_ => Array.Empty<(string Url, int? Width)>()
};
var usable = candidates.Where(c => Origin.Of(c.Url) != default).ToList();
return usable.Where(c => c.Width <= CoverMaxWidth).OrderByDescending(c => c.Width).Select(c => c.Url).FirstOrDefault()
?? usable.Select(c => c.Url).FirstOrDefault();
}
public static string Cover(JsonObject note) =>
Image(note["image"]) ?? Image(note["icon"]) ?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Image" ? Image(preview) : default);
public static double? Seconds(JsonNode node)
{
switch (node)
{
case JsonValue value when value.TryGetValue<double>(out var number):
return number >= 0 && number < 1e7 ? number : default;
case JsonValue value when value.TryGetValue<string>(out var text) && !string.IsNullOrWhiteSpace(text):
try
{
var span = XmlConvert.ToTimeSpan(text.Trim());
return span >= TimeSpan.Zero ? span.TotalSeconds : default;
}
catch (FormatException)
{
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0 ? seconds : default;
}
default:
return default;
}
}
public static string Text(string html, int max)
{
if (string.IsNullOrWhiteSpace(html))
return default;
var text = WebUtility.HtmlDecode(Tags().Replace(html, " "));
text = Spaces().Replace(text, " ").Trim();
if (text.Length == 0)
return default;
return text.Length <= max ? text : text[..max];
}
public static string Language(JsonObject note) =>
note["@context"] is JsonArray context
? context.OfType<JsonObject>().Select(entry => Value(entry, "@language")).FirstOrDefault(language => !string.IsNullOrWhiteSpace(language))
: default;
public static PostSource Source(JsonObject note) => note["source"] is JsonObject source && Value(source, "content") is { } content
? new PostSource { Content = content.Length <= 64 * 1024 ? content : content[..(64 * 1024)], MediaType = Value(source, "mediaType") }
: default;
public static PostLink Link(JsonObject note)
{
var attached = Objects(note["attachment"]).FirstOrDefault(a => Value(a, "type") == "Link" && IsWeb(Value(a, "href")));
if (attached != default)
{
var preview = attached["preview"] as JsonObject;
return new PostLink
{
Href = Value(attached, "href"),
MediaType = Value(attached, "mediaType"),
Title = Text(Value(preview, "name") ?? Value(attached, "name"), 300),
Description = Text(Value(preview, "summary") ?? Value(preview, "content"), 1000),
ImageURL = preview == default ? Image(note["image"]) : Image(preview["image"]) ?? Image(preview["icon"]),
FromPublisher = preview != default
};
}
if (note["source"] is JsonValue source && source.TryGetValue<string>(out var sourceUrl) && IsWeb(sourceUrl))
return new PostLink { Href = sourceUrl, ImageURL = Image(note["image"]) };
return default;
}
public static VideoDetails Video(JsonObject note)
{
if (Value(note, "type") != "Video")
return default;
var links = Objects(note["url"]).ToList();
return new VideoDetails
{
DurationSeconds = Seconds(note["duration"]),
Variants = Variants(links, "video/", "audio/"),
PlaylistURL = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
.Select(l => Value(l, "href")).FirstOrDefault(IsWeb),
IsLive = Bool(note, "isLiveBroadcast") == true,
State = Int(note, "state"),
Views = Long(note, "views"),
CommentsPolicy = Int(note, "commentsPolicy"),
DownloadEnabled = Bool(note, "downloadEnabled"),
Licence = Named(note["licence"] ?? note["license"]),
Category = Named(note["category"]),
ChannelURI = Objects(note["attributedTo"]).Where(a => Value(a, "type") == "Group").Select(a => Id(a)).FirstOrDefault(),
EmbedURL = Value(note, "embedUrl") is { } embed && IsWeb(embed) ? embed : default,
ChaptersURL = Value(note, "hasParts") is { } chapters && IsWeb(chapters) ? chapters : default,
Captions = Objects(note["subtitleLanguage"])
.Select(c => new Caption
{
Language = Value(c, "identifier"),
Url = Objects(c["url"]).Where(u => Value(u, "mediaType") == "text/vtt").Select(u => Value(u, "href")).FirstOrDefault(IsWeb)
?? (Value(c, "url") is { } plain && IsWeb(plain) ? plain : default)
})
.Where(c => c.Url != default)
.Take(MaxCaptions)
.ToList()
};
}
public static AudioDetails Audio(JsonObject note)
{
if (Value(note, "type") != "Audio")
return default;
return new AudioDetails
{
DurationSeconds = Seconds(note["duration"]),
Variants = Variants(Objects(note["url"]).ToList(), "audio/"),
Album = Named(note["album"]),
Position = Int(note, "position"),
Licence = Named(note["license"] ?? note["licence"])
};
}
public static EventDetails Event(JsonObject note)
{
if (Value(note, "type") != "Event")
return default;
var start = Value(note, "startTime");
var places = Objects(note["location"]).Select(Place).Where(p => p != default).Take(MaxPlaces).ToList();
return new EventDetails
{
Start = Moment(start),
End = Moment(Value(note, "endTime")),
TimeZone = Value(note, "timezone"),
FloatingTime = start?.EndsWith("-00:00", StringComparison.Ordinal) == true,
Places = places,
OnlineURL = places.Select(p => p.Url).FirstOrDefault(u => u != default)
?? Objects(note["attachment"]).Where(a => Value(a, "type") == "Link" && Value(a, "name") == "Website").Select(a => Value(a, "href")).FirstOrDefault(IsWeb),
JoinMode = Value(note, "joinMode"),
ParticipantCount = Int(note, "participantCount"),
Capacity = Int(note, "maximumAttendeeCapacity"),
Status = Value(note, "ical:status") ?? Value(note, "eventStatus") ?? Value(note, "status"),
Category = Named(note["category"])
};
}
static EventPlace Place(JsonObject location)
{
var type = Value(location, "type");
if (type == "VirtualLocation")
return Value(location, "url") is { } url && IsWeb(url) ? new EventPlace { Name = Text(Value(location, "name"), 200), Url = url } : default;
var address = location["address"] switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
JsonObject postal => string.Join(", ", new[] { "streetAddress", "postalCode", "addressLocality", "addressRegion", "addressCountry" }
.Select(field => Value(postal, field)).Where(part => !string.IsNullOrWhiteSpace(part))),
_ => default
};
var place = new EventPlace
{
Name = Text(Value(location, "name") ?? Value(location, "content"), 200),
Address = Text(address, 300),
Latitude = Double(location, "latitude"),
Longitude = Double(location, "longitude")
};
return place.Name == default && place.Address == default && place.Latitude == default ? default : place;
}
static List<MediaVariant> Variants(List<JsonObject> links, params string[] prefixes) =>
links.Where(l => Value(l, "mediaType") is { } type && prefixes.Any(p => type.StartsWith(p, StringComparison.OrdinalIgnoreCase)) && IsWeb(Value(l, "href")))
.Select(l => new MediaVariant
{
Url = Value(l, "href"),
MediaType = Value(l, "mediaType"),
Width = Int(l, "width"),
Height = Int(l, "height"),
Fps = Double(l, "fps"),
Size = Long(l, "size"),
Streams = Objects(l["attachment"])
.Where(p => Value(p, "name") == "ffprobe_codec_type")
.Select(p => Value(p, "value"))
.Where(v => v is "audio" or "video")
.Distinct()
.ToList()
})
.Take(MaxVariants)
.ToList();
static string Named(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => Text(text, 200),
JsonObject named => Text(Value(named, "name") ?? Value(named, "identifier"), 200),
_ => default
};
static DateTime? Moment(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed.UtcDateTime : (DateTime?)null;
static bool IsWeb(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp);
static int? Int(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && number is >= 0 and < int.MaxValue ? (int)number : (int?)null;
static long? Long(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && number is >= 0 and < long.MaxValue ? (long)number : (long?)null;
static double? Double(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && double.IsFinite(number) ? number : (double?)null;
static bool? Bool(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<bool>(out var flag) ? flag : (bool?)null;
[GeneratedRegex("<[^>]*>")]
private static partial Regex Tags();
[GeneratedRegex(@"\s+")]
private static partial Regex Spaces();
}
}