- A tag counts the followed channels the Subscriptions tab shows: hidden ones are left out, and a subscribe, unsubscribe, hide or tag toggle refreshes the count. The Tag manager's row and its hover list count the same channels as the chip; /api/tags sends both counts under their names. - A chip's count keeps the chip's colour after a dot (a dimmed count fell under 4.5:1). A pick in the open "+N" fold closes it; a chip that leaves the list hands the focus to the one in its place. The fold is a dialog of toggle buttons, not an empty menu. - Upload: a folder name left by a press is made once the press is over, if the dialog is still open, instead of guessing a dismissal from Modal's markup. While the folder is made, its place holds the focus and a live region says so. - Share: an expired link has no Copy and a warn badge; the link made on open goes into the cached list, so a reopen makes no second one; the New link form survives the link made on open. - Watch: a repeated Enter sends one unlock while one is on its way. See all buttons are named with their rail. - An unsized clip of a rotated file gets the upright size its crop cut.
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}
|
|
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, 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)]
|