Automation
Higher-level helpers built on the client: monitoring the current user's torrents,
cross-seeding a release to another tracker, and cross-uploading one. These are
exported from the top-level pygazelle package.
Monitoring
Watch the current user's uploaded and snatched torrents for deletion or trump.
Construct a monitor with client.monitor(...).
pygazelle.TorrentMonitor
TorrentMonitor(
client: GazelleClient,
*,
sources: tuple[UserTorrentType, ...] = (
"uploaded",
"snatched",
),
page_size: int = 50,
)
Watches the current user's torrent lists and reports deletions/trumps.
Stateless from the caller's view: each poll() returns the changes since
the previous snapshot. The monitor runs no loop, timer, or callbacks — the
caller controls cadence. Build one via client.monitor(...).
Source code in src/pygazelle/monitoring.py
| def __init__(
self,
client: GazelleClient,
*,
sources: tuple[UserTorrentType, ...] = ("uploaded", "snatched"),
page_size: int = 50,
) -> None:
self._client = client
self._sources = tuple(sources)
self._page_size = page_size
self._user_id: int | None = None
self._snapshot: MonitorSnapshot | None = None
|
poll
async
Source code in src/pygazelle/monitoring.py
| async def poll(self) -> list[TorrentChangeEvent]:
if self._user_id is None:
self._user_id = (await self._client.user.me()).id
current = MonitorSnapshot()
for source in self._sources:
current.sources[source] = await self._fetch_source(self._user_id, source)
if self._snapshot is None:
self._snapshot = current
return []
events: list[TorrentChangeEvent] = []
for source in self._sources:
prior = self._snapshot.sources.get(source, {})
now = current.sources.get(source, {})
for torrent_id, entry in prior.items():
if torrent_id not in now:
events.append(await self._classify(source, entry))
# Atomic commit: only advance the snapshot once every fetch + lookup
# above succeeded. A mid-poll error leaves the prior snapshot intact.
self._snapshot = current
return events
|
dump_state
dump_state() -> dict[str, object] | None
A json-serializable snapshot, or None before the first poll.
Source code in src/pygazelle/monitoring.py
| def dump_state(self) -> dict[str, object] | None:
"""A json-serializable snapshot, or None before the first poll."""
if self._snapshot is None:
return None
return self._snapshot.model_dump(mode="json")
|
load_state
load_state(state: dict[str, object]) -> None
Restore a snapshot previously produced by dump_state().
Source code in src/pygazelle/monitoring.py
| def load_state(self, state: dict[str, object]) -> None:
"""Restore a snapshot previously produced by ``dump_state()``."""
self._snapshot = MonitorSnapshot.model_validate(state)
|
pygazelle.TorrentChangeEvent
Bases: GazelleModel
A classified change to a previously-watched torrent.
source
instance-attribute
torrent_id
instance-attribute
group_id
instance-attribute
replacement_torrent_id
class-attribute
instance-attribute
replacement_torrent_id: int | None = None
Cross-seed
Find the same release on a target tracker (metadata match + strict file-list
verification) and fetch its .torrent.
pygazelle.cross_seed
async
Find source_torrent_id (on source_client) on target_client and
return the target's .torrent. Returns None when the source has no file list
or no candidate exactly matches.
Source code in src/pygazelle/crossseed.py
| async def cross_seed(
source_client: GazelleClient,
source_torrent_id: int,
target_client: GazelleClient,
*,
max_deep_checks: int = DEFAULT_MAX_DEEP_CHECKS,
) -> CrossSeedResult | None:
"""Find ``source_torrent_id`` (on ``source_client``) on ``target_client`` and
return the target's .torrent. Returns None when the source has no file list
or no candidate exactly matches.
"""
source = await source_client.torrents.get(source_torrent_id)
if not source.files:
logger.info("cross-seed: source torrent %d has no file list", source_torrent_id)
return None
candidates = await find_candidates(source, target_client, max_deep_checks=max_deep_checks)
for candidate in candidates:
if verify_match(source, candidate):
torrent_file = await target_client.torrents.download(candidate.id)
return CrossSeedResult(
match=candidate,
torrent_file=torrent_file,
source_torrent_id=source_torrent_id,
target_torrent_id=candidate.id,
)
return None
|
pygazelle.cross_seed_sync
Synchronous cross-seed: runs the async cross_seed on the source client's
background loop and returns the result (or None) directly, no await.
Source code in src/pygazelle/sync.py
| def cross_seed_sync(
source_client: GazelleSyncClient,
source_torrent_id: int,
target_client: GazelleSyncClient,
*,
max_deep_checks: int | None = None,
) -> CrossSeedResult | None:
"""Synchronous cross-seed: runs the async cross_seed on the source client's
background loop and returns the result (or None) directly, no await.
"""
from .crossseed import DEFAULT_MAX_DEEP_CHECKS, cross_seed
return source_client._bg.run( # pyright: ignore[reportPrivateUsage]
cross_seed(
source_client._async, # pyright: ignore[reportPrivateUsage]
source_torrent_id,
target_client._async, # pyright: ignore[reportPrivateUsage]
max_deep_checks=DEFAULT_MAX_DEEP_CHECKS if max_deep_checks is None else max_deep_checks,
)
)
|
pygazelle.find_candidates
async
Search the target tracker by the source's artist/album, cheaply pre-filter
candidates on format/encoding/media/size/file_count, then fetch the full
file list for each survivor (bounded by max_deep_checks).
Source code in src/pygazelle/crossseed.py
| async def find_candidates(
source: Torrent,
target_client: GazelleClient,
*,
max_deep_checks: int = DEFAULT_MAX_DEEP_CHECKS,
) -> list[Torrent]:
"""Search the target tracker by the source's artist/album, cheaply pre-filter
candidates on format/encoding/media/size/file_count, then fetch the full
file list for each survivor (bounded by ``max_deep_checks``).
"""
artist = source.group.artists[0].name if source.group and source.group.artists else None
album = source.group.name if source.group else None
results = []
if artist:
params: dict[str, str | int] = {"artistname": artist}
if album:
params["groupname"] = album
results = await target_client.torrents.search("", **params)
if not results and album:
results = await target_client.torrents.search("", groupname=album)
candidate_ids: list[int] = []
for group in results:
for row in group.torrents:
if (
row.format == source.format
and row.encoding == source.encoding
and row.media == source.media
and row.size == source.size
and row.file_count == source.file_count
):
candidate_ids.append(row.torrent_id)
if len(candidate_ids) > max_deep_checks:
logger.warning(
"cross-seed: %d candidates exceed the deep-check cap (%d); checking only the first %d",
len(candidate_ids),
max_deep_checks,
max_deep_checks,
)
candidate_ids = candidate_ids[:max_deep_checks]
candidates: list[Torrent] = []
for cid in candidate_ids:
try:
candidates.append(await target_client.torrents.get(cid))
except GazelleNotFoundError:
continue
return candidates
|
pygazelle.verify_match
Strict cross-seed match: identical top-level folder and identical sorted
(path, size) file list. Empty file lists never match.
Source code in src/pygazelle/crossseed.py
| def verify_match(source: Torrent, candidate: Torrent) -> bool:
"""Strict cross-seed match: identical top-level folder and identical sorted
(path, size) file list. Empty file lists never match.
"""
if source.file_path != candidate.file_path:
return False
source_files = sorted((f.path, f.size) for f in source.files)
if not source_files:
return False
candidate_files = sorted((f.path, f.size) for f in candidate.files)
return source_files == candidate_files
|
pygazelle.CrossSeedResult
dataclass
CrossSeedResult(
match: Torrent,
torrent_file: bytes,
source_torrent_id: int,
target_torrent_id: int,
confidence: Literal["exact"] = "exact",
)
A confirmed cross-seed match plus the target tracker's .torrent bytes.
torrent_file
instance-attribute
source_torrent_id
instance-attribute
target_torrent_id
instance-attribute
confidence
class-attribute
instance-attribute
confidence: Literal['exact'] = 'exact'
Cross-upload
Two-phase upload of a release to another tracker: a read-only prepare_upload
(map metadata, detect duplicates, build a draft) followed by an explicit,
duplicate-gated submit_upload. The caller builds the target .torrent; the
library exposes the target announce URL via client.user.announce_url().
pygazelle.prepare_upload
async
Read-only: map metadata + detect duplicates + build an UploadDraft. No write.
Source code in src/pygazelle/crossupload.py
| async def prepare_upload(
source_client: GazelleClient,
source_torrent_id: int,
target_client: GazelleClient,
*,
torrent_file: bytes,
) -> UploadDraft:
"""Read-only: map metadata + detect duplicates + build an UploadDraft. No write."""
source = await source_client.torrents.get(source_torrent_id)
target = _tracker_kind(target_client)
mapped = map_metadata(source, target)
try:
duplicates = await duplicate_check(source, target_client)
except GazelleError as exc:
duplicates = []
mapped.warnings.append(f"duplicate check failed: {exc}")
return UploadDraft(
form=mapped.fields,
unmapped=mapped.unmapped,
warnings=mapped.warnings,
duplicates=duplicates,
torrent_file=torrent_file,
source_torrent_id=source_torrent_id,
target_tracker=target,
)
|
pygazelle.submit_upload
async
The live write: validate the draft, gate on exact duplicates, then POST
action=upload. Refuses (no write) on missing required fields or an unallowed
exact duplicate.
Source code in src/pygazelle/crossupload.py
| async def submit_upload(
target_client: GazelleClient,
draft: UploadDraft,
*,
allow_duplicate: bool = False,
) -> UploadResult:
"""The live write: validate the draft, gate on exact duplicates, then POST
action=upload. Refuses (no write) on missing required fields or an unallowed
exact duplicate."""
missing = _missing_required(draft)
if missing:
raise ValueError(f"cannot submit: missing required field(s): {', '.join(missing)}")
if not allow_duplicate and any(d.kind == "exact" for d in draft.duplicates):
raise ValueError(
"an exact duplicate exists on the target; pass allow_duplicate=True to override"
)
files = [
("file_input", ("upload.torrent", draft.torrent_file))
] # VERIFY upload file field name
data = await target_client._transport.request_write( # pyright: ignore[reportPrivateUsage]
"upload", data=dict(draft.form), files=files
)
# VERIFY response keys for the new torrent/group id.
torrent_id = int(data.get("torrentid") or data.get("torrentId") or 0)
group_id = int(data.get("groupid") or data.get("groupId") or 0)
return UploadResult(
torrent_id=torrent_id,
group_id=group_id,
url=f"torrents.php?id={group_id}&torrentid={torrent_id}",
)
|
pygazelle.prepare_upload_sync
Source code in src/pygazelle/sync.py
| def prepare_upload_sync(
source_client: GazelleSyncClient,
source_torrent_id: int,
target_client: GazelleSyncClient,
*,
torrent_file: bytes,
) -> UploadDraft:
from .crossupload import prepare_upload
return source_client._bg.run( # pyright: ignore[reportPrivateUsage]
prepare_upload(
source_client._async, # pyright: ignore[reportPrivateUsage]
source_torrent_id,
target_client._async, # pyright: ignore[reportPrivateUsage]
torrent_file=torrent_file,
)
)
|
pygazelle.submit_upload_sync
Source code in src/pygazelle/sync.py
| def submit_upload_sync(
target_client: GazelleSyncClient,
draft: UploadDraft,
*,
allow_duplicate: bool = False,
) -> UploadResult:
from .crossupload import submit_upload
return target_client._bg.run( # pyright: ignore[reportPrivateUsage]
submit_upload(target_client._async, draft, allow_duplicate=allow_duplicate) # pyright: ignore[reportPrivateUsage]
)
|
map_metadata(
source: Torrent, target: TrackerKind
) -> MappedForm
Best-effort map of a source release's metadata to the target upload schema.
Confident fields go to fields; anything unmappable/uncertain is recorded in
unmapped + warnings and never guessed.
Source code in src/pygazelle/crossupload.py
| def map_metadata(source: Torrent, target: TrackerKind) -> MappedForm:
"""Best-effort map of a source release's metadata to the target upload schema.
Confident fields go to ``fields``; anything unmappable/uncertain is recorded in
``unmapped`` + ``warnings`` and never guessed.
"""
out = MappedForm()
group = source.group
if group is not None:
out.fields["title"] = group.name
out.fields["year"] = group.year
out.fields["artists"] = [a.name for a in group.artists]
if group.tags:
out.fields["tags"] = list(group.tags)
out.warnings.append("tags carried over verbatim; review against the target's tag rules")
rt = group.release_type
mapped_rt = RELEASE_TYPE_MAP.get(target, {}).get(rt) if rt is not None else None
if mapped_rt is not None:
out.fields["release_type"] = mapped_rt
else:
out.unmapped.append("release_type")
out.warnings.append(f"release type {rt} has no {target} equivalent; set it manually")
else:
# No group metadata on the source: flag the group-derived fields for the
# caller rather than silently omitting them.
out.unmapped.extend(["title", "year", "artists", "release_type"])
out.warnings.append(
"source has no group metadata; title/year/artists/release_type must be set manually"
)
out.fields["format"] = source.format
out.fields["bitrate"] = source.encoding # Gazelle 'bitrate' carries the encoding value
out.fields["media"] = source.media
return out
|
pygazelle.duplicate_check
async
Search the target for releases matching the source; classify each as an
exact duplicate (file-list match) or a possible duplicate (same group/metadata).
Source code in src/pygazelle/crossupload.py
| async def duplicate_check(source: Torrent, target_client: GazelleClient) -> list[DuplicateMatch]:
"""Search the target for releases matching the source; classify each as an
exact duplicate (file-list match) or a possible duplicate (same group/metadata)."""
candidates = await find_candidates(source, target_client)
matches: list[DuplicateMatch] = []
for cand in candidates:
kind: DuplicateKind = "exact" if verify_match(source, cand) else "possible"
group_id = cand.group.id if cand.group else 0
name = cand.group.name if cand.group else ""
matches.append(DuplicateMatch(torrent_id=cand.id, group_id=group_id, kind=kind, name=name))
return matches
|
pygazelle.UploadDraft
dataclass
UploadDraft(
form: dict[str, Any],
unmapped: list[str],
warnings: list[str],
duplicates: list[DuplicateMatch],
torrent_file: bytes,
source_torrent_id: int,
target_tracker: TrackerKind,
)
Mutable: the caller fills form for any unmapped fields before submit.
unmapped
instance-attribute
warnings
instance-attribute
duplicates
instance-attribute
torrent_file
instance-attribute
source_torrent_id
instance-attribute
target_tracker
instance-attribute
target_tracker: TrackerKind
pygazelle.UploadResult
dataclass
UploadResult(torrent_id: int, group_id: int, url: str)
torrent_id
instance-attribute
group_id
instance-attribute
pygazelle.DuplicateMatch
dataclass
DuplicateMatch(
torrent_id: int,
group_id: int,
kind: DuplicateKind,
name: str,
)
torrent_id
instance-attribute
group_id
instance-attribute