Skip to content

Commit 0afb01c

Browse files
committed
Resolves Modrinth mod updates through the platform update endpoint
Update paths were derived by downloading full metadata for every version every project in an entity had ever published: 94 requests and about 100 MB of JSON for a 105 mod instance, repeated daily per install and a regular source of 429 responses. Modrinth answers that question directly. Given the file hashes plus the game version and loaders an entity runs, it returns the newest matching version per hash. Each release channel is asked separately, because a mod whose newest build is a beta may still have a newer stable one than the installed file and which of them counts as an update is the user's choice. That is three requests per batch of mods; measured against a real profile, seven instances drop from 265 requests to 21. The channel filter is absent from Modrinth's published schema for this route but honoured by it, so a test pins the behaviour. The paths were also collected from a list holding every project's versions ordered by publication date, with the walk terminating at the first entry belonging to a different project. That ended it before anything was collected for all but the project owning the newest version of the whole set, leaving 430 of 431 cached mods with no update path. Candidates are now filtered to the project and to versions published after the installed file. The stored format is unchanged.
1 parent 95ee728 commit 0afb01c

4 files changed

Lines changed: 440 additions & 66 deletions

File tree

crates/carbon_app/src/managers/instance/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3255,6 +3255,13 @@ pub struct InstanceData {
32553255
icon_revision: Option<u32>,
32563256
}
32573257

3258+
impl InstanceData {
3259+
/// The game version this instance is configured to launch, if it has one.
3260+
pub fn game_version(&self) -> Option<&GameVersion> {
3261+
self.config.game_configuration.version.as_ref()
3262+
}
3263+
}
3264+
32583265
#[derive(Debug, Clone)]
32593266
pub struct Mod {
32603267
id: String,

crates/carbon_app/src/managers/metadata/cache/modrinth/mod.rs

Lines changed: 228 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
use super::{BundleSender, CacheEntityId, ModplatformCacher, UpdateNotifier};
22
use crate::domain::instance::InstanceId;
3-
use crate::domain::instance::info::ModLoaderType;
3+
use crate::domain::instance::info::{GameVersion, ModLoaderType};
44
use crate::managers::App;
5+
use crate::managers::instance::InstanceType;
56
use anyhow::anyhow;
67
use carbon_platforms::ModChannel;
7-
use carbon_platforms::modrinth::search::VersionIDs;
88
use carbon_platforms::modrinth::version::Version;
99
use carbon_platforms::modrinth::{
1010
project::Project,
1111
responses::{ProjectsResponse, TeamResponse, VersionHashesResponse},
1212
search::{ProjectIDs, TeamIDs, VersionHashesQuery},
13-
version::HashAlgorithm,
13+
version::{HashAlgorithm, LatestVersionsBody, VersionType},
1414
};
1515
use carbon_repos::db::read_filters::{DateTimeFilter, IntFilter, StringFilter};
1616
use carbon_repos::db::{
1717
mod_file_cache as fcdb, mod_metadata as metadb, modrinth_mod_cache as mrdb,
18-
modrinth_mod_image_cache as mrimgdb, server_mod_file_cache as sfcdb,
18+
modrinth_mod_image_cache as mrimgdb, server as serverdb, server_mod_file_cache as sfcdb,
1919
};
2020
use itertools::Itertools;
2121
use std::collections::{HashMap, HashSet, VecDeque};
@@ -24,6 +24,93 @@ use tracing::{debug, error, trace, warn};
2424

2525
pub mod modpack;
2626

27+
/// The game version and loaders an entity runs, as Modrinth spells them.
28+
///
29+
/// Update paths are only ever read filtered by this pair, so it is also the only
30+
/// filter worth asking the API about. `None` when the entity has no usable
31+
/// version yet, in which case update paths are left alone.
32+
async fn target_compatibility(
33+
app: &App,
34+
entity_id: CacheEntityId,
35+
) -> Option<(Vec<String>, Vec<String>)> {
36+
match entity_id {
37+
CacheEntityId::Instance(instance_id) => {
38+
let instance_manager = app.instance_manager();
39+
let instances = instance_manager.instances.read().await;
40+
let instance = instances.get(&instance_id)?;
41+
42+
let InstanceType::Valid(data) = &instance.type_ else {
43+
return None;
44+
};
45+
46+
let Some(GameVersion::Standard(version)) = data.game_version() else {
47+
return None;
48+
};
49+
50+
let loaders = version
51+
.modloaders
52+
.iter()
53+
.map(|loader| loader.type_.to_string().to_lowercase())
54+
.collect::<Vec<_>>();
55+
56+
(!loaders.is_empty()).then(|| (vec![version.release.clone()], loaders))
57+
}
58+
CacheEntityId::Server(server_id) => {
59+
let server = app
60+
.prisma_client
61+
.server()
62+
.find_unique(serverdb::UniqueWhereParam::IdEquals(server_id))
63+
.exec()
64+
.await
65+
.ok()??;
66+
67+
let loader = server.modloader_type?;
68+
69+
Some((vec![server.game_version], vec![loader.to_lowercase()]))
70+
}
71+
}
72+
}
73+
74+
/// The `gamever,loader,channel` triples an installed file can be updated along,
75+
/// in the `;`-separated form the cache stores and the mod list parses back.
76+
///
77+
/// `candidates` are the versions the platform reported as compatible with what
78+
/// the entity runs. A candidate only describes an update when it belongs to the
79+
/// same project and was published after the installed file: the newest version
80+
/// for this entity's game version can predate a file installed for another one.
81+
fn build_update_paths(installed: &Version, project_id: &str, candidates: &[Version]) -> String {
82+
let mut paths = HashSet::<(&str, ModLoaderType, ModChannel)>::new();
83+
84+
let updates = candidates.iter().filter(|candidate| {
85+
candidate.project_id == project_id
86+
&& candidate.id != installed.id
87+
&& candidate.date_published > installed.date_published
88+
});
89+
90+
for update in updates {
91+
for game_version in &update.game_versions {
92+
for loader in &update.loaders {
93+
let Ok(loader) = ModLoaderType::try_from(loader as &str) else {
94+
continue;
95+
};
96+
97+
paths.insert((game_version, loader, update.version_type.into()));
98+
}
99+
}
100+
}
101+
102+
paths
103+
.into_iter()
104+
.map(|(gamever, loader, channel)| {
105+
format!(
106+
"{gamever},{},{}",
107+
loader.to_string().to_lowercase(),
108+
channel.as_str(),
109+
)
110+
})
111+
.join(";")
112+
}
113+
27114
pub struct ModrinthModCacher;
28115

29116
#[async_trait::async_trait]
@@ -131,6 +218,8 @@ impl ModplatformCacher for ModrinthModCacher {
131218

132219
drop(failed_instances);
133220

221+
let target_compat = target_compatibility(app, entity_id).await;
222+
134223
let fut = async {
135224
while !modlist.is_empty() {
136225
let (sha512_hashes, metadata) = modlist
@@ -174,27 +263,42 @@ impl ModplatformCacher for ModrinthModCacher {
174263

175264
let mpm = app.modplatforms_manager();
176265

177-
let combined_versions_list = projects_response
178-
.iter()
179-
.map(|project| &project.versions)
180-
.flatten()
181-
.map(|v| v.clone())
182-
.collect::<Vec<_>>();
266+
// Update paths are only ever read filtered by what the entity runs,
267+
// so let the server pick the newest version for that game version
268+
// and loader. Fetching every version every project ever published
269+
// costs hundreds of requests and answers the same question.
270+
//
271+
// Each channel is asked separately: a mod whose newest build is a
272+
// beta may still have a newer stable one than the installed file,
273+
// and which of them counts as an update is the user's choice.
274+
let combined_versions_response = match &target_compat {
275+
Some((game_versions, loaders)) => {
276+
let mut newest_per_channel = Vec::new();
277+
278+
for version_type in
279+
[VersionType::Release, VersionType::Beta, VersionType::Alpha]
280+
{
281+
mcm.modrinth_throttle.acquire().await;
282+
let latest = mpm
283+
.modrinth
284+
.get_latest_versions_from_hashes(&LatestVersionsBody {
285+
hashes: sha512_hashes.clone(),
286+
algorithm: HashAlgorithm::SHA512,
287+
loaders: loaders.clone(),
288+
game_versions: game_versions.clone(),
289+
version_types: Some(vec![version_type]),
290+
})
291+
.await?;
292+
293+
newest_per_channel.extend(latest.0.into_values());
294+
}
183295

184-
let mpm = app.modplatforms_manager();
185-
// Run version-batch requests sequentially so each one passes
186-
// through the throttle. join_all here would race past it.
187-
let mut combined_versions_response = Vec::new();
188-
for chunk in combined_versions_list.chunks(350) {
189-
mcm.modrinth_throttle.acquire().await;
190-
let resp = mpm
191-
.modrinth
192-
.get_versions(VersionIDs {
193-
ids: chunk.to_vec(),
194-
})
195-
.await?;
196-
combined_versions_response.extend(resp.0);
197-
}
296+
newest_per_channel
297+
}
298+
// Without a known game version and loader there is nothing to
299+
// ask for; mods are still cached, only update paths are skipped.
300+
None => Vec::new(),
301+
};
198302

199303
sender.send((
200304
sha512_hashes,
@@ -487,47 +591,7 @@ async fn cache_modrinth_meta_unchecked(
487591
authors: String,
488592
versions: &[Version],
489593
) -> anyhow::Result<()> {
490-
let mut file_update_paths = HashSet::<(&str, ModLoaderType, ModChannel)>::new();
491-
492-
let mut versions_sorted = versions.iter().collect::<Vec<_>>();
493-
versions_sorted.sort_by(|f1, f2| Ord::cmp(&f2.date_published, &f1.date_published));
494-
495-
for other_version in versions_sorted {
496-
if other_version.project_id != project.id
497-
|| other_version.id == version.id
498-
|| !version
499-
.game_versions
500-
.iter()
501-
.any(|v| other_version.game_versions.contains(v))
502-
|| !version
503-
.loaders
504-
.iter()
505-
.any(|l| other_version.loaders.contains(l))
506-
{
507-
break;
508-
}
509-
510-
for game_version in &other_version.game_versions {
511-
for loader in &other_version.loaders {
512-
let Ok(loader) = ModLoaderType::try_from(loader as &str) else {
513-
continue;
514-
};
515-
516-
file_update_paths.insert((game_version, loader, other_version.version_type.into()));
517-
}
518-
}
519-
}
520-
521-
let update_paths = file_update_paths
522-
.into_iter()
523-
.map(|(gamever, loader, channel)| {
524-
format!(
525-
"{gamever},{},{}",
526-
loader.to_string().to_lowercase(),
527-
channel.as_str(),
528-
)
529-
})
530-
.join(";");
594+
let update_paths = build_update_paths(version, &project.id, versions);
531595

532596
if let Ok(Some(existing_entry)) = app
533597
.prisma_client
@@ -618,3 +682,102 @@ async fn cache_modrinth_meta_unchecked(
618682

619683
Ok(())
620684
}
685+
686+
#[cfg(test)]
687+
mod test {
688+
use super::*;
689+
use carbon_platforms::modrinth::UtcDateTime;
690+
use carbon_platforms::modrinth::version::VersionType;
691+
692+
fn version(id: &str, project: &str, day: u32, version_type: VersionType) -> Version {
693+
Version {
694+
name: id.to_string(),
695+
version_number: id.to_string(),
696+
changelog: None,
697+
dependencies: Vec::new(),
698+
game_versions: vec!["1.20.1".to_string()],
699+
version_type,
700+
loaders: vec!["forge".to_string()],
701+
featured: false,
702+
status: None,
703+
requested_status: None,
704+
id: id.to_string(),
705+
project_id: project.to_string(),
706+
author_id: "author".to_string(),
707+
date_published: format!("2026-01-{day:02}T00:00:00Z")
708+
.parse::<UtcDateTime>()
709+
.unwrap(),
710+
downloads: 0,
711+
files: Vec::new(),
712+
}
713+
}
714+
715+
#[test]
716+
fn no_update_paths_without_candidates() {
717+
let installed = version("installed", "project", 10, VersionType::Release);
718+
719+
assert_eq!(build_update_paths(&installed, "project", &[]), "");
720+
}
721+
722+
#[test]
723+
fn the_installed_version_is_not_an_update_of_itself() {
724+
let installed = version("installed", "project", 10, VersionType::Release);
725+
726+
assert_eq!(
727+
build_update_paths(&installed, "project", &[installed.clone()]),
728+
""
729+
);
730+
}
731+
732+
#[test]
733+
fn only_versions_published_after_the_installed_one_are_updates() {
734+
let installed = version("installed", "project", 10, VersionType::Release);
735+
let older = version("older", "project", 5, VersionType::Release);
736+
let newer = version("newer", "project", 20, VersionType::Release);
737+
738+
assert_eq!(build_update_paths(&installed, "project", &[older]), "");
739+
assert_eq!(
740+
build_update_paths(&installed, "project", &[newer]),
741+
"1.20.1,forge,stable"
742+
);
743+
}
744+
745+
/// Every project's versions used to be walked as one list, which collected
746+
/// nothing as soon as another project's version came first.
747+
#[test]
748+
fn versions_of_other_projects_are_ignored_without_hiding_later_ones() {
749+
let installed = version("installed", "project", 10, VersionType::Release);
750+
// Published later than the real update and distinguishable, so including
751+
// it would both hide the update and show up in the result.
752+
let mut foreign = version("foreign", "other-project", 30, VersionType::Release);
753+
foreign.game_versions = vec!["1.19.2".to_string()];
754+
let newer = version("newer", "project", 20, VersionType::Release);
755+
756+
assert_eq!(
757+
build_update_paths(&installed, "project", &[foreign, newer]),
758+
"1.20.1,forge,stable"
759+
);
760+
}
761+
762+
#[test]
763+
fn each_channel_is_reported_so_the_allowed_one_can_be_picked() {
764+
let installed = version("installed", "project", 10, VersionType::Release);
765+
let stable = version("stable", "project", 20, VersionType::Release);
766+
let beta = version("beta", "project", 30, VersionType::Beta);
767+
768+
let paths = build_update_paths(&installed, "project", &[stable, beta]);
769+
let mut paths = paths.split(';').collect::<Vec<_>>();
770+
paths.sort();
771+
772+
assert_eq!(paths, vec!["1.20.1,forge,beta", "1.20.1,forge,stable"]);
773+
}
774+
775+
#[test]
776+
fn unknown_loaders_are_skipped() {
777+
let installed = version("installed", "project", 10, VersionType::Release);
778+
let mut newer = version("newer", "project", 20, VersionType::Release);
779+
newer.loaders = vec!["not-a-loader".to_string()];
780+
781+
assert_eq!(build_update_paths(&installed, "project", &[newer]), "");
782+
}
783+
}

0 commit comments

Comments
 (0)