diff --git a/src/main/java/me/itzg/helpers/curseforge/CurseForgeFilesCommand.java b/src/main/java/me/itzg/helpers/curseforge/CurseForgeFilesCommand.java index a6179dad..2ebea08b 100644 --- a/src/main/java/me/itzg/helpers/curseforge/CurseForgeFilesCommand.java +++ b/src/main/java/me/itzg/helpers/curseforge/CurseForgeFilesCommand.java @@ -3,12 +3,10 @@ import static me.itzg.helpers.curseforge.CurseForgeApiClient.*; import static me.itzg.helpers.curseforge.ModFileRefResolver.idsFrom; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -20,13 +18,12 @@ import me.itzg.helpers.cache.ApiCachingImpl; import me.itzg.helpers.cache.CacheArgs; import me.itzg.helpers.curseforge.CurseForgeFilesManifest.FileEntry; -import me.itzg.helpers.curseforge.model.Category; +import me.itzg.helpers.curseforge.OutputSubdirResolver.Result; import me.itzg.helpers.curseforge.model.CurseForgeFile; import me.itzg.helpers.curseforge.model.CurseForgeMod; import me.itzg.helpers.curseforge.model.FileDependency; import me.itzg.helpers.curseforge.model.FileRelationType; import me.itzg.helpers.curseforge.model.ModLoaderType; -import me.itzg.helpers.errors.GenericException; import me.itzg.helpers.errors.InvalidParameterException; import me.itzg.helpers.files.Manifests; import me.itzg.helpers.http.SharedFetchArgs; @@ -38,7 +35,6 @@ import picocli.CommandLine.Parameters; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; import reactor.util.function.Tuple2; import reactor.util.function.Tuples; @@ -51,13 +47,6 @@ public class CurseForgeFilesCommand implements Callable { CATEGORY_BUKKIT_PLUGINS ); - private static final Map categorySubdirs = new HashMap<>(); - - static { - categorySubdirs.put(CATEGORY_MC_MODS, "mods"); - categorySubdirs.put(CATEGORY_BUKKIT_PLUGINS, "plugins"); - } - @Option(names = {"--help", "-h"}, usageHelp = true) boolean help; @@ -135,7 +124,8 @@ public Integer call() throws Exception { apiCaching ) ) { - newManifest = apiClient.loadCategoryInfo(Arrays.asList(CATEGORY_MC_MODS, CATEGORY_BUKKIT_PLUGINS)) + newManifest = + apiClient.loadCategoryInfo(Arrays.asList(CATEGORY_MC_MODS, CATEGORY_BUKKIT_PLUGINS)) .flatMap(categoryInfo -> processModFileRefs(categoryInfo, previousFiles, apiClient) .map(entries -> CurseForgeFilesManifest.builder() @@ -178,37 +168,63 @@ private Mono> processModFileRefs(CategoryInfo categoryInfo, final ModFileRefResolver modFileRefResolver = new ModFileRefResolver(apiClient, categoryInfo); - return modFileRefResolver.resolveModFiles(modFileRefs, defaultCategory, gameVersion, modLoaderType) - .flatMapMany(modFiles -> { + final OutputSubdirResolver outputSubdirResolver = new OutputSubdirResolver(outputDir, categoryInfo); + + return + modFileRefResolver.resolveModFiles(modFileRefs, defaultCategory, gameVersion, modLoaderType) + .flatMapMany(modFiles -> + { final Set requestedModIds = modFiles.stream() .map(CurseForgeFile::getModId) .collect(Collectors.toSet()); return Flux.fromIterable(modFiles) - .flatMap(modFile -> { - - final Mono retrieval; - final ModFileIds modFileIds = idsFrom(modFile); - final FileEntry entry = previousFiles.get(modFileIds); - if (entry != null - && Files.exists(outputDir.resolve(entry.getFilePath())) - ) { - log.debug("Mod file {} already exists at {}", modFile.getFileName(), entry.getFilePath()); - retrieval = Mono.just(entry); - } - else { - retrieval = retrieveModFile(apiClient, categoryInfo, modFile) - .map(path -> new FileEntry(modFileIds, outputDir.relativize(path).toString())); - } - - return reportMissingDependencies(apiClient, modFile, requestedModIds) - .then(retrieval); - }); + .flatMap(cfFile -> processFile(apiClient, outputSubdirResolver, previousFiles, requestedModIds, cfFile)); } ) .collectList(); } + private Mono processFile(CurseForgeApiClient apiClient, OutputSubdirResolver outputSubdirResolver, Map previousFiles, + Set requestedModIds, CurseForgeFile cfFile + ) { + final ModFileIds modFileIds = idsFrom(cfFile); + final FileEntry entry = previousFiles.get(modFileIds); + + final Mono retrievalMono; + if (entry != null) { + log.debug("Mod file {} already exists at {}", cfFile.getFileName(), entry.getFilePath()); + retrievalMono = Mono.just(entry); + } + else { + retrievalMono = + resolveOutputSubdir(apiClient, outputSubdirResolver, cfFile) + .flatMap(subdir -> + apiClient.download(cfFile, + subdir.resolve(cfFile.getFileName()), + modFileDownloadStatusHandler(outputDir, log) + ) + .map(path -> new FileEntry(modFileIds, + outputDir.relativize(path).toString() + )) + ); + } + + return reportMissingDependencies(apiClient, cfFile, requestedModIds) + .then(retrievalMono); + } + + private Mono resolveOutputSubdir( + CurseForgeApiClient apiClient, OutputSubdirResolver outputSubdirResolver, + CurseForgeFile cfFile + ) { + return apiClient.getModInfo(cfFile.getModId()) + .flatMap(modInfo -> + outputSubdirResolver.resolve(modInfo) + .map(Result::getDir) + ); + } + /** * * @return flux of missing, required dependencies @@ -251,9 +267,11 @@ else if (missingDep.getT2().getRelationType() == FileRelationType.OptionalDepend } @NotNull - private static Map buildPreviousFilesFromManifest(CurseForgeFilesManifest oldManifest) { + private Map buildPreviousFilesFromManifest(CurseForgeFilesManifest oldManifest) { return oldManifest != null ? oldManifest.getEntries().stream() + // make sure file still exists + .filter(fileEntry -> Files.exists(outputDir.resolve(fileEntry.getFilePath()))) .collect(Collectors.toMap( FileEntry::getIds, fileEntry -> fileEntry, @@ -266,60 +284,6 @@ private static Map buildPreviousFilesFromManifest(CurseFo : Collections.emptyMap(); } - @NotNull - private Mono retrieveModFile(CurseForgeApiClient apiClient, CategoryInfo categoryInfo, - CurseForgeFile curseForgeFile - ) { - return apiClient.getModInfo(curseForgeFile.getModId()) - .flatMap(curseForgeMod -> { - if (curseForgeFile.getDownloadUrl() == null) { - log.error("The authors of the mod '{}' have disallowed automated downloads. " + - "Manually download the file '{}' from {} and supply separately.", - curseForgeMod.getName(), curseForgeFile.getDisplayName(), curseForgeMod.getLinks().getWebsiteUrl() - ); - - return Mono.error(new InvalidParameterException( - String.format("The authors of %s do not allow automated downloads", - curseForgeMod.getName() - )) - ); - } - - return setupSubdir(categoryInfo, curseForgeMod) - .flatMap(subdir -> - apiClient.download(curseForgeFile, - outputDir.resolve(subdir).resolve(curseForgeFile.getFileName()), - modFileDownloadStatusHandler(outputDir, log) - ) - ); - } - ) - .checkpoint(String.format("Retrieving %d:%d", curseForgeFile.getModId(), curseForgeFile.getId())); - } - - @NotNull - private Mono setupSubdir(CategoryInfo categoryInfo, CurseForgeMod curseForgeMod) { - return Mono.defer(() -> { - final Category category = categoryInfo.getCategory(curseForgeMod.getClassId()); - - final String subdir = categorySubdirs.get(category.getSlug()); - if (subdir == null) { - return Mono.error(new InvalidParameterException( - String.format("Category %s does not have a known subdir", category.getName()))); - } - - try { - //noinspection BlockingMethodInNonBlockingContext due to subscribeOn - Files.createDirectories(outputDir.resolve(subdir)); - } catch (IOException e) { - return Mono.error(new GenericException("Failed to create mod file directory", e)); - } - - return Mono.just(subdir); - }) - .subscribeOn(Schedulers.boundedElastic()); - } - private static List mapFilePathsFromEntries(CurseForgeFilesManifest oldManifest) { return oldManifest != null ? diff --git a/src/main/java/me/itzg/helpers/curseforge/CurseForgeInstaller.java b/src/main/java/me/itzg/helpers/curseforge/CurseForgeInstaller.java index 54b60618..d776f77f 100644 --- a/src/main/java/me/itzg/helpers/curseforge/CurseForgeInstaller.java +++ b/src/main/java/me/itzg/helpers/curseforge/CurseForgeInstaller.java @@ -18,7 +18,6 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -38,7 +37,6 @@ import me.itzg.helpers.cache.CacheArgs; import me.itzg.helpers.curseforge.ExcludeIncludesContent.ExcludeIncludes; import me.itzg.helpers.curseforge.OverridesApplier.Result; -import me.itzg.helpers.curseforge.model.Category; import me.itzg.helpers.curseforge.model.CurseForgeFile; import me.itzg.helpers.curseforge.model.CurseForgeMod; import me.itzg.helpers.curseforge.model.ManifestFileRef; @@ -61,6 +59,7 @@ import me.itzg.helpers.json.ObjectMappers; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; +import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import reactor.core.publisher.Flux; @@ -116,13 +115,6 @@ public class CurseForgeInstaller { @Getter @Setter private Path downloadsRepo; - private final Set applicableClassIdSlugs = new HashSet<>(Arrays.asList( - CurseForgeApiClient.CATEGORY_MODPACKS, - CurseForgeApiClient.CATEGORY_MC_MODS, - CurseForgeApiClient.CATEGORY_BUKKIT_PLUGINS, - CurseForgeApiClient.CATEGORY_WORLDS - )); - @Getter @Setter private List overridesExclusions; @@ -226,7 +218,12 @@ void install(String slug, InstallationEntryPoint entryPoint) { apiCaching ) ) { - final CategoryInfo categoryInfo = cfApi.loadCategoryInfo(applicableClassIdSlugs) + final CategoryInfo categoryInfo = cfApi.loadCategoryInfo(Arrays.asList( + CurseForgeApiClient.CATEGORY_MODPACKS, + CurseForgeApiClient.CATEGORY_MC_MODS, + CurseForgeApiClient.CATEGORY_BUKKIT_PLUGINS, + CurseForgeApiClient.CATEGORY_WORLDS + )) .block(); entryPoint.install( @@ -557,11 +554,7 @@ private ModPackResults processModpack(InstallContext context, .findFirst() .orElseThrow(() -> new GenericException("Unable to find primary mod loader in modpack")); - final OutputPaths outputPaths = new OutputPaths( - Files.createDirectories(outputDir.resolve("mods")), - Files.createDirectories(outputDir.resolve("plugins")), - Files.createDirectories(outputDir.resolve("saves")) - ); + final OutputSubdirResolver outputSubdirResolver = new OutputSubdirResolver(outputDir, context.categoryInfo); final ExcludeIncludeIds excludeIncludeIds = resolveExcludeIncludes(context); log.debug("Using {}", excludeIncludeIds); @@ -594,10 +587,8 @@ private ModPackResults processModpack(InstallContext context, }) // ...download and possibly unzip world file .flatMap(fileRef -> - processFileFromModpack(context, outputPaths, - fileRef.getProjectID(), fileRef.getFileID(), - excludeIncludeIds.getForceIncludeIds(), - context.categoryInfo + processFileWithIds(context, outputSubdirResolver, + excludeIncludeIds.getForceIncludeIds(), fileRef.getProjectID(), fileRef.getFileID() ) .checkpoint() ) @@ -707,98 +698,94 @@ private Mono> resolveFromSlugOrIds( /** * Downloads the referenced project-file into the appropriate subdirectory from outputPaths */ - private Mono processFileFromModpack( - InstallContext context, OutputPaths outputPaths, - int projectID, int fileID, - Set forceIncludeIds, - CategoryInfo categoryInfo + private Mono processFileWithIds( + InstallContext context, OutputSubdirResolver outputSubdirResolver, + Set forceIncludeIds, int projectID, int fileID ) { return context.cfApi.getModInfo(projectID) - .flatMap(modInfo -> { - final Category category = categoryInfo.contentClassIds.get(modInfo.getClassId()); - // applicable category? - if (category == null) { - log.debug("Skipping project={} slug={} file={} since it is not an applicable classId={}", - projectID, modInfo.getSlug(), fileID, modInfo.getClassId() - ); - return Mono.empty(); - } + .flatMap(modInfo -> + context.cfApi.getModFileInfo(projectID, fileID) + .flatMap(cfFile -> processFile(context, outputSubdirResolver, forceIncludeIds, modInfo, cfFile))) + .checkpoint(String.format("Processing file %d:%d from modpack", projectID, fileID)); + } - final Path outputDir; - final boolean isWorld; - if (category.getSlug().endsWith("-mods")) { - outputDir = outputPaths.getModsDir(); - isWorld = false; - } - else if (category.getSlug().endsWith("-plugins")) { - outputDir = outputPaths.getPluginsDir(); - isWorld = false; - } - else if (category.getSlug().equals("worlds")) { - outputDir = outputPaths.getWorldsDir(); - isWorld = true; - } - else { - return Mono.error( - new GenericException( - String.format("Unsupported category type=%s from mod=%s", category.getSlug(), modInfo.getSlug())) - ); - } + private Mono processFile(InstallContext context, OutputSubdirResolver outputSubdirResolver, + Set forceIncludeIds, CurseForgeMod modInfo, CurseForgeFile cfFile + ) { + if (!forceIncludeIds.contains(modInfo.getId()) && !isServerMod(cfFile)) { + log.debug("Skipping {} since it is a client mod", cfFile.getFileName()); + return Mono.empty(); + } + log.debug("Download/confirm mod {} @ {}:{}", + // several mods have non-descriptive display names, like "v1.0.0", so filename tends to be better + cfFile.getFileName(), + modInfo.getSlug(), cfFile.getId() + ); - return context.cfApi.getModFileInfo(projectID, fileID) - .flatMap(cfFile -> { - if (!forceIncludeIds.contains(projectID) && !isServerMod(cfFile)) { - log.debug("Skipping {} since it is a client mod", cfFile.getFileName()); - return Mono.empty(); - } - log.debug("Download/confirm mod {} @ {}:{}", - // several mods have non-descriptive display names, like "v1.0.0", so filename tends to be better - cfFile.getFileName(), - projectID, fileID - ); - - final Mono resolvedFileMono = - Mono.defer(() -> - downloadOrResolveFile(context, modInfo, isWorld, outputDir, cfFile) - .checkpoint() - ) - // retry the deferred part above if one of the expected failure cases - .retryWhen( - Retry.fixedDelay(BAD_FILE_ATTEMPTS, BAD_FILE_DELAY) - .filter(throwable -> - throwable instanceof FileHashInvalidException || - throwable instanceof FailedRequestException - ) - .doBeforeRetry(retrySignal -> - log.warn("Retrying to download {} @ {}:{}", - cfFile.getFileName(), projectID, fileID) - ) - ); + return outputSubdirResolver.resolve(modInfo) + .flatMap(outputResult -> + downloadAndPostProcess(context, modInfo, cfFile, + outputResult.isWorld(), + outputResult.getDir() + ) + ) + .switchIfEmpty(Mono.error(() -> new GenericException( + String.format("Unable to determine output location for file '%s' in '%s'", + cfFile.getDisplayName(), modInfo.getName() + ))) + ); + } - return isWorld ? - resolvedFileMono - .map(resolveResult -> - resolveResult.downloadNeeded ? - new PathWithInfo(resolveResult.path) - .setDownloadNeeded(resolveResult.downloadNeeded) - .setModInfo(modInfo) - .setCurseForgeFile(cfFile) - : extractWorldZip(modInfo, resolveResult.path, outputPaths.getWorldsDir()) - ) - : resolvedFileMono - .map(resolveResult -> - new PathWithInfo(resolveResult.path) - .setDownloadNeeded(resolveResult.downloadNeeded) - .setModInfo(modInfo) - .setCurseForgeFile(cfFile) - ); - }); - }) - .checkpoint(String.format("Downloading file from modpack %d:%d", projectID, fileID)); + private Mono downloadAndPostProcess( + InstallContext context, CurseForgeMod modInfo, CurseForgeFile cfFile, + boolean isWorld, Path outputSubdir + ) { + return isWorld ? + buildRetryableDownload(context, modInfo, cfFile, isWorld, outputSubdir) + .flatMap(downloadResult -> + downloadResult.downloadNeeded ? + Mono.just(new PathWithInfo(downloadResult.path) + .setDownloadNeeded(downloadResult.downloadNeeded) + .setModInfo(modInfo) + .setCurseForgeFile(cfFile) + ) + : Mono.fromCallable(() -> extractWorldZip(modInfo, downloadResult.path, outputSubdir)) + .subscribeOn(Schedulers.boundedElastic()) + ) + : buildRetryableDownload(context, modInfo, cfFile, isWorld, outputSubdir) + .map(resolveResult -> + new PathWithInfo(resolveResult.path) + .setDownloadNeeded(resolveResult.downloadNeeded) + .setModInfo(modInfo) + .setCurseForgeFile(cfFile) + ); + } + + private Mono buildRetryableDownload(InstallContext context, + CurseForgeMod modInfo, CurseForgeFile cfFile, boolean isWorld, Path outputSubdir + ) { + // use defer so that the download mono is rebuilt on each retry + return Mono.defer(() -> + downloadOrResolveFile(context, modInfo, isWorld, outputSubdir, cfFile) + .checkpoint() + ) + // retry the deferred part above if one of the expected failure cases + .retryWhen( + Retry.fixedDelay(BAD_FILE_ATTEMPTS, BAD_FILE_DELAY) + .filter(throwable -> + throwable instanceof FileHashInvalidException || + throwable instanceof FailedRequestException + ) + .doBeforeRetry(retrySignal -> + log.warn("Retrying to download {} @ {}:{}", + cfFile.getFileName(), modInfo.getName(), cfFile.getDisplayName() + ) + ) + ); } @RequiredArgsConstructor - static class ResolveResult { + static class DownloadOrResolveResult { final Path path; @Setter @@ -806,23 +793,23 @@ static class ResolveResult { } /** - * @param outputDir the mods, plugins, etc directory to place the mod file + * @param outputSubdir the mods, plugins, etc directory to place the mod file */ - private Mono downloadOrResolveFile(InstallContext context, CurseForgeMod modInfo, - boolean isWorld, Path outputDir, CurseForgeFile cfFile + private Mono downloadOrResolveFile(InstallContext context, CurseForgeMod modInfo, + boolean isWorld, Path outputSubdir, CurseForgeFile cfFile ) { - final Path outputFile = outputDir.resolve(cfFile.getFileName()); + final Path outputFile = outputSubdir.resolve(cfFile.getFileName()); // Will try to locate an existing file by alternate names that browser might create, // but only for non-world files of the modpack final Path locatedFile = !isWorld ? - locateFileIn(cfFile.getFileName(), outputDir) + locateFileIn(cfFile.getFileName(), outputSubdir) : null; if (locatedFile != null) { log.info("Mod file {} already exists", locatedFile); return verifyHash(cfFile, locatedFile) - .map(ResolveResult::new); + .map(DownloadOrResolveResult::new); } else { final Path fileInRepo = locateModInRepo(cfFile.getFileName()); @@ -832,7 +819,7 @@ private Mono downloadOrResolveFile(InstallContext context, CurseF return context.cfApi.download(cfFile, outputFile, modFileDownloadStatusHandler(this.outputDir, log)) .flatMap(path -> verifyHash(cfFile, path)) - .map(ResolveResult::new) + .map(DownloadOrResolveResult::new) .onErrorResume( e -> e instanceof FailedRequestException && ((FailedRequestException) e).getStatusCode() == 404, @@ -841,6 +828,9 @@ private Mono downloadOrResolveFile(InstallContext context, CurseF } } + /** + * @return Mono.error with {@link FileHashInvalidException} when not valid + */ private static Mono verifyHash(CurseForgeFile cfFile, Path path) { return FileHashVerifier.verify(path, cfFile.getHashes()) .onErrorResume(IllegalArgumentException.class::isInstance, @@ -851,7 +841,7 @@ private static Mono verifyHash(CurseForgeFile cfFile, Path path) { ); } - private Mono handleFileNeedingManualDownload(CurseForgeMod modInfo, boolean isWorld, CurseForgeFile cfFile, + private Mono handleFileNeedingManualDownload(CurseForgeMod modInfo, boolean isWorld, CurseForgeFile cfFile, Path outputFile ) { final Path resolved = @@ -863,14 +853,14 @@ private Mono handleFileNeedingManualDownload(CurseForgeMod modInf "Manually download the file '{}' from {} and supply via downloads repo or separately.", modInfo.getName(), cfFile.getDisplayName(), modInfo.getLinks().getWebsiteUrl() ); - return Mono.just(new ResolveResult(outputFile).setDownloadNeeded(true)); + return Mono.just(new DownloadOrResolveResult(outputFile).setDownloadNeeded(true)); } else { return copyFromDownloadsRepo(outputFile, resolved); } } - private @NotNull Mono copyFromDownloadsRepo(Path outputFile, Path resolved) { + private @NotNull Mono copyFromDownloadsRepo(Path outputFile, Path resolved) { return Mono.fromCallable(() -> { log.info("Mod file {} obtained from downloads repo", @@ -879,9 +869,10 @@ private Mono handleFileNeedingManualDownload(CurseForgeMod modInf return Files.copy(resolved, outputFile); }) .subscribeOn(Schedulers.boundedElastic()) - .map(ResolveResult::new); + .map(DownloadOrResolveResult::new); } + @Blocking private PathWithInfo extractWorldZip(CurseForgeMod modInfo, Path zipPath, Path worldsDir) { if (levelFrom != LevelFrom.WORLD_FILE) { return new PathWithInfo(zipPath); diff --git a/src/main/java/me/itzg/helpers/curseforge/ModFileRefResolver.java b/src/main/java/me/itzg/helpers/curseforge/ModFileRefResolver.java index 89804c9c..d1ce81ff 100644 --- a/src/main/java/me/itzg/helpers/curseforge/ModFileRefResolver.java +++ b/src/main/java/me/itzg/helpers/curseforge/ModFileRefResolver.java @@ -89,7 +89,7 @@ private static Flux expandFileListings(List modFileRefs) { .subscribeOn(Schedulers.boundedElastic()); } - private Mono resolveModFileFromMod(String ref, String gameVersion, String category, + private Mono resolveModFileFromMod(String ref, String gameVersion, String category, ModLoaderType modLoaderType, CurseForgeMod mod, Matcher m ) { final String fileId = Optional.ofNullable(m.group("fileIdInUrl")) diff --git a/src/main/java/me/itzg/helpers/curseforge/OutputPaths.java b/src/main/java/me/itzg/helpers/curseforge/OutputPaths.java index 41a25de6..4a98b597 100644 --- a/src/main/java/me/itzg/helpers/curseforge/OutputPaths.java +++ b/src/main/java/me/itzg/helpers/curseforge/OutputPaths.java @@ -3,11 +3,12 @@ import java.nio.file.Path; import lombok.AllArgsConstructor; import lombok.Getter; +import reactor.core.publisher.Mono; @AllArgsConstructor @Getter class OutputPaths { - private final Path modsDir; - private final Path pluginsDir; - private final Path worldsDir; + private final Mono modsDir; + private final Mono pluginsDir; + private final Mono worldsDir; } diff --git a/src/main/java/me/itzg/helpers/curseforge/OutputSubdirResolver.java b/src/main/java/me/itzg/helpers/curseforge/OutputSubdirResolver.java new file mode 100644 index 00000000..128b388c --- /dev/null +++ b/src/main/java/me/itzg/helpers/curseforge/OutputSubdirResolver.java @@ -0,0 +1,68 @@ +package me.itzg.helpers.curseforge; + +import java.nio.file.Path; +import java.util.concurrent.ConcurrentHashMap; +import lombok.Data; +import me.itzg.helpers.curseforge.model.Category; +import me.itzg.helpers.curseforge.model.CurseForgeMod; +import me.itzg.helpers.errors.GenericException; +import me.itzg.helpers.files.ReactiveFileUtils; +import org.jetbrains.annotations.NotNull; +import reactor.core.publisher.Mono; + +public class OutputSubdirResolver { + + private final CategoryInfo categoryInfo; + private final ConcurrentHashMap> subDirs + = new ConcurrentHashMap<>(); + private final OutputPaths outputPaths; + + public OutputSubdirResolver(Path outputDir, CategoryInfo categoryInfo) { + this.categoryInfo = categoryInfo; + + this.outputPaths = new OutputPaths( + cachedDir(outputDir, "mods"), + cachedDir(outputDir, "plugins"), + cachedDir(outputDir, "saves") + ); + } + + private static @NotNull Mono cachedDir(Path outputDir, String subdir) { + return ReactiveFileUtils.createDirectories(outputDir.resolve(subdir)).cache(); + } + + @Data + public static class Result { + final Path dir; + final boolean world; + } + + public Mono resolve(CurseForgeMod modInfo) { + final Category category = categoryInfo.contentClassIds.get(modInfo.getClassId()); + // applicable category? + if (category == null) { + return Mono.empty(); + } + + final Mono subDirMono; + if (category.getSlug().endsWith("-mods")) { + subDirMono = outputPaths.getModsDir(); + } + else if (category.getSlug().endsWith("-plugins")) { + subDirMono = outputPaths.getPluginsDir(); + } + else if (category.getSlug().equals("worlds")) { + subDirMono = outputPaths.getWorldsDir(); + } + else { + return Mono.error( + new GenericException( + String.format("Unsupported category type=%s from mod=%s", category.getSlug(), modInfo.getSlug())) + ); + } + + return subDirMono + .map(path -> new Result(path, false)); + + } +} diff --git a/src/main/java/me/itzg/helpers/files/ReactiveFileUtils.java b/src/main/java/me/itzg/helpers/files/ReactiveFileUtils.java index ad7c9eea..b8e1c78b 100644 --- a/src/main/java/me/itzg/helpers/files/ReactiveFileUtils.java +++ b/src/main/java/me/itzg/helpers/files/ReactiveFileUtils.java @@ -34,6 +34,11 @@ public static Mono fileExists(Path file) { .subscribeOn(Schedulers.boundedElastic()); } + public static Mono createDirectories(Path dir) { + return Mono.fromCallable(() -> Files.createDirectories(dir)) + .subscribeOn(Schedulers.boundedElastic()); + } + @SuppressWarnings("BlockingMethodInNonBlockingContext") public static Mono copyByteBufFluxToFile(ByteBufFlux byteBufFlux, Path file) { return Mono.fromCallable(() -> {