mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 01:07:02 +01:00
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
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
250 lines
8.0 KiB
Python
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),
|
|
)
|