Files
siftlode/backend/tests/test_video_length.py
T
peter 5b846136ad fix(catalog): a 24/7 stream archive's years-long length is unknown, not summed or sorted by
YouTube reports a 24/7 stream's archive with the years it has run (prod: 122,962,914 s), and one
such video made the Watch later total read 44,073 hours. A length past a day is now no length
(`Video.length_seconds`, LENGTH_MAX_S): the API sends none, the playlist and channel totals
leave it out and count it as `live_without_length` ("+N live" beside the sum), and the duration
filter, sort and Home budget never match it. Migration 0117 moves the duration sort indexes onto
the length expression. An AST test holds who may still read the raw column.

The migration 0110 test now ends its read before the way down: 0117 builds an index
CONCURRENTLY there, which waited for that open transaction forever.
2026-11-07 02:20:03 +01:00

183 lines
7.0 KiB
Python

"""A YouTube length past a day is unknown, everywhere a length is used (`catalog.models.LENGTH_MAX_S`).
Measured on prod (2026-11-06): two 24/7 streams' archives report 122,962,914 s and 34,762,459 s, and the
owner's Watch later total read "44,073 hr 55 min". Such a video now has no length on any API answer, the
totals leave it out and count it as `live_without_length`, and the duration filter and sort pass it over
like a video whose length is not fetched yet. An AST check holds who may still read the raw column.
DB lane (the AST check needs none).
"""
import ast
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.dialects import postgresql
from app.catalog.models import LENGTH_MAX_S, Channel, Subscription, Video, known_length
from app.reader.models import Playlist, PlaylistItem
YEARS = 122_962_914 # the measured archive
HOUR = 3600
def _seed(db, user):
db.add(Channel(id="UClen", title="Len"))
db.flush()
db.add(Subscription(user_id=user.id, channel_id="UClen", priority=0, hidden=False))
rows = [
("short", 600, "none"),
("long", 2 * HOUR, "none"),
("day", LENGTH_MAX_S, "was_live"), # exactly a day still is a length
("archive", YEARS, "was_live"),
("onair", None, "live"),
("unfetched", None, "none"), # not live: not counted as "+N live"
]
for i, (vid, seconds, live) in enumerate(rows):
db.add(
Video(
id=vid,
channel_id="UClen",
published_at=datetime(2024, 1, i + 1, tzinfo=timezone.utc),
title=vid,
duration_seconds=seconds,
live_status=live,
is_short=False,
via_search=False,
via_explore=False,
)
)
pl = Playlist(user_id=user.id, name="Later")
db.add(pl)
db.flush()
for i, (vid, _s, _l) in enumerate(rows):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=i))
db.commit()
return pl
KNOWN_SUM = 600 + 2 * HOUR + LENGTH_MAX_S
def test_the_rule():
assert known_length(None) is None
assert known_length(LENGTH_MAX_S) == LENGTH_MAX_S
assert known_length(LENGTH_MAX_S + 1) is None
assert known_length(YEARS) is None
def test_the_sql_length_carries_its_limit_as_a_literal():
# The duration sort indexes (migration 0117) are built on this expression with 86400 in it. A
# bound parameter matches no index once the driver reuses a prepared, generic plan, and the
# sort-index test cannot see that: it compiles with literal binds.
compiled = Video.length_seconds.expression.compile(dialect=postgresql.dialect())
assert compiled.params == {}
assert f"> {LENGTH_MAX_S}" in str(compiled)
def test_a_years_long_archive_has_no_length_in_the_feed_or_a_playlist(db, make_user, as_user):
user = make_user()
pl = _seed(db, user)
client = as_user(user)
feed = client.get("/api/feed?show=all&include_live=true&limit=50")
assert feed.status_code == 200
by_id = {v["id"]: v["duration_seconds"] for v in feed.json()["items"]}
assert by_id["archive"] is None
assert by_id["day"] == LENGTH_MAX_S
detail = client.get(f"/api/playlists/{pl.id}")
assert detail.status_code == 200
items = {v["id"]: v["duration_seconds"] for v in detail.json()["items"]}
assert items["archive"] is None
one = client.get("/api/videos/archive")
assert one.status_code == 200
assert one.json()["duration_seconds"] is None
def test_the_totals_leave_it_out_and_count_the_live_ones(db, make_user, as_user):
user = make_user()
pl = _seed(db, user)
client = as_user(user)
listed = client.get("/api/playlists")
assert listed.status_code == 200
(summary,) = [p for p in listed.json() if p["id"] == pl.id]
assert summary["total_duration_seconds"] == KNOWN_SUM
assert summary["live_without_length"] == 2 # the archive and the one on air
renamed = client.patch(f"/api/playlists/{pl.id}", json={"name": "Later 2"})
assert renamed.status_code == 200
assert renamed.json()["total_duration_seconds"] == KNOWN_SUM
assert renamed.json()["live_without_length"] == 2
channels = client.get("/api/channels")
assert channels.status_code == 200
(ch,) = channels.json()
assert ch["total_duration_seconds"] == KNOWN_SUM
assert ch["live_without_length"] == 2
def test_the_duration_filter_and_sort_pass_it_over(db, make_user, as_user):
user = make_user()
_seed(db, user)
client = as_user(user)
base = "/api/feed?show=all&include_live=true&limit=50"
longer = client.get(f"{base}&min_duration={HOUR}")
assert longer.status_code == 200
assert {v["id"] for v in longer.json()["items"]} == {"long", "day"}
longest = client.get(f"{base}&sort=duration_desc")
assert longest.status_code == 200
order = [v["id"] for v in longest.json()["items"]]
assert order[:3] == ["day", "long", "short"]
assert set(order[3:]) == {"archive", "onair", "unfetched"}
# Who may read `duration_seconds` itself, by (file, function): what each one asks is not "how long".
RAW_READERS = {
# The column is defined and turned into a length here.
("catalog/models.py", "length_seconds"),
("catalog/models.py", "_length_seconds_expression"),
# Ingest writes it; the live refresh asks whether YouTube has answered for an archive yet; the
# Shorts classification needs only "longer than a Short", which a years-long value proves.
("sync/videos.py", "apply_video_details"),
("sync/videos.py", "refresh_live"),
("sync/videos.py", "run_shorts_classification"),
("catalog/search.py", "classify_shorts"),
("catalog/queries.py", "work_queue"),
# Not a video's column: the player's own report, and the watch history's looked-up lengths, which
# count a watched video as more than a glance (a watched archive is that too).
("routes/_common.py", "kept_position"),
("routes/plex/playback.py", "item_progress"),
("reader/queries.py", "_counted_channels"),
("reader/queries.py", "watched_channels"),
("reader/queries.py", "watched_channel_videos"),
}
def _raw_reads() -> set[tuple[str, str]]:
root = Path(__file__).resolve().parents[1] / "app"
found: set[tuple[str, str]] = set()
for path in root.rglob("*.py"):
if "alembic" in path.parts:
continue
rel = path.relative_to(root).as_posix()
tree = ast.parse(path.read_text(encoding="utf-8"))
for fn in ast.walk(tree):
if not isinstance(fn, ast.FunctionDef):
continue
for node in ast.walk(fn):
if isinstance(node, ast.Attribute) and node.attr == "duration_seconds":
found.add((rel, fn.name))
return found
def test_only_the_listed_readers_take_the_raw_column():
found = _raw_reads()
stray = sorted(found - RAW_READERS)
assert not stray, f"read `length_seconds` (a length) instead of the raw column: {stray}"
# A listed reader that stopped reading it leaves the list.
assert not RAW_READERS - found, sorted(RAW_READERS - found)