first commit
This commit is contained in:
207
pytorrent/services/rtorrent/chunks.py
Normal file
207
pytorrent/services/rtorrent/chunks.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
|
||||
|
||||
_HEX_RE = re.compile(r"[0-9a-fA-F]")
|
||||
|
||||
|
||||
def _clean_hex_bitfield(value) -> str:
|
||||
"""Return only hexadecimal bitfield characters from rTorrent output."""
|
||||
# Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload.
|
||||
return "".join(_HEX_RE.findall(str(value or ""))).lower()
|
||||
|
||||
|
||||
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
|
||||
"""Decode an rTorrent hex bitfield into one bit per torrent piece."""
|
||||
# Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents.
|
||||
bits: list[int] = []
|
||||
for char in _clean_hex_bitfield(value):
|
||||
nibble = int(char, 16)
|
||||
bits.extend([
|
||||
1 if nibble & 0b1000 else 0,
|
||||
1 if nibble & 0b0100 else 0,
|
||||
1 if nibble & 0b0010 else 0,
|
||||
1 if nibble & 0b0001 else 0,
|
||||
])
|
||||
if limit is not None and limit >= 0:
|
||||
if len(bits) < limit:
|
||||
bits.extend([0] * (limit - len(bits)))
|
||||
return bits[:limit]
|
||||
return bits
|
||||
|
||||
|
||||
def _chunk_status(completed: int, total: int, seen: bool = False) -> str:
|
||||
"""Classify a visual chunk cell for CSS and filtering."""
|
||||
if total <= 0:
|
||||
return "missing"
|
||||
if completed >= total:
|
||||
return "complete"
|
||||
if completed <= 0:
|
||||
return "seen" if seen else "missing"
|
||||
return "partial"
|
||||
|
||||
|
||||
def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||
"""Reduce very large torrents to a browser-friendly number of visual cells."""
|
||||
# Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress.
|
||||
if max_cells <= 0 or len(cells) <= max_cells:
|
||||
return cells
|
||||
grouped: list[dict] = []
|
||||
scale = len(cells) / float(max_cells)
|
||||
for out_idx in range(max_cells):
|
||||
start = int(math.floor(out_idx * scale))
|
||||
end = int(math.floor((out_idx + 1) * scale))
|
||||
part = cells[start:max(end, start + 1)]
|
||||
if not part:
|
||||
continue
|
||||
completed = sum(int(c.get("completed") or 0) for c in part)
|
||||
total = sum(int(c.get("total") or 0) for c in part)
|
||||
seen = any(bool(c.get("seen")) for c in part)
|
||||
percent = round((completed / total) * 100.0, 2) if total > 0 else 0.0
|
||||
grouped.append({
|
||||
"index": out_idx,
|
||||
"first_chunk": int(part[0].get("first_chunk", 0)),
|
||||
"last_chunk": int(part[-1].get("last_chunk", 0)),
|
||||
"completed": completed,
|
||||
"total": total,
|
||||
"percent": percent,
|
||||
"seen": seen,
|
||||
"status": _chunk_status(completed, total, seen),
|
||||
"grouped": True,
|
||||
"unit_count": len(part),
|
||||
})
|
||||
return grouped
|
||||
|
||||
|
||||
def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]:
|
||||
"""Create one raw cell per real torrent piece."""
|
||||
# Note: The UI still groups these cells later when needed, but the source data remains exact per piece.
|
||||
cells: list[dict] = []
|
||||
for idx in range(max(0, int(total_chunks or 0))):
|
||||
completed = 1 if idx < len(have_bits) and have_bits[idx] else 0
|
||||
seen = idx < len(seen_bits) and bool(seen_bits[idx])
|
||||
cells.append({
|
||||
"index": idx,
|
||||
"first_chunk": idx,
|
||||
"last_chunk": idx,
|
||||
"completed": completed,
|
||||
"total": 1,
|
||||
"percent": 100.0 if completed else 0.0,
|
||||
"seen": seen,
|
||||
"status": _chunk_status(completed, 1, seen),
|
||||
"grouped": False,
|
||||
"unit_count": 1,
|
||||
})
|
||||
return cells
|
||||
|
||||
|
||||
def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
|
||||
"""Return ruTorrent-like visual chunk data for one torrent."""
|
||||
# Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks.
|
||||
c = client_for(profile)
|
||||
values = {
|
||||
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)),
|
||||
"seen": "",
|
||||
"chunk_size": 0,
|
||||
"size_chunks": 0,
|
||||
"completed_chunks": 0,
|
||||
"chunks_hashed": 0,
|
||||
}
|
||||
optional_calls = {
|
||||
"seen": "d.chunks_seen",
|
||||
"chunk_size": "d.chunk_size",
|
||||
"size_chunks": "d.size_chunks",
|
||||
"completed_chunks": "d.completed_chunks",
|
||||
"chunks_hashed": "d.chunks_hashed",
|
||||
}
|
||||
for key, method in optional_calls.items():
|
||||
try:
|
||||
raw = c.call(method, torrent_hash)
|
||||
values[key] = _clean_hex_bitfield(raw) if key == "seen" else int(raw or 0)
|
||||
except Exception:
|
||||
values[key] = "" if key == "seen" else 0
|
||||
|
||||
total_chunks = int(values["size_chunks"] or 0)
|
||||
completed = int(values["completed_chunks"] or 0)
|
||||
if total_chunks <= 0:
|
||||
total_chunks = max(completed, len(values["bitfield"]) * 4)
|
||||
|
||||
have_bits = _hex_to_bits(values["bitfield"], total_chunks)
|
||||
seen_bits = _hex_to_bits(values["seen"], total_chunks)
|
||||
cells = _build_piece_cells(total_chunks, have_bits, seen_bits)
|
||||
|
||||
visual_cells = _group_cells(cells, max(64, min(10000, int(max_cells or 2048))))
|
||||
return {
|
||||
"hash": torrent_hash,
|
||||
"chunk_size": int(values["chunk_size"] or 0),
|
||||
"chunk_size_h": human_size(values["chunk_size"] or 0),
|
||||
"size_chunks": total_chunks,
|
||||
"completed_chunks": completed,
|
||||
"chunks_hashed": int(values["chunks_hashed"] or 0),
|
||||
"bitfield_units": len(have_bits),
|
||||
"visual_cells": len(visual_cells),
|
||||
"grouped": len(visual_cells) != len(cells),
|
||||
"cells": visual_cells,
|
||||
"summary": {
|
||||
"complete": sum(1 for c in visual_cells if c.get("status") == "complete"),
|
||||
"partial": sum(1 for c in visual_cells if c.get("status") == "partial"),
|
||||
"missing": sum(1 for c in visual_cells if c.get("status") == "missing"),
|
||||
"seen": sum(1 for c in visual_cells if c.get("status") == "seen"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk: int, last_chunk: int) -> list[dict]:
|
||||
"""Find files whose rTorrent chunk range overlaps the selected visual cells."""
|
||||
# Note: rTorrent exposes file chunk coverage through f.range_first and f.range_second; the second value is exclusive.
|
||||
rows = c.f.multicall(torrent_hash, "", "f.path=", "f.range_first=", "f.range_second=", "f.priority=")
|
||||
matches = []
|
||||
for idx, row in enumerate(rows):
|
||||
start = int(row[1] or 0)
|
||||
end_exclusive = int(row[2] or 0)
|
||||
end = max(start, end_exclusive - 1)
|
||||
if start <= last_chunk and end >= first_chunk:
|
||||
matches.append({
|
||||
"index": idx,
|
||||
"path": str(row[0] or ""),
|
||||
"range_first": start,
|
||||
"range_second": end_exclusive,
|
||||
"priority": int(row[3] or 0),
|
||||
})
|
||||
return matches
|
||||
|
||||
|
||||
def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
|
||||
"""Run safe actions related to visual chunk selection."""
|
||||
# Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide.
|
||||
payload = payload or {}
|
||||
action = str(action or "").strip().lower()
|
||||
c = client_for(profile)
|
||||
if action == "recheck":
|
||||
c.call("d.check_hash", torrent_hash)
|
||||
return {"action": action, "message": "Torrent hash check queued", "scope": "torrent"}
|
||||
if action == "prioritize_files":
|
||||
first_chunk = max(0, int(payload.get("first_chunk") or 0))
|
||||
last_chunk = max(first_chunk, int(payload.get("last_chunk") if payload.get("last_chunk") is not None else first_chunk))
|
||||
priority = max(0, min(3, int(payload.get("priority") or 2)))
|
||||
matches = _files_touching_chunks(c, torrent_hash, first_chunk, last_chunk)
|
||||
if not matches:
|
||||
return {"action": action, "updated": [], "errors": [{"error": "No files overlap selected chunk range"}]}
|
||||
result = set_file_priorities(profile, torrent_hash, [{"index": m["index"], "priority": priority} for m in matches])
|
||||
try:
|
||||
c.call("d.update_priorities", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
result.update({"action": action, "files": matches, "priority": priority, "first_chunk": first_chunk, "last_chunk": last_chunk})
|
||||
return result
|
||||
raise ValueError("Unknown chunk action")
|
||||
|
||||
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
Reference in New Issue
Block a user