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:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user