diff --git a/src/main/java/me/itzg/helpers/curseforge/CurseForgeInstaller.java b/src/main/java/me/itzg/helpers/curseforge/CurseForgeInstaller.java index 5cdfffca..ea951212 100644 --- a/src/main/java/me/itzg/helpers/curseforge/CurseForgeInstaller.java +++ b/src/main/java/me/itzg/helpers/curseforge/CurseForgeInstaller.java @@ -13,12 +13,12 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.zip.ZipEntry; @@ -61,6 +61,7 @@ public class CurseForgeInstaller { public static final String CURSEFORGE_ID = "curseforge"; public static final String LEVEL_DAT_SUFFIX = "/level.dat"; public static final int LEVEL_DAT_SUFFIX_LEN = LEVEL_DAT_SUFFIX.length(); + public static final String CATEGORY_SLUG_MODPACKS = "modpacks"; private final Path outputDir; private final Path resultsFile; @@ -108,8 +109,11 @@ public void install(String slug, String fileMatcher, Integer fileId) throws IOEx try (SharedFetch preparedFetch = Fetch.sharedFetch("install-curseforge")) { // TODO encapsulate preparedFetch and uriBuilder to avoid passing deep into call tree + final CategoryInfo categoryInfo = loadCategoryInfo(preparedFetch, uriBuilder); + final ModsSearchResponse searchResponse = preparedFetch.fetch( - uriBuilder.resolve("/mods/search?gameId={gameId}&slug={slug}", MINECRAFT_GAME_ID, slug) + uriBuilder.resolve("/mods/search?gameId={gameId}&slug={slug}&classId={classId}", + MINECRAFT_GAME_ID, slug, categoryInfo.modpackClassId) ) .toObject(ModsSearchResponse.class) .execute(); @@ -118,15 +122,17 @@ public void install(String slug, String fileMatcher, Integer fileId) throws IOEx throw new GenericException("No mods found with slug={}" + slug); } else if (searchResponse.getData().size() > 1) { - throw new GenericException("More than one mod found with slug={}" + slug); + throw new GenericException("More than one mod found with slug=" + slug); } else { - processModPack(preparedFetch, uriBuilder, searchResponse.getData().get(0), fileId, fileMatcher); + processModPack(preparedFetch, uriBuilder, categoryInfo, searchResponse.getData().get(0), fileId, fileMatcher); } } } - private void processModPack(SharedFetch preparedFetch, UriBuilder uriBuilder, CurseForgeMod mod, Integer fileId, + private void processModPack( + SharedFetch preparedFetch, UriBuilder uriBuilder, CategoryInfo categoryInfo, + CurseForgeMod mod, Integer fileId, String fileMatcher ) throws IOException { @@ -183,7 +189,7 @@ else if (Manifests.allFilesPresent(outputDir, manifest)) { mod.getSlug(), modFile.getModId(), modFile.getId()); final ModPackResults results = downloadAndProcessModpackZip( - preparedFetch, uriBuilder, + preparedFetch, uriBuilder, categoryInfo, normalizeDownloadUrl(modFile.getDownloadUrl()), mod.getSlug() ); @@ -255,7 +261,8 @@ private static CurseForgeFile resolveModpackFile(SharedFetch preparedFetch, UriB } private ModPackResults downloadAndProcessModpackZip( - SharedFetch preparedFetch, UriBuilder uriBuilder, URI downloadUrl, String modpackSlug + SharedFetch preparedFetch, UriBuilder uriBuilder, CategoryInfo categoryInfo, + URI downloadUrl, String modpackSlug ) throws IOException { final Path modpackZip = Files.createTempFile("curseforge-modpack", "zip"); @@ -268,18 +275,18 @@ private ModPackResults downloadAndProcessModpackZip( ) .execute(); - return processModpackZip(preparedFetch, uriBuilder, modpackZip, modpackSlug); + return processModpackZip(preparedFetch, uriBuilder, categoryInfo, modpackZip, modpackSlug); } finally { Files.delete(modpackZip); } } private ModPackResults processModpackZip( - SharedFetch preparedFetch, UriBuilder uriBuilder, Path modpackZip, String modpackSlug + SharedFetch preparedFetch, UriBuilder uriBuilder, CategoryInfo categoryInfo, Path modpackZip, String modpackSlug ) throws IOException { - final ExcludeIncludeIds excludeIncludeIds = resolveExcludeIncludes(preparedFetch, uriBuilder, modpackSlug); + final ExcludeIncludeIds excludeIncludeIds = resolveExcludeIncludes(preparedFetch, uriBuilder, categoryInfo, modpackSlug); log.debug("Using {}", excludeIncludeIds); final MinecraftModpackManifest modpackManifest = extractModpackManifest(modpackZip); @@ -295,8 +302,6 @@ private ModPackResults processModpackZip( Files.createDirectories(outputDir.resolve("saves")) ); - final Map categoryClasses = loadCategoryClasses(preparedFetch, uriBuilder); - // Go through all the files listed in modpack (given project ID + file ID) final List modFiles = Flux.fromIterable(modpackManifest.getFiles()) // ...do parallel downloads to let small ones make progress during big ones @@ -311,7 +316,7 @@ private ModPackResults processModpackZip( downloadModFile(preparedFetch, uriBuilder, outputPaths, fileRef.getProjectID(), fileRef.getFileID(), excludeIncludeIds.getForceIncludeIds(), - categoryClasses + categoryInfo.contentClassIds ) ) .sequential() @@ -350,7 +355,8 @@ else if (levelFrom == LevelFrom.WORLD_FILE && modFiles != null) { } } - private ExcludeIncludeIds resolveExcludeIncludes(SharedFetch preparedFetch, UriBuilder uriBuilder, + private ExcludeIncludeIds resolveExcludeIncludes( + SharedFetch preparedFetch, UriBuilder uriBuilder, CategoryInfo categoryInfo, String modpackSlug ) { log.debug("Reconciling exclude/includes from given {}", excludeIncludes); @@ -364,12 +370,12 @@ private ExcludeIncludeIds resolveExcludeIncludes(SharedFetch preparedFetch, UriB return Mono.zip( resolveFromSlugOrIds( - preparedFetch, uriBuilder, + preparedFetch, uriBuilder, categoryInfo, excludeIncludes.getGlobalExcludes(), specific != null ? specific.getExcludes() : null ), resolveFromSlugOrIds( - preparedFetch, uriBuilder, + preparedFetch, uriBuilder, categoryInfo, excludeIncludes.getGlobalForceIncludes(), specific != null ? specific.getForceIncludes() : null ) @@ -380,7 +386,8 @@ private ExcludeIncludeIds resolveExcludeIncludes(SharedFetch preparedFetch, UriB .block(); } - private Mono> resolveFromSlugOrIds(SharedFetch preparedFetch, UriBuilder uriBuilder, + private Mono> resolveFromSlugOrIds( + SharedFetch preparedFetch, UriBuilder uriBuilder, CategoryInfo categoryInfo, Collection global, Collection specific ) { log.trace("Resolving slug|id into IDs global={} specific={}", global, specific); @@ -396,27 +403,28 @@ private Mono> resolveFromSlugOrIds(SharedFetch preparedFetch, UriBu final int id = Integer.parseInt(s); return Mono.just(id); } catch (NumberFormatException e) { - return slugToId(preparedFetch, uriBuilder, s); + return slugToId(preparedFetch, uriBuilder, categoryInfo, s); } }) .sequential() .collect(Collectors.toSet()); } - private Mono slugToId(SharedFetch preparedFetch, UriBuilder uriBuilder, String slug) { + private Mono slugToId(SharedFetch preparedFetch, UriBuilder uriBuilder, CategoryInfo categoryInfo, + String slug) { return preparedFetch .fetch( uriBuilder.resolve("/mods/search?gameId={gameId}&slug={slug}", MINECRAFT_GAME_ID, slug) ) .toObject(ModsSearchResponse.class) .assemble() - .flatMap(resp -> - resp.getData() == null || resp.getData().isEmpty() ? - Mono.error(new GenericException("Unable to resolve slug into ID (no matches): "+slug)) - : resp.getData().size() > 1 ? - Mono.error(new GenericException("Unable to resolve slug into ID (multiple): "+slug)) - : Mono.just(resp.getData().get(0).getId()) - ); + .map(resp -> + resp.getData().stream() + .filter(curseForgeMod -> categoryInfo.contentClassIds.containsKey(curseForgeMod.getClassId())) + .findFirst() + .map(CurseForgeMod::getId) + .orElseThrow(() -> new GenericException("Unable to resolve slug into ID (no matches): " + slug)) + ); } @AllArgsConstructor @@ -505,7 +513,9 @@ private String findLevelEntryInOverrides(Path modpackZip, String overridesDir) t return null; } - private Mono downloadModFile(SharedFetch preparedFetch, UriBuilder uriBuilder, OutputPaths outputPaths, int projectID, int fileID, + private Mono downloadModFile( + SharedFetch preparedFetch, UriBuilder uriBuilder, OutputPaths outputPaths, + int projectID, int fileID, Set forceIncludeIds, Map categoryClasses ) { @@ -717,24 +727,40 @@ private MinecraftModpackManifest extractModpackManifest(Path modpackZip) throws } } + @AllArgsConstructor + static class CategoryInfo { + Map contentClassIds; + int modpackClassId; + } + /** * @return mapping of classId to category instances that are classes and an acceptable server-side type */ - private Map loadCategoryClasses(SharedFetch preparedFetch, UriBuilder uriBuilder) { + private CategoryInfo loadCategoryInfo(SharedFetch preparedFetch, UriBuilder uriBuilder) { return preparedFetch // get only categories that are classes, like mc-mods .fetch(uriBuilder.resolve("/categories?gameId={gameId}&classesOnly=true", MINECRAFT_GAME_ID)) .toObject(GetCategoriesResponse.class) .assemble() - .map(resp -> - resp.getData().stream() - // only keep the specific classes we want: mods, plugins, worlds - .filter(category -> applicableClassIdSlugs.contains(category.getSlug())) - .collect(Collectors.toMap( - // ...and enable quick lookup from "classId" in mod file metadata - Category::getId, - Function.identity() - )) + .map(resp -> { + final Map contentClassIds = new HashMap<>(); + Integer modpackClassId = null; + + for (final Category category : resp.getData()) { + if (applicableClassIdSlugs.contains(category.getSlug())) { + contentClassIds.put(category.getId(), category); + } + if (category.getSlug().equals(CATEGORY_SLUG_MODPACKS)) { + modpackClassId = category.getId(); + } + } + + if (modpackClassId == null) { + throw new GenericException("Unable to lookup classId for modpacks"); + } + + return new CategoryInfo(contentClassIds, modpackClassId); + } ) .block(); } diff --git a/src/test/java/me/itzg/helpers/curseforge/CurseForgeInstallerTest.java b/src/test/java/me/itzg/helpers/curseforge/CurseForgeInstallerTest.java index fb67407a..3c5d41f7 100644 --- a/src/test/java/me/itzg/helpers/curseforge/CurseForgeInstallerTest.java +++ b/src/test/java/me/itzg/helpers/curseforge/CurseForgeInstallerTest.java @@ -13,7 +13,14 @@ class CurseForgeInstallerTest { Path tempDir; /* - Scenarios to test +Scenarios to test + +### +Duplicated slug for modpack and mod (hyperion) + +### +Exclude/include by "gameVersions" + "gameVersions": [ "Client", "1.16.5",