testsAndMisc/python_pkg/wsg_grabber/app.py
Krzysztof kuhy Rudnicki 33f0fee342
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
wsg_grabber: keep to the cloud folder, cap trash at 200 passes
Kept videos land in ~/data/cloud/Media/<year>/wsg (~/Downloads without the
cloud folder); trash/ keeps only the newest 200 passes and marks the rest
purged. Legacy keep/ is migrated once. Undo follows a keep through every
place it can end up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8G3BNETGmbr6aM5cc18g4
2026-09-28 10:44:00 +02:00

250 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,
housekeeping,
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,
)
housekeeping.after_verdict(self.conn, choice)
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 = housekeeping.locate_reviewed(action)
# 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)
housekeeping.on_startup(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),
)