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.
371 lines
15 KiB
Python
371 lines
15 KiB
Python
"""Reader's service and queries, one use case each, below the HTTP layer.
|
|
|
|
The routes are thin callers now, and their own tests stay the contract a client sees. These hold
|
|
what each write does and leaves on its own: that it flushes and leaves the commit to its caller,
|
|
that a row of someone else's reads as a missing one, and the rule each write carries (one default
|
|
view per module, a resume point a status keeps, a move that never leaves a video in two lists, the
|
|
auto-tagger touching only its own links). DB-backed (real Postgres).
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app.discover import pins
|
|
from app.catalog.models import Channel, Subscription, Video
|
|
from app.reader import queries, service
|
|
from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState
|
|
|
|
|
|
def _videos(db, *ids):
|
|
if db.get(Channel, "UCr") is None:
|
|
db.add(Channel(id="UCr", title="R"))
|
|
now = datetime.now(timezone.utc)
|
|
for v in ids:
|
|
db.add(Video(id=v, channel_id="UCr", published_at=now, duration_seconds=60))
|
|
db.commit()
|
|
|
|
|
|
def _uncommitted(db):
|
|
"""What the caller still has to commit: the session holds pending work."""
|
|
return bool(db.new or db.dirty or db.deleted) or db.in_transaction()
|
|
|
|
|
|
def _ids(db, pl):
|
|
return [it.video_id for it in queries.items(db, pl.id)]
|
|
|
|
|
|
# --- whose a row is ------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_row_of_someone_elses_reads_as_a_missing_one(db, make_user):
|
|
owner, stranger = make_user(), make_user()
|
|
tag = service.create_tag(db, owner, "Mine", "other", None)
|
|
view = service.create_view(db, owner, "feed", "V", {})
|
|
pl = service.create_playlist(db, owner, "L")
|
|
db.commit()
|
|
assert queries.own_tag(db, owner, tag.id) is tag
|
|
assert queries.own_tag(db, stranger, tag.id) is None
|
|
assert queries.own_view(db, stranger, view.id) is None
|
|
assert queries.own_playlist(db, stranger, pl.id) is None
|
|
assert queries.own_playlist(db, owner, pl.id) is pl
|
|
assert queries.playlists_for(db, stranger) == []
|
|
|
|
|
|
def test_a_system_tag_is_nobodys_to_edit(db, make_user):
|
|
user, other = make_user(), make_user()
|
|
system = service.ensure_system_tag(db, "Music", "topic", None)
|
|
service.create_tag(db, other, "Theirs", "other", None) # another user's tag stays theirs
|
|
db.commit()
|
|
assert queries.own_tag(db, user, system.id) is None
|
|
assert [t.id for t, *_ in queries.tags_with_counts(db, user)] == [system.id]
|
|
|
|
|
|
# --- tags ------------------------------------------------------------------------------------
|
|
|
|
|
|
def _music_on_four(db, make_user):
|
|
"""A tag on four channels: two followed, one followed but hidden, one no longer followed."""
|
|
user, other = make_user(), make_user()
|
|
tag = service.create_tag(db, user, "Music", "other", None)
|
|
for cid in ("UCa", "UCb", "UChid", "UCgone"):
|
|
db.add(Channel(id=cid, title=cid))
|
|
db.flush()
|
|
for cid in ("UCa", "UCb"):
|
|
db.add(Subscription(user_id=user.id, channel_id=cid))
|
|
db.add(Subscription(user_id=user.id, channel_id="UChid", hidden=True))
|
|
db.add(Subscription(user_id=other.id, channel_id="UCgone"))
|
|
for cid in ("UCa", "UCb", "UChid", "UCgone"):
|
|
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=user.id, source="user"))
|
|
db.commit()
|
|
return user, tag
|
|
|
|
|
|
def test_a_tag_counts_apart_the_links_on_channels_the_user_still_follows(db, make_user):
|
|
"""An unsubscribe and a hide keep the channel's tag links; the Channels page's filter lists only
|
|
the followed channels not hidden, so its count must not hold the others."""
|
|
user, tag = _music_on_four(db, make_user)
|
|
[(_, count, subscribed)] = [r for r in queries.tags_with_counts(db, user) if r[0].id == tag.id]
|
|
assert (count, subscribed) == (4, 2)
|
|
|
|
|
|
def test_the_tag_list_sends_each_count_under_its_own_name(db, make_user, as_user):
|
|
"""Both counts are ints, so a swap would type-check: the answer itself pins which is which."""
|
|
user, tag = _music_on_four(db, make_user)
|
|
[row] = [t for t in as_user(user).get("/api/tags").json() if t["id"] == tag.id]
|
|
assert (row["channel_count"], row["subscribed_count"]) == (4, 2)
|
|
|
|
|
|
def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user):
|
|
user = make_user()
|
|
tag = service.create_tag(db, user, "Later", "nonsense", "#fff")
|
|
assert _uncommitted(db)
|
|
db.commit()
|
|
assert tag.category == "other"
|
|
with pytest.raises(IntegrityError):
|
|
service.create_tag(db, user, "Later", "topic", None)
|
|
db.rollback()
|
|
|
|
|
|
def test_a_tag_changes_only_what_was_sent_and_a_sent_null_clears_its_color(db, make_user):
|
|
user = make_user()
|
|
tag = service.create_tag(db, user, "A", "other", "#fff")
|
|
service.update_tag(tag, sent={"name"}, name="B", color=None)
|
|
assert (tag.name, tag.color) == ("B", "#fff")
|
|
service.update_tag(tag, sent={"color"}, name=None, color=None)
|
|
assert (tag.name, tag.color) == ("B", None)
|
|
service.delete_tag(db, tag)
|
|
db.commit()
|
|
assert db.get(Tag, tag.id) is None
|
|
|
|
|
|
def test_the_auto_tagger_moves_only_its_own_links(db, make_user):
|
|
user = make_user()
|
|
_videos(db)
|
|
gaming = service.ensure_system_tag(db, "Gaming", "topic", None)
|
|
music = service.ensure_system_tag(db, "Music", "topic", None)
|
|
assert service.ensure_system_tag(db, "Gaming", "topic", None) is gaming # made once
|
|
mine = service.create_tag(db, user, "Fav", "other", None)
|
|
db.add(ChannelTag(channel_id="UCr", tag_id=mine.id, user_id=user.id, source="user"))
|
|
db.commit() # the user's link is in the table the auto-tagger reads
|
|
service.set_auto_tags(db, "UCr", {gaming.id: 0.5})
|
|
db.commit()
|
|
service.set_auto_tags(db, "UCr", {music.id: None})
|
|
db.commit()
|
|
links = db.execute(select(ChannelTag.tag_id, ChannelTag.source)).tuples().all()
|
|
assert sorted(links) == sorted([(music.id, "auto"), (mine.id, "user")])
|
|
assert db.execute(
|
|
select(Channel.id).where(queries.has_auto_tags(Channel.id))
|
|
).scalars().all() == ["UCr"]
|
|
|
|
|
|
# --- saved views -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_views_number_per_module_and_one_default_per_module(db, make_user):
|
|
user = make_user()
|
|
a = service.create_view(db, user, "feed", "A", {})
|
|
b = service.create_view(db, user, "feed", "B", {})
|
|
plex = service.create_view(db, user, "plex", "P", {})
|
|
assert (a.position, b.position, plex.position) == (0, 1, 0)
|
|
service.update_view(db, plex, sent={"is_default"}, is_default=True)
|
|
service.update_view(db, a, sent={"is_default"}, is_default=True)
|
|
service.update_view(db, b, sent={"is_default"}, is_default=True)
|
|
db.commit()
|
|
db.expire_all()
|
|
assert [(v.name, v.is_default) for v in queries.views(db, user, "feed")] == [
|
|
("A", False),
|
|
("B", True),
|
|
]
|
|
assert queries.views(db, user, "plex")[0].is_default is True # another module's default stays
|
|
|
|
|
|
def test_reordering_views_skips_ids_that_are_not_the_callers(db, make_user):
|
|
user, other = make_user(), make_user()
|
|
a = service.create_view(db, user, "feed", "A", {})
|
|
b = service.create_view(db, user, "feed", "B", {})
|
|
theirs = service.create_view(db, other, "feed", "T", {})
|
|
db.commit()
|
|
service.reorder_views(db, user, [theirs.id, b.id, a.id])
|
|
db.commit()
|
|
assert [v.name for v in queries.views(db, user, "feed")] == ["B", "A"]
|
|
assert theirs.position == 0
|
|
|
|
|
|
# --- watch state -----------------------------------------------------------------------------
|
|
|
|
|
|
def _state(db, user, vid):
|
|
db.expire_all()
|
|
return queries.video_state(db, user, vid)
|
|
|
|
|
|
def test_unmarking_keeps_a_resume_point_and_drops_a_row_with_nothing_else(db, make_user):
|
|
user = make_user()
|
|
_videos(db, "v1", "v2")
|
|
service.mark(db, user, "v1", "watched")
|
|
service.mark(db, user, "v2", "watched")
|
|
service.set_progress(db, user, "v1", 120)
|
|
db.commit()
|
|
service.unmark(db, user, "v1")
|
|
service.unmark(db, user, "v2")
|
|
db.commit()
|
|
kept = _state(db, user, "v1")
|
|
assert (kept.status, kept.watched_at, kept.position_seconds) == ("new", None, 120)
|
|
assert _state(db, user, "v2") is None
|
|
|
|
|
|
def test_hiding_a_watched_video_keeps_when_it_was_watched(db, make_user):
|
|
user = make_user()
|
|
_videos(db, "v1")
|
|
service.mark(db, user, "v1", "watched")
|
|
db.commit()
|
|
watched_at = _state(db, user, "v1").watched_at
|
|
service.mark(db, user, "v1", "hidden")
|
|
db.commit()
|
|
row = _state(db, user, "v1")
|
|
assert (row.status, row.watched_at) == ("hidden", watched_at)
|
|
assert db.execute(select(VideoState)).scalars().all() == [row] # one row per user and video
|
|
|
|
|
|
def test_a_cleared_resume_point_drops_a_bare_row_and_zeroes_one_with_a_status(db, make_user):
|
|
user = make_user()
|
|
_videos(db, "v1", "v2")
|
|
service.set_progress(db, user, "v1", 30)
|
|
service.set_progress(db, user, "v2", 30)
|
|
service.mark(db, user, "v2", "hidden")
|
|
db.commit()
|
|
service.set_progress(db, user, "v1", 0)
|
|
service.set_progress(db, user, "v2", 0)
|
|
db.commit()
|
|
assert _state(db, user, "v1") is None
|
|
assert (_state(db, user, "v2").status, _state(db, user, "v2").position_seconds) == ("hidden", 0)
|
|
|
|
|
|
# --- playlists -------------------------------------------------------------------------------
|
|
|
|
|
|
def test_watch_later_is_made_once(db, make_user):
|
|
user = make_user()
|
|
first = service.watch_later(db, user)
|
|
db.commit()
|
|
assert service.watch_later(db, user) is first
|
|
assert first.kind == "watch_later"
|
|
assert [p.id for p in queries.playlists_for(db, user)] == [first.id]
|
|
|
|
|
|
def test_an_add_is_idempotent_and_lands_where_asked(db, make_user):
|
|
user = make_user()
|
|
_videos(db, "a", "b", "c")
|
|
pl = service.create_playlist(db, user, "L")
|
|
assert service.add_item(db, pl, "a", mark_dirty=False) is True
|
|
assert service.add_item(db, pl, "a", mark_dirty=False) is False
|
|
assert service.add_item(db, pl, "b", mark_dirty=True) is True
|
|
assert service.restore_items(db, pl, ["c", "a"], [0, 2]) == 1 # "a" is there: it stays put
|
|
assert _uncommitted(db)
|
|
db.commit()
|
|
assert _ids(db, pl) == ["c", "a", "b"]
|
|
assert service.remove_item(db, pl, "a", mark_dirty=True) is True
|
|
assert service.remove_item(db, pl, "a", mark_dirty=True) is False
|
|
db.commit()
|
|
assert _ids(db, pl) == ["c", "b"]
|
|
assert queries.item_counts(db, [pl.id]) == {pl.id: 2}
|
|
assert queries.durations(db, [pl.id]) == {pl.id: (120, 0)}
|
|
assert queries.containing(db, [pl.id], "b") == {pl.id}
|
|
|
|
|
|
def test_a_move_never_leaves_a_video_in_two_lists(db, make_user):
|
|
user = make_user()
|
|
_videos(db, "a", "b")
|
|
src = service.create_playlist(db, user, "S")
|
|
dst = service.create_playlist(db, user, "D")
|
|
service.add_item(db, src, "a", mark_dirty=False)
|
|
service.add_item(db, src, "b", mark_dirty=False)
|
|
service.add_item(db, dst, "b", mark_dirty=False)
|
|
db.commit()
|
|
with pytest.raises(service.MoveRefused, match="same_playlist"):
|
|
service.move_items(db, src, src, ["a"])
|
|
assert service.move_items(db, src, dst, ["a"]) == (["a"], [])
|
|
# already in the target: dropped from the source
|
|
assert service.move_items(db, src, dst, ["b"]) == ([], ["b"])
|
|
db.commit()
|
|
assert (_ids(db, src), _ids(db, dst)) == ([], ["b", "a"])
|
|
assert service.move_items(db, src, dst, ["a"]) == ([], [])
|
|
|
|
|
|
def test_a_reorder_keeps_what_it_left_out_after_the_listed(db, make_user):
|
|
user = make_user()
|
|
_videos(db, "a", "b", "c")
|
|
pl = service.create_playlist(db, user, "L")
|
|
for v in ("a", "b", "c"):
|
|
service.add_item(db, pl, v, mark_dirty=False)
|
|
db.commit()
|
|
assert service.reorder_items(db, pl, ["c", "zzz", "b"]) == 3
|
|
db.commit()
|
|
assert _ids(db, pl) == ["c", "b", "a"]
|
|
|
|
|
|
def test_a_rename_and_a_delete(db, make_user):
|
|
user = make_user()
|
|
_videos(db, "a")
|
|
pl = service.create_playlist(db, user, "Old")
|
|
service.add_item(db, pl, "a", mark_dirty=False)
|
|
service.rename_playlist(db, pl, "New")
|
|
db.commit()
|
|
assert queries.playlist_totals(db, pl.id) == (1, 60, 0, None)
|
|
assert pl.name == "New"
|
|
service.delete_playlist(db, pl)
|
|
db.commit()
|
|
assert db.execute(select(PlaylistItem)).first() is None
|
|
assert db.get(Playlist, pl.id) is None
|
|
|
|
|
|
# --- what the other units ask ----------------------------------------------------------------
|
|
|
|
|
|
def test_a_video_someone_holds_is_kept_and_its_playlisters_are_known(db, make_user):
|
|
user = make_user()
|
|
_videos(db, "watched", "listed", "also", "loose")
|
|
service.mark(db, user, "watched", "watched")
|
|
pl = service.create_playlist(db, user, "L")
|
|
service.add_item(db, pl, "listed", mark_dirty=False)
|
|
service.add_item(db, pl, "also", mark_dirty=False)
|
|
db.commit()
|
|
kept = db.execute(select(Video.id).where(queries.kept_by_a_reader(Video.id))).scalars()
|
|
assert sorted(kept) == ["also", "listed", "watched"]
|
|
assert queries.playlisted_by(db, ["listed", "loose"]) == [(user.id, "listed")]
|
|
|
|
|
|
# --- the comfort shelf -----------------------------------------------------------------------
|
|
|
|
|
|
def test_a_pin_is_one_per_thing_and_only_its_owners(db, make_user):
|
|
user, stranger = make_user(), make_user()
|
|
_videos(db, "a")
|
|
pin = pins.add(db, user, "video_id", "a")
|
|
db.commit()
|
|
assert pins.pin_of(db, user, "video_id", "a") is pin
|
|
assert pins.pin_of(db, stranger, "video_id", "a") is None
|
|
assert pins.own_pin(db, stranger, pin.id) is None
|
|
with pytest.raises(IntegrityError):
|
|
pins.add(db, user, "video_id", "a")
|
|
db.rollback()
|
|
pins.remove(db, pins.own_pin(db, user, pin.id))
|
|
db.commit()
|
|
assert db.execute(select(ComfortPin)).first() is None
|
|
|
|
|
|
def test_a_watch_later_save_that_lost_the_race_uses_the_winners_list(db, make_user, monkeypatch):
|
|
"""The first two saves at once both read "no Watch later"; the second must land in the list the
|
|
first made, not make one of its own."""
|
|
user = make_user()
|
|
won = service.watch_later(db, user)
|
|
db.commit()
|
|
real = queries.watch_later
|
|
stale = [True] # this request's first read happened before the winner committed
|
|
|
|
def read(d, u):
|
|
return None if stale and stale.pop() else real(d, u)
|
|
|
|
monkeypatch.setattr(queries, "watch_later", read)
|
|
assert service.watch_later(db, user).id == won.id
|
|
db.commit()
|
|
assert [p.id for p in queries.playlists_for(db, user) if p.kind == "watch_later"] == [won.id]
|
|
|
|
|
|
def test_a_first_resume_point_beside_the_watched_mark_lands_on_its_row(db, make_user, monkeypatch):
|
|
"""The first checkpoint of a video and the end-of-playback mark both read "no row"; the
|
|
checkpoint that writes second must update the mark's row, not fail on `uq_user_video`."""
|
|
user = make_user()
|
|
_videos(db, "v1")
|
|
service.mark(db, user, "v1", "watched") # the mark that won, committed
|
|
db.commit()
|
|
monkeypatch.setattr(queries, "video_state", lambda *a, **k: None) # read before it committed
|
|
service.set_progress(db, user, "v1", 90)
|
|
db.commit()
|
|
db.expire_all()
|
|
rows = db.execute(select(VideoState)).scalars().all()
|
|
assert [(r.status, r.position_seconds) for r in rows] == [("watched", 90)]
|