Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.
The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.
The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
122 lines
3.9 KiB
Python
122 lines
3.9 KiB
Python
"""The library player's resume point (`library_states`).
|
|
|
|
A checkpoint stores where the caller stopped in a library item they can play — their own download,
|
|
or one shared with them — and the two library listings hand it back per item, so the player opens
|
|
there. Too early or too close to the end clears it. Nobody else's checkpoint, and no item the caller
|
|
cannot reach, is ever read or written. DB-backed (real Postgres).
|
|
"""
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.models import DownloadJob, DownloadShare, LibraryState
|
|
|
|
|
|
def _done_job(db, user) -> DownloadJob:
|
|
job = DownloadJob(
|
|
user_id=user.id,
|
|
source_kind="url",
|
|
source_ref="https://example.com/a.mp4",
|
|
status="done",
|
|
profile_snapshot={},
|
|
)
|
|
db.add(job)
|
|
db.commit()
|
|
return job
|
|
|
|
|
|
def _checkpoint(client, job_id: int, position: float, duration: float = 3000) -> int:
|
|
"""Send a checkpoint the caller may make; the point the server kept (0 = cleared)."""
|
|
r = client.post(
|
|
f"/api/downloads/{job_id}/progress",
|
|
json={"position_seconds": position, "duration_seconds": duration},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["position_seconds"]
|
|
|
|
|
|
def _states(db) -> list[tuple[int, int, int]]:
|
|
db.expire_all()
|
|
rows = db.execute(
|
|
select(LibraryState.user_id, LibraryState.job_id, LibraryState.position_seconds)
|
|
).all()
|
|
return [tuple(r) for r in rows]
|
|
|
|
|
|
def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user):
|
|
me = make_user()
|
|
job = _done_job(db, me)
|
|
c = as_user(me)
|
|
|
|
r = c.post(
|
|
f"/api/downloads/{job.id}/progress",
|
|
json={"position_seconds": 754.9, "duration_seconds": 3000},
|
|
)
|
|
assert r.status_code == 200 and r.json() == {
|
|
"ok": True,
|
|
"job_id": job.id,
|
|
"position_seconds": 754,
|
|
}
|
|
|
|
[item] = c.get("/api/downloads").json()
|
|
assert item["position_seconds"] == 754
|
|
# A second checkpoint moves it rather than adding a row.
|
|
assert _checkpoint(c, job.id, 900) == 900
|
|
assert _states(db) == [(me.id, job.id, 900)]
|
|
|
|
|
|
def test_an_item_never_played_lists_zero(db, make_user, as_user):
|
|
me = make_user()
|
|
_done_job(db, me)
|
|
[item] = as_user(me).get("/api/downloads").json()
|
|
assert item["position_seconds"] == 0
|
|
|
|
|
|
def test_too_early_or_near_the_end_clears_it(db, make_user, as_user):
|
|
me = make_user()
|
|
job = _done_job(db, me)
|
|
c = as_user(me)
|
|
|
|
assert _checkpoint(c, job.id, 600) == 600
|
|
assert _checkpoint(c, job.id, 2995) == 0
|
|
assert _states(db) == []
|
|
|
|
assert _checkpoint(c, job.id, 600) == 600
|
|
assert _checkpoint(c, job.id, 3) == 0
|
|
assert _states(db) == []
|
|
# Clearing what is not there is not an error (the beacon and the last checkpoint of one stop).
|
|
assert _checkpoint(c, job.id, 0) == 0
|
|
|
|
|
|
def test_a_recipient_keeps_their_own_place_in_a_shared_item(db, make_user, as_user):
|
|
owner, friend = make_user(), make_user()
|
|
job = _done_job(db, owner)
|
|
db.add(DownloadShare(job_id=job.id, from_user_id=owner.id, to_user_id=friend.id))
|
|
db.commit()
|
|
|
|
assert _checkpoint(as_user(owner), job.id, 100) == 100
|
|
assert _checkpoint(as_user(friend), job.id, 2000) == 2000
|
|
|
|
assert as_user(owner).get("/api/downloads").json()[0]["position_seconds"] == 100
|
|
assert as_user(friend).get("/api/downloads/shared").json()[0]["position_seconds"] == 2000
|
|
|
|
|
|
def test_an_item_the_caller_cannot_play_is_a_404_and_writes_nothing(db, make_user, as_user):
|
|
owner, stranger = make_user(), make_user()
|
|
job = _done_job(db, owner)
|
|
|
|
r = as_user(stranger).post(
|
|
f"/api/downloads/{job.id}/progress",
|
|
json={"position_seconds": 600, "duration_seconds": 3000},
|
|
)
|
|
assert r.status_code == 404
|
|
assert _states(db) == []
|
|
|
|
|
|
def test_deleting_the_job_deletes_its_places(db, make_user, as_user):
|
|
me = make_user()
|
|
job = _done_job(db, me)
|
|
assert _checkpoint(as_user(me), job.id, 600) == 600
|
|
db.delete(db.get(DownloadJob, job.id))
|
|
db.commit()
|
|
assert _states(db) == []
|