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.
- {unit3dprep-1.2.0/unit3dprep.egg-info → unit3dprep-1.2.1}/PKG-INFO +1 -1
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/pyproject.toml +1 -1
- unit3dprep-1.2.1/unit3dprep/web/duplicate_check.py +398 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/reseed.py +1 -9
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/webup_orchestrator.py +281 -53
- {unit3dprep-1.2.0 → unit3dprep-1.2.1/unit3dprep.egg-info}/PKG-INFO +1 -1
- unit3dprep-1.2.0/unit3dprep/web/duplicate_check.py +0 -168
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/LICENSE +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/MANIFEST.in +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/README.md +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/setup.cfg +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/__init__.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/cli.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/core.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/i18n.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/media.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/upload.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/__init__.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/_env.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/__init__.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/auth.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/fs.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/library.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/logs.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/queue.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/quickupload.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/reseed.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/search.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/settings.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/tmdb.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/trackers.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/uploaded.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/version.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/webup.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/api/wizard.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/app.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/auth.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/clients.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/config.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/db.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/assets/index-CPw9mcxL.js +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/assets/index-FFoOmpDN.css +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/dist/index.html +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/lang_cache.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/logbuf.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/tmdb_cache.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/tocheck.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/trackers.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/webup_client.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/webup_job_fix.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/webup_logclass.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep/web/webup_ws.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep.egg-info/SOURCES.txt +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep.egg-info/dependency_links.txt +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep.egg-info/entry_points.txt +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.2.1}/unit3dprep.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
796
|
+
await asyncio.wait_for(client.upload(job_id), timeout=PHASE_TIMEOUT)
|
|
634
797
|
except asyncio.TimeoutError:
|
|
635
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|