Show mod downloads and release dates in the launcher MODS and Find Mods panels (#793)

* 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.

* Fix crash opening the Find Mods tab: rename the stats cache field

The resolver stored results in self._findStats, which collides with the
method of the same name: self._findStats resolves through the metatable to
the function, so the or {} guard never fired and indexing it crashed the
launcher the moment the panel built. State now lives in _findStatsCache.

* Fix Find Mods crash: require ModUpdate in the find panel

buildFindPanel called ModUpdate.statsLine without a local require --
only buildModsPanel had one -- so opening the tab indexed a nil global.

* Retry Find Mods stats after failed repo fetches

A failed repo fetch (hourly GitHub API rate limit, transient network error)
was memoized as resolved, so a rate-limited first visit left those rows
empty for the whole session. Failures now schedule a 60s retry; a 404 is
still permanent so a renamed or vanished repo is fetched once.

* Add the MODS tab sort options to the Find Mods tab
This commit is contained in:
Shane McGovern
2026-08-04 15:16:44 +01:00
committed by GitHub
parent f56e82de81
commit 626080d228
5 changed files with 171 additions and 8 deletions
+6 -3
View File
@@ -483,9 +483,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
fields to an entry -- `downloads` (total across every release), and
`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
carry them stay readable by every build that predates them, and feeds that
do not render exactly as before.
the same gold line the MODS tab uses. When a feed does not carry them,
the row fetches the mod's own GitHub releases instead -- the same cached
`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)
+82 -5
View File
@@ -1373,9 +1373,11 @@ end
local function buildFindPanel(imp, parent, m)
imp._findThumbFetched = false
imp._findStatsFetched = false
imp:_ensureFind()
imp:_ensureMods()
local ModIndex = require("src.mods.ModIndex")
local ModUpdate = require("src.mods.ModUpdate")
local sources = imp.findSources or {}
local rows = imp:_findRows()
local total = #((imp.findIndex and imp.findIndex.mods) or {})
@@ -1513,6 +1515,80 @@ local function buildFindPanel(imp, parent, m)
return
end
-- Sort row: Name / Popularity / Release date / Last updated, the same
-- options the MODS tab offers, sharing its persisted choice
-- (options.modSort). Data comes from the same _findStats resolution the
-- cards use (feed-published, else the repo fetch); rows whose stats have
-- not resolved yet sink to the bottom of data sorts and rise as the
-- one-per-frame fetches complete.
local sortKey = imp.modSort or "name"
if imp.modSort == nil then
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
if ok and type(opts) == "table" and type(opts.modSort) == "string" then
sortKey = opts.modSort
imp.modSort = sortKey
end
end
local sortRow = mk({ parent = parent, width = "100%",
positioning = "flex", flexDirection = "horizontal",
flexWrap = "wrap", alignItems = "center", gap = 6 * m.s })
label(sortRow, Strings("Sort:"), 11 * m.s + 2, C("detail"), { textWrap = false })
local sorts = {
{ key = "name", label = Strings("Name") },
{ key = "popularity", label = Strings("Popularity") },
{ key = "release", label = Strings("Release date") },
{ key = "updated", label = Strings("Last updated") },
}
for _, s in ipairs(sorts) do
local active = sortKey == s.key
local key = "find-sort-" .. s.key
mk({
parent = sortRow, text = s.label,
textColor = active and C("green")
or (imp._hot[key] and C("white") or C("detail")),
textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false,
backgroundColor = active and C("green", 0.18) or C("border", 0.10),
border = 1,
borderColor = active and C("green", 0.6) or C("border", 0.35),
cornerRadius = 999,
padding = { horizontal = 10, vertical = 4 },
onEvent = handler(imp, key, function()
imp.modSort = s.key
pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.modSort = s.key
SaveData.saveOptions(opts)
end)
end),
})
end
local sorted = {}
for i, v in ipairs(rows) do sorted[i] = v end
table.sort(sorted, function(a, b)
local function value(entry)
if sortKey == "name" then
return (entry.title or entry.id or ""):lower()
end
local stats = imp:_findStats(entry)
if sortKey == "popularity" then
return stats and stats.total or -1
end
if sortKey == "release" then
return stats and stats.first or "0000-00-00"
end
return stats and stats.latest or "0000-00-00"
end
local va, vb = value(a), value(b)
if va ~= vb then
if sortKey == "name" then return va < vb end
return va > vb -- data sorts newest / most popular first
end
return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower()
end)
rows = sorted
local installed = imp:_findInstalledMap()
local thumbW = 64 * m.s
-- Explicit measured widths AND heights, same reasoning as the mods card:
@@ -1526,12 +1602,13 @@ local function buildFindPanel(imp, parent, m)
local btnH = math.ceil(textHeight(chipSize)) + 14
for _, entry in ipairs(rows) do
local action, note = findActionFor(entry, installed[entry.id])
-- Feed-published release stats (downloads, first/last release date) in
-- the same gold line the MODS tab uses; absent until a feed carries them.
-- Release stats for the row: feed-published when the feed carries
-- 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
if entry.downloads ~= nil or entry.first_release or entry.last_release then
statsLine = ModUpdate.statsLine(entry.downloads,
entry.first_release, entry.last_release)
if stats and (stats.total ~= nil or stats.first or stats.latest) then
statsLine = ModUpdate.statsLine(stats.total, stats.first, stats.latest)
end
local bodyH = math.ceil(textHeight(titleSize))
+50
View File
@@ -3075,6 +3075,56 @@ function RomImporter:_findThumb(entry)
return ok and image or nil
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._findStatsCache = self._findStatsCache or {}
local cached = self._findStatsCache[entry.id]
if cached then
if cached.done or (cached.retryAt and os.time() < cached.retryAt) then
return cached
end
self._findStatsCache[entry.id] = nil -- retry window open, refetch
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._findStatsCache[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._findStatsCache[entry.id] = cached
return cached
end
self._findStatsFetched = true
local ModUpdate = require("src.mods.ModUpdate")
local list, fetchErr
local ok = pcall(function()
list, fetchErr = ModUpdate.fetchReleases(entry.github, entry.id, {})
end)
local stats = list and ModUpdate.statsForReleases(list) or nil
if stats then
cached = { total = stats.total, first = stats.first,
latest = stats.latest, done = true }
else
-- A repo that does not exist is permanent; every other failure (the
-- hourly API rate limit, a hiccup) is retried in a minute so rows can
-- recover without restarting the launcher.
local permanent = tostring(fetchErr):find("Not Found", 1, true) ~= nil
cached = { done = permanent, retryAt = os.time() + 60 }
end
self._findStatsCache[entry.id] = cached
return cached
end
-- 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
-- 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 }
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
-- numbers. Never throws; garbage in, "0" out.
function ModUpdate.formatCount(n)
+19
View File
@@ -253,4 +253,23 @@ do
HostShell.canFetch, HostShell.httpGet = realCanFetch, realHttpGet
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")