packmodsdown 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- packmodsdown-1.0.0/LICENSE +7 -0
- packmodsdown-1.0.0/PKG-INFO +51 -0
- packmodsdown-1.0.0/README.md +30 -0
- packmodsdown-1.0.0/pyproject.toml +27 -0
- packmodsdown-1.0.0/src/packmodsdown/__init__.py +0 -0
- packmodsdown-1.0.0/src/packmodsdown/api.py +146 -0
- packmodsdown-1.0.0/src/packmodsdown/cli.py +70 -0
- packmodsdown-1.0.0/src/packmodsdown/downloader.py +287 -0
- packmodsdown-1.0.0/src/packmodsdown/manifest.py +147 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright <2025> <QianFuv>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: packmodsdown
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Batch download mods from CurseForge modpack zip files
|
|
5
|
+
Author: QianFuv
|
|
6
|
+
Author-email: QianFuv <qianfuv@qq.com>
|
|
7
|
+
License: Copyright <2025> <QianFuv>
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
14
|
+
Requires-Dist: httpx>=0.28.1
|
|
15
|
+
Requires-Dist: rich>=13.0.0
|
|
16
|
+
Requires-Dist: mypy>=1.18.2 ; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff>=0.14.6 ; extra == 'dev'
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# PackModsDown
|
|
23
|
+
|
|
24
|
+
Batch download mods from CurseForge modpack zip files.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install packmodsdown
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pmd -m <modpack.zip> -o <output_dir> -k <api_key>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or use the full name:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
packmodsdown -m <modpack.zip> -o <output_dir> -k <api_key> [-c <concurrency>]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Options
|
|
45
|
+
|
|
46
|
+
| Flag | Description | Default |
|
|
47
|
+
| ------------------- | ------------------------ | -------- |
|
|
48
|
+
| `-m, --modpack` | Path to modpack `.zip` | required |
|
|
49
|
+
| `-o, --output` | Output directory | `./mods` |
|
|
50
|
+
| `-k, --api-key` | CurseForge API key | required |
|
|
51
|
+
| `-c, --concurrency` | Max concurrent downloads | `10` |
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# PackModsDown
|
|
2
|
+
|
|
3
|
+
Batch download mods from CurseForge modpack zip files.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install packmodsdown
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pmd -m <modpack.zip> -o <output_dir> -k <api_key>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or use the full name:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
packmodsdown -m <modpack.zip> -o <output_dir> -k <api_key> [-c <concurrency>]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Options
|
|
24
|
+
|
|
25
|
+
| Flag | Description | Default |
|
|
26
|
+
| ------------------- | ------------------------ | -------- |
|
|
27
|
+
| `-m, --modpack` | Path to modpack `.zip` | required |
|
|
28
|
+
| `-o, --output` | Output directory | `./mods` |
|
|
29
|
+
| `-k, --api-key` | CurseForge API key | required |
|
|
30
|
+
| `-c, --concurrency` | Max concurrent downloads | `10` |
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "packmodsdown"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Batch download mods from CurseForge modpack zip files"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "QianFuv", email = "qianfuv@qq.com" }]
|
|
7
|
+
license = { file = "LICENSE" }
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = ["httpx>=0.28.1", "rich>=13.0.0"]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
packmodsdown = "packmodsdown.cli:main"
|
|
13
|
+
pmd = "packmodsdown.cli:main"
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = ["mypy>=1.18.2", "ruff>=0.14.6"]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.8.5,<0.9.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
[tool.ruff.lint]
|
|
24
|
+
select = ["E", "F", "UP", "B", "SIM", "I"]
|
|
25
|
+
|
|
26
|
+
[tool.mypy]
|
|
27
|
+
disable_error_code = ["import-untyped"]
|
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async CurseForge API client using httpx.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://api.curseforge.com"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class FileInfo:
|
|
17
|
+
"""
|
|
18
|
+
Metadata for a single mod file returned by the CurseForge API.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
file_id: The file ID.
|
|
22
|
+
mod_id: The parent mod (project) ID.
|
|
23
|
+
display_name: Human-readable file display name.
|
|
24
|
+
file_name: Actual filename for download.
|
|
25
|
+
download_url: Direct download URL, may be None if restricted.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
file_id: int
|
|
29
|
+
mod_id: int
|
|
30
|
+
display_name: str
|
|
31
|
+
file_name: str
|
|
32
|
+
download_url: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CurseForgeClient:
|
|
36
|
+
"""
|
|
37
|
+
Async HTTP client for the CurseForge API.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
api_key: CurseForge API key for authentication.
|
|
41
|
+
timeout: Request timeout in seconds.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, api_key: str, timeout: float = 30.0) -> None:
|
|
45
|
+
self._client = httpx.AsyncClient(
|
|
46
|
+
base_url=BASE_URL,
|
|
47
|
+
headers={
|
|
48
|
+
"x-api-key": api_key,
|
|
49
|
+
"Accept": "application/json",
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
},
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def close(self) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Close the underlying HTTP client.
|
|
58
|
+
"""
|
|
59
|
+
await self._client.aclose()
|
|
60
|
+
|
|
61
|
+
async def __aenter__(self) -> CurseForgeClient:
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
65
|
+
await self.close()
|
|
66
|
+
|
|
67
|
+
async def get_files(self, file_ids: list[int]) -> list[FileInfo]:
|
|
68
|
+
"""
|
|
69
|
+
Batch-fetch file metadata via POST /v1/mods/files.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
file_ids: List of CurseForge file IDs to look up.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of FileInfo objects with download metadata.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
httpx.HTTPStatusError: On non-2xx responses.
|
|
79
|
+
"""
|
|
80
|
+
resp = await self._client.post(
|
|
81
|
+
"/v1/mods/files",
|
|
82
|
+
json={"fileIds": file_ids},
|
|
83
|
+
)
|
|
84
|
+
resp.raise_for_status()
|
|
85
|
+
data: list[dict[str, Any]] = resp.json().get("data", [])
|
|
86
|
+
return [_parse_file_info(item) for item in data]
|
|
87
|
+
|
|
88
|
+
async def get_mod_file_download_url(self, mod_id: int, file_id: int) -> str | None:
|
|
89
|
+
"""
|
|
90
|
+
Fetch the download URL for a specific file as a fallback.
|
|
91
|
+
|
|
92
|
+
Uses GET /v1/mods/{modId}/files/{fileId}/download-url.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
mod_id: The mod (project) ID.
|
|
96
|
+
file_id: The file ID.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The download URL string, or None if unavailable.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
httpx.HTTPStatusError: On non-2xx responses.
|
|
103
|
+
"""
|
|
104
|
+
resp = await self._client.get(f"/v1/mods/{mod_id}/files/{file_id}/download-url")
|
|
105
|
+
resp.raise_for_status()
|
|
106
|
+
return resp.json().get("data")
|
|
107
|
+
|
|
108
|
+
async def get_mod_file(self, mod_id: int, file_id: int) -> FileInfo:
|
|
109
|
+
"""
|
|
110
|
+
Fetch metadata for a single mod file.
|
|
111
|
+
|
|
112
|
+
Uses GET /v1/mods/{modId}/files/{fileId}.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
mod_id: The mod (project) ID.
|
|
116
|
+
file_id: The file ID.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
FileInfo object with download metadata.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
httpx.HTTPStatusError: On non-2xx responses.
|
|
123
|
+
"""
|
|
124
|
+
resp = await self._client.get(f"/v1/mods/{mod_id}/files/{file_id}")
|
|
125
|
+
resp.raise_for_status()
|
|
126
|
+
data: dict[str, Any] = resp.json().get("data", {})
|
|
127
|
+
return _parse_file_info(data)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_file_info(item: dict[str, Any]) -> FileInfo:
|
|
131
|
+
"""
|
|
132
|
+
Parse a raw API file object into a FileInfo dataclass.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
item: Raw JSON dict from the API response.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Parsed FileInfo.
|
|
139
|
+
"""
|
|
140
|
+
return FileInfo(
|
|
141
|
+
file_id=item.get("id", 0),
|
|
142
|
+
mod_id=item.get("modId", 0),
|
|
143
|
+
display_name=item.get("displayName", ""),
|
|
144
|
+
file_name=item.get("fileName", ""),
|
|
145
|
+
download_url=item.get("downloadUrl"),
|
|
146
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for the CurseForge modpack mod downloader.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from packmodsdown.downloader import download_modpack
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
"""
|
|
16
|
+
Parse command-line arguments and launch the modpack download process.
|
|
17
|
+
"""
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="pmd",
|
|
20
|
+
description="Download all mods from a CurseForge modpack zip file.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-m",
|
|
24
|
+
"--modpack",
|
|
25
|
+
required=True,
|
|
26
|
+
help="Path to the CurseForge modpack .zip file.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"-o",
|
|
30
|
+
"--output",
|
|
31
|
+
default="./mods",
|
|
32
|
+
help="Output directory for downloaded mod files (default: ./mods).",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"-k",
|
|
36
|
+
"--api-key",
|
|
37
|
+
required=True,
|
|
38
|
+
help="CurseForge API key for authentication.",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"-c",
|
|
42
|
+
"--concurrency",
|
|
43
|
+
type=int,
|
|
44
|
+
default=10,
|
|
45
|
+
help="Maximum number of concurrent downloads (default: 10).",
|
|
46
|
+
)
|
|
47
|
+
args = parser.parse_args()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
asyncio.run(
|
|
51
|
+
download_modpack(
|
|
52
|
+
modpack_path=args.modpack,
|
|
53
|
+
output_dir=args.output,
|
|
54
|
+
api_key=args.api_key,
|
|
55
|
+
max_concurrency=args.concurrency,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
except FileNotFoundError as exc:
|
|
59
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
except KeyError as exc:
|
|
62
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
except KeyboardInterrupt:
|
|
65
|
+
print("\nDownload cancelled by user.", file=sys.stderr)
|
|
66
|
+
sys.exit(130)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core download orchestration for CurseForge modpack mods.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import shutil
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.progress import (
|
|
16
|
+
BarColumn,
|
|
17
|
+
MofNCompleteColumn,
|
|
18
|
+
Progress,
|
|
19
|
+
SpinnerColumn,
|
|
20
|
+
TextColumn,
|
|
21
|
+
TimeRemainingColumn,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from packmodsdown.api import CurseForgeClient, FileInfo
|
|
25
|
+
from packmodsdown.manifest import ModFileEntry, ModpackManifest, parse_manifest
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def download_modpack(
|
|
31
|
+
modpack_path: str | Path,
|
|
32
|
+
output_dir: str | Path,
|
|
33
|
+
api_key: str,
|
|
34
|
+
max_concurrency: int = 10,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Parse a CurseForge modpack and download all mod files.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
modpack_path: Path to the modpack .zip file.
|
|
41
|
+
output_dir: Directory to save downloaded mod files.
|
|
42
|
+
api_key: CurseForge API key.
|
|
43
|
+
max_concurrency: Maximum number of concurrent downloads.
|
|
44
|
+
"""
|
|
45
|
+
manifest = parse_manifest(modpack_path)
|
|
46
|
+
_print_modpack_info(manifest)
|
|
47
|
+
|
|
48
|
+
output = Path(output_dir)
|
|
49
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
async with CurseForgeClient(api_key) as client:
|
|
52
|
+
file_infos = await _resolve_file_infos(client, manifest)
|
|
53
|
+
await _download_all(client, file_infos, manifest.files, output, max_concurrency)
|
|
54
|
+
|
|
55
|
+
_extract_overrides_mods(modpack_path, manifest.overrides, output)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_overrides_mods(
|
|
59
|
+
modpack_path: str | Path,
|
|
60
|
+
overrides_dir: str,
|
|
61
|
+
output: Path,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Extract mod files from the overrides/mods directory in the modpack zip.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
modpack_path: Path to the modpack .zip file.
|
|
68
|
+
overrides_dir: Name of the overrides directory in the manifest.
|
|
69
|
+
output: Output directory for mod files.
|
|
70
|
+
"""
|
|
71
|
+
prefix = f"{overrides_dir}/mods/"
|
|
72
|
+
count = 0
|
|
73
|
+
|
|
74
|
+
with zipfile.ZipFile(modpack_path, "r") as zf:
|
|
75
|
+
for entry in zf.namelist():
|
|
76
|
+
if not entry.startswith(prefix):
|
|
77
|
+
continue
|
|
78
|
+
basename = entry.rsplit("/", 1)[-1]
|
|
79
|
+
if not basename:
|
|
80
|
+
continue
|
|
81
|
+
dest = output / basename
|
|
82
|
+
if dest.exists():
|
|
83
|
+
continue
|
|
84
|
+
with zf.open(entry) as src, open(dest, "wb") as dst:
|
|
85
|
+
shutil.copyfileobj(src, dst)
|
|
86
|
+
count += 1
|
|
87
|
+
|
|
88
|
+
if count:
|
|
89
|
+
console.print(f"[bold cyan]Overrides:[/] copied {count} files from {prefix}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _print_modpack_info(manifest: ModpackManifest) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Display modpack metadata to the console.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
manifest: Parsed modpack manifest.
|
|
98
|
+
"""
|
|
99
|
+
console.print()
|
|
100
|
+
console.print(f"[bold cyan]Modpack:[/] {manifest.name} v{manifest.version}")
|
|
101
|
+
console.print(f"[bold cyan]Author:[/] {manifest.author}")
|
|
102
|
+
console.print(f"[bold cyan]MC:[/] {manifest.minecraft_version}")
|
|
103
|
+
if manifest.mod_loaders:
|
|
104
|
+
console.print(f"[bold cyan]Loaders:[/] {', '.join(manifest.mod_loaders)}")
|
|
105
|
+
console.print(f"[bold cyan]Mods:[/] {len(manifest.files)} files to download")
|
|
106
|
+
console.print()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _resolve_file_infos(
|
|
110
|
+
client: CurseForgeClient,
|
|
111
|
+
manifest: ModpackManifest,
|
|
112
|
+
) -> dict[int, FileInfo]:
|
|
113
|
+
"""
|
|
114
|
+
Batch-fetch file metadata from the CurseForge API.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
client: Authenticated CurseForge API client.
|
|
118
|
+
manifest: Parsed modpack manifest with file entries.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Dict mapping file_id to FileInfo.
|
|
122
|
+
"""
|
|
123
|
+
file_ids = [f.file_id for f in manifest.files]
|
|
124
|
+
|
|
125
|
+
all_infos: list[FileInfo] = []
|
|
126
|
+
batch_size = 50
|
|
127
|
+
for i in range(0, len(file_ids), batch_size):
|
|
128
|
+
batch = file_ids[i : i + batch_size]
|
|
129
|
+
console.print(
|
|
130
|
+
f"[dim]Fetching file info batch "
|
|
131
|
+
f"{i // batch_size + 1}/{(len(file_ids) + batch_size - 1) // batch_size}"
|
|
132
|
+
f" ({len(batch)} files)...[/]"
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
infos = await client.get_files(batch)
|
|
136
|
+
all_infos.extend(infos)
|
|
137
|
+
except httpx.HTTPStatusError as exc:
|
|
138
|
+
console.print(
|
|
139
|
+
f"[bold red]API error fetching batch: {exc.response.status_code}[/]"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return {info.file_id: info for info in all_infos}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _download_all(
|
|
146
|
+
client: CurseForgeClient,
|
|
147
|
+
file_infos: dict[int, FileInfo],
|
|
148
|
+
entries: list[ModFileEntry],
|
|
149
|
+
output: Path,
|
|
150
|
+
max_concurrency: int,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Download all mod files concurrently with progress tracking.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
client: Authenticated CurseForge API client.
|
|
157
|
+
file_infos: Pre-fetched file metadata keyed by file_id.
|
|
158
|
+
entries: Original manifest file entries.
|
|
159
|
+
output: Output directory for downloads.
|
|
160
|
+
max_concurrency: Maximum concurrent downloads.
|
|
161
|
+
"""
|
|
162
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
163
|
+
success = 0
|
|
164
|
+
failed = 0
|
|
165
|
+
skipped = 0
|
|
166
|
+
|
|
167
|
+
with Progress(
|
|
168
|
+
SpinnerColumn(),
|
|
169
|
+
TextColumn("[progress.description]{task.description}"),
|
|
170
|
+
BarColumn(),
|
|
171
|
+
MofNCompleteColumn(),
|
|
172
|
+
TimeRemainingColumn(),
|
|
173
|
+
console=console,
|
|
174
|
+
) as progress:
|
|
175
|
+
task = progress.add_task("Downloading mods", total=len(entries))
|
|
176
|
+
|
|
177
|
+
async def _download_one(entry: ModFileEntry) -> None:
|
|
178
|
+
nonlocal success, failed, skipped
|
|
179
|
+
|
|
180
|
+
async with semaphore:
|
|
181
|
+
result = await _download_single_file(client, file_infos, entry, output)
|
|
182
|
+
|
|
183
|
+
if result == "success":
|
|
184
|
+
success += 1
|
|
185
|
+
elif result == "skipped":
|
|
186
|
+
skipped += 1
|
|
187
|
+
else:
|
|
188
|
+
failed += 1
|
|
189
|
+
progress.advance(task)
|
|
190
|
+
|
|
191
|
+
await asyncio.gather(*[_download_one(e) for e in entries])
|
|
192
|
+
|
|
193
|
+
console.print()
|
|
194
|
+
console.print(f"[bold green]Success:[/] {success}")
|
|
195
|
+
if skipped:
|
|
196
|
+
console.print(f"[bold yellow]Skipped:[/] {skipped}")
|
|
197
|
+
if failed:
|
|
198
|
+
console.print(f"[bold red]Failed:[/] {failed}")
|
|
199
|
+
console.print()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _download_single_file(
|
|
203
|
+
client: CurseForgeClient,
|
|
204
|
+
file_infos: dict[int, FileInfo],
|
|
205
|
+
entry: ModFileEntry,
|
|
206
|
+
output: Path,
|
|
207
|
+
) -> str:
|
|
208
|
+
"""
|
|
209
|
+
Download a single mod file.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
client: Authenticated CurseForge API client.
|
|
213
|
+
file_infos: Pre-fetched file metadata.
|
|
214
|
+
entry: The manifest file entry to download.
|
|
215
|
+
output: Output directory.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
"success", "skipped", or "failed".
|
|
219
|
+
"""
|
|
220
|
+
info = file_infos.get(entry.file_id)
|
|
221
|
+
|
|
222
|
+
if info is None:
|
|
223
|
+
try:
|
|
224
|
+
info = await client.get_mod_file(entry.project_id, entry.file_id)
|
|
225
|
+
except httpx.HTTPStatusError:
|
|
226
|
+
console.print(
|
|
227
|
+
f"[red]X Could not resolve file info: "
|
|
228
|
+
f"project={entry.project_id} file={entry.file_id}[/]"
|
|
229
|
+
)
|
|
230
|
+
return "failed"
|
|
231
|
+
|
|
232
|
+
file_name = info.file_name or f"{entry.project_id}_{entry.file_id}.jar"
|
|
233
|
+
|
|
234
|
+
if file_name.lower().endswith(".zip"):
|
|
235
|
+
return "skipped"
|
|
236
|
+
|
|
237
|
+
dest = output / file_name
|
|
238
|
+
|
|
239
|
+
if dest.exists():
|
|
240
|
+
return "skipped"
|
|
241
|
+
|
|
242
|
+
download_url = info.download_url
|
|
243
|
+
if not download_url:
|
|
244
|
+
try:
|
|
245
|
+
download_url = await client.get_mod_file_download_url(
|
|
246
|
+
entry.project_id, entry.file_id
|
|
247
|
+
)
|
|
248
|
+
except httpx.HTTPStatusError:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
if not download_url:
|
|
252
|
+
download_url = _construct_edge_url(entry.project_id, entry.file_id, file_name)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
async with httpx.AsyncClient(follow_redirects=True, timeout=60.0) as dl_client:
|
|
256
|
+
async with dl_client.stream("GET", download_url) as resp:
|
|
257
|
+
resp.raise_for_status()
|
|
258
|
+
with open(dest, "wb") as f:
|
|
259
|
+
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
|
260
|
+
f.write(chunk)
|
|
261
|
+
return "success"
|
|
262
|
+
except (httpx.HTTPStatusError, httpx.RequestError) as exc:
|
|
263
|
+
console.print(f"[red]X Failed to download {file_name}: {exc}[/]")
|
|
264
|
+
if dest.exists():
|
|
265
|
+
dest.unlink()
|
|
266
|
+
return "failed"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _construct_edge_url(project_id: int, file_id: int, file_name: str) -> str:
|
|
270
|
+
"""
|
|
271
|
+
Construct a CurseForge Edge CDN URL as a last-resort fallback.
|
|
272
|
+
|
|
273
|
+
The CDN URL pattern is:
|
|
274
|
+
https://edge.forgecdn.net/files/{first4}/{last3}/{fileName}
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
project_id: The mod project ID (unused but kept for consistency).
|
|
278
|
+
file_id: The file ID.
|
|
279
|
+
file_name: The filename.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Constructed CDN URL string.
|
|
283
|
+
"""
|
|
284
|
+
file_id_str = str(file_id)
|
|
285
|
+
first_part = file_id_str[:4]
|
|
286
|
+
second_part = file_id_str[4:]
|
|
287
|
+
return f"https://edge.forgecdn.net/files/{first_part}/{second_part}/{file_name}"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parse CurseForge modpack zip files and extract manifest information.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import zipfile
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ModFileEntry:
|
|
16
|
+
"""
|
|
17
|
+
Represents a single mod file entry from the manifest.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
project_id: The CurseForge project (mod) ID.
|
|
21
|
+
file_id: The CurseForge file ID.
|
|
22
|
+
required: Whether this mod is required by the modpack.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
project_id: int
|
|
26
|
+
file_id: int
|
|
27
|
+
required: bool = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ModpackManifest:
|
|
32
|
+
"""
|
|
33
|
+
Parsed CurseForge modpack manifest data.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
name: Modpack display name.
|
|
37
|
+
version: Modpack version string.
|
|
38
|
+
author: Modpack author.
|
|
39
|
+
minecraft_version: Target Minecraft version.
|
|
40
|
+
mod_loaders: List of mod loader descriptors (e.g. forge-47.3.0).
|
|
41
|
+
files: List of mod file entries to download.
|
|
42
|
+
overrides: Name of the overrides directory inside the zip.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
version: str
|
|
47
|
+
author: str
|
|
48
|
+
minecraft_version: str
|
|
49
|
+
mod_loaders: list[str] = field(default_factory=list)
|
|
50
|
+
files: list[ModFileEntry] = field(default_factory=list)
|
|
51
|
+
overrides: str = "overrides"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_manifest(modpack_path: str | Path) -> ModpackManifest:
|
|
55
|
+
"""
|
|
56
|
+
Extract and parse manifest.json from a CurseForge modpack zip.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
modpack_path: Path to the modpack .zip file.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A ModpackManifest containing all parsed data.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
FileNotFoundError: If the modpack file does not exist.
|
|
66
|
+
KeyError: If manifest.json is not found inside the zip.
|
|
67
|
+
ValueError: If the manifest JSON is malformed.
|
|
68
|
+
"""
|
|
69
|
+
modpack_path = Path(modpack_path)
|
|
70
|
+
if not modpack_path.exists():
|
|
71
|
+
raise FileNotFoundError(f"Modpack file not found: {modpack_path}")
|
|
72
|
+
|
|
73
|
+
with zipfile.ZipFile(modpack_path, "r") as zf:
|
|
74
|
+
manifest_name = _find_manifest(zf)
|
|
75
|
+
raw: dict[str, Any] = json.loads(zf.read(manifest_name))
|
|
76
|
+
|
|
77
|
+
return _build_manifest(raw)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _find_manifest(zf: zipfile.ZipFile) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Locate manifest.json inside the zip archive.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
zf: An opened ZipFile object.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The archive-internal path to manifest.json.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
KeyError: If no manifest.json is found.
|
|
92
|
+
"""
|
|
93
|
+
for name in zf.namelist():
|
|
94
|
+
basename = name.rsplit("/", 1)[-1] if "/" in name else name
|
|
95
|
+
if basename.lower() == "manifest.json":
|
|
96
|
+
return name
|
|
97
|
+
raise KeyError("manifest.json not found in modpack archive")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _build_manifest(raw: dict[str, Any]) -> ModpackManifest:
|
|
101
|
+
"""
|
|
102
|
+
Convert raw JSON dict into a ModpackManifest dataclass.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
raw: Parsed JSON from manifest.json.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A populated ModpackManifest.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ValueError: If required fields are missing.
|
|
112
|
+
"""
|
|
113
|
+
minecraft_block = raw.get("minecraft", {})
|
|
114
|
+
mc_version = minecraft_block.get("version", "unknown")
|
|
115
|
+
|
|
116
|
+
mod_loaders: list[str] = []
|
|
117
|
+
for loader in minecraft_block.get("modLoaders", []):
|
|
118
|
+
loader_id = loader.get("id", "")
|
|
119
|
+
if loader_id:
|
|
120
|
+
mod_loaders.append(loader_id)
|
|
121
|
+
|
|
122
|
+
files: list[ModFileEntry] = []
|
|
123
|
+
for entry in raw.get("files", []):
|
|
124
|
+
project_id = entry.get("projectID")
|
|
125
|
+
file_id = entry.get("fileID")
|
|
126
|
+
if project_id is None or file_id is None:
|
|
127
|
+
continue
|
|
128
|
+
files.append(
|
|
129
|
+
ModFileEntry(
|
|
130
|
+
project_id=int(project_id),
|
|
131
|
+
file_id=int(file_id),
|
|
132
|
+
required=bool(entry.get("required", True)),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if not files:
|
|
137
|
+
raise ValueError("Manifest contains no mod file entries")
|
|
138
|
+
|
|
139
|
+
return ModpackManifest(
|
|
140
|
+
name=raw.get("name", "Unknown Modpack"),
|
|
141
|
+
version=raw.get("version", "0.0.0"),
|
|
142
|
+
author=raw.get("author", "Unknown"),
|
|
143
|
+
minecraft_version=mc_version,
|
|
144
|
+
mod_loaders=mod_loaders,
|
|
145
|
+
files=files,
|
|
146
|
+
overrides=raw.get("overrides", "overrides"),
|
|
147
|
+
)
|