Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for downloading and installing informal modrinth packs #272

Merged
merged 24 commits into from
Aug 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1b8980c
Add characterization tests to ProjectRef
kMaiSmith Jul 29, 2023
60e84bc
Initial happy path test for downloading a modrinth pack with version id
kMaiSmith Jul 29, 2023
ea4ca6c
Copy .mrpack extraction logic into ModrinthPackInstaller
kMaiSmith Jul 29, 2023
a52f7cf
Undo package separation of modrinth modpack functionality
kMaiSmith Jul 29, 2023
0cc41e8
ModrinthApiPackFetcher constructor takes arguments for fields
kMaiSmith Jul 29, 2023
93d2c50
ModrinthPackInstaller constructor takes fields as arguments
kMaiSmith Jul 29, 2023
dcd9426
Whitespacing cleanup
kMaiSmith Jul 29, 2023
950244d
TestModrinthModpackInstaller proves the index is processed
kMaiSmith Aug 1, 2023
9df511e
Break Modrinth test helper functions into test helper class
kMaiSmith Aug 1, 2023
9f05c84
Prove ModrinthPackInstaller downloads files for the modpack
kMaiSmith Aug 1, 2023
51ea538
Modrinth structural changes in preparation for InstallCommand test
kMaiSmith Aug 1, 2023
e96cdf4
WIP: end-to-end characterization test of InstallModrinthModpackCommand
kMaiSmith Aug 1, 2023
891ba70
First working characterization test of InstallModrinthModpackCommand
kMaiSmith Aug 2, 2023
1970ab7
Add characterization test for manifest, cleanup
kMaiSmith Aug 4, 2023
e59b23f
Fix modrinth modpack cleanup test
kMaiSmith Aug 5, 2023
cfbc8e4
InstallModrinthModpackCommand uses Fetcher and Installer classes
kMaiSmith Aug 5, 2023
b3f7b91
Add basic http fetcher using SharedFetch
kMaiSmith Aug 5, 2023
a5de160
Test expects InstallModrinthModpackCommand to handle generic url modp…
kMaiSmith Aug 5, 2023
d9a56f9
Rename Modrinth test classes to reflect rest of project
kMaiSmith Aug 5, 2023
b267b5e
InstallModrinthModpackCommand uses Http Fetcher
kMaiSmith Aug 6, 2023
c016d27
Merge remote-tracking branch 'itzg/master' into informal-modrinth-packs
kMaiSmith Aug 11, 2023
141739f
Merge remote-tracking branch 'itzg/master' into informal-modrinth-packs
kMaiSmith Aug 14, 2023
ef82e8d
Switch assertions to AssertJ
kMaiSmith Aug 14, 2023
4c02cc5
ModrinthPackInstaller uses Shared Fetch Options
kMaiSmith Aug 14, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public ModrinthApiClient(String baseUrl, String command, Options options) {
sharedFetch = Fetch.sharedFetch(command, options);
}

static VersionFile pickVersionFile(Version version) {
public static VersionFile pickVersionFile(Version version) {
if (version.getFiles().size() == 1) {
return version.getFiles().get(0);
}
Expand Down
76 changes: 76 additions & 0 deletions src/main/java/me/itzg/helpers/modrinth/ModrinthApiPackFetcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package me.itzg.helpers.modrinth;

import java.nio.file.Path;

import lombok.extern.slf4j.Slf4j;
import me.itzg.helpers.errors.*;
import me.itzg.helpers.files.Manifests;
import me.itzg.helpers.http.FailedRequestException;
import me.itzg.helpers.modrinth.model.*;
import reactor.core.publisher.Mono;

@Slf4j
public class ModrinthApiPackFetcher implements ModrinthPackFetcher {
private ModrinthApiClient apiClient;

private ProjectRef modpackProjectRef;

private Loader modLoaderType;
private String gameVersion;
private VersionType defaultVersionType;
private Path modpackOutputDirectory;

ModrinthApiPackFetcher(
ModrinthApiClient apiClient, ProjectRef projectRef,
Path outputDirectory, String gameVersion,
VersionType defaultVersionType, Loader loader)
{
this.apiClient = apiClient;
this.modpackProjectRef = projectRef;
this.modpackOutputDirectory = outputDirectory;
this.gameVersion = gameVersion;
this.defaultVersionType = defaultVersionType;
this.modLoaderType = loader;
}

public Mono<Path> fetchModpack(ModrinthModpackManifest prevManifest) {
return this.resolveModpackVersion()
.filter(version -> needsInstall(prevManifest, version))
.flatMap(version ->
Mono.just(ModrinthApiClient.pickVersionFile(version)))
.flatMap(versionFile -> apiClient.downloadMrPack(versionFile));
}

private Mono<Version> resolveModpackVersion() {
return this.apiClient.getProject(this.modpackProjectRef.getIdOrSlug())
.onErrorMap(FailedRequestException::isNotFound,
throwable ->
new InvalidParameterException(
"Unable to locate requested project given " +
this.modpackProjectRef.getIdOrSlug(), throwable))
.flatMap(project ->
this.apiClient.resolveProjectVersion(
project, this.modpackProjectRef, this.modLoaderType,
this.gameVersion, this.defaultVersionType)
);
}

private boolean needsInstall(
ModrinthModpackManifest prevManifest, Version version)
{
if (prevManifest != null) {
if (prevManifest.getProjectSlug().equals(version.getProjectId())
&& prevManifest.getVersionId().equals(version.getId())
&& prevManifest.getDependencies() != null
&& Manifests.allFilesPresent(
modpackOutputDirectory, prevManifest)
) {
log.info("Modpack {} version {} is already installed",
version.getProjectId(), version.getName()
);
return false;
}
}
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package me.itzg.helpers.modrinth;

import java.net.URI;
import java.nio.file.Path;

import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;

@Slf4j
public class ModrinthHttpPackFetcher implements ModrinthPackFetcher {
private final ModrinthApiClient apiClient;
private final Path destFilePath;
private final URI modpackUri;

ModrinthHttpPackFetcher(ModrinthApiClient apiClient, Path basePath, URI uri) {
this.apiClient = apiClient;
this.destFilePath = basePath.resolve("modpack.mrpack");
this.modpackUri = uri;
}

@Override
public Mono<Path> fetchModpack(ModrinthModpackManifest prevManifest) {
return this.apiClient.downloadFileFromUrl(
this.destFilePath, this.modpackUri,
(uri, file, contentSizeBytes) ->
log.info("Downloaded {}", this.destFilePath));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package me.itzg.helpers.modrinth;

import java.nio.file.Path;

import reactor.core.publisher.Mono;

public interface ModrinthPackFetcher {
itzg marked this conversation as resolved.
Show resolved Hide resolved
Mono<Path> fetchModpack(ModrinthModpackManifest prevManifest);
}
222 changes: 222 additions & 0 deletions src/main/java/me/itzg/helpers/modrinth/ModrinthPackInstaller.java
itzg marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package me.itzg.helpers.modrinth;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipFile;

import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import me.itzg.helpers.errors.GenericException;
import me.itzg.helpers.errors.InvalidParameterException;
import me.itzg.helpers.fabric.FabricLauncherInstaller;
import me.itzg.helpers.files.IoStreams;
import me.itzg.helpers.forge.ForgeInstaller;
import me.itzg.helpers.http.SharedFetch.Options;
import me.itzg.helpers.json.ObjectMappers;
import me.itzg.helpers.modrinth.model.*;
import me.itzg.helpers.quilt.QuiltInstaller;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

@Slf4j
public class ModrinthPackInstaller {
private final ModrinthApiClient apiClient;
private final Path zipFile;
private final Path outputDirectory;
private final Path resultsFile;
private final boolean forceModloaderReinstall;
private final Options sharedFetchOpts;

public ModrinthPackInstaller(
ModrinthApiClient apiClient, Options sharedFetchOpts,
Path zipFile, Path outputDirectory, Path resultsFile,
boolean forceModloaderReinstall)
{
this.apiClient = apiClient;
this.sharedFetchOpts = sharedFetchOpts;
this.zipFile = zipFile;
this.outputDirectory = outputDirectory;
this.resultsFile = resultsFile;
this.forceModloaderReinstall = forceModloaderReinstall;
}

public Mono<Installation> processModpack() {
final ModpackIndex modpackIndex;
try {
modpackIndex = IoStreams.readFileFromZip(
this.zipFile, "modrinth.index.json", in ->
ObjectMappers.defaultMapper().readValue(in, ModpackIndex.class)
);
} catch (IOException e) {
return Mono.error(
new GenericException("Failed to read modpack index", e));
}

if (modpackIndex == null) {
return Mono.error(
new InvalidParameterException(
"Modpack is missing modrinth.index.json")
);
}

if (!Objects.equals("minecraft", modpackIndex.getGame())) {
return Mono.error(
new InvalidParameterException(
"Requested modpack is not for minecraft: " +
modpackIndex.getGame()));
}

return processModpackFiles(modpackIndex)
.collectList()
.map(modFiles ->
Stream.of(
modFiles.stream(),
extractOverrides("overrides", "server-overrides")
)
.flatMap(Function.identity())
.collect(Collectors.toList())
)
.flatMap(paths -> {
try {
applyModLoader(modpackIndex.getDependencies());
} catch (IOException e) {
return Mono.error(
new GenericException("Failed to apply mod loader", e));
}

return Mono.just(new Installation()
.setIndex(modpackIndex)
.setFiles(paths));
});
}

private Flux<Path> processModpackFiles(ModpackIndex modpackIndex) {
return Flux.fromStream(modpackIndex.getFiles().stream()
.filter(modpackFile ->
// env is optional
modpackFile.getEnv() == null
|| modpackFile.getEnv()
.get(Env.server) != EnvType.unsupported
)
)
.publishOn(Schedulers.boundedElastic())
.flatMap(modpackFile -> {
final Path outFilePath =
this.outputDirectory.resolve(modpackFile.getPath());
try {
//noinspection BlockingMethodInNonBlockingContext
Files.createDirectories(outFilePath.getParent());
} catch (IOException e) {
return Mono.error(new GenericException(
"Failed to created directory for file to download", e));
}

return this.apiClient.downloadFileFromUrl(
outFilePath,
modpackFile.getDownloads().get(0),
(uri, file, contentSizeBytes) ->
log.info("Downloaded {}", modpackFile.getPath())
);
});
}

@SuppressWarnings("SameParameterValue")
private Stream<Path> extractOverrides(String... overridesDirs) {
try (ZipFile zipFileReader = new ZipFile(zipFile.toFile())) {
return Stream.of(overridesDirs)
.flatMap(dir -> {
final String prefix = dir + "/";
return zipFileReader.stream()
.filter(entry -> !entry.isDirectory()
&& entry.getName().startsWith(prefix)
)
.map(entry -> {
final Path outFile = outputDirectory.resolve(
entry.getName().substring(prefix.length())
);

try {
Files.createDirectories(outFile.getParent());
Files.copy(zipFileReader.getInputStream(entry), outFile, StandardCopyOption.REPLACE_EXISTING);
return outFile;
} catch (IOException e) {
throw new GenericException(
String.format("Failed to extract %s from overrides", entry.getName()), e
);
}
});
})
// need to eager load the stream while the zip file is open
.collect(Collectors.toList())
.stream();
} catch (IOException e) {
throw new GenericException("Failed to extract overrides", e);
}
}

private void applyModLoader(
Map<DependencyId, String> dependencies
) throws IOException
{
log.debug("Applying mod loader from dependencies={}", dependencies);

final String minecraftVersion = dependencies.get(DependencyId.minecraft);
if (minecraftVersion == null) {
throw new GenericException(
"Modpack dependencies missing minecraft version: " + dependencies);
}

final String forgeVersion = dependencies.get(DependencyId.forge);
if (forgeVersion != null) {
new ForgeInstaller().install(
minecraftVersion,
forgeVersion,
this.outputDirectory,
this.resultsFile,
this.forceModloaderReinstall,
null
);
return;
}

final String fabricVersion = dependencies.get(DependencyId.fabricLoader);
if (fabricVersion != null) {
new FabricLauncherInstaller(this.outputDirectory)
.setResultsFile(this.resultsFile)
.installUsingVersions(
minecraftVersion,
fabricVersion,
null
);
return;
}

final String quiltVersion = dependencies.get(DependencyId.quiltLoader);
if (quiltVersion != null) {
try (QuiltInstaller installer =
new QuiltInstaller(QuiltInstaller.DEFAULT_REPO_URL,
this.sharedFetchOpts,
this.outputDirectory,
minecraftVersion)
.setResultsFile(this.resultsFile)) {

installer.installWithVersion(null, quiltVersion);
}
}
}

@Data
class Installation {
ModpackIndex index;
List<Path> files;
}
}
Loading