unit3dprep 1.2.0__tar.gz → 1.2.1__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.
Files changed (60) hide show
  1. {unit3dprep-1.2.0/unit3dprep.egg-info → unit3dprep-1.2.1}/PKG-INFO +1 -1
  2. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/pyproject.toml +1 -1
  3. unit3dprep-1.2.1/unit3dprep/web/duplicate_check.py +398 -0
  4. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/reseed.py +1 -9
  5. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/webup_orchestrator.py +281 -53
  6. {unit3dprep-1.2.0 → unit3dprep-1.2.1/unit3dprep.egg-info}/PKG-INFO +1 -1
  7. unit3dprep-1.2.0/unit3dprep/web/duplicate_check.py +0 -168
  8. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/LICENSE +0 -0
  9. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/MANIFEST.in +0 -0
  10. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/README.md +0 -0
  11. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/setup.cfg +0 -0
  12. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/__init__.py +0 -0
  13. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/cli.py +0 -0
  14. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/core.py +0 -0
  15. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/i18n.py +0 -0
  16. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/media.py +0 -0
  17. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/upload.py +0 -0
  18. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/__init__.py +0 -0
  19. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/_env.py +0 -0
  20. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/__init__.py +0 -0
  21. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/auth.py +0 -0
  22. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/fs.py +0 -0
  23. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/library.py +0 -0
  24. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/logs.py +0 -0
  25. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/queue.py +0 -0
  26. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/quickupload.py +0 -0
  27. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/reseed.py +0 -0
  28. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/search.py +0 -0
  29. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/settings.py +0 -0
  30. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/tmdb.py +0 -0
  31. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/trackers.py +0 -0
  32. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/uploaded.py +0 -0
  33. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/version.py +0 -0
  34. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/webup.py +0 -0
  35. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/wizard.py +0 -0
  36. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/app.py +0 -0
  37. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/auth.py +0 -0
  38. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/clients.py +0 -0
  39. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/config.py +0 -0
  40. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/db.py +0 -0
  41. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
  42. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
  43. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
  44. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/assets/index-CPw9mcxL.js +0 -0
  45. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/assets/index-FFoOmpDN.css +0 -0
  46. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/index.html +0 -0
  47. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/lang_cache.py +0 -0
  48. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/logbuf.py +0 -0
  49. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/tmdb_cache.py +0 -0
  50. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/tocheck.py +0 -0
  51. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/trackers.py +0 -0
  52. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/webup_client.py +0 -0
  53. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/webup_job_fix.py +0 -0
  54. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/webup_logclass.py +0 -0
  55. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/webup_ws.py +0 -0
  56. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep.egg-info/SOURCES.txt +0 -0
  57. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep.egg-info/dependency_links.txt +0 -0
  58. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep.egg-info/entry_points.txt +0 -0
  59. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep.egg-info/requires.txt +0 -0
  60. {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unit3dprep
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Web UI + CLI di pre-flight per tracker Unit3D, companion di Unit3DWebUp (audio ITA, nomenclatura ItaTorrents, hardlink, upload)
5
5
  Author: Davide Sidoti
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unit3dprep"
7
- version = "1.2.0"
7
+ version = "1.2.1"
8
8
  description = "Web UI + CLI di pre-flight per tracker Unit3D, companion di Unit3DWebUp (audio ITA, nomenclatura ItaTorrents, hardlink, upload)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -0,0 +1,398 @@
1
+ """Tracker lookups by TMDB id + content fingerprint.
2
+
3
+ Two users of the same primitive:
4
+
5
+ * :func:`find_duplicate` — pre-upload duplicate detection. Webup 0.0.25 does
6
+ not implement it (`DUPLICATE_ON` / `SKIP_DUPLICATE` are commented
7
+ `# Todo Not yet implemented` in its `config/settings.py`). The legacy
8
+ `unit3dup` CLI used to query the tracker by TMDB id and refuse the upload
9
+ when an existing torrent had the *exact* same file size in bytes —
10
+ irrespective of name/encode/etc. We replicate that as a bridge pre-flight.
11
+ Triggered by the `W_DUPLICATE_CHECK` runtime setting (default ON).
12
+ * :func:`find_recent_match` — post-upload confirmation, used when webup cannot
13
+ tell us whether the tracker accepted the torrent.
14
+
15
+ Both identify a torrent by *content* (file count, per-file byte sizes, total
16
+ size), never by name.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ import time
23
+ from datetime import datetime, timedelta, timezone
24
+ from typing import Any
25
+
26
+ import httpx
27
+
28
+ log = logging.getLogger("unit3dprep.duplicate_check")
29
+
30
+ _TIMEOUT = httpx.Timeout(15.0, connect=5.0)
31
+
32
+ # How far back a tracker entry may have been created and still count as "the
33
+ # torrent we just uploaded". Generous, to absorb clock skew against the tracker.
34
+ DEFAULT_RECENT_WINDOW = 1800.0
35
+
36
+ # How long a freshly accepted torrent stays invisible to the API varies a lot:
37
+ # measured on ITT 2026-08-01, one upload was listed within seconds while the
38
+ # next one (two minutes later) took several minutes. Keep polling for ~6 min
39
+ # before concluding it never landed — the alternative is a torrent published on
40
+ # the tracker that nobody seeds.
41
+ _RECENT_BACKOFF = (3.0, 6.0, 12.0, 20.0, 30.0, 45.0, 60.0, 60.0, 60.0, 60.0)
42
+
43
+
44
+ def _retry_after_seconds(resp: httpx.Response) -> float:
45
+ """``Retry-After`` in seconds, 0 when absent or unparseable."""
46
+ try:
47
+ ra = resp.headers.get("retry-after")
48
+ return float(ra) if ra else 0.0
49
+ except (TypeError, ValueError):
50
+ return 0.0
51
+
52
+
53
+ def _to_int(v: Any) -> int | None:
54
+ try:
55
+ return int(v) if v is not None else None
56
+ except (TypeError, ValueError):
57
+ return None
58
+
59
+
60
+ def _entry_file_sizes(attrs: dict[str, Any]) -> list[int]:
61
+ """Sorted per-file byte sizes from a tracker torrent's ``files`` list."""
62
+ files = attrs.get("files")
63
+ if not isinstance(files, list):
64
+ return []
65
+ sizes: list[int] = []
66
+ for f in files:
67
+ if isinstance(f, dict):
68
+ s = _to_int(f.get("size"))
69
+ if s is not None:
70
+ sizes.append(s)
71
+ return sorted(sizes)
72
+
73
+
74
+ def _entry_delta(
75
+ attrs: dict[str, Any],
76
+ *,
77
+ num_files: int | None,
78
+ total_size: int,
79
+ file_sizes: list[int] | None,
80
+ tolerance_bytes: int,
81
+ ) -> int | None:
82
+ """Absolute size delta if ``attrs`` matches the fingerprint, else ``None``.
83
+
84
+ A match requires the same file count (when both sides expose ``num_file``)
85
+ and a total size within ``tolerance_bytes``. In exact mode (tolerance 0)
86
+ the per-file size multiset must also match when both sides provide it —
87
+ this rules out same-total/same-count torrents with a different make-up.
88
+ Name/encode/release-group are irrelevant.
89
+ """
90
+ existing_size = _to_int(attrs.get("size"))
91
+ if existing_size is None or existing_size <= 0:
92
+ return None
93
+ existing_num = _to_int(attrs.get("num_file"))
94
+ if num_files and existing_num and existing_num != num_files:
95
+ return None
96
+ delta = abs(existing_size - total_size)
97
+ if delta > tolerance_bytes:
98
+ return None
99
+ if tolerance_bytes == 0 and file_sizes:
100
+ existing_sizes = _entry_file_sizes(attrs)
101
+ if existing_sizes and existing_sizes != sorted(file_sizes):
102
+ return None
103
+ return delta
104
+
105
+
106
+ def _parse_created_at(value: Any) -> datetime | None:
107
+ """Parse a tracker ``created_at`` into an aware datetime (UTC assumed)."""
108
+ if not isinstance(value, str) or not value.strip():
109
+ return None
110
+ try:
111
+ dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
112
+ except ValueError:
113
+ return None
114
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
115
+
116
+
117
+ def _summarize(entry: dict[str, Any], attrs: dict[str, Any], tmdb_int: int, delta: int) -> dict[str, Any]:
118
+ """Flatten a tracker entry into the dict shape the API/UI consume."""
119
+ return {
120
+ "id": entry.get("id") or attrs.get("id"),
121
+ "name": attrs.get("name"),
122
+ "size": _to_int(attrs.get("size")),
123
+ "num_file": _to_int(attrs.get("num_file")),
124
+ "type": attrs.get("type"),
125
+ "resolution": attrs.get("resolution"),
126
+ "category": attrs.get("category"),
127
+ "uploader": attrs.get("uploader"),
128
+ "seeders": attrs.get("seeders"),
129
+ "leechers": attrs.get("leechers"),
130
+ "created_at": attrs.get("created_at"),
131
+ "details_link": attrs.get("details_link"),
132
+ # Carries the rsskey — the only way to fetch the .torrent (see
133
+ # reseed.download_torrent_file). Saves re-querying the show endpoint.
134
+ "download_link": attrs.get("download_link") or "",
135
+ "tmdb_id": tmdb_int,
136
+ "size_delta": delta,
137
+ "approx": delta > 0,
138
+ }
139
+
140
+
141
+ def _prepare(
142
+ tmdb_id: int | str | None, total_size: int | None, tolerance_pct: float,
143
+ ) -> tuple[int, int, int] | None:
144
+ """``(tmdb_id, total_bytes, tolerance_bytes)`` or ``None`` if unusable."""
145
+ if not tmdb_id or not total_size:
146
+ return None
147
+ try:
148
+ total_int = int(total_size)
149
+ tmdb_int = int(tmdb_id)
150
+ except (TypeError, ValueError):
151
+ return None
152
+ if total_int <= 0 or tmdb_int <= 0:
153
+ return None
154
+ try:
155
+ tol = max(0.0, float(tolerance_pct))
156
+ except (TypeError, ValueError):
157
+ tol = 0.0
158
+ return tmdb_int, total_int, int(round(total_int * tol / 100.0))
159
+
160
+
161
+ async def _fetch_rows(
162
+ url: str, params: dict[str, str], *, throttle_retries: int = 2,
163
+ ) -> list[dict[str, Any]] | None:
164
+ """GET a Unit3D torrent listing. ``None`` marks a failed API call — callers
165
+ must not read that as "nothing on the tracker".
166
+
167
+ Unit3D throttles its API, so a 429 is waited out (honouring ``Retry-After``,
168
+ capped) rather than reported as a failure.
169
+ """
170
+ try:
171
+ async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as client:
172
+ for attempt in range(throttle_retries + 1):
173
+ r = await client.get(url, params=params)
174
+ if r.status_code == 429 and attempt < throttle_retries:
175
+ wait = min(_retry_after_seconds(r) or 10.0, 30.0)
176
+ log.info("tracker 429 — waiting %.0fs then retrying", wait)
177
+ await asyncio.sleep(wait)
178
+ continue
179
+ r.raise_for_status()
180
+ payload = r.json()
181
+ break
182
+ else: # pragma: no cover — loop always breaks or raises
183
+ return None
184
+ except (httpx.HTTPError, ValueError) as e:
185
+ log.warning("tracker query failed (%s): %s", url, e)
186
+ return None
187
+ items = payload.get("data") if isinstance(payload, dict) else None
188
+ if not isinstance(items, list):
189
+ return []
190
+ return [e for e in items if isinstance(e, dict)]
191
+
192
+
193
+ def _bust(params: dict[str, str], nonce: str | None) -> dict[str, str]:
194
+ """Add a throwaway query param so a response cached against the exact query
195
+ string cannot be replayed to us. Unit3D ignores unknown params."""
196
+ if not nonce:
197
+ return params
198
+ return {**params, "_": nonce}
199
+
200
+
201
+ async def _fetch_entries(
202
+ tracker_url: str, api_token: str, tmdb_int: int, *,
203
+ throttle_retries: int = 2, nonce: str | None = None,
204
+ ) -> list[dict[str, Any]] | None:
205
+ """Tracker entries for a TMDB id, via the search/filter endpoint."""
206
+ base = tracker_url.rstrip("/")
207
+ return await _fetch_rows(
208
+ f"{base}/api/torrents/filter",
209
+ _bust({"tmdbId": str(tmdb_int), "api_token": api_token, "perPage": "100"}, nonce),
210
+ throttle_retries=throttle_retries,
211
+ )
212
+
213
+
214
+ async def _fetch_newest(
215
+ tracker_url: str, api_token: str, *, per_page: int = 50,
216
+ throttle_retries: int = 2, nonce: str | None = None,
217
+ ) -> list[dict[str, Any]] | None:
218
+ """The most recently uploaded torrents, newest first.
219
+
220
+ ``/api/torrents/filter`` lags badly behind a fresh upload — measured on ITT
221
+ 2026-08-01: a torrent created at T was still missing from the filter results
222
+ at T+2min and only showed up around T+7min, while the plain list endpoint
223
+ (a straight newest-first listing) had it. So confirming a just-finished
224
+ upload starts here, and only falls back to the TMDB filter.
225
+ """
226
+ base = tracker_url.rstrip("/")
227
+ return await _fetch_rows(
228
+ f"{base}/api/torrents",
229
+ _bust({"api_token": api_token, "perPage": str(per_page)}, nonce),
230
+ throttle_retries=throttle_retries,
231
+ )
232
+
233
+
234
+ async def find_duplicate(
235
+ *,
236
+ tracker_url: str,
237
+ api_token: str,
238
+ tmdb_id: int | str | None,
239
+ num_files: int | None,
240
+ total_size: int | None,
241
+ file_sizes: list[int] | None = None,
242
+ tolerance_pct: float = 0.0,
243
+ ) -> dict[str, Any] | None:
244
+ """Query the tracker for an existing torrent matching the local fingerprint.
245
+
246
+ The fingerprint is the number of video files (``num_files``), their total
247
+ byte size (``total_size``) and — optionally — the sorted list of per-file
248
+ byte sizes (``file_sizes``). ``tolerance_pct`` widens the total-size match
249
+ to catch near-identical re-encodes (e.g. 12.00 vs 12.02 GB); ``0`` means an
250
+ exact match and additionally compares the per-file size multiset.
251
+
252
+ Returns a dict with the matched torrent details (the closest one when
253
+ several qualify) or ``None`` when nothing matches, an input is missing, or
254
+ the API call fails. ``None`` is always safe to treat as "no duplicate".
255
+ """
256
+ prepared = _prepare(tmdb_id, total_size, tolerance_pct)
257
+ if not tracker_url or not api_token or prepared is None:
258
+ return None
259
+ tmdb_int, total_int, tolerance_bytes = prepared
260
+
261
+ entries = await _fetch_entries(tracker_url, api_token, tmdb_int)
262
+ if not entries:
263
+ return None
264
+
265
+ best: dict[str, Any] | None = None
266
+ best_delta = -1
267
+ for entry in entries:
268
+ attrs = entry.get("attributes") or {}
269
+ delta = _entry_delta(
270
+ attrs,
271
+ num_files=num_files,
272
+ total_size=total_int,
273
+ file_sizes=file_sizes,
274
+ tolerance_bytes=tolerance_bytes,
275
+ )
276
+ if delta is None:
277
+ continue
278
+ if best is None or delta < best_delta:
279
+ best_delta = delta
280
+ best = _summarize(entry, attrs, tmdb_int, delta)
281
+ if best_delta == 0:
282
+ break
283
+ return best
284
+
285
+
286
+ def _select_recent(
287
+ entries: list[dict[str, Any]],
288
+ *,
289
+ num_files: int | None,
290
+ total_size: int,
291
+ file_sizes: list[int] | None,
292
+ tolerance_bytes: int,
293
+ cutoff: datetime,
294
+ tmdb_int: int,
295
+ require_tmdb: bool = True,
296
+ ) -> dict[str, Any] | None:
297
+ """Newest entry matching the fingerprint and created at/after ``cutoff``.
298
+
299
+ An entry without a parseable ``created_at`` is skipped: we cannot tell it
300
+ apart from a torrent that was already on the tracker. With ``require_tmdb``
301
+ an entry that advertises a different ``tmdb_id`` is skipped too — the
302
+ newest-uploads listing spans every title, so the fingerprint alone could in
303
+ principle collide with somebody else's upload in the same window.
304
+ """
305
+ newest: dict[str, Any] | None = None
306
+ newest_at: datetime | None = None
307
+ for entry in entries:
308
+ attrs = entry.get("attributes") or {}
309
+ entry_tmdb = _to_int(attrs.get("tmdb_id"))
310
+ if require_tmdb and entry_tmdb is not None and entry_tmdb != tmdb_int:
311
+ continue
312
+ delta = _entry_delta(
313
+ attrs,
314
+ num_files=num_files,
315
+ total_size=total_size,
316
+ file_sizes=file_sizes,
317
+ tolerance_bytes=tolerance_bytes,
318
+ )
319
+ if delta is None:
320
+ continue
321
+ created = _parse_created_at(attrs.get("created_at"))
322
+ if created is None or created < cutoff:
323
+ continue
324
+ if newest_at is None or created > newest_at:
325
+ newest_at = created
326
+ newest = _summarize(entry, attrs, tmdb_int, delta)
327
+ return newest
328
+
329
+
330
+ async def find_recent_match(
331
+ *,
332
+ tracker_url: str,
333
+ api_token: str,
334
+ tmdb_id: int | str | None,
335
+ num_files: int | None,
336
+ total_size: int | None,
337
+ file_sizes: list[int] | None = None,
338
+ within_seconds: float = DEFAULT_RECENT_WINDOW,
339
+ backoff: tuple[float, ...] = _RECENT_BACKOFF,
340
+ on_attempt: Any = None,
341
+ ) -> dict[str, Any] | None:
342
+ """Confirm that a torrent we just sent actually reached the tracker.
343
+
344
+ Webup raises when the tracker answers the upload POST with anything other
345
+ than JSON (ITT replies ``200 text/html`` on success, and
346
+ `itt_tracker_helper._post` calls `resp.json()` unconditionally), so a
347
+ perfectly successful upload surfaces to us as an HTTP 500. This looks the
348
+ torrent up by TMDB id + exact content fingerprint instead of trusting that
349
+ response.
350
+
351
+ The match is exact — same file count, same per-file sizes, same total — and
352
+ the entry must be younger than ``within_seconds``, so an older duplicate is
353
+ never mistaken for our upload.
354
+
355
+ A just-accepted torrent is not immediately visible on the filter endpoint,
356
+ so the lookup is retried along ``backoff`` (delays in seconds between
357
+ attempts) before concluding the upload did not land. ``on_attempt`` is an
358
+ optional ``(index, total, found) -> None`` callback for progress reporting.
359
+ """
360
+ prepared = _prepare(tmdb_id, total_size, 0.0)
361
+ if not tracker_url or not api_token or prepared is None:
362
+ return None
363
+ tmdb_int, total_int, tolerance_bytes = prepared
364
+ cutoff = datetime.now(timezone.utc) - timedelta(seconds=max(0.0, within_seconds))
365
+
366
+ def _pick(entries: list[dict[str, Any]] | None) -> dict[str, Any] | None:
367
+ if not entries:
368
+ return None
369
+ return _select_recent(
370
+ entries,
371
+ num_files=num_files,
372
+ total_size=total_int,
373
+ file_sizes=file_sizes,
374
+ tolerance_bytes=tolerance_bytes,
375
+ cutoff=cutoff,
376
+ tmdb_int=tmdb_int,
377
+ )
378
+
379
+ delays = tuple(backoff or ())
380
+ total_rounds = len(delays) + 1
381
+ for attempt in range(total_rounds):
382
+ # Newest-uploads listing first — it usually carries a fresh torrent
383
+ # sooner than the TMDB filter (see _fetch_newest). Both get a per-attempt
384
+ # nonce so no cached snapshot taken before our upload can be replayed.
385
+ nonce = f"{time.time_ns()}"
386
+ match = _pick(await _fetch_newest(tracker_url, api_token, nonce=nonce))
387
+ if match is None:
388
+ match = _pick(await _fetch_entries(tracker_url, api_token, tmdb_int, nonce=nonce))
389
+ if on_attempt is not None:
390
+ try:
391
+ on_attempt(attempt + 1, total_rounds, match is not None)
392
+ except Exception: # a progress callback must never break the lookup
393
+ log.debug("on_attempt callback failed", exc_info=True)
394
+ if match is not None:
395
+ return match
396
+ if attempt < len(delays):
397
+ await asyncio.sleep(delays[attempt])
398
+ return None
@@ -31,7 +31,7 @@ import httpx
31
31
  from ..core import VIDEO_EXTENSIONS, hardlink_file, seedings_dir
32
32
  from ..media import discover_categories, scan_category
33
33
  from .clients import get_client
34
- from .duplicate_check import _entry_delta, _entry_file_sizes
34
+ from .duplicate_check import _entry_delta, _entry_file_sizes, _retry_after_seconds
35
35
  from .db import record_upload
36
36
  from .tmdb_cache import get_many
37
37
  from .trackers import _human_size, _resolution_for, _type_for
@@ -102,14 +102,6 @@ class _RateLimiter:
102
102
  _itt_rate = _RateLimiter()
103
103
 
104
104
 
105
- def _retry_after_seconds(resp: httpx.Response) -> float:
106
- try:
107
- ra = resp.headers.get("retry-after")
108
- return float(ra) if ra else 0.0
109
- except (TypeError, ValueError):
110
- return 0.0
111
-
112
-
113
105
  async def _itt_get(
114
106
  client: httpx.AsyncClient, url: str, params: dict[str, str], *, retries: int = 3,
115
107
  ) -> httpx.Response:
@@ -27,10 +27,22 @@ from __future__ import annotations
27
27
 
28
28
  import asyncio
29
29
  import logging
30
+ import time
30
31
  from pathlib import Path
31
32
  from typing import Any, AsyncGenerator
32
33
 
33
- from .config import runtime_setting
34
+ from ..core import iter_video_files
35
+ from .clients import get_client as get_qbit_client
36
+ from .config import load as load_config, runtime_setting
37
+ from .duplicate_check import find_recent_match
38
+ from .logbuf import emit as log_emit
39
+ from .reseed import (
40
+ _await_new_hash,
41
+ _poll_recheck,
42
+ _reseed_lock as reseed_lock,
43
+ download_torrent_file,
44
+ fetch_torrent_meta,
45
+ )
34
46
  from .webup_client import WebupClient, compute_job_id
35
47
  from .webup_job_fix import (
36
48
  DEFAULT_TRACKER_SIGNATURE,
@@ -361,6 +373,155 @@ async def _drain_buffered(
361
373
  yield {"type": "log", "data": text, "kind": ev_kind, "event": slug}, msg
362
374
 
363
375
 
376
+ def _content_fingerprint(path: Path) -> tuple[int | None, int | None, list[int]]:
377
+ """(file count, total bytes, sorted per-file sizes) of what went into the
378
+ torrent.
379
+
380
+ ``hardlink_tree`` packs video files only, so the tracker's ``num_file`` and
381
+ ``size`` mirror exactly this for both a single movie and a season pack.
382
+ """
383
+ try:
384
+ files = [path] if path.is_file() else list(iter_video_files(path))
385
+ except OSError:
386
+ return (None, None, [])
387
+ sizes: list[int] = []
388
+ for f in files:
389
+ try:
390
+ sizes.append(f.stat().st_size)
391
+ except OSError:
392
+ continue
393
+ if not sizes:
394
+ return (None, None, [])
395
+ return (len(sizes), sum(sizes), sorted(sizes))
396
+
397
+
398
+ async def _seed_from_tracker(
399
+ entry: dict[str, Any], match_path: str, result: dict[str, bool],
400
+ ) -> AsyncGenerator[dict[str, Any], None]:
401
+ """Seed the `.torrent` the *tracker* stored, not the one webup built.
402
+
403
+ Unit3D normalizes an uploaded torrent before saving it — on ITT that means
404
+ rewriting ``info.source`` (``ITT`` → ``ItaTorrents``), which changes the
405
+ infohash. The local `.torrent` therefore announces as "InfoHash not found"
406
+ and never seeds, even though the upload succeeded. Downloading the
407
+ tracker's copy is the only way to get the infohash it registered.
408
+
409
+ The content is already hardlinked where the torrent expects it, so this
410
+ only adds → rechecks → resumes. Reports through ``result``:
411
+ ``added`` (a torrent reached qBittorrent — never fall back to webup's
412
+ /seed afterwards, it would add a second, dead one) and ``ok`` (seeding).
413
+ """
414
+ cfg = load_config()
415
+ base = (cfg.get("ITT_URL") or "").rstrip("/")
416
+ token = (cfg.get("ITT_APIKEY") or "").strip()
417
+ try:
418
+ torrent_id = int(entry.get("id"))
419
+ except (TypeError, ValueError):
420
+ yield {"type": "log", "kind": "warn", "data": f"webup: id torrent non valido ({entry.get('id')!r})"}
421
+ return
422
+ save_path = str(Path(match_path).parent)
423
+
424
+ async with reseed_lock:
425
+ try:
426
+ # The listing already gave us the download link (it embeds the
427
+ # rsskey); only ask the show endpoint when it didn't.
428
+ link = (entry.get("download_link") or "").strip()
429
+ if not link:
430
+ meta = await fetch_torrent_meta(base, token, torrent_id)
431
+ link = (meta or {}).get("download_link") or ""
432
+ tbytes = await download_torrent_file(link)
433
+ client = get_qbit_client(cfg)
434
+ before = {t.hash for t in await client.list()}
435
+ # Same qBittorrent tag webup's own /seed applies (TORRENT__TAG), so
436
+ # bot uploads keep showing up under one tag in the sidebar.
437
+ await client.add_torrent(
438
+ tbytes, save_path=save_path, paused=True, skip_checking=False,
439
+ tags=(cfg.get("TAG") or "").strip() or None,
440
+ )
441
+ new_hash = await _await_new_hash(client, before)
442
+ if not new_hash:
443
+ yield {
444
+ "type": "log", "kind": "warn",
445
+ "data": "webup: torrent aggiunto a qBittorrent ma non individuato "
446
+ "(forse già presente) — verifica manualmente",
447
+ "event": "upload.qbit",
448
+ }
449
+ return
450
+ result["added"] = True
451
+ await client.recheck(new_hash)
452
+ final = 0.0
453
+ async for prog, _state in _poll_recheck(client, new_hash):
454
+ final = prog
455
+ yield _progress_event("seed", prog * 100)
456
+ if final < 0.999:
457
+ yield {
458
+ "type": "log", "kind": "error",
459
+ "data": f"webup: recheck fermo al {final * 100:.1f}% — il contenuto locale non "
460
+ "corrisponde al torrent del tracker; lasciato in pausa",
461
+ "event": "upload.qbit",
462
+ }
463
+ return
464
+ await client.resume(new_hash)
465
+ result["ok"] = True
466
+ yield {
467
+ "type": "log", "kind": "ok",
468
+ "data": f"webup: in seed con il .torrent del tracker (infohash {new_hash[:12]}…)",
469
+ "event": "upload.qbit",
470
+ }
471
+ except Exception as exc:
472
+ yield {
473
+ "type": "log", "kind": "warn",
474
+ "data": f"webup: seed dal tracker fallito ({exc!r})",
475
+ "event": "upload.qbit",
476
+ }
477
+
478
+
479
+ async def _verify_upload_on_tracker(match_path: str, tmdb_id: str) -> dict[str, Any] | None:
480
+ """Did the torrent we just sent actually land on the tracker?
481
+
482
+ Webup's `itt_tracker_helper._post` calls `resp.json()` without checking the
483
+ content type, so a tracker answering `200 text/html` — which ITT does on a
484
+ *successful* upload — makes webup's `/upload` raise and reach us as an HTTP
485
+ 500. Abandoning the run there leaves a torrent published on the tracker
486
+ that nobody seeds, so instead of trusting that response we look the torrent
487
+ up by TMDB id + exact content fingerprint.
488
+
489
+ Returns the matched tracker entry, or ``None`` when we cannot confirm it —
490
+ in which case the caller must treat the upload as failed.
491
+ """
492
+ if not tmdb_id:
493
+ return None
494
+ num_files, total_size, file_sizes = _content_fingerprint(Path(match_path))
495
+ if not total_size:
496
+ return None
497
+ cfg = load_config()
498
+
499
+ # The wait can run for minutes; report into the Logs tab so it doesn't look
500
+ # like the upload silently hung (the SSE stream is busy awaiting this call).
501
+ started = time.monotonic()
502
+
503
+ def _report(index: int, total: int, found: bool) -> None:
504
+ if not (found or index == 1 or index % 3 == 0):
505
+ return
506
+ elapsed = int(time.monotonic() - started)
507
+ log_emit(
508
+ "ok" if found else "info",
509
+ f"Tracker: tentativo {index}/{total} — "
510
+ + ("torrent trovato" if found else f"non ancora visibile ({elapsed}s)"),
511
+ "webup", source="webup", event="upload.tracker_response",
512
+ )
513
+
514
+ return await find_recent_match(
515
+ tracker_url=(cfg.get("ITT_URL") or "").strip(),
516
+ api_token=(cfg.get("ITT_APIKEY") or "").strip(),
517
+ tmdb_id=tmdb_id,
518
+ num_files=num_files,
519
+ total_size=total_size,
520
+ file_sizes=file_sizes,
521
+ on_attempt=_report,
522
+ )
523
+
524
+
364
525
  async def stream_webup(
365
526
  *,
366
527
  client: WebupClient,
@@ -617,6 +778,7 @@ async def stream_webup(
617
778
  # WSL/dev can exercise the full pipeline without polluting the live
618
779
  # tracker. Maketorrent and seed still run, the .torrent ends up in qBit.
619
780
  dry_run = runtime_setting("U3DP_DRY_RUN_TRACKER", "0") in {"1", "true", "True", "yes"}
781
+ landed: dict[str, Any] | None = None
620
782
  if dry_run:
621
783
  yield _progress_event("upload", 0)
622
784
  yield {
@@ -629,70 +791,136 @@ async def stream_webup(
629
791
  else:
630
792
  yield _progress_event("upload", 0)
631
793
  yield {"type": "log", "data": "webup: /upload…"}
794
+ upload_error: str | None = None
632
795
  try:
633
- upload_http_resp = await asyncio.wait_for(client.upload(job_id), timeout=PHASE_TIMEOUT)
796
+ await asyncio.wait_for(client.upload(job_id), timeout=PHASE_TIMEOUT)
634
797
  except asyncio.TimeoutError:
635
- yield {"type": "error", "data": f"/upload timeout after {PHASE_TIMEOUT}s"}
636
- yield {"type": "done", "exit_code": 1}
637
- return
798
+ upload_error = f"/upload timeout after {PHASE_TIMEOUT}s"
638
799
  except Exception as e:
639
- yield {"type": "error", "data": f"/upload failed: {e}"}
640
- yield {"type": "done", "exit_code": 1}
641
- return
642
-
643
- # webup's /upload endpoint returns JSON null regardless of outcome
644
- # (FastAPI default for endpoints with no explicit return value).
645
- # The actual tracker result comes exclusively through WebSocket
646
- # posterLogMessage events broadcast inside UploadUseCase.execute().
647
- # Do NOT treat None here as an error — drain WS for the real status.
648
-
649
- _log.info(
650
- "upload: /upload HTTP done, ws_connected=%s queue_size=%d — draining WS",
651
- ws.connected, queue.qsize(),
652
- )
653
-
654
- upload_failed = False
655
- upload_succeeded = False
656
- async for ev, msg in _drain_buffered(queue, job_id, window=8.0):
657
- yield ev
658
- if is_terminal_failure(msg):
659
- upload_failed = True
660
- elif is_terminal_success(msg):
661
- upload_succeeded = True
662
-
663
- _log.info(
664
- "upload: drain done — succeeded=%s failed=%s queue_size=%d ws_connected=%s",
665
- upload_succeeded, upload_failed, queue.qsize(), ws.connected,
666
- )
667
-
668
- if upload_succeeded:
669
- pass # WS confirmed success
670
- elif upload_failed:
671
- yield {"type": "error", "data": "/upload tracker rejected — see log above for details"}
672
- yield {"type": "done", "exit_code": 1}
673
- return
674
- else:
675
- # No posterLogMessage arrived within 8 s.
676
- # This is a WS delivery issue (timing race, connection glitch)
677
- # rather than a definitive upload failure — the HTTP 200 from
678
- # webup means execute() completed and the tracker call was made.
679
- # Log a warning and proceed to seed so the workflow still
680
- # completes; the operator should verify the tracker manually.
800
+ upload_error = f"/upload failed: {e}"
801
+
802
+ if upload_error:
803
+ # webup blew up — but its own crash says nothing about what the
804
+ # tracker did with our POST. It raises on any non-JSON reply,
805
+ # and ITT answers `200 text/html` on success, so the torrent is
806
+ # usually already published. Ask the tracker directly; only give
807
+ # up when it cannot confirm the upload.
808
+ yield {
809
+ "type": "log", "kind": "warn",
810
+ "data": f"webup: {upload_error} — controllo sul tracker se il torrent "
811
+ "è stato accettato (può richiedere un paio di minuti)…",
812
+ "event": "upload.tracker_response",
813
+ }
814
+ try:
815
+ landed = await _verify_upload_on_tracker(match_path, wanted_tmdb or webup_tmdb)
816
+ except Exception as exc:
817
+ _log.warning("upload verification failed: %r", exc)
818
+ if landed is None:
819
+ yield {"type": "error", "data": upload_error}
820
+ yield {"type": "done", "exit_code": 1}
821
+ return
681
822
  yield {
682
823
  "type": "log",
683
- "data": (
684
- f"webup: /upload — no WS status received within 8 s "
685
- f"(ws_connected={ws.connected}, queue_size={queue.qsize()}). "
686
- "Proceeding to seed. Check the tracker to confirm the upload succeeded."
687
- ),
688
824
  "kind": "warn",
689
825
  "event": "upload.tracker_response",
826
+ "data": (
827
+ f"webup: {upload_error} — but the torrent IS on the tracker "
828
+ f"(id={landed.get('id')}, {landed.get('name')!r}). Known webup "
829
+ "limitation: it cannot read a non-JSON tracker reply. Seeding it."
830
+ ),
690
831
  }
691
- yield _progress_event("upload", 100)
832
+ yield _progress_event("upload", 100)
833
+ else:
834
+ # webup's /upload endpoint returns JSON null regardless of
835
+ # outcome (FastAPI default for endpoints with no explicit return
836
+ # value). The actual tracker result comes exclusively through
837
+ # WebSocket posterLogMessage events broadcast inside
838
+ # UploadUseCase.execute(). Do NOT treat None here as an error —
839
+ # drain WS for the real status.
840
+
841
+ _log.info(
842
+ "upload: /upload HTTP done, ws_connected=%s queue_size=%d — draining WS",
843
+ ws.connected, queue.qsize(),
844
+ )
845
+
846
+ upload_failed = False
847
+ upload_succeeded = False
848
+ async for ev, msg in _drain_buffered(queue, job_id, window=8.0):
849
+ yield ev
850
+ if is_terminal_failure(msg):
851
+ upload_failed = True
852
+ elif is_terminal_success(msg):
853
+ upload_succeeded = True
854
+
855
+ _log.info(
856
+ "upload: drain done — succeeded=%s failed=%s queue_size=%d ws_connected=%s",
857
+ upload_succeeded, upload_failed, queue.qsize(), ws.connected,
858
+ )
859
+
860
+ if upload_succeeded:
861
+ pass # WS confirmed success
862
+ elif upload_failed:
863
+ yield {"type": "error", "data": "/upload tracker rejected — see log above for details"}
864
+ yield {"type": "done", "exit_code": 1}
865
+ return
866
+ else:
867
+ # No posterLogMessage arrived within 8 s.
868
+ # This is a WS delivery issue (timing race, connection glitch)
869
+ # rather than a definitive upload failure — the HTTP 200 from
870
+ # webup means execute() completed and the tracker call was made.
871
+ # Log a warning and proceed to seed so the workflow still
872
+ # completes; the operator should verify the tracker manually.
873
+ yield {
874
+ "type": "log",
875
+ "data": (
876
+ f"webup: /upload — no WS status received within 8 s "
877
+ f"(ws_connected={ws.connected}, queue_size={queue.qsize()}). "
878
+ "Proceeding to seed. Check the tracker to confirm the upload succeeded."
879
+ ),
880
+ "kind": "warn",
881
+ "event": "upload.tracker_response",
882
+ }
883
+ yield _progress_event("upload", 100)
692
884
 
693
885
  # ---- Seed (optional) ----
694
886
  if do_seed:
695
887
  yield _progress_event("seed", 0)
888
+
889
+ # Prefer the tracker's own .torrent: Unit3D rewrites `info.source`
890
+ # when it stores the upload, so the locally built file has a
891
+ # different infohash and would announce "InfoHash not found".
892
+ # Fall back to webup's /seed only when the tracker entry is unknown
893
+ # (dry-run, missing TMDB id, tracker unreachable).
894
+ seeded = {"added": False, "ok": False}
895
+ if not dry_run:
896
+ if landed is None:
897
+ yield {"type": "log", "data": "webup: cerco il torrent sul tracker…"}
898
+ try:
899
+ landed = await _verify_upload_on_tracker(match_path, wanted_tmdb or webup_tmdb)
900
+ except Exception as exc:
901
+ _log.warning("tracker lookup before seed failed: %r", exc)
902
+ if landed is None:
903
+ yield {
904
+ "type": "log", "kind": "warn",
905
+ "data": "webup: torrent non trovato sul tracker — uso il .torrent locale "
906
+ "(l'announce potrebbe rispondere 'InfoHash not found')",
907
+ "event": "upload.qbit",
908
+ }
909
+ else:
910
+ async for ev in _seed_from_tracker(landed, match_path, seeded):
911
+ yield ev
912
+
913
+ if seeded["ok"]:
914
+ yield _progress_event("seed", 100)
915
+ yield {"type": "done", "exit_code": 0}
916
+ return
917
+ if seeded["added"]:
918
+ # Something reached qBittorrent but isn't seeding — adding the
919
+ # local .torrent on top would just create a dead duplicate.
920
+ yield _progress_event("seed", 100)
921
+ yield {"type": "done", "exit_code": 1}
922
+ return
923
+
696
924
  yield {"type": "log", "data": "webup: /seed…"}
697
925
  try:
698
926
  code, body = await client.seed(job_id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unit3dprep
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Web UI + CLI di pre-flight per tracker Unit3D, companion di Unit3DWebUp (audio ITA, nomenclatura ItaTorrents, hardlink, upload)
5
5
  Author: Davide Sidoti
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -1,168 +0,0 @@
1
- """Pre-upload duplicate detection against the ITT Unit3D API.
2
-
3
- Webup 0.0.25 does not implement duplicate detection (`DUPLICATE_ON` /
4
- `SKIP_DUPLICATE` are commented `# Todo Not yet implemented` in its
5
- `config/settings.py`). The legacy `unit3dup` CLI used to query the
6
- tracker by TMDB id and refuse the upload when an existing torrent had
7
- the *exact* same file size in bytes — irrespective of name/encode/etc.
8
- We replicate that behaviour here as a pre-flight performed by the
9
- bridge before invoking webup.
10
-
11
- Triggered by the `W_DUPLICATE_CHECK` runtime setting (default ON).
12
- """
13
- from __future__ import annotations
14
-
15
- import logging
16
- from typing import Any
17
-
18
- import httpx
19
-
20
- log = logging.getLogger("unit3dprep.duplicate_check")
21
-
22
- _TIMEOUT = httpx.Timeout(15.0, connect=5.0)
23
-
24
-
25
- def _to_int(v: Any) -> int | None:
26
- try:
27
- return int(v) if v is not None else None
28
- except (TypeError, ValueError):
29
- return None
30
-
31
-
32
- def _entry_file_sizes(attrs: dict[str, Any]) -> list[int]:
33
- """Sorted per-file byte sizes from a tracker torrent's ``files`` list."""
34
- files = attrs.get("files")
35
- if not isinstance(files, list):
36
- return []
37
- sizes: list[int] = []
38
- for f in files:
39
- if isinstance(f, dict):
40
- s = _to_int(f.get("size"))
41
- if s is not None:
42
- sizes.append(s)
43
- return sorted(sizes)
44
-
45
-
46
- def _entry_delta(
47
- attrs: dict[str, Any],
48
- *,
49
- num_files: int | None,
50
- total_size: int,
51
- file_sizes: list[int] | None,
52
- tolerance_bytes: int,
53
- ) -> int | None:
54
- """Absolute size delta if ``attrs`` matches the fingerprint, else ``None``.
55
-
56
- A match requires the same file count (when both sides expose ``num_file``)
57
- and a total size within ``tolerance_bytes``. In exact mode (tolerance 0)
58
- the per-file size multiset must also match when both sides provide it —
59
- this rules out same-total/same-count torrents with a different make-up.
60
- Name/encode/release-group are irrelevant.
61
- """
62
- existing_size = _to_int(attrs.get("size"))
63
- if existing_size is None or existing_size <= 0:
64
- return None
65
- existing_num = _to_int(attrs.get("num_file"))
66
- if num_files and existing_num and existing_num != num_files:
67
- return None
68
- delta = abs(existing_size - total_size)
69
- if delta > tolerance_bytes:
70
- return None
71
- if tolerance_bytes == 0 and file_sizes:
72
- existing_sizes = _entry_file_sizes(attrs)
73
- if existing_sizes and existing_sizes != sorted(file_sizes):
74
- return None
75
- return delta
76
-
77
-
78
- async def find_duplicate(
79
- *,
80
- tracker_url: str,
81
- api_token: str,
82
- tmdb_id: int | str | None,
83
- num_files: int | None,
84
- total_size: int | None,
85
- file_sizes: list[int] | None = None,
86
- tolerance_pct: float = 0.0,
87
- ) -> dict[str, Any] | None:
88
- """Query the tracker for an existing torrent matching the local fingerprint.
89
-
90
- The fingerprint is the number of video files (``num_files``), their total
91
- byte size (``total_size``) and — optionally — the sorted list of per-file
92
- byte sizes (``file_sizes``). ``tolerance_pct`` widens the total-size match
93
- to catch near-identical re-encodes (e.g. 12.00 vs 12.02 GB); ``0`` means an
94
- exact match and additionally compares the per-file size multiset.
95
-
96
- Returns a dict with the matched torrent details (the closest one when
97
- several qualify) or ``None`` when nothing matches, an input is missing, or
98
- the API call fails. ``None`` is always safe to treat as "no duplicate".
99
- """
100
- if not tracker_url or not api_token or not tmdb_id or not total_size:
101
- return None
102
- try:
103
- total_int = int(total_size)
104
- tmdb_int = int(tmdb_id)
105
- except (TypeError, ValueError):
106
- return None
107
- if total_int <= 0 or tmdb_int <= 0:
108
- return None
109
-
110
- try:
111
- tol = max(0.0, float(tolerance_pct))
112
- except (TypeError, ValueError):
113
- tol = 0.0
114
- tolerance_bytes = int(round(total_int * tol / 100.0))
115
-
116
- base = tracker_url.rstrip("/")
117
- url = f"{base}/api/torrents/filter"
118
- params = {"tmdbId": str(tmdb_int), "api_token": api_token, "perPage": "100"}
119
- try:
120
- async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as client:
121
- r = await client.get(url, params=params)
122
- r.raise_for_status()
123
- payload = r.json()
124
- except (httpx.HTTPError, ValueError) as e:
125
- log.warning("duplicate check failed (%s): %s", url, e)
126
- return None
127
-
128
- items = payload.get("data") if isinstance(payload, dict) else None
129
- if not isinstance(items, list):
130
- return None
131
-
132
- best: dict[str, Any] | None = None
133
- best_delta = -1
134
- for entry in items:
135
- if not isinstance(entry, dict):
136
- continue
137
- attrs = entry.get("attributes") or {}
138
- delta = _entry_delta(
139
- attrs,
140
- num_files=num_files,
141
- total_size=total_int,
142
- file_sizes=file_sizes,
143
- tolerance_bytes=tolerance_bytes,
144
- )
145
- if delta is None:
146
- continue
147
- if best is None or delta < best_delta:
148
- best_delta = delta
149
- best = {
150
- "id": entry.get("id") or attrs.get("id"),
151
- "name": attrs.get("name"),
152
- "size": _to_int(attrs.get("size")),
153
- "num_file": _to_int(attrs.get("num_file")),
154
- "type": attrs.get("type"),
155
- "resolution": attrs.get("resolution"),
156
- "category": attrs.get("category"),
157
- "uploader": attrs.get("uploader"),
158
- "seeders": attrs.get("seeders"),
159
- "leechers": attrs.get("leechers"),
160
- "created_at": attrs.get("created_at"),
161
- "details_link": attrs.get("details_link"),
162
- "tmdb_id": tmdb_int,
163
- "size_delta": delta,
164
- "approx": delta > 0,
165
- }
166
- if best_delta == 0:
167
- break
168
- return best
File without changes
File without changes
File without changes
File without changes
File without changes