Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ async def find_existing_torrents(self, meta: Meta) -> list[str]:
continue
torrent = Torrent.read(candidate)
has_subs = any(Path(str(file)).suffix.casefold() in SUBTITLE_EXTENSIONS for file in torrent.files)
entry = manifest.register(candidate, "base_subs" if has_subs else "base", f"client:{client_name}")
entry = manifest.register(candidate, "base_subs" if has_subs else "base", f"client:{client_name}", client_infohash=str(torrent.infohash))
managed = str(manifest.entry_path(entry))
if managed not in paths:
paths.append(managed)
Expand All @@ -319,6 +319,7 @@ async def find_existing_torrent(self, meta: Meta) -> str | None:
chosen = entries[0]
if chosen.origin.startswith("client:"):
meta.reuse_torrent_client = chosen.origin.removeprefix("client:")
meta.reuse_torrent_infohash = chosen.client_infohash
return str(manifest.entry_path(chosen))

async def _find_existing_torrent(self, meta: Meta) -> str | None:
Expand Down
1 change: 1 addition & 0 deletions src/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ class Meta:
retrieved_aka: str | None = None
retry_count: int = 0
reuse_torrent_client: str | None = None
reuse_torrent_infohash: str | None = None
reuse_torrent_path: str | None = None
rtorrent_label: str | None = None
runtime: int = 60
Expand Down
2 changes: 1 addition & 1 deletion src/prep_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,7 @@ async def process_trackers_and_torrent(
else:
meta.base_reuse_torrent_path = reuse_torrent_path
try:
meta.infohash = Torrent.read(reuse_torrent_path).infohash
meta.infohash = meta.reuse_torrent_infohash or Torrent.read(reuse_torrent_path).infohash
except Exception as e:
logger.debug(f"[yellow]Unable to read infohash from cached torrent: {e}")
# Fetch properties only: this preserves comment/tracker-ID discovery
Expand Down
14 changes: 11 additions & 3 deletions src/torrent_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class TorrentEntry:
piece_count: int
content_size: int
metainfo_size: int
client_infohash: str | None = None

@property
def stats(self) -> TorrentStats:
Expand Down Expand Up @@ -109,13 +110,21 @@ def _normalize(source: str | Path) -> Torrent:
torrent.private = True
return torrent

def register(self, source: str | Path, layout: TorrentLayout, origin: str, *, make_default: bool = False) -> TorrentEntry:
def register(self, source: str | Path, layout: TorrentLayout, origin: str, *, make_default: bool = False, client_infohash: str | None = None) -> TorrentEntry:
with self._lock:
torrent = self._normalize(source)
infohash = str(torrent.infohash)
entry_id = f"{layout}:{infohash}"
relative = Path("torrents") / str(int(torrent.piece_size)) / f"{infohash}.torrent"
output = self._resolve(relative.as_posix())
manifest = self._load()
torrents = cast(dict[str, Any], manifest["torrents"])
# Re-registering a managed base must retain its original client
# identity, rather than treating the normalized hash as a client hash.
previous = torrents.get(entry_id)
if Path(source).resolve() == output and isinstance(previous, dict):
origin = str(previous.get("origin", origin))
client_infohash = previous.get("client_infohash")
output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_suffix(".tmp")
Torrent.copy(torrent).write(temporary, overwrite=True)
Expand All @@ -131,9 +140,8 @@ def register(self, source: str | Path, layout: TorrentLayout, origin: str, *, ma
piece_count=stats.piece_count,
content_size=stats.content_size,
metainfo_size=stats.metainfo_size,
client_infohash=client_infohash,
)
manifest = self._load()
torrents = cast(dict[str, Any], manifest["torrents"])
torrents[entry_id] = asdict(entry)
defaults = cast(dict[str, Any], manifest["defaults"])
if make_default or layout not in defaults:
Expand Down
20 changes: 19 additions & 1 deletion tests/test_torrent_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from torf import Torrent

from src.torrent_manifest import TorrentEntry, TorrentManifest
from src.torrent_policy import ANTHELION_POLICY, PASSTHEPOPCORN_POLICY, MIB, TorrentPolicy, TorrentStats
from src.torrent_policy import ANTHELION_POLICY, MIB, PASSTHEPOPCORN_POLICY, TorrentPolicy, TorrentStats
from src.torrent_provision import provision_tracker_torrents
from src.torrentcreate import TorrentCreator
from src.trackers.common import Common
Expand Down Expand Up @@ -59,6 +59,24 @@ def test_manifest_keeps_layouts_separate_and_deduplicates(tmp_path):
assert len(manifest.entries("base_subs")) == 1


def test_client_infohash_survives_normalization_and_managed_reregistration(tmp_path):
source = tmp_path / "fictional-release.torrent"
torrent = write_torrent(source)
torrent.source = "CLIENT"
torrent.write(source, overwrite=True)
original_hash = torrent.infohash
manifest = TorrentManifest(tmp_path, "fictional-release")

entry = manifest.register(source, "base", "client:fictional-qbit", client_infohash=original_hash)
assert entry.infohash != original_hash
assert entry.client_infohash == original_hash

managed = manifest.entry_path(entry)
repeated = manifest.register(managed, "base", "client")
assert repeated.client_infohash == original_hash
assert repeated.origin == "client:fictional-qbit"


def test_explicit_default_replaces_the_previous_default(tmp_path):
first = tmp_path / "first.torrent"
second = tmp_path / "second.torrent"
Expand Down
36 changes: 36 additions & 0 deletions tests/test_torrent_reuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,47 @@

from src.clients import Clients
from src.meta import Meta
from src.prep_helpers import process_trackers_and_torrent
from src.torrent_manifest import TorrentManifest
from src.torrent_policy import MIB, TorrentPolicy
from src.torrentcreate import TorrentCreator


@pytest.mark.asyncio
async def test_preparation_looks_up_original_client_hash_after_reuse(tmp_path, monkeypatch):
media = tmp_path / "fictional-release.mkv"
source = tmp_path / "fictional-release.torrent"
torrent = Torrent()
torrent.metainfo["info"] = {"name": media.name, "length": 8 * MIB, "piece length": 4 * MIB, "pieces": b"x" * 40}
torrent.source = "CLIENT"
torrent.write(source, overwrite=True)
original_hash = torrent.infohash

async def find_candidate(_self, _meta, _client_name, *_args):
return [str(source)]

looked_up = []

async def capture_lookup(_self, meta, **_kwargs):
looked_up.append(meta.infohash)
return meta

monkeypatch.setattr(Clients, "_search_single_client_for_torrent", find_candidate)
monkeypatch.setattr(Clients, "get_ptp_from_hash", capture_lookup)
config = {
"DEFAULT": {"default_torrent_client": "fictional-qbit"},
"TORRENT_CLIENTS": {"fictional-qbit": {"torrent_client": "qbit"}},
"TRACKERS": {},
}
meta = Meta({"base_dir": str(tmp_path), "uuid": "fictional-release", "path": str(media), "filelist": [str(media)], "client": "fictional-qbit", "trackers": ["ANTHELION"]})

await process_trackers_and_torrent(SimpleNamespace(config=config), meta, Clients(config), [], [], "", "")

assert looked_up == [original_hash]
assert meta.reuse_torrent_client == "fictional-qbit"
assert Torrent.read(meta.reuse_torrent_path).infohash != original_hash


@pytest.mark.asyncio
async def test_base_subs_contains_external_subtitle_with_custom_torrent(tmp_path):
video = tmp_path / "release.mkv"
Expand Down
Loading