mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 23:07:03 +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>
249 lines
8.0 KiB
Python
249 lines
8.0 KiB
Python
"""Wires the pieces together and owns an orderly shutdown.
|
|
|
|
Kept separate from :mod:`python_pkg.wsg_grabber.cli` so the composition can be
|
|
exercised without argument parsing, and separate from
|
|
:mod:`python_pkg.wsg_grabber.ui` so nothing here needs a display.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import queue
|
|
import threading
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.wsg_grabber import (
|
|
db,
|
|
downloader,
|
|
files,
|
|
logs,
|
|
net,
|
|
paths,
|
|
review,
|
|
store,
|
|
store_verdicts,
|
|
verdict,
|
|
)
|
|
from python_pkg.wsg_grabber.constants import JOIN_TIMEOUT_S
|
|
from python_pkg.wsg_grabber.models import FileMove, Verdict, WorkerFailed
|
|
from python_pkg.wsg_grabber.states import TERMINAL, FileState
|
|
|
|
if TYPE_CHECKING:
|
|
import sqlite3
|
|
|
|
from python_pkg.wsg_grabber.models import DownloadEvent
|
|
from python_pkg.wsg_grabber.review import SessionState
|
|
|
|
|
|
@dataclass
|
|
class Session:
|
|
"""A running review session and everything it must clean up."""
|
|
|
|
conn: sqlite3.Connection
|
|
events: queue.SimpleQueue[DownloadEvent]
|
|
stop: threading.Event
|
|
worker: threading.Thread | None = None
|
|
|
|
def commit(self, state: SessionState, choice: Verdict) -> SessionState:
|
|
"""Apply a verdict: move the file, record it, advance the queue.
|
|
|
|
Args:
|
|
state: Current session state.
|
|
choice: What the user decided.
|
|
|
|
Returns:
|
|
SessionState: Updated state.
|
|
"""
|
|
item = state.current
|
|
if item is None:
|
|
return state
|
|
move = verdict.plan_move(
|
|
item,
|
|
choice,
|
|
paths.keep_dir(),
|
|
paths.trash_dir(),
|
|
files.existing_names(
|
|
verdict.target_dir(choice, paths.keep_dir(), paths.trash_dir()),
|
|
),
|
|
)
|
|
if not files.apply_move(move):
|
|
logs.warning(
|
|
"verdict.source_gone",
|
|
file=move.src.name,
|
|
choice=choice.value,
|
|
)
|
|
return self.missing(state)
|
|
logs.event(
|
|
"verdict.moved",
|
|
choice=choice.value,
|
|
src=str(move.src),
|
|
dst=str(move.dst),
|
|
md5=item.md5,
|
|
)
|
|
store_verdicts.record_verdict(
|
|
self.conn,
|
|
item.md5,
|
|
verdict.target_state(choice),
|
|
move.dst.name,
|
|
)
|
|
return review.on_verdict(state, choice)
|
|
|
|
def undo(self, state: SessionState) -> SessionState:
|
|
"""Take back the last verdict, putting the file back in the queue.
|
|
|
|
Args:
|
|
state: Current session state.
|
|
|
|
Returns:
|
|
SessionState: Updated state showing the restored video.
|
|
"""
|
|
action = store_verdicts.newest_verdict(self.conn, paths.incoming_dir())
|
|
if action is None:
|
|
return state
|
|
source = (
|
|
paths.keep_dir() if action.choice is Verdict.KEEP else paths.trash_dir()
|
|
) / action.reviewed_name
|
|
# Mirror what commit does: never assume the old name is free again.
|
|
destination = verdict.unique_destination(
|
|
paths.incoming_dir(),
|
|
action.item.path.name,
|
|
files.existing_names(paths.incoming_dir()),
|
|
)
|
|
back = FileMove(md5=action.item.md5, src=source, dst=destination)
|
|
if not files.apply_move(back):
|
|
# The file is no longer where the verdict left it -- most likely the
|
|
# user cleared trash/ by hand. Drop it from the undo trail so a
|
|
# second attempt does not keep failing on the same entry.
|
|
logs.warning(
|
|
"undo.file_gone",
|
|
expected=str(source),
|
|
md5=action.item.md5,
|
|
)
|
|
store_verdicts.forget_verdict(self.conn, action.item.md5)
|
|
return review.forget_last(state)
|
|
store_verdicts.restore_for_review(
|
|
self.conn,
|
|
action.item.md5,
|
|
destination.name,
|
|
)
|
|
logs.event(
|
|
"undo.applied",
|
|
choice=action.choice.value,
|
|
file=destination.name,
|
|
restored_from=str(source),
|
|
remaining=store_verdicts.reviewed_count(self.conn),
|
|
)
|
|
return review.on_undo(state, action)
|
|
|
|
def missing(self, state: SessionState) -> SessionState:
|
|
"""Write off a video whose file has disappeared.
|
|
|
|
Args:
|
|
state: Current session state.
|
|
|
|
Returns:
|
|
SessionState: Updated state showing the next video.
|
|
"""
|
|
item = state.current
|
|
if item is None:
|
|
return state
|
|
# A row that already carries a verdict must not be forced to GONE: that
|
|
# would drop it out of the undo trail while the in-memory count still
|
|
# believes it is there, and the verdict itself would be unrecoverable.
|
|
# Reachable when the same item is queued twice, e.g. two reviewers
|
|
# running against one index.
|
|
if store.state_of(self.conn, item.md5) in TERMINAL:
|
|
logs.warning("missing.already_reviewed", md5=item.md5)
|
|
return review.on_missing_locally(state)
|
|
store.set_state(
|
|
self.conn,
|
|
item.md5,
|
|
FileState.GONE,
|
|
error="file missing locally",
|
|
)
|
|
return review.on_missing_locally(state)
|
|
|
|
def shutdown(self) -> None:
|
|
"""Stop the worker and close the index.
|
|
|
|
Joining matters: the worker owns a database connection and an HTTP
|
|
session, and abandoning it leaks both. Note the suite does NOT catch
|
|
that for us -- ``meta/pyproject.toml`` downgrades
|
|
``PytestUnraisableExceptionWarning`` to a warning -- so this has to be
|
|
right by construction rather than by test pressure.
|
|
"""
|
|
self.stop.set()
|
|
if self.worker is not None:
|
|
self.worker.join(timeout=JOIN_TIMEOUT_S)
|
|
if self.worker.is_alive():
|
|
# A worker blocked in a socket read can outlive the join, since
|
|
# READ_TIMEOUT_S exceeds JOIN_TIMEOUT_S. Keep the handle so the
|
|
# failure is visible rather than silently discarded.
|
|
logs.warning("shutdown.worker_still_running")
|
|
else:
|
|
self.worker = None
|
|
self.conn.close()
|
|
|
|
|
|
def open_session(*, include_archive: bool = True) -> Session:
|
|
"""Prepare storage and start the background worker.
|
|
|
|
Args:
|
|
include_archive: Whether to walk the board's archive as well.
|
|
|
|
Returns:
|
|
Session: A running session; call :meth:`Session.shutdown` when done.
|
|
"""
|
|
paths.ensure_dirs()
|
|
conn = db.open_index(paths.db_path())
|
|
store.reset_in_flight(conn)
|
|
events: queue.SimpleQueue[DownloadEvent] = queue.SimpleQueue()
|
|
stop = threading.Event()
|
|
session = Session(conn=conn, events=events, stop=stop)
|
|
|
|
incoming = paths.incoming_dir()
|
|
db_file = paths.db_path()
|
|
|
|
def build() -> downloader.WorkerDeps:
|
|
"""Open the worker's own resources on the worker's own thread.
|
|
|
|
Returns:
|
|
downloader.WorkerDeps: Dependencies owned by the worker.
|
|
"""
|
|
return downloader.WorkerDeps(
|
|
conn=db.open_index(db_file),
|
|
session=net.build_session(),
|
|
incoming=incoming,
|
|
events=events,
|
|
stop=stop,
|
|
include_archive=include_archive,
|
|
)
|
|
|
|
try:
|
|
session.worker = downloader.start(
|
|
build,
|
|
lambda message: events.put(WorkerFailed(message=message)),
|
|
)
|
|
except BaseException:
|
|
conn.close()
|
|
raise
|
|
return session
|
|
|
|
|
|
def initial_state(session: Session) -> SessionState:
|
|
"""Build the state a window should open with.
|
|
|
|
Args:
|
|
session: The running session.
|
|
|
|
Returns:
|
|
SessionState: Starting state, including anything left unreviewed by a
|
|
previous run.
|
|
"""
|
|
counts = store.counts(session.conn)
|
|
return review.initial_state(
|
|
counts.get("total", 0),
|
|
store.ready_items(session.conn, paths.incoming_dir()),
|
|
store_verdicts.reviewed_count(session.conn),
|
|
)
|