model-mirror-cli 0.2.0__py3-none-any.whl
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.
- model_mirror/__init__.py +7 -0
- model_mirror/audit.py +214 -0
- model_mirror/checksums.py +371 -0
- model_mirror/cli.py +2436 -0
- model_mirror/config.py +300 -0
- model_mirror/hub.py +901 -0
- model_mirror/lock.py +119 -0
- model_mirror/mirror.py +201 -0
- model_mirror/progress.py +260 -0
- model_mirror/removal.py +164 -0
- model_mirror/repair.py +399 -0
- model_mirror/state.py +150 -0
- model_mirror/torrent.py +453 -0
- model_mirror/torrent_coverage.py +400 -0
- model_mirror/torrent_hashes.py +168 -0
- model_mirror/torrent_import.py +620 -0
- model_mirror/torrent_publication.py +582 -0
- model_mirror/torrent_seed.py +228 -0
- model_mirror/verify.py +153 -0
- model_mirror_cli-0.2.0.dist-info/METADATA +550 -0
- model_mirror_cli-0.2.0.dist-info/RECORD +24 -0
- model_mirror_cli-0.2.0.dist-info/WHEEL +4 -0
- model_mirror_cli-0.2.0.dist-info/entry_points.txt +2 -0
- model_mirror_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
model_mirror/torrent.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
from .checksums import load_manifest, record_is_current
|
|
10
|
+
from .hub import read_snapshot_plan
|
|
11
|
+
from .state import read_verification_state
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
PUBLICATION_PROFILE = "hybrid-v1-v2-1"
|
|
15
|
+
DESCRIPTOR_SCHEMA = "model-mirror-publication"
|
|
16
|
+
DESCRIPTOR_VERSION = 1
|
|
17
|
+
DESCRIPTOR_INFO_KEY = b"x-model-mirror"
|
|
18
|
+
MIN_PIECE_LENGTH = 1024 * 1024
|
|
19
|
+
MAX_PIECE_LENGTH = 16 * 1024 * 1024
|
|
20
|
+
TARGET_PIECES = 128 * 1024
|
|
21
|
+
SAFE_COMMIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
22
|
+
RESERVED_TOP_LEVEL = {
|
|
23
|
+
".archive",
|
|
24
|
+
".cache",
|
|
25
|
+
".checksums",
|
|
26
|
+
".manifest",
|
|
27
|
+
".model-mirror",
|
|
28
|
+
".pad",
|
|
29
|
+
".verification",
|
|
30
|
+
".verification.lock",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TorrentPublicationError(RuntimeError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TorrentBackendUnavailable(TorrentPublicationError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class TorrentPayloadFile:
|
|
44
|
+
path: str
|
|
45
|
+
size: int
|
|
46
|
+
sha256: str
|
|
47
|
+
git_blob_sha1: str
|
|
48
|
+
lfs_sha256: str | None
|
|
49
|
+
blob_id: str | None
|
|
50
|
+
|
|
51
|
+
def bencode_value(self) -> dict[bytes, bytes | int]:
|
|
52
|
+
value: dict[bytes, bytes | int] = {
|
|
53
|
+
b"path": self.path.encode("utf-8"),
|
|
54
|
+
b"size": self.size,
|
|
55
|
+
b"sha256": self.sha256.encode("ascii"),
|
|
56
|
+
b"git_blob_sha1": self.git_blob_sha1.encode("ascii"),
|
|
57
|
+
}
|
|
58
|
+
if self.lfs_sha256 is not None:
|
|
59
|
+
value[b"lfs_sha256"] = self.lfs_sha256.encode("ascii")
|
|
60
|
+
if self.blob_id is not None:
|
|
61
|
+
value[b"blob_id"] = self.blob_id.encode("ascii")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class PublicationDescriptor:
|
|
67
|
+
repo_id: str
|
|
68
|
+
repo_type: str
|
|
69
|
+
resolved_commit: str
|
|
70
|
+
piece_length: int
|
|
71
|
+
files: tuple[TorrentPayloadFile, ...]
|
|
72
|
+
profile: str = PUBLICATION_PROFILE
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def total_size(self) -> int:
|
|
76
|
+
return sum(item.size for item in self.files)
|
|
77
|
+
|
|
78
|
+
def bencode_value(self) -> dict[bytes, object]:
|
|
79
|
+
return {
|
|
80
|
+
b"schema": DESCRIPTOR_SCHEMA.encode("ascii"),
|
|
81
|
+
b"version": DESCRIPTOR_VERSION,
|
|
82
|
+
b"profile": self.profile.encode("ascii"),
|
|
83
|
+
b"provider": b"huggingface",
|
|
84
|
+
b"repo_id": self.repo_id.encode("utf-8"),
|
|
85
|
+
b"repo_type": self.repo_type.encode("ascii"),
|
|
86
|
+
b"resolved_commit": self.resolved_commit.encode("ascii"),
|
|
87
|
+
b"piece_length": self.piece_length,
|
|
88
|
+
b"files": [item.bencode_value() for item in self.files],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class HybridMetainfo:
|
|
94
|
+
metainfo: bytes
|
|
95
|
+
descriptor_sha256: str
|
|
96
|
+
metainfo_sha256: str
|
|
97
|
+
infohash_v1: str
|
|
98
|
+
infohash_v2: str
|
|
99
|
+
magnet_uri: str
|
|
100
|
+
piece_length: int
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_publication_descriptor(
|
|
104
|
+
root: Path,
|
|
105
|
+
*,
|
|
106
|
+
repo_id: str,
|
|
107
|
+
repo_type: str,
|
|
108
|
+
) -> PublicationDescriptor:
|
|
109
|
+
state = read_verification_state(root)
|
|
110
|
+
if state is None:
|
|
111
|
+
raise TorrentPublicationError(f"verification state is missing: {root / '.verification'}")
|
|
112
|
+
if not state.clean:
|
|
113
|
+
raise TorrentPublicationError(f"mirror is not clean: {repo_id} state={state.status}")
|
|
114
|
+
if state.repo_id != repo_id or state.repo_type != repo_type:
|
|
115
|
+
raise TorrentPublicationError(
|
|
116
|
+
f"verification identity mismatch: expected {repo_type}:{repo_id}, "
|
|
117
|
+
f"found {state.repo_type}:{state.repo_id}"
|
|
118
|
+
)
|
|
119
|
+
if not state.resolved_commit or SAFE_COMMIT.fullmatch(state.resolved_commit) is None:
|
|
120
|
+
raise TorrentPublicationError(f"invalid resolved commit in verification state: {state.resolved_commit!r}")
|
|
121
|
+
|
|
122
|
+
snapshot = read_snapshot_plan(root)
|
|
123
|
+
if snapshot is None:
|
|
124
|
+
raise TorrentPublicationError(f"pinned snapshot is missing: {root / '.model-mirror' / 'snapshot.json'}")
|
|
125
|
+
if (
|
|
126
|
+
snapshot.repo_id != repo_id
|
|
127
|
+
or snapshot.repo_type != repo_type
|
|
128
|
+
or snapshot.resolved_commit != state.resolved_commit
|
|
129
|
+
):
|
|
130
|
+
raise TorrentPublicationError("pinned snapshot does not match the clean verification state")
|
|
131
|
+
|
|
132
|
+
manifest = load_manifest(root)
|
|
133
|
+
payload_files: list[TorrentPayloadFile] = []
|
|
134
|
+
seen: set[str] = set()
|
|
135
|
+
for item in sorted(snapshot.files, key=lambda candidate: candidate.path.encode("utf-8")):
|
|
136
|
+
rel = validate_payload_path(item.path)
|
|
137
|
+
if rel in seen:
|
|
138
|
+
raise TorrentPublicationError(f"duplicate payload path in pinned snapshot: {rel}")
|
|
139
|
+
seen.add(rel)
|
|
140
|
+
if item.size is None or item.size < 0:
|
|
141
|
+
raise TorrentPublicationError(f"missing or invalid expected size for {rel}")
|
|
142
|
+
|
|
143
|
+
path = root / PurePosixPath(rel)
|
|
144
|
+
validate_payload_file(root, path, rel)
|
|
145
|
+
stat = path.stat()
|
|
146
|
+
row = manifest.get(rel)
|
|
147
|
+
if not record_is_current(row, stat.st_size, stat.st_mtime_ns):
|
|
148
|
+
raise TorrentPublicationError(f"manifest record is missing or stale for {rel}")
|
|
149
|
+
if stat.st_size != item.size:
|
|
150
|
+
raise TorrentPublicationError(f"size mismatch for {rel}: expected {item.size}, got {stat.st_size}")
|
|
151
|
+
|
|
152
|
+
sha256 = require_hex(row.get("sha256"), 64, f"manifest SHA-256 for {rel}")
|
|
153
|
+
git_blob_sha1 = require_hex(row.get("git_blob_sha1"), 40, f"manifest Git blob SHA-1 for {rel}")
|
|
154
|
+
lfs_sha256 = optional_hex(item.lfs_sha256, 64, f"LFS SHA-256 for {rel}")
|
|
155
|
+
blob_id = optional_hex(item.blob_id, 40, f"upstream blob ID for {rel}")
|
|
156
|
+
if lfs_sha256 is None and blob_id is None:
|
|
157
|
+
raise TorrentPublicationError(f"upstream content identity is missing for {rel}")
|
|
158
|
+
if lfs_sha256 is not None and sha256 != lfs_sha256:
|
|
159
|
+
raise TorrentPublicationError(f"manifest SHA-256 does not match upstream LFS identity for {rel}")
|
|
160
|
+
if lfs_sha256 is None and git_blob_sha1 != blob_id:
|
|
161
|
+
raise TorrentPublicationError(f"manifest Git blob SHA-1 does not match upstream identity for {rel}")
|
|
162
|
+
|
|
163
|
+
payload_files.append(
|
|
164
|
+
TorrentPayloadFile(
|
|
165
|
+
path=rel,
|
|
166
|
+
size=item.size,
|
|
167
|
+
sha256=sha256,
|
|
168
|
+
git_blob_sha1=git_blob_sha1,
|
|
169
|
+
lfs_sha256=lfs_sha256,
|
|
170
|
+
blob_id=blob_id,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
total_size = sum(item.size for item in payload_files)
|
|
175
|
+
if not payload_files or total_size == 0:
|
|
176
|
+
raise TorrentPublicationError("a torrent publication requires at least one non-empty payload file")
|
|
177
|
+
return PublicationDescriptor(
|
|
178
|
+
repo_id=repo_id,
|
|
179
|
+
repo_type=repo_type,
|
|
180
|
+
resolved_commit=state.resolved_commit,
|
|
181
|
+
piece_length=select_piece_length(total_size),
|
|
182
|
+
files=tuple(payload_files),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def select_piece_length(total_size: int) -> int:
|
|
187
|
+
if total_size < 1:
|
|
188
|
+
raise ValueError("total size must be positive")
|
|
189
|
+
required = math.ceil(total_size / TARGET_PIECES)
|
|
190
|
+
selected = 1 << (required - 1).bit_length()
|
|
191
|
+
return min(MAX_PIECE_LENGTH, max(MIN_PIECE_LENGTH, selected))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def validate_payload_path(value: str) -> str:
|
|
195
|
+
if not value or "\\" in value or "\x00" in value:
|
|
196
|
+
raise TorrentPublicationError(f"unsafe payload path: {value!r}")
|
|
197
|
+
path = PurePosixPath(value)
|
|
198
|
+
if (
|
|
199
|
+
path.is_absolute()
|
|
200
|
+
or path.as_posix() != value
|
|
201
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
202
|
+
):
|
|
203
|
+
raise TorrentPublicationError(f"unsafe payload path: {value!r}")
|
|
204
|
+
normalized = path.as_posix()
|
|
205
|
+
if path.parts[0] in RESERVED_TOP_LEVEL:
|
|
206
|
+
raise TorrentPublicationError(f"payload path conflicts with model-mirror metadata: {value!r}")
|
|
207
|
+
return normalized
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def validate_payload_file(root: Path, path: Path, rel: str) -> None:
|
|
211
|
+
if not path.exists() or not path.is_file():
|
|
212
|
+
raise TorrentPublicationError(f"payload file is missing: {rel}")
|
|
213
|
+
current = root
|
|
214
|
+
for part in PurePosixPath(rel).parts:
|
|
215
|
+
current = current / part
|
|
216
|
+
if current.is_symlink():
|
|
217
|
+
raise TorrentPublicationError(f"payload path contains a symlink: {rel}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def require_hex(value: object, length: int, label: str) -> str:
|
|
221
|
+
text = str(value or "").lower()
|
|
222
|
+
if len(text) != length or any(character not in "0123456789abcdef" for character in text):
|
|
223
|
+
raise TorrentPublicationError(f"invalid {label}")
|
|
224
|
+
return text
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def optional_hex(value: object, length: int, label: str) -> str | None:
|
|
228
|
+
if value in {None, ""}:
|
|
229
|
+
return None
|
|
230
|
+
return require_hex(value, length, label)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def create_hybrid_metainfo(
|
|
234
|
+
root: Path,
|
|
235
|
+
descriptor: PublicationDescriptor,
|
|
236
|
+
*,
|
|
237
|
+
libtorrent_module=None,
|
|
238
|
+
) -> HybridMetainfo:
|
|
239
|
+
lt = libtorrent_module or load_libtorrent()
|
|
240
|
+
storage = lt.file_storage()
|
|
241
|
+
for item in descriptor.files:
|
|
242
|
+
storage.add_file(f"{root.name}/{item.path}", item.size)
|
|
243
|
+
creator = lt.create_torrent(storage, descriptor.piece_length, 0)
|
|
244
|
+
lt.set_piece_hashes(creator, str(root.parent))
|
|
245
|
+
metainfo_tree = creator.generate()
|
|
246
|
+
metainfo_tree.pop(b"creation date", None)
|
|
247
|
+
metainfo_tree[b"info"][DESCRIPTOR_INFO_KEY] = descriptor.bencode_value()
|
|
248
|
+
return hybrid_metainfo_from_tree(metainfo_tree, descriptor, libtorrent_module=lt)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def create_hybrid_metainfo_from_coverage(
|
|
252
|
+
root: Path,
|
|
253
|
+
descriptor: PublicationDescriptor,
|
|
254
|
+
coverage,
|
|
255
|
+
*,
|
|
256
|
+
libtorrent_module=None,
|
|
257
|
+
) -> HybridMetainfo:
|
|
258
|
+
"""Build hybrid metainfo from verified coverage without reading payload bytes."""
|
|
259
|
+
lt = libtorrent_module or load_libtorrent()
|
|
260
|
+
validate_coverage_for_descriptor(root, descriptor, coverage)
|
|
261
|
+
|
|
262
|
+
v1_pieces = bytearray()
|
|
263
|
+
file_tree: dict[bytes, object] = {}
|
|
264
|
+
piece_layers: dict[bytes, bytes] = {}
|
|
265
|
+
|
|
266
|
+
for item in descriptor.files:
|
|
267
|
+
row = coverage.files[item.path]
|
|
268
|
+
path_parts = [part.encode("utf-8") for part in PurePosixPath(item.path).parts]
|
|
269
|
+
v1_pieces.extend(b"".join(bytes.fromhex(value) for value in row.v1_piece_hashes))
|
|
270
|
+
|
|
271
|
+
leaf: dict[bytes, object] = {b"length": item.size}
|
|
272
|
+
if row.v2_file_root is not None:
|
|
273
|
+
file_root = bytes.fromhex(row.v2_file_root)
|
|
274
|
+
leaf[b"pieces root"] = file_root
|
|
275
|
+
if item.size > descriptor.piece_length:
|
|
276
|
+
piece_layers[file_root] = b"".join(
|
|
277
|
+
bytes.fromhex(value) for value in row.v2_piece_hashes
|
|
278
|
+
)
|
|
279
|
+
insert_file_tree_leaf(file_tree, path_parts, leaf)
|
|
280
|
+
|
|
281
|
+
metainfo_tree = {
|
|
282
|
+
b"info": {
|
|
283
|
+
b"file tree": file_tree,
|
|
284
|
+
b"files": hybrid_v1_files(descriptor),
|
|
285
|
+
b"meta version": 2,
|
|
286
|
+
b"name": root.name.encode("utf-8"),
|
|
287
|
+
b"piece length": descriptor.piece_length,
|
|
288
|
+
b"pieces": bytes(v1_pieces),
|
|
289
|
+
DESCRIPTOR_INFO_KEY: descriptor.bencode_value(),
|
|
290
|
+
},
|
|
291
|
+
b"piece layers": piece_layers,
|
|
292
|
+
}
|
|
293
|
+
return hybrid_metainfo_from_tree(metainfo_tree, descriptor, libtorrent_module=lt)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def hybrid_v1_files(descriptor: PublicationDescriptor) -> list[dict[bytes, object]]:
|
|
297
|
+
result: list[dict[bytes, object]] = []
|
|
298
|
+
multifile = len(descriptor.files) > 1
|
|
299
|
+
for item in descriptor.files:
|
|
300
|
+
result.append(
|
|
301
|
+
{
|
|
302
|
+
b"length": item.size,
|
|
303
|
+
b"path": [
|
|
304
|
+
part.encode("utf-8")
|
|
305
|
+
for part in PurePosixPath(item.path).parts
|
|
306
|
+
],
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
if multifile and item.size and item.size % descriptor.piece_length:
|
|
310
|
+
padding = descriptor.piece_length - (item.size % descriptor.piece_length)
|
|
311
|
+
result.append(
|
|
312
|
+
{
|
|
313
|
+
b"attr": b"p",
|
|
314
|
+
b"length": padding,
|
|
315
|
+
b"path": [b".pad", str(padding).encode("ascii")],
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def validate_coverage_for_descriptor(
|
|
322
|
+
root: Path,
|
|
323
|
+
descriptor: PublicationDescriptor,
|
|
324
|
+
coverage,
|
|
325
|
+
) -> None:
|
|
326
|
+
identity = (
|
|
327
|
+
coverage.repo_id,
|
|
328
|
+
coverage.repo_type,
|
|
329
|
+
coverage.resolved_commit,
|
|
330
|
+
coverage.profile,
|
|
331
|
+
coverage.piece_length,
|
|
332
|
+
)
|
|
333
|
+
expected_identity = (
|
|
334
|
+
descriptor.repo_id,
|
|
335
|
+
descriptor.repo_type,
|
|
336
|
+
descriptor.resolved_commit,
|
|
337
|
+
descriptor.profile,
|
|
338
|
+
descriptor.piece_length,
|
|
339
|
+
)
|
|
340
|
+
if identity != expected_identity:
|
|
341
|
+
raise TorrentPublicationError(
|
|
342
|
+
f"torrent coverage does not match publication descriptor: "
|
|
343
|
+
f"expected {expected_identity!r}, found {identity!r}"
|
|
344
|
+
)
|
|
345
|
+
expected_paths = {item.path for item in descriptor.files}
|
|
346
|
+
if set(coverage.files) != expected_paths:
|
|
347
|
+
missing = sorted(expected_paths - set(coverage.files))
|
|
348
|
+
extra = sorted(set(coverage.files) - expected_paths)
|
|
349
|
+
raise TorrentPublicationError(
|
|
350
|
+
f"torrent coverage file set does not match publication descriptor: "
|
|
351
|
+
f"missing={missing!r} extra={extra!r}"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
for item in descriptor.files:
|
|
355
|
+
row = coverage.files[item.path]
|
|
356
|
+
path = root / PurePosixPath(item.path)
|
|
357
|
+
validate_payload_file(root, path, item.path)
|
|
358
|
+
stat = path.stat()
|
|
359
|
+
expected_pieces = (item.size + descriptor.piece_length - 1) // descriptor.piece_length
|
|
360
|
+
if (
|
|
361
|
+
row.path != item.path
|
|
362
|
+
or row.size != item.size
|
|
363
|
+
or stat.st_size != item.size
|
|
364
|
+
or row.mtime_ns != stat.st_mtime_ns
|
|
365
|
+
or row.sha256 != item.sha256
|
|
366
|
+
or row.git_blob_sha1 != item.git_blob_sha1
|
|
367
|
+
or row.lfs_sha256 != item.lfs_sha256
|
|
368
|
+
or row.blob_id != item.blob_id
|
|
369
|
+
or len(row.v1_piece_hashes) != expected_pieces
|
|
370
|
+
or len(row.v2_piece_hashes) != expected_pieces
|
|
371
|
+
or (item.size > 0) != (row.v2_file_root is not None)
|
|
372
|
+
):
|
|
373
|
+
raise TorrentPublicationError(f"torrent coverage is incomplete or stale for {item.path}")
|
|
374
|
+
for value in row.v1_piece_hashes:
|
|
375
|
+
require_hex(value, 40, f"v1 piece hash for {item.path}")
|
|
376
|
+
for value in row.v2_piece_hashes:
|
|
377
|
+
require_hex(value, 64, f"v2 piece hash for {item.path}")
|
|
378
|
+
if row.v2_file_root is not None:
|
|
379
|
+
require_hex(row.v2_file_root, 64, f"v2 file root for {item.path}")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def insert_file_tree_leaf(
|
|
383
|
+
tree: dict[bytes, object],
|
|
384
|
+
path_parts: list[bytes],
|
|
385
|
+
leaf: dict[bytes, object],
|
|
386
|
+
) -> None:
|
|
387
|
+
current = tree
|
|
388
|
+
for part in path_parts[:-1]:
|
|
389
|
+
child = current.setdefault(part, {})
|
|
390
|
+
if not isinstance(child, dict) or b"" in child:
|
|
391
|
+
raise TorrentPublicationError("payload paths collide in the v2 file tree")
|
|
392
|
+
current = child
|
|
393
|
+
final = path_parts[-1]
|
|
394
|
+
if final in current:
|
|
395
|
+
raise TorrentPublicationError("payload paths collide in the v2 file tree")
|
|
396
|
+
current[final] = {b"": leaf}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def hybrid_metainfo_from_tree(
|
|
400
|
+
metainfo_tree: dict[bytes, object],
|
|
401
|
+
descriptor: PublicationDescriptor,
|
|
402
|
+
*,
|
|
403
|
+
libtorrent_module,
|
|
404
|
+
) -> HybridMetainfo:
|
|
405
|
+
lt = libtorrent_module
|
|
406
|
+
metainfo = bytes(lt.bencode(metainfo_tree))
|
|
407
|
+
torrent_info = lt.torrent_info(metainfo)
|
|
408
|
+
info_hashes = torrent_info.info_hashes()
|
|
409
|
+
if not info_hashes.has_v1() or not info_hashes.has_v2():
|
|
410
|
+
raise TorrentPublicationError("publication profile did not produce a hybrid v1/v2 torrent")
|
|
411
|
+
descriptor_bytes = bytes(lt.bencode(descriptor.bencode_value()))
|
|
412
|
+
return HybridMetainfo(
|
|
413
|
+
metainfo=metainfo,
|
|
414
|
+
descriptor_sha256=hashlib.sha256(descriptor_bytes).hexdigest(),
|
|
415
|
+
metainfo_sha256=hashlib.sha256(metainfo).hexdigest(),
|
|
416
|
+
infohash_v1=str(info_hashes.v1),
|
|
417
|
+
infohash_v2=str(info_hashes.v2),
|
|
418
|
+
magnet_uri=lt.make_magnet_uri(torrent_info),
|
|
419
|
+
piece_length=descriptor.piece_length,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def verified_seed_params(
|
|
424
|
+
metainfo: bytes,
|
|
425
|
+
payload_root: Path,
|
|
426
|
+
*,
|
|
427
|
+
libtorrent_module=None,
|
|
428
|
+
):
|
|
429
|
+
lt = libtorrent_module or load_libtorrent()
|
|
430
|
+
torrent_info = lt.torrent_info(metainfo)
|
|
431
|
+
if torrent_info.name() != payload_root.name:
|
|
432
|
+
raise TorrentPublicationError(
|
|
433
|
+
f"torrent payload root mismatch: expected {payload_root.name!r}, found {torrent_info.name()!r}"
|
|
434
|
+
)
|
|
435
|
+
params = lt.add_torrent_params()
|
|
436
|
+
params.ti = torrent_info
|
|
437
|
+
params.save_path = str(payload_root.parent)
|
|
438
|
+
params.have_pieces = [True] * torrent_info.num_pieces()
|
|
439
|
+
params.verified_pieces = [True] * torrent_info.num_pieces()
|
|
440
|
+
params.flags &= ~lt.torrent_flags.paused
|
|
441
|
+
params.flags &= ~lt.torrent_flags.auto_managed
|
|
442
|
+
return params
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def load_libtorrent():
|
|
446
|
+
try:
|
|
447
|
+
import libtorrent
|
|
448
|
+
except ModuleNotFoundError as exc:
|
|
449
|
+
raise TorrentBackendUnavailable(
|
|
450
|
+
"libtorrent is required for managed torrent support; "
|
|
451
|
+
"install the model-mirror-cli distribution with the 'torrent' extra"
|
|
452
|
+
) from exc
|
|
453
|
+
return libtorrent
|