mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 00:27:01 +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>
148 lines
4.3 KiB
Python
148 lines
4.3 KiB
Python
"""Pure planning helpers for the background worker.
|
|
|
|
Nothing here performs IO, so every scheduling and retry decision is testable
|
|
from plain values. The worker in :mod:`python_pkg.wsg_grabber.downloader` is a
|
|
thin shell that calls these and then acts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.wsg_grabber.constants import (
|
|
BACKOFF_BASE_S,
|
|
BACKOFF_CAP_S,
|
|
MIN_REQUEST_INTERVAL_S,
|
|
)
|
|
from python_pkg.wsg_grabber.models import ResumePlan, TaskKind
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
from python_pkg.wsg_grabber.models import ThreadRef
|
|
|
|
|
|
def choose_task(
|
|
pending_downloads: int,
|
|
stale_thread_count: int,
|
|
*,
|
|
catalog_fresh: bool,
|
|
) -> TaskKind:
|
|
"""Decide what the worker should do next.
|
|
|
|
Downloads win over scanning so the reviewer gets its first playable video
|
|
as soon as possible, which is the whole point of loading in the background.
|
|
|
|
Args:
|
|
pending_downloads: Files known but not yet fetched.
|
|
stale_thread_count: Threads whose contents may have changed.
|
|
catalog_fresh: Whether the thread list has been read at least once this
|
|
cycle.
|
|
|
|
Returns:
|
|
TaskKind: The next action.
|
|
"""
|
|
if pending_downloads > 0:
|
|
return TaskKind.DOWNLOAD
|
|
if stale_thread_count > 0 or not catalog_fresh:
|
|
return TaskKind.SCAN
|
|
return TaskKind.IDLE
|
|
|
|
|
|
def stale_threads(
|
|
known: Mapping[int, int],
|
|
live: Sequence[ThreadRef],
|
|
) -> list[ThreadRef]:
|
|
"""Return threads worth fetching.
|
|
|
|
A thread is worth fetching when it has never been seen, or when the board
|
|
reports a newer ``last_modified`` than the one already stored. On a steady
|
|
board this reduces a cycle to a single request.
|
|
|
|
Args:
|
|
known: Thread number mapped to the stored timestamp.
|
|
live: Threads currently advertised by the board.
|
|
|
|
Returns:
|
|
list[ThreadRef]: Threads to fetch, in the order given.
|
|
"""
|
|
return [
|
|
ref
|
|
for ref in live
|
|
if ref.thread_no not in known or ref.api_last_modified > known[ref.thread_no]
|
|
]
|
|
|
|
|
|
def next_delay(last_request: float, now: float) -> float:
|
|
"""Return how long to wait before the next API request.
|
|
|
|
The 4chan API rules ask for at most one request per second.
|
|
|
|
Args:
|
|
last_request: Monotonic timestamp of the previous request, or 0.
|
|
now: Current monotonic timestamp.
|
|
|
|
Returns:
|
|
float: Seconds to sleep, never negative.
|
|
"""
|
|
elapsed = now - last_request
|
|
remaining = MIN_REQUEST_INTERVAL_S - elapsed
|
|
return max(0.0, remaining)
|
|
|
|
|
|
def backoff_seconds(attempt: int, retry_after: float | None = None) -> float:
|
|
"""Return how long to pause after a rejected request.
|
|
|
|
An explicit ``Retry-After`` always wins; otherwise the delay doubles per
|
|
attempt up to a cap so a sustained block does not spin.
|
|
|
|
Args:
|
|
attempt: 1 for the first failure.
|
|
retry_after: Server-supplied delay in seconds, when present.
|
|
|
|
Returns:
|
|
float: Seconds to wait, capped.
|
|
"""
|
|
if retry_after is not None and retry_after > 0:
|
|
return min(retry_after, BACKOFF_CAP_S)
|
|
exponent = max(0, attempt - 1)
|
|
return min(BACKOFF_BASE_S * (2.0**exponent), BACKOFF_CAP_S)
|
|
|
|
|
|
def parse_retry_after(header: str | None) -> float | None:
|
|
"""Read a ``Retry-After`` header expressed in seconds.
|
|
|
|
The HTTP-date form is deliberately not handled; falling back to exponential
|
|
backoff is safe and avoids a clock-skew dependency.
|
|
|
|
Args:
|
|
header: Raw header value, if any.
|
|
|
|
Returns:
|
|
float | None: Seconds to wait, or None when absent or not numeric.
|
|
"""
|
|
if header is None:
|
|
return None
|
|
try:
|
|
value = float(header.strip())
|
|
except ValueError:
|
|
return None
|
|
return value if value >= 0 else None
|
|
|
|
|
|
def resume_plan(part_size: int, expected_size: int) -> ResumePlan:
|
|
"""Decide how to continue a partially downloaded file.
|
|
|
|
Args:
|
|
part_size: Bytes already on disk in the ``.part`` file.
|
|
expected_size: Size the API says the file should be.
|
|
|
|
Returns:
|
|
ResumePlan: Byte offset to request, and whether to bin what is there.
|
|
"""
|
|
if part_size <= 0:
|
|
return ResumePlan(offset=0, discard=False)
|
|
if 0 < expected_size <= part_size:
|
|
return ResumePlan(offset=0, discard=True)
|
|
return ResumePlan(offset=part_size, discard=False)
|