testsAndMisc/python_pkg/wsg_grabber
Krzysztof kuhy Rudnicki adf2e402df Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading `meta/` against 3.10 -- three tools judging an interpreter
nothing runs any more, and ruff withholding the pyupgrade fixes that only
become safe at the newer target. `pin_ci_python.py` rewrites these three knobs.

What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), and ruff's stable formatter applying PEP 758 --
`except (A, B):` becomes `except A, B:`. That makes the source 3.14-only at the
syntax level, which is what `requires-python = ">=3.14"` already promised; kuhy
chose it deliberately over holding ruff a release back (2026-08-29).

Also drops a comment in `wsg_grabber/models.py` that the bump makes false: it
explained why the file used ``str, Enum`` instead of ``StrEnum`` under mypy at
3.10, sitting directly above a ``class Verdict(StrEnum)`` that had already
stopped obeying it.

ruff, ruff-format, mypy --strict and pylint all clean at the new target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:50:39 +02:00
..
tests Clear the wsg_grabber test modules: python_pkg is now under the cap 2026-08-16 18:19:26 +02:00
__init__.py wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00
__main__.py wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00
_coerce.py Split wsg_grabber/catalog.py under the 250-line cap 2026-08-16 15:36:02 +02:00
_display.py Split wsg_grabber/ui.py under the 250-line cap 2026-08-16 16:18:13 +02:00
_download.py Split wsg_grabber/net.py under the 250-line cap 2026-08-16 15:45:39 +02:00
_fetch.py Split wsg_grabber/downloader.py under the 250-line cap 2026-08-16 16:01:11 +02:00
_logs_report.py Split wsg_grabber/cli.py under the 250-line cap 2026-08-16 15:39:10 +02:00
_mpv_ipc.py Split wsg_grabber/player.py under the 250-line cap 2026-08-16 16:05:41 +02:00
_player_api.py Split wsg_grabber/player.py under the 250-line cap 2026-08-16 16:05:41 +02:00
_reject.py Split wsg_grabber/net.py under the 250-line cap 2026-08-16 15:45:39 +02:00
_render.py Split wsg_grabber/review.py under the 250-line cap 2026-08-16 15:42:10 +02:00
_scan.py Split wsg_grabber/downloader.py under the 250-line cap 2026-08-16 16:01:11 +02:00
_store_queries.py Split wsg_grabber/store.py under the 250-line cap 2026-08-16 15:52:08 +02:00
_undo.py Split wsg_grabber/review.py under the 250-line cap 2026-08-16 15:42:10 +02:00
_worker_base.py Split wsg_grabber/downloader.py under the 250-line cap 2026-08-16 16:01:11 +02:00
app.py wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00
catalog.py Split wsg_grabber/catalog.py under the 250-line cap 2026-08-16 15:36:02 +02:00
cli.py Split wsg_grabber/cli.py under the 250-line cap 2026-08-16 15:39:10 +02:00
constants.py wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00
db.py wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00
downloader.py Split wsg_grabber/downloader.py under the 250-line cap 2026-08-16 16:01:11 +02:00
files.py wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00
install.sh wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00
logs.py Grade against 3.14 too, not just run on it 2026-08-29 11:50:39 +02:00
models.py Grade against 3.14 too, not just run on it 2026-08-29 11:50:39 +02:00
net.py Split wsg_grabber/net.py under the 250-line cap 2026-08-16 15:45:39 +02:00
paths.py wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00
player.py Split wsg_grabber/player.py under the 250-line cap 2026-08-16 16:05:41 +02:00
README.md wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00
review.py Split wsg_grabber/review.py under the 250-line cap 2026-08-16 15:42:10 +02:00
scanner.py wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00
states.py Grade against 3.14 too, not just run on it 2026-08-29 11:50:39 +02:00
store_threads.py Grade against 3.14 too, not just run on it 2026-08-29 11:50:39 +02:00
store_verdicts.py Grade against 3.14 too, not just run on it 2026-08-29 11:50:39 +02:00
store.py Grade against 3.14 too, not just run on it 2026-08-29 11:50:39 +02:00
ui.py Split wsg_grabber/ui.py under the 250-line cap 2026-08-16 16:18:13 +02:00
verdict.py wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass 2026-07-27 14:55:30 +02:00

wsg_grabber

Scrapes 4chan's /wsg/ board for videos, downloads every one it has not seen before, and shows them one at a time for a keep/pass decision — mpv embedded in a Tk window with the buttons directly underneath.

PYTHONPATH=~/testsAndMisc python3 -m python_pkg.wsg_grabber          # review
PYTHONPATH=~/testsAndMisc python3 -m python_pkg.wsg_grabber scrape   # no UI
PYTHONPATH=~/testsAndMisc python3 -m python_pkg.wsg_grabber stats

k / → keeps, j / space / ← passes, u / Backspace undoes, q / Esc quits. The buttons do the same thing for the mouse.

Undo

Misclicked? u takes the last verdict back: the file returns from keep/ or trash/ to incoming/, the index row goes back to ready, the counter is corrected, and that video is on screen again. Whatever was showing when you hit undo goes to the front of the queue, so nothing is skipped.

It steps back through any number of verdicts, and survives quitting — the trail lives in the index, not in memory, so reopening the reviewer can still undo what you decided yesterday. The status bar shows how many are left (u undoes (17)), and the button greys out when there is nothing to take back. If you cleared trash/ by hand in the meantime, undo says so in the log and drops that entry rather than failing on it repeatedly.

Storage

~/.local/share/wsg_grabber/
├── index.db     sqlite, remembers every file ever seen
├── incoming/    downloaded, awaiting your verdict
├── keep/        you kept these
└── trash/       you passed on these — NOTHING here is ever removed automatically

No downloaded video is ever deleted. A pass moves the file into trash/; emptying it is your call. unlink appears only on things that are not videos — a corrupt or abandoned .part, and mpv's control socket — and files.apply_move refuses to overwrite an existing destination, so a move can never destroy a video you already decided on.

Why it never shows you the same video twice

Every 4chan post that carries a file also carries md5 — the base64 MD5 of the whole file, published by the API before you fetch anything. That is the primary key of the files table, so:

  • a video you have already reviewed is never downloaded again, at a cost of zero bytes;
  • the same clip reposted in another thread collapses onto the existing row, which on /wsg/ is the common case;
  • a truncated or mangled download is caught, because the bytes are checked against that same value before the file is offered for review.

Verified against a live file during development: the API's Nz9OEKdMuMZEdYE6eLTKmA== matched base64(md5(bytes)) exactly.

Being a good citizen

  • One request per second to a.4cdn.org, which is what the API rules ask for.
  • If-Modified-Since on every API request — thread bodies, threads.json and archive.json alike — plus a last_modified comparison against the stored value, so an unchanged thread costs a 304 and nothing else. A steady-state rescan is a couple of conditional requests that return 304.
  • Accept-Encoding: identity on both hosts, and hard caps on response and download size. A compressed response is refused outright: a few hundred KB on the wire can decode to gigabytes, and the md5 check does not help when the same party publishes both the bytes and the digest.
  • One long-lived requests.Session. This is required rather than merely tidy: i.4cdn.org answers with 429 under load and sets __cf_bm / _cfuvid cookies that have to persist across requests.
  • 429 and 5xx back off exponentially, honouring Retry-After.

Downloading is prioritised over indexing

While anything is queued the worker downloads rather than walking more threads. That is deliberate: it is what puts a playable video on screen seconds after launch instead of after the whole board has been catalogued. The consequence is that stats shows the known-file count climbing in bursts rather than smoothly — the board is fully indexed once downloads drain. A first full run is roughly 9,000 files and 20–25 GB, so expect a couple of hours; it is resumable and restarting picks up mid-file via HTTP Range.

Logs

Every run writes one JSON object per line to ~/.local/share/wsg_grabber/logs/session-<time>-<pid>.jsonl.

wsg-grabber logs                # where they are, plus a tally of the newest
wsg-grabber --log-level debug   # record every IPC command as well
wsg-grabber --echo-log          # mirror records to stderr while running

The point is after-the-fact diagnosis. If the reviewer ever looks stuck on one video while the filenames keep advancing, one query settles whether mpv was handed the new file and was actually decoding it:

LOG=$(ls -t ~/.local/share/wsg_grabber/logs/*.jsonl | head -1)
jq -r 'select(.event=="review.show")
       | "show \(.file)  mpv_was: \(.mpv_before.path) t=\(.mpv_before["playback-time"]) \(.mpv_before.width)x\(.mpv_before.height)"' "$LOG"

A healthy run shows playback-time advancing and the dimensions changing per video. A frozen one shows them static, which points at the player rather than at the queue. Other useful queries:

jq -c 'select(.level=="error")' "$LOG"                          # what went wrong
jq -r 'select(.event=="review.verdict")|"\(.choice) \(.on_file)"' "$LOG"
jq -c 'select(.event|startswith("player."))' "$LOG"

Field names are stable; every record carries ts, seq, level, event and thread.

Debugging without touching your queue

paths honours XDG_DATA_HOME, so a throwaway instance costs nothing and can neither consume real videos nor record real verdicts:

XDG_DATA_HOME=/tmp/wsg-sandbox python3 -m python_pkg.wsg_grabber stats

Layout

Module Responsibility
catalog.py Parses the board's JSON. Pure.
scanner.py Scheduling, backoff, resume decisions. Pure.
review.py Everything the reviewer does: queue, verdicts, status text. Pure.
verdict.py What keep/pass means, as a planned file move. Pure.
net.py The only module that speaks HTTP.
db.py sqlite schema, connection setup, migrations.
store.py The download lifecycle of a file.
store_threads.py Which threads were visited, and their HTTP stamps.
store_verdicts.py The review trail that undo walks back.
downloader.py The background worker thread.
files.py The only module that moves files.
player.py mpv as a subprocess over its JSON IPC socket.
ui.py The only module that imports tkinter.
logs.py Structured JSONL event log.
app.py / cli.py Composition and argument parsing.

The split is what makes 100% branch coverage achievable: ui.py and player.py are close to pure wiring, and every decision they would otherwise make lives in a pure function tested from plain values with no display and no network. Two decisions do remain in ui.py, both because a bug proved they had to: the already-playing guard (without it loadfile fires every tick and the video restarts continuously) and dropping an item whose file has vanished.

mpv is a subprocess, not python-mpv

python-mpv resolves libmpv at import time and raises OSError wherever it is missing — including this repo's CI runner, so it would pass locally and fail on push. Driving mpv --wid=<frame> --input-ipc-server=<socket> over a unix socket needs only subprocess, socket and json.

The socket must be drained. mpv pushes an asynchronous event stream plus a reply per command down the same connection. A client that only ever writes lets that backlog fill the kernel buffer, at which point mpv's writer for that connection blocks and it stops reading commands. Nothing errors: sendall still succeeds, mpv stays alive and keeps playing whatever it had, so the reviewer advances through filenames while the picture never changes. Measured: commands were silently ignored from about the thirty-fifth video onwards, and the very same command sent on a fresh connection worked instantly. player.py therefore runs a reader thread whose only job is to drain — deleting it reintroduces the freeze roughly half an hour into a session.

Two more mpv details worth knowing if you touch ui.py:

  • --wid does not draw into your widget. mpv creates its own X window and reparents it onto the frame, covering it completely — which is why the control bar is packed below the video rather than over it.
  • --input-vo-keyboard=no is what lets the Tk key bindings fire at all. Without it mpv's child window takes keyboard focus and Tk never sees a keypress.

Requirements

mpv and tk (system packages, installed by install.sh) and requests, which the repo already declares. Nothing else.