style: apply ruff format across backend + scripts (line-length 100)

Mechanical, whole-tree `ruff format` pass — no behaviour change. Recorded in
.git-blame-ignore-revs (next commit) so git blame skips it.
This commit is contained in:
2026-08-23 07:43:06 +02:00
parent 434a98d6ed
commit b1832afd18
217 changed files with 3149 additions and 1744 deletions
+1
View File
@@ -12,6 +12,7 @@ config the videos.search_vector generated column needs). Building it via migrati
that the migration chain applies cleanly. Each test then gets a clean database via TRUNCATE so
tests that COMMIT (the whole point of this lane — transaction-ownership behaviour) don't leak.
"""
import os
import shutil
+23 -6
View File
@@ -1,5 +1,6 @@
"""Per-source adapter selection + the source-specific policy each one carries. These lock the
behaviour that used to live inline in worker.build_uploader_url / backfill._should_refetch."""
from app.downloads.adapters import (
FacebookAdapter,
RedditAdapter,
@@ -30,7 +31,10 @@ def test_host_match_is_netloc_not_a_loose_substring():
def test_info_match_is_extractor_key_not_the_webpage_url():
# The webpage_url path may carry a SIBLING source's name (a reddit slug about youtube); only the
# yt-dlp extractor_key identifies the source, so this must resolve reddit, not youtube.
info = {"extractor_key": "Reddit", "webpage_url": "https://reddit.com/r/x/comments/y/youtube_is_dead"}
info = {
"extractor_key": "Reddit",
"webpage_url": "https://reddit.com/r/x/comments/y/youtube_is_dead",
}
assert resolve_adapter("url", None, info).key == "reddit"
@@ -53,16 +57,27 @@ def test_lookalike_hosts_do_not_misresolve_but_subdomains_do():
def test_webpage_url_netloc_identifies_source_when_extractor_key_absent():
# The early-fill path passes info with no source_ref; the webpage_url netloc still resolves the
# source even if extractor_key is missing from the progress-hook info_dict.
assert resolve_adapter(None, None, {"webpage_url": "https://www.facebook.com/reel/123"}).key == "facebook"
assert (
resolve_adapter(None, None, {"webpage_url": "https://www.facebook.com/reel/123"}).key
== "facebook"
)
# A sibling source's name in the webpage_url PATH must not misresolve (host, not substring).
info = {"extractor_key": "Reddit", "webpage_url": "https://www.reddit.com/r/x/comments/y/youtube"}
info = {
"extractor_key": "Reddit",
"webpage_url": "https://www.reddit.com/r/x/comments/y/youtube",
}
assert resolve_adapter(None, None, info).key == "reddit"
def test_uploader_url_youtube_uses_channel_url():
a = YouTubeAdapter()
assert a.uploader_url({"channel_url": "https://youtube.com/@chan"}) == "https://youtube.com/@chan"
assert a.uploader_url({"uploader_url": "https://youtube.com/user/u"}) == "https://youtube.com/user/u"
assert (
a.uploader_url({"channel_url": "https://youtube.com/@chan"}) == "https://youtube.com/@chan"
)
assert (
a.uploader_url({"uploader_url": "https://youtube.com/user/u"})
== "https://youtube.com/user/u"
)
assert a.uploader_url({}) is None
@@ -83,7 +98,9 @@ def test_uploader_url_reddit_and_generic_have_no_channel():
def test_generic_source_url_is_passthrough_youtube_reconstructs():
assert SourceAdapter().source_url("https://x/y") == "https://x/y"
assert YouTubeAdapter().source_url("VIDID12345_") == "https://www.youtube.com/watch?v=VIDID12345_"
assert (
YouTubeAdapter().source_url("VIDID12345_") == "https://www.youtube.com/watch?v=VIDID12345_"
)
def test_client_sets_and_retriable_are_youtube_only():
+11 -3
View File
@@ -9,6 +9,7 @@ override) so login, the wallet gate and epoch revoke are the code under test.
Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False}` when logged out, not a
401. A revoked session is therefore checked on a PROTECTED endpoint (`/api/feed`), which does 401.
"""
from datetime import timedelta
from fastapi.testclient import TestClient
@@ -82,13 +83,17 @@ def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_us
raw = _issue_token(db, user, "reset", timedelta(hours=1))
db.commit()
ok = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "brand new pw!"})
ok = client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": "brand new pw!"}
)
assert ok.status_code == 200
# A reset bumps the epoch (compromise response) -> the pre-reset cookie is now rejected.
assert client.get("/api/feed").status_code == 401
# The token is single-use: a replay is a clean 400, not a second reset.
replay = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "another pw!!"})
replay = client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": "another pw!!"}
)
assert replay.status_code == 400
@@ -99,7 +104,10 @@ def test_activating_an_unverified_account_drops_its_preset_password(db, make_use
from app.routes.admin import _activate_user_by_email
victim = make_user(
email="victim@example.com", password="attacker-chosen", email_verified=False, is_active=False
email="victim@example.com",
password="attacker-chosen",
email_verified=False,
is_active=False,
)
epoch0 = victim.session_epoch or 0
_activate_user_by_email(db, "victim@example.com")
+7 -2
View File
@@ -1,6 +1,7 @@
"""R14 backend coverage: the channel-detail Normal/Shorts/Live counts (moved off the manager table),
the update_channel -> immediate-backfill wiring (incl. the demo guard), and the interactive
"get full history" guard that keeps it from racing the scheduler's cursor or draining the budget."""
from datetime import datetime, timezone
import pytest
@@ -156,14 +157,18 @@ class TestImmediateBackfillGuard:
monkeypatch.setattr(runner.quota, "remaining_today", lambda _db: 10_000)
monkeypatch.setattr(runner, "YouTubeClient", lambda *a, **k: _NullYt())
monkeypatch.setattr(
runner, "backfill_channel_deep", lambda _db, _yt, channel, _mp: paged.append(channel.id) or 0
runner,
"backfill_channel_deep",
lambda _db, _yt, channel, _mp: paged.append(channel.id) or 0,
)
monkeypatch.setattr(runner, "reconcile_full_history", lambda _db: 0)
# …is skipped by run_deep_backfill while an interactive backfill owns it (no cursor race).
runner._backfill_in_progress.add(ch.id)
runner.run_deep_backfill(db)
assert paged == [], "the scheduler must not page a channel an immediate backfill is handling"
assert paged == [], (
"the scheduler must not page a channel an immediate backfill is handling"
)
# …and IS paged once the interactive backfill releases it.
runner._backfill_in_progress.discard(ch.id)
+1
View File
@@ -5,6 +5,7 @@ query ever dropped its user_id filter.
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from datetime import datetime, timezone
from fastapi.testclient import TestClient
@@ -3,6 +3,7 @@ transaction and a change lands together with its audit row. These lock that cont
before the caller commits discards the change (proving no hidden internal commit), while a same-session
read still sees it (flush visibility). DB lane (`db` fixture) — skipped without Postgres.
"""
from app import state, sysconfig
@@ -14,7 +15,9 @@ def test_set_value_flushes_but_leaves_the_commit_to_the_caller(db):
assert sysconfig.get_int(db, "quota_daily_budget") == new # visible in-session (flushed)
db.rollback() # the caller never committed
assert sysconfig.get_int(db, "quota_daily_budget") == default # discarded — no hidden self-commit
assert (
sysconfig.get_int(db, "quota_daily_budget") == default
) # discarded — no hidden self-commit
def test_reset_flushes_but_leaves_the_commit_to_the_caller(db):
@@ -25,7 +28,9 @@ def test_reset_flushes_but_leaves_the_commit_to_the_caller(db):
sysconfig.reset(db, "quota_daily_budget")
assert sysconfig.get_int(db, "quota_daily_budget") == default # reset visible in-session
db.rollback()
assert sysconfig.get_int(db, "quota_daily_budget") == default + 10 # reset discarded until committed
assert (
sysconfig.get_int(db, "quota_daily_budget") == default + 10
) # reset discarded until committed
def test_set_sync_paused_flushes_but_leaves_the_commit_to_the_caller(db):
+1
View File
@@ -1,6 +1,7 @@
"""Smoke test proving the DB-backed lane works (R6 S3b): a real commit round-trips, and each
test starts from a clean database (TRUNCATE isolation), which is what lets the transaction-
ownership tests (R6 S2) trust what they commit."""
from sqlalchemy import func, select
from app.models import User
+1
View File
@@ -1,4 +1,5 @@
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
from app.downloads.errors import classify
+13 -6
View File
@@ -9,6 +9,7 @@
Uses the `db` fixture (real Postgres) — skipped when no database is reachable. `_claim_job` runs in
its OWN session, so every test COMMITS its setup before calling it.
"""
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
from app.worker import _claim_job
@@ -28,9 +29,7 @@ def _job(db, user_id, queue_pos, status="queued", asset_id=None):
def _asset(db, status, ref):
a = MediaAsset(
source_kind="url", source_ref=ref, format_sig="s", status=status, ref_count=1
)
a = MediaAsset(source_kind="url", source_ref=ref, format_sig="s", status=status, ref_count=1)
db.add(a)
db.flush()
return a
@@ -50,12 +49,15 @@ def _quota(db, user_id, max_concurrent, unlimited=False):
# --- #2 · claim fairness --------------------------------------------------------------------
def test_capped_user_flood_does_not_starve_users_behind_the_window(db):
a = User(email="flooder@example.com")
b = User(email="waiter@example.com")
db.add_all([a, b])
db.flush()
_quota(db, a.id, max_concurrent=1) # A: 1 concurrent, already running one -> all A's queued are ineligible
_quota(
db, a.id, max_concurrent=1
) # A: 1 concurrent, already running one -> all A's queued are ineligible
_quota(db, b.id, max_concurrent=3)
_job(db, a.id, queue_pos=0, status="running")
for i in range(1, 61): # A floods the FRONT with far more than the 50-row fetch window
@@ -122,10 +124,14 @@ def test_job_whose_asset_is_already_downloading_is_skipped(db):
u = User(email="asset-excl@example.com")
db.add(u)
db.flush()
_quota(db, u.id, max_concurrent=5) # high → the ASSET rule, not the per-user rule, does the excluding
_quota(
db, u.id, max_concurrent=5
) # high → the ASSET rule, not the per-user rule, does the excluding
busy = _asset(db, status="downloading", ref="busy")
free = _asset(db, status="pending", ref="free")
_job(db, u.id, queue_pos=1, status="running", asset_id=busy.id) # the holder actually downloading it
_job(
db, u.id, queue_pos=1, status="running", asset_id=busy.id
) # the holder actually downloading it
competitor = _job(db, u.id, queue_pos=2, asset_id=busy.id) # same asset — must be skipped
behind = _job(db, u.id, queue_pos=3, asset_id=free.id) # different asset — should be served
db.commit()
@@ -149,6 +155,7 @@ def test_unlimited_user_is_never_excluded(db):
# --- #3 · list cap --------------------------------------------------------------------------
def test_list_downloads_is_capped(db, monkeypatch):
from app.routes import downloads as dl
+22 -5
View File
@@ -3,6 +3,7 @@
The regression these cover: `Path(info["filepath"] or "")` is `Path(".")`, which PASSES `.exists()`,
so a missing filepath (an arbitrary-URL extractor) skipped the staging fallback and crashed later
with "PosixPath('.') has an empty name" instead of downloading."""
import pytest
from app.downloads.edit import _concat_quote, parse_probe_streams
@@ -23,7 +24,9 @@ class TestProducedFile:
info = {"requested_downloads": [{"filepath": str(tmp_path / "v.mp4")}]}
assert _produced_file(info, staging) == tmp_path / "v.mp4"
@pytest.mark.parametrize("info", [{}, {"requested_downloads": [{}]}, {"requested_downloads": [{"filepath": ""}]}])
@pytest.mark.parametrize(
"info", [{}, {"requested_downloads": [{}]}, {"requested_downloads": [{"filepath": ""}]}]
)
def test_missing_filepath_falls_back_to_staging(self, tmp_path, info):
staging = _staging(tmp_path, "thumb.jpg", "clip.mp4")
assert _produced_file(info, staging) == tmp_path / "clip.mp4"
@@ -62,7 +65,11 @@ class TestProducedFile:
class TestMediaInfo:
def test_playlist_wrapper_unwraps_to_the_entry_that_downloaded(self):
info = {"id": "page", "title": "Article", "entries": [{"id": "vid", "requested_downloads": [{"filepath": "/x.mp4"}]}]}
info = {
"id": "page",
"title": "Article",
"entries": [{"id": "vid", "requested_downloads": [{"filepath": "/x.mp4"}]}],
}
assert _media_info(info)["id"] == "vid"
def test_plain_result_is_untouched(self):
@@ -86,7 +93,11 @@ class TestMediaInfo:
assert _media_info(info)["id"] == "a"
def test_wrapper_that_downloaded_itself_wins_over_its_entries(self):
info = {"id": "vid", "requested_downloads": [{"filepath": "/x.mp4"}], "entries": [{"id": "other"}]}
info = {
"id": "vid",
"requested_downloads": [{"filepath": "/x.mp4"}],
"entries": [{"id": "other"}],
}
assert _media_info(info)["id"] == "vid"
def test_empty_entries_are_ignored(self):
@@ -115,8 +126,14 @@ class TestParseProbeStreams:
assert parse_probe_streams(payload) == ("h264", "opus")
def test_audio_only_and_video_only(self):
assert parse_probe_streams('{"streams":[{"codec_name":"opus","codec_type":"audio"}]}') == (None, "opus")
assert parse_probe_streams('{"streams":[{"codec_name":"av1","codec_type":"video"}]}') == ("av1", None)
assert parse_probe_streams('{"streams":[{"codec_name":"opus","codec_type":"audio"}]}') == (
None,
"opus",
)
assert parse_probe_streams('{"streams":[{"codec_name":"av1","codec_type":"video"}]}') == (
"av1",
None,
)
def test_garbage_is_not_a_crash(self):
assert parse_probe_streams("") == (None, None)
+17 -5
View File
@@ -9,6 +9,7 @@ conversion is provably behaviour-equivalent (concurrency itself can't be exercis
DB lane (`db` fixture) — skipped without Postgres.
"""
from sqlalchemy import select, text
from app.downloads import service
@@ -31,8 +32,12 @@ def _ref_count(db, asset_id) -> int | None:
def _holding_asset(db, *, status="ready", ref_count=1, rel_path=None) -> MediaAsset:
asset = MediaAsset(
source_kind="url", source_ref=f"r{ref_count}-{status}", format_sig="sig",
status=status, ref_count=ref_count, rel_path=rel_path,
source_kind="url",
source_ref=f"r{ref_count}-{status}",
format_sig="sig",
status=status,
ref_count=ref_count,
rel_path=rel_path,
)
db.add(asset)
db.flush()
@@ -41,8 +46,13 @@ def _holding_asset(db, *, status="ready", ref_count=1, rel_path=None) -> MediaAs
def _job(db, user_id, asset_id, status="done") -> DownloadJob:
j = DownloadJob(
user_id=user_id, asset_id=asset_id, source_kind="url", source_ref="r",
status=status, queue_pos=1, profile_snapshot={},
user_id=user_id,
asset_id=asset_id,
source_kind="url",
source_ref="r",
status=status,
queue_pos=1,
profile_snapshot={},
)
db.add(j)
db.flush()
@@ -86,7 +96,9 @@ def test_release_is_a_noop_for_a_non_holding_job(db):
service.release_asset(db, canceled)
db.commit()
assert _ref_count(db, asset.id) == 1 # untouched — a canceled job already released while holding
assert (
_ref_count(db, asset.id) == 1
) # untouched — a canceled job already released while holding
def test_release_never_drives_ref_count_negative(db):
+14 -4
View File
@@ -5,6 +5,7 @@ the asset bookkeeping (release, error-reset, fresh-asset rebuild). This pins the
behaviour so the logic can move into `service.*` methods (with the routes as thin callers) provably
behaviour-equivalently. DB lane (`db` fixture) — skipped without Postgres.
"""
from sqlalchemy import text
from app.models import DownloadJob, MediaAsset, User
@@ -15,14 +16,23 @@ def _seed(db, *, job_status, asset_status="ready", ref_count=1, rel_path=None):
db.add(u)
db.flush()
asset = MediaAsset(
source_kind="url", source_ref=f"r-{u.id}", format_sig="s",
status=asset_status, ref_count=ref_count, rel_path=rel_path,
source_kind="url",
source_ref=f"r-{u.id}",
format_sig="s",
status=asset_status,
ref_count=ref_count,
rel_path=rel_path,
)
db.add(asset)
db.flush()
job = DownloadJob(
user_id=u.id, asset_id=asset.id, source_kind="url", source_ref="r",
status=job_status, queue_pos=1, profile_snapshot={},
user_id=u.id,
asset_id=asset.id,
source_kind="url",
source_ref="r",
status=job_status,
queue_pos=1,
profile_snapshot={},
)
db.add(job)
db.commit()
+3 -6
View File
@@ -6,6 +6,7 @@ file-delete-under-live-holder class (done/error hold the asset, canceled does no
atomic `UPDATE ... WHERE status='running'`, so these tests assert it fires exactly when the job is
still running and is a no-op otherwise. Uses the real-Postgres `db` fixture; skipped without a DB.
"""
from sqlalchemy import text
from app.downloads import service
@@ -53,9 +54,7 @@ def test_finalize_is_a_noop_on_a_canceled_job(db):
# finishing child then tries to write `done`. The guard must NOT resurrect it, or a later delete
# would double-release the asset.
job = _running_job(db)
db.execute(
text("UPDATE download_jobs SET status = 'canceled' WHERE id = :id"), {"id": job.id}
)
db.execute(text("UPDATE download_jobs SET status = 'canceled' WHERE id = :id"), {"id": job.id})
db.commit()
won = service.finalize_if_running(db, job.id, status=JobStatus.DONE, progress=100)
db.commit()
@@ -65,9 +64,7 @@ def test_finalize_is_a_noop_on_a_canceled_job(db):
def test_finalize_is_a_noop_on_a_paused_job(db):
job = _running_job(db)
db.execute(
text("UPDATE download_jobs SET status = 'paused' WHERE id = :id"), {"id": job.id}
)
db.execute(text("UPDATE download_jobs SET status = 'paused' WHERE id = :id"), {"id": job.id})
db.commit()
won = service.finalize_if_running(db, job.id, status=JobStatus.ERROR, error="boom", phase=None)
db.commit()
+4 -1
View File
@@ -5,6 +5,7 @@ plain ValueError and routes/feed.get_feed translates it to 400 — so a future r
try/except, or a new decode_cursor caller without its own catch) turns the 400 back into a 500 and
fails right here. DB lane (`db` fixture) — skipped without Postgres.
"""
from fastapi.testclient import TestClient
import app.state as state
@@ -15,7 +16,9 @@ from app.models import User
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app
monkeypatch.setattr(
state, "_configured_cache", True
) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
+53 -25
View File
@@ -11,6 +11,7 @@ NULLs in published_at / view_count / duration_seconds / title / subscriber_count
exercised on both a first and a second key). (The malformed-cursor -> 400 path is covered by
test_feed_cursor.py.)
"""
from datetime import datetime, timezone
import pytest
@@ -19,8 +20,19 @@ from app.models import Channel, Subscription, User, Video
# Every sort feedquery.sort_keys defines except "relevance" (which needs a search query to rank).
_SORTS = [
"newest", "oldest", "views", "views_asc", "duration_desc", "duration_asc",
"title", "title_desc", "subscribers", "subscribers_asc", "priority", "priority_asc", "shuffle",
"newest",
"oldest",
"views",
"views_asc",
"duration_desc",
"duration_asc",
"title",
"title_desc",
"subscribers",
"subscribers_asc",
"priority",
"priority_asc",
"shuffle",
]
@@ -30,38 +42,52 @@ def _dt(day: int) -> datetime:
def _seed(db):
# Two channels; one has a NULL subscriber_count (exercises the subscribers sort's nulls_last).
db.add_all([
Channel(id="UCa", title="Channel A", subscriber_count=1000),
Channel(id="UCb", title="Channel B", subscriber_count=None),
])
db.add_all(
[
Channel(id="UCa", title="Channel A", subscriber_count=1000),
Channel(id="UCb", title="Channel B", subscriber_count=None),
]
)
user = User(email="keyset@example.com", email_verified=True)
db.add(user)
db.flush()
db.add_all([
Subscription(user_id=user.id, channel_id="UCa", priority=5, hidden=False),
Subscription(user_id=user.id, channel_id="UCb", priority=0, hidden=False),
])
db.add_all(
[
Subscription(user_id=user.id, channel_id="UCa", priority=5, hidden=False),
Subscription(user_id=user.id, channel_id="UCb", priority=0, hidden=False),
]
)
# 7 normal/organic videos. Deliberate duplicates (published_at day 1 x2, day 2 x2 and day 3 x2,
# view_count 100 x2, duration 60 x2 / 30 x2) and NULLs spread across the still-nullable sort columns
# (view_count / duration_seconds / title / subscriber_count) to exercise nulls_last. published_at is
# NOT NULL (migration 0068), so no NULL there — the duplicates keep the id tiebreaker load-bearing.
rows = [
# id ch published views dur title
("vid00", "UCa", _dt(1), 100, 60, "alpha"),
("vid01", "UCa", _dt(1), 100, 60, "beta"),
("vid02", "UCa", _dt(2), None, None, "gamma"),
("vid03", "UCb", _dt(2), 50, 120, None),
("vid04", "UCb", _dt(3), 200, 30, "delta"),
("vid05", "UCb", _dt(3), None, 30, "epsilon"),
("vid06", "UCa", _dt(28), None, None, "zeta"),
("vid00", "UCa", _dt(1), 100, 60, "alpha"),
("vid01", "UCa", _dt(1), 100, 60, "beta"),
("vid02", "UCa", _dt(2), None, None, "gamma"),
("vid03", "UCb", _dt(2), 50, 120, None),
("vid04", "UCb", _dt(3), 200, 30, "delta"),
("vid05", "UCb", _dt(3), None, 30, "epsilon"),
("vid06", "UCa", _dt(28), None, None, "zeta"),
]
db.add_all([
Video(
id=vid, channel_id=ch, published_at=pub, view_count=views, duration_seconds=dur,
title=title, is_short=False, live_status="none", via_search=False, via_explore=False,
)
for vid, ch, pub, views, dur, title in rows
])
db.add_all(
[
Video(
id=vid,
channel_id=ch,
published_at=pub,
view_count=views,
duration_seconds=dur,
title=title,
is_short=False,
live_status="none",
via_search=False,
via_explore=False,
)
for vid, ch, pub, views, dur, title in rows
]
)
db.commit()
return user, [r[0] for r in rows]
@@ -91,7 +117,9 @@ def test_cursor_walk_matches_the_unpaged_order(db, as_user, sort):
full = [it["id"] for it in client.get(f"/api/feed?sort={sort}&limit=50").json()["items"]]
# Guard against a vacuous pass: if seeding ever stops surfacing the videos, `walked == full` would
# be [] == [] for every sort and never exercise the cursor. All 7 rows must be present.
assert len(full) == len(ids), f"{sort}: seed did not surface all rows — the walk would be vacuous"
assert len(full) == len(ids), (
f"{sort}: seed did not surface all rows — the walk would be vacuous"
)
# Walk it two at a time via the cursor and reassemble.
walked: list[str] = []
+45 -28
View File
@@ -7,6 +7,7 @@ Regression nets for two query rewrites that must stay behaviour-equivalent:
- PERF-16: the watched/hidden/in_progress views moved from an outer join + `coalesce(state.status,'new')`
to an inner join on video_states + the raw column. Each view must return exactly the same set.
"""
from datetime import datetime, timezone
import pytest
@@ -20,8 +21,14 @@ def _dt(day: int) -> datetime:
def _video(vid: str, ch: str, title: str) -> Video:
return Video(
id=vid, channel_id=ch, published_at=_dt(1), title=title,
is_short=False, live_status="none", via_search=False, via_explore=False,
id=vid,
channel_id=ch,
published_at=_dt(1),
title=title,
is_short=False,
live_status="none",
via_search=False,
via_explore=False,
)
@@ -37,19 +44,25 @@ def test_search_matches_both_video_text_and_channel_name(db, as_user, make_user)
# the name match, so both videos come back — and an unrelated video on a matching-name-less channel
# stays out.
user = make_user(email="search@example.com")
db.add_all([
Channel(id="UCkube", title="Kubernetes Weekly", subscriber_count=10),
Channel(id="UCother", title="Some Other Channel", subscriber_count=10),
])
db.add_all([
Subscription(user_id=user.id, channel_id="UCkube", priority=0, hidden=False),
Subscription(user_id=user.id, channel_id="UCother", priority=0, hidden=False),
])
db.add_all([
_video("vidChan", "UCkube", "Totally unrelated content"), # channel NAME matches
_video("vidText", "UCother", "kubernetes deep dive"), # video TEXT matches
_video("vidNone", "UCother", "cooking pasta at home"), # neither matches
])
db.add_all(
[
Channel(id="UCkube", title="Kubernetes Weekly", subscriber_count=10),
Channel(id="UCother", title="Some Other Channel", subscriber_count=10),
]
)
db.add_all(
[
Subscription(user_id=user.id, channel_id="UCkube", priority=0, hidden=False),
Subscription(user_id=user.id, channel_id="UCother", priority=0, hidden=False),
]
)
db.add_all(
[
_video("vidChan", "UCkube", "Totally unrelated content"), # channel NAME matches
_video("vidText", "UCother", "kubernetes deep dive"), # video TEXT matches
_video("vidNone", "UCother", "cooking pasta at home"), # neither matches
]
)
db.commit()
ids = _feed_ids(as_user(user), "q=kubernetes&show=all")
@@ -60,20 +73,24 @@ def test_watch_state_views_return_the_right_sets(db, as_user, make_user):
user = make_user(email="views@example.com")
db.add(Channel(id="UCv", title="Channel V", subscriber_count=10))
db.add(Subscription(user_id=user.id, channel_id="UCv", priority=0, hidden=False))
db.add_all([
_video("vWatch", "UCv", "watched one"),
_video("vHide", "UCv", "hidden one"),
_video("vProg", "UCv", "in progress one"),
_video("vNew", "UCv", "brand new one"),
])
db.add_all(
[
_video("vWatch", "UCv", "watched one"),
_video("vHide", "UCv", "hidden one"),
_video("vProg", "UCv", "in progress one"),
_video("vNew", "UCv", "brand new one"),
]
)
db.flush()
db.add_all([
VideoState(user_id=user.id, video_id="vWatch", status="watched"),
VideoState(user_id=user.id, video_id="vHide", status="hidden"),
# in progress = a stored resume position while still "new" (not watched/hidden)
VideoState(user_id=user.id, video_id="vProg", status="new", position_seconds=30),
# vNew has NO state row -> "new"/unwatched
])
db.add_all(
[
VideoState(user_id=user.id, video_id="vWatch", status="watched"),
VideoState(user_id=user.id, video_id="vHide", status="hidden"),
# in progress = a stored resume position while still "new" (not watched/hidden)
VideoState(user_id=user.id, video_id="vProg", status="new", position_seconds=30),
# vNew has NO state row -> "new"/unwatched
]
)
db.commit()
client = as_user(user)
+7 -2
View File
@@ -4,6 +4,7 @@ run_ffmpeg calls `on_progress`/`should_cancel` once per ffmpeg output LINE (`-pr
emits a block of ~12 every half second), and both callbacks are database round-trips: `_set_job`
opens a session and UPDATE+COMMITs, `_job_status` opens one and SELECTs. Unthrottled, a long
re-encode is ~10^5 of each."""
import app.downloads.pipeline as pipeline
@@ -71,7 +72,9 @@ class TestProgressWriter:
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
write(6.0) # progress write at t=1000 → refreshes updated_at
clock["t"] += 0.3
write(6.4) # same percent, SAME interval → the write already touched the row, so no heartbeat
write(
6.4
) # same percent, SAME interval → the write already touched the row, so no heartbeat
assert [w["progress"] for w in writes] == [6]
assert touches == []
@@ -79,7 +82,9 @@ class TestProgressWriter:
class TestCancelPoll:
def test_asks_once_then_reuses_the_answer(self, monkeypatch):
calls = []
monkeypatch.setattr(pipeline, "_job_status", lambda job_id: calls.append(job_id) or "running")
monkeypatch.setattr(
pipeline, "_job_status", lambda job_id: calls.append(job_id) or "running"
)
cancelled = pipeline._cancel_poll(7)
for _ in range(40):
assert cancelled() is False
+1
View File
@@ -1,6 +1,7 @@
"""build_ydl_opts: the mp4 compat profile must SELECT H.264 (not merely prefer it), so shared
downloads play on iOS/Safari, while custom vcodec / non-mp4 profiles opt out. See the iPad AV1
broken-player fix."""
from app.downloads import edit as editmod
from app.downloads import formats
+1
View File
@@ -1,5 +1,6 @@
"""Orphan-file sweep: remove on-disk cruft no ready asset owns (old accented dirs, stale posters),
while never touching system trees, owned files, or freshly-written downloads."""
import os
import time
from pathlib import Path
+1
View File
@@ -1,5 +1,6 @@
"""Signed-grant HMAC for password-protected share links: a grant proves the viewer unlocked a
token, rides in the file URL (a <video src> can't send a header), and expires."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
+8 -2
View File
@@ -16,6 +16,7 @@ constraints, SERIAL sequences) that can't be expressed on `Base.metadata`, so co
noise; they're filtered out. The column/table signal is the "someone changed a model without a migration"
case worth failing on.
"""
from alembic import command
from alembic.autogenerate import compare_metadata
from alembic.config import Config
@@ -26,7 +27,12 @@ from app.db import Base, engine
# The diff ops that mean a real table/column disagreement (presence AND shape) — not the index/
# constraint/sequence noise from migration-only objects.
_DRIFT_OPS = {
"add_table", "remove_table", "add_column", "remove_column", "modify_type", "modify_nullable",
"add_table",
"remove_table",
"add_column",
"remove_column",
"modify_type",
"modify_nullable",
}
@@ -39,7 +45,7 @@ def _structural_drift():
diffs = compare_metadata(ctx, Base.metadata)
drift = []
for d in diffs:
for entry in (d if isinstance(d, list) else [d]):
for entry in d if isinstance(d, list) else [d]:
if isinstance(entry, tuple) and entry and entry[0] in _DRIFT_OPS:
drift.append(entry)
return drift
+8 -4
View File
@@ -1,5 +1,6 @@
"""ASCII-slug naming (no spaces, no accents) for on-disk paths and served filenames, and the
social engagement-prefix strip in title normalization."""
from app.downloads import storage
from app.titles import normalize_title
@@ -11,7 +12,9 @@ def _is_ascii_slug(s: str) -> bool:
def test_sanitize_folds_accents_to_ascii():
assert storage.sanitize("Barnabás Skriván") == "Barnabas_Skrivan"
assert storage.sanitize("Comedy Central Magyarország") == "Comedy_Central_Magyarorszag"
assert storage.sanitize("Marco Mobili Bútoráruház Veszprém") == "Marco_Mobili_Butoraruhaz_Veszprem"
assert (
storage.sanitize("Marco Mobili Bútoráruház Veszprém") == "Marco_Mobili_Butoraruhaz_Veszprem"
)
# Hungarian double-acute (ő/ű) and German ß.
assert storage.sanitize("Előző Műsor") == "Elozo_Musor"
assert storage.sanitize("Straße") == "Strasse"
@@ -61,9 +64,10 @@ def test_rel_path_strips_exclamation_and_other_shell_chars():
def test_engagement_prefix_stripped():
assert normalize_title(
"1.6M views · 66K reactions | Amikor a férfi éhesen ér haza"
) == "Amikor a férfi éhesen ér haza"
assert (
normalize_title("1.6M views · 66K reactions | Amikor a férfi éhesen ér haza")
== "Amikor a férfi éhesen ér haza"
)
assert normalize_title("13M views · 162K reactions |Valami cím") == "Valami cím"
assert normalize_title("500 views | Rövid") == "Rövid"
+3 -6
View File
@@ -5,6 +5,7 @@ clamp that string went straight into Notification.title (String(255)) and, at th
download_gc commit, raised psycopg StringDataRightTruncation — aborting the entire GC run so
no expiry deletion / LRU eviction / orphan reap ever ran. The clamp trims the title to the
column limit centrally, defending all callers regardless of DB backend."""
from app.models import Notification, User
from app.notifications import TITLE_MAX_LEN, create_notification
@@ -28,9 +29,7 @@ class _CaptureDB:
def test_title_clamped_to_column_limit():
long_title = "x" * 2000 # e.g. a reel caption
notif = create_notification(
_CaptureDB(), user_id=1, type="download_expiring", title=long_title
)
notif = create_notification(_CaptureDB(), user_id=1, type="download_expiring", title=long_title)
assert len(notif.title) == TITLE_MAX_LEN
assert notif.title.endswith("…")
# The untruncated title still reaches the client via `data`, which is never clamped.
@@ -68,8 +67,6 @@ def test_over_long_title_actually_commits_on_postgres(db):
user = User(email="reel@example.com")
db.add(user)
db.flush()
notif = create_notification(
db, user_id=user.id, type="download_expiring", title="z" * 2000
)
notif = create_notification(db, user_id=user.id, type="download_expiring", title="z" * 2000)
db.refresh(notif)
assert len(notif.title) == TITLE_MAX_LEN # persisted, not truncated by the DB
+4 -1
View File
@@ -2,6 +2,7 @@
use the fixed configured URL and never trust the request Host (a forged Host must not be able to
redirect the code); in local dev it honors a small allowlist of dev origins so a login started on the
Vite server (:5173) returns to :5173."""
from types import SimpleNamespace
from app.routes import auth
@@ -44,7 +45,9 @@ def test_dev_host_is_case_insensitive(monkeypatch):
def test_dev_unknown_or_missing_host_falls_back_to_configured(monkeypatch):
monkeypatch.setattr(auth, "settings", _dev_settings())
# An origin not on the allowlist (or a request with no Host) must not drive the redirect.
assert auth._oauth_redirect_uri(_req("evil.example.com")) == "http://localhost:8080/auth/callback"
assert (
auth._oauth_redirect_uri(_req("evil.example.com")) == "http://localhost:8080/auth/callback"
)
assert auth._oauth_redirect_uri(_req(None)) == "http://localhost:8080/auth/callback"
+1
View File
@@ -3,6 +3,7 @@
Facebook's scraper fetches og:image asynchronously on first scrape, so the card is image-less
unless og:image:width/height are declared — which needs the poster's real dimensions, read from the
JPEG header without an image library. This pins that parser."""
import struct
from app.downloads.og import _jpeg_size
+125 -37
View File
@@ -2,6 +2,7 @@
several collections). `collections=A,B` = ANY (OR, default); `&collection_mode=all` = ALL (AND).
DB lane (`db` fixture) — skipped without Postgres.
"""
from datetime import datetime, timezone
from fastapi.testclient import TestClient
@@ -26,14 +27,31 @@ def _seed(db):
kind="movie",
people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
db.add_all([
PlexItem(rating_key="mA", title="Only A", collection_keys=["A"],
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), **common),
PlexItem(rating_key="mB", title="Only B", collection_keys=["B"],
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), **common),
PlexItem(rating_key="mAB", title="A and B", collection_keys=["A", "B"],
added_at=datetime(2024, 1, 3, tzinfo=timezone.utc), **common),
])
db.add_all(
[
PlexItem(
rating_key="mA",
title="Only A",
collection_keys=["A"],
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
**common,
),
PlexItem(
rating_key="mB",
title="Only B",
collection_keys=["B"],
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
**common,
),
PlexItem(
rating_key="mAB",
title="A and B",
collection_keys=["A", "B"],
added_at=datetime(2024, 1, 3, tzinfo=timezone.utc),
**common,
),
]
)
user = User(email="plexcoll@example.com")
db.add(user)
db.commit()
@@ -69,7 +87,9 @@ def test_multiple_collections_any_is_the_union(db, monkeypatch):
def test_multiple_collections_all_is_the_intersection(db, monkeypatch):
user = _seed(db)
resp = _get(
db, user, monkeypatch,
db,
user,
monkeypatch,
"/api/plex/library?scope=movie&collections=A,B&collection_mode=all",
)
assert _titles(resp) == {"A and B"}
@@ -82,17 +102,34 @@ def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch):
db.flush()
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Coll A", child_count=2))
common = dict(library_id=lib.id, kind="movie", collection_keys=["A"])
db.add_all([
PlexItem(rating_key="m1", title="M1", duration_s=3600, rating=8.0,
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), **common),
PlexItem(rating_key="m2", title="M2", duration_s=1800, rating=9.0,
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), **common),
])
db.add_all(
[
PlexItem(
rating_key="m1",
title="M1",
duration_s=3600,
rating=8.0,
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
**common,
),
PlexItem(
rating_key="m2",
title="M2",
duration_s=1800,
rating=9.0,
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
**common,
),
]
)
user = User(email="collart@example.com")
db.add(user)
db.commit()
card = next(c for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
if c["id"] == "A")
card = next(
c
for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
if c["id"] == "A"
)
assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0)
assert card["total_runtime_s"] == 5400 # 3600 + 1800
@@ -128,12 +165,32 @@ def test_collection_detail_hero_and_ordered_members(db, monkeypatch):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Coll A", summary="S", child_count=2))
db.add(
PlexCollection(
rating_key="A", library_id=lib.id, title="Coll A", summary="S", child_count=2
)
)
common = dict(library_id=lib.id, kind="movie", collection_keys=["A"])
db.add_all([
PlexItem(rating_key="later", title="Later", year=1996, duration_s=3600, content_rating="PG", **common),
PlexItem(rating_key="early", title="Early", year=1992, duration_s=1800, content_rating="G", **common),
])
db.add_all(
[
PlexItem(
rating_key="later",
title="Later",
year=1996,
duration_s=3600,
content_rating="PG",
**common,
),
PlexItem(
rating_key="early",
title="Early",
year=1992,
duration_s=1800,
content_rating="G",
**common,
),
]
)
user = User(email="cd@example.com")
db.add(user)
db.commit()
@@ -186,7 +243,9 @@ class _CollStub:
def _resync_ok(db, plex, lib, rk):
"""Stand in for the post-write refresh: return the existing local row (no live Plex read)."""
return db.execute(select(PlexCollection).where(PlexCollection.rating_key == rk)).scalars().first()
return (
db.execute(select(PlexCollection).where(PlexCollection.rating_key == rk)).scalars().first()
)
def _seed_editable(db):
@@ -194,10 +253,16 @@ def _seed_editable(db):
db.add(lib)
db.flush()
# Plain, admin-editable collection (title isn't a provider word, not smart → source "collection").
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="My Coll", editable=True,
child_count=1))
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1",
collection_keys=["A"]))
db.add(
PlexCollection(
rating_key="A", library_id=lib.id, title="My Coll", editable=True, child_count=1
)
)
db.add(
PlexItem(
rating_key="m1", library_id=lib.id, kind="movie", title="M1", collection_keys=["A"]
)
)
user = User(email="mut@example.com")
db.add(user)
db.commit()
@@ -262,7 +327,11 @@ def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeyp
def _resync_boom(db_, plex, lib_, rk):
# Simulate resync having flushed a partial local change before its Plex read blew up — the
# route's rollback must undo it (this is what makes the rollback assertion non-vacuous).
row = db_.execute(select(PlexCollection).where(PlexCollection.rating_key == rk)).scalars().first()
row = (
db_.execute(select(PlexCollection).where(PlexCollection.rating_key == rk))
.scalars()
.first()
)
row.title = "PARTIAL_SHOULD_ROLL_BACK"
db_.flush()
raise PlexError("read failed")
@@ -275,7 +344,9 @@ def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeyp
assert "rename_collection" in stub.calls # the write DID happen
finally:
app.dependency_overrides.clear()
row = db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
row = (
db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
)
assert row.title == "My Coll" # the partial resync upsert was rolled back
@@ -286,16 +357,33 @@ def test_show_collection_gets_montage_art_from_shows(db, monkeypatch):
db.add(lib)
db.flush()
db.add(PlexCollection(rating_key="S", library_id=lib.id, title="Show Coll", child_count=2))
db.add_all([
PlexShow(rating_key="s1", library_id=lib.id, title="S1", collection_keys=["S"], rating=7.0,
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc)),
PlexShow(rating_key="s2", library_id=lib.id, title="S2", collection_keys=["S"], rating=9.0,
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc)),
])
db.add_all(
[
PlexShow(
rating_key="s1",
library_id=lib.id,
title="S1",
collection_keys=["S"],
rating=7.0,
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
),
PlexShow(
rating_key="s2",
library_id=lib.id,
title="S2",
collection_keys=["S"],
rating=9.0,
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
),
]
)
user = User(email="showcoll@example.com")
db.add(user)
db.commit()
card = next(c for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
if c["id"] == "S")
card = next(
c
for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
if c["id"] == "S"
)
assert card["art"] == ["s2", "s1"] # show members, best-rated first
assert card["total_runtime_s"] == 0
+32 -7
View File
@@ -9,6 +9,7 @@ rich-fetch time and served from the DB row instead of a live plex.metadata() cal
The endpoint tests use the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from types import SimpleNamespace
from fastapi.testclient import TestClient
@@ -37,7 +38,11 @@ RICH_META = {
"studio": "Paramount",
"tagline": "An offer you can't refuse.",
"Role": [
{"tag": "Marlon Brando", "role": "Vito Corleone", "thumb": "https://metadata-static.plex.tv/b.jpg"},
{
"tag": "Marlon Brando",
"role": "Vito Corleone",
"thumb": "https://metadata-static.plex.tv/b.jpg",
},
{"tag": "Al Pacino", "role": "Michael"},
],
}
@@ -45,6 +50,7 @@ RICH_META = {
# ---- pure parse/persist --------------------------------------------------------------------------
def test_rich_detail_prefers_imdb_entry_and_builds_link():
d = detail.rich_detail(RICH_META)
assert d["imdb_rating"] == 8.7 # the imdb Rating entry, not audienceRating (9.9)
@@ -83,6 +89,7 @@ def test_apply_rich_detail_empty_cast_persists_null():
# ---- backfill persistence (DB lane) --------------------------------------------------------------
class _StubClient:
"""Stand-in for PlexClient: `_enrich_people_batch` calls plex.metadata(rating_key); item_detail
uses it as a context manager and passes markers=True. `raise_` simulates a Plex outage."""
@@ -108,7 +115,10 @@ def test_apply_facet_fields_preserves_enriched_cast_names():
sync never clears people_enriched_at, nothing would regrow it and the person-page filter would lose
the names past the listing cap (regression guard for E5 S5b's enrich gating)."""
# The cheap listing's top few cast + the (always-present) director.
thin = {"Role": [{"tag": "Al Pacino"}, {"tag": "James Caan"}], "Director": [{"tag": "Francis Ford Coppola"}]}
thin = {
"Role": [{"tag": "Al Pacino"}, {"tag": "James Caan"}],
"Director": [{"tag": "Francis Ford Coppola"}],
}
# Not-yet-enriched row: the listing cast IS applied.
fresh = SimpleNamespace(people_enriched_at=None, directors=None, cast_names=None)
@@ -117,6 +127,7 @@ def test_apply_facet_fields_preserves_enriched_cast_names():
# Enriched row: the fuller stored cast is KEPT, not overwritten by the thin listing.
from datetime import datetime, timezone
enriched = SimpleNamespace(
people_enriched_at=datetime.now(timezone.utc),
directors=["Francis Ford Coppola"],
@@ -179,6 +190,7 @@ def test_enrich_people_batch_persists_detail(db):
# ---- item_detail serving (DB lane) ---------------------------------------------------------------
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
app.dependency_overrides[get_db] = lambda: db
@@ -203,10 +215,18 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
db.add(lib)
db.flush()
from datetime import datetime, timezone
mv = PlexItem(
rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather",
genres=["Crime", "Drama"], directors=["Francis Ford Coppola"], studio="Paramount",
content_rating="R", imdb_rating=8.7, imdb_id="tt0068646",
rating_key="m1",
library_id=lib.id,
kind="movie",
title="The Godfather",
genres=["Crime", "Drama"],
directors=["Francis Ford Coppola"],
studio="Paramount",
content_rating="R",
imdb_rating=8.7,
imdb_id="tt0068646",
tagline="An offer you can't refuse.",
full_cast=[{"name": "Marlon Brando", "role": "Vito Corleone", "thumb": None}],
people_enriched_at=datetime.now(timezone.utc),
@@ -283,9 +303,14 @@ def test_show_detail_serves_from_row(db, monkeypatch):
db.add(lib)
db.flush()
from datetime import datetime, timezone
show = PlexShow(
rating_key="s1", library_id=lib.id, title="A Show", genres=["Drama"],
imdb_rating=9.1, imdb_id="tt0903747",
rating_key="s1",
library_id=lib.id,
title="A Show",
genres=["Drama"],
imdb_rating=9.1,
imdb_id="tt0903747",
full_cast=[{"name": "Bryan Cranston", "role": "Walter White", "thumb": None}],
people_enriched_at=datetime.now(timezone.utc),
)
+118 -42
View File
@@ -3,6 +3,7 @@ payload (continue watching / recently added / genre tiles / because-you-watched)
and GET /api/plex/surprise returns a scope-bounded random sample. DB lane (`db` fixture) — skipped
without Postgres.
"""
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
@@ -26,8 +27,13 @@ def _seed(db):
db.flush()
m1 = PlexItem(
rating_key="m1", library_id=lib_m.id, kind="movie", title="Anchor Movie",
guid="tmdb:111", genres=["Drama"], year=2001,
rating_key="m1",
library_id=lib_m.id,
kind="movie",
title="Anchor Movie",
guid="tmdb:111",
genres=["Drama"],
year=2001,
added_at=NOW - timedelta(days=1),
tmdb_enriched_at=NOW - timedelta(days=1),
recommendations=[
@@ -35,26 +41,52 @@ def _seed(db):
],
)
m2 = PlexItem(
rating_key="m2", library_id=lib_m.id, kind="movie", title="Rec Movie",
guid="tmdb:555", genres=["Comedy"], year=2002,
rating_key="m2",
library_id=lib_m.id,
kind="movie",
title="Rec Movie",
guid="tmdb:555",
genres=["Comedy"],
year=2002,
added_at=NOW - timedelta(days=2),
)
m3 = PlexItem(
rating_key="m3", library_id=lib_m.id, kind="movie", title="Half-Watched Movie",
guid="tmdb:333", genres=["Drama"], year=2003, duration_s=6000,
rating_key="m3",
library_id=lib_m.id,
kind="movie",
title="Half-Watched Movie",
guid="tmdb:333",
genres=["Drama"],
year=2003,
duration_s=6000,
added_at=NOW - timedelta(days=3),
)
sh1 = PlexShow(rating_key="sh1", library_id=lib_s.id, title="A Show", child_count=1,
added_at=NOW - timedelta(hours=1)) # newest → leads recently-added
sh1 = PlexShow(
rating_key="sh1",
library_id=lib_s.id,
title="A Show",
child_count=1,
added_at=NOW - timedelta(hours=1),
) # newest → leads recently-added
db.add_all([m1, m2, m3, sh1])
db.flush()
ep1 = PlexItem(
rating_key="sh1e1", library_id=lib_s.id, kind="episode", title="Ep 1",
show_id=sh1.id, season_number=1, episode_number=1,
rating_key="sh1e1",
library_id=lib_s.id,
kind="episode",
title="Ep 1",
show_id=sh1.id,
season_number=1,
episode_number=1,
)
ep2 = PlexItem(
rating_key="sh1e2", library_id=lib_s.id, kind="episode", title="Ep 2",
show_id=sh1.id, season_number=1, episode_number=2,
rating_key="sh1e2",
library_id=lib_s.id,
kind="episode",
title="Ep 2",
show_id=sh1.id,
season_number=1,
episode_number=2,
)
db.add_all([ep1, ep2])
@@ -62,16 +94,28 @@ def _seed(db):
db.add(user)
db.flush()
db.add_all([
# m1 watched most recently → the because-you-watched anchor.
PlexState(user_id=user.id, item_id=m1.id, status="watched", watched_at=NOW),
# m3 in-progress → continue watching.
PlexState(user_id=user.id, item_id=m3.id, status="new", position_seconds=1200,
progress_updated_at=NOW - timedelta(hours=1)),
# sh1's ep1 in-progress → the show is in continue watching.
PlexState(user_id=user.id, item_id=ep1.id, status="new", position_seconds=300,
progress_updated_at=NOW - timedelta(minutes=30)),
])
db.add_all(
[
# m1 watched most recently → the because-you-watched anchor.
PlexState(user_id=user.id, item_id=m1.id, status="watched", watched_at=NOW),
# m3 in-progress → continue watching.
PlexState(
user_id=user.id,
item_id=m3.id,
status="new",
position_seconds=1200,
progress_updated_at=NOW - timedelta(hours=1),
),
# sh1's ep1 in-progress → the show is in continue watching.
PlexState(
user_id=user.id,
item_id=ep1.id,
status="new",
position_seconds=300,
progress_updated_at=NOW - timedelta(minutes=30),
),
]
)
db.commit()
return user
@@ -131,22 +175,40 @@ def test_continue_watching_null_status_in_hidden_out(db, monkeypatch):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
m_null = PlexItem(rating_key="mn", library_id=lib.id, kind="movie", title="Null Status", guid="tmdb:1")
m_hidden = PlexItem(rating_key="mh", library_id=lib.id, kind="movie", title="Hidden", guid="tmdb:2")
m_null = PlexItem(
rating_key="mn", library_id=lib.id, kind="movie", title="Null Status", guid="tmdb:1"
)
m_hidden = PlexItem(
rating_key="mh", library_id=lib.id, kind="movie", title="Hidden", guid="tmdb:2"
)
db.add_all([m_null, m_hidden])
db.flush()
user = User(email="cw-null@example.com")
db.add(user)
db.flush()
db.add_all([
PlexState(user_id=user.id, item_id=m_null.id, status=None, position_seconds=500,
progress_updated_at=NOW),
PlexState(user_id=user.id, item_id=m_hidden.id, status="hidden", position_seconds=500,
progress_updated_at=NOW - timedelta(hours=1)),
])
db.add_all(
[
PlexState(
user_id=user.id,
item_id=m_null.id,
status=None,
position_seconds=500,
progress_updated_at=NOW,
),
PlexState(
user_id=user.id,
item_id=m_hidden.id,
status="hidden",
position_seconds=500,
progress_updated_at=NOW - timedelta(hours=1),
),
]
)
db.commit()
ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()["continue_watching"])
ids = _ids(
_get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()["continue_watching"]
)
assert "mn" in ids # NULL-status started movie IS in progress
assert "mh" not in ids # hidden is excluded
@@ -160,18 +222,34 @@ def test_continue_watching_excludes_fully_watched_show(db, monkeypatch):
sh = PlexShow(rating_key="shw", library_id=lib_s.id, title="All Watched", child_count=1)
db.add(sh)
db.flush()
ep = PlexItem(rating_key="shwe1", library_id=lib_s.id, kind="episode", title="E1",
show_id=sh.id, season_number=1, episode_number=1)
ep = PlexItem(
rating_key="shwe1",
library_id=lib_s.id,
kind="episode",
title="E1",
show_id=sh.id,
season_number=1,
episode_number=1,
)
db.add(ep)
db.flush()
user = User(email="fullywatched@example.com")
db.add(user)
db.flush()
db.add(PlexState(user_id=user.id, item_id=ep.id, status="watched", position_seconds=0,
progress_updated_at=NOW))
db.add(
PlexState(
user_id=user.id,
item_id=ep.id,
status="watched",
position_seconds=0,
progress_updated_at=NOW,
)
)
db.commit()
ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=show").json()["continue_watching"])
ids = _ids(
_get(db, user, monkeypatch, "/api/plex/discover?scope=show").json()["continue_watching"]
)
assert "shw" not in ids # fully watched → excluded from continue-watching
@@ -209,9 +287,7 @@ def test_recently_watched_movies_and_episodes(db, monkeypatch):
# The rail lists last-fully-watched titles newest-first: a watched MOVIE as its own card, a show as
# the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode.
user = _seed(db)
ep2 = db.execute(
select(PlexItem).where(PlexItem.rating_key == "sh1e2")
).scalars().one()
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e2")).scalars().one()
db.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW))
db.commit()
rw = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()["recently_watched"]
@@ -260,8 +336,8 @@ def test_filters_narrow_every_discover_rail(db, monkeypatch):
def test_filters_narrow_surprise(db, monkeypatch):
user = _seed(db)
items = _get(
db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy"
).json()["items"]
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy").json()[
"items"
]
# Only the one Comedy movie qualifies under the filter.
assert _ids(items) == ["m2"]
+9 -6
View File
@@ -5,6 +5,7 @@ library whose show has a JSONB-scalar `null` genres (a pre-0.53.1 row). The endp
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from fastapi.testclient import TestClient
from sqlalchemy import text
@@ -20,12 +21,14 @@ def test_facets_returns_studio_vocabulary(db, monkeypatch):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all([
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A", studio="Pixar"),
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="B", studio="Pixar"),
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="C", studio="A24"),
PlexItem(rating_key="m4", library_id=lib.id, kind="movie", title="D"), # no studio
])
db.add_all(
[
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A", studio="Pixar"),
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="B", studio="Pixar"),
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="C", studio="A24"),
PlexItem(rating_key="m4", library_id=lib.id, kind="movie", title="D"), # no studio
]
)
user = User(email="studiofacets@example.com")
db.add(user)
db.commit()
+91 -23
View File
@@ -11,6 +11,7 @@ That locks the two duplicated branch-sets together BEFORE they are merged into o
the merge is provably behaviour-equivalent (and a drift between grid and facets fails here). DB lane
(`db` fixture) — skipped without Postgres.
"""
from collections import Counter
from datetime import datetime, timezone
@@ -41,22 +42,78 @@ def _seed(db):
db.flush()
# Movies — new / in-progress / watched / hidden, with overlapping genres.
m_new = PlexItem(rating_key="m_new", library_id=movie_lib.id, kind="movie", title="New Movie",
year=2001, added_at=_dt(1), genres=["Drama"], content_rating="PG")
m_prog = PlexItem(rating_key="m_prog", library_id=movie_lib.id, kind="movie", title="Prog Movie",
year=2002, added_at=_dt(2), genres=["Drama", "Comedy"], content_rating="PG-13")
m_watch = PlexItem(rating_key="m_watch", library_id=movie_lib.id, kind="movie", title="Watched Movie",
year=2003, added_at=_dt(3), genres=["Comedy"], content_rating="R")
m_hidden = PlexItem(rating_key="m_hidden", library_id=movie_lib.id, kind="movie", title="Hidden Movie",
year=2004, added_at=_dt(4), genres=["Drama"], content_rating="R")
m_new = PlexItem(
rating_key="m_new",
library_id=movie_lib.id,
kind="movie",
title="New Movie",
year=2001,
added_at=_dt(1),
genres=["Drama"],
content_rating="PG",
)
m_prog = PlexItem(
rating_key="m_prog",
library_id=movie_lib.id,
kind="movie",
title="Prog Movie",
year=2002,
added_at=_dt(2),
genres=["Drama", "Comedy"],
content_rating="PG-13",
)
m_watch = PlexItem(
rating_key="m_watch",
library_id=movie_lib.id,
kind="movie",
title="Watched Movie",
year=2003,
added_at=_dt(3),
genres=["Comedy"],
content_rating="R",
)
m_hidden = PlexItem(
rating_key="m_hidden",
library_id=movie_lib.id,
kind="movie",
title="Hidden Movie",
year=2004,
added_at=_dt(4),
genres=["Drama"],
content_rating="R",
)
# Shows — new / in-progress / watched (state comes from the episodes below).
sh_new = PlexShow(rating_key="sh_new", library_id=show_lib.id, title="New Show", year=2005,
added_at=_dt(5), child_count=1, genres=["Sci-Fi"], content_rating="TV-14")
sh_prog = PlexShow(rating_key="sh_prog", library_id=show_lib.id, title="Prog Show", year=2006,
added_at=_dt(6), child_count=1, genres=["Sci-Fi", "Drama"], content_rating="TV-MA")
sh_watch = PlexShow(rating_key="sh_watch", library_id=show_lib.id, title="Watched Show", year=2007,
added_at=_dt(7), child_count=1, genres=["Drama"], content_rating="TV-PG")
sh_new = PlexShow(
rating_key="sh_new",
library_id=show_lib.id,
title="New Show",
year=2005,
added_at=_dt(5),
child_count=1,
genres=["Sci-Fi"],
content_rating="TV-14",
)
sh_prog = PlexShow(
rating_key="sh_prog",
library_id=show_lib.id,
title="Prog Show",
year=2006,
added_at=_dt(6),
child_count=1,
genres=["Sci-Fi", "Drama"],
content_rating="TV-MA",
)
sh_watch = PlexShow(
rating_key="sh_watch",
library_id=show_lib.id,
title="Watched Show",
year=2007,
added_at=_dt(7),
child_count=1,
genres=["Drama"],
content_rating="TV-PG",
)
db.add_all([m_new, m_prog, m_watch, m_hidden, sh_new, sh_prog, sh_watch])
db.flush()
@@ -64,8 +121,17 @@ def _seed(db):
se = PlexSeason(rating_key=f"se_{rk}", show_id=show.id, season_number=1)
db.add(se)
db.flush()
ep = PlexItem(rating_key=rk, library_id=show_lib.id, kind="episode", title=f"Ep {rk}",
show_id=show.id, season_id=se.id, season_number=1, episode_number=n, added_at=_dt(day))
ep = PlexItem(
rating_key=rk,
library_id=show_lib.id,
kind="episode",
title=f"Ep {rk}",
show_id=show.id,
season_id=se.id,
season_number=1,
episode_number=n,
added_at=_dt(day),
)
db.add(ep)
db.flush()
return ep
@@ -78,13 +144,15 @@ def _seed(db):
user = User(email="plexparity@example.com")
db.add(user)
db.flush()
db.add_all([
PlexState(user_id=user.id, item_id=m_prog.id, position_seconds=120),
PlexState(user_id=user.id, item_id=m_watch.id, status="watched"),
PlexState(user_id=user.id, item_id=m_hidden.id, status="hidden"),
PlexState(user_id=user.id, item_id=e_prog.id, position_seconds=90),
PlexState(user_id=user.id, item_id=e_watch.id, status="watched"),
])
db.add_all(
[
PlexState(user_id=user.id, item_id=m_prog.id, position_seconds=120),
PlexState(user_id=user.id, item_id=m_watch.id, status="watched"),
PlexState(user_id=user.id, item_id=m_hidden.id, status="hidden"),
PlexState(user_id=user.id, item_id=e_prog.id, position_seconds=90),
PlexState(user_id=user.id, item_id=e_watch.id, status="watched"),
]
)
db.commit()
# rating_key -> (table, genres, content_rating) so the test can total the grid's visible set.
meta = {}
+52 -15
View File
@@ -7,6 +7,7 @@ Everywhere, that `all` is unchanged from the pre-scope behaviour, and that the E
the movie/show grid and only applies where shows are in scope. DB lane (`db` fixture) — skipped
without Postgres (the `search_vector` GIN + per-field `to_tsvector` recheck are real Postgres FTS).
"""
from datetime import datetime, timezone
import app.state as state
@@ -36,26 +37,62 @@ def _seed(db):
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([movie_lib, show_lib])
db.flush()
mt = PlexItem(rating_key="mt", library_id=movie_lib.id, kind="movie",
title="Zephyr Rising", people_text="Alice Stone", summary="A calm coastal day.",
year=2001, added_at=_dt(1))
mp = PlexItem(rating_key="mp", library_id=movie_lib.id, kind="movie",
title="Quiet Harbor", people_text="Zephyr Vance", summary="A calm coastal day.",
year=2002, added_at=_dt(2))
ms = PlexItem(rating_key="ms", library_id=movie_lib.id, kind="movie",
title="Quiet Harbor Two", people_text="Alice Stone",
summary="A story about zephyr winds.", year=2003, added_at=_dt(3))
show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Nimbus Show",
summary="Weather anthology.", people_text="Bob Cloud",
year=2004, added_at=_dt(4), child_count=1)
mt = PlexItem(
rating_key="mt",
library_id=movie_lib.id,
kind="movie",
title="Zephyr Rising",
people_text="Alice Stone",
summary="A calm coastal day.",
year=2001,
added_at=_dt(1),
)
mp = PlexItem(
rating_key="mp",
library_id=movie_lib.id,
kind="movie",
title="Quiet Harbor",
people_text="Zephyr Vance",
summary="A calm coastal day.",
year=2002,
added_at=_dt(2),
)
ms = PlexItem(
rating_key="ms",
library_id=movie_lib.id,
kind="movie",
title="Quiet Harbor Two",
people_text="Alice Stone",
summary="A story about zephyr winds.",
year=2003,
added_at=_dt(3),
)
show = PlexShow(
rating_key="s1",
library_id=show_lib.id,
title="Nimbus Show",
summary="Weather anthology.",
people_text="Bob Cloud",
year=2004,
added_at=_dt(4),
child_count=1,
)
db.add_all([mt, mp, ms, show])
db.flush()
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
db.add(season)
db.flush()
ep = PlexItem(rating_key="ep", library_id=show_lib.id, kind="episode", title="Zephyr Episode",
show_id=show.id, season_id=season.id, season_number=1, episode_number=1,
added_at=_dt(5))
ep = PlexItem(
rating_key="ep",
library_id=show_lib.id,
kind="episode",
title="Zephyr Episode",
show_id=show.id,
season_id=season.id,
season_number=1,
episode_number=1,
added_at=_dt(5),
)
db.add(ep)
user = User(email="plexfield@example.com")
db.add(user)
+15 -4
View File
@@ -4,6 +4,7 @@ UPDATE statements against seeded rows (the migration itself is a no-op in the em
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from sqlalchemy import text
from app.models import PlexLibrary, PlexShow
@@ -30,15 +31,25 @@ def test_scalar_null_normalized_array_preserved(db):
db.commit()
# Set a pre-0.53.1 JSONB scalar null on one, a genuine array on the other.
db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 'null_row'"))
db.execute(text("""UPDATE plex_shows SET genres = '["Drama"]'::jsonb WHERE rating_key = 'array_row'"""))
db.execute(
text("""UPDATE plex_shows SET genres = '["Drama"]'::jsonb WHERE rating_key = 'array_row'""")
)
db.commit()
assert db.scalar(text("SELECT jsonb_typeof(genres) FROM plex_shows WHERE rating_key='null_row'")) == "null"
assert (
db.scalar(text("SELECT jsonb_typeof(genres) FROM plex_shows WHERE rating_key='null_row'"))
== "null"
)
for stmt in _NORMALIZE:
db.execute(text(stmt))
db.commit()
# The scalar null is now SQL NULL (jsonb_typeof of SQL NULL is itself NULL, not 'null')...
assert db.scalar(text("SELECT genres IS NULL FROM plex_shows WHERE rating_key='null_row'")) is True
assert (
db.scalar(text("SELECT genres IS NULL FROM plex_shows WHERE rating_key='null_row'")) is True
)
# ...and a real array is untouched.
assert db.scalar(text("SELECT jsonb_typeof(genres) FROM plex_shows WHERE rating_key='array_row'")) == "array"
assert (
db.scalar(text("SELECT jsonb_typeof(genres) FROM plex_shows WHERE rating_key='array_row'"))
== "array"
)
+20 -7
View File
@@ -2,6 +2,7 @@
narrows the unified library to rows whose persisted `keywords` JSONB array contains it. DB lane
(`db` fixture) — skipped without Postgres.
"""
from fastapi.testclient import TestClient
import app.state as state
@@ -15,13 +16,25 @@ def _seed(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all([
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Heist Movie",
keywords=["heist", "bank robbery"]),
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Space Movie",
keywords=["space", "aliens"]),
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="No Keywords Movie"),
])
db.add_all(
[
PlexItem(
rating_key="m1",
library_id=lib.id,
kind="movie",
title="Heist Movie",
keywords=["heist", "bank robbery"],
),
PlexItem(
rating_key="m2",
library_id=lib.id,
kind="movie",
title="Space Movie",
keywords=["space", "aliens"],
),
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="No Keywords Movie"),
]
)
user = User(email="kwfilter@example.com")
db.add(user)
db.commit()
+40 -17
View File
@@ -3,6 +3,7 @@ detail endpoints serve the Hungarian `*_hu` columns (falling back to EN where a
?lang=en (the default) serves English. Also covers the imdb_rating IMDb-only / audience-score split.
DB lane (`db` fixture) — skipped without Postgres.
"""
from datetime import datetime, timezone
from fastapi.testclient import TestClient
@@ -21,18 +22,31 @@ def _seed(db):
db.add(lib)
db.flush()
m1 = PlexItem(
rating_key="m1", library_id=lib.id, kind="movie",
title="English Title", title_hu="Magyar Cím",
summary="English summary.", summary_hu="Magyar összefoglaló.",
genres=["Drama"], genres_hu=["Dráma"],
rating=6.5, imdb_rating=None,
year=2001, added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), # serve from row, skip live enrich
rating_key="m1",
library_id=lib.id,
kind="movie",
title="English Title",
title_hu="Magyar Cím",
summary="English summary.",
summary_hu="Magyar összefoglaló.",
genres=["Drama"],
genres_hu=["Dráma"],
rating=6.5,
imdb_rating=None,
year=2001,
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
people_enriched_at=datetime(
2024, 1, 1, tzinfo=timezone.utc
), # serve from row, skip live enrich
)
m2 = PlexItem(
rating_key="m2", library_id=lib.id, kind="movie",
title="EN Only", genres=["Comedy"], # no HU twin
year=2002, added_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
rating_key="m2",
library_id=lib.id,
kind="movie",
title="EN Only",
genres=["Comedy"], # no HU twin
year=2002,
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
people_enriched_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
)
db.add_all([m1, m2])
@@ -43,7 +57,9 @@ def _seed(db):
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app
monkeypatch.setattr(
state, "_configured_cache", True
) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
@@ -110,12 +126,19 @@ def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch):
db.flush()
# imdb 8.0 but audience 4.0 → a rating_min of 7 must MATCH (filter the displayed best score,
# IMDb preferred), where filtering the raw audience column (4.0) would wrongly exclude it.
db.add(PlexItem(
rating_key="r1", library_id=lib.id, kind="movie", title="High IMDb",
imdb_rating=8.0, rating=4.0, year=2001,
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
))
db.add(
PlexItem(
rating_key="r1",
library_id=lib.id,
kind="movie",
title="High IMDb",
imdb_rating=8.0,
rating=4.0,
year=2001,
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
)
user = User(email="rating@example.com")
db.add(user)
db.commit()
+30 -7
View File
@@ -5,6 +5,7 @@ browse scope. Also covers the canonical-guid parse and that the generated search
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. Runs against the real schema
(migrations 0063/0064) so the JSONB + STORED generated columns are exercised.
"""
from sqlalchemy import func, select
import app.plex.sync as syncmod
@@ -25,7 +26,12 @@ class _StubPlex:
def _hu_movie(rk: str, title: str, summary: str, genres: list[str], guid: str | None) -> dict:
meta = {"ratingKey": rk, "title": title, "summary": summary, "Genre": [{"tag": g} for g in genres]}
meta = {
"ratingKey": rk,
"title": title,
"summary": summary,
"Genre": [{"tag": g} for g in genres],
}
if guid:
meta["Guid"] = [{"id": guid}]
return meta
@@ -68,7 +74,9 @@ def test_sync_overlay_writes_hu_onto_matched_en_rows(db):
assert matched.title_hu == "Öldöklés"
assert matched.summary_hu == "Egy rablás terve."
assert matched.genres_hu == ["Akció", "Krimi"]
assert matched.search_vector_hu is not None # the STORED generated column populated from title_hu
assert (
matched.search_vector_hu is not None
) # the STORED generated column populated from title_hu
# The unmatched EN row keeps NULL HU fields (serving falls back to EN).
assert unmatched.title_hu is None
assert stats["overlaid"] == 1
@@ -110,8 +118,11 @@ class _StubClient:
elif key == "9":
items = [
{
"ratingKey": "hu1", "title": "Öldöklés", "summary": "Rablás.",
"Genre": [{"tag": "Akció"}], "Guid": [{"id": "tmdb://1"}],
"ratingKey": "hu1",
"title": "Öldöklés",
"summary": "Rablás.",
"Genre": [{"tag": "Akció"}],
"Guid": [{"id": "tmdb://1"}],
}
]
else:
@@ -158,8 +169,14 @@ def test_overlay_reconcile_clears_stale_hu_when_twin_vanishes(db):
db.flush()
hu_lib.overlay_of = en_lib.id
row = PlexItem(
rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat",
guid="tmdb:1", title_hu="Öldöklés", summary_hu="régi", genres_hu=["Akció"],
rating_key="m1",
library_id=en_lib.id,
kind="movie",
title="Heat",
guid="tmdb:1",
title_hu="Öldöklés",
summary_hu="régi",
genres_hu=["Akció"],
)
db.add(row)
db.commit()
@@ -185,7 +202,13 @@ def test_overlay_applies_to_all_editions_sharing_a_guid(db):
db.flush()
hu_lib.overlay_of = en_lib.id
ed1 = PlexItem(rating_key="a", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1")
ed2 = PlexItem(rating_key="b", library_id=en_lib.id, kind="movie", title="Heat (Director's Cut)", guid="tmdb:1")
ed2 = PlexItem(
rating_key="b",
library_id=en_lib.id,
kind="movie",
title="Heat (Director's Cut)",
guid="tmdb:1",
)
db.add_all([ed1, ed2])
db.commit()
+42 -14
View File
@@ -5,6 +5,7 @@ scoping (disabled libraries excluded) and the kind filter (episodes are not movi
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from fastapi.testclient import TestClient
import app.state as state
@@ -42,23 +43,40 @@ def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
db.flush()
heat = PlexItem(
rating_key="m1", library_id=movies_lib.id, kind="movie", title="Heat",
year=1995, cast_names=[NAME, "Al Pacino"],
rating_key="m1",
library_id=movies_lib.id,
kind="movie",
title="Heat",
year=1995,
cast_names=[NAME, "Al Pacino"],
)
# Director-only credit: the name is NOT in cast_names, only directors → the OR must still catch it.
directed = PlexItem(
rating_key="m2", library_id=movies_lib.id, kind="movie", title="A Bronx Tale",
year=1993, cast_names=["Chazz Palminteri"], directors=[NAME],
rating_key="m2",
library_id=movies_lib.id,
kind="movie",
title="A Bronx Tale",
year=1993,
cast_names=["Chazz Palminteri"],
directors=[NAME],
)
# Excluded: a movie in a DISABLED library.
hidden = PlexItem(
rating_key="m3", library_id=off_lib.id, kind="movie", title="Hidden",
year=2000, cast_names=[NAME],
rating_key="m3",
library_id=off_lib.id,
kind="movie",
title="Hidden",
year=2000,
cast_names=[NAME],
)
# Excluded: an episode (kind != "movie") that happens to list the name.
episode = PlexItem(
rating_key="e1", library_id=movies_lib.id, kind="episode", title="An Episode",
year=2001, cast_names=[NAME],
rating_key="e1",
library_id=movies_lib.id,
kind="episode",
title="An Episode",
year=2001,
cast_names=[NAME],
)
show = PlexShow(rating_key="s1", library_id=shows_lib.id, title="A Show", cast_names=[NAME])
db.add_all([heat, directed, hidden, episode, show])
@@ -92,7 +110,15 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Solo", cast_names=["Nobody Else"]))
db.add(
PlexItem(
rating_key="m1",
library_id=lib.id,
kind="movie",
title="Solo",
cast_names=["Nobody Else"],
)
)
user = User(email="empty@example.com")
db.add(user)
db.commit()
@@ -117,11 +143,13 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch):
def test_content_libs_partitions_only_enabled(db):
db.add_all([
PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True),
PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True),
PlexLibrary(plex_key="3", title="OffMovie", kind="movie", enabled=False),
])
db.add_all(
[
PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True),
PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True),
PlexLibrary(plex_key="3", title="OffMovie", kind="movie", enabled=False),
]
)
db.commit()
movie_ids, show_ids = _content_libs(db)
assert len(movie_ids) == 1 and len(show_ids) == 1 # the disabled movie lib is excluded
+63 -18
View File
@@ -3,6 +3,7 @@ popularity-ranked, deduped `known_for`); `enrich_person` lazily fetches + persis
key + a tmdb_person_id); and /person/{name} serves the bio + resolves `known_for` to library rows.
DB lane for the endpoint/enrich tests; parse_person is pure. Skipped without Postgres where DB-backed.
"""
from fastapi.testclient import TestClient
import app.state as state
@@ -55,12 +56,20 @@ def test_parse_person_keeps_same_id_across_media_types():
"cast": [
{"id": 42, "media_type": "movie", "title": "Movie 42", "popularity": 90},
{"id": 42, "media_type": "tv", "name": "Show 42", "popularity": 80},
{"id": 42, "media_type": "movie", "title": "dup movie 42", "popularity": 5}, # real dup
{
"id": 42,
"media_type": "movie",
"title": "dup movie 42",
"popularity": 5,
}, # real dup
]
}
}
kf = plex_tmdb.parse_person(payload)["known_for"]
assert [(k["tmdb_id"], k["media_type"]) for k in kf] == [(42, "movie"), (42, "tv")] # both kept, dup dropped
assert [(k["tmdb_id"], k["media_type"]) for k in kf] == [
(42, "movie"),
(42, "tv"),
] # both kept, dup dropped
class _FakeTmdb:
@@ -81,7 +90,9 @@ def test_enrich_person_persists_and_stamps(db, monkeypatch):
person = PlexPerson(name="Actor X", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
monkeypatch.setattr(
plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else ""
)
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdb(PAYLOAD))
assert plex_tmdb.enrich_person(db, person) is True
assert person.biography == "A celebrated actor."
@@ -114,8 +125,12 @@ def test_enrich_person_transient_error_leaves_watermark_null(db, monkeypatch):
person = PlexPerson(name="Transient P", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(raises=True))
monkeypatch.setattr(
plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else ""
)
monkeypatch.setattr(
plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(raises=True)
)
assert plex_tmdb.enrich_person(db, person) is False
assert person.tmdb_enriched_at is None # left NULL → retried next open
@@ -125,8 +140,12 @@ def test_enrich_person_404_stamps_watermark_without_bio(db, monkeypatch):
person = PlexPerson(name="Gone P", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(payload=None))
monkeypatch.setattr(
plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else ""
)
monkeypatch.setattr(
plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(payload=None)
)
assert plex_tmdb.enrich_person(db, person) is True
assert person.tmdb_enriched_at is not None # stamped
assert person.biography is None # nothing fetched
@@ -206,7 +225,9 @@ def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
# Success path: person found → enrich called, session closed, no rollback.
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(object()))
monkeypatch.setattr(
people.plex_tmdb, "enrich_person", lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1)
people.plex_tmdb,
"enrich_person",
lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1),
)
people._enrich_person_bg(5)
assert calls == {"rollback": 0, "close": 1, "enriched": 1}
@@ -248,8 +269,14 @@ def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch):
lib = PlexLibrary(plex_key="8", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexPerson(name="Done P", tmdb_person_id=888,
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), biography="Bio."))
db.add(
PlexPerson(
name="Done P",
tmdb_person_id=888,
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
biography="Bio.",
)
)
user = User(email="donebg@example.com")
db.add(user)
db.commit()
@@ -268,16 +295,34 @@ def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch):
db.add(lib)
db.flush()
# A library movie that IS one of the person's known_for credits (matched by guid tmdb:100).
db.add(PlexItem(rating_key="km", library_id=lib.id, kind="movie", title="Known Movie",
guid="tmdb:100", cast_names=["Actor X"]))
db.add(
PlexItem(
rating_key="km",
library_id=lib.id,
kind="movie",
title="Known Movie",
guid="tmdb:100",
cast_names=["Actor X"],
)
)
# Already-enriched person (tmdb_enriched_at set → no lazy fetch), with a known_for pool.
from datetime import datetime, timezone
db.add(PlexPerson(name="Actor X", tmdb_person_id=555,
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
biography="Bio here.", birthday="1970-01-01", known_for_department="Acting",
also_known_as=["Alt Name"],
known_for=[{"tmdb_id": 100, "media_type": "movie", "title": "Known Movie"},
{"tmdb_id": 999, "media_type": "movie", "title": "Not In Library"}]))
db.add(
PlexPerson(
name="Actor X",
tmdb_person_id=555,
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
biography="Bio here.",
birthday="1970-01-01",
known_for_department="Acting",
also_known_as=["Alt Name"],
known_for=[
{"tmdb_id": 100, "media_type": "movie", "title": "Known Movie"},
{"tmdb_id": 999, "media_type": "movie", "title": "Not In Library"},
],
)
)
user = User(email="personbio@example.com")
db.add(user)
db.commit()
+176 -51
View File
@@ -6,6 +6,7 @@ aggregate, and the sync upsert idempotency — so the mechanical 1.x -> 2.0 conv
behaviour-equivalent: the exact same assertions must pass before and after the conversion. DB lane
(`db` fixture) — skipped without Postgres.
"""
from datetime import datetime, timezone
from fastapi.testclient import TestClient
@@ -30,35 +31,75 @@ def _seed(db):
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([movie_lib, show_lib])
db.flush()
m1 = PlexItem(rating_key="m1", library_id=movie_lib.id, kind="movie",
title="Alpha Movie", year=2001, added_at=_dt(1))
m2 = PlexItem(rating_key="m2", library_id=movie_lib.id, kind="movie",
title="Bravo Movie", year=2002, added_at=_dt(2))
show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Charlie Show",
year=2003, added_at=_dt(3), child_count=1)
m1 = PlexItem(
rating_key="m1",
library_id=movie_lib.id,
kind="movie",
title="Alpha Movie",
year=2001,
added_at=_dt(1),
)
m2 = PlexItem(
rating_key="m2",
library_id=movie_lib.id,
kind="movie",
title="Bravo Movie",
year=2002,
added_at=_dt(2),
)
show = PlexShow(
rating_key="s1",
library_id=show_lib.id,
title="Charlie Show",
year=2003,
added_at=_dt(3),
child_count=1,
)
db.add_all([m1, m2, show])
db.flush()
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
db.add(season)
db.flush()
e1 = PlexItem(rating_key="e1", library_id=show_lib.id, kind="episode", title="Charlie Pilot",
show_id=show.id, season_id=season.id, season_number=1, episode_number=1, added_at=_dt(4))
e2 = PlexItem(rating_key="e2", library_id=show_lib.id, kind="episode", title="Charlie Finale",
show_id=show.id, season_id=season.id, season_number=1, episode_number=2, added_at=_dt(5))
e1 = PlexItem(
rating_key="e1",
library_id=show_lib.id,
kind="episode",
title="Charlie Pilot",
show_id=show.id,
season_id=season.id,
season_number=1,
episode_number=1,
added_at=_dt(4),
)
e2 = PlexItem(
rating_key="e2",
library_id=show_lib.id,
kind="episode",
title="Charlie Finale",
show_id=show.id,
season_id=season.id,
season_number=1,
episode_number=2,
added_at=_dt(5),
)
db.add_all([e1, e2])
user = User(email="plexbrowse@example.com")
db.add(user)
db.flush()
db.add_all([
PlexState(user_id=user.id, item_id=m1.id, status="watched"),
PlexState(user_id=user.id, item_id=e1.id, position_seconds=120),
])
db.add_all(
[
PlexState(user_id=user.id, item_id=m1.id, status="watched"),
PlexState(user_id=user.id, item_id=e1.id, position_seconds=120),
]
)
db.commit()
return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show}
def _request(db, user, monkeypatch, method, path, json=None):
monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app
monkeypatch.setattr(
state, "_configured_cache", True
) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
@@ -124,7 +165,10 @@ class TestUnifiedLibrary:
s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?limit=2&offset=0").json()
assert body["total"] == 3
assert [i["id"] for i in body["items"]] == ["s1", "m2"] # first page of the added-desc order
assert [i["id"] for i in body["items"]] == [
"s1",
"m2",
] # first page of the added-desc order
class TestRollup:
@@ -153,6 +197,7 @@ def _fake_paginate(by_type):
if progress is not None:
progress["complete"] = True # the fake returns the whole list — a complete listing
return list(by_type.get(typ, []))
return _p
@@ -172,9 +217,13 @@ class TestSyncUpsertIdempotency:
metas[0]["title"] = "First Renamed"
sync._sync_movies(db, object(), lib, {"movies": 0})
db.commit()
rows = db.execute(
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
).scalars().all()
rows = (
db.execute(
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
)
.scalars()
.all()
)
assert {r.rating_key for r in rows} == {"100", "101"} # no duplicate rows
assert next(r for r in rows if r.rating_key == "100").title == "First Renamed"
@@ -190,8 +239,15 @@ class TestSyncUpsertIdempotency:
{"ratingKey": "se1", "parentRatingKey": "s1", "title": "Season 1", "index": 1}
],
sync._TYPE_EPISODE: [
{"ratingKey": "e1", "title": "Ep One", "grandparentRatingKey": "s1",
"parentRatingKey": "se1", "parentIndex": 1, "index": 1, "addedAt": 1700000001}
{
"ratingKey": "e1",
"title": "Ep One",
"grandparentRatingKey": "s1",
"parentRatingKey": "se1",
"parentIndex": 1,
"index": 1,
"addedAt": 1700000001,
}
],
}
monkeypatch.setattr(sync, "_paginate", _fake_paginate(by_type))
@@ -216,15 +272,41 @@ class TestFacetsWithData:
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([movie_lib, show_lib])
db.flush()
db.add_all([
PlexItem(rating_key="m1", library_id=movie_lib.id, kind="movie", title="M1",
genres=["Comedy", "Drama"], content_rating="PG", year=2000, rating=7.5,
duration_s=6000),
PlexItem(rating_key="m2", library_id=movie_lib.id, kind="movie", title="M2",
genres=["Comedy"], content_rating="R", year=2010, rating=8.0, duration_s=7200),
PlexShow(rating_key="s1", library_id=show_lib.id, title="S1",
genres=["Drama"], content_rating="TV-14", year=2005, rating=9.0),
])
db.add_all(
[
PlexItem(
rating_key="m1",
library_id=movie_lib.id,
kind="movie",
title="M1",
genres=["Comedy", "Drama"],
content_rating="PG",
year=2000,
rating=7.5,
duration_s=6000,
),
PlexItem(
rating_key="m2",
library_id=movie_lib.id,
kind="movie",
title="M2",
genres=["Comedy"],
content_rating="R",
year=2010,
rating=8.0,
duration_s=7200,
),
PlexShow(
rating_key="s1",
library_id=show_lib.id,
title="S1",
genres=["Drama"],
content_rating="TV-14",
year=2005,
rating=9.0,
),
]
)
user = User(email="facetdata@example.com")
db.add(user)
db.commit()
@@ -232,7 +314,9 @@ class TestFacetsWithData:
body = _get(db, user, monkeypatch, "/api/plex/facets?scope=both").json()
assert {g["value"]: g["count"] for g in body["genres"]} == {"Comedy": 2, "Drama": 2}
assert {c["value"]: c["count"] for c in body["content_ratings"]} == {
"PG": 1, "R": 1, "TV-14": 1
"PG": 1,
"R": 1,
"TV-14": 1,
}
assert (body["year_min"], body["year_max"]) == (2000, 2010)
assert body["rating_max"] == 9.0
@@ -252,14 +336,22 @@ class TestPlaylistAndCollectionEndpoints:
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all([self._movie(db, lib.id, "m1", "First"), self._movie(db, lib.id, "m2", "Second")])
db.add_all(
[self._movie(db, lib.id, "m1", "First"), self._movie(db, lib.id, "m2", "Second")]
)
user = User(email="pl@example.com")
db.add(user)
db.commit()
# create (seeded with m1) -> item_count 1
created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists",
{"title": "Faves", "item_rating_key": "m1"})
created = _request(
db,
user,
monkeypatch,
"POST",
"/api/plex/playlists",
{"title": "Faves", "item_rating_key": "m1"},
)
assert created.status_code == 200, created.text
pid = created.json()["id"]
assert created.json()["item_count"] == 1
@@ -269,11 +361,23 @@ class TestPlaylistAndCollectionEndpoints:
assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)]
# add m2, then reorder to [m2, m1]
add = _request(db, user, monkeypatch, "POST", f"/api/plex/playlists/{pid}/items",
{"item_rating_key": "m2"})
add = _request(
db,
user,
monkeypatch,
"POST",
f"/api/plex/playlists/{pid}/items",
{"item_rating_key": "m2"},
)
assert add.status_code == 200, add.text
_request(db, user, monkeypatch, "PUT", f"/api/plex/playlists/{pid}/order",
{"item_rating_keys": ["m2", "m1"]})
_request(
db,
user,
monkeypatch,
"PUT",
f"/api/plex/playlists/{pid}/order",
{"item_rating_keys": ["m2", "m1"]},
)
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json()
assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order
@@ -301,29 +405,46 @@ class TestPlaylistAndCollectionEndpoints:
created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists", {"title": "Bulk"})
pid = created.json()["id"]
added = _request(db, user, monkeypatch, "POST", f"/api/plex/playlists/{pid}/items/bulk",
{"item_rating_keys": ["m1", "m2", "m3"]})
added = _request(
db,
user,
monkeypatch,
"POST",
f"/api/plex/playlists/{pid}/items/bulk",
{"item_rating_keys": ["m1", "m2", "m3"]},
)
assert added.status_code == 200, added.text
assert added.json() == {"added": 3, "item_count": 3}
removed = _request(db, user, monkeypatch, "POST",
f"/api/plex/playlists/{pid}/items/remove-bulk",
{"item_rating_keys": ["m2"]})
removed = _request(
db,
user,
monkeypatch,
"POST",
f"/api/plex/playlists/{pid}/items/remove-bulk",
{"item_rating_keys": ["m2"]},
)
assert removed.status_code == 200, removed.text
assert removed.json() == {"removed": 1, "item_count": 2}
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json()
assert [it["id"] for it in detail["items"]] == ["m1", "m3"] # input order preserved, m2 gone
assert [it["id"] for it in detail["items"]] == [
"m1",
"m3",
] # input order preserved, m2 gone
def test_collections_list(self, db, monkeypatch):
from app.models import PlexCollection
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all([
PlexCollection(rating_key="c1", library_id=lib.id, title="Marvel", child_count=12),
PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=5),
])
db.add_all(
[
PlexCollection(rating_key="c1", library_id=lib.id, title="Marvel", child_count=12),
PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=5),
]
)
user = User(email="col@example.com")
db.add(user)
db.commit()
@@ -346,8 +467,9 @@ class TestPlaylistAndCollectionEndpoints:
db.add(user)
db.commit()
watched = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state",
{"status": "watched"})
watched = _request(
db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "watched"}
)
assert watched.status_code == 200, watched.text
assert watched.json()["status"] == "watched"
row = db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().one()
@@ -356,4 +478,7 @@ class TestPlaylistAndCollectionEndpoints:
# -> new deletes the state row
back = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "new"})
assert back.status_code == 200, back.text
assert db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first() is None
assert (
db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first()
is None
)
+1
View File
@@ -6,6 +6,7 @@ that keeps the sweep off a neighbour's data, so the names it accepts are pinned
Since the sweep stopped being awaited at startup it also runs CONCURRENTLY with playback, so the
second half of this file pins what keeps it off a LIVE session's segments."""
import os
import pytest
+45 -16
View File
@@ -5,6 +5,7 @@ non-empty listing so a transient Plex glitch (an empty page) can never wipe a wh
DB lane (`db` fixture) — skipped without Postgres. Drives `_sync_movies` / `_sync_shows` directly
with a stub Plex (the same seam the overlay test uses), so no live server is needed.
"""
from sqlalchemy import select
from app.models import (
@@ -63,10 +64,12 @@ def _seed_movies(db):
db.add(user)
db.flush()
# A watch-state on EACH movie: the kept one's must survive, the pruned one's must cascade away.
db.add_all([
PlexState(user_id=user.id, item_id=m1.id, status="watched"),
PlexState(user_id=user.id, item_id=m2.id, status="watched"),
])
db.add_all(
[
PlexState(user_id=user.id, item_id=m1.id, status="watched"),
PlexState(user_id=user.id, item_id=m2.id, status="watched"),
]
)
db.commit()
return lib, m1.id, m2.id, user.id
@@ -132,22 +135,48 @@ def test_show_prune_removes_stale_show_season_and_episode(db):
se2 = PlexSeason(rating_key="se2", show_id=s2.id, season_number=1)
db.add_all([se1, se2])
db.flush()
e1 = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="Kept Ep",
show_id=s1.id, season_id=se1.id, season_number=1, episode_number=1)
e2 = PlexItem(rating_key="e2", library_id=lib.id, kind="episode", title="Gone Ep",
show_id=s2.id, season_id=se2.id, season_number=1, episode_number=1)
e1 = PlexItem(
rating_key="e1",
library_id=lib.id,
kind="episode",
title="Kept Ep",
show_id=s1.id,
season_id=se1.id,
season_number=1,
episode_number=1,
)
e2 = PlexItem(
rating_key="e2",
library_id=lib.id,
kind="episode",
title="Gone Ep",
show_id=s2.id,
season_id=se2.id,
season_number=1,
episode_number=1,
)
db.add_all([e1, e2])
db.commit()
# Plex now lists only s1's subtree.
stub = _StubPlex({
_TYPE_SHOW: [{"ratingKey": "s1", "title": "Kept Show"}],
_TYPE_SEASON: [{"ratingKey": "se1", "parentRatingKey": "s1", "index": 1, "title": "Season 1"}],
_TYPE_EPISODE: [{
"ratingKey": "e1", "grandparentRatingKey": "s1", "parentRatingKey": "se1",
"parentIndex": 1, "index": 1, "title": "Kept Ep",
}],
})
stub = _StubPlex(
{
_TYPE_SHOW: [{"ratingKey": "s1", "title": "Kept Show"}],
_TYPE_SEASON: [
{"ratingKey": "se1", "parentRatingKey": "s1", "index": 1, "title": "Season 1"}
],
_TYPE_EPISODE: [
{
"ratingKey": "e1",
"grandparentRatingKey": "s1",
"parentRatingKey": "se1",
"parentIndex": 1,
"index": 1,
"title": "Kept Ep",
}
],
}
)
stats = {"shows": 0, "seasons": 0, "episodes": 0, "pruned": 0}
_sync_shows(db, stub, lib, stats)
+6 -3
View File
@@ -2,6 +2,7 @@
libraries already mirrored (committed per section), not roll the whole reconcile back. Uses the DB
lane (`db` fixture) with a faked PlexClient — skipped without Postgres.
"""
from sqlalchemy import select
from app.plex import people, sync
@@ -25,9 +26,9 @@ class _FakePlex:
def test_late_section_failure_keeps_earlier_sections(db, monkeypatch):
monkeypatch.setattr(sync.sysconfig, "get_bool", lambda *a, **k: True) # plex_enabled
monkeypatch.setattr(sync, "_enabled_section_keys", lambda db: None) # all sections wanted
monkeypatch.setattr(sync, "_enabled_section_keys", lambda db: None) # all sections wanted
monkeypatch.setattr(sync, "PlexClient", lambda db: _FakePlex())
monkeypatch.setattr(sync, "_sync_movies", lambda *a, **k: None) # skip the real Plex fetch
monkeypatch.setattr(sync, "_sync_movies", lambda *a, **k: None) # skip the real Plex fetch
# Section 1 completes and commits; section 2's collections step raises → its work rolls back.
def collections(db, plex, lib, stats):
@@ -75,7 +76,9 @@ def test_enrich_people_batch_dedupes_shared_cast(db):
fake = _FakeMetaPlex(
{
"m1": {"Role": [{"tag": shared, "role": "Self"}, {"tag": "Alice Unique"}]},
"m2": {"Role": [{"tag": shared, "role": "Self", "thumb": thumb}, {"tag": "Bob Unique"}]},
"m2": {
"Role": [{"tag": shared, "role": "Self", "thumb": thumb}, {"tag": "Bob Unique"}]
},
}
)
+85 -21
View File
@@ -3,6 +3,7 @@ DB-lane batch/serve integration (enrich stamps the watermark + links people; ser
recommendations to library rows; the whole thing no-ops without an API key).
The DB tests use the `db` fixture (real Postgres) — skipped when none is reachable."""
from datetime import datetime, timezone
import httpx
@@ -21,11 +22,28 @@ MOVIE_PAYLOAD = {
"keywords": {"keywords": [{"id": 1, "name": "mafia"}, {"id": 2, "name": "crime family"}]},
"credits": {
"cast": [
{"id": 3084, "name": "Marlon Brando", "character": "Vito", "profile_path": "/a.jpg", "order": 0},
{"id": 1158, "name": "Al Pacino", "character": "Michael", "profile_path": "/b.jpg", "order": 1},
{
"id": 3084,
"name": "Marlon Brando",
"character": "Vito",
"profile_path": "/a.jpg",
"order": 0,
},
{
"id": 1158,
"name": "Al Pacino",
"character": "Michael",
"profile_path": "/b.jpg",
"order": 1,
},
],
"crew": [
{"id": 1776, "name": "Francis Ford Coppola", "job": "Director", "profile_path": "/c.jpg"},
{
"id": 1776,
"name": "Francis Ford Coppola",
"job": "Director",
"profile_path": "/c.jpg",
},
{"id": 1776, "name": "Francis Ford Coppola", "job": "Writer", "profile_path": "/c.jpg"},
{"id": 9, "name": "Some Gaffer", "job": "Gaffer"}, # not a key job → dropped
],
@@ -34,7 +52,12 @@ MOVIE_PAYLOAD = {
"production_countries": [{"name": "United States of America"}],
"recommendations": {
"results": [
{"id": 240, "title": "The Godfather Part II", "poster_path": "/p.jpg", "media_type": "movie"},
{
"id": 240,
"title": "The Godfather Part II",
"poster_path": "/p.jpg",
"media_type": "movie",
},
{"id": 999, "title": "Not In Library", "poster_path": "/q.jpg", "media_type": "movie"},
]
},
@@ -79,6 +102,7 @@ class FakeTmdb:
# ---- pure parse/persist --------------------------------------------------------------------------
def test_parse_movie_pulls_all_fields():
d = tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie")
assert d["tmdb_id"] == 238
@@ -88,8 +112,18 @@ def test_parse_movie_pulls_all_fields():
assert d["revenue"] == 245066411
assert d["keywords"] == ["mafia", "crime family"]
assert d["crew"] == [
{"name": "Francis Ford Coppola", "job": "Director", "tmdb_id": 1776, "profile_path": "/c.jpg"},
{"name": "Francis Ford Coppola", "job": "Writer", "tmdb_id": 1776, "profile_path": "/c.jpg"},
{
"name": "Francis Ford Coppola",
"job": "Director",
"tmdb_id": 1776,
"profile_path": "/c.jpg",
},
{
"name": "Francis Ford Coppola",
"job": "Writer",
"tmdb_id": 1776,
"profile_path": "/c.jpg",
},
] # the Gaffer is filtered; the two Coppola jobs survive (dedup is by name+job)
assert d["studios"] == ["Paramount", "Alfran"]
assert d["production_countries"] == ["United States of America"]
@@ -110,21 +144,30 @@ def test_parse_tv_uses_aggregate_credits_and_no_money():
# filtered; TV jobs are read from jobs[0]. Crew is display-priority ordered (Creator before EP).
assert d["crew"] == [
{"name": "Vince Gilligan", "job": "Creator", "tmdb_id": 66633, "profile_path": "/vg.jpg"},
{"name": "Vince Gilligan", "job": "Executive Producer", "tmdb_id": 66633, "profile_path": None},
{
"name": "Vince Gilligan",
"job": "Executive Producer",
"tmdb_id": 66633,
"profile_path": None,
},
]
assert d["tmdb_rating"] == 9.0
assert ("Bryan Cranston", 17419) in {(p["name"], p["tmdb_id"]) for p in d["people"]}
def test_parse_zero_votes_and_money_are_unknown():
d = tmdb.parse_tmdb({"id": 1, "vote_average": 0.0, "vote_count": 0, "budget": 0, "revenue": 0}, "movie")
d = tmdb.parse_tmdb(
{"id": 1, "vote_average": 0.0, "vote_count": 0, "budget": 0, "revenue": 0}, "movie"
)
assert d["tmdb_rating"] is None # 0.0 with no votes is meaningless
assert d["tmdb_vote_count"] is None
assert d["budget"] is None and d["revenue"] is None
def test_apply_writes_row_fields_without_touching_plex_owned():
it = PlexItem(rating_key="m1", kind="movie", title="The Godfather", imdb_rating=8.7, full_cast=[{"x": 1}])
it = PlexItem(
rating_key="m1", kind="movie", title="The Godfather", imdb_rating=8.7, full_cast=[{"x": 1}]
)
tmdb.apply_tmdb_detail(it, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie"))
assert it.tmdb_id == 238
assert it.budget == 6000000
@@ -157,6 +200,7 @@ def test_guid_to_tmdb_id_tvdb_source_and_tv_media_type():
# ---- DB-lane: batch enrich + serve --------------------------------------------------------------
def _lib(db, key="1", kind="movie"):
lib = PlexLibrary(plex_key=key, title="L", kind=kind, enabled=True)
db.add(lib)
@@ -166,12 +210,18 @@ def _lib(db, key="1", kind="movie"):
def test_enrich_batch_persists_stamps_and_links_people(db):
lib = _lib(db)
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238")
unmatched = PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Nope", guid="tmdb:777")
mv = PlexItem(
rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238"
)
unmatched = PlexItem(
rating_key="m2", library_id=lib.id, kind="movie", title="Nope", guid="tmdb:777"
)
db.add_all([mv, unmatched])
db.commit()
fake = FakeTmdb(payloads={("movie", 238): MOVIE_PAYLOAD}) # 777 has no payload → definitive no-match
fake = FakeTmdb(
payloads={("movie", 238): MOVIE_PAYLOAD}
) # 777 has no payload → definitive no-match
n = tmdb.enrich_batch(db, fake, 50)
assert n == 2 # both processed (one matched, one definitively unmatched)
@@ -185,17 +235,23 @@ def test_enrich_batch_persists_stamps_and_links_people(db):
assert unmatched.tmdb_enriched_at is not None # stamped so it isn't re-fetched every run
assert unmatched.tmdb_id is None # but nothing stored
brando = db.execute(
PlexPerson.__table__.select().where(PlexPerson.name == "Marlon Brando")
).mappings().first()
brando = (
db.execute(PlexPerson.__table__.select().where(PlexPerson.name == "Marlon Brando"))
.mappings()
.first()
)
assert brando is not None and brando["tmdb_person_id"] == 3084
def test_serve_block_none_until_enriched_and_resolves_recs(db):
lib = _lib(db)
# A recommendation target that IS in our library (guid tmdb:240) → deep-linkable.
sequel = PlexItem(rating_key="m240", library_id=lib.id, kind="movie", title="Part II", guid="tmdb:240")
fresh = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238")
sequel = PlexItem(
rating_key="m240", library_id=lib.id, kind="movie", title="Part II", guid="tmdb:240"
)
fresh = PlexItem(
rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238"
)
db.add_all([sequel, fresh])
db.commit()
@@ -272,7 +328,9 @@ def test_enrich_batch_transient_error_leaves_watermark_null(db):
# A transient TmdbError mid-batch must leave the row's tmdb_enriched_at NULL so the next run retries
# it (unlike a definitive no-match, which stamps the watermark). The row is NOT counted as processed.
lib = _lib(db)
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Godfather", guid="tmdb:238")
mv = PlexItem(
rating_key="m1", library_id=lib.id, kind="movie", title="Godfather", guid="tmdb:238"
)
db.add(mv)
db.commit()
@@ -285,7 +343,9 @@ def test_enrich_batch_transient_error_leaves_watermark_null(db):
def test_enrich_batch_skips_shows_without_guid(db):
lib = _lib(db, key="2", kind="show")
sh = PlexShow(rating_key="s1", library_id=lib.id, title="No Guid Show") # guid None → not selected
sh = PlexShow(
rating_key="s1", library_id=lib.id, title="No Guid Show"
) # guid None → not selected
db.add(sh)
db.commit()
assert tmdb.enrich_batch(db, FakeTmdb(), 50) == 0
@@ -299,8 +359,12 @@ def test_resolve_library_guids_scopes_to_given_libraries(db):
# and an explicit empty list skips that media side entirely (distinct from None = match all).
lib_on = _lib(db, key="on", kind="movie")
lib_off = _lib(db, key="off", kind="movie")
m_on = PlexItem(rating_key="on1", library_id=lib_on.id, kind="movie", title="In Scope", guid="tmdb:100")
m_off = PlexItem(rating_key="off1", library_id=lib_off.id, kind="movie", title="Out", guid="tmdb:200")
m_on = PlexItem(
rating_key="on1", library_id=lib_on.id, kind="movie", title="In Scope", guid="tmdb:100"
)
m_off = PlexItem(
rating_key="off1", library_id=lib_off.id, kind="movie", title="Out", guid="tmdb:200"
)
db.add_all([m_on, m_off])
db.commit()
entries = [{"tmdb_id": 100, "media_type": "movie"}, {"tmdb_id": 200, "media_type": "movie"}]
+10 -6
View File
@@ -3,6 +3,7 @@ session, so a spend is DURABLE even when the calling job rolls back (the units w
at YouTube), and it NEVER commits the caller's session (the old behaviour flushed a job's partial
state mid-run behind its own rollback). Uses the DB lane (`db` fixture) — skipped without Postgres.
"""
from sqlalchemy import func, select
from app import quota
@@ -13,14 +14,17 @@ def test_record_usage_survives_caller_rollback_and_leaves_caller_uncommitted(db)
# The caller has uncommitted work of its own; then a YouTube call spends quota; then the caller
# rolls back (a failed job).
db.add(User(email="pending@example.com")) # caller's own, never committed
quota.record_usage(10) # dedicated session — commits independently
db.rollback() # the job fails and rolls back
quota.record_usage(10) # dedicated session — commits independently
db.rollback() # the job fails and rolls back
# Quota spend PERSISTED (real units were consumed) — the guard stays accurate ...
assert quota.units_used_today(db) == 10
assert db.scalar(select(func.count()).select_from(QuotaEvent)) == 1
# ... but the caller's own partial work did NOT leak (quota never committed the caller's session).
assert db.scalar(select(func.count()).select_from(User).where(User.email == "pending@example.com")) == 0
assert (
db.scalar(select(func.count()).select_from(User).where(User.email == "pending@example.com"))
== 0
)
def test_record_usage_accumulates(db):
@@ -63,7 +67,7 @@ def test_backfill_reserve_reached_tracks_the_boundary(db):
# record_usage's independently-committed spend — and the boundary is inclusive (<=). Defaults:
# quota_daily_budget 9000, backfill_quota_reserve 2000 -> reached once 7000 units are used.
assert quota.backfill_reserve_reached() is False # 0 used, remaining 9000
quota.record_usage(6999) # remaining 2001, just above the reserve
quota.record_usage(6999) # remaining 2001, just above the reserve
assert quota.backfill_reserve_reached() is False
quota.record_usage(1) # remaining 2000, exactly at the reserve
assert quota.backfill_reserve_reached() is True # boundary is <=, so the reserve is reached
quota.record_usage(1) # remaining 2000, exactly at the reserve
assert quota.backfill_reserve_reached() is True # boundary is <=, so the reserve is reached
+3
View File
@@ -1,6 +1,7 @@
"""R6 S1b: the request models turn payloads that used to 500 into clean validation errors,
and PATCH models distinguish an absent field from an explicit null. Pure-logic (no DB) — it
exercises the Pydantic models the route modules define, not the endpoints."""
import pytest
from pydantic import ValidationError
@@ -13,6 +14,7 @@ from app.routes.tags import TagUpdateIn
# --- The three ROADMAP-named 500-producers are now 422s (ValidationError at the boundary). ---
def test_channel_priority_rejects_non_numeric():
# Was `int(payload["priority"])` → 500 on garbage. Now a validation error.
with pytest.raises(ValidationError):
@@ -58,6 +60,7 @@ def test_progress_accepts_fractional_seconds():
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
def test_tag_update_absent_vs_null_color():
absent = TagUpdateIn()
assert "color" not in absent.model_fields_set # untouched on PATCH
+17 -8
View File
@@ -4,6 +4,7 @@ No ffmpeg here: a fake Popen stands in, so the reader threads, the 1s poll loop,
and the bounded exit wait are all exercised on any machine. These are regression tests for a real
deadlock: stderr was opened and never read, so a chatty ffmpeg filled the ~64KB pipe, blocked on its
next write and never exited, and `proc.wait()` never returned."""
import subprocess
import time
@@ -109,7 +110,9 @@ def test_a_nonzero_exit_raises_with_the_stderr_tail(fake_popen):
_FakeProc(_FakeStream([]), _FakeStream(["Invalid data found\n"]), returncode=2)
)
with pytest.raises(RuntimeError, match="Invalid data found"):
edit.run_ffmpeg(["ffmpeg"], total_s=10, on_progress=lambda _p: None, should_cancel=lambda: False)
edit.run_ffmpeg(
["ffmpeg"], total_s=10, on_progress=lambda _p: None, should_cancel=lambda: False
)
assert not proc.terminated # it exited on its own; nothing to kill
@@ -119,7 +122,9 @@ def test_cancel_is_honoured_while_the_process_is_silent(fake_popen):
proc = fake_popen(_FakeProc(_FakeStream(block=True), _FakeStream(block=True)))
started = time.monotonic()
with pytest.raises(edit.EditAborted):
edit.run_ffmpeg(["ffmpeg"], total_s=10, on_progress=lambda _p: None, should_cancel=lambda: True)
edit.run_ffmpeg(
["ffmpeg"], total_s=10, on_progress=lambda _p: None, should_cancel=lambda: True
)
assert proc.terminated
assert time.monotonic() - started < 5 # not "whenever ffmpeg next speaks"
@@ -153,11 +158,11 @@ def test_a_callback_exception_still_stops_the_process(fake_popen):
def test_a_process_that_ignores_sigterm_is_killed(fake_popen):
# `_stop`'s escalation: terminate, wait 5s, then SIGKILL. Without it a cancel could park the
# worker thread on a process that simply refuses to go.
proc = fake_popen(
_FakeProc(_FakeStream(block=True), _FakeStream(block=True), wait_timeouts=1)
)
proc = fake_popen(_FakeProc(_FakeStream(block=True), _FakeStream(block=True), wait_timeouts=1))
with pytest.raises(edit.EditAborted):
edit.run_ffmpeg(["ffmpeg"], total_s=10, on_progress=lambda _p: None, should_cancel=lambda: True)
edit.run_ffmpeg(
["ffmpeg"], total_s=10, on_progress=lambda _p: None, should_cancel=lambda: True
)
assert proc.terminated and proc.killed
@@ -166,7 +171,9 @@ def test_a_process_lingering_after_eof_is_stopped_and_reported(fake_popen):
# the worker thread on `proc.wait()` forever (the old code waited with no timeout at all).
proc = fake_popen(_FakeProc(_FakeStream([]), _FakeStream(), wait_timeouts=1))
with pytest.raises(RuntimeError, match="did not exit"):
edit.run_ffmpeg(["ffmpeg"], total_s=10, on_progress=lambda _p: None, should_cancel=lambda: False)
edit.run_ffmpeg(
["ffmpeg"], total_s=10, on_progress=lambda _p: None, should_cancel=lambda: False
)
assert proc.terminated
# Two waits: the bounded grace wait that timed out, then _stop's own — never an unbounded one.
assert proc.waits == 2
@@ -177,6 +184,8 @@ def test_stderr_is_drained_while_the_process_runs(fake_popen):
# real pipe, an unread one fills at ~64KB and blocks ffmpeg's next write forever.
err = _FakeStream([f"warning {i}\n" for i in range(500)])
fake_popen(_FakeProc(_FakeStream(["out_time_us=1000000\n"]), err))
edit.run_ffmpeg(["ffmpeg"], total_s=10, on_progress=lambda _p: None, should_cancel=lambda: False)
edit.run_ffmpeg(
["ffmpeg"], total_s=10, on_progress=lambda _p: None, should_cancel=lambda: False
)
assert err.consumed == 500
assert err.closed
+10 -3
View File
@@ -1,6 +1,7 @@
"""ask 4: saved views are per-module (`source`). Feed and Plex views are separate lists, may share a
name, and each has its own single default. DB lane (`db` fixture) — skipped without Postgres.
"""
from fastapi.testclient import TestClient
import app.state as state
@@ -11,7 +12,9 @@ from app.models import User
def _client(db, user, monkeypatch):
monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app
monkeypatch.setattr(
state, "_configured_cache", True
) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user # require_human resolves through this
return TestClient(app)
@@ -29,8 +32,12 @@ def test_views_are_scoped_by_source(db, monkeypatch):
try:
c = _client(db, user, monkeypatch)
# Same NAME under two sources is allowed (unique is per user+source now).
f = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"a": 1}, "source": "feed"})
p = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"b": 2}, "source": "plex"})
f = c.post(
"/api/saved-views", json={"name": "Fresh", "filters": {"a": 1}, "source": "feed"}
)
p = c.post(
"/api/saved-views", json={"name": "Fresh", "filters": {"b": 2}, "source": "plex"}
)
assert f.status_code == 200 and p.status_code == 200, (f.text, p.text)
feed = c.get("/api/saved-views?source=feed").json()
+1
View File
@@ -1,6 +1,7 @@
"""_claim_running is the single-run guard that both the scheduled fire and the manual trigger go
through (at the top of _job): holding _activity_lock across the check-and-set means exactly one
caller can hold a job's "running" claim at a time, and it's claimable again once released."""
from app import scheduler
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
@@ -10,6 +10,7 @@ The stub now falls back to now() (a non-null placeholder), and the enrich overwr
video's real snippet date. These DB-lane tests lock both halves: no crash on a date-less stub, and
a placeholder never becomes the video's permanent date.
"""
from datetime import datetime, timezone
from app import sysconfig
+1
View File
@@ -5,6 +5,7 @@ the token-at-rest path (Google/API/SMTP/Plex secrets in system_config, OAuth ref
Fernet key is monkeypatched in so the test doesn't depend on TOKEN_ENCRYPTION_KEY being set in the env.
Pure-logic (no DB).
"""
from cryptography.fernet import Fernet
from app import security
+1
View File
@@ -9,6 +9,7 @@ Endpoints that actually answer (/healthz, /api/version, the auth/feed/downloads
the database, so exercising THEM belongs in a DB-backed integration lane (needs a test Postgres +
migrations) — deferred; see siftlode-ops/ROADMAP.md R2 S2b.
"""
from fastapi.testclient import TestClient
from app.main import app
+4 -2
View File
@@ -1,5 +1,6 @@
"""On-disk layout helpers: name sanitation, the Plex/flat path shapes, and — most importantly —
the safe_abs_path traversal guard that every file-serving endpoint depends on."""
from datetime import date
from app.downloads.storage import (
@@ -164,8 +165,9 @@ def test_sidecars_keep_the_full_basename_when_the_title_has_a_dot(tmp_path: Path
media.write_text("x")
thumb = tmp_path / "t.jpg"
thumb.write_bytes(b"\xff\xd8\xff")
meta = MediaMeta(video_id="abc", title="Sonka Házilag. | Villám konyha", uploader="Chan",
upload_date=None)
meta = MediaMeta(
video_id="abc", title="Sonka Házilag. | Villám konyha", uploader="Chan", upload_date=None
)
assert write_sidecars(str(tmp_path), rel, meta, thumb) is True
names = sorted(p.name for p in media.parent.iterdir())
+2 -3
View File
@@ -1,5 +1,6 @@
"""Guards that stop one bad YouTube answer from cascading: the subscription prune guard, the
playlist-push circuit breaker, and RSS telling a failure apart from an empty feed."""
import httpx
import pytest
@@ -176,9 +177,7 @@ class TestRssPollResult:
def test_no_channels_at_all_is_not_an_outage(self, monkeypatch):
# A fresh instance with nothing subscribed must not report an error every poll.
out = self._run(
monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0, channels=()
)
out = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0, channels=())
assert out == {"new": 0, "failed": 0}
+14 -2
View File
@@ -5,6 +5,7 @@ already-JPEG thumbnails (Reddit serves a JPEG under a `.png` name) — and becau
error is fatal, it used to fail the WHOLE download. The key regression property here is that a bad
thumbnail returns None (poster falls back to a cut frame) instead of raising.
"""
import shutil
import subprocess
from pathlib import Path
@@ -25,8 +26,19 @@ def _is_jpeg(p: Path) -> bool:
def test_converts_a_png_to_a_real_jpeg(tmp_path):
src = tmp_path / "thumb.png"
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi",
"-i", "color=c=red:s=64x64", "-frames:v", "1", str(src)],
[
"ffmpeg",
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=c=red:s=64x64",
"-frames:v",
"1",
str(src),
],
check=True,
)
out = edit.thumbnail_to_jpg(src, tmp_path / "poster.jpg")
+4 -1
View File
@@ -1,4 +1,5 @@
"""normalize_title: de-shout / de-clutter video titles without mangling non-English text."""
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeout
@@ -51,7 +52,9 @@ def test_midstring_hashtags_dont_hang_and_are_kept_redos_regression():
try:
out = fut.result(timeout=5) or ""
except FuturesTimeout:
pytest.fail("normalize_title did not return within 5s — catastrophic backtracking regressed")
pytest.fail(
"normalize_title did not return within 5s — catastrophic backtracking regressed"
)
finally:
ex.shutdown(wait=False) # never join a (hypothetically) wedged thread
assert "#foryoupage" in out and "#reel" in out # mid-string hashtags preserved (not trailing)
+1
View File
@@ -1,5 +1,6 @@
"""is_messageable_user — the "real, active, non-suspended human" predicate reused by the messaging
directory and the download share endpoints. A row must be non-demo AND active AND not-suspended."""
from types import SimpleNamespace
from app.userscope import is_messageable_user
+1
View File
@@ -1,4 +1,5 @@
"""iso(): the single date/datetime→JSON serializer the routes share (R6 S1a)."""
from datetime import date, datetime, timezone
from app.utils import iso, normalize_description
+3
View File
@@ -4,6 +4,7 @@ the DB-liveness lookup + side effects are monkeypatched, so the decision logic i
machine. These are regression tests for the class of bug that motivated the whole change: a wedged
download that a thread could neither kill nor cancel.
"""
import signal
import subprocess
@@ -45,6 +46,7 @@ def rec(monkeypatch):
def record(name):
def _f(*args, **kwargs):
calls.append((name, args, kwargs))
return _f
monkeypatch.setattr(worker, "_reset_asset_pending", record("reset_asset"))
@@ -209,6 +211,7 @@ def test_a_watchdog_error_still_stops_the_child(monkeypatch, rec):
# --- _stop_child escalation -----------------------------------------------------------------
@pytest.fixture
def killpg_rec(monkeypatch):
sent = []