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
+83 -2
View File
@@ -8,9 +8,10 @@ from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from app import scheduler
from app import jobruns, scheduler
from app.catalog.models import Channel, Video
from app.models import Notification
from app.models import JobRun, Notification
from app.runstatus import RunStatus, RunTrigger
from app.reader import service, takeout
from app.reader.models import (
Playlist,
@@ -360,3 +361,83 @@ def test_an_ad_hoc_run_that_asks_for_it_tells_its_user_how_it_went(db, make_user
assert [(n.data["job_id"], n.data["result"]) for n in notices] == [
("takeout_import", {"watched": 3})
]
# --- the videos that wait ----------------------------------------------------------------------
def test_waiting_videos_are_marked_once_the_shorts_probe_has_cleared_them(db, make_user):
a, b = make_user("a@example.com"), make_user("b@example.com")
_video(db, "later000000", probed=False)
_video(db, "notyet00000", probed=False)
db.commit()
service.stage_takeout(
db, a, TakeoutKind.WATCHED, {"later000000": (T0, None), "notyet00000": (T0, None)}
)
service.stage_takeout(db, b, TakeoutKind.WATCHED, {"later000000": (T0, None)})
db.commit()
assert takeout.apply_waiting(db) == {}
# The probe clears one of them; the other is still unprobed.
db.get(Video, "later000000").shorts_probed = True
db.commit()
assert takeout.apply_waiting(db) == {"takeout_watched": 2}
assert _states(db, a)["later000000"].watched_at == T0
assert _states(db, b)["later000000"].status == "watched"
assert _rows(db, a)["notyet00000"].outcome is None
assert takeout.apply_waiting(db) == {}
def test_the_shorts_job_settles_the_waiting_rows_its_probe_made_ready(db, make_user):
user = make_user("job-shorts@example.com")
_video(db, "ready000000") # probed already: the job's probe has nothing left to do
db.commit()
service.stage_takeout(db, user, TakeoutKind.WATCHED, {"ready000000": (T0, None)})
db.commit()
scheduler._shorts_job()
db.expire_all()
run = db.execute(select(JobRun).where(JobRun.job == "shorts")).scalar_one()
assert run.result == {"probed": 0, "shorts": 0, "takeout_watched": 1}
assert _states(db, user)["ready000000"].status == "watched"
# --- what the settings page reads --------------------------------------------------------------
def test_the_status_is_the_users_own_last_run_and_counts(db, make_user, as_user):
user, other = make_user("status@example.com"), make_user("other@example.com")
assert as_user(user).get("/api/takeout").json() == {
"run": None,
"watched": {},
"watch_later": {},
}
_video(db, "plain000000")
_video(db, "short000000", short=True)
db.commit()
service.stage_takeout(
db,
user,
TakeoutKind.WATCHED,
{v: (T0, None) for v in ("plain000000", "short000000", "unknown0000")},
)
service.stage_takeout(db, user, TakeoutKind.WATCH_LATER, {"plain000000": (T0, None)})
service.stage_takeout(db, other, TakeoutKind.WATCHED, {"plain000000": (T0, None)})
service.apply_takeout_watched(db, user)
db.commit()
older = jobruns.start("takeout_import", RunTrigger.AD_HOC, user.id, f"user:{user.id}")
jobruns.finish(older, RunStatus.OK, summary=None, result={"watched": 0})
newest = jobruns.start("takeout_import", RunTrigger.AD_HOC, user.id, f"user:{user.id}")
jobruns.finish(newest, RunStatus.OK, summary=None, result={"watched": 1})
theirs = jobruns.start("takeout_import", RunTrigger.AD_HOC, other.id, f"user:{other.id}")
jobruns.finish(theirs, RunStatus.OK, summary=None, result={"watched": 99})
body = as_user(user).get("/api/takeout").json()
assert body["run"]["status"] == "ok"
assert body["run"]["result"] == {"watched": 1}
assert body["run"]["finished_at"] is not None
assert body["watched"] == {"watched": 1, "short": 1, "waiting": 1}
assert body["watch_later"] == {"waiting": 1}
+2
View File
@@ -364,6 +364,8 @@ UNIT_COMMITS = {
# The Takeout import is a recorded run with its own session: the watched part lands before
# the Watch later part asks YouTube (which commits the videos it fetched).
"reader/takeout.py:run_import",
# Run inside the Shorts job, on its session: each user's waiting rows land on their own.
"reader/takeout.py:apply_waiting",
},
# The catalog's ingest jobs commit per page or batch, a YouTube call between each: a page
# fetched and stored is not lost when the next call fails, and an enrich that fails rolls back