Show mod downloads and release dates in the launcher MODS panel

The MODS tab now shows each installed mod's total GitHub downloads
(summed asset download_count across all releases), its first and latest
release dates, and a Sort row (Name / Popularity / Release date /
Last updated) persisted in options.modSort.

The launcher already fetched per-repo release lists for update checks, so
the data rides the same cached fetch: parseRelease keeps download_count
and published_at, writeCache persists them, and a cache entry written
before the fields existed is treated as stale and refetched once instead
of hiding the line behind an old cache.
This commit is contained in:
Shane McGovern
2026-08-03 22:17:56 +01:00
parent 51fb4db82e
commit 6ea59f1a79
5 changed files with 314 additions and 3 deletions
+78 -3
View File
@@ -102,7 +102,9 @@ function ModUpdate.previewLine(text, maxChars)
return s
end
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }.
-- Decode one GitHub release object into { version, tag, zip, prerelease,
-- name, body, downloads }. `downloads` is the sum of every asset's
-- download_count, GitHub's own measure of a release's downloads.
function ModUpdate.parseRelease(doc, modId)
if type(doc) ~= "table" or not doc.tag_name then
return nil, "no tag_name in release"
@@ -114,6 +116,13 @@ function ModUpdate.parseRelease(doc, modId)
local triple = version:match("^(%d+%.%d+%.%d+)")
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
local body = type(doc.body) == "string" and doc.body or ""
local downloads = 0
if type(doc.assets) == "table" then
for _, a in ipairs(doc.assets) do
local d = type(a) == "table" and tonumber(a.download_count)
if d and d > 0 then downloads = downloads + d end
end
end
return {
version = triple,
tag = tostring(doc.tag_name),
@@ -121,6 +130,8 @@ function ModUpdate.parseRelease(doc, modId)
prerelease = doc.prerelease == true,
name = type(doc.name) == "string" and doc.name or triple,
body = body,
downloads = downloads,
published = (doc.published_at or doc.created_at or ""):match("^(%d+%-%d+%-%d+)"),
}
end
@@ -154,7 +165,7 @@ function ModUpdate.parseReleases(jsonText, modId, Json)
end
function ModUpdate.apiReleasesUrl(repo)
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30"
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=100"
end
function ModUpdate.apiLatestUrl(repo)
@@ -180,6 +191,52 @@ function ModUpdate.pickBest(releases)
return releases[1]
end
-- Sum of per-release download counts. Returns nil when no release carries
-- the field (a cache entry written before downloads existed), else
-- { total = <sum>, releases = <release count> } so a caller can tell a
-- real "0 downloads" apart from "no data yet".
function ModUpdate.totalDownloads(releases)
if type(releases) ~= "table" or #releases == 0 then return nil end
local total, hasData = 0, false
for _, rel in ipairs(releases) do
local d = type(rel) == "table" and tonumber(rel.downloads)
if d then
hasData = true
total = total + d
end
end
if not hasData then return nil end
return { total = total, releases = #releases }
end
-- First and latest release dates ("YYYY-MM-DD", ISO strings compare in
-- calendar order). Returns nil when no release carries a date -- same
-- "no data yet" rule as totalDownloads.
function ModUpdate.releaseDates(releases)
if type(releases) ~= "table" or #releases == 0 then return nil end
local first, latest, has = nil, nil, false
for _, rel in ipairs(releases) do
local p = type(rel) == "table" and rel.published
if type(p) == "string" and p ~= "" then
has = true
if not first or p < first then first = p end
if not latest or p > latest then latest = p end
end
end
if not has then return nil end
return { first = first, latest = latest }
end
-- Thousands-separated count for the launcher ("12,345"), plain for small
-- numbers. Never throws; garbage in, "0" out.
function ModUpdate.formatCount(n)
n = tonumber(n)
if not n or n ~= n or n < 0 then return "0" end
local s = tostring(math.floor(n))
s = s:reverse():gsub("(%d%d%d)", "%1,"):reverse()
return (s:gsub("^,", ""))
end
-- ------- cache (options.modUpdateCache[repo])
local function cacheStore()
@@ -210,6 +267,22 @@ function ModUpdate.cacheFresh(entry, now, ttl)
and (now - entry.checkedAt) < ttl
end
-- A cache entry written before download counts existed carries no
-- `downloads` on any release. It is provably stale -- parseRelease always
-- sets the field now -- so treat it as expired: the next fetch rewrites
-- the entry in the current format, one refetch per repo, and the launcher's
-- download line stops hiding behind an old cache.
function ModUpdate.cacheUsable(cached)
if type(cached) ~= "table" or type(cached.releases) ~= "table" then
return false
end
if #cached.releases == 0 then return true end
for _, rel in ipairs(cached.releases) do
if tonumber(rel.downloads) then return true end
end
return false
end
function ModUpdate.writeCache(repo, releases)
if type(repo) ~= "string" or repo == "" then return false end
local ok = pcall(function()
@@ -226,6 +299,8 @@ function ModUpdate.writeCache(repo, releases)
name = rel.name,
prerelease = rel.prerelease == true,
body = type(rel.body) == "string" and rel.body or "",
downloads = tonumber(rel.downloads) or 0,
published = rel.published,
zip = rel.zip and {
name = rel.zip.name,
url = rel.zip.url,
@@ -280,7 +355,7 @@ function ModUpdate.fetchReleases(repo, modId, opts)
end
if not opts.force then
local cached = ModUpdate.readCache(repo)
if ModUpdate.cacheFresh(cached) then
if ModUpdate.cacheFresh(cached) and ModUpdate.cacheUsable(cached) then
return cached.releases, nil, { fromCache = true }
end
end