feat: enrich embedded account stats in timeline responses

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).
This commit is contained in:
Ricardo
2026-03-21 16:05:32 +01:00
parent fb11a517c0
commit 35ed4a333e
3 changed files with 126 additions and 2 deletions
+97
View File
@@ -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);
}
+28 -1
View File
@@ -9,6 +9,7 @@ import express from "express";
import { serializeStatus } from "../entities/status.js"; import { serializeStatus } from "../entities/status.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js"; import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { loadModerationData, applyModerationFilters } from "../../item-processing.js"; import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
import { enrichAccountStats } from "../helpers/enrich-accounts.js";
const router = express.Router(); // eslint-disable-line new-cap 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 // Set pagination Link headers
setPaginationHeaders(res, req, items, limit); setPaginationHeaders(res, req, items, limit);
@@ -99,7 +105,22 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
visibility: "public", 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") { if (req.query.only_media === "true") {
baseFilter.$or = [ baseFilter.$or = [
{ "photo.0": { $exists: true } }, { "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); setPaginationHeaders(res, req, items, limit);
res.json(statuses); res.json(statuses);
} catch (error) { } 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); setPaginationHeaders(res, req, items, limit);
res.json(statuses); res.json(statuses);
} catch (error) { } catch (error) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "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.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",