testsAndMisc/python_pkg/wsg_grabber/states.py
Krzysztof kuhy Rudnicki 89ed9bc95d
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Python tests / test (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass
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>
2026-07-27 14:55:30 +02:00

146 lines
4.2 KiB
Python

"""The lifecycle of a single file, as a pure transition function.
``GONE`` is the load-bearing state. A full first pass over /wsg/ takes hours, so
threads fall off page 10 and files 404 between being catalogued and being
fetched. Without a terminal sink those files would be retried on every future
run forever.
"""
from __future__ import annotations
from enum import Enum
from typing import Final
from python_pkg.wsg_grabber.constants import MAX_ATTEMPTS
# ``str, Enum`` not ``StrEnum``: mypy targets 3.10, where ``StrEnum`` is absent
# and would collapse to ``Any``, disabling checking on every member below.
class FileState(str, Enum):
"""Where a file is in its lifecycle."""
NEW = "new"
DOWNLOADING = "downloading"
READY = "ready"
FAILED = "failed"
CORRUPT = "corrupt"
KEPT = "kept"
PASSED = "passed"
GONE = "gone"
class FileEvent(str, Enum):
"""Something that happened to a file."""
CLAIMED = "claimed"
COMPLETED = "completed"
NOT_FOUND = "not_found"
DELETED_UPSTREAM = "deleted_upstream"
TRANSIENT_ERROR = "transient_error"
CHECKSUM_MISMATCH = "checksum_mismatch"
KEPT = "kept"
PASSED = "passed"
MISSING_LOCALLY = "missing_locally"
TERMINAL: Final[frozenset[FileState]] = frozenset(
{FileState.KEPT, FileState.PASSED, FileState.GONE},
)
CLAIMABLE: Final[frozenset[FileState]] = frozenset(
{FileState.NEW, FileState.FAILED, FileState.CORRUPT},
)
def is_terminal(state: FileState) -> bool:
"""Report whether *state* can never change again.
Args:
state: State to classify.
Returns:
bool: True when no further transition is possible.
"""
return state in TERMINAL
def next_state(current: FileState, event: FileEvent, attempts: int) -> FileState:
"""Return the state *current* moves to on *event*.
Args:
current: The file's present state.
event: What happened.
attempts: Download attempts made so far, including the one that just
failed. Retryable failures give up once this reaches
``MAX_ATTEMPTS``.
Returns:
FileState: The new state.
Raises:
ValueError: If the transition is not legal, which means a caller has a
bug rather than the board behaving oddly.
"""
if current in CLAIMABLE and event is FileEvent.CLAIMED:
return FileState.DOWNLOADING
if current in CLAIMABLE and event is FileEvent.DELETED_UPSTREAM:
return FileState.GONE
if current is FileState.DOWNLOADING:
return _from_downloading(event, attempts)
if current is FileState.READY:
return _from_ready(event)
msg = f"illegal transition: {current.value} + {event.value}"
raise ValueError(msg)
def _from_downloading(event: FileEvent, attempts: int) -> FileState:
"""Resolve a transition out of ``DOWNLOADING``.
Args:
event: What happened.
attempts: Download attempts made so far.
Returns:
FileState: The new state.
Raises:
ValueError: If *event* cannot follow ``DOWNLOADING``.
"""
if event is FileEvent.COMPLETED:
return FileState.READY
if event is FileEvent.NOT_FOUND:
return FileState.GONE
exhausted = attempts >= MAX_ATTEMPTS
if event is FileEvent.TRANSIENT_ERROR:
return FileState.GONE if exhausted else FileState.FAILED
if event is FileEvent.CHECKSUM_MISMATCH:
return FileState.GONE if exhausted else FileState.CORRUPT
msg = f"illegal transition: downloading + {event.value}"
raise ValueError(msg)
def _from_ready(event: FileEvent) -> FileState:
"""Resolve a transition out of ``READY``.
Upstream deletion is deliberately not handled here: once the bytes are on
disk it no longer matters that the post was removed.
Args:
event: What happened.
Returns:
FileState: The new state.
Raises:
ValueError: If *event* cannot follow ``READY``.
"""
if event is FileEvent.KEPT:
return FileState.KEPT
if event is FileEvent.PASSED:
return FileState.PASSED
if event is FileEvent.MISSING_LOCALLY:
return FileState.GONE
msg = f"illegal transition: ready + {event.value}"
raise ValueError(msg)