feat(reader): a Takeout video the catalog learns later is marked watched then; the import's status

A watched video the catalog did not know at import time waits in `takeout_items`. The Shorts job now
settles those rows right after its probe (the only place a video's Short-ness becomes known), every
user's, a commit each: `reader.takeout.apply_waiting`, its count in the job's result.

`GET /api/takeout` is what the settings page reads: the user's last import run (`jobruns.last_run`)
and the kept videos by kind and outcome, "waiting" for one not known yet.
This commit is contained in:
2026-10-23 17:30:28 +02:00
parent f486a69ef2
commit 5d9952ab1e
9 changed files with 181 additions and 9 deletions
+10
View File
@@ -119,6 +119,16 @@ def recent_runs(db: Session, jobs: list[str], per_job: int) -> dict[str, list[Jo
return out
def last_run(db: Session, job: str, target: str) -> JobRun | None:
"""The newest run of an ad-hoc `job` on `target` (a user's own import), or None."""
return db.execute(
select(JobRun)
.where(JobRun.job == job, JobRun.target == target)
.order_by(JobRun.started_at.desc(), JobRun.id.desc())
.limit(1)
).scalar_one_or_none()
def gc(db: Session, retention_days: int) -> int:
"""Delete finished runs older than `retention_days` (0 = keep forever). A run still going is
never reaped. The caller commits."""
+27
View File
@@ -422,3 +422,30 @@ def takeout_outcomes(db: Session, user: User) -> dict[tuple[str, str | None], in
.group_by(TakeoutItem.kind, TakeoutItem.outcome)
).all()
return {(kind, outcome): n for kind, outcome, n in rows}
def takeout_summary(db: Session, user: User) -> dict[str, dict[str, int]]:
"""The user's kept Takeout videos by kind and outcome, for their settings page: `{"watched":
{"watched": n, "short": n, "waiting": n, …}, "watch_later": {…}}` — the import's outcome words,
"waiting" for a video not known yet."""
out: dict[str, dict[str, int]] = {kind.value: {} for kind in TakeoutKind}
for (kind, outcome), n in takeout_outcomes(db, user).items():
out[kind][outcome or "waiting"] = n
return out
def takeout_users_ready(db: Session) -> list[int]:
"""Every user with a waiting "watched" row whose video is now known and probed for Shorts: the
rows a later run of the Shorts probe made ready."""
return list(
db.execute(
select(TakeoutItem.user_id)
.join(Video, Video.id == TakeoutItem.video_id)
.where(
TakeoutItem.kind == TakeoutKind.WATCHED,
TakeoutItem.outcome.is_(None),
Video.shorts_probed,
)
.distinct()
).scalars()
)
+24
View File
@@ -25,6 +25,8 @@ from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from sqlalchemy.orm import Session
from app import quota
from app.bodylimit import TAKEOUT_MAX_BYTES
from app.db import SessionLocal
@@ -174,6 +176,11 @@ def _one_import(user_id: int) -> Iterator[None]:
yield
def run_target(user_id: int) -> str:
"""The import run's target, `job_runs.target`: one user's import."""
return f"user:{user_id}"
def run_import(user_id: int) -> dict:
"""The import job: apply what the user's kept Takeout rows can say now, as one recorded run.
The watched part commits before the Watch later part asks YouTube for the videos the catalog
@@ -198,3 +205,20 @@ def run_import(user_id: int) -> dict:
if outcomes.get((TakeoutKind.WATCH_LATER, None)):
result["watch_later_waiting"] = outcomes[(TakeoutKind.WATCH_LATER, None)]
return result
def apply_waiting(db: Session) -> dict:
"""Mark watched the waiting Takeout rows whose video the catalog has come to know and the Shorts
probe has cleared since the import — every user's, one commit each. Run after each Shorts probe
(the scheduler's `shorts` job), the only place a video's Short-ness becomes known. Returns its
counts, or an empty dict when nothing was waiting on a probed video."""
users = queries.takeout_users_ready(db)
marked = 0
for user_id in users:
user = db.get(User, user_id)
if user is None:
continue
with _one_import(user_id):
marked += service.apply_takeout_watched(db, user)["watched"]
db.commit()
return {"takeout_watched": marked} if users else {}
+22 -2
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy.orm.exc import StaleDataError
from app import quota, scheduler
from app import jobruns, quota, scheduler
from app.auth import current_user
from app.db import get_db, release_read_connection
from app.mailtext import lang_for_user
@@ -334,7 +334,7 @@ def upload_takeout(
scheduler.TAKEOUT_IMPORT,
partial(takeout.run_import, user.id),
actor_id=user.id,
target=f"user:{user.id}",
target=takeout.run_target(user.id),
notify=True,
)
return {
@@ -346,3 +346,23 @@ def upload_takeout(
"other": parsed.other,
"malformed": parsed.malformed,
}
@router.get("/takeout")
def takeout_status(user: User = Depends(current_user), db: Session = Depends(get_db)):
"""What the user's Takeout import has done: its last run (none before the first upload), and
every video kept from the export, by kind and outcome — a video the catalog did not know yet
counts as "waiting" until it does, and is marked then."""
run = jobruns.last_run(db, scheduler.TAKEOUT_IMPORT, takeout.run_target(user.id))
return {
"run": None
if run is None
else {
"status": run.status,
"started_at": iso(run.started_at),
"finished_at": iso(run.finished_at),
"result": run.result,
"failure": run.failure,
},
**queries.takeout_summary(db, user),
}
+7 -1
View File
@@ -22,6 +22,7 @@ from app.plex.tmdb import run_people_enrich
from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync
from app.notifications import create_notification
from app.runstatus import RunStatus, RunTrigger
from app.reader import takeout
from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all
from app.sync.explore import purge_ephemeral
@@ -403,7 +404,12 @@ def _autotag_job() -> None:
def _shorts_job() -> None:
_job("shorts", run_shorts)
def work(db):
# A video waiting in someone's Takeout import becomes markable the moment the probe has
# said it is not a Short, so the import's waiting rows are settled right after it.
return run_shorts(db) | takeout.apply_waiting(db)
_job("shorts", work)
def _subscriptions_job() -> None: