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:
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,4 +1,5 @@
|
||||
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
|
||||
|
||||
from app.downloads.errors import classify
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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"}]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user