Resolve Find Mods stats from each mod's GitHub repo when the feed lacks them

A FIND MODS row now shows download/date stats even when its feed publishes
none: the row fetches the mod's own GitHub releases through the same
cached ModUpdate.fetchReleases the MODS tab uses (six-hour options cache,
so an installed mod's repo is instant). Feed-published stats still win
when present; otherwise one repo is fetched per frame -- the thumbnail
budget pattern -- so opening the tab never stalls for the whole listing.
ModUpdate.statsForReleases is the shared resolver.
This commit is contained in:
Shane McGovern
2026-08-04 13:34:36 +01:00
parent b692474e24
commit ee5c168983
5 changed files with 84 additions and 8 deletions
+6 -3
View File
@@ -461,9 +461,12 @@ one; paste an index URL or its `owner/repo` and it is remembered in
A feed author can publish per-mod release stats by adding three optional A feed author can publish per-mod release stats by adding three optional
fields to an entry -- `downloads` (total across every release), and fields to an entry -- `downloads` (total across every release), and
`first_release` / `last_release` (ISO days) -- which the listing shows in `first_release` / `last_release` (ISO days) -- which the listing shows in
the same gold line the MODS tab uses. The fields are additive: feeds that the same gold line the MODS tab uses. When a feed does not carry them,
carry them stay readable by every build that predates them, and feeds that the row fetches the mod's own GitHub releases instead -- the same cached
do not render exactly as before. `ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats
appear for any mod with a `github` field regardless of feed maintenance.
The fields are additive: feeds that carry them stay readable by every
build that predates them, and feeds that do not render exactly as before.
## Soft reset (all versions) ## Soft reset (all versions)
+7 -5
View File
@@ -1291,6 +1291,7 @@ end
local function buildFindPanel(imp, parent, m) local function buildFindPanel(imp, parent, m)
imp._findThumbFetched = false imp._findThumbFetched = false
imp._findStatsFetched = false
imp:_ensureFind() imp:_ensureFind()
imp:_ensureMods() imp:_ensureMods()
local ModIndex = require("src.mods.ModIndex") local ModIndex = require("src.mods.ModIndex")
@@ -1437,12 +1438,13 @@ local function buildFindPanel(imp, parent, m)
local btnH = math.ceil(textHeight(chipSize)) + 14 local btnH = math.ceil(textHeight(chipSize)) + 14
for _, entry in ipairs(rows) do for _, entry in ipairs(rows) do
local action, note = findActionFor(entry, installed[entry.id]) local action, note = findActionFor(entry, installed[entry.id])
-- Feed-published release stats (downloads, first/last release date) in -- Release stats for the row: feed-published when the feed carries
-- the same gold line the MODS tab uses; absent until a feed carries them. -- them, otherwise fetched from the mod's GitHub repo (one per frame,
-- cached six hours) exactly like the MODS tab does.
local stats = imp:_findStats(entry)
local statsLine local statsLine
if entry.downloads ~= nil or entry.first_release or entry.last_release then if stats and (stats.total ~= nil or stats.first or stats.latest) then
statsLine = ModUpdate.statsLine(entry.downloads, statsLine = ModUpdate.statsLine(stats.total, stats.first, stats.latest)
entry.first_release, entry.last_release)
end end
local bodyH = math.ceil(textHeight(titleSize)) local bodyH = math.ceil(textHeight(titleSize))
+38
View File
@@ -3025,6 +3025,44 @@ function RomImporter:_findThumb(entry)
return ok and image or nil return ok and image or nil
end end
-- Release stats for a FIND MODS row, resolved the same way the MODS tab
-- does it: the mod's own GitHub releases through ModUpdate's cached fetch,
-- so an installed mod's repo is instant and every result lands in
-- options.modUpdateCache for six hours. A feed that publishes stats wins
-- outright (fresher, zero network); otherwise the repo is fetched, one
-- entry per frame so opening the tab cannot stall for the whole listing.
-- The result is memoized per id for the session; a repo with no releases
-- or a failed fetch resolves to an empty table so it is tried once.
function RomImporter:_findStats(entry)
self._findStats = self._findStats or {}
local cached = self._findStats[entry.id]
if cached then return cached end
if entry.downloads ~= nil or entry.first_release or entry.last_release then
cached = { total = entry.downloads, first = entry.first_release,
latest = entry.last_release, done = true }
self._findStats[entry.id] = cached
return cached
end
if self._findStatsFetched then return nil end -- budget spent this frame
if not entry.github or entry.github == "" then
cached = { done = true }
self._findStats[entry.id] = cached
return cached
end
self._findStatsFetched = true
local ModUpdate = require("src.mods.ModUpdate")
local ok, releases = pcall(function()
local list, err = ModUpdate.fetchReleases(entry.github, entry.id, {})
if not list then error(tostring(err), 0) end
return list
end)
local stats = ok and ModUpdate.statsForReleases(releases) or nil
cached = { total = stats and stats.total, first = stats and stats.first,
latest = stats and stats.latest, done = true }
self._findStats[entry.id] = cached
return cached
end
-- Open the "add an index" text prompt. Deliberately a typed URL rather than a -- Open the "add an index" text prompt. Deliberately a typed URL rather than a
-- picked-from-a-list affair: there is no blessed index, and presenting one -- picked-from-a-list affair: there is no blessed index, and presenting one
-- would make the launcher's choice look like an endorsement. -- would make the launcher's choice look like an endorsement.
+14
View File
@@ -229,6 +229,20 @@ function ModUpdate.releaseDates(releases)
return { first = first, latest = latest } return { first = first, latest = latest }
end end
-- One resolver over a release list: { total, first, latest } or nil when
-- the list carries neither counts nor dates. The FIND MODS rows use this
-- on the repo's fetched releases, the same source the MODS tab trusts.
function ModUpdate.statsForReleases(releases)
local dl = ModUpdate.totalDownloads(releases)
local d = ModUpdate.releaseDates(releases)
if not dl and not d then return nil end
return {
total = dl and dl.total or nil,
first = d and d.first or nil,
latest = d and d.latest or nil,
}
end
-- Thousands-separated count for the launcher ("12,345"), plain for small -- Thousands-separated count for the launcher ("12,345"), plain for small
-- numbers. Never throws; garbage in, "0" out. -- numbers. Never throws; garbage in, "0" out.
function ModUpdate.formatCount(n) function ModUpdate.formatCount(n)
+19
View File
@@ -253,4 +253,23 @@ do
HostShell.canFetch, HostShell.httpGet = realCanFetch, realHttpGet HostShell.canFetch, HostShell.httpGet = realCanFetch, realHttpGet
end end
-- statsForReleases: one resolver over a release list, the FIND MODS path
do
local stats = ModUpdate.statsForReleases({
{ version = "1.0.0", downloads = 41, published = "2024-05-31" },
{ version = "1.1.0", downloads = 9, published = "2025-11-02" },
})
eq(stats.total, 50, "total downloads across releases")
eq(stats.first, "2024-05-31", "first release date")
eq(stats.latest, "2025-11-02", "latest release date")
check(ModUpdate.statsForReleases({ { version = "1.0.0" } }) == nil,
"a list with neither counts nor dates resolves to nil")
check(ModUpdate.statsForReleases(nil) == nil, "nil resolves to nil")
local datesOnly = ModUpdate.statsForReleases({
{ version = "1.0.0", published = "2024-05-31" },
})
eq(datesOnly.total, nil, "dates without counts keep total nil")
eq(datesOnly.first, "2024-05-31", "but keep the date")
end
print("ok mod_update_tests") print("ok mod_update_tests")