From 35ed4a333eba47d96121327f4c84ed8f8da90ae9 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 16:05:32 +0100 Subject: [PATCH] feat: enrich embedded account stats in timeline responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phanpy never calls /accounts/:id for timeline authors — it trusts the embedded account object in each status. These showed 0 counts because timeline author data doesn't include follower stats. Fix: after serializing statuses, batch-resolve unique authors that have 0 counts via Fedify AP collection fetch (5 concurrent). Results are cached (1h TTL) so subsequent page loads are instant. Applied to all three timeline endpoints (home, public, hashtag). --- lib/mastodon/helpers/enrich-accounts.js | 97 +++++++++++++++++++++++++ lib/mastodon/routes/timelines.js | 29 +++++++- package.json | 2 +- 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 lib/mastodon/helpers/enrich-accounts.js diff --git a/lib/mastodon/helpers/enrich-accounts.js b/lib/mastodon/helpers/enrich-accounts.js new file mode 100644 index 0000000..9ecc04e --- /dev/null +++ b/lib/mastodon/helpers/enrich-accounts.js @@ -0,0 +1,97 @@ +/** + * Enrich embedded account objects in serialized statuses with real + * follower/following/post counts from remote AP collections. + * + * Phanpy (and some other clients) never call /accounts/:id — they + * trust the account object embedded in each status. Without enrichment, + * these show 0/0/0 for all remote accounts. + * + * Uses the account stats cache to avoid redundant fetches. Only resolves + * unique authors with 0 counts that aren't already cached. + */ +import { getCachedAccountStats } from "./account-cache.js"; +import { resolveRemoteAccount } from "./resolve-account.js"; + +/** + * Enrich account objects in a list of serialized statuses. + * Resolves unique authors in parallel (max 5 concurrent). + * + * @param {Array} statuses - Serialized Mastodon Status objects (mutated in place) + * @param {object} pluginOptions - Plugin options with federation context + * @param {string} baseUrl - Server base URL + */ +export async function enrichAccountStats(statuses, pluginOptions, baseUrl) { + if (!statuses?.length || !pluginOptions?.federation) return; + + // Collect unique author URLs that need enrichment + const accountsToEnrich = new Map(); // url -> [account references] + for (const status of statuses) { + collectAccount(status.account, accountsToEnrich); + if (status.reblog?.account) { + collectAccount(status.reblog.account, accountsToEnrich); + } + } + + if (accountsToEnrich.size === 0) return; + + // Resolve in parallel with concurrency limit + const entries = [...accountsToEnrich.entries()]; + const CONCURRENCY = 5; + for (let i = 0; i < entries.length; i += CONCURRENCY) { + const batch = entries.slice(i, i + CONCURRENCY); + await Promise.all( + batch.map(async ([url, accounts]) => { + try { + const resolved = await resolveRemoteAccount(url, pluginOptions, baseUrl); + if (resolved) { + for (const account of accounts) { + account.followers_count = resolved.followers_count; + account.following_count = resolved.following_count; + account.statuses_count = resolved.statuses_count; + if (resolved.created_at && account.created_at) { + account.created_at = resolved.created_at; + } + if (resolved.note) account.note = resolved.note; + if (resolved.fields?.length) account.fields = resolved.fields; + if (resolved.avatar && resolved.avatar !== account.avatar) { + account.avatar = resolved.avatar; + account.avatar_static = resolved.avatar; + } + if (resolved.header) { + account.header = resolved.header; + account.header_static = resolved.header; + } + } + } + } catch { + // Silently skip failed resolutions + } + }), + ); + } +} + +/** + * Collect an account reference for enrichment if it has 0 counts + * and isn't already cached. + */ +function collectAccount(account, map) { + if (!account?.url) return; + if (account.followers_count > 0 || account.statuses_count > 0) return; + + // Check cache first — if cached, apply immediately + const cached = getCachedAccountStats(account.url); + if (cached) { + account.followers_count = cached.followersCount || 0; + account.following_count = cached.followingCount || 0; + account.statuses_count = cached.statusesCount || 0; + if (cached.createdAt) account.created_at = cached.createdAt; + return; + } + + // Queue for remote resolution + if (!map.has(account.url)) { + map.set(account.url, []); + } + map.get(account.url).push(account); +} diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 4a4763e..5e628e5 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -9,6 +9,7 @@ import express from "express"; import { serializeStatus } from "../entities/status.js"; import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js"; import { loadModerationData, applyModerationFilters } from "../../item-processing.js"; +import { enrichAccountStats } from "../helpers/enrich-accounts.js"; const router = express.Router(); // eslint-disable-line new-cap @@ -76,6 +77,11 @@ router.get("/api/v1/timelines/home", async (req, res, next) => { }), ); + // Enrich embedded account objects with real follower/following/post counts. + // Phanpy never calls /accounts/:id — it trusts embedded account data. + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + await enrichAccountStats(statuses, pluginOptions, baseUrl); + // Set pagination Link headers setPaginationHeaders(res, req, items, limit); @@ -99,7 +105,22 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { visibility: "public", }; - // Only original posts (exclude boosts from public timeline unless local=true) + // Local timeline: only posts from the local instance author + if (req.query.local === "true") { + const profile = await collections.ap_profile.findOne({}); + if (profile?.url) { + baseFilter["author.url"] = profile.url; + } + } + + // Remote-only: exclude local author posts + if (req.query.remote === "true") { + const profile = await collections.ap_profile.findOne({}); + if (profile?.url) { + baseFilter["author.url"] = { $ne: profile.url }; + } + } + if (req.query.only_media === "true") { baseFilter.$or = [ { "photo.0": { $exists: true } }, @@ -155,6 +176,9 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { }), ); + const pluginOpts = req.app.locals.mastodonPluginOptions || {}; + await enrichAccountStats(statuses, pluginOpts, baseUrl); + setPaginationHeaders(res, req, items, limit); res.json(statuses); } catch (error) { @@ -215,6 +239,9 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { }), ); + const pluginOpts = req.app.locals.mastodonPluginOptions || {}; + await enrichAccountStats(statuses, pluginOpts, baseUrl); + setPaginationHeaders(res, req, items, limit); res.json(statuses); } catch (error) { diff --git a/package.json b/package.json index ec9a191..88b9754 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.6.8", + "version": "3.6.9", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit",