mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 22:47:02 +01:00
New package. Walks every thread on 4chan's /wsg/, downloads each video it has
not seen, and reviews them one at a time: mpv embedded in a Tk window via --wid
with Keep/Pass/Undo directly beneath the picture.
Dedupe is structural rather than a convention. Every post carrying a file also
carries md5 -- base64 MD5 of the whole file -- so it is the sqlite primary key.
An already-seen video costs zero bytes to skip and a byte-identical repost in
another thread collapses onto the existing row. Verified against a live file:
the API's Nz9OEKdMuMZEdYE6eLTKmA== matched base64(md5(bytes)) exactly.
Undo is unbounded and survives quitting: the verdict trail lives in the index,
not in memory, so yesterday's decisions can still be taken back.
No downloaded video is ever deleted. A pass moves the file to trash/ and leaves
it there; files.apply_move refuses to overwrite an existing destination, so the
guarantee holds by construction rather than per caller.
Three bugs only running it could find, each caught from evidence rather than
guesswork:
- the video stayed black: a <Configure> handler set mpv's geometry, fighting
the automatic sizing --wid already does
- playback never advanced: refresh() ran every tick and re-sent loadfile ~7x
a second, restarting the video continuously
- it froze on one video around the 26th while filenames kept advancing: mpv
pushes an event stream down the same socket and nothing drained it, so the
buffer filled and mpv stopped reading commands. Isolated to command #35
without a drain thread; all 60 honoured with one.
A five-lens review then found nine more, all reproduced before fixing: a failed
mpv start stranded the non-daemon worker forever; undo could clobber a file in
incoming/; a NUL-bearing md5 poisoned the index permanently; an oversized int
killed the worker with no event; gzip bombs on both hosts were accepted; the
board endpoints were fetched unconditionally forever; and Session.missing could
corrupt the undo trail.
mpv runs as a subprocess over its JSON IPC socket rather than via python-mpv,
which resolves libmpv at import time and would fail on CI while passing here.
Third-party surface is requests alone, already declared.
277 package tests, 100% branch coverage on all 22 modules, no suppressions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
336 lines
10 KiB
Python
336 lines
10 KiB
Python
"""The only module that speaks HTTP.
|
|
|
|
Two hosts with different manners. ``a.4cdn.org`` is polite and supports
|
|
``If-Modified-Since``, so a steady-state rescan costs almost nothing.
|
|
``i.4cdn.org`` answered a bare request with 429 during development, and the
|
|
cookies it sets in reply (``__cf_bm``, ``_cfuvid``) live in the session jar --
|
|
which is why one long-lived :class:`requests.Session` is required rather than
|
|
merely convenient.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from dataclasses import dataclass
|
|
import hashlib
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
import requests
|
|
|
|
from python_pkg.wsg_grabber.constants import (
|
|
API_HEADERS,
|
|
CHUNK_BYTES,
|
|
CONNECT_TIMEOUT_S,
|
|
MAX_JSON_BYTES,
|
|
MEDIA_HEADERS,
|
|
READ_TIMEOUT_S,
|
|
SIZE_SLACK_BYTES,
|
|
)
|
|
from python_pkg.wsg_grabber.models import DownloadResult, JsonResponse, Outcome
|
|
from python_pkg.wsg_grabber.scanner import parse_retry_after, resume_plan
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
_TIMEOUT = (CONNECT_TIMEOUT_S, READ_TIMEOUT_S)
|
|
_OK = 200
|
|
_PARTIAL = 206
|
|
_NOT_MODIFIED = 304
|
|
_NOT_FOUND = 404
|
|
_GONE = 410
|
|
_RANGE_NOT_SATISFIABLE = 416
|
|
_TOO_MANY = 429
|
|
_SERVER_ERROR = 500
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Transfer:
|
|
"""Everything one download needs, bundled to stay within the arg limit."""
|
|
|
|
url: str
|
|
part_path: Path
|
|
expected_md5: str
|
|
expected_size: int
|
|
should_stop: Callable[[], bool]
|
|
on_progress: Callable[[int], None] | None = None
|
|
|
|
|
|
def build_session() -> requests.Session:
|
|
"""Return a session configured for the API host.
|
|
|
|
Returns:
|
|
requests.Session: Caller owns it and must close it.
|
|
"""
|
|
session = requests.Session()
|
|
session.headers.update(API_HEADERS)
|
|
return session
|
|
|
|
|
|
def digest_of(payload: bytes) -> str:
|
|
"""Return the base64 MD5 the API uses to identify a file.
|
|
|
|
MD5 here is 4chan's content identifier, not a security control, hence
|
|
``usedforsecurity=False``.
|
|
|
|
Args:
|
|
payload: Full file contents.
|
|
|
|
Returns:
|
|
str: 24-character base64 digest.
|
|
"""
|
|
digest = hashlib.md5(payload, usedforsecurity=False).digest()
|
|
return base64.b64encode(digest).decode("ascii")
|
|
|
|
|
|
def get_json(
|
|
session: requests.Session,
|
|
url: str,
|
|
last_modified: str | None = None,
|
|
) -> JsonResponse:
|
|
"""Fetch and decode a JSON endpoint, conditionally when possible.
|
|
|
|
Args:
|
|
session: Shared session.
|
|
url: Absolute API URL.
|
|
last_modified: Previously seen ``Last-Modified`` value, echoed back as
|
|
``If-Modified-Since`` so an unchanged thread costs a 304.
|
|
|
|
Returns:
|
|
JsonResponse: Decoded payload, or a flag explaining why there is none.
|
|
"""
|
|
headers = {"If-Modified-Since": last_modified} if last_modified else {}
|
|
response = session.get(url, headers=headers, timeout=_TIMEOUT)
|
|
status = response.status_code
|
|
if status == _NOT_MODIFIED:
|
|
return JsonResponse(
|
|
payload=None,
|
|
last_modified=last_modified,
|
|
not_modified=True,
|
|
not_found=False,
|
|
)
|
|
if status in (_NOT_FOUND, _GONE):
|
|
return JsonResponse(
|
|
payload=None,
|
|
last_modified=None,
|
|
not_modified=False,
|
|
not_found=True,
|
|
)
|
|
response.raise_for_status()
|
|
body = response.content[: MAX_JSON_BYTES + 1]
|
|
if len(body) > MAX_JSON_BYTES:
|
|
msg = f"API response exceeded {MAX_JSON_BYTES} bytes"
|
|
raise ValueError(msg)
|
|
return JsonResponse(
|
|
payload=json.loads(body),
|
|
last_modified=response.headers.get("Last-Modified"),
|
|
not_modified=False,
|
|
not_found=False,
|
|
)
|
|
|
|
|
|
def download(session: requests.Session, transfer: Transfer) -> DownloadResult:
|
|
"""Stream a file to disk, resuming where possible and verifying the result.
|
|
|
|
The bytes are checked against the API's md5 before the download counts as
|
|
finished, so a truncated or mangled transfer can never reach the reviewer.
|
|
|
|
Args:
|
|
session: Shared session.
|
|
transfer: What to fetch and where to put it.
|
|
|
|
Returns:
|
|
DownloadResult: Outcome plus the byte count now on disk.
|
|
"""
|
|
plan = resume_plan(size_of(transfer.part_path), transfer.expected_size)
|
|
if plan.discard:
|
|
transfer.part_path.unlink(missing_ok=True)
|
|
headers = dict(MEDIA_HEADERS)
|
|
if plan.offset:
|
|
headers["Range"] = f"bytes={plan.offset}-"
|
|
|
|
try:
|
|
return _stream(session, transfer, headers, plan.offset)
|
|
except requests.RequestException as exc:
|
|
return DownloadResult(
|
|
outcome=Outcome.TRANSIENT,
|
|
bytes_done=size_of(transfer.part_path),
|
|
message=str(exc),
|
|
)
|
|
|
|
|
|
def _stream(
|
|
session: requests.Session,
|
|
transfer: Transfer,
|
|
headers: dict[str, str],
|
|
offset: int,
|
|
) -> DownloadResult:
|
|
"""Perform the request and write the body out.
|
|
|
|
Args:
|
|
session: Shared session.
|
|
transfer: What to fetch and where to put it.
|
|
headers: Media headers, possibly carrying a ``Range``.
|
|
offset: Byte offset requested; zero for a fresh download.
|
|
|
|
Returns:
|
|
DownloadResult: Outcome plus the byte count now on disk.
|
|
"""
|
|
response = session.get(
|
|
transfer.url,
|
|
headers=headers,
|
|
timeout=_TIMEOUT,
|
|
stream=True,
|
|
)
|
|
rejection = _rejection(response, transfer)
|
|
if rejection is not None:
|
|
return rejection
|
|
|
|
# A server that ignores Range replies 200 with the whole file, so anything
|
|
# already on disk has to be thrown away rather than appended to.
|
|
append = offset > 0 and response.status_code == _PARTIAL
|
|
written = _write_body(response, transfer, append=append)
|
|
if written is None:
|
|
return DownloadResult(
|
|
outcome=Outcome.ABORTED,
|
|
bytes_done=size_of(transfer.part_path),
|
|
)
|
|
if _over_budget(written, transfer.expected_size):
|
|
transfer.part_path.unlink(missing_ok=True)
|
|
return DownloadResult(
|
|
outcome=Outcome.TRANSIENT,
|
|
bytes_done=0,
|
|
message=f"body exceeded the advertised {transfer.expected_size} bytes",
|
|
)
|
|
|
|
payload = transfer.part_path.read_bytes()
|
|
if digest_of(payload) != transfer.expected_md5:
|
|
transfer.part_path.unlink(missing_ok=True)
|
|
return DownloadResult(
|
|
outcome=Outcome.CHECKSUM_MISMATCH,
|
|
bytes_done=0,
|
|
message="md5 did not match the value published by the API",
|
|
)
|
|
return DownloadResult(outcome=Outcome.COMPLETED, bytes_done=len(payload))
|
|
|
|
|
|
def _rejection(
|
|
response: requests.Response,
|
|
transfer: Transfer,
|
|
) -> DownloadResult | None:
|
|
"""Classify a non-success status, if any.
|
|
|
|
Args:
|
|
response: The (streamed) response.
|
|
transfer: The transfer being attempted.
|
|
|
|
Returns:
|
|
DownloadResult | None: None when the response is usable.
|
|
"""
|
|
status = response.status_code
|
|
if status in (_NOT_FOUND, _GONE):
|
|
transfer.part_path.unlink(missing_ok=True)
|
|
return DownloadResult(
|
|
outcome=Outcome.NOT_FOUND,
|
|
bytes_done=0,
|
|
message=f"HTTP {status}",
|
|
)
|
|
encoding = response.headers.get("Content-Encoding", "identity")
|
|
if encoding != "identity":
|
|
# We asked for identity. A compressed body means the far side is not
|
|
# playing by the rules, and requests would silently expand it -- a
|
|
# 200 KB response can decode to 200 MB, and the md5 would still match
|
|
# because the same party published it.
|
|
return DownloadResult(
|
|
outcome=Outcome.TRANSIENT,
|
|
bytes_done=size_of(transfer.part_path),
|
|
message=f"refused Content-Encoding: {encoding}",
|
|
)
|
|
if status == _RANGE_NOT_SATISFIABLE:
|
|
# The leftover part is longer than the file actually is; start over.
|
|
transfer.part_path.unlink(missing_ok=True)
|
|
return DownloadResult(
|
|
outcome=Outcome.TRANSIENT,
|
|
bytes_done=0,
|
|
message="range rejected; restarting",
|
|
)
|
|
if status == _TOO_MANY or status >= _SERVER_ERROR:
|
|
return DownloadResult(
|
|
outcome=Outcome.TRANSIENT,
|
|
bytes_done=size_of(transfer.part_path),
|
|
retry_after=parse_retry_after(response.headers.get("Retry-After")),
|
|
message=f"HTTP {status}",
|
|
)
|
|
if status not in (_OK, _PARTIAL):
|
|
return DownloadResult(
|
|
outcome=Outcome.TRANSIENT,
|
|
bytes_done=size_of(transfer.part_path),
|
|
message=f"unexpected HTTP {status}",
|
|
)
|
|
return None
|
|
|
|
|
|
def _over_budget(written: int, expected: int) -> bool:
|
|
"""Report whether a transfer outgrew the size the API advertised.
|
|
|
|
Without this a server that lies about Content-Length can write until the
|
|
disk is full; the md5 check alone does not help, because whoever serves the
|
|
bytes also publishes the digest.
|
|
|
|
Args:
|
|
written: Bytes now on disk.
|
|
expected: Size the API declared; zero means it did not say.
|
|
|
|
Returns:
|
|
bool: True when the transfer should be abandoned.
|
|
"""
|
|
if expected <= 0:
|
|
return False
|
|
return written > expected + SIZE_SLACK_BYTES
|
|
|
|
|
|
def _write_body(
|
|
response: requests.Response,
|
|
transfer: Transfer,
|
|
*,
|
|
append: bool,
|
|
) -> int | None:
|
|
"""Write the response body to the part file.
|
|
|
|
Args:
|
|
response: Streamed response.
|
|
transfer: What is being fetched.
|
|
append: Whether to continue an existing part file.
|
|
|
|
Returns:
|
|
int | None: Bytes written, or None when shutdown interrupted it.
|
|
"""
|
|
mode = "ab" if append else "wb"
|
|
done = size_of(transfer.part_path) if append else 0
|
|
with transfer.part_path.open(mode) as handle:
|
|
for chunk in response.iter_content(chunk_size=CHUNK_BYTES):
|
|
if transfer.should_stop():
|
|
return None
|
|
if not chunk:
|
|
continue
|
|
handle.write(chunk)
|
|
done += len(chunk)
|
|
if transfer.on_progress is not None:
|
|
transfer.on_progress(done)
|
|
if _over_budget(done, transfer.expected_size):
|
|
return done
|
|
return done
|
|
|
|
|
|
def size_of(path: Path) -> int:
|
|
"""Return the size of *path*, or zero when it does not exist.
|
|
|
|
Args:
|
|
path: File to measure.
|
|
|
|
Returns:
|
|
int: Size in bytes.
|
|
"""
|
|
return path.stat().st_size if path.exists() else 0
|