Channel features: subscribers, new-videos badges, activity-driven sync
- Store subscriber count from YouTube statistics and show it on channel page - Sync 50 videos per channel with playlistItems pagination support - Show per-channel and per-category new-videos counters (2-day window) - Replace hourly videos sync with activity trigger (2h idle) and incremental backfill (hard cap 200 per channel) - Clicking the sidebar new-videos count filters the category feed to recent videos only (new_only) - Update agent-team docs: deploy after green checks
This commit is contained in:
parent
fde9a439df
commit
e10df8dcbd
27 changed files with 1420 additions and 107 deletions
159
tests/test_sync_trigger.py
Normal file
159
tests/test_sync_trigger.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services import sync_trigger
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_is_videos_sync_due_without_finished_at():
|
||||
assert sync_trigger.is_videos_sync_due(None, _now(), 2) is True
|
||||
|
||||
|
||||
def test_is_videos_sync_due_after_idle():
|
||||
finished = (_now() - timedelta(hours=3)).isoformat()
|
||||
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is True
|
||||
|
||||
|
||||
def test_is_videos_sync_due_at_exact_threshold():
|
||||
finished = (_now() - timedelta(hours=2)).isoformat()
|
||||
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is True
|
||||
|
||||
|
||||
def test_is_videos_sync_due_before_idle():
|
||||
finished = (_now() - timedelta(hours=1)).isoformat()
|
||||
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is False
|
||||
|
||||
|
||||
def test_is_videos_sync_due_unparseable_finished_at():
|
||||
assert sync_trigger.is_videos_sync_due("not-a-date", _now(), 2) is True
|
||||
assert sync_trigger.is_videos_sync_due(12345, _now(), 2) is True # non-str garbage
|
||||
|
||||
|
||||
def test_is_videos_sync_due_naive_finished_at():
|
||||
naive = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=3)
|
||||
assert sync_trigger.is_videos_sync_due(naive.isoformat(), _now(), 2) is True
|
||||
|
||||
|
||||
def test_extract_finished_at():
|
||||
assert sync_trigger._extract_finished_at(None) is None
|
||||
assert sync_trigger._extract_finished_at("not json") is None
|
||||
assert sync_trigger._extract_finished_at(json.dumps(["a"])) is None
|
||||
assert sync_trigger._extract_finished_at(json.dumps({"status": "running", "finished_at": None})) is None
|
||||
assert (
|
||||
sync_trigger._extract_finished_at(json.dumps({"status": "completed", "finished_at": "2026-09-10T12:00:00+00:00"}))
|
||||
== "2026-09-10T12:00:00+00:00"
|
||||
)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _patch_threads(monkeypatch, started):
|
||||
monkeypatch.setattr(
|
||||
sync_trigger,
|
||||
"threading",
|
||||
SimpleNamespace(Thread=lambda target=None, daemon=None: started.append({"target": target, "daemon": daemon})),
|
||||
)
|
||||
|
||||
|
||||
def test_maybe_trigger_skips_when_sync_running(monkeypatch):
|
||||
started = []
|
||||
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
sync_trigger,
|
||||
"SessionLocal",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("DB must not be touched while running")),
|
||||
)
|
||||
_patch_threads(monkeypatch, started)
|
||||
|
||||
sync_trigger.maybe_trigger_videos_sync()
|
||||
|
||||
assert started == []
|
||||
|
||||
|
||||
def test_maybe_trigger_skips_when_not_due(monkeypatch):
|
||||
started = []
|
||||
db = _FakeDB()
|
||||
raw = json.dumps({"status": "completed", "finished_at": (_now() - timedelta(hours=1)).isoformat()})
|
||||
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
||||
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: raw)
|
||||
_patch_threads(monkeypatch, started)
|
||||
|
||||
sync_trigger.maybe_trigger_videos_sync()
|
||||
|
||||
assert started == []
|
||||
assert db.closed is True
|
||||
|
||||
|
||||
def test_maybe_trigger_starts_thread_when_due_never_synced(monkeypatch):
|
||||
started = []
|
||||
db = _FakeDB()
|
||||
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
||||
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: None)
|
||||
_patch_threads(monkeypatch, started)
|
||||
|
||||
sync_trigger.maybe_trigger_videos_sync()
|
||||
|
||||
assert len(started) == 1
|
||||
assert started[0]["target"] == sync_trigger._run_videos_sync
|
||||
assert started[0]["daemon"] is True
|
||||
assert db.closed is True
|
||||
|
||||
|
||||
def test_maybe_trigger_starts_thread_when_due_after_idle(monkeypatch):
|
||||
started = []
|
||||
db = _FakeDB()
|
||||
raw = json.dumps({"status": "completed", "finished_at": (_now() - timedelta(hours=5)).isoformat()})
|
||||
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
||||
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: raw)
|
||||
_patch_threads(monkeypatch, started)
|
||||
|
||||
sync_trigger.maybe_trigger_videos_sync()
|
||||
|
||||
assert len(started) == 1
|
||||
assert started[0]["daemon"] is True
|
||||
|
||||
|
||||
def test_maybe_trigger_never_raises_when_db_unavailable(monkeypatch):
|
||||
started = []
|
||||
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
sync_trigger,
|
||||
"SessionLocal",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
|
||||
)
|
||||
_patch_threads(monkeypatch, started)
|
||||
|
||||
sync_trigger.maybe_trigger_videos_sync() # must not raise
|
||||
|
||||
assert started == []
|
||||
|
||||
|
||||
def test_run_videos_sync_swallows_sync_in_progress(monkeypatch):
|
||||
closed = []
|
||||
monkeypatch.setattr(
|
||||
sync_trigger,
|
||||
"SessionLocal",
|
||||
lambda: SimpleNamespace(close=lambda: closed.append(True)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync_trigger.sync,
|
||||
"sync_videos",
|
||||
lambda db: (_ for _ in ()).throw(sync_trigger.sync.SyncInProgress("busy")),
|
||||
)
|
||||
|
||||
sync_trigger._run_videos_sync() # must not raise
|
||||
|
||||
assert closed == [True]
|
||||
Loading…
Add table
Add a link
Reference in a new issue