Feat/curse retry and cache (#6595)

Co-authored-by: Glavo <zjx001202@gmail.com>
This commit is contained in:
bluebullet
2026-09-10 21:02:07 +08:00
committed by GitHub
parent 24702dc5a0
commit e287f672fd
3 changed files with 67 additions and 8 deletions
@@ -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<FingerprintMatchesResult> 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<CurseAddon> 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<CurseAddon.LatestFile> 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<List<CurseAddon.LatestFile>> 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<String> 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<CurseAddon> 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<List<Category>> 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<String, String> 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.");
@@ -134,7 +134,10 @@ public final class CurseCompletionTask extends Task<Void> {
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<Void> {
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"));
@@ -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<String, String> hashes) implements Validation {
private static final List<Pair<String, String>> 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<String, String> 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<String, String> hashes) {
return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes);
}
@Override