diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java index d0eabbcb37..e044ff332b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java @@ -56,6 +56,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit private static final String PREFIX = "https://api.curseforge.com"; private static final String BASE = "https://www.curseforge.com"; private static final Semaphore SEMAPHORE = new Semaphore(16); + private static final int DEFAULT_RETRY_COUNT = 3; public static final String API_KEY = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", "")); @@ -126,8 +127,8 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit /// Searches the configured section, preserving the order returned by the server. /// /// @throws UnsupportedOperationException if this repository has no configured section - /// @throws NoCandidatesException if no response is obtained and no I/O failure was recorded - /// @throws IOException if all candidate requests fail with I/O errors + /// @throws NoCandidatesException if no response is obtained and no I/O failure was recorded + /// @throws IOException if all candidate requests fail with I/O errors @Override public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteAddonRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException { if (type == null) throw new UnsupportedOperationException(); @@ -158,6 +159,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit LOG.info("Fetching " + candidate); try { response = withApiKey(HttpRequest.GET(candidate.toString())) + .retry(DEFAULT_RETRY_COUNT) .getJson(Response.typeOf(listTypeOf(CurseAddon.class))); break; } catch (IOException e) { @@ -249,6 +251,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit try { Response response = withApiKey(HttpRequest.POST(PREFIX + "/v1/fingerprints/432")) .json(mapOf(pair("fingerprints", Collections.singletonList(hash)))) + .retry(DEFAULT_RETRY_COUNT) .getJson(Response.typeOf(FingerprintMatchesResult.class)); if (response.data().exactMatches() == null || response.data().exactMatches().isEmpty()) { @@ -266,6 +269,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit SEMAPHORE.acquireUninterruptibly(); try { Response response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id)) + .retry(DEFAULT_RETRY_COUNT) .getJson(Response.typeOf(CurseAddon.class)); return response.data.toAddon(); } finally { @@ -291,6 +295,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit SEMAPHORE.acquireUninterruptibly(); try { Response response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, projectId, fileId))) + .retry(DEFAULT_RETRY_COUNT) .getJson(Response.typeOf(CurseAddon.LatestFile.class)); return response.data().toVersion().file(); } finally { @@ -304,6 +309,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit try { Response> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files", pair("pageSize", "10000"))) + .retry(DEFAULT_RETRY_COUNT) .getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class))); return response.data().stream().map(CurseAddon.LatestFile::toVersion); } finally { @@ -316,6 +322,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit SEMAPHORE.acquireUninterruptibly(); try { Response response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s/changelog", PREFIX, addonId, versionId))) + .retry(DEFAULT_RETRY_COUNT) .getJson(Response.typeOf(String.class)); return response.data(); } finally { @@ -328,6 +335,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit SEMAPHORE.acquireUninterruptibly(); try { Response response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + version.projectId())) + .retry(DEFAULT_RETRY_COUNT) .getJson(Response.typeOf(CurseAddon.class)); var addon = response.data(); var classId = addon.classId(); @@ -356,6 +364,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit SEMAPHORE.acquireUninterruptibly(); try { Response> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432"))) + .retry(DEFAULT_RETRY_COUNT) .getJson(Response.typeOf(listTypeOf(Category.class))); return reorganizeCategories(categories.data(), section).stream().map(Category::toCategory); } finally { @@ -574,6 +583,22 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit default -> RemoteAddon.VersionType.Release; }; + Map knownHashes; + if (hashes != null) { + knownHashes = new HashMap<>(); + for (LatestFileHash hash : hashes) { + if (hash.value != null) { + switch (hash.algo) { + case 1 -> knownHashes.put("sha1", hash.value); + case 2 -> knownHashes.put("md5", hash.value); + } + } + } + knownHashes = Map.copyOf(knownHashes); + } else { + knownHashes = Map.of(); + } + return new RemoteAddon.Version( this, Integer.toString(id()), @@ -582,7 +607,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit fileName(), fileDate(), versionType, - new RemoteAddon.File(Collections.emptyMap(), downloadUrl(), fileName()), + new RemoteAddon.File(knownHashes, downloadUrl(), fileName()), dependencies.stream().map(dependency -> { if (!RELATION_TYPE.containsKey(dependency.relationType())) { throw new IllegalStateException("Broken datas."); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java index aadc19a153..dcb36f2474 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java @@ -134,7 +134,10 @@ public final class CurseCompletionTask extends Task { if (StringUtils.isBlank(file.fileName()) || file.url() == null) { try { RemoteAddon.File remoteFile = CurseForgeRemoteAddonRepository.MODS.getAddonFile(Integer.toString(file.projectID()), Integer.toString(file.fileID())); - return file.withFileName(remoteFile.filename()).withURL(remoteFile.url()); + return file + .withFileName(remoteFile.filename()) + .withURL(remoteFile.url()) + .withHashes(remoteFile.hashes()); } catch (FileNotFoundException fof) { LOG.warning("Could not query api.curseforge.com for deleted mods: " + file.projectID() + ", " + file.fileID(), fof); notFound.set(true); @@ -165,7 +168,7 @@ public final class CurseCompletionTask extends Task { return Stream.empty(); } - var task = new FileDownloadTask(f.url(), path); + var task = new FileDownloadTask(f.url(), path, f.getIntegrityCheck()); task.setCacheRepository(dependency.getCacheRepository()); task.setCaching(true); return Stream.of(task.withCounter("hmcl.modpack.download")); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java index b8edc77f56..a188856919 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java @@ -19,19 +19,33 @@ package org.jackhuang.hmcl.modpack.curse; import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; +import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.Validation; import org.jetbrains.annotations.Nullable; +import java.util.List; +import java.util.Map; import java.util.Objects; +import static org.jackhuang.hmcl.util.Pair.pair; + /// @author huangyuhui @JsonSerializable public record CurseManifestFile(@SerializedName("projectID") int projectID, @SerializedName("fileID") int fileID, @SerializedName("fileName") String fileName, @SerializedName("url") String url, - @SerializedName("required") boolean required) implements Validation { + @SerializedName("required") boolean required, + @SerializedName("hashes") Map hashes) implements Validation { + + private static final List> HASH_ALGORITHMS = List.of( + pair("sha1", "SHA-1"), + pair("sha256", "SHA-256"), + pair("sha512", "SHA-512"), + pair("md5", "MD5") + ); @Override public void validate() throws JsonParseException { @@ -51,12 +65,29 @@ public record CurseManifestFile(@SerializedName("projectID") int projectID, } } + @Nullable + public FileDownloadTask.IntegrityCheck getIntegrityCheck() { + if (hashes == null || hashes.isEmpty()) return null; + + for (Pair algorithm : HASH_ALGORITHMS) { + String hash = hashes.get(algorithm.key()); + if (hash != null) { + return new FileDownloadTask.IntegrityCheck(algorithm.value(), hash); + } + } + return null; + } + public CurseManifestFile withFileName(String fileName) { - return new CurseManifestFile(projectID, fileID, fileName, url, required); + return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes); } public CurseManifestFile withURL(String url) { - return new CurseManifestFile(projectID, fileID, fileName, url, required); + return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes); + } + + public CurseManifestFile withHashes(Map hashes) { + return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes); } @Override