11use super :: { BundleSender , CacheEntityId , ModplatformCacher , UpdateNotifier } ;
22use crate :: domain:: instance:: InstanceId ;
3- use crate :: domain:: instance:: info:: ModLoaderType ;
3+ use crate :: domain:: instance:: info:: { GameVersion , ModLoaderType } ;
44use crate :: managers:: App ;
5+ use crate :: managers:: instance:: InstanceType ;
56use anyhow:: anyhow;
67use carbon_platforms:: ModChannel ;
7- use carbon_platforms:: modrinth:: search:: VersionIDs ;
88use carbon_platforms:: modrinth:: version:: Version ;
99use 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} ;
1515use carbon_repos:: db:: read_filters:: { DateTimeFilter , IntFilter , StringFilter } ;
1616use 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} ;
2020use itertools:: Itertools ;
2121use std:: collections:: { HashMap , HashSet , VecDeque } ;
@@ -24,6 +24,93 @@ use tracing::{debug, error, trace, warn};
2424
2525pub 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+
27114pub 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