Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3987cd4260 | ||
|
|
95d9ed4aa6 | ||
|
|
8d0063d514 | ||
|
|
0ca0cac534 | ||
|
|
cf0c55ba80 | ||
|
|
fa4f823fc8 | ||
|
|
884060891e | ||
|
|
955c8c3445 | ||
|
|
99df44d7fb | ||
|
|
30fbdc52b2 | ||
|
|
046ff735cf | ||
|
|
7afaba0547 | ||
|
|
db8ed5a004 | ||
|
|
0ef25a8037 | ||
|
|
17a26894b0 | ||
|
|
946c8f26db | ||
|
|
8759b5860d | ||
|
|
dc902d2c0e | ||
|
|
cedcbb1434 | ||
|
|
4f72b193aa | ||
|
|
7b94e9a651 | ||
|
|
c6bf84a578 | ||
|
|
444f9b8166 | ||
|
|
7a47223a58 | ||
|
|
bc138e3b37 | ||
|
|
df7c3db085 | ||
|
|
6fda49369f | ||
|
|
8018cd4685 | ||
|
|
29875b7367 | ||
|
|
7ae5c57b6b | ||
|
|
dc4e8b9fcd | ||
|
|
6f9ba428d5 | ||
|
|
c60359bfed | ||
|
|
e6b76e156a | ||
|
|
e74986cc9a | ||
|
|
2df45b27d3 | ||
|
|
bacd6aec4f | ||
|
|
fad301e7a4 |
@@ -7,6 +7,35 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.92.0 — 2026-09-19
|
||||
|
||||
A debt burn: every item the roadmap had deferred is done or dropped — Plex playback per viewer, refusals said in your language, controls that keep the keyboard focus, and a screen reader that hears Hungarian.
|
||||
|
||||
### Features
|
||||
|
||||
- The command palette has a visible way in: a Ctrl K key cap in the header's search field, and a “Jump to…” pill on pages without one.
|
||||
- The Playlists rail says, under “Missing a list? Why?”, which lists YouTube's API does not hand out (Watch later, History, lists saved from other channels).
|
||||
- The last native drop-downs are the app's own controls: the live search's result count, rows per page, a download format's content, audio format and quality, and the download dialog's format.
|
||||
- Plex plays one session per viewer, so two accounts can watch the same film at once; an administrator sets the ceiling on concurrent sessions (Configuration → Plex).
|
||||
|
||||
### Fixes
|
||||
|
||||
- A YouTube action YouTube refuses says what to do — wait for tomorrow's quota, reconnect the account, or try again later — instead of a server error; a passing Google hiccup no longer asks you to reconnect.
|
||||
- Every refusal the server gives is said in your language, and every label a screen reader or tooltip reads is translated.
|
||||
- Dragging to reorder a list is announced in your language and with the right position, and a Plex playlist keeps a drag within the list it started in.
|
||||
- A button that turns itself off, or a row that removes itself, leaves the keyboard focus where it was; Back from a Plex title returns the focus to the tile you opened, even after using a button there.
|
||||
- With Plex turned off, the Home shelf shows your YouTube pins only, and reordering it leaves the hidden Plex pins in their places; two reorders at once can no longer put two pins on one slot.
|
||||
- The Home hero from a pinned show offers to unpin the show, and “Surprise me” from the command palette really asks for a surprise.
|
||||
- A finished Plex title scrobbles once and gets no resume point pushed after it; a subtitle pass that stalled resumes instead of starting over.
|
||||
- Tables keep their j/k keys after the window is resized across the phone breakpoint, and boxes grow with the text-size setting.
|
||||
- Whitelisting an address and activating its account happen in one step, and the audit log names the entry.
|
||||
|
||||
### Internal
|
||||
|
||||
- Every mutation answers `ok`, and route helpers leave the commit to the request — both held by tests; a request that leaves a write uncommitted now fails its test.
|
||||
- New lint rules and contracts: no native select, no hover that ignores aria-disabled, no untranslated accessible text, no unused translation key.
|
||||
- Two adversarial review rounds at the epic's end: 23 findings, all fixed, each regression test proven to fail with its own fix removed.
|
||||
|
||||
## 0.91.0 — 2026-09-19
|
||||
|
||||
What a test run on the live instance turned up, fixed: new videos keep arriving while YouTube's feeds are down, an open tab survives a release, and Back returns to what you left.
|
||||
|
||||
@@ -82,8 +82,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
|
||||
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
@@ -92,7 +92,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
|
||||
|
||||
+6
-8
@@ -14,6 +14,7 @@ from app.db import get_db
|
||||
from app.models import Invite, LogEvent, User
|
||||
from app.security import decrypt_optional
|
||||
from app.utils import canonical_email
|
||||
from app.refusal import refused
|
||||
|
||||
PASSWORD_MIN_LEN = 10
|
||||
|
||||
@@ -341,19 +342,19 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
|
||||
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
raise refused(401, "not_authenticated")
|
||||
user = db.get(User, user_id)
|
||||
if user is None or not user.is_active or user.is_suspended:
|
||||
# Suspended/deactivated mid-session → end it so the block takes effect at once.
|
||||
_end_account_session(request, user_id, is_default)
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
raise refused(401, "not_authenticated")
|
||||
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
|
||||
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
|
||||
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
|
||||
epochs = request.session.get("epochs") or {}
|
||||
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
||||
_end_account_session(request, user_id, is_default)
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
raise refused(401, "not_authenticated")
|
||||
# Always keep the session default in the switchable list — covers sessions created before
|
||||
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
|
||||
default_id = request.session.get("user_id")
|
||||
@@ -418,10 +419,7 @@ def require_write_scope(user: User = Depends(require_human)) -> User:
|
||||
checks that had drifted into three different 403 messages across channels/playlists. Layers on
|
||||
`require_human` because writing to YouTube needs a real, non-demo identity."""
|
||||
if not has_write_scope(user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Enable YouTube editing in Settings first.",
|
||||
)
|
||||
raise refused(403, "youtube_editing_off")
|
||||
return user
|
||||
|
||||
|
||||
@@ -429,7 +427,7 @@ def admin_user(user: User = Depends(current_user)) -> User:
|
||||
"""Dependency: require the signed-in user to be an admin. Single source of truth for the
|
||||
admin gate — every admin route/action depends on this rather than re-checking the role."""
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
raise refused(403, "admin_only")
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -315,6 +315,10 @@ class Settings(BaseSettings):
|
||||
# re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve
|
||||
# and remux (stream-copy) are not capped by this.
|
||||
plex_max_transcodes: int = 4
|
||||
# Max concurrent playback sessions of ANY kind — remux (stream-copy) included. Cheap on the CPU but
|
||||
# each one is a whole segmented copy of the film on the scratch volume, written faster than
|
||||
# realtime. One account holds at most two regardless (plex/stream.py `_MAX_SESSIONS_PER_USER`).
|
||||
plex_max_sessions: int = 8
|
||||
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
|
||||
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
|
||||
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
|
||||
|
||||
@@ -81,7 +81,11 @@ def pick(
|
||||
return None
|
||||
|
||||
def _comfort() -> dict | None:
|
||||
for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf):
|
||||
if shelf is None:
|
||||
own = pins.shelf(db, user, include_plex=rails.plex_available(db))
|
||||
else:
|
||||
own = shelf
|
||||
for card in pins.playables(own):
|
||||
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
|
||||
return card
|
||||
return None
|
||||
|
||||
+107
-15
@@ -8,14 +8,16 @@ second one.
|
||||
|
||||
Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile —
|
||||
a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first
|
||||
unwatched item. It costs a query per non-leaf pin, which is affordable precisely because this shelf is
|
||||
short and hand-curated, and it buys the property the whole surface exists for: one click, no
|
||||
intermediate page, no second decision.
|
||||
unwatched item. That buys the property the whole surface exists for: one click, no intermediate page,
|
||||
no second decision. Channels and playlists are resolved for the whole shelf at once (`_Batch`), so the
|
||||
cost does not grow a query or two per pin.
|
||||
|
||||
Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not
|
||||
yours" and the route turns that into its 404 — core never imports routes.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -73,6 +75,44 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
|
||||
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
|
||||
|
||||
|
||||
def reorder(db: Session, user: User, ids: list[int]) -> None:
|
||||
"""Put the listed pins in the listed order, WITHIN the slots they already hold.
|
||||
|
||||
The client sends the order it can SEE, which is not always the whole shelf: with Plex off the Home
|
||||
rail hides the Plex pins, and a stale tab may not know a pin added elsewhere. Numbering only the
|
||||
listed ids 0..n-1 collided with every pin left out — two tiles on one position, and the hidden
|
||||
ones jumped about when they came back. So the pins left out keep their slots, and the listed ones
|
||||
are dealt into the slots the listed set occupies. Ids that are not this user's, or repeated, are
|
||||
ignored rather than failing a stale replay wholesale. Flushes; the route commits."""
|
||||
# Locked: the rows it leaves alone are part of the answer too. Two overlapping reorders each
|
||||
# skipped the rows that already matched in their own snapshot and interleaved into two pins on
|
||||
# one slot; the second now waits and deals from the first one's result. Locked in id order and
|
||||
# sorted HERE: with `ORDER BY position … FOR UPDATE` Postgres sorts before it locks, so the one
|
||||
# that waited got the fresh rows in its stale order and dealt the pins it left out back to their
|
||||
# old slots — and one global lock order means two reorders cannot deadlock.
|
||||
locked = db.execute(
|
||||
select(ComfortPin)
|
||||
.where(ComfortPin.user_id == user.id)
|
||||
.order_by(ComfortPin.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
).scalars()
|
||||
current = sorted(locked, key=lambda p: (p.position, p.id))
|
||||
owned = {p.id for p in current}
|
||||
listed: list[int] = []
|
||||
for i in ids:
|
||||
if i in owned and i not in listed:
|
||||
listed.append(i)
|
||||
chosen = set(listed)
|
||||
queue = iter(listed)
|
||||
order = [next(queue) if p.id in chosen else p.id for p in current]
|
||||
by_id = {p.id: p for p in current}
|
||||
for position, pin_id in enumerate(order):
|
||||
if by_id[pin_id].position != position:
|
||||
by_id[pin_id].position = position
|
||||
db.flush()
|
||||
|
||||
|
||||
def list_pins(db: Session, user: User) -> list[ComfortPin]:
|
||||
return list(
|
||||
db.execute(
|
||||
@@ -103,7 +143,34 @@ def next_position(db: Session, user: User) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
|
||||
@dataclass
|
||||
class _Batch:
|
||||
"""What the shelf resolves for every pinned channel and playlist in one go."""
|
||||
|
||||
channels: dict[str, Channel] = field(default_factory=dict)
|
||||
channel_summary: dict[str, tuple[int, dict | None]] = field(default_factory=dict)
|
||||
playlists: dict[int, Playlist] = field(default_factory=dict)
|
||||
playlist_summary: dict[int, tuple[int, dict | None]] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def load(cls, db: Session, user: User, pins: list[ComfortPin]) -> "_Batch":
|
||||
ch_ids = [p.channel_id for p in pins if p.channel_id is not None]
|
||||
pl_ids = [p.playlist_id for p in pins if p.playlist_id is not None]
|
||||
b = cls()
|
||||
if ch_ids:
|
||||
b.channels = {
|
||||
c.id: c for c in db.scalars(select(Channel).where(Channel.id.in_(ch_ids)))
|
||||
}
|
||||
b.channel_summary = queries.channels_summary(db, user, ch_ids)
|
||||
if pl_ids:
|
||||
b.playlists = {
|
||||
p.id: p for p in db.scalars(select(Playlist).where(Playlist.id.in_(pl_ids)))
|
||||
}
|
||||
b.playlist_summary = queries.playlists_summary(db, user, pl_ids)
|
||||
return b
|
||||
|
||||
|
||||
def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict | None:
|
||||
"""One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK
|
||||
cascade removes the pin itself, so this is only the window between a delete and a refetch)."""
|
||||
card: dict | None = None
|
||||
@@ -117,11 +184,11 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
|
||||
nxt = dict(card) # a video plays itself
|
||||
|
||||
elif pin.channel_id is not None:
|
||||
ch = db.get(Channel, pin.channel_id)
|
||||
ch = batch.channels.get(pin.channel_id)
|
||||
if ch is None:
|
||||
return None
|
||||
card = cards.from_channel(ch, queries.channel_unwatched_count(db, user, ch.id))
|
||||
row = queries.channel_next_video(db, user, ch.id)
|
||||
count, row = batch.channel_summary.get(ch.id, (0, None))
|
||||
card = cards.from_channel(ch, count)
|
||||
nxt = cards.from_video(row) if row else None
|
||||
|
||||
elif pin.plex_item_id is not None:
|
||||
@@ -142,10 +209,10 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
|
||||
nxt = cards.from_plex(ep) if ep else None
|
||||
|
||||
elif pin.playlist_id is not None:
|
||||
pl = db.get(Playlist, pin.playlist_id)
|
||||
pl = batch.playlists.get(pin.playlist_id)
|
||||
if pl is None:
|
||||
return None
|
||||
count, row = queries.playlist_summary(db, user, pl.id)
|
||||
count, row = batch.playlist_summary.get(pl.id, (0, None))
|
||||
card = cards.from_playlist(pl, count, row.get("thumbnail_url") if row else None)
|
||||
nxt = cards.from_video(row) if row else None
|
||||
|
||||
@@ -159,11 +226,24 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
|
||||
return card
|
||||
|
||||
|
||||
def shelf(db: Session, user: User) -> list[dict]:
|
||||
"""The whole shelf, in the user's own order."""
|
||||
_PLEX_COLUMNS = ("plex_item_id", "plex_show_id")
|
||||
|
||||
|
||||
def shelf(db: Session, user: User, *, include_plex: bool = True) -> list[dict]:
|
||||
"""The whole shelf, in the user's own order.
|
||||
|
||||
`include_plex=False` is the Home surface with the deployment's Plex toggle off (the owner's call,
|
||||
2026-09-19): the rails beside it go YouTube-only, and a Plex tile there would offer a Play that
|
||||
lands on a gated module. The pins themselves are kept — the pin menus still read the full shelf
|
||||
(`GET /pins`), and switching Plex back on brings the tiles back in their old slots
|
||||
(`reorder` keeps a hidden pin's place)."""
|
||||
out = []
|
||||
for pin in list_pins(db, user):
|
||||
card = _shelf_card(db, user, pin)
|
||||
pins = list_pins(db, user)
|
||||
if not include_plex:
|
||||
pins = [p for p in pins if all(getattr(p, c) is None for c in _PLEX_COLUMNS)]
|
||||
batch = _Batch.load(db, user, pins)
|
||||
for pin in pins:
|
||||
card = _shelf_card(db, user, pin, batch)
|
||||
if card is not None:
|
||||
out.append(card)
|
||||
return out
|
||||
@@ -171,5 +251,17 @@ def shelf(db: Session, user: User) -> list[dict]:
|
||||
|
||||
def playables(shelf_cards: list[dict]) -> list[dict]:
|
||||
"""The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel
|
||||
with nothing unwatched left, or an empty playlist, simply is not an answer right now."""
|
||||
return [c["next"] for c in shelf_cards if c.get("next")]
|
||||
with nothing unwatched left, or an empty playlist, simply is not an answer right now.
|
||||
|
||||
Each carries `pinned_from`: the pin it was resolved from. A show's next EPISODE is not itself
|
||||
pinned, so without it the hero's menu looked the episode up, missed, and offered "Pin" under a
|
||||
kicker that says "From your shelf" — and taking it inserted a second pin. With it, the hero's
|
||||
pin row acts on the shelf entry the card came from (the owner's call, 2026-09-19: "Unpin")."""
|
||||
return [
|
||||
{
|
||||
**c["next"],
|
||||
"pinned_from": {k: c[k] for k in ("source", "kind", "id", "title")},
|
||||
}
|
||||
for c in shelf_cards
|
||||
if c.get("next")
|
||||
]
|
||||
|
||||
@@ -43,8 +43,9 @@ from app.plex import rollup
|
||||
from app.plex.cards import leaf_card, movie_card, show_card
|
||||
from app.plex.query import apply_show_state_filter, content_libs
|
||||
|
||||
# How many rows a single rail may carry. Matches `routes/plex/discover.py`'s RAIL_LIMIT on purpose —
|
||||
# the two surfaces should feel like the same app, and a rail is a rail.
|
||||
# How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
|
||||
# (`routes/plex/discover.py` imports it): the two surfaces should feel like the same app, and a rail
|
||||
# is a rail. One definition, because a comment was all that held two copies in step.
|
||||
RAIL_LIMIT = 20
|
||||
|
||||
# The feed parameters a Home rail runs with. Spelled out rather than borrowed from the route's
|
||||
@@ -129,18 +130,6 @@ def yt_surprise(db: Session, user: User, budget: str, seed: int, limit: int) ->
|
||||
return [r for r in out if not r["position_seconds"]][:limit]
|
||||
|
||||
|
||||
def channel_next_video(db: Session, user: User, channel_id: str) -> dict | None:
|
||||
"""The newest unwatched video of a pinned channel — what "put on that channel" resolves to."""
|
||||
# `library_source="all"`: a pinned channel means the whole channel, including videos that only
|
||||
# ever arrived through a search. The feed's "organic" default is a feed-scoping choice, not a
|
||||
# statement about what belongs to a channel.
|
||||
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all")
|
||||
query, rank_expr = filtered_query(db, user, **params)
|
||||
keys = sort_keys("newest", 0, rank_expr)
|
||||
row = db.execute(query.order_by(*order_by(keys)).limit(1)).first()
|
||||
return serialize(row) if row is not None else None
|
||||
|
||||
|
||||
def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
|
||||
"""One video as a feed row, with this user's watch state.
|
||||
|
||||
@@ -158,31 +147,55 @@ def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
|
||||
return serialize(row) if row is not None else None
|
||||
|
||||
|
||||
def channel_unwatched_count(db: Session, user: User, channel_id: str) -> int:
|
||||
"""How many unwatched videos a pinned channel is holding — the tile's only honest promise, since a
|
||||
channel has no runtime of its own."""
|
||||
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all")
|
||||
query, _ = filtered_query(db, user, **params)
|
||||
return int(db.execute(select(func.count()).select_from(query.subquery())).scalar() or 0)
|
||||
def channels_summary(
|
||||
db: Session, user: User, channel_ids: list[str]
|
||||
) -> dict[str, tuple[int, dict | None]]:
|
||||
"""For each pinned channel: (unwatched count, its newest unwatched video) — the tile's only honest
|
||||
promise, since a channel has no runtime of its own, and what "put on that channel" resolves to.
|
||||
|
||||
ONE query for the whole shelf, not two per channel: the count and the first row per channel are
|
||||
window functions over the same filtered set. `library_source="all"`: a pinned channel means the
|
||||
whole channel, including videos that only ever arrived through a search — the feed's "organic"
|
||||
default is a feed-scoping choice, not a statement about what belongs to a channel."""
|
||||
if not channel_ids:
|
||||
return {}
|
||||
params = dict(_FEED_BASE, channel_ids=list(channel_ids), scope="all", library_source="all")
|
||||
query, rank_expr = filtered_query(db, user, **params)
|
||||
newest = order_by(sort_keys("newest", 0, rank_expr))
|
||||
per_channel = {"partition_by": Video.channel_id}
|
||||
sub = query.add_columns(
|
||||
func.row_number().over(**per_channel, order_by=newest).label("_rn"),
|
||||
func.count().over(**per_channel).label("_total"),
|
||||
).subquery()
|
||||
rows = db.execute(select(sub).where(sub.c._rn == 1)).all()
|
||||
return {r.channel_id: (int(r._total), serialize(r)) for r in rows}
|
||||
|
||||
|
||||
def playlist_summary(db: Session, user: User, playlist_id: int) -> tuple[int, dict | None]:
|
||||
"""(item count, the row to play next) for a pinned playlist — its first not-yet-watched item, or
|
||||
its first item once the whole list is watched. The same row doubles as the tile's artwork, so a
|
||||
shelf of playlists is scannable instead of a row of blank boxes."""
|
||||
def playlists_summary(
|
||||
db: Session, user: User, playlist_ids: list[int]
|
||||
) -> dict[int, tuple[int, dict | None]]:
|
||||
"""For each pinned playlist: (item count, the row to play next) — its first not-yet-watched item,
|
||||
or its first item once the whole list is watched. The same row doubles as the tile's artwork, so
|
||||
a shelf of playlists is scannable instead of a row of blank boxes. One query for the whole shelf."""
|
||||
if not playlist_ids:
|
||||
return {}
|
||||
st = aliased(VideoState)
|
||||
rows = db.execute(
|
||||
select(*feed_columns(user, st), PlaylistItem.position)
|
||||
select(*feed_columns(user, st), PlaylistItem.playlist_id.label("_playlist"))
|
||||
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
||||
.where(PlaylistItem.playlist_id == playlist_id)
|
||||
.order_by(PlaylistItem.position.asc())
|
||||
.where(PlaylistItem.playlist_id.in_(playlist_ids))
|
||||
.order_by(PlaylistItem.playlist_id, PlaylistItem.position.asc())
|
||||
).all()
|
||||
if not rows:
|
||||
return 0, None
|
||||
nxt = next((r for r in rows if (r.status or "new") not in ("watched", "hidden")), rows[0])
|
||||
return len(rows), serialize(nxt)
|
||||
grouped: dict[int, list] = {}
|
||||
for r in rows:
|
||||
grouped.setdefault(r._playlist, []).append(r)
|
||||
out: dict[int, tuple[int, dict | None]] = {}
|
||||
for pid, items in grouped.items():
|
||||
nxt = next((r for r in items if (r.status or "new") not in ("watched", "hidden")), items[0])
|
||||
out[pid] = (len(items), serialize(nxt))
|
||||
return out
|
||||
|
||||
|
||||
# ── Plex ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,7 +43,9 @@ def rollup(cards: list[dict]) -> dict:
|
||||
]
|
||||
resume = None
|
||||
if inprog:
|
||||
resume = inprog[-1] # continue the latest-started episode
|
||||
# The LAST in-progress episode in broadcast order — no timestamp is consulted. Home's
|
||||
# `discover.queries.next_episode` picks the same one, so both Resume buttons agree.
|
||||
resume = inprog[-1]
|
||||
else:
|
||||
resume = next((c for c in cards if c.get("status") != "watched"), None)
|
||||
return {
|
||||
|
||||
+80
-42
@@ -60,8 +60,10 @@ _WARMUP_SEG_TIMEOUT_S = 8.0
|
||||
# its index for that one. See `segment_name` for why this is a constant and not a comment.
|
||||
_VIDEO_VARIANT = "0"
|
||||
_SESSION_IDLE_S = 600 # reap a session with no access for this long
|
||||
# A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session);
|
||||
# before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names
|
||||
# A session directory is named `{rating_key}_u{user_id}_{int(start_s)}_{tag}_{aoff:+.2f}` (see
|
||||
# start_session); before per-viewer sessions it was `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}`,
|
||||
# and before multi-audio just `{rating_key}_{int(start_s)}` — the older shapes stay sweepable, since a
|
||||
# restart leaves them behind like any other. `sweep_orphans` deletes ONLY names
|
||||
# matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may
|
||||
# legitimately point at a shared scratch path we do not own, and a blanket "delete every directory
|
||||
# under the root" would take the neighbours with it.
|
||||
@@ -72,7 +74,9 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
|
||||
# non-numeric rating_key would merely leave its directory unswept, which is the safe direction to
|
||||
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
|
||||
# and someone else's data.
|
||||
_SESSION_DIR_RE = re.compile(r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$")
|
||||
_SESSION_DIR_RE = re.compile(
|
||||
r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$|^\d+_u\d+_\d+_[^_]+_[+-]\d+\.\d{2}$"
|
||||
)
|
||||
# When this process started. `sweep_orphans` only deletes directories older than this: everything
|
||||
# newer belongs to a session THIS run created, and since `start_session` reuses a directory path
|
||||
# for the same (rating_key, offset, tag), "stale name" is not the same as "stale directory".
|
||||
@@ -88,7 +92,16 @@ _PROCESS_START = time.time()
|
||||
_MTIME_GRACE_S = 2.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
_sessions: dict[str, "HlsSession"] = {}
|
||||
# Keyed by VIEWER and item, (user_id, rating_key). Keyed by the item alone, a second account opening
|
||||
# the same film `_kill`ed the first one's session: the displaced viewer got a 404, reclaimed at its
|
||||
# playhead, and so evicted the other in turn — two people could not watch one film at once. (One
|
||||
# account on two devices still shares a session; that case ends in the player's bounded reclaim.)
|
||||
_sessions: dict[tuple[int, str], "HlsSession"] = {}
|
||||
# How many live sessions one account may hold at once (two devices, or a next item starting while the
|
||||
# last one winds down). A new one past it replaces that account's least recently used — whose waiting
|
||||
# session request then returns at once, since `wait_for` watches the killed ffmpeg — so one account
|
||||
# can neither pile up film-sized scratch copies nor park more than this many request workers.
|
||||
_MAX_SESSIONS_PER_USER = 2
|
||||
|
||||
|
||||
class HlsSession:
|
||||
@@ -102,16 +115,17 @@ class HlsSession:
|
||||
transcode: bool = False,
|
||||
hw: bool = False,
|
||||
tonemap: bool = False,
|
||||
user_id: int = 0,
|
||||
):
|
||||
# Unguessable per-session id, carried in the URLs the client is handed. Sessions are keyed
|
||||
# by rating_key alone, so a seek-restart or a SECOND viewer of the same item replaces the
|
||||
# registered one — and the old viewer's next request for `seg_12.ts`, resolved by rating_key,
|
||||
# was answered 200 with the NEW session's twelfth segment, produced from a different offset
|
||||
# Unguessable per-session id, carried in the URLs the client is handed. A seek-restart
|
||||
# replaces the viewer's registered session for the item — and the old player's next request
|
||||
# for `seg_12.ts`, resolved by (viewer, rating_key) alone, was answered 200 with the NEW session's twelfth segment, produced from a different offset
|
||||
# under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it
|
||||
# (deep-review round 5). A request naming a session that is no longer registered is a 404,
|
||||
# which the player recovers from by restarting at the playhead.
|
||||
self.id = secrets.token_urlsafe(9)
|
||||
self.key = key
|
||||
self.key = key # the item's rating_key (what the logs name)
|
||||
self.user_id = user_id # whose session it is: the registry key is (user_id, key)
|
||||
self.dir = directory
|
||||
self.proc = proc
|
||||
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
|
||||
@@ -637,29 +651,47 @@ def _kill(s: HlsSession) -> None:
|
||||
shutil.rmtree(s.dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _enforce_cap(cap: int) -> None:
|
||||
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux
|
||||
# (stream-copy) and direct-serve are cheap and deliberately uncapped (see config.py:
|
||||
# plex_max_transcodes, and the user-facing config hint). Drop the least-recently-accessed
|
||||
# transcode sessions over the cap, leaving any concurrent remux streams untouched.
|
||||
tx = [(key, s) for key, s in _sessions.items() if s.transcode]
|
||||
if len(tx) <= cap:
|
||||
return
|
||||
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]:
|
||||
# Somebody's playback stops here. The route turns the missing session into a retryable
|
||||
# 404 and the player restarts at the playhead, so from the server nothing failed — and
|
||||
# "my film keeps rebuffering" has no other explanation anywhere.
|
||||
log.info(
|
||||
"a transcode session was dropped to stay under the cap",
|
||||
extra=logs.event("plex.stream.session_culled", key=key, cap=cap, sessions=len(tx)),
|
||||
)
|
||||
_kill(s)
|
||||
_sessions.pop(key, None)
|
||||
def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
|
||||
"""Caller holds _lock. Three bounds, applied after a session is registered, each dropping the
|
||||
least recently accessed sessions over it:
|
||||
|
||||
* this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
|
||||
box evicts itself before anyone else;
|
||||
* TRANSCODE sessions (plex_max_transcodes): the CPU/GPU-heavy ones;
|
||||
* EVERY session (plex_max_sessions). A remux is cheap on the CPU but not on the disk: each one is a
|
||||
whole segmented copy of the film under `_HLS_ROOT`, written faster than realtime. Counted only
|
||||
by the transcode cap, N distinct items were N ffmpegs and N film-sized scratch directories.
|
||||
|
||||
Somebody's playback stops at each drop. The route turns the missing session into a retryable 404
|
||||
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
|
||||
rebuffering" has no other explanation anywhere, hence the line."""
|
||||
|
||||
def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
|
||||
if len(pool) <= cap:
|
||||
return
|
||||
for reg, sess in sorted(pool, key=lambda kv: kv[1].last_access)[: len(pool) - cap]:
|
||||
log.info(
|
||||
"a session was dropped to stay under a cap",
|
||||
extra=logs.event(
|
||||
"plex.stream.session_culled",
|
||||
key=sess.key,
|
||||
bound=bound,
|
||||
cap=cap,
|
||||
sessions=len(pool),
|
||||
),
|
||||
)
|
||||
_kill(sess)
|
||||
_sessions.pop(reg, None)
|
||||
|
||||
cull([kv for kv in _sessions.items() if kv[0][0] == user_id], _MAX_SESSIONS_PER_USER, "user")
|
||||
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
|
||||
cull(list(_sessions.items()), max_sessions, "total")
|
||||
|
||||
|
||||
def start_session(
|
||||
db: DbSession,
|
||||
item: PlexItem,
|
||||
user_id: int,
|
||||
start_s: float,
|
||||
audio_ord: int | None = None,
|
||||
aoff: float = 0.0,
|
||||
@@ -679,6 +711,7 @@ def start_session(
|
||||
# session respawn (the HW->SW fallback) must not pick up a mid-playback config change.
|
||||
loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm")
|
||||
key = item.rating_key
|
||||
reg = (user_id, key) # the registry key: one session per viewer per item
|
||||
start_s = max(0.0, float(start_s))
|
||||
audio_langs = _probe_audio_langs(src) if multi else []
|
||||
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
||||
@@ -691,7 +724,7 @@ def start_session(
|
||||
# the tag with a DASH — `_SESSION_DIR_RE` treats the tag as one underscore-free field, and that
|
||||
# regex is the only thing keeping the orphan sweep off a shared scratch path (see its comment).
|
||||
tag = ("multi" if is_multi else str(audio_ord)) + ("-ln" if loudnorm else "")
|
||||
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
||||
directory = _HLS_ROOT / f"{key}_u{user_id}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
||||
|
||||
def _seg(i: int) -> Path:
|
||||
return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
|
||||
@@ -717,9 +750,9 @@ def start_session(
|
||||
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
||||
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
||||
with _lock:
|
||||
if replace_only is not None and _sessions.get(key) is not replace_only:
|
||||
if replace_only is not None and _sessions.get(reg) is not replace_only:
|
||||
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
||||
old = _sessions.pop(key, None)
|
||||
old = _sessions.pop(reg, None)
|
||||
if old is not None:
|
||||
_kill(old)
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
@@ -753,8 +786,9 @@ def start_session(
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
user_id=user_id,
|
||||
)
|
||||
_sessions[key] = s
|
||||
_sessions[reg] = s
|
||||
# The one line that says which pipeline a session actually got. Everything downstream
|
||||
# — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
|
||||
# now it was only inferable from the ffmpeg command nobody keeps.
|
||||
@@ -770,9 +804,13 @@ def start_session(
|
||||
start_s=round(start_s, 1),
|
||||
),
|
||||
)
|
||||
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
||||
# at least 1 so playback can't be capped to zero.
|
||||
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
||||
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
||||
# capped to zero.
|
||||
_enforce_caps(
|
||||
user_id,
|
||||
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
||||
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
||||
)
|
||||
return s
|
||||
|
||||
s = _spawn(*_select_encode(db, item, transcode, src))
|
||||
@@ -798,7 +836,7 @@ def start_session(
|
||||
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
|
||||
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
|
||||
# "Not available yet") the client re-requests.
|
||||
return current_session(key) or s
|
||||
return current_session(user_id, key) or s
|
||||
s = respawned
|
||||
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||
if ready:
|
||||
@@ -829,9 +867,9 @@ def start_session(
|
||||
return s
|
||||
|
||||
|
||||
def current_session(key: str) -> HlsSession | None:
|
||||
def current_session(user_id: int, key: str) -> HlsSession | None:
|
||||
with _lock:
|
||||
s = _sessions.get(key)
|
||||
s = _sessions.get((user_id, key))
|
||||
if s is not None:
|
||||
s.last_access = time.time()
|
||||
return s
|
||||
@@ -1060,21 +1098,21 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
|
||||
now = time.time()
|
||||
dropped = 0
|
||||
with _lock:
|
||||
for key, s in list(_sessions.items()):
|
||||
for reg, s in list(_sessions.items()):
|
||||
done = s.proc.poll() is not None
|
||||
idle = now - s.last_access
|
||||
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
||||
_kill(s)
|
||||
_sessions.pop(key, None)
|
||||
_sessions.pop(reg, None)
|
||||
dropped += 1
|
||||
return dropped
|
||||
|
||||
|
||||
def touch_session(key: str) -> None:
|
||||
"""Say a viewer is still there for `key`, without fetching anything. `last_access` used to move
|
||||
def touch_session(user_id: int, key: str) -> None:
|
||||
"""Say this viewer is still there for `key`, without fetching anything. `last_access` used to move
|
||||
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
|
||||
viewer's session alive was to keep EVERY abandoned one just as long."""
|
||||
with _lock:
|
||||
s = _sessions.get(key)
|
||||
s = _sessions.get((user_id, key))
|
||||
if s is not None:
|
||||
s.last_access = time.time()
|
||||
|
||||
@@ -461,11 +461,18 @@ _FULL_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="plex-subs-ful
|
||||
# which time something has finished.
|
||||
_MAX_WINDOWS_INFLIGHT = 8
|
||||
_MAX_FULL_INFLIGHT = 6
|
||||
# …of which one account may hold this many. The full-pass slots are minutes each, and a hostile (or
|
||||
# merely curious) account opening subtitles on film after film could otherwise hold all of them while
|
||||
# everyone else's tracks never completed. A viewer over their share still gets windows — the part
|
||||
# that makes a track watchable now — just not another whole-file read until one of theirs ends.
|
||||
_MAX_FULL_PER_USER = 3
|
||||
_MAX_TRACK_WINDOWS = 2
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
# Queued or running jobs → the absolute span a window job reads (None for a full pass).
|
||||
_INFLIGHT: dict[tuple, tuple[float, float] | None] = {}
|
||||
# rating_key → the account whose poll queued that item's full pass (see _MAX_FULL_PER_USER).
|
||||
_FULL_OWNERS: dict[str, int] = {}
|
||||
# Track-level failures: a track that cannot be served at all (no item, a bitmap track, Plex down…).
|
||||
_FAILURES: dict[str, tuple[str, float]] = {}
|
||||
# rating_key → when its full pass last failed (see FULL_RETRY_S).
|
||||
@@ -515,6 +522,7 @@ def reset_state() -> None:
|
||||
global _STOPPING
|
||||
with _LOCK:
|
||||
_INFLIGHT.clear()
|
||||
_FULL_OWNERS.clear()
|
||||
_FAILURES.clear()
|
||||
_FULL_FAILED.clear()
|
||||
_WIN_FAILED.clear()
|
||||
@@ -598,7 +606,7 @@ def _window_failed(key: str, at: float) -> bool:
|
||||
return any(a <= at <= b for a, b, _ in _live_window_failures(key))
|
||||
|
||||
|
||||
def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict:
|
||||
def ensure(rating_key: str, sub_ord: int, at: float = 0.0, viewer: int = 0) -> dict:
|
||||
"""State of one subtitle track, scheduling whatever extraction is still missing. NEVER blocks.
|
||||
|
||||
``ready`` = fully extracted · ``partial`` = usable, covers ``at``, still growing · ``preparing``
|
||||
@@ -641,8 +649,23 @@ def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict:
|
||||
if edge - at <= LOOKAHEAD_S and edge < _FULL_END_S:
|
||||
probe = edge + 1.0
|
||||
want_window = not _window_failed(key, probe)
|
||||
_schedule(rating_key, sub_ord, probe, want_window=want_window, want_full=not full_failed)
|
||||
return {"state": "partial" if covered else "preparing", "code": None, "gen": gen}
|
||||
_schedule(
|
||||
rating_key,
|
||||
sub_ord,
|
||||
probe,
|
||||
want_window=want_window,
|
||||
want_full=not full_failed,
|
||||
viewer=viewer,
|
||||
)
|
||||
# `growing`: whether the track will fill in on its own. False once the full pass has failed and
|
||||
# is backed off — a partial track then stays partial until a window is asked for, so the player
|
||||
# may poll slowly (only to read ahead of an edge) instead of every two seconds for the whole film.
|
||||
return {
|
||||
"state": "partial" if covered else "preparing",
|
||||
"code": None,
|
||||
"gen": gen,
|
||||
"growing": not full_failed,
|
||||
}
|
||||
|
||||
|
||||
def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]:
|
||||
@@ -659,7 +682,12 @@ def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]
|
||||
|
||||
|
||||
def _schedule(
|
||||
rating_key: str, sub_ord: int, at: float, want_window: bool, want_full: bool = True
|
||||
rating_key: str,
|
||||
sub_ord: int,
|
||||
at: float,
|
||||
want_window: bool,
|
||||
want_full: bool = True,
|
||||
viewer: int = 0,
|
||||
) -> None:
|
||||
"""Kick off the jobs this track still needs: the window that makes it watchable now, and the one
|
||||
full pass that makes it complete. A 1 s status poll (or a second viewer) joins a job already
|
||||
@@ -692,8 +720,15 @@ def _schedule(
|
||||
ident = ("win", rating_key, sub_ord, w_start)
|
||||
_INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S)
|
||||
jobs.append((ident, w_start))
|
||||
if want_full and n_full < _MAX_FULL_INFLIGHT and ("full", rating_key) not in _INFLIGHT:
|
||||
mine = sum(1 for owner in _FULL_OWNERS.values() if owner == viewer)
|
||||
if (
|
||||
want_full
|
||||
and n_full < _MAX_FULL_INFLIGHT
|
||||
and ("full", rating_key) not in _INFLIGHT
|
||||
and mine < _MAX_FULL_PER_USER
|
||||
):
|
||||
_INFLIGHT[("full", rating_key)] = None
|
||||
_FULL_OWNERS[rating_key] = viewer
|
||||
jobs.append((("full", rating_key), None))
|
||||
for ident, window_start in jobs:
|
||||
pool = _FULL_POOL if window_start is None else _WIN_POOL
|
||||
@@ -757,6 +792,8 @@ def _run(
|
||||
finally:
|
||||
with _LOCK:
|
||||
_INFLIGHT.pop(ident, None)
|
||||
if ident[0] == "full":
|
||||
_FULL_OWNERS.pop(rating_key, None)
|
||||
|
||||
|
||||
def _pass_failed(
|
||||
@@ -822,10 +859,13 @@ def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | No
|
||||
for i, s in enumerate(subs)
|
||||
if s.get("index") is not None and not _is_bitmap(s)
|
||||
}
|
||||
start = window_start or 0.0
|
||||
# A full pass starts where every track's leading coverage ends: a pass that ran out of time
|
||||
# (`EXTRACT_TIMEOUT_S`) kept what it reached, and restarting from 0 each `FULL_RETRY_S` meant a file
|
||||
# too big to read in one go never completed. Windows handle an offset start already; so does this.
|
||||
start = window_start if window_start is not None else _full_pass_start(rating_key, targets)
|
||||
length = (WINDOW_LEN_S + WINDOW_PRE_S) if window_start is not None else None
|
||||
end = _FULL_END_S if length is None else start + length
|
||||
texts, clean = _ffmpeg_extract(str(src), sorted(targets), window_start, length)
|
||||
texts, clean = _ffmpeg_extract(str(src), sorted(targets), start or None, length)
|
||||
for emb_ord, vtt in texts.items():
|
||||
key = cache_key(rating_key, targets[emb_ord])
|
||||
# A pass that exited non-zero holds only what ffmpeg reached before it died. Claim no more
|
||||
@@ -846,6 +886,24 @@ def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | No
|
||||
_pass_failed(rating_key, [key], window_start, (covered_end, end))
|
||||
|
||||
|
||||
def _full_pass_start(rating_key: str, targets: dict[int, int]) -> float:
|
||||
"""Where the full pass may begin: the end of the span from 0 that EVERY target track already has
|
||||
(one read feeds them all, so the least-covered track decides), backed off by `WINDOW_PRE_S` so a
|
||||
cue running across the join is read whole — `merge_vtt` folds the overlap. 0 when any track has
|
||||
nothing from the start."""
|
||||
ends = []
|
||||
for ord_ in targets.values():
|
||||
key = cache_key(rating_key, ord_)
|
||||
ranges, _ = read_meta(key) if _exists(_vtt_path(key)) else ([], False)
|
||||
lead = [b for a, b in ranges if a <= 0.0]
|
||||
if not lead:
|
||||
return 0.0
|
||||
ends.append(max(lead))
|
||||
if not ends:
|
||||
return 0.0
|
||||
return max(0.0, float(f"{min(ends) - WINDOW_PRE_S:.3f}"))
|
||||
|
||||
|
||||
def _sidecar_vtt(db: Session, stream: dict) -> str:
|
||||
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
|
||||
with PlexClient(db) as plex:
|
||||
@@ -884,9 +942,10 @@ def _ffmpeg_extract(
|
||||
cmd += ["-map", f"0:s:{emb_ord}"]
|
||||
if length is not None:
|
||||
cmd += ["-t", f"{length:.3f}"]
|
||||
if start:
|
||||
# The input seek lands on the keyframe at or BEFORE `start`, so a cue already running
|
||||
# at `start` comes out at a negative time — and by default the muxer then shifts the
|
||||
# WHOLE output so that cue sits at 0: every cue of the window late by the distance to
|
||||
# WHOLE output so that cue sits at 0: every cue of the read late by the distance to
|
||||
# that keyframe (1–5 s measured on a real library file, a whole GOP on a synthetic
|
||||
# one), and doubled for good once the full pass merged in beside it. Kept negative, the
|
||||
# times are exact on the zero-based clock (`_vtt_ts_to_s` reads the sign).
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""A request the app refuses, said in a way the UI can put in the reader's language.
|
||||
|
||||
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
|
||||
translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI
|
||||
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
|
||||
whatever sentence the route happened to hand-write. `refused` is the generic family
|
||||
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
|
||||
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
|
||||
|
||||
A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail,
|
||||
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both
|
||||
languages — so the rule is enforced, not described.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def refused(status: int, reason: str, **params: object) -> HTTPException:
|
||||
"""The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in
|
||||
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see."""
|
||||
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params})
|
||||
|
||||
|
||||
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the frontend's
|
||||
# contract test can read every reason this can produce.
|
||||
_SETTING_REASONS = {
|
||||
"not_a_number": "setting_not_a_number",
|
||||
"too_low": "setting_too_low",
|
||||
"too_high": "setting_too_high",
|
||||
"not_a_choice": "setting_not_a_choice",
|
||||
"no_encryption_key": "no_encryption_key",
|
||||
}
|
||||
|
||||
|
||||
def setting_refused(exc) -> HTTPException:
|
||||
"""A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`)."""
|
||||
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import Base
|
||||
from app.models import User
|
||||
from app.youtube.client import YouTubeError, refusal_reason
|
||||
|
||||
M = TypeVar("M", bound=Base)
|
||||
|
||||
@@ -45,9 +46,20 @@ def owned_or_404(
|
||||
) -> M:
|
||||
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
|
||||
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
|
||||
Generalizes the near-identical per-route ownership helpers (e.g. plex's
|
||||
`_own_playlist_or_404`, the saved-views owner check)."""
|
||||
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
|
||||
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
|
||||
obj = get_or_404(db, model, id_, what=what)
|
||||
if getattr(obj, owner_attr) != user.id:
|
||||
raise not_found(what)
|
||||
return obj
|
||||
|
||||
|
||||
def youtube_refusal(exc: Exception) -> HTTPException:
|
||||
"""The answer to an action YouTube refused: a 422 whose reason the UI translates
|
||||
(`errors.youtube.<reason>`). One definition, because the routes had grown three shapes of it —
|
||||
a bare English string quoting the upstream message, a playlist-scoped reason, and nothing at
|
||||
all (a raw 500 from the manual syncs). The upstream failure is already logged where it
|
||||
happened (`youtube.api.error`). A failure that is not the Data API's (the search scraper's) is
|
||||
YouTube being unavailable to us — the retryable answer."""
|
||||
reason = refusal_reason(exc) if isinstance(exc, YouTubeError) else "unavailable"
|
||||
return HTTPException(status_code=422, detail={"code": "youtube", "reason": reason})
|
||||
|
||||
+27
-25
@@ -39,6 +39,7 @@ from app.routes.auth import (
|
||||
send_verification_link,
|
||||
)
|
||||
from app.utils import canonical_email, iso, valid_email
|
||||
from app.refusal import refused
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
@@ -169,7 +170,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
|
||||
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
|
||||
if user is not None and not user.is_active:
|
||||
user.is_active = True
|
||||
db.commit()
|
||||
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||
|
||||
|
||||
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||
@@ -179,7 +180,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
||||
# mailing a rejection to an already-active account.
|
||||
if inv.status != "pending":
|
||||
raise HTTPException(status_code=409, detail="Invite already decided")
|
||||
raise refused(409, "invite_decided")
|
||||
inv.status = "approved" if approved else "denied"
|
||||
inv.decided_at = datetime.now(timezone.utc)
|
||||
inv.decided_by = admin.email
|
||||
@@ -191,7 +192,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||
target_id=inv.id,
|
||||
summary=inv.email,
|
||||
)
|
||||
db.commit()
|
||||
db.flush() # the handler commits, with the activation that goes with it
|
||||
return inv
|
||||
|
||||
|
||||
@@ -210,6 +211,9 @@ def approve_invite(
|
||||
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
||||
lang = mailtext.lang_for_email(db, inv.email)
|
||||
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
|
||||
# Before the response, so the mail (a background task, after it) never describes a decision
|
||||
# the database does not hold yet.
|
||||
db.commit()
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||
|
||||
|
||||
@@ -226,6 +230,7 @@ def deny_invite(
|
||||
background.add_task(
|
||||
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
|
||||
)
|
||||
db.commit()
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||
|
||||
|
||||
@@ -242,7 +247,7 @@ def add_invite(
|
||||
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
||||
email = body.email.strip().lower()
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
raise refused(400, "invalid_email")
|
||||
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
|
||||
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
|
||||
# which is how an admin ends up approving the same human twice and still seeing them bounce.
|
||||
@@ -259,11 +264,13 @@ def add_invite(
|
||||
inv.status = "approved"
|
||||
inv.decided_at = datetime.now(timezone.utc)
|
||||
inv.decided_by = admin.email
|
||||
db.flush() # a new invite's id, for the audit row — before it, the row named no target
|
||||
audit.record(
|
||||
db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email
|
||||
)
|
||||
db.commit()
|
||||
# One transaction: the whitelisting and the activation it implies land together or not at all.
|
||||
_activate_user_by_email(db, email)
|
||||
db.commit()
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
||||
|
||||
|
||||
@@ -366,10 +373,7 @@ def delete_invite(
|
||||
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
|
||||
inv = get_or_404(db, Invite, invite_id, what="invite")
|
||||
if _accounts_for(db, [inv.email]):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="That address already has an account — delete the account instead.",
|
||||
)
|
||||
raise refused(409, "address_has_account")
|
||||
still_allowed_by_env = _env_allowed(inv.email)
|
||||
audit.record(
|
||||
db,
|
||||
@@ -435,19 +439,19 @@ def set_user_role(
|
||||
account, and can't remove the last remaining admin. Emails the user when it actually changes."""
|
||||
role = body.role
|
||||
if role not in ("user", "admin"):
|
||||
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
|
||||
raise refused(400, "invalid_role")
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
||||
raise refused(400, "demo_role")
|
||||
if target.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
||||
raise refused(400, "own_role")
|
||||
if (
|
||||
target.role == "admin"
|
||||
and role == "user"
|
||||
and not target.is_suspended
|
||||
and count_admins(db, active_only=True) <= 1
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
|
||||
raise refused(400, "last_admin")
|
||||
changed = target.role != role
|
||||
if changed:
|
||||
audit.record(
|
||||
@@ -493,16 +497,16 @@ def set_user_suspended(
|
||||
suspended = body.suspended
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
|
||||
raise refused(400, "demo_suspend")
|
||||
if target.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
|
||||
raise refused(400, "own_suspend")
|
||||
if (
|
||||
suspended
|
||||
and target.role == "admin"
|
||||
and not target.is_suspended
|
||||
and count_admins(db, active_only=True) <= 1
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
|
||||
raise refused(400, "last_active_admin")
|
||||
was_suspended = target.is_suspended
|
||||
if was_suspended != suspended:
|
||||
audit.record(
|
||||
@@ -537,17 +541,15 @@ def admin_delete_user(
|
||||
not the demo account, not yourself (use Settings → Account), and not the last admin."""
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(status_code=400, detail="The demo account can't be deleted.")
|
||||
raise refused(400, "demo_delete")
|
||||
if target.id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Delete your own account from Settings → Account."
|
||||
)
|
||||
raise refused(400, "own_delete")
|
||||
if (
|
||||
target.role == "admin"
|
||||
and not target.is_suspended
|
||||
and count_admins(db, active_only=True) <= 1
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
|
||||
raise refused(400, "last_admin")
|
||||
# Record BEFORE the purge — the target row (and its email) is gone afterwards.
|
||||
audit.record(
|
||||
db,
|
||||
@@ -691,7 +693,7 @@ def activate_user(
|
||||
`_activate_user_by_email`). What was missing was any way to know they had to."""
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(status_code=400, detail="The demo account can't be activated.")
|
||||
raise refused(400, "demo_activate")
|
||||
if target.is_active:
|
||||
return _serialize_user(target) # idempotent: a double click is not an error
|
||||
if not is_allowed(db, target.email):
|
||||
@@ -788,7 +790,7 @@ def add_demo_whitelist(
|
||||
sign-in). Idempotent — re-adding an existing email just returns it."""
|
||||
email = body.email.strip().lower()
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
raise refused(400, "invalid_email")
|
||||
row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = DemoWhitelist(
|
||||
@@ -835,7 +837,7 @@ def reset_demo(admin: User = Depends(admin_user), db: Session = Depends(get_db))
|
||||
db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded"
|
||||
)
|
||||
db.commit()
|
||||
return {"reset": True, "playlists_seeded": seeded}
|
||||
return {"ok": True, "reset": True, "playlists_seeded": seeded}
|
||||
|
||||
|
||||
@router.post("/purge-discovery")
|
||||
@@ -854,4 +856,4 @@ def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get
|
||||
after=result,
|
||||
)
|
||||
db.commit()
|
||||
return result
|
||||
return {"ok": True, **result}
|
||||
|
||||
@@ -68,4 +68,4 @@ def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
n = db.execute(delete(AuditLog)).rowcount or 0
|
||||
audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries")
|
||||
db.commit()
|
||||
return {"cleared": n}
|
||||
return {"ok": True, "cleared": n}
|
||||
|
||||
+20
-25
@@ -43,6 +43,7 @@ from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
||||
from app.ratelimit import RateLimiter, client_key
|
||||
from app.security import encrypt, hash_password, hash_token, verify_password
|
||||
from app.utils import canonical_email, valid_email
|
||||
from app.refusal import refused
|
||||
|
||||
VERIFY_TTL = timedelta(hours=24)
|
||||
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
|
||||
@@ -378,7 +379,7 @@ async def callback(
|
||||
|
||||
userinfo = token.get("userinfo")
|
||||
if not userinfo or not userinfo.get("sub"):
|
||||
raise HTTPException(status_code=400, detail="No user info returned by Google")
|
||||
raise refused(400, "google_no_userinfo")
|
||||
|
||||
sub = userinfo["sub"]
|
||||
email = (userinfo.get("email") or "").lower()
|
||||
@@ -653,15 +654,15 @@ async def request_access(
|
||||
endpoint can't be probed for throttling either."""
|
||||
email = (body.email or "").strip().lower()
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
raise refused(400, "invalid_email")
|
||||
if not _request_access_limiter.allow(client_key(request)):
|
||||
return {"status": "pending"}
|
||||
return {"ok": True, "status": "pending"}
|
||||
if is_allowed(db, email):
|
||||
return {"status": "pending"} # already allowed — don't reveal it
|
||||
return {"ok": True, "status": "pending"} # already allowed — don't reveal it
|
||||
req = upsert_pending_invite(db, email, body.lang)
|
||||
if req.is_new:
|
||||
notify_admins_of_request(db, background, email)
|
||||
return {"status": "pending"}
|
||||
return {"ok": True, "status": "pending"}
|
||||
|
||||
|
||||
@router.post("/demo")
|
||||
@@ -672,7 +673,7 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
|
||||
exactly like a non-match (authenticated=false) so the field can't be hammered as an
|
||||
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
||||
if not _demo_limiter.allow(client_key(request)):
|
||||
return {"authenticated": False}
|
||||
return {"ok": True, "authenticated": False}
|
||||
email = (body.email or "").strip().lower()
|
||||
if valid_email(email) and is_demo_allowed(db, email):
|
||||
demo = get_or_create_demo_user(db)
|
||||
@@ -683,8 +684,8 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
|
||||
_remember_epoch(request, demo)
|
||||
# The demo key IS an address, so it is not in the line — see the denied branch above.
|
||||
log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id))
|
||||
return {"authenticated": True}
|
||||
return {"authenticated": False}
|
||||
return {"ok": True, "authenticated": True}
|
||||
return {"ok": True, "authenticated": False}
|
||||
|
||||
|
||||
def _establish_session(request: Request, user: User) -> None:
|
||||
@@ -815,23 +816,20 @@ def register(
|
||||
password = body.password or ""
|
||||
# Input validation is safe to reveal (independent of whether the email exists).
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
raise refused(400, "invalid_email")
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
||||
)
|
||||
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||
if not sysconfig.get_bool(db, "allow_registration"):
|
||||
raise HTTPException(status_code=403, detail="Registration is currently closed.")
|
||||
raise refused(403, "registration_closed")
|
||||
if not _register_limiter.allow(client_key(request)):
|
||||
return {"status": "ok"} # silently throttle; uniform response
|
||||
return {"ok": True, "status": "ok"} # silently throttle; uniform response
|
||||
|
||||
# Do the existence check + account creation OFF the response path (a background task with its own
|
||||
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
||||
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
||||
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
||||
background.add_task(_register_account, email, password, body.lang)
|
||||
return {"status": "ok"}
|
||||
return {"ok": True, "status": "ok"}
|
||||
|
||||
|
||||
def _register_account(email: str, password: str, lang: str | None = None) -> None:
|
||||
@@ -997,11 +995,11 @@ def verify_email_resend(
|
||||
mail had no way back. Uniform response + timing regardless of whether the email has a pending
|
||||
account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||
if not _verify_resend_limiter.allow(client_key(request)):
|
||||
return {"status": "ok"} # silently throttle; uniform response
|
||||
return {"ok": True, "status": "ok"} # silently throttle; uniform response
|
||||
email = (body.email or "").strip().lower()
|
||||
if valid_email(email):
|
||||
background.add_task(_resend_verify_if_eligible, email)
|
||||
return {"status": "ok"}
|
||||
return {"ok": True, "status": "ok"}
|
||||
|
||||
|
||||
def _resend_verify_if_eligible(email: str) -> None:
|
||||
@@ -1091,14 +1089,14 @@ def password_reset_request(
|
||||
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a
|
||||
password account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||
if not _reset_limiter.allow(client_key(request)):
|
||||
return {"status": "ok"}
|
||||
return {"ok": True, "status": "ok"}
|
||||
email = (body.email or "").strip().lower()
|
||||
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
||||
# own session), so the endpoint takes the same time for any valid email whether or not it has a
|
||||
# password account — no timing-based enumeration. Any valid email schedules the same task.
|
||||
if valid_email(email):
|
||||
background.add_task(_send_reset_if_eligible, email)
|
||||
return {"status": "ok"}
|
||||
return {"ok": True, "status": "ok"}
|
||||
|
||||
|
||||
def _send_reset_if_eligible(email: str) -> None:
|
||||
@@ -1293,13 +1291,10 @@ def set_password(
|
||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||
new_password = body.password or ""
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
||||
)
|
||||
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||
if user.password_hash:
|
||||
if not verify_password(body.current_password or "", user.password_hash):
|
||||
raise HTTPException(status_code=403, detail="Current password is incorrect.")
|
||||
raise refused(403, "wrong_password")
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.password_revoked_at = None # same as the reset path: a password exists again
|
||||
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
|
||||
|
||||
@@ -4,7 +4,7 @@ hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side w
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -25,12 +25,13 @@ from app.models import (
|
||||
User,
|
||||
Video,
|
||||
)
|
||||
from app.routes._common import get_or_404, not_found, owned_or_404
|
||||
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
|
||||
from app.sync.explore import explore_ingest_page
|
||||
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
||||
from app.sync.subscriptions import apply_channel_details
|
||||
from app.utils import iso
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
from app.refusal import refused
|
||||
|
||||
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
||||
|
||||
@@ -383,12 +384,9 @@ def explore_channel(
|
||||
backfill quota reserve; blocked for the demo account (it spends shared quota)."""
|
||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||
if _is_blocked(db, user, channel_id):
|
||||
raise HTTPException(status_code=403, detail="You've blocked this channel.")
|
||||
raise refused(403, "channel_blocked")
|
||||
if quota.backfill_reserve_reached():
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="The shared daily quota reserve is reached — try exploring later.",
|
||||
)
|
||||
raise refused(429, "quota_reserve")
|
||||
|
||||
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
|
||||
# videos as soon as they land. Flag the channel ephemeral unless it's organically followed.
|
||||
@@ -424,8 +422,9 @@ def explore_channel(
|
||||
channel = db.get(Channel, channel_id)
|
||||
result = explore_ingest_page(db, yt, channel, page_token)
|
||||
except YouTubeError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"YouTube couldn't load this channel: {exc}")
|
||||
raise youtube_refusal(exc)
|
||||
return {
|
||||
"ok": True,
|
||||
"channel_id": channel_id,
|
||||
"ingested": result["ingested"],
|
||||
"next_page_token": result["next_page_token"],
|
||||
@@ -507,6 +506,7 @@ def update_channel(
|
||||
background.add_task(backfill_channel_now, channel_id, user.id)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"id": channel_id,
|
||||
"priority": sub.priority,
|
||||
"hidden": sub.hidden,
|
||||
@@ -541,7 +541,7 @@ def reset_backfill(
|
||||
db.commit()
|
||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||
run_recent_backfill(db, [channel], max_channels=1)
|
||||
return {"id": channel_id, "reset": True}
|
||||
return {"ok": True, "id": channel_id, "reset": True}
|
||||
|
||||
|
||||
@router.post("/{channel_id}/subscribe")
|
||||
@@ -562,7 +562,7 @@ def subscribe(
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
|
||||
raise refused(409, "already_subscribed")
|
||||
with (
|
||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE),
|
||||
YouTubeClient(db, user) as yt,
|
||||
@@ -583,9 +583,7 @@ def subscribe(
|
||||
if "subscriptionDuplicate" in exc.reason.split(","):
|
||||
yt_sub_id = ""
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"YouTube couldn't subscribe you: {exc}"
|
||||
)
|
||||
raise youtube_refusal(exc)
|
||||
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
|
||||
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
|
||||
# render. Keep the client open (we're still inside the `with`); a metadata failure here
|
||||
@@ -622,7 +620,7 @@ def subscribe(
|
||||
.values(via_explore=False)
|
||||
)
|
||||
db.commit()
|
||||
return {"subscribed": channel_id}
|
||||
return {"ok": True, "subscribed": channel_id}
|
||||
|
||||
|
||||
@router.delete("/{channel_id}/subscription")
|
||||
@@ -636,10 +634,7 @@ def unsubscribe(
|
||||
should Hide instead."""
|
||||
sub = _user_subscription(db, user, channel_id)
|
||||
if not sub.yt_subscription_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No YouTube subscription id on record — sync subscriptions first.",
|
||||
)
|
||||
raise refused(400, "no_subscription_id")
|
||||
try:
|
||||
with (
|
||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE),
|
||||
@@ -649,10 +644,10 @@ def unsubscribe(
|
||||
except YouTubeError as exc:
|
||||
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
||||
# a transient "connection lost" gateway blip.
|
||||
raise HTTPException(status_code=422, detail=f"YouTube couldn't unsubscribe you: {exc}")
|
||||
raise youtube_refusal(exc)
|
||||
db.delete(sub)
|
||||
db.commit()
|
||||
return {"unsubscribed": channel_id}
|
||||
return {"ok": True, "unsubscribed": channel_id}
|
||||
|
||||
|
||||
@router.get("/blocked/list")
|
||||
@@ -693,7 +688,7 @@ def block_channel(
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"blocked": channel_id}
|
||||
return {"ok": True, "blocked": channel_id}
|
||||
|
||||
|
||||
@router.delete("/{channel_id}/block")
|
||||
@@ -708,7 +703,7 @@ def unblock_channel(
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"unblocked": channel_id}
|
||||
return {"ok": True, "unblocked": channel_id}
|
||||
|
||||
|
||||
class AttachTagIn(BaseModel):
|
||||
@@ -743,7 +738,7 @@ def attach_tag(
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"channel_id": channel_id, "tag_id": body.tag_id}
|
||||
return {"ok": True, "channel_id": channel_id, "tag_id": body.tag_id}
|
||||
|
||||
|
||||
@router.delete("/{channel_id}/tags/{tag_id}")
|
||||
@@ -763,4 +758,4 @@ def detach_tag(
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"channel_id": channel_id, "tag_id": tag_id}
|
||||
return {"ok": True, "channel_id": channel_id, "tag_id": tag_id}
|
||||
|
||||
@@ -4,7 +4,7 @@ app.sysconfig. Secret values (e.g. SMTP password) are write-only — never retur
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -17,6 +17,9 @@ from app.audit import AuditAction
|
||||
from app.auth import admin_user
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
from app.refusal import setting_refused
|
||||
|
||||
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
||||
|
||||
@@ -59,19 +62,16 @@ def set_config(
|
||||
) -> dict:
|
||||
s = sysconfig.spec(key)
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown config key")
|
||||
raise not_found("config_key")
|
||||
if "value" not in body.model_fields_set:
|
||||
raise HTTPException(status_code=400, detail="Missing 'value'")
|
||||
raise refused(400, "value_missing")
|
||||
if s.secret and not sysconfig.secrets_manageable():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
||||
)
|
||||
raise refused(400, "no_encryption_key")
|
||||
old = None if s.secret else sysconfig.get(db, key)
|
||||
try:
|
||||
sysconfig.set_value(db, key, body.value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
||||
except sysconfig.InvalidSetting as exc:
|
||||
raise setting_refused(exc) from None
|
||||
# Never log a secret's value — only that it was changed, by whom. Non-secret values are logged
|
||||
# but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when
|
||||
# a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets
|
||||
@@ -101,7 +101,7 @@ def reset_config(
|
||||
) -> dict:
|
||||
s = sysconfig.spec(key)
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown config key")
|
||||
raise not_found("config_key")
|
||||
old = None if s.secret else sysconfig.get(db, key)
|
||||
sysconfig.reset(db, key)
|
||||
audit.record(
|
||||
@@ -126,8 +126,8 @@ def send_test_email(
|
||||
"""Send a test email to the requesting admin using the current (DB or env) SMTP config —
|
||||
lets the admin verify the settings actually work."""
|
||||
if not email_mod.email_enabled():
|
||||
raise HTTPException(status_code=400, detail="SMTP is not configured.")
|
||||
raise refused(400, "smtp_off")
|
||||
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
||||
if not sent:
|
||||
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.")
|
||||
return {"sent": True, "to": user.email}
|
||||
raise refused(422, "smtp_failed")
|
||||
return {"ok": True, "sent": True, "to": user.email}
|
||||
|
||||
@@ -11,9 +11,9 @@ thing it exists to remove.
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.discover import hero as hero_mod
|
||||
from app.discover import pins, rails
|
||||
from app.discover.budget import Budget, DEFAULT_BUDGET
|
||||
from app.models import ComfortPin, User
|
||||
from app.routes._common import owned_or_404
|
||||
from app.routes._common import owned_or_404, not_found
|
||||
|
||||
router = APIRouter(prefix="/api/discover", tags=["discover"])
|
||||
|
||||
@@ -53,7 +53,9 @@ def get_discover(
|
||||
`resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from
|
||||
are not queried twice for a single page load."""
|
||||
resume = rails.continue_watching(db, user)
|
||||
shelf = pins.shelf(db, user)
|
||||
# Plex off → the shelf rail (and so the hero's comfort tier) goes YouTube-only like the rails
|
||||
# beside it; the pins are kept and `GET /pins` still lists them (`pins.shelf`).
|
||||
shelf = pins.shelf(db, user, include_plex=rails.plex_available(db))
|
||||
return {
|
||||
"budget": budget,
|
||||
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf),
|
||||
@@ -125,7 +127,7 @@ def add_pin(
|
||||
constraint is the mechanism, not the message."""
|
||||
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown item")
|
||||
raise not_found("item")
|
||||
column, value = resolved
|
||||
|
||||
existing = db.execute(
|
||||
@@ -157,7 +159,7 @@ def add_pin(
|
||||
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
|
||||
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check
|
||||
# returns above — re-raising handed the user a 500 they could do nothing with.
|
||||
raise HTTPException(status_code=404, detail="Unknown item")
|
||||
raise not_found("item")
|
||||
return {"ok": True, "id": winner.id, "created": False}
|
||||
return {"ok": True, "id": pin.id, "created": True}
|
||||
|
||||
@@ -180,18 +182,11 @@ def reorder_pins(
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Rewrite the shelf order from a full list of pin ids.
|
||||
"""Rewrite the shelf order from the list of pin ids the client shows.
|
||||
|
||||
Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two
|
||||
tiles claiming the same slot the way an interrupted sequence of single moves can. Ids that are not
|
||||
this user's are ignored rather than 404'd — a stale tab replaying an order that includes a pin
|
||||
deleted meanwhile should reorder what still exists, not fail wholesale."""
|
||||
owned = {p.id for p in pins.list_pins(db, user)}
|
||||
for position, pin_id in enumerate(i for i in body.ids if i in owned):
|
||||
db.execute(
|
||||
update(ComfortPin)
|
||||
.where(ComfortPin.id == pin_id, ComfortPin.user_id == user.id)
|
||||
.values(position=position)
|
||||
)
|
||||
tiles claiming the same slot the way an interrupted sequence of single moves can. The list may be
|
||||
PARTIAL (hidden Plex pins, a stale tab) — `pins.reorder` keeps what it leaves out in place."""
|
||||
pins.reorder(db, user, body.ids)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -38,10 +38,11 @@ from app.models import (
|
||||
MediaAsset,
|
||||
User,
|
||||
)
|
||||
from app.routes._common import get_or_404, not_found
|
||||
from app.routes._common import get_or_404, not_found, owned_or_404
|
||||
from app.security import hash_password
|
||||
from app.userscope import is_messageable_user, messageable_clauses
|
||||
from app.utils import iso
|
||||
from app.refusal import refused
|
||||
|
||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
|
||||
@@ -63,7 +64,7 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
||||
as a raw URL for yt-dlp's generic extractor."""
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="A video link or id is required.")
|
||||
raise refused(400, "link_required")
|
||||
if _VIDEO_ID.match(raw):
|
||||
return "youtube", raw
|
||||
if raw.startswith(("http://", "https://")):
|
||||
@@ -93,7 +94,7 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
||||
if _VIDEO_ID.match(vid):
|
||||
return "youtube", vid
|
||||
return "url", raw
|
||||
raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.")
|
||||
raise refused(400, "invalid_link")
|
||||
|
||||
|
||||
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||
@@ -166,15 +167,8 @@ def _serialize_job(db: Session, job: DownloadJob) -> dict:
|
||||
return _serialize(job, asset)
|
||||
|
||||
|
||||
def _own_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job is None or job.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Unknown download")
|
||||
return job
|
||||
|
||||
|
||||
def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||
"""`_own_job` for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
|
||||
"""The ownership check (`owned_or_404`) for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
|
||||
or writes the status those decide on (pause). The job is re-read under a row lock, so two
|
||||
overlapping requests for one job run one after the other and the second decides on the first's
|
||||
committed result — never both releasing the same hold, and never a pause turning a job a cancel
|
||||
@@ -183,7 +177,7 @@ def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||
The asset is locked BEFORE the job: the order a finishing worker child takes them in
|
||||
(`service.finalize_if_running`), so the two can never deadlock."""
|
||||
while True:
|
||||
asset_id = _own_job(db, user, job_id).asset_id
|
||||
asset_id = owned_or_404(db, DownloadJob, job_id, user, what="download").asset_id
|
||||
if asset_id is not None:
|
||||
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
||||
job = db.execute(
|
||||
@@ -193,7 +187,7 @@ def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||
.execution_options(populate_existing=True)
|
||||
).scalar_one_or_none()
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown download")
|
||||
raise not_found("download")
|
||||
if job.asset_id == asset_id:
|
||||
return job
|
||||
db.rollback() # another request moved the job to another asset meanwhile: lock that one
|
||||
@@ -240,7 +234,7 @@ def create_profile(
|
||||
name = body.name.strip()
|
||||
spec = body.spec
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="A profile name is required.")
|
||||
raise refused(400, "profile_name_required")
|
||||
maxpos = db.execute(
|
||||
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
|
||||
DownloadProfile.user_id == user.id
|
||||
@@ -268,16 +262,16 @@ def update_profile(
|
||||
) -> dict:
|
||||
p = db.get(DownloadProfile, profile_id)
|
||||
if p is None or p.is_builtin or p.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Unknown profile")
|
||||
raise not_found("profile")
|
||||
sent = body.model_fields_set
|
||||
if "name" in sent:
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="A profile name is required.")
|
||||
raise refused(400, "profile_name_required")
|
||||
p.name = name[:80]
|
||||
if "spec" in sent:
|
||||
if body.spec is None:
|
||||
raise HTTPException(status_code=400, detail="spec must be an object")
|
||||
raise refused(400, "spec_not_object")
|
||||
p.spec = body.spec
|
||||
db.commit()
|
||||
return _serialize_profile(p)
|
||||
@@ -289,7 +283,7 @@ def delete_profile(
|
||||
) -> dict:
|
||||
p = db.get(DownloadProfile, profile_id)
|
||||
if p is None or p.is_builtin or p.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Unknown profile")
|
||||
raise not_found("profile")
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
return {"deleted": profile_id}
|
||||
@@ -319,7 +313,7 @@ def set_credential(
|
||||
"""Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is
|
||||
never echoed back, logged, or audited — only the fact that it changed."""
|
||||
if not dlcreds.is_supported_host(host_key):
|
||||
raise HTTPException(status_code=404, detail="Unknown host")
|
||||
raise not_found("host")
|
||||
try:
|
||||
cleaned = dlcreds.validate_cookies(body.cookies)
|
||||
except dlcreds.InvalidCookies as e:
|
||||
@@ -341,7 +335,7 @@ def delete_credential(
|
||||
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
if not dlcreds.is_supported_host(host_key):
|
||||
raise HTTPException(status_code=404, detail="Unknown host")
|
||||
raise not_found("host")
|
||||
if dlcreds.delete_credential(db, user.id, host_key):
|
||||
audit.record(
|
||||
db,
|
||||
@@ -362,7 +356,7 @@ def _resolve_spec(
|
||||
if profile_id is not None:
|
||||
p = db.get(DownloadProfile, profile_id)
|
||||
if p is None or (not p.is_builtin and p.user_id != user.id):
|
||||
raise HTTPException(status_code=404, detail="Unknown profile")
|
||||
raise not_found("profile")
|
||||
return p.spec, p.id
|
||||
if spec is not None:
|
||||
return spec, None
|
||||
@@ -373,7 +367,7 @@ def _resolve_spec(
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if fallback is None:
|
||||
raise HTTPException(status_code=400, detail="No download profile available.")
|
||||
raise refused(400, "no_profile")
|
||||
return fallback.spec, fallback.id
|
||||
|
||||
|
||||
@@ -484,7 +478,7 @@ def enqueue_edit(
|
||||
source file is only read, never modified, so editing a shared video is safe."""
|
||||
source_job = _accessible_job(db, user, body.source_job_id)
|
||||
if source_job.status != "done" or source_job.asset_id is None:
|
||||
raise HTTPException(status_code=409, detail="You can only edit a finished download.")
|
||||
raise refused(409, "edit_unfinished")
|
||||
spec = body.edit_spec
|
||||
display_name = (body.display_name or "").strip() or None
|
||||
try:
|
||||
@@ -614,7 +608,7 @@ def update_download_meta(
|
||||
) -> dict:
|
||||
"""Update a download's display metadata: title, channel name + link, and extra reference URLs.
|
||||
All are display-only overrides — the on-disk file keeps its system-decided, id-bound name."""
|
||||
job = _own_job(db, user, job_id)
|
||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
sent = body.model_fields_set
|
||||
if "display_name" in sent:
|
||||
job.display_name = ((body.display_name or "").strip())[:255] or None
|
||||
@@ -668,9 +662,9 @@ def redownload_download(
|
||||
if job.job_kind in ("edit", "upload"):
|
||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
||||
raise HTTPException(status_code=409, detail="This item can't be re-downloaded.")
|
||||
raise refused(409, "not_redownloadable")
|
||||
if job.status in ("queued", "running", "paused"):
|
||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
||||
raise refused(409, "still_downloading")
|
||||
service.redownload(db, job)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
@@ -699,7 +693,7 @@ def delete_download(
|
||||
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown download")
|
||||
raise not_found("download")
|
||||
if job.user_id == user.id:
|
||||
return job
|
||||
shared = db.execute(
|
||||
@@ -708,7 +702,7 @@ def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||
)
|
||||
).first()
|
||||
if shared is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown download")
|
||||
raise not_found("download")
|
||||
return job
|
||||
|
||||
|
||||
@@ -722,10 +716,10 @@ def download_file(
|
||||
job = _accessible_job(db, user, job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
if asset is None or asset.status != "ready" or not asset.rel_path:
|
||||
raise HTTPException(status_code=409, detail="This download isn't ready.")
|
||||
raise refused(409, "download_not_ready")
|
||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="File is no longer available.")
|
||||
raise not_found("file")
|
||||
|
||||
filename = storage.download_filename(
|
||||
job.display_name or asset.title or asset.source_ref, asset.container, path
|
||||
@@ -764,7 +758,7 @@ def storyboard_meta(
|
||||
job = _accessible_job(db, user, job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
if asset is None or asset.status != "ready" or not asset.rel_path:
|
||||
raise HTTPException(status_code=409, detail="This download isn't ready.")
|
||||
raise refused(409, "download_not_ready")
|
||||
meta = editmod.ensure_storyboard(asset, settings.download_root)
|
||||
if meta is None:
|
||||
return {"available": False}
|
||||
@@ -785,10 +779,10 @@ def poster_image(job_id: int, user: User = Depends(require_human), db: Session =
|
||||
job = _accessible_job(db, user, job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
if asset is None or not asset.poster_path:
|
||||
raise HTTPException(status_code=404, detail="No poster.")
|
||||
raise not_found("poster")
|
||||
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="No poster.")
|
||||
raise not_found("poster")
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
|
||||
|
||||
@@ -799,10 +793,10 @@ def storyboard_image(
|
||||
job = _accessible_job(db, user, job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
if asset is None or not asset.storyboard_path:
|
||||
raise HTTPException(status_code=404, detail="No filmstrip.")
|
||||
raise not_found("filmstrip")
|
||||
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="No filmstrip.")
|
||||
raise not_found("filmstrip")
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
|
||||
|
||||
@@ -820,12 +814,12 @@ def share_download(
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = _own_job(db, user, job_id)
|
||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
if job.status != "done" or job.asset_id is None:
|
||||
raise HTTPException(status_code=409, detail="Only a finished download can be shared.")
|
||||
raise refused(409, "share_unfinished")
|
||||
target = body.email.strip().lower()
|
||||
if not target:
|
||||
raise HTTPException(status_code=400, detail="A recipient email is required.")
|
||||
raise refused(400, "recipient_required")
|
||||
recipient = db.execute(
|
||||
select(User).where(func.lower(User.email) == target)
|
||||
).scalar_one_or_none()
|
||||
@@ -834,7 +828,7 @@ def share_download(
|
||||
if not is_messageable_user(recipient):
|
||||
raise not_found("recipient")
|
||||
if recipient.id == user.id:
|
||||
raise HTTPException(status_code=400, detail="That's already your download.")
|
||||
raise refused(400, "share_own")
|
||||
exists = db.execute(
|
||||
select(DownloadShare.id).where(
|
||||
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
|
||||
@@ -853,7 +847,7 @@ def share_download(
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return {"shared_with": recipient.email}
|
||||
return {"ok": True, "shared_with": recipient.email}
|
||||
|
||||
|
||||
@router.delete("/{job_id}/share/{email}")
|
||||
@@ -863,7 +857,9 @@ def unshare_download(
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
_own_job(db, user, job_id) # ownership guard (404s if not the caller's job)
|
||||
owned_or_404(
|
||||
db, DownloadJob, job_id, user, what="download"
|
||||
) # ownership guard (404s if not the caller's job)
|
||||
recipient = db.execute(
|
||||
select(User).where(func.lower(User.email) == email.strip().lower())
|
||||
).scalar_one_or_none()
|
||||
@@ -895,7 +891,7 @@ def remove_shared_with_me(
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"removed": job_id}
|
||||
return {"ok": True, "removed": job_id}
|
||||
|
||||
|
||||
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
||||
@@ -925,10 +921,10 @@ def share_recipients(
|
||||
def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
|
||||
link = db.get(DownloadLink, link_id)
|
||||
if link is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown link")
|
||||
raise not_found("link")
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
if job is None or job.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Unknown link")
|
||||
raise not_found("link")
|
||||
return link
|
||||
|
||||
|
||||
@@ -944,7 +940,7 @@ def _link_expiry(days: int | None) -> datetime | None:
|
||||
def list_links(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
_own_job(db, user, job_id)
|
||||
owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
rows = (
|
||||
db.execute(
|
||||
select(DownloadLink)
|
||||
@@ -975,9 +971,9 @@ def create_link(
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = _own_job(db, user, job_id)
|
||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
if job.status != "done" or job.asset_id is None:
|
||||
raise HTTPException(status_code=409, detail="Only a finished download can be shared.")
|
||||
raise refused(409, "share_unfinished")
|
||||
pw = body.password.strip()
|
||||
link = DownloadLink(
|
||||
job_id=job_id,
|
||||
|
||||
+13
-12
@@ -1,6 +1,6 @@
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
@@ -29,10 +29,11 @@ from app.models import (
|
||||
Video,
|
||||
VideoState,
|
||||
)
|
||||
from app.routes._common import get_or_404
|
||||
from app.routes._common import get_or_404, youtube_refusal, not_found
|
||||
from app.sync.videos import parse_iso8601_duration
|
||||
from app.utils import iso
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
from app.refusal import refused
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["feed"])
|
||||
|
||||
@@ -120,7 +121,7 @@ def get_feed(
|
||||
vals, cursor_id = decode_cursor(keys, cursor)
|
||||
except ValueError:
|
||||
# `from None`: the ValueError is an expected bad-input signal, not an error to chain.
|
||||
raise HTTPException(status_code=400, detail="Invalid feed cursor") from None
|
||||
raise refused(400, "invalid_cursor") from None
|
||||
for k, v in zip(keys, vals):
|
||||
k["value"] = v
|
||||
query = query.where(keyset_predicate(keys, cursor_id))
|
||||
@@ -235,7 +236,7 @@ def set_video_state(
|
||||
) -> dict:
|
||||
status = body.status
|
||||
if status not in VALID_STATES:
|
||||
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}")
|
||||
raise refused(400, "invalid_status")
|
||||
get_or_404(db, Video, video_id, what="video")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -260,7 +261,7 @@ def set_video_state(
|
||||
# or moved this row. The state the caller asked for is the state that holds either way.
|
||||
except StaleDataError:
|
||||
db.rollback()
|
||||
return {"video_id": video_id, "status": "new"}
|
||||
return {"ok": True, "video_id": video_id, "status": "new"}
|
||||
|
||||
# watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at
|
||||
# the same instant a progress checkpoint may DELETE the in-progress row; a plain
|
||||
@@ -281,7 +282,7 @@ def set_video_state(
|
||||
)
|
||||
db.execute(stmt)
|
||||
db.commit()
|
||||
return {"video_id": video_id, "status": status}
|
||||
return {"ok": True, "video_id": video_id, "status": status}
|
||||
|
||||
|
||||
@router.delete("/videos/{video_id}/state")
|
||||
@@ -303,7 +304,7 @@ def clear_video_state(
|
||||
# silent-ok: the row went under the delete, which is the outcome the delete wanted.
|
||||
except StaleDataError:
|
||||
db.rollback()
|
||||
return {"video_id": video_id, "status": "new", "position_seconds": 0}
|
||||
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
|
||||
|
||||
|
||||
class VideoProgressIn(BaseModel):
|
||||
@@ -361,7 +362,7 @@ def set_video_progress(
|
||||
# silent-ok: exactly the race the comment above describes; the response stays true.
|
||||
except StaleDataError:
|
||||
db.rollback()
|
||||
return {"video_id": video_id, "position_seconds": 0}
|
||||
return {"ok": True, "video_id": video_id, "position_seconds": 0}
|
||||
|
||||
if row is None:
|
||||
row = VideoState(user_id=user.id, video_id=video_id)
|
||||
@@ -369,7 +370,7 @@ def set_video_progress(
|
||||
row.position_seconds = position
|
||||
row.progress_updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"video_id": video_id, "position_seconds": position}
|
||||
return {"ok": True, "video_id": video_id, "position_seconds": position}
|
||||
|
||||
|
||||
@router.get("/videos/{video_id}")
|
||||
@@ -402,7 +403,7 @@ def get_video_detail(
|
||||
# The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the
|
||||
# demo account can't reach it — it just sees "unknown" for a video we don't already store.
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=404, detail="Unknown video")
|
||||
raise not_found("video")
|
||||
|
||||
try:
|
||||
with (
|
||||
@@ -411,9 +412,9 @@ def get_video_detail(
|
||||
):
|
||||
items = yt.get_videos([video_id])
|
||||
except YouTubeError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}")
|
||||
raise youtube_refusal(exc)
|
||||
if not items:
|
||||
raise HTTPException(status_code=404, detail="Unknown video")
|
||||
raise not_found("video")
|
||||
|
||||
snippet = items[0].get("snippet", {})
|
||||
stats = items[0].get("statistics", {})
|
||||
|
||||
+10
-13
@@ -24,6 +24,8 @@ from app import sysconfig
|
||||
from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
from app.utils import iso
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
|
||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||
|
||||
@@ -70,7 +72,7 @@ def switch_account(
|
||||
here) are allowed, and access is re-checked in case the invite was revoked since."""
|
||||
target = body.user_id
|
||||
if target not in (request.session.get("account_ids") or []):
|
||||
raise HTTPException(status_code=403, detail="Not an account you've signed into here.")
|
||||
raise refused(403, "not_your_account")
|
||||
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
|
||||
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
|
||||
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
|
||||
@@ -78,22 +80,20 @@ def switch_account(
|
||||
u = db.get(User, target)
|
||||
if u is None:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise HTTPException(status_code=404, detail="That account no longer exists.")
|
||||
raise not_found("account")
|
||||
if not is_allowed(db, u.email):
|
||||
raise HTTPException(status_code=403, detail="That account no longer has access.")
|
||||
raise refused(403, "account_no_access")
|
||||
if not u.is_active:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise HTTPException(status_code=403, detail="That account no longer has access.")
|
||||
raise refused(403, "account_no_access")
|
||||
if u.is_suspended:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise HTTPException(status_code=403, detail="That account is suspended.")
|
||||
raise refused(403, "account_suspended")
|
||||
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
||||
epochs = request.session.get("epochs") or {}
|
||||
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
||||
drop_from_wallet(request.session, target)
|
||||
raise HTTPException(
|
||||
status_code=403, detail="That account was signed out elsewhere — sign in again."
|
||||
)
|
||||
raise refused(403, "account_signed_out")
|
||||
request.session["user_id"] = target
|
||||
return {"ok": True, "user_id": target}
|
||||
|
||||
@@ -181,10 +181,7 @@ def delete_my_account(
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
|
||||
if user.role == "admin" and count_admins(db) <= 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="You're the only admin — promote another admin before deleting your account.",
|
||||
)
|
||||
raise refused(400, "only_admin_delete")
|
||||
user_id = user.id
|
||||
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
||||
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
||||
@@ -217,7 +214,7 @@ def update_preferences(
|
||||
user.preferences = merged
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return {"preferences": merged}
|
||||
return {"ok": True, "preferences": merged}
|
||||
|
||||
|
||||
class DiagnosticsIn(BaseModel):
|
||||
|
||||
@@ -16,7 +16,7 @@ routing and unread counts.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, case, delete, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -29,6 +29,8 @@ from app.realtime import manager
|
||||
from app.security import verify_password
|
||||
from app.userscope import is_messageable_user, messageable_clauses
|
||||
from app.utils import iso
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
|
||||
router = APIRouter(prefix="/api/messages", tags=["messages"])
|
||||
|
||||
@@ -152,7 +154,7 @@ def setup_keys(
|
||||
"""Store this user's public key + passphrase-wrapped private key. Set-once: changing the
|
||||
key would orphan every existing conversation key, so re-setup is rejected here."""
|
||||
if db.get(MessageKey, user.id) is not None:
|
||||
raise HTTPException(status_code=409, detail="Secure messaging is already set up.")
|
||||
raise refused(409, "messaging_set_up")
|
||||
db.add(
|
||||
MessageKey(
|
||||
user_id=user.id,
|
||||
@@ -164,7 +166,7 @@ def setup_keys(
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"configured": True}
|
||||
return {"ok": True, "configured": True}
|
||||
|
||||
|
||||
@router.post("/keys/reset")
|
||||
@@ -185,11 +187,11 @@ def reset_keys(
|
||||
less (Google-only) account has nothing to re-verify, so it relies on its live session plus the
|
||||
client-side danger confirmation."""
|
||||
if not _reset_key_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
|
||||
raise refused(429, "too_many_attempts")
|
||||
if user.password_hash and not verify_password(
|
||||
payload.current_password or "", user.password_hash
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Current password is incorrect.")
|
||||
raise refused(403, "wrong_password")
|
||||
# Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a
|
||||
# fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit.
|
||||
db.execute(
|
||||
@@ -202,7 +204,7 @@ def reset_keys(
|
||||
if key is not None:
|
||||
db.delete(key)
|
||||
db.commit()
|
||||
return {"configured": False}
|
||||
return {"ok": True, "configured": False}
|
||||
|
||||
|
||||
@router.get("/keys/{user_id}")
|
||||
@@ -223,10 +225,10 @@ def public_key(
|
||||
p = db.get(User, user_id)
|
||||
has_history = db.scalar(select(Message.id).where(cond).limit(1))
|
||||
if p is None or (not is_messageable_user(p) and not has_history):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise not_found("user")
|
||||
k = db.get(MessageKey, user_id)
|
||||
if k is None:
|
||||
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
||||
raise refused(404, "no_messaging")
|
||||
return {"user_id": user_id, "public_key": k.public_key}
|
||||
|
||||
|
||||
@@ -344,7 +346,7 @@ def get_thread(
|
||||
# still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle.
|
||||
has_history = db.scalar(select(Message.id).where(cond).limit(1))
|
||||
if p is None or (not is_messageable_user(p) and not has_history):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise not_found("user")
|
||||
partner = _serialize_user(p)
|
||||
msgs = (
|
||||
db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all()
|
||||
@@ -374,20 +376,20 @@ def send_message(
|
||||
"""Send an end-to-end-encrypted message. The server stores only the ciphertext the client
|
||||
produced, then pushes it live to the recipient (and the sender's other devices)."""
|
||||
if not _send_limiter.allow(f"msg:{user.id}"):
|
||||
raise HTTPException(status_code=429, detail="Too many messages — slow down a moment.")
|
||||
raise refused(429, "message_rate")
|
||||
ct = (payload.ciphertext or "").strip()
|
||||
iv = (payload.iv or "").strip()
|
||||
if not ct or not iv:
|
||||
raise HTTPException(status_code=400, detail="Message is empty.")
|
||||
raise refused(400, "message_empty")
|
||||
if len(ct) > MAX_CIPHERTEXT:
|
||||
raise HTTPException(status_code=400, detail="Message is too long.")
|
||||
raise refused(400, "message_too_long")
|
||||
if payload.recipient_id == user.id:
|
||||
raise HTTPException(status_code=400, detail="You can't message yourself.")
|
||||
raise refused(400, "message_self")
|
||||
recipient = db.get(User, payload.recipient_id)
|
||||
if not is_messageable_user(recipient):
|
||||
raise HTTPException(status_code=404, detail="Recipient not available.")
|
||||
raise not_found("recipient")
|
||||
if db.get(MessageKey, recipient.id) is None:
|
||||
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
||||
raise refused(404, "no_messaging")
|
||||
m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
|
||||
@@ -5,7 +5,7 @@ All endpoints use `current_user` (not `require_human`): notifications and their
|
||||
state are personal UI state with no quota or YouTube involvement, so the shared demo account
|
||||
manages its own inbox normally."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.db import get_db
|
||||
from app.models import Notification, User
|
||||
from app.notifications import trim_read
|
||||
from app.utils import iso
|
||||
from app.routes._common import owned_or_404
|
||||
|
||||
router = APIRouter(prefix="/api/me/notifications", tags=["notifications"])
|
||||
|
||||
@@ -72,20 +73,13 @@ def unread_count(user: User = Depends(current_user), db: Session = Depends(get_d
|
||||
return {"count": count or 0}
|
||||
|
||||
|
||||
def _owned(db: Session, notification_id: int, user: User) -> Notification:
|
||||
n = db.get(Notification, notification_id)
|
||||
if n is None or n.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
return n
|
||||
|
||||
|
||||
@router.post("/{notification_id}/read")
|
||||
def mark_read(
|
||||
notification_id: int,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
n = _owned(db, notification_id, user)
|
||||
n = owned_or_404(db, Notification, notification_id, user, what="notification")
|
||||
n.read = True
|
||||
db.commit()
|
||||
trim_read(db, user.id)
|
||||
@@ -110,7 +104,7 @@ def dismiss(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
n = _owned(db, notification_id, user)
|
||||
n = owned_or_404(db, Notification, notification_id, user, what="notification")
|
||||
# Dismiss implies read (it no longer counts toward the unread badge).
|
||||
n.dismissed = True
|
||||
n.read = True
|
||||
|
||||
@@ -3,11 +3,10 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
|
||||
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, and_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app import quota
|
||||
@@ -23,7 +22,7 @@ from app.sync.playlists import (
|
||||
sync_user_playlists,
|
||||
)
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes._common import get_or_404, owned_or_404
|
||||
from app.routes._common import get_or_404, owned_or_404, youtube_refusal
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||
@@ -48,10 +47,6 @@ def _check_pull_cooldown(user: User) -> None:
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
|
||||
|
||||
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
return owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
|
||||
|
||||
def _refused(status: int, reason: str) -> HTTPException:
|
||||
"""A playlist action the server refuses, as a reason the UI translates
|
||||
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
|
||||
@@ -64,8 +59,11 @@ def _touch(pl: Playlist) -> None:
|
||||
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
|
||||
reorder / move write the item rows instead — so without this the rail's "recently changed"
|
||||
sort would only ever see renames. The assignment is what makes the column mean "last time
|
||||
this list changed", which is what the rail promises the reader."""
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
this list changed", which is what the rail promises the reader.
|
||||
|
||||
The DATABASE's clock, like the mixin's own `onupdate`: one sort key written from two clocks
|
||||
(this process's and Postgres's) could order a rename and an item edit the wrong way round."""
|
||||
pl.updated_at = func.now()
|
||||
|
||||
|
||||
def _next_playlist_position(db: Session, user_id: int) -> int:
|
||||
@@ -108,7 +106,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
|
||||
if mark_dirty:
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||
return True
|
||||
|
||||
|
||||
@@ -126,7 +124,7 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
|
||||
if mark_dirty:
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||
return True
|
||||
|
||||
|
||||
@@ -280,7 +278,7 @@ def _watch_later(db: Session, user: User) -> Playlist:
|
||||
position=_next_playlist_position(db, user.id),
|
||||
)
|
||||
db.add(pl)
|
||||
db.commit()
|
||||
db.flush() # the id, for the item the caller adds next; the handler commits
|
||||
return pl
|
||||
|
||||
|
||||
@@ -295,7 +293,8 @@ def add_watch_later(
|
||||
get_or_404(db, Video, video_id, what="video")
|
||||
pl = _watch_later(db, user)
|
||||
_add_item(db, pl, video_id, mark_dirty=False)
|
||||
return {"saved": True, "playlist_id": pl.id}
|
||||
db.commit()
|
||||
return {"ok": True, "saved": True, "playlist_id": pl.id}
|
||||
|
||||
|
||||
@router.delete("/watch-later/{video_id}")
|
||||
@@ -309,7 +308,8 @@ def remove_watch_later(
|
||||
)
|
||||
if pl is not None:
|
||||
_remove_item(db, pl, video_id, mark_dirty=False)
|
||||
return {"saved": False}
|
||||
db.commit()
|
||||
return {"ok": True, "saved": False}
|
||||
|
||||
|
||||
@router.post("/sync-youtube")
|
||||
@@ -322,8 +322,12 @@ def sync_youtube(
|
||||
if not has_read_scope(user):
|
||||
raise _refused(403, "youtube_read_scope")
|
||||
_check_pull_cooldown(user)
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return sync_user_playlists(db, user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return {"ok": True, **sync_user_playlists(db, user)}
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise youtube_refusal(exc)
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/revert-youtube")
|
||||
@@ -335,7 +339,7 @@ def revert_youtube(
|
||||
"""Reset one YouTube-linked playlist to its current YouTube state, discarding local
|
||||
edits (and clearing the dirty flag). The user-facing 'undo all my changes' for a list
|
||||
once the in-session undo history is gone."""
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
if not pl.yt_playlist_id:
|
||||
raise _refused(400, "not_on_youtube")
|
||||
if not has_read_scope(user):
|
||||
@@ -343,16 +347,16 @@ def revert_youtube(
|
||||
_check_pull_cooldown(user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return repull_playlist(db, user, pl)
|
||||
except YouTubeError:
|
||||
return {"ok": True, **repull_playlist(db, user, pl)}
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise _refused(422, "youtube_failed")
|
||||
raise youtube_refusal(exc)
|
||||
|
||||
|
||||
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
"""A local playlist the user may push to YouTube (create or update). The built-in
|
||||
Watch later and read-only YouTube mirrors are never pushable."""
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
if pl.kind == "watch_later":
|
||||
raise _refused(400, "watch_later_not_pushable")
|
||||
return pl
|
||||
@@ -371,8 +375,8 @@ def push_plan(
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||
plan = plan_merge(db, user, pl)
|
||||
except YouTubeError:
|
||||
raise _refused(422, "youtube_failed")
|
||||
except YouTubeError as exc:
|
||||
raise youtube_refusal(exc)
|
||||
plan["remaining_today"] = quota.remaining_today(db)
|
||||
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
||||
return plan
|
||||
@@ -403,10 +407,10 @@ def push(
|
||||
plan = plan_merge(db, user, pl, live=live)
|
||||
if plan["units_estimate"] > quota.remaining_today(db):
|
||||
raise _refused(429, "quota_exceeded")
|
||||
return merge_playlist(db, user, pl, live=live)
|
||||
except YouTubeError:
|
||||
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise _refused(422, "youtube_failed")
|
||||
raise youtube_refusal(exc)
|
||||
|
||||
|
||||
@router.get("/{playlist_id}")
|
||||
@@ -415,7 +419,7 @@ def get_playlist(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
state = aliased(VideoState)
|
||||
rows = db.execute(
|
||||
select(*feed_columns(user, state))
|
||||
@@ -447,7 +451,7 @@ def rename_playlist(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
if "name" in body.model_fields_set:
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
@@ -484,7 +488,7 @@ def delete_playlist(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
if pl.kind == "watch_later":
|
||||
raise _refused(400, "watch_later_undeletable")
|
||||
if on_youtube:
|
||||
@@ -496,8 +500,8 @@ def delete_playlist(
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
||||
with YouTubeClient(db, user) as yt:
|
||||
yt.delete_playlist(pl.yt_playlist_id)
|
||||
except YouTubeError:
|
||||
raise _refused(422, "youtube_failed")
|
||||
except YouTubeError as exc:
|
||||
raise youtube_refusal(exc)
|
||||
db.delete(pl) # items cascade
|
||||
db.commit()
|
||||
return {"deleted": playlist_id}
|
||||
@@ -510,11 +514,12 @@ def add_item(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
video_id = body.video_id
|
||||
get_or_404(db, Video, video_id, what="video")
|
||||
_add_item(db, pl, video_id, mark_dirty=True)
|
||||
return {"playlist_id": pl.id, "video_id": video_id}
|
||||
db.commit()
|
||||
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}/items/{video_id}")
|
||||
@@ -524,9 +529,10 @@ def remove_item(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
_remove_item(db, pl, video_id, mark_dirty=True)
|
||||
return {"playlist_id": pl.id, "video_id": video_id}
|
||||
db.commit()
|
||||
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||
|
||||
|
||||
class MoveItemIn(BaseModel):
|
||||
@@ -548,7 +554,7 @@ def move_item(
|
||||
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
|
||||
case. Idempotent: if the video is already in the target, the source row is simply dropped
|
||||
(moving into a list that already has it is a removal, not a duplicate)."""
|
||||
src = _own_playlist(db, user, playlist_id)
|
||||
src = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
# Its own reason: "this playlist doesn't exist any more" about a TARGET that is gone was read as
|
||||
# the list on screen — which is still there.
|
||||
dst = owned_or_404(db, Playlist, body.target_playlist_id, user, what="move_target")
|
||||
@@ -571,19 +577,33 @@ def move_item(
|
||||
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
|
||||
)
|
||||
)
|
||||
if already is not None:
|
||||
db.delete(item) # the uq_playlist_video constraint would reject a second row anyway
|
||||
else:
|
||||
dst_changed = False
|
||||
if already is None:
|
||||
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
||||
# survives the move, and there is no window where the video belongs to neither list.
|
||||
item.playlist_id = dst.id
|
||||
item.position = _next_item_position(db, dst.id)
|
||||
# Both sides changed, so both get the dirty recompute and the timestamp — the asymmetric
|
||||
# version of this (touching only the source) is the recurring smell CLAUDE.md warns about.
|
||||
# survives the move, and there is no window where the video belongs to neither list. The
|
||||
# check above is not a lock: a second tab's add or the scheduler's YouTube merge can insert
|
||||
# the same (playlist, video) in between, and uq_playlist_video then refuses the re-parent.
|
||||
# That is the already-present outcome arriving late, not a failure — answer it the same way
|
||||
# instead of a raw 500.
|
||||
try:
|
||||
with db.begin_nested():
|
||||
item.playlist_id = dst.id
|
||||
item.position = _next_item_position(db, dst.id)
|
||||
db.flush()
|
||||
dst_changed = True
|
||||
# silent-ok: the conflict IS the answer — the target already holds the video, which is the
|
||||
# idempotent outcome; the savepoint put the row back in the source list, dropped below.
|
||||
except IntegrityError:
|
||||
pass
|
||||
if not dst_changed:
|
||||
db.delete(item)
|
||||
# The source always changed; the target only when the row landed in it. `_touch` on a target
|
||||
# that already had the video promoted an untouched list in the rail's "recently changed" sort.
|
||||
recompute_dirty(db, src)
|
||||
recompute_dirty(db, dst)
|
||||
_touch(src)
|
||||
_touch(dst)
|
||||
if dst_changed:
|
||||
recompute_dirty(db, dst)
|
||||
_touch(dst)
|
||||
db.commit()
|
||||
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
|
||||
|
||||
@@ -599,7 +619,7 @@ def reorder_items(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
order = body.video_ids
|
||||
items = {
|
||||
it.video_id: it
|
||||
@@ -625,4 +645,4 @@ def reorder_items(
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
return {"playlist_id": pl.id, "count": pos}
|
||||
return {"ok": True, "playlist_id": pl.id, "count": pos}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Shared helpers, serializers and constants for the plex route package."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app.models import PlexItem
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes._common import not_found
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
@@ -13,7 +13,7 @@ log = logs.get_logger(__name__)
|
||||
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||
it = by_rating_key(db, PlexItem, rating_key)
|
||||
if it is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
||||
raise not_found("plex_item")
|
||||
return it
|
||||
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app import logs, sysconfig
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
@@ -20,6 +20,9 @@ from app.plex import sync as plex_sync
|
||||
from app.plex import watch_sync as plex_watch
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.utils import iso
|
||||
from app.refusal import refused
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
@@ -35,10 +38,15 @@ def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
info = plex.server_info()
|
||||
sections = plex.sections()
|
||||
media_check = _media_check(db, plex, sections)
|
||||
except PlexNotConfigured as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except PlexNotConfigured:
|
||||
raise refused(400, "plex_not_configured") from None
|
||||
except PlexError as e:
|
||||
raise HTTPException(status_code=422, detail=f"Plex connection failed: {e}")
|
||||
# What the upstream said is for the log, not the toast: it can quote the server URL back.
|
||||
log.warning(
|
||||
"the Plex connection test failed",
|
||||
extra=logs.event("plex.admin.connection_failed", reason=type(e).__name__, error=str(e)),
|
||||
)
|
||||
raise refused(422, "plex_unreachable") from None
|
||||
return {
|
||||
"ok": True,
|
||||
"server_name": info.get("friendlyName") or info.get("title1") or "Plex",
|
||||
@@ -95,7 +103,7 @@ def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict:
|
||||
def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a
|
||||
while on a large library); a background/scheduled sync also runs periodically."""
|
||||
return plex_sync.sync(db)
|
||||
return {"ok": True, **plex_sync.sync(db)}
|
||||
|
||||
|
||||
def _link_dict(link: PlexLink | None) -> dict:
|
||||
@@ -129,7 +137,7 @@ def watch_link_set(
|
||||
admin token, so no separate Plex login. Enabling it for the first time runs the one-time
|
||||
"Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode."""
|
||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||
raise HTTPException(status_code=409, detail="The Plex module is disabled.")
|
||||
raise refused(409, "plex_disabled")
|
||||
enabled = body.enabled
|
||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||
if link is None:
|
||||
@@ -141,7 +149,7 @@ def watch_link_set(
|
||||
result: dict = {}
|
||||
if enabled and not link.initial_import_done:
|
||||
result = plex_watch.import_owner_watch_state(db, link)
|
||||
return {**_link_dict(link), "import": result or None}
|
||||
return {"ok": True, **_link_dict(link), "import": result or None}
|
||||
|
||||
|
||||
@router.post("/watch/import")
|
||||
@@ -150,6 +158,6 @@ def watch_import_now(user: User = Depends(admin_user), db: Session = Depends(get
|
||||
intersection; Siftlode-only states are preserved. Idempotent."""
|
||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||
if link is None or not link.sync_enabled:
|
||||
raise HTTPException(status_code=409, detail="Enable Plex watch-sync first.")
|
||||
raise refused(409, "watch_sync_off")
|
||||
result = plex_watch.import_owner_watch_state(db, link)
|
||||
return {**_link_dict(link), "import": result}
|
||||
return {"ok": True, **_link_dict(link), "import": result}
|
||||
|
||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
@@ -26,6 +27,10 @@ from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.query import content_libs, runtime_by_key, top_art_keys
|
||||
from app.utils import like_escape
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
|
||||
# members (Plex shows one cert on the collection; we don't store it, so derive it).
|
||||
@@ -234,7 +239,7 @@ def collection_detail(
|
||||
endpoints below."""
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
if col is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
||||
raise not_found("collection")
|
||||
movies = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
@@ -325,9 +330,9 @@ def collection_detail(
|
||||
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
if col is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
||||
raise not_found("collection")
|
||||
if not _collection_editable(col):
|
||||
raise HTTPException(status_code=403, detail="This collection is read-only")
|
||||
raise refused(403, "collection_read_only")
|
||||
return col
|
||||
|
||||
|
||||
@@ -362,14 +367,13 @@ def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException:
|
||||
Both use 422, NOT a 5xx: the frontend treats 502/503/504 as "gateway unreachable" and raises a
|
||||
sticky offline banner (see api/core.ts RETRIABLE_GATEWAY). A 422 surfaces the detail as an ordinary
|
||||
action-error toast, which is what both of these are — the app answered, it didn't go unreachable."""
|
||||
if wrote:
|
||||
detail = (
|
||||
"Collection updated in Plex, but refreshing the local copy failed; "
|
||||
f"it will reconcile on the next sync ({e})"
|
||||
)
|
||||
else:
|
||||
detail = f"Plex write failed: {e}"
|
||||
return HTTPException(status_code=422, detail=detail)
|
||||
log.warning(
|
||||
"a Plex collection write did not settle",
|
||||
extra=logs.event(
|
||||
"plex.collections.write_failed", wrote=wrote, reason=type(e).__name__, error=str(e)
|
||||
),
|
||||
)
|
||||
return refused(422, "collection_pending_sync") if wrote else refused(422, "plex_write_failed")
|
||||
|
||||
|
||||
class CreateCollectionIn(BaseModel):
|
||||
@@ -389,7 +393,7 @@ def create_collection(
|
||||
title = body.title.strip()
|
||||
seed = body.item_rating_key
|
||||
if not title or not seed:
|
||||
raise HTTPException(status_code=400, detail="title and item_rating_key are required")
|
||||
raise refused(400, "collection_fields_required")
|
||||
if library:
|
||||
lib = (
|
||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
||||
@@ -399,7 +403,7 @@ def create_collection(
|
||||
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
|
||||
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||
if lib is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown library")
|
||||
raise not_found("library")
|
||||
_reject_duplicate_name(db, lib.id, title)
|
||||
wrote = False
|
||||
try:
|
||||
@@ -473,7 +477,7 @@ def collection_rename(
|
||||
col = _editable_collection_or_403(db, rating_key)
|
||||
title = body.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
raise refused(400, "title_required")
|
||||
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
wrote = False
|
||||
@@ -502,7 +506,7 @@ def collection_delete(
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
||||
raise _write_or_pending_error(False, e) from None
|
||||
return {"deleted": rating_key}
|
||||
|
||||
|
||||
@@ -521,13 +525,10 @@ def collection_set_editable(
|
||||
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
if col is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
||||
raise not_found("collection")
|
||||
want = body.editable
|
||||
if want and (col.smart or _collection_source(col) != "collection"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Smart / auto-list collections can't be made editable",
|
||||
)
|
||||
raise refused(400, "collection_smart")
|
||||
col.editable = want
|
||||
db.commit()
|
||||
return _collection_card(col)
|
||||
|
||||
@@ -5,7 +5,7 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
||||
from app.routes.plex.collections import _collection_source
|
||||
from app.routes._common import not_found
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
@@ -109,7 +110,7 @@ def show_detail(
|
||||
hu = _norm_lang(lang) == "hu"
|
||||
sh = by_rating_key(db, PlexShow, rating_key)
|
||||
if sh is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
||||
raise not_found("show")
|
||||
seasons = (
|
||||
db.execute(
|
||||
select(PlexSeason)
|
||||
|
||||
@@ -32,12 +32,12 @@ from app.plex.query import (
|
||||
meta_filter_conds,
|
||||
top_art_keys,
|
||||
)
|
||||
from app.discover.queries import RAIL_LIMIT
|
||||
from app.plex.tmdb import resolve_recs
|
||||
from app.routes.plex._shared import _norm_lang
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
RAIL_LIMIT = 20
|
||||
GENRE_TILES = 12
|
||||
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ from fastapi import (
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user, resolved_user_id
|
||||
@@ -38,6 +39,8 @@ from app.plex import stream as plex_stream
|
||||
from app.plex import watch_sync as plex_watch
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.routes.plex._shared import item_or_404
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
@@ -48,7 +51,7 @@ def _require_session(request: Request) -> int:
|
||||
low-sensitivity; a valid session is enough."""
|
||||
uid, _ = resolved_user_id(request)
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
raise refused(401, "not_authenticated")
|
||||
return uid
|
||||
|
||||
|
||||
@@ -77,15 +80,17 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
||||
with SessionLocal() as db:
|
||||
key = imgcache.key_for(db, str(rating_key), v)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="No image")
|
||||
raise not_found("image")
|
||||
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
||||
except PlexNotConfigured as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except PlexNotConfigured:
|
||||
raise refused(400, "plex_not_configured") from None
|
||||
try:
|
||||
with plex:
|
||||
data, ct = plex.image_bytes(key, w, h)
|
||||
except PlexError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
||||
# silent-ok: a byte proxy for an <img>; the browser shows the placeholder and no reader sees the
|
||||
# detail — the 502 is the whole answer, as for the person-image proxy below.
|
||||
except PlexError:
|
||||
raise refused(502, "image_fetch_failed") from None
|
||||
imgcache.write(cache_key, data, ct)
|
||||
return imgcache.response(data, ct)
|
||||
|
||||
@@ -120,7 +125,7 @@ def subtitle_status(
|
||||
|
||||
`at` is the viewer's absolute position: the extractor windows around it, so what gets read first
|
||||
is the part actually about to be watched."""
|
||||
return subtitles.ensure(rating_key, sub_ord, at)
|
||||
return subtitles.ensure(rating_key, sub_ord, at, viewer=user.id)
|
||||
|
||||
|
||||
@router.get("/subtitle/{rating_key}/{sub_ord}")
|
||||
@@ -213,7 +218,7 @@ def person_image(u: str, request: Request) -> Response:
|
||||
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
|
||||
_require_session(request)
|
||||
if not u.startswith(plex_people.PERSON_IMG_HOST):
|
||||
raise HTTPException(status_code=400, detail="Unsupported image host")
|
||||
raise refused(400, "image_host")
|
||||
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
|
||||
hit = imgcache.read(cache_key)
|
||||
if hit:
|
||||
@@ -223,7 +228,7 @@ def person_image(u: str, request: Request) -> Response:
|
||||
r = c.get(u)
|
||||
r.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(status_code=502, detail="Image fetch failed")
|
||||
raise refused(502, "image_fetch_failed")
|
||||
ct = r.headers.get("content-type", "image/jpeg")
|
||||
imgcache.write(cache_key, r.content, ct)
|
||||
return imgcache.response(r.content, ct)
|
||||
@@ -295,6 +300,37 @@ def _push_watch(
|
||||
)
|
||||
|
||||
|
||||
def _mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bool:
|
||||
"""Mark the item watched (position cleared) and say whether THIS call made it so. Two near-end
|
||||
checkpoints arrive together routinely (the 10 s tick and the debounced `seeked` flush), and a
|
||||
read-then-write let both see "not watched" and both scrobble. The transition is decided by the
|
||||
write itself: an insert that did not conflict, or an update conditional on the old status —
|
||||
under READ COMMITTED the second writer re-reads the committed row and matches nothing."""
|
||||
values = {
|
||||
"status": "watched",
|
||||
"watched_at": now,
|
||||
"position_seconds": 0,
|
||||
"progress_updated_at": now,
|
||||
"synced_to_plex": False,
|
||||
}
|
||||
inserted = db.execute(
|
||||
pg_insert(PlexState)
|
||||
.values(user_id=user_id, item_id=item_id, **values)
|
||||
.on_conflict_do_nothing(constraint="uq_plex_user_item")
|
||||
.returning(PlexState.id)
|
||||
).first()
|
||||
if inserted is not None:
|
||||
return True
|
||||
mine = (PlexState.user_id == user_id) & (PlexState.item_id == item_id)
|
||||
if db.execute(
|
||||
update(PlexState).where(mine, PlexState.status != "watched").values(values)
|
||||
).rowcount:
|
||||
return True
|
||||
# Already watched: a re-finish still restamps the row, as it always did — just no scrobble.
|
||||
db.execute(update(PlexState).where(mine).values(values))
|
||||
return False
|
||||
|
||||
|
||||
class ItemProgressIn(BaseModel):
|
||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
||||
# one into a 500.
|
||||
@@ -330,11 +366,27 @@ def item_progress(
|
||||
# finish latch is up, or before the first position is known. Those are the states in which
|
||||
# reaping is the right answer anyway, and a viewer who scrubs back into a reaped session gets
|
||||
# the same 404 → reclaim the player already handles.
|
||||
plex_stream.touch_session(str(rating_key))
|
||||
plex_stream.touch_session(user.id, str(rating_key))
|
||||
position = max(0, int(body.position_seconds))
|
||||
duration = int(body.duration_seconds)
|
||||
final = body.final
|
||||
now = datetime.now(timezone.utc)
|
||||
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
||||
if near_end:
|
||||
flipped = _mark_finished(db, user.id, it.id, now)
|
||||
# The finish supersedes every resume push still in flight for this item: stamping it as the
|
||||
# latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep
|
||||
# the map small) removed the very guard, and an older resume push could then land at Plex
|
||||
# AFTER the scrobble and revive a resume point on a watched item. Eviction bounds the map.
|
||||
with _push_throttle_lock:
|
||||
_last_push_at[(user.id, it.id)] = now
|
||||
db.commit()
|
||||
# Only the checkpoint that flipped the row scrobbles: a later checkpoint of the same finished
|
||||
# item — or a second one racing this one — would otherwise bump Plex's viewCount again.
|
||||
if flipped:
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||
return {"ok": True, "status": "watched", "position_seconds": 0}
|
||||
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
||||
@@ -342,28 +394,6 @@ def item_progress(
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
was_watched = st is not None and st.status == "watched"
|
||||
|
||||
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
||||
if near_end:
|
||||
if st is None:
|
||||
st = PlexState(user_id=user.id, item_id=it.id)
|
||||
db.add(st)
|
||||
st.status = "watched"
|
||||
st.watched_at = now
|
||||
st.position_seconds = 0
|
||||
st.progress_updated_at = now
|
||||
st.synced_to_plex = False
|
||||
# Terminal state for resume write-back: drop the throttle/ordering entry so the map doesn't
|
||||
# retain a dead key for every finished item.
|
||||
with _push_throttle_lock:
|
||||
_last_push_at.pop((user.id, it.id), None)
|
||||
db.commit()
|
||||
# Immediate on the watched transition; skip a repeat scrobble if it was already watched
|
||||
# (a later checkpoint of the same finished item would otherwise bump Plex's viewCount again).
|
||||
if not was_watched:
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||
return {"status": "watched", "position_seconds": 0}
|
||||
|
||||
if position < _PROGRESS_MIN_S:
|
||||
if st is not None and st.status == "new":
|
||||
@@ -372,7 +402,7 @@ def item_progress(
|
||||
st.position_seconds = 0
|
||||
st.progress_updated_at = now
|
||||
db.commit()
|
||||
return {"position_seconds": 0}
|
||||
return {"ok": True, "position_seconds": 0}
|
||||
|
||||
if st is None:
|
||||
st = PlexState(user_id=user.id, item_id=it.id)
|
||||
@@ -397,7 +427,7 @@ def item_progress(
|
||||
duration * 1000,
|
||||
intent=now,
|
||||
)
|
||||
return {"position_seconds": position}
|
||||
return {"ok": True, "position_seconds": position}
|
||||
|
||||
|
||||
class ItemStateIn(BaseModel):
|
||||
@@ -418,7 +448,7 @@ def item_state(
|
||||
it = item_or_404(db, rating_key)
|
||||
status = body.status
|
||||
if status not in ("new", "watched", "hidden"):
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
raise refused(400, "invalid_status")
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
||||
@@ -443,7 +473,7 @@ def item_state(
|
||||
db.commit()
|
||||
if mirrored_on_plex:
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
||||
return {"status": "new"}
|
||||
return {"ok": True, "status": "new"}
|
||||
if st is None:
|
||||
st = PlexState(user_id=user.id, item_id=it.id)
|
||||
db.add(st)
|
||||
@@ -455,7 +485,7 @@ def item_state(
|
||||
db.commit()
|
||||
if status == "watched" and prev_status != "watched":
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||
return {"status": status}
|
||||
return {"ok": True, "status": status}
|
||||
|
||||
|
||||
def _bulk_state(
|
||||
@@ -497,7 +527,9 @@ def _bulk_state(
|
||||
db.delete(st)
|
||||
if prev != "new":
|
||||
to_push.append((e.id, e.rating_key, "unwatched"))
|
||||
db.commit()
|
||||
# Flush, not commit: the handler commits, and it does so before the response — which is when
|
||||
# the background push below runs, so the push always reads the committed marks.
|
||||
db.flush()
|
||||
if pushable and to_push:
|
||||
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
||||
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
||||
@@ -520,7 +552,7 @@ def show_state(
|
||||
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
|
||||
sh = by_rating_key(db, PlexShow, rating_key)
|
||||
if sh is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
||||
raise not_found("show")
|
||||
watched = body.watched
|
||||
eps = (
|
||||
db.execute(select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode"))
|
||||
@@ -528,7 +560,8 @@ def show_state(
|
||||
.all()
|
||||
)
|
||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||
return {"changed": changed, "watched": watched}
|
||||
db.commit()
|
||||
return {"ok": True, "changed": changed, "watched": watched}
|
||||
|
||||
|
||||
@router.post("/season/{rating_key}/state")
|
||||
@@ -542,7 +575,7 @@ def season_state(
|
||||
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
|
||||
se = by_rating_key(db, PlexSeason, rating_key)
|
||||
if se is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex season")
|
||||
raise not_found("season")
|
||||
watched = body.watched
|
||||
eps = (
|
||||
db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode"))
|
||||
@@ -550,7 +583,8 @@ def season_state(
|
||||
.all()
|
||||
)
|
||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||
return {"changed": changed, "watched": watched}
|
||||
db.commit()
|
||||
return {"ok": True, "changed": changed, "watched": watched}
|
||||
|
||||
|
||||
@router.post("/stream/{rating_key}/session")
|
||||
@@ -575,21 +609,23 @@ def stream_session(
|
||||
# the client can switch audio without a restart — even for otherwise direct-playable files.
|
||||
if it.playable == "direct" and audio is None and not aoff and not multi:
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "direct",
|
||||
"url": f"/api/plex/stream/{it.rating_key}/file",
|
||||
"start_s": 0.0,
|
||||
}
|
||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
s = plex_stream.start_session(db, it, start, audio, aoff, multi, transcode=transcode)
|
||||
raise refused(404, "media_missing")
|
||||
s = plex_stream.start_session(db, it, user.id, start, audio, aoff, multi, transcode=transcode)
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
raise refused(404, "media_missing")
|
||||
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
||||
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
||||
# `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
|
||||
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
|
||||
# segment streams are otherwise identical.
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "hls",
|
||||
"start_s": s.media_start_s,
|
||||
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
|
||||
@@ -614,7 +650,7 @@ def stream_file(
|
||||
it = item_or_404(db, rating_key)
|
||||
src = plex_paths.local_media_path(db, it.file_path)
|
||||
if src is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
raise refused(404, "media_missing")
|
||||
if download:
|
||||
# A clean, safe download name from the title + the real file extension.
|
||||
ext = src.suffix or (f".{it.container}" if it.container else "")
|
||||
@@ -651,9 +687,9 @@ def stream_hls_file(
|
||||
references its segments/subtitles by relative name, all resolving back here.
|
||||
|
||||
The session id in the path is what makes "back here" mean the same session. Sessions are keyed by
|
||||
rating_key alone and a seek-restart or a second viewer of the same item replaces the registered
|
||||
one, while the DIRECTORY is per (key, offset, tag) — so resolving by rating_key handed the old
|
||||
viewer the new session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
|
||||
(viewer, rating_key) and a seek-restart replaces the viewer's registered one, while the DIRECTORY
|
||||
is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new
|
||||
session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
|
||||
bytes with a 200, which the player has no way to notice (deep-review round 5). A request for a
|
||||
session that is no longer the registered one is a 404, and the player reclaims at the playhead.
|
||||
|
||||
@@ -668,10 +704,10 @@ def stream_hls_file(
|
||||
or "\\" in filename
|
||||
or ".." in filename
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
s = plex_stream.current_session(str(rating_key))
|
||||
raise not_found("file")
|
||||
s = plex_stream.current_session(user.id, str(rating_key))
|
||||
if s is None or s.id != session_id:
|
||||
raise HTTPException(status_code=404, detail="No active playback session")
|
||||
raise refused(404, "session_gone")
|
||||
path = s.dir / filename
|
||||
# Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
|
||||
# segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them
|
||||
@@ -681,14 +717,14 @@ def stream_hls_file(
|
||||
# past the produced region — or a deliberate flood of hopeless names — from parking threadpool
|
||||
# workers and their DB connections (see `artifact_may_arrive`).
|
||||
if not plex_stream.artifact_may_arrive(s, filename):
|
||||
raise HTTPException(status_code=404, detail="Not available yet")
|
||||
raise refused(404, "not_ready")
|
||||
timeout = 30 if ext == ".m3u8" else 20
|
||||
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
|
||||
# whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection
|
||||
# with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek
|
||||
# past the produced region was enough to do it (round 5).
|
||||
if not plex_stream.wait_for(path, timeout, proc=s.proc):
|
||||
raise HTTPException(status_code=404, detail="Not available yet")
|
||||
raise refused(404, "not_ready")
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except OSError:
|
||||
@@ -699,7 +735,7 @@ def stream_hls_file(
|
||||
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
|
||||
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
|
||||
# instance's in-flight segment request races the rmtree on every seek-restart.
|
||||
raise HTTPException(status_code=404, detail="No active playback session") from None
|
||||
raise refused(404, "session_gone") from None
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=_HLS_CT[ext],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -19,15 +19,12 @@ from app.models import (
|
||||
)
|
||||
from app.plex.cards import leaf_card
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes._common import owned_or_404
|
||||
from app.routes._common import owned_or_404, not_found
|
||||
from app.refusal import refused
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
|
||||
def _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist:
|
||||
return owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
|
||||
|
||||
def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict:
|
||||
return {
|
||||
"id": pl.id,
|
||||
@@ -120,7 +117,7 @@ def create_playlist(
|
||||
) -> dict:
|
||||
title = body.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
raise refused(400, "title_required")
|
||||
pl = PlexPlaylist(user_id=user.id, title=title)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
@@ -140,7 +137,7 @@ def create_playlist(
|
||||
def playlist_detail(
|
||||
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
rows = db.execute(_playlist_item_query(pid)).all()
|
||||
items = [it for (_pi, it) in rows]
|
||||
states = {
|
||||
@@ -183,10 +180,10 @@ def rename_playlist(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
title = body.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
raise refused(400, "title_required")
|
||||
pl.title = title
|
||||
db.commit()
|
||||
count = (
|
||||
@@ -203,7 +200,7 @@ def rename_playlist(
|
||||
def delete_playlist(
|
||||
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
db.delete(pl)
|
||||
db.commit()
|
||||
return {"deleted": pid}
|
||||
@@ -220,10 +217,10 @@ def playlist_add_item(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
it = by_rating_key(db, PlexItem, body.item_rating_key)
|
||||
if it is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown item")
|
||||
raise not_found("item")
|
||||
exists = (
|
||||
db.execute(
|
||||
select(PlexPlaylistItem).where(
|
||||
@@ -252,7 +249,7 @@ def playlist_remove_item(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
it = by_rating_key(db, PlexItem, item_rating_key)
|
||||
if it is not None:
|
||||
db.execute(
|
||||
@@ -297,7 +294,7 @@ def playlist_add_bulk(
|
||||
) -> dict:
|
||||
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
|
||||
order is preserved for the newly-added ones. Returns how many were added + the new total."""
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
items = _items_by_rks(db, body.item_rating_keys)
|
||||
existing = set(
|
||||
db.execute(
|
||||
@@ -328,7 +325,7 @@ def playlist_add_bulk(
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"added": added, "item_count": total}
|
||||
return {"ok": True, "added": added, "item_count": total}
|
||||
|
||||
|
||||
@router.post("/playlists/{pid}/items/remove-bulk")
|
||||
@@ -340,7 +337,7 @@ def playlist_remove_bulk(
|
||||
) -> dict:
|
||||
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
|
||||
removed + the new total."""
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
|
||||
removed = 0
|
||||
if ids:
|
||||
@@ -359,7 +356,7 @@ def playlist_remove_bulk(
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"removed": removed, "item_count": total}
|
||||
return {"ok": True, "removed": removed, "item_count": total}
|
||||
|
||||
|
||||
@router.put("/playlists/{pid}/order")
|
||||
@@ -370,7 +367,7 @@ def reorder_playlist(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
order = body.item_rating_keys
|
||||
id_by_rk = {
|
||||
it.rating_key: it.id
|
||||
|
||||
@@ -6,7 +6,7 @@ the app or any account data. Password-protected links exchange the password once
|
||||
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
@@ -19,6 +19,8 @@ from app.downloads import storage
|
||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||
from app.ratelimit import RateLimiter, client_key
|
||||
from app.security import verify_password
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||
|
||||
@@ -37,7 +39,7 @@ _unlock_failures = RateLimiter(max_events=50, window_seconds=300)
|
||||
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
||||
if link is None or linksmod.is_expired(link):
|
||||
raise HTTPException(status_code=404, detail="This link is no longer available.")
|
||||
raise refused(404, "link_gone")
|
||||
return link
|
||||
|
||||
|
||||
@@ -45,12 +47,12 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||
if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
|
||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||
raise refused(404, "video_gone")
|
||||
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
|
||||
# loads a player for a missing file — meta and file agree.
|
||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||
raise refused(404, "video_gone")
|
||||
return job, asset
|
||||
|
||||
|
||||
@@ -100,22 +102,24 @@ def watch_unlock(
|
||||
link = _link_or_404(db, token)
|
||||
viewer = f"{token}|{client_key(request)}"
|
||||
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
|
||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
|
||||
raise refused(429, "too_many_attempts")
|
||||
if not link.password_hash:
|
||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||
job, asset = _ready_target(db, link)
|
||||
return {
|
||||
"ok": True,
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||
}
|
||||
if not verify_password(body.password, link.password_hash):
|
||||
_unlock_failures.allow(token)
|
||||
raise HTTPException(status_code=403, detail="Wrong password.")
|
||||
raise refused(403, "wrong_share_password")
|
||||
job, asset = _ready_target(db, link)
|
||||
link.view_count += 1
|
||||
db.commit()
|
||||
grant = linksmod.make_grant(token, link.password_version)
|
||||
return {
|
||||
"ok": True,
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(
|
||||
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
||||
@@ -129,11 +133,11 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
`allow_download` link serves it as an attachment. Password links require a valid grant."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||
raise HTTPException(status_code=403, detail="This link needs a password.")
|
||||
raise refused(403, "share_password_required")
|
||||
asset = _ready_asset(db, link)
|
||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||
raise refused(404, "video_gone")
|
||||
|
||||
filename = storage.download_filename(asset.title or "video", asset.container, path)
|
||||
# The served Content-Type comes from the asset's ffprobe-verified media_kind + a container
|
||||
@@ -163,11 +167,11 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)
|
||||
and the link-preview image). Password links require a valid grant, like the file."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||
raise HTTPException(status_code=403, detail="This link needs a password.")
|
||||
raise refused(403, "share_password_required")
|
||||
job, asset = _ready_target(db, link)
|
||||
if not asset.poster_path:
|
||||
raise HTTPException(status_code=404, detail="No poster.")
|
||||
raise not_found("poster")
|
||||
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="No poster.")
|
||||
raise not_found("poster")
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -8,6 +8,7 @@ from app.auth import require_human
|
||||
from app.db import get_db
|
||||
from app.models import SavedView, User
|
||||
from app.routes._common import owned_or_404
|
||||
from app.refusal import refused
|
||||
|
||||
# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
|
||||
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list
|
||||
@@ -66,7 +67,7 @@ def create_view(
|
||||
) -> dict:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
raise refused(400, "name_required")
|
||||
source = _norm_source(body.source)
|
||||
# Position is per (user, source), so each module's views number 0..N independently.
|
||||
maxpos = db.execute(
|
||||
@@ -86,14 +87,10 @@ def create_view(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail=f"You already have a view named “{name}”.")
|
||||
raise refused(409, "view_name_taken", name=name)
|
||||
return _serialize(view)
|
||||
|
||||
|
||||
def _own_view(db: Session, user: User, view_id: int) -> SavedView:
|
||||
return owned_or_404(db, SavedView, view_id, user, what="view")
|
||||
|
||||
|
||||
class UpdateViewIn(BaseModel):
|
||||
# PATCH: presence decides what's touched (read via model_fields_set). filters, when sent,
|
||||
# must be a real object — an explicit null is rejected (a view can't have null filters).
|
||||
@@ -109,16 +106,16 @@ def update_view(
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
view = _own_view(db, user, view_id)
|
||||
view = owned_or_404(db, SavedView, view_id, user, what="view")
|
||||
sent = body.model_fields_set
|
||||
if "name" in sent:
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
||||
raise refused(400, "name_required")
|
||||
view.name = name[:80]
|
||||
if "filters" in sent:
|
||||
if body.filters is None:
|
||||
raise HTTPException(status_code=400, detail="filters must be an object")
|
||||
raise refused(400, "filters_not_object")
|
||||
view.filters = body.filters
|
||||
if "is_default" in sent:
|
||||
make_default = bool(body.is_default)
|
||||
@@ -138,7 +135,7 @@ def update_view(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail=f"You already have a view named “{view.name}”.")
|
||||
raise refused(409, "view_name_taken", name=view.name)
|
||||
return _serialize(view)
|
||||
|
||||
|
||||
@@ -148,7 +145,7 @@ def delete_view(
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
view = _own_view(db, user, view_id)
|
||||
view = owned_or_404(db, SavedView, view_id, user, what="view")
|
||||
db.delete(view)
|
||||
db.commit()
|
||||
return {"deleted": view_id}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
|
||||
and the shared quota picture."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -25,6 +25,8 @@ from app.scheduler import (
|
||||
from app.sync.explore import followed_channel
|
||||
from app.sync.runner import estimate_deep_backfill
|
||||
from app.routes._common import not_found
|
||||
from app.refusal import refused
|
||||
from app.refusal import setting_refused
|
||||
|
||||
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
||||
|
||||
@@ -46,10 +48,7 @@ def set_job_interval(
|
||||
raise not_found("job")
|
||||
minutes = body.interval_minutes
|
||||
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
|
||||
)
|
||||
raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL)
|
||||
applied = apply_interval(db, job_id, minutes)
|
||||
audit.record(
|
||||
db,
|
||||
@@ -61,7 +60,7 @@ def set_job_interval(
|
||||
after={"interval_minutes": applied},
|
||||
)
|
||||
db.commit()
|
||||
return {"id": job_id, "interval_minutes": applied}
|
||||
return {"ok": True, "id": job_id, "interval_minutes": applied}
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/run")
|
||||
@@ -77,14 +76,14 @@ def run_job_now(
|
||||
except KeyError:
|
||||
raise not_found("job")
|
||||
if result == "already_running":
|
||||
raise HTTPException(status_code=409, detail="Job is already running")
|
||||
return {"id": job_id, "started": True}
|
||||
raise refused(409, "job_running")
|
||||
return {"ok": True, "id": job_id, "started": True}
|
||||
|
||||
|
||||
@router.post("/run-all")
|
||||
def run_all_now(user: User = Depends(admin_user)) -> dict:
|
||||
"""Trigger every job that isn't already running. Returns the ids actually started."""
|
||||
return {"started": trigger_all(actor_id=user.id)}
|
||||
return {"ok": True, "started": trigger_all(actor_id=user.id)}
|
||||
|
||||
|
||||
class MaintenanceSettingsIn(BaseModel):
|
||||
@@ -102,8 +101,8 @@ def set_maintenance_settings(
|
||||
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
|
||||
try:
|
||||
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
||||
except sysconfig.InvalidSetting as exc:
|
||||
raise setting_refused(exc) from None
|
||||
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
||||
audit.record(
|
||||
db,
|
||||
@@ -113,7 +112,7 @@ def set_maintenance_settings(
|
||||
after={"revalidate_batch": new},
|
||||
)
|
||||
db.commit()
|
||||
return {"revalidate_batch": new}
|
||||
return {"ok": True, "revalidate_batch": new}
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
||||
@@ -16,7 +16,7 @@ dropped from the search response up front)."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, delete, func, or_, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
@@ -44,6 +44,8 @@ from app.sync.videos import insert_stubs, apply_video_details, parse_dt
|
||||
from app.youtube import search_scrape
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
from app.youtube.shorts import make_client, probe_is_short
|
||||
from app.refusal import refused
|
||||
from app.routes._common import youtube_refusal
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
@@ -258,7 +260,7 @@ def search_youtube(
|
||||
page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further."""
|
||||
term = (q or "").strip()
|
||||
if not term:
|
||||
raise HTTPException(status_code=400, detail="A search term is required.")
|
||||
raise refused(400, "search_term_required")
|
||||
limit = max(1, min(limit, 100))
|
||||
|
||||
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
||||
@@ -275,20 +277,14 @@ def search_youtube(
|
||||
"a live search was refused: the per-user daily cap is spent",
|
||||
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
|
||||
)
|
||||
raise refused(429, "search_daily_limit", limit=daily_limit)
|
||||
# The 100-unit budget pre-check only applies to the API source.
|
||||
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
||||
log.info(
|
||||
"a live search was refused: the shared YouTube budget is spent",
|
||||
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
|
||||
)
|
||||
raise refused(429, "quota_spent")
|
||||
|
||||
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
|
||||
blocked = set(
|
||||
@@ -313,9 +309,14 @@ def search_youtube(
|
||||
# dropout (auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY),
|
||||
# which hides the real message and burns a second quota-costed search. Upstream failure
|
||||
# on a JSON endpoint = 422 + message; reserve 5xx for the raw byte-proxies. (B-core-1)
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"YouTube search failed: {exc}"
|
||||
) from exc
|
||||
# A Data API failure is logged where it happened; the scraper's is not, and its text
|
||||
# no longer rides the answer — so it is said here.
|
||||
if isinstance(exc, search_scrape.ScrapeError):
|
||||
log.warning(
|
||||
"the YouTube search scrape failed",
|
||||
extra=logs.event("search.scrape_failed", error=str(exc)),
|
||||
)
|
||||
raise youtube_refusal(exc) from exc
|
||||
|
||||
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
|
||||
candidates = [
|
||||
@@ -357,9 +358,7 @@ def search_youtube(
|
||||
)
|
||||
if collected:
|
||||
break # keep what earlier pages already gathered
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"YouTube search failed: {exc}"
|
||||
) from exc
|
||||
raise youtube_refusal(exc) from exc
|
||||
next_cursor = page["next_page_token"]
|
||||
# The API source pages cost 100 units each — never auto-page; one page per request.
|
||||
if source == "api" or len(collected) >= limit or not next_cursor:
|
||||
@@ -412,7 +411,7 @@ def clear_search(
|
||||
find. Kept videos and organically-owned ones are left untouched."""
|
||||
ids = body.video_ids
|
||||
if not ids:
|
||||
return {"removed": 0}
|
||||
return {"ok": True, "removed": 0}
|
||||
db.execute(
|
||||
delete(SearchFind).where(SearchFind.user_id == user.id, SearchFind.video_id.in_(ids))
|
||||
)
|
||||
@@ -432,4 +431,4 @@ def clear_search(
|
||||
)
|
||||
).rowcount
|
||||
db.commit()
|
||||
return {"removed": removed}
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
+14
-14
@@ -8,7 +8,7 @@ X-Setup-Token header).
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -21,6 +21,8 @@ from app.auth import PASSWORD_MIN_LEN
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
from app.security import hash_password
|
||||
from app.refusal import refused
|
||||
from app.refusal import setting_refused
|
||||
|
||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||
|
||||
@@ -31,9 +33,9 @@ def require_setup(
|
||||
) -> Session:
|
||||
"""Gate for setup steps. Returns the db session for the handler to reuse."""
|
||||
if state.is_configured(db):
|
||||
raise HTTPException(status_code=409, detail="This instance is already set up.")
|
||||
raise refused(409, "already_set_up")
|
||||
if not state.check_setup_token(db, x_setup_token):
|
||||
raise HTTPException(status_code=403, detail="Invalid or missing setup token.")
|
||||
raise refused(403, "setup_token")
|
||||
return db
|
||||
|
||||
|
||||
@@ -66,11 +68,9 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
|
||||
email = body.email.strip().lower()
|
||||
password = body.password
|
||||
if "@" not in email:
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
raise refused(400, "invalid_email")
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
|
||||
)
|
||||
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
|
||||
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
|
||||
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
|
||||
@@ -97,16 +97,16 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
|
||||
saved: list[str] = []
|
||||
for key, value in settings_map.items():
|
||||
if sysconfig.spec(key) is None:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
|
||||
raise refused(400, "unknown_setting", key=key)
|
||||
if value is None or value == "":
|
||||
continue
|
||||
try:
|
||||
sysconfig.set_value(db, key, value)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except sysconfig.InvalidSetting as exc:
|
||||
raise setting_refused(exc) from None
|
||||
saved.append(key)
|
||||
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
||||
return {"saved": saved}
|
||||
return {"ok": True, "saved": saved}
|
||||
|
||||
|
||||
class SetupTestEmailIn(BaseModel):
|
||||
@@ -121,9 +121,9 @@ def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup
|
||||
"""Send a test email to confirm the SMTP settings the wizard just saved."""
|
||||
to = body.to.strip()
|
||||
if "@" not in to:
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
raise refused(400, "invalid_email")
|
||||
if not email_mod.send_test(to, mailtext.normalize(body.lang)):
|
||||
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
|
||||
raise refused(400, "smtp_failed")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -133,6 +133,6 @@ def setup_finish(db: Session = Depends(require_setup)) -> dict:
|
||||
invalidates the setup token and disables the wizard)."""
|
||||
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
|
||||
if not has_admin:
|
||||
raise HTTPException(status_code=400, detail="Create the admin account first.")
|
||||
raise refused(400, "admin_first")
|
||||
state.mark_configured(db)
|
||||
return {"ok": True}
|
||||
|
||||
+16
-10
@@ -7,10 +7,13 @@ from app.audit import AuditAction
|
||||
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.routes._common import youtube_refusal
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.sync.runner import estimate_deep_backfill
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
from app.scheduler import running_job_ids
|
||||
from app.youtube.client import YouTubeError
|
||||
from app.refusal import refused
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
@@ -26,17 +29,20 @@ _sync_limiter = RateLimiter(max_events=3, window_seconds=600)
|
||||
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||
if not has_read_scope(user):
|
||||
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
||||
)
|
||||
raise refused(403, "youtube_read_scope")
|
||||
if not _sync_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||
result = import_subscriptions(db, user)
|
||||
try:
|
||||
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||
result = import_subscriptions(db, user)
|
||||
except YouTubeError as exc:
|
||||
# The onboarding wizard's first call: an unguarded failure here was a raw 500 and the
|
||||
# blocking "Server error" dialog, with nothing the user could act on (E9 F1).
|
||||
db.rollback()
|
||||
raise youtube_refusal(exc)
|
||||
result["quota_used_estimate"] = m.used
|
||||
result["quota_remaining_today"] = m.remaining
|
||||
return result
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
@@ -137,7 +143,7 @@ def deep_all(
|
||||
update(Subscription).where(Subscription.user_id == user.id).values(deep_requested=on)
|
||||
)
|
||||
db.commit()
|
||||
return {"updated": result.rowcount, "deep_requested": on}
|
||||
return {"ok": True, "updated": result.rowcount, "deep_requested": on}
|
||||
|
||||
|
||||
@router.post("/pause")
|
||||
@@ -145,7 +151,7 @@ def pause_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db))
|
||||
state.set_sync_paused(db, True)
|
||||
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
|
||||
db.commit()
|
||||
return {"paused": True}
|
||||
return {"ok": True, "paused": True}
|
||||
|
||||
|
||||
@router.post("/resume")
|
||||
@@ -153,4 +159,4 @@ def resume_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
state.set_sync_paused(db, False)
|
||||
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
|
||||
db.commit()
|
||||
return {"paused": False}
|
||||
return {"ok": True, "paused": False}
|
||||
|
||||
+24
-14
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -9,6 +9,7 @@ from app.db import get_db
|
||||
from app.models import ChannelTag, Tag, User
|
||||
from app.routes._common import owned_or_404
|
||||
from app.sync.tagsuggest import compute_tag_suggestions
|
||||
from app.refusal import refused
|
||||
|
||||
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
||||
|
||||
@@ -60,7 +61,7 @@ def create_tag(
|
||||
) -> dict:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
raise refused(400, "name_required")
|
||||
# An unknown category silently falls back to "other" (unchanged behaviour) rather than 422.
|
||||
category = body.category if body.category in ("language", "topic", "other") else "other"
|
||||
tag = Tag(
|
||||
@@ -74,13 +75,14 @@ def create_tag(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail=f"You already have a tag named “{name}”.")
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
||||
|
||||
|
||||
def _own_tag(db: Session, user: User, tag_id: int) -> Tag:
|
||||
# System tags (user_id NULL) are not user-editable → owned_or_404 404s them too.
|
||||
return owned_or_404(db, Tag, tag_id, user, what="tag")
|
||||
raise refused(409, "tag_name_taken", name=name)
|
||||
return {
|
||||
"ok": True,
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"color": tag.color,
|
||||
"category": tag.category,
|
||||
}
|
||||
|
||||
|
||||
class TagUpdateIn(BaseModel):
|
||||
@@ -97,12 +99,13 @@ def update_tag(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
tag = _own_tag(db, user, tag_id)
|
||||
# System tags (user_id NULL) are not user-editable: owned_or_404 404s them too.
|
||||
tag = owned_or_404(db, Tag, tag_id, user, what="tag")
|
||||
sent = body.model_fields_set
|
||||
if "name" in sent:
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
||||
raise refused(400, "name_required")
|
||||
tag.name = name
|
||||
if "color" in sent:
|
||||
tag.color = body.color
|
||||
@@ -110,8 +113,14 @@ def update_tag(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail=f"You already have a tag named “{tag.name}”.")
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
||||
raise refused(409, "tag_name_taken", name=tag.name)
|
||||
return {
|
||||
"ok": True,
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"color": tag.color,
|
||||
"category": tag.category,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{tag_id}")
|
||||
@@ -120,7 +129,8 @@ def delete_tag(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
tag = _own_tag(db, user, tag_id)
|
||||
# System tags (user_id NULL) are not user-editable: owned_or_404 404s them too.
|
||||
tag = owned_or_404(db, Tag, tag_id, user, what="tag")
|
||||
db.delete(tag) # ChannelTag rows cascade on tag delete.
|
||||
db.commit()
|
||||
return {"deleted": tag_id}
|
||||
|
||||
@@ -176,6 +176,7 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
|
||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
||||
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
||||
ConfigSpec("plex_max_sessions", "int", "plex", "plex_max_sessions", min=1, max=64),
|
||||
# Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback.
|
||||
# crf 0–51 (x264 range); max_height 0 = source.
|
||||
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
|
||||
@@ -211,11 +212,23 @@ def _default(s: ConfigSpec):
|
||||
return getattr(settings, s.default_attr)
|
||||
|
||||
|
||||
class InvalidSetting(ValueError):
|
||||
"""A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_<reason>`)
|
||||
plus what the sentence needs — the English message is for logs only. A ValueError still, so a
|
||||
caller that only needs "refused or not" keeps working."""
|
||||
|
||||
def __init__(self, key: str, reason: str, **params: object):
|
||||
super().__init__(f"{key}: {reason} {params}")
|
||||
self.key = key
|
||||
self.reason = reason
|
||||
self.params = params
|
||||
|
||||
|
||||
def _choice(s: ConfigSpec, raw: object) -> str:
|
||||
"""The canonical spelling of `raw` among the key's choices (any letter case), or ValueError."""
|
||||
"""The canonical spelling of `raw` among the key's choices (any letter case), or InvalidSetting."""
|
||||
option = canonical_choice(s.choices, raw)
|
||||
if option is None:
|
||||
raise ValueError(f"{s.key} must be one of: {', '.join(s.choices)}")
|
||||
raise InvalidSetting(s.key, "not_a_choice", choices=", ".join(s.choices))
|
||||
return option
|
||||
|
||||
|
||||
@@ -305,11 +318,14 @@ def set_value(db: Session, key: str, value) -> None:
|
||||
(B-core-10). The staged value is visible to a same-session `get()` before the commit."""
|
||||
s = _BY_KEY[key]
|
||||
if s.type == "int":
|
||||
v = int(value)
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise InvalidSetting(key, "not_a_number") from None
|
||||
if s.min is not None and v < s.min:
|
||||
raise ValueError(f"{key} must be >= {s.min}")
|
||||
raise InvalidSetting(key, "too_low", min=s.min)
|
||||
if s.max is not None and v > s.max:
|
||||
raise ValueError(f"{key} must be <= {s.max}")
|
||||
raise InvalidSetting(key, "too_high", max=s.max)
|
||||
raw = str(v)
|
||||
elif s.type == "bool":
|
||||
raw = "true" if value else "false"
|
||||
@@ -321,7 +337,7 @@ def set_value(db: Session, key: str, value) -> None:
|
||||
stored, encrypted = raw, False
|
||||
if s.secret:
|
||||
if not secrets_manageable():
|
||||
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
|
||||
raise InvalidSetting(key, "no_encryption_key")
|
||||
stored, encrypted = security.encrypt(raw) or "", True
|
||||
|
||||
row = db.get(SystemConfig, key)
|
||||
|
||||
@@ -9,6 +9,7 @@ uses OAuth.
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -108,6 +109,30 @@ def is_quota_refusal(exc: YouTubeError) -> bool:
|
||||
return any(r in _QUOTA_REASONS for r in exc.reason.split(","))
|
||||
|
||||
|
||||
# The token endpoint's answer for a revoked or expired grant — the one refusal a reconnect fixes.
|
||||
_DEAD_GRANT = "invalid_grant"
|
||||
|
||||
# What a refused YouTube action means to the person who asked for it — the three things they can
|
||||
# act on differently: wait for tomorrow's budget, reconnect the account, or try again later.
|
||||
YouTubeRefusal = Literal["quota", "reconnect", "unavailable"]
|
||||
|
||||
|
||||
def refusal_reason(exc: YouTubeError) -> YouTubeRefusal:
|
||||
"""Sort a failure by what the user can do about it. A dead grant (Google refused the refresh
|
||||
with `invalid_grant`, which also drops the grant — see `_drop_dead_grant`) or no usable token at
|
||||
all (raised before any endpoint answered, so with no path and no transport reason) means the
|
||||
account must be reconnected; retrying cannot help. Any other token-endpoint failure — a 5xx, a
|
||||
429, a misconfigured client — leaves the grant as it was: reconnecting would not fix it, and
|
||||
the account would still read as connected, with no connect button to press."""
|
||||
if is_quota_refusal(exc):
|
||||
return "quota"
|
||||
if exc.path == "oauth2/token":
|
||||
return "reconnect" if exc.reason == _DEAD_GRANT else "unavailable"
|
||||
if not exc.path and exc.reason != "transport":
|
||||
return "reconnect"
|
||||
return "unavailable"
|
||||
|
||||
|
||||
def _chunks(items: list, size: int) -> Iterator[list]:
|
||||
for i in range(0, len(items), size):
|
||||
yield items[i : i + size]
|
||||
@@ -181,7 +206,10 @@ class YouTubeClient:
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise _upstream_failure("POST", "oauth2/token", resp)
|
||||
exc = _upstream_failure("POST", "oauth2/token", resp)
|
||||
if exc.reason == _DEAD_GRANT:
|
||||
self._drop_dead_grant(tok)
|
||||
raise exc
|
||||
data = resp.json()
|
||||
access = data["access_token"]
|
||||
tok.access_token = encrypt(access)
|
||||
@@ -191,6 +219,25 @@ class YouTubeClient:
|
||||
log.info("Refreshed access token for user %s", self.user.id)
|
||||
return access
|
||||
|
||||
def _drop_dead_grant(self, tok) -> None:
|
||||
"""Google answered the refresh with `invalid_grant`: the grant was revoked or expired, and no
|
||||
retry will revive it. Forget it, so the account reads as NOT connected — Settings and the
|
||||
onboarding wizard then offer the connect button again (a fresh consent issues a new refresh
|
||||
token). Kept as "granted", the UI showed a live connection with no way to renew it while
|
||||
every call failed. Committed like the refresh above: the caller rolls back on the error."""
|
||||
tok.scopes = None
|
||||
tok.access_token = None
|
||||
tok.refresh_token_enc = None
|
||||
tok.expiry = None
|
||||
self.db.add(tok)
|
||||
self.db.commit()
|
||||
# INFO: the refusal itself is already the warning (`youtube.api.error`, invalid_grant).
|
||||
log.info(
|
||||
"cleared the dead YouTube grant of user %s",
|
||||
self.user.id,
|
||||
extra=logs.event("youtube.grant.cleared"),
|
||||
)
|
||||
|
||||
# --- core request ---
|
||||
def _get(
|
||||
self,
|
||||
|
||||
@@ -21,7 +21,8 @@ from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
import app.models # noqa: F401 — register every model on Base.metadata
|
||||
import app.state as app_state
|
||||
@@ -100,17 +101,61 @@ def pytest_configure(config):
|
||||
)
|
||||
|
||||
|
||||
def _request_dep(db, unfinished: list[str]):
|
||||
"""`get_db` for a test request: the test's own session, plus the check a shared session hides.
|
||||
|
||||
A route helper flushes and its HANDLER commits (CLAUDE.md). Production's `get_db` closes the
|
||||
session after the request, which rolls back anything flushed but not committed — yet here every
|
||||
request shares the test's session, so a follow-up GET or an assertion reads the flushed write
|
||||
and the suite stays green over a handler that forgot to commit (deep review, debt burn). So a
|
||||
request that ends normally with a write it did not commit — flushed, or still only staged — is
|
||||
recorded, and the test fails on it at teardown. Only the ROOT transaction's end clears it: a
|
||||
savepoint released inside the handler commits nothing. The test's own staged rows are flushed before the request starts — they are the
|
||||
test's business, not the handler's. A request that raises is exempt: its writes are meant to
|
||||
roll back."""
|
||||
|
||||
def dep(conn: HTTPConnection):
|
||||
db.flush()
|
||||
state = {"dirty": False}
|
||||
|
||||
def flushed(session, ctx):
|
||||
state["dirty"] = True
|
||||
|
||||
def ended(session, transaction):
|
||||
if transaction.parent is None:
|
||||
state["dirty"] = False
|
||||
|
||||
hooks = (("after_flush", flushed), ("after_transaction_end", ended))
|
||||
for name, fn in hooks:
|
||||
event.listen(db, name, fn)
|
||||
try:
|
||||
yield db
|
||||
staged = db.new or db.deleted or any(db.is_modified(o) for o in db.dirty)
|
||||
if state["dirty"] or staged:
|
||||
unfinished.append(f"{conn.scope.get('method', 'WS')} {conn.url.path}")
|
||||
finally:
|
||||
for name, fn in hooks:
|
||||
event.remove(db, name, fn)
|
||||
|
||||
return dep
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(_schema):
|
||||
"""A real session against the test Postgres, on a clean (empty) database. The code under test
|
||||
may commit freely."""
|
||||
may commit freely. `db.info["request"]` is the `get_db` override for a request against it — the
|
||||
one every test client uses — and a handler that left a write uncommitted fails the test here."""
|
||||
_truncate_all()
|
||||
session = SessionLocal()
|
||||
unfinished: list[str] = []
|
||||
session.info["request"] = _request_dep(session, unfinished)
|
||||
session.info["unfinished"] = unfinished # the requests recorded so far (the check's own test)
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
assert unfinished == [], "a handler left a write uncommitted"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -168,7 +213,7 @@ def client(db, override_dep, _configured):
|
||||
`current_user` override — so login, the wallet gate and epoch revoke are exercised end to end.
|
||||
Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates
|
||||
the next on this same instance. No lifespan (the scheduler / HLS sweep stay off)."""
|
||||
override_dep(get_db, lambda: db)
|
||||
override_dep(get_db, db.info["request"])
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -187,7 +232,7 @@ def as_user(db, override_dep, _configured):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return registry[uid]
|
||||
|
||||
override_dep(get_db, lambda: db)
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, _resolver)
|
||||
|
||||
def _as(user: User) -> TestClient:
|
||||
|
||||
@@ -168,3 +168,15 @@ def test_deleting_the_user_also_clears_the_way_to_re_invite(db, make_user, as_us
|
||||
assert (
|
||||
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none() is None
|
||||
)
|
||||
|
||||
|
||||
def test_whitelisting_a_new_address_audits_it_by_id(db, make_user, as_user):
|
||||
"""The audit row was written before the new invite had an id, so it named no target."""
|
||||
from app.models import AuditLog
|
||||
|
||||
admin = make_user(role="admin")
|
||||
r = as_user(admin).post("/api/admin/invites", json={"email": "fresh-e27@example.com"})
|
||||
assert r.status_code == 200
|
||||
inv = db.scalar(select(Invite).where(Invite.email == "fresh-e27@example.com"))
|
||||
row = db.scalar(select(AuditLog).where(AuditLog.target_type == "invite"))
|
||||
assert row is not None and row.target_id == str(inv.id)
|
||||
|
||||
@@ -447,7 +447,7 @@ def test_register_route_creates_the_account_end_to_end(client, db, monkeypatch):
|
||||
|
||||
r = client.post("/auth/register", json={"email": "newbie@example.com", "password": _PW})
|
||||
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||
assert r.status_code == 200 and r.json() == {"ok": True, "status": "ok"}
|
||||
db.expire_all()
|
||||
user = db.execute(_select(User).where(User.email == "newbie@example.com")).scalar_one_or_none()
|
||||
assert user is not None, "POST /auth/register answered ok but created nothing"
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.sync import runner, videos
|
||||
_PUB = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
|
||||
def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch, override_dep):
|
||||
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
|
||||
ch = Channel(id="UC_nsl", title="N/S/L Channel", published_at=_PUB)
|
||||
db.add(ch)
|
||||
@@ -40,12 +40,9 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
|
||||
|
||||
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: me
|
||||
try:
|
||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: me)
|
||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
@@ -56,7 +53,7 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hidden", [True, False])
|
||||
def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden):
|
||||
def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden, override_dep):
|
||||
# S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer
|
||||
# the same Unhide toggle the manager has (the flag was previously omitted from the detail dict).
|
||||
ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB)
|
||||
@@ -68,18 +65,15 @@ def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidde
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: me
|
||||
try:
|
||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: me)
|
||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["hidden"] is hidden
|
||||
|
||||
|
||||
def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatch):
|
||||
def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatch, override_dep):
|
||||
# No subscription row → the flag can't be True; it must serialize as False, not raise.
|
||||
ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB)
|
||||
me = User(email="hidns@example.com")
|
||||
@@ -87,12 +81,9 @@ def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatc
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: me
|
||||
try:
|
||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: me)
|
||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["hidden"] is False
|
||||
@@ -115,33 +106,30 @@ def _setup_channel_and_sub(db, *, email, is_demo=False):
|
||||
return ch, user
|
||||
|
||||
|
||||
def _patch_deep_requested(db, monkeypatch, user):
|
||||
def _patch_deep_requested(db, monkeypatch, override_dep, user):
|
||||
calls: list[tuple] = []
|
||||
# background.add_task runs after the response in TestClient; record what would be scheduled.
|
||||
monkeypatch.setattr(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a))
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
|
||||
return resp, calls
|
||||
|
||||
|
||||
def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch):
|
||||
def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch, override_dep):
|
||||
ch, me = _setup_channel_and_sub(db, email="wire@example.com")
|
||||
resp, calls = _patch_deep_requested(db, monkeypatch, me)
|
||||
resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, me)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["deep_requested"] is True
|
||||
# scheduled exactly once, with (channel_id, user_id) — the args backfill_channel_now needs.
|
||||
assert calls == [(ch.id, me.id)]
|
||||
|
||||
|
||||
def test_deep_request_does_not_schedule_immediate_backfill_for_demo(db, monkeypatch):
|
||||
def test_deep_request_does_not_schedule_immediate_backfill_for_demo(db, monkeypatch, override_dep):
|
||||
# The SHARED demo account must not be able to kick immediate full backfills (budget drain).
|
||||
_ch, demo = _setup_channel_and_sub(db, email="demo@example.com", is_demo=True)
|
||||
resp, calls = _patch_deep_requested(db, monkeypatch, demo)
|
||||
resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, demo)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert calls == [], "demo must not schedule an immediate backfill"
|
||||
# …and deep_requested is not persisted on the shared demo subscription either.
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.main import app
|
||||
from app.models import Channel, ChannelTag, Tag, User
|
||||
|
||||
|
||||
def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch):
|
||||
def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch, override_dep):
|
||||
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
|
||||
ch = Channel(
|
||||
id="UC_tags_test",
|
||||
@@ -43,13 +43,10 @@ def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch):
|
||||
|
||||
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: me
|
||||
try:
|
||||
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
|
||||
resp = client.get(f"/api/channels/{ch.id}")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: me)
|
||||
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
|
||||
resp = client.get(f"/api/channels/{ch.id}")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["tag_ids"] == [mine.id]
|
||||
|
||||
@@ -544,6 +544,73 @@ def test_shelf_resolves_a_playlist_to_its_first_unwatched_item(db):
|
||||
assert card["next"]["id"] == "vmed"
|
||||
|
||||
|
||||
def _count_statements(db, fn) -> int:
|
||||
"""How many SQL statements `fn` sends through the session's connection."""
|
||||
from sqlalchemy import event
|
||||
|
||||
seen: list[str] = []
|
||||
engine = db.get_bind()
|
||||
listener = lambda *a, **k: seen.append(a[2]) # noqa: E731 — (conn, cursor, statement, …)
|
||||
event.listen(engine, "before_cursor_execute", listener)
|
||||
try:
|
||||
fn()
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", listener)
|
||||
return len(seen)
|
||||
|
||||
|
||||
def test_the_shelf_resolves_each_channel_and_playlist_on_its_own_terms(db):
|
||||
"""Batched, the per-channel count and newest video come from one windowed query: each channel
|
||||
must still get ITS count and ITS newest, not the shelf's overall first row."""
|
||||
user, ch = _seed_yt(db)
|
||||
other = Channel(id="UCother", title="Other")
|
||||
db.add(other)
|
||||
db.flush()
|
||||
db.add(Video(id="vother", channel_id=other.id, title="x", published_at=NOW - timedelta(days=9)))
|
||||
pl = Playlist(user_id=user.id, name="Két elem")
|
||||
empty = Playlist(user_id=user.id, name="Üres")
|
||||
db.add_all([pl, empty])
|
||||
db.flush()
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id="vlong", position=0))
|
||||
for i, target in enumerate(
|
||||
[
|
||||
{"channel_id": ch.id},
|
||||
{"channel_id": other.id},
|
||||
{"playlist_id": pl.id},
|
||||
{"playlist_id": empty.id},
|
||||
]
|
||||
):
|
||||
db.add(ComfortPin(user_id=user.id, position=i, **target))
|
||||
db.commit()
|
||||
|
||||
got = [
|
||||
(c.get("unwatched_count", c.get("item_count")), (c["next"] or {}).get("id"))
|
||||
for c in pins.shelf(db, user)
|
||||
]
|
||||
assert got == [(3, "vshort"), (1, "vother"), (1, "vlong"), (0, None)]
|
||||
|
||||
|
||||
def test_the_shelf_costs_the_same_queries_for_one_pin_or_many(db):
|
||||
"""A channel pin used to cost three queries and a playlist pin two, each; the shelf now
|
||||
resolves every channel and every playlist together."""
|
||||
user, ch = _seed_yt(db)
|
||||
extra = [Channel(id=f"UCx{i}", title=f"x{i}") for i in range(3)]
|
||||
lists = [Playlist(user_id=user.id, name=f"p{i}") for i in range(4)]
|
||||
db.add_all(extra + lists)
|
||||
db.flush()
|
||||
db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=0))
|
||||
db.add(ComfortPin(user_id=user.id, playlist_id=lists[0].id, position=1))
|
||||
db.commit()
|
||||
few = _count_statements(db, lambda: pins.shelf(db, user))
|
||||
|
||||
for i, c in enumerate(extra):
|
||||
db.add(ComfortPin(user_id=user.id, channel_id=c.id, position=10 + i))
|
||||
for i, p in enumerate(lists[1:]):
|
||||
db.add(ComfortPin(user_id=user.id, playlist_id=p.id, position=20 + i))
|
||||
db.commit()
|
||||
assert _count_statements(db, lambda: pins.shelf(db, user)) == few
|
||||
|
||||
|
||||
def test_a_pinned_video_survives_leaving_the_feed_scope(db):
|
||||
"""The shelf is the fallback surface. An entry silently vanishing because the user unsubscribed
|
||||
from the channel is exactly the failure it must not have."""
|
||||
@@ -940,3 +1007,115 @@ def test_deleting_a_pin_reports_what_went(db, as_user):
|
||||
r = c.delete(f"/api/discover/pins/{pin_id}")
|
||||
assert r.status_code == 200 and r.json() == {"deleted": pin_id}
|
||||
assert c.get("/api/discover/pins").json()["items"] == []
|
||||
|
||||
|
||||
def test_with_plex_off_the_home_shelf_hides_plex_pins_but_keeps_them(db, as_user):
|
||||
"""The owner's call (2026-09-19): with the deployment's Plex toggle off, the Home shelf goes
|
||||
YouTube-only like the rails beside it — a Plex tile there offered a Play that landed on a gated
|
||||
module. The hero's comfort tier draws from the same filtered shelf, and the pin menus' own
|
||||
listing still sees every pin, so nothing the user curated is lost."""
|
||||
user, _ = _seed_yt(db)
|
||||
_seed_plex(db, user)
|
||||
db.add_all(
|
||||
[
|
||||
ComfortPin(
|
||||
user_id=user.id,
|
||||
plex_item_id=db.scalar(select(PlexItem.id).where(PlexItem.rating_key == "pm1")),
|
||||
position=0,
|
||||
),
|
||||
ComfortPin(user_id=user.id, video_id="vlong", position=1),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
c = as_user(user)
|
||||
assert [t["id"] for t in c.get("/api/discover").json()["comfort"]] == ["vlong"]
|
||||
assert c.get("/api/discover/pick?start=1").json()["hero"]["card"]["id"] == "vlong"
|
||||
assert {t["id"] for t in c.get("/api/discover/pins").json()["items"]} == {"pm1", "vlong"}
|
||||
_enable_plex(db)
|
||||
assert [t["id"] for t in c.get("/api/discover").json()["comfort"]] == ["pm1", "vlong"]
|
||||
|
||||
|
||||
def test_a_reorder_of_the_visible_pins_keeps_the_hidden_ones_in_place(db, as_user):
|
||||
"""The client sends the order it SHOWS. With Plex off that leaves the Plex pins out, and
|
||||
numbering only the listed ids 0..n-1 put two pins on one position and moved the hidden one about
|
||||
when Plex came back. The listed pins trade the slots they hold; the rest stay put."""
|
||||
user, ch = _seed_yt(db)
|
||||
_seed_plex(db, user)
|
||||
c = as_user(user)
|
||||
_enable_plex(db)
|
||||
a = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
|
||||
hidden = c.post("/api/discover/pins", json={"kind": "plex_item", "ref": "pm1"}).json()["id"]
|
||||
b = c.post("/api/discover/pins", json={"kind": "channel", "ref": ch.id}).json()["id"]
|
||||
assert c.post("/api/discover/pins/reorder", json={"ids": [b, b, a]}).status_code == 200
|
||||
assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [b, hidden, a]
|
||||
positions = db.scalars(select(ComfortPin.position).where(ComfortPin.user_id == user.id)).all()
|
||||
assert sorted(positions) == [0, 1, 2]
|
||||
|
||||
|
||||
def test_a_hero_from_a_show_pin_names_the_pin_it_came_from(db, as_user):
|
||||
"""A show's next EPISODE is not itself pinned, so the hero's menu used to look the episode up,
|
||||
miss, and offer "Pin" under "From your shelf" — creating a second pin. The card now carries
|
||||
the pin it was resolved from, which the menu acts on (owner's call: "Unpin")."""
|
||||
user, _ = _seed_yt(db)
|
||||
_seed_plex(db, user)
|
||||
_enable_plex(db)
|
||||
c = as_user(user)
|
||||
assert c.post("/api/discover/pins", json={"kind": "plex_show", "ref": "ps1"}).status_code == 200
|
||||
got = c.get("/api/discover/pick?start=1").json()["hero"]
|
||||
assert got["tier"] == "comfort" and got["card"]["id"] == "ps1e1"
|
||||
assert got["card"]["pinned_from"] == {
|
||||
"source": "plex",
|
||||
"kind": "show",
|
||||
"id": "ps1",
|
||||
"title": "A Show",
|
||||
}
|
||||
|
||||
|
||||
def test_two_overlapping_reorders_never_share_a_slot(db, as_user):
|
||||
"""`reorder` writes only the rows that change. Unlocked, two tabs reordering from the same
|
||||
snapshot each skipped the rows that already matched in THEIR snapshot and interleaved row by
|
||||
row — two pins on one slot (deep review, debt burn). Locked, the second waits for the first and
|
||||
deals from its result: here a partial list (a tab with Plex off, one pin hidden), whose left-out
|
||||
pin must stay where the FIRST reorder put it — a lock taken in position order handed the waiter
|
||||
fresh rows in its stale order, and it dealt that pin back to its old slot (round 2)."""
|
||||
import threading
|
||||
|
||||
from app.db import SessionLocal
|
||||
|
||||
user, ch = _seed_yt(db)
|
||||
c = as_user(user)
|
||||
p1 = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
|
||||
p2 = c.post("/api/discover/pins", json={"kind": "channel", "ref": ch.id}).json()["id"]
|
||||
p3 = c.post("/api/discover/pins", json={"kind": "video", "ref": "vlong"}).json()["id"]
|
||||
uid = user.id
|
||||
|
||||
with SessionLocal() as tab_a, SessionLocal() as tab_b, SessionLocal() as probe:
|
||||
pins.reorder(tab_a, tab_a.get(User, uid), [p2, p3, p1]) # written, not yet committed
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def second_tab():
|
||||
try:
|
||||
pins.reorder(tab_b, tab_b.get(User, uid), [p3, p2])
|
||||
tab_b.commit()
|
||||
except BaseException as exc: # noqa: BLE001 — re-raised in the test thread below
|
||||
errors.append(exc)
|
||||
|
||||
worker = threading.Thread(target=second_tab)
|
||||
worker.start()
|
||||
# Commit A only once B is blocked on a row lock, so B's read is the stale snapshot.
|
||||
waiting = 0
|
||||
for _ in range(200):
|
||||
waiting = probe.scalar(text("SELECT count(*) FROM pg_locks WHERE NOT granted"))
|
||||
probe.rollback()
|
||||
if waiting:
|
||||
break
|
||||
worker.join(0.05)
|
||||
assert waiting, "tab B never blocked — the race was not exercised"
|
||||
tab_a.commit()
|
||||
worker.join(10)
|
||||
assert not worker.is_alive() and errors == []
|
||||
|
||||
db.expire_all()
|
||||
positions = db.scalars(select(ComfortPin.position).where(ComfortPin.user_id == uid)).all()
|
||||
assert sorted(positions) == [0, 1, 2]
|
||||
assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [p3, p2, p1]
|
||||
|
||||
@@ -15,34 +15,31 @@ from app.main import app
|
||||
from app.models import User
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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:
|
||||
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
||||
return client.get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
||||
return client.get(path)
|
||||
|
||||
|
||||
def test_malformed_feed_cursor_returns_400(db, monkeypatch):
|
||||
def test_malformed_feed_cursor_returns_400(db, monkeypatch, override_dep):
|
||||
user = User(email="feedcursor@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/feed?cursor=not-a-real-cursor")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Invalid feed cursor"
|
||||
assert resp.json()["detail"] == {"code": "refused", "reason": "invalid_cursor"}
|
||||
|
||||
|
||||
def test_feed_without_cursor_is_200(db, monkeypatch):
|
||||
def test_feed_without_cursor_is_200(db, monkeypatch, override_dep):
|
||||
# Sanity: the same endpoint without a cursor is a normal 200 (proves the 400 above is the
|
||||
# cursor path, not a globally-broken request). A fresh user has no subscriptions → empty feed.
|
||||
user = User(email="feednocursor@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
resp = _get(db, user, monkeypatch, "/api/feed")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/feed")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["items"] == []
|
||||
|
||||
@@ -116,10 +116,10 @@ def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limi
|
||||
|
||||
def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions):
|
||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["12345"] = s
|
||||
stream.touch_session("12345")
|
||||
sessions[(1, "12345")] = s
|
||||
stream.touch_session(1, "12345")
|
||||
assert stream.reap_idle() == 0
|
||||
assert "12345" in sessions and s.dir.exists() and not s.proc.killed
|
||||
assert (1, "12345") in sessions and s.dir.exists() and not s.proc.killed
|
||||
|
||||
|
||||
# --- the two call sites, driven through the real routes (DB lane) -------------------------------
|
||||
@@ -142,13 +142,15 @@ def _library_item(db, rating_key: str, *, playable: str = "remux"):
|
||||
return it
|
||||
|
||||
|
||||
def _client(db, user):
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
def _client(db, user, override_dep):
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch, tmp_path, sessions):
|
||||
def test_a_paused_viewers_checkpoint_keeps_their_session_alive(
|
||||
db, monkeypatch, tmp_path, sessions, override_dep
|
||||
):
|
||||
"""The whole reason the short rule is safe again: a paused player fetches nothing, so
|
||||
`last_access` never moved on its own — but it does keep checkpointing its position."""
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
@@ -157,21 +159,18 @@ def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch,
|
||||
db.add(user)
|
||||
db.commit()
|
||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["paused1"] = s
|
||||
try:
|
||||
resp = _client(db, user).post(
|
||||
f"/api/plex/item/{it.rating_key}/progress",
|
||||
json={"position_seconds": 600, "duration_seconds": 3000, "final": False},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
sessions[(user.id, "paused1")] = s
|
||||
resp = _client(db, user, override_dep).post(
|
||||
f"/api/plex/item/{it.rating_key}/progress",
|
||||
json={"position_seconds": 600, "duration_seconds": 3000, "final": False},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there"
|
||||
assert "paused1" in sessions and not s.proc.killed
|
||||
assert (user.id, "paused1") in sessions and not s.proc.killed
|
||||
|
||||
|
||||
def test_starting_a_session_leaves_another_viewers_paused_one_alone(
|
||||
db, monkeypatch, tmp_path, sessions
|
||||
db, monkeypatch, tmp_path, sessions, override_dep
|
||||
):
|
||||
"""The opportunistic sweep this route used to run was not scoped to the key being started: it
|
||||
walked the WHOLE registry with the 30 s finished rule, so pressing play on any item killed a
|
||||
@@ -183,9 +182,9 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
|
||||
db.add(user)
|
||||
db.commit()
|
||||
paused = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["someone-else"] = paused
|
||||
sessions[(999, "someone-else")] = paused
|
||||
|
||||
def fake_start(db_, item, start, audio=None, aoff=0.0, multi=False, transcode=False):
|
||||
def fake_start(db_, item, user_id, start, audio=None, aoff=0.0, multi=False, transcode=False):
|
||||
return SimpleNamespace(
|
||||
id="sid",
|
||||
media_start_s=start,
|
||||
@@ -196,9 +195,6 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
|
||||
)
|
||||
|
||||
monkeypatch.setattr(stream, "start_session", fake_start)
|
||||
try:
|
||||
resp = _client(db, user).post(f"/api/plex/stream/{started.rating_key}/session")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
resp = _client(db, user, override_dep).post(f"/api/plex/stream/{started.rating_key}/session")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "someone-else" in sessions and not paused.proc.killed
|
||||
assert (999, "someone-else") in sessions and not paused.proc.killed
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""No refusal answers in a hand-written English sentence.
|
||||
|
||||
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
|
||||
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
|
||||
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
|
||||
became; this test is what keeps a new one from being written the old way. Pure: it reads the sources.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||
|
||||
|
||||
def _is_text(node: ast.AST) -> bool:
|
||||
"""A detail that is a sentence: a literal, an f-string, `str(exc)`, or `"…" + x`."""
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return True
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
return True
|
||||
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "str":
|
||||
return True
|
||||
if isinstance(node, ast.BoolOp):
|
||||
return any(_is_text(v) for v in node.values)
|
||||
if isinstance(node, ast.BinOp):
|
||||
return _is_text(node.left) or _is_text(node.right)
|
||||
return False
|
||||
|
||||
|
||||
def test_no_http_exception_carries_a_sentence():
|
||||
offenders = []
|
||||
for path in sorted(APP.rglob("*.py")):
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if not (
|
||||
isinstance(node, ast.Call)
|
||||
and getattr(node.func, "id", getattr(node.func, "attr", "")) == "HTTPException"
|
||||
):
|
||||
continue
|
||||
kw = {k.arg: k.value for k in node.keywords}
|
||||
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
|
||||
if detail is not None and _is_text(detail):
|
||||
offenders.append(f"{path.relative_to(APP).as_posix()}:{node.lineno}")
|
||||
assert offenders == [], (
|
||||
"a refusal with an English sentence for a detail — raise `refused(status, reason)` "
|
||||
f"(app/refusal.py) and name the reason in both locales: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_scan_sees_a_sentence_when_there_is_one():
|
||||
"""A guard that matches nothing is green over nothing."""
|
||||
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value
|
||||
assert _is_text(sample.keywords[1].value)
|
||||
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value)
|
||||
@@ -79,7 +79,7 @@ def test_the_demo_entry_does_not_stamp_the_shared_account(client, db):
|
||||
|
||||
r = client.post("/auth/demo", json={"email": "visitor@example.com"})
|
||||
|
||||
assert r.json() == {"authenticated": True}
|
||||
assert r.json() == {"ok": True, "authenticated": True}
|
||||
demo = auth_routes.get_or_create_demo_user(db)
|
||||
db.refresh(demo)
|
||||
assert demo.last_login_at is None
|
||||
|
||||
@@ -140,9 +140,9 @@ def test_a_refused_token_refresh_names_the_oauth_reason_and_not_its_prose(sink,
|
||||
request=httpx.Request("POST", "https://oauth2.googleapis.com/token"),
|
||||
)
|
||||
client = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub
|
||||
client.db = None
|
||||
client.db = SimpleNamespace(add=lambda obj: None, commit=lambda: None)
|
||||
client.user = SimpleNamespace(
|
||||
token=SimpleNamespace(access_token=None, expiry=None, refresh_token_enc=b"x")
|
||||
id=1, token=SimpleNamespace(access_token=None, expiry=None, refresh_token_enc=b"x")
|
||||
)
|
||||
monkeypatch.setattr(yt, "decrypt_optional", lambda value: None)
|
||||
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Dependency overrides go through `conftest.override_dep`, never `app.dependency_overrides` directly.
|
||||
|
||||
The hand-rolled shape — assign, then `app.dependency_overrides.clear()` in a `finally` — wipes EVERY
|
||||
override, not the ones the test set, so it stops fixtures composing: one test's teardown silently
|
||||
clobbers an override another fixture installed. `override_dep` restores each key's prior value
|
||||
instead. 22 files had drifted to the hand-rolled shape (it was the house norm at ~20 files to 3), and
|
||||
the sweep that converted them is only as good as the check that keeps it converted — this one.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
TESTS = Path(__file__).parent
|
||||
|
||||
|
||||
def test_no_test_writes_dependency_overrides_directly():
|
||||
offenders = [
|
||||
f"{p.name}:{n}"
|
||||
for p in sorted(TESTS.rglob("*.py"))
|
||||
if p.name not in {"conftest.py", Path(__file__).name}
|
||||
for n, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1)
|
||||
if "dependency_overrides" in line
|
||||
]
|
||||
assert offenders == [], "use the override_dep fixture: " + ", ".join(offenders)
|
||||
|
||||
|
||||
def test_a_request_gets_the_session_that_checks_its_commit():
|
||||
"""`get_db` is overridden with `db.info["request"]` — the test session plus the check that the
|
||||
handler committed what it flushed (conftest `_request_dep`). A bare `lambda: db` hands the route
|
||||
the same session without the check, and a handler that forgot its commit passes again."""
|
||||
offenders = [
|
||||
f"{p.name}:{n}"
|
||||
for p in sorted(TESTS.rglob("*.py"))
|
||||
if p.name != Path(__file__).name
|
||||
for n, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1)
|
||||
if "override_dep(get_db," in line and 'override_dep(get_db, db.info["request"])' not in line
|
||||
]
|
||||
assert offenders == [], 'override get_db with db.info["request"]: ' + ", ".join(offenders)
|
||||
|
||||
|
||||
def test_the_commit_check_counts_only_the_root_transaction(db):
|
||||
"""A savepoint released inside a handler commits nothing to the database; ending it must not
|
||||
clear the check (round 2). Driven directly, as a request would drive the dependency."""
|
||||
import pytest
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from app.models import User
|
||||
|
||||
conn = HTTPConnection(
|
||||
{"type": "http", "method": "POST", "path": "/probe", "headers": [], "query_string": b""}
|
||||
)
|
||||
request = db.info["request"](conn)
|
||||
session = next(request)
|
||||
session.add(User(email="savepoint-probe@example.com"))
|
||||
session.flush()
|
||||
with session.begin_nested():
|
||||
pass
|
||||
with pytest.raises(StopIteration):
|
||||
next(request)
|
||||
assert db.info["unfinished"] == ["POST /probe"]
|
||||
db.info["unfinished"].clear() # recorded as it should be — not this test's failure
|
||||
@@ -235,6 +235,13 @@ def test_item_edits_bump_updated_at(db, make_user, as_user):
|
||||
assert db.get(Playlist, src.id).updated_at > old
|
||||
assert db.get(Playlist, dst.id).updated_at > old
|
||||
|
||||
# …and removing one, the fourth item-only write (it had no test: E10 review coverage debt).
|
||||
src.updated_at = old
|
||||
db.commit()
|
||||
assert client.delete(f"/api/playlists/{src.id}/items/v1").status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, src.id).updated_at > old
|
||||
|
||||
|
||||
def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
|
||||
user = make_user()
|
||||
@@ -242,3 +249,49 @@ def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
|
||||
rows = as_user(user).get("/api/playlists").json()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["updated_at"] # the rail cannot sort by a field the API never sends
|
||||
|
||||
|
||||
def test_a_target_that_already_had_it_is_not_recently_changed(db, make_user, as_user):
|
||||
"""Moving into a list that already holds the video only removes it from the source: the
|
||||
target did not change, and bumping it promoted an untouched list in the rail's sort."""
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", ["v1"])
|
||||
dst = _playlist(db, user, "Target", ["v1"])
|
||||
old = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
src.updated_at = old
|
||||
dst.updated_at = old
|
||||
db.commit()
|
||||
|
||||
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, src.id).updated_at > old
|
||||
assert db.get(Playlist, dst.id).updated_at == old
|
||||
|
||||
|
||||
def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
"""The already-in-target check is a read, not a lock: another tab's add (or the scheduler's
|
||||
YouTube merge) can commit the same video to the target in between, and the re-parent then
|
||||
hits uq_playlist_video. That is the idempotent outcome arriving late — not a raw 500."""
|
||||
from app.db import SessionLocal
|
||||
from app.routes import playlists as plroutes
|
||||
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", ["v1"])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
real = plroutes._next_item_position
|
||||
|
||||
def add_first(session, playlist_id):
|
||||
with SessionLocal() as other: # the other tab, committing between check and re-parent
|
||||
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
|
||||
other.commit()
|
||||
return real(session, playlist_id)
|
||||
|
||||
monkeypatch.setattr(plroutes, "_next_item_position", add_first)
|
||||
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == []
|
||||
assert _ids(db, dst.id) == ["v1"]
|
||||
|
||||
@@ -42,7 +42,7 @@ def _seed(db, *, status: str, position: int):
|
||||
return it, user
|
||||
|
||||
|
||||
def _clear(db, user, it, monkeypatch) -> list[tuple]:
|
||||
def _clear(db, user, it, monkeypatch, override_dep) -> list[tuple]:
|
||||
"""Drive the endpoint with the Plex link satisfied and the background push recorded."""
|
||||
pushes: list[tuple] = []
|
||||
monkeypatch.setattr(state, "_configured_cache", True) # pass the instance-setup request gate
|
||||
@@ -52,32 +52,29 @@ def _clear(db, user, it, monkeypatch) -> list[tuple]:
|
||||
"push_state_to_plex",
|
||||
lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)),
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return pushes
|
||||
|
||||
|
||||
def test_clearing_an_in_progress_item_tells_plex(db, monkeypatch):
|
||||
def test_clearing_an_in_progress_item_tells_plex(db, monkeypatch, override_dep):
|
||||
# The regression: status "new" + a position IS the in-progress shape, and it must push.
|
||||
it, user = _seed(db, status="new", position=92)
|
||||
assert _clear(db, user, it, monkeypatch) == [("98614", "unwatched")]
|
||||
assert _clear(db, user, it, monkeypatch, override_dep) == [("98614", "unwatched")]
|
||||
assert db.query(PlexState).count() == 0
|
||||
|
||||
|
||||
def test_clearing_a_watched_item_still_tells_plex(db, monkeypatch):
|
||||
def test_clearing_a_watched_item_still_tells_plex(db, monkeypatch, override_dep):
|
||||
# The case the old guard did cover — kept, so the fix cannot narrow into a new hole.
|
||||
it, user = _seed(db, status="watched", position=0)
|
||||
assert _clear(db, user, it, monkeypatch) == [("98614", "unwatched")]
|
||||
assert _clear(db, user, it, monkeypatch, override_dep) == [("98614", "unwatched")]
|
||||
|
||||
|
||||
def test_unhiding_touches_plex_not_at_all(db, monkeypatch):
|
||||
def test_unhiding_touches_plex_not_at_all(db, monkeypatch, override_dep):
|
||||
# `hidden` is a Siftlode-only concept "with no Plex analogue, so it never touches Plex" — the
|
||||
# endpoint's own docstring. The old status-only guard pushed here anyway, un-marking an item on
|
||||
# Plex that the user had merely hidden.
|
||||
it, user = _seed(db, status="hidden", position=0)
|
||||
assert _clear(db, user, it, monkeypatch) == []
|
||||
assert _clear(db, user, it, monkeypatch, override_dep) == []
|
||||
|
||||
@@ -231,61 +231,70 @@ def _seed(db):
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
def _order(r) -> list[str]:
|
||||
return [i["title"] for i in r.json()["items"]]
|
||||
|
||||
|
||||
def test_collection_detail_defaults_to_release_year_ascending(db, monkeypatch):
|
||||
def test_collection_detail_defaults_to_release_year_ascending(db, monkeypatch, override_dep):
|
||||
"""The order the page had before it grew a control — a franchise reads in release order."""
|
||||
user = _seed(db)
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections/col1")
|
||||
r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections/col1")
|
||||
assert r.status_code == 200
|
||||
assert _order(r) == ["Zulu", "Mike", "Series", "Alpha"]
|
||||
|
||||
|
||||
def test_collection_detail_honours_sort_and_direction(db, monkeypatch):
|
||||
def test_collection_detail_honours_sort_and_direction(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=year&sort_dir=desc")
|
||||
r = _get(
|
||||
db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=year&sort_dir=desc"
|
||||
)
|
||||
assert _order(r) == ["Alpha", "Series", "Mike", "Zulu"]
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=title&sort_dir=asc")
|
||||
r = _get(
|
||||
db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=title&sort_dir=asc"
|
||||
)
|
||||
assert _order(r) == ["Alpha", "Mike", "Series", "Zulu"]
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=rating&sort_dir=desc")
|
||||
r = _get(
|
||||
db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=rating&sort_dir=desc"
|
||||
)
|
||||
# The show has no rating, so it sinks — it is not "rated 0".
|
||||
assert _order(r) == ["Zulu", "Mike", "Alpha", "Series"]
|
||||
|
||||
|
||||
def test_lastwatched_ranks_a_shows_episodes_alongside_the_movies(db, monkeypatch):
|
||||
def test_lastwatched_ranks_a_shows_episodes_alongside_the_movies(db, monkeypatch, override_dep):
|
||||
"""The show leg needs the rollup's `resumed_at`; with only a status map this key would order
|
||||
every show as never-watched."""
|
||||
user = _seed(db)
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=lastwatched&sort_dir=desc")
|
||||
r = _get(
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"/api/plex/collections/col1?sort=lastwatched&sort_dir=desc",
|
||||
)
|
||||
# Series (episode resumed NOW) > Zulu (watched 2 days ago) > the two never-touched, title-asc.
|
||||
assert _order(r) == ["Series", "Zulu", "Alpha", "Mike"]
|
||||
|
||||
|
||||
def test_collections_list_resolves_titles_by_key(db, monkeypatch):
|
||||
def test_collections_list_resolves_titles_by_key(db, monkeypatch, override_dep):
|
||||
"""What a shared Plex link needs: the URL carries bare rating_keys, so the sidebar token and the
|
||||
filter chip have nothing to show but `17045` until these are resolved."""
|
||||
user = _seed(db)
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections?keys=col1")
|
||||
r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?keys=col1")
|
||||
assert r.status_code == 200
|
||||
assert [c["title"] for c in r.json()["collections"]] == ["Franchise"]
|
||||
|
||||
|
||||
def test_an_empty_keys_param_asks_for_nothing(db, monkeypatch):
|
||||
def test_an_empty_keys_param_asks_for_nothing(db, monkeypatch, override_dep):
|
||||
"""`keys=` with nothing in it must not fall through to "every collection" — that would be a
|
||||
200 KB answer to a question about zero ids."""
|
||||
user = _seed(db)
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections?keys=")
|
||||
r = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?keys=")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["collections"] == []
|
||||
|
||||
@@ -58,44 +58,44 @@ def _seed(db):
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
def _titles(resp):
|
||||
return {i["title"] for i in resp.json()["items"]}
|
||||
|
||||
|
||||
def test_single_collection_filters_to_its_members(db, monkeypatch):
|
||||
def test_single_collection_filters_to_its_members(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&collections=A")
|
||||
assert _titles(resp) == {"Only A", "A and B"}
|
||||
|
||||
|
||||
def test_multiple_collections_any_is_the_union(db, monkeypatch):
|
||||
def test_multiple_collections_any_is_the_union(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A,B")
|
||||
resp = _get(
|
||||
db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&collections=A,B"
|
||||
)
|
||||
assert _titles(resp) == {"Only A", "Only B", "A and B"}
|
||||
|
||||
|
||||
def test_multiple_collections_all_is_the_intersection(db, monkeypatch):
|
||||
def test_multiple_collections_all_is_the_intersection(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"/api/plex/library?scope=movie&collections=A,B&collection_mode=all",
|
||||
)
|
||||
assert _titles(resp) == {"A and B"}
|
||||
|
||||
|
||||
def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch):
|
||||
def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch, override_dep):
|
||||
# The browse-tile montage (top films, best-rated first) + the collection's total runtime.
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
@@ -127,14 +127,16 @@ def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch):
|
||||
db.commit()
|
||||
card = next(
|
||||
c
|
||||
for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
|
||||
for c in _get(db, user, monkeypatch, override_dep, "/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
|
||||
|
||||
|
||||
def test_create_collection_rejects_duplicate_name(db, monkeypatch):
|
||||
def test_create_collection_rejects_duplicate_name(db, monkeypatch, override_dep):
|
||||
# Same-named collections conflate in Plex, so a create with an existing name (case-insensitive) is
|
||||
# rejected 409 — BEFORE any Plex call (the guard runs first, so the DB lane can assert it).
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
@@ -146,21 +148,18 @@ def test_create_collection_rejects_duplicate_name(db, monkeypatch):
|
||||
user = User(email="dup@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
app.dependency_overrides[admin_user] = lambda: user
|
||||
try:
|
||||
r = TestClient(app).post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"}
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
override_dep(admin_user, lambda: user)
|
||||
r = TestClient(app).post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"}
|
||||
|
||||
|
||||
def test_collection_detail_hero_and_ordered_members(db, monkeypatch):
|
||||
def test_collection_detail_hero_and_ordered_members(db, monkeypatch, override_dep):
|
||||
# The Collection info page: hero aggregates (year range, most-restrictive cert, total runtime) +
|
||||
# members ordered by release year then title.
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
@@ -195,7 +194,7 @@ def test_collection_detail_hero_and_ordered_members(db, monkeypatch):
|
||||
user = User(email="cd@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
d = _get(db, user, monkeypatch, "/api/plex/collections/A").json()
|
||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/collections/A").json()
|
||||
assert d["title"] == "Coll A" and d["summary"] == "S"
|
||||
assert d["year_min"] == 1992 and d["year_max"] == 1996
|
||||
assert d["content_rating"] == "PG" # most restrictive of {G, PG}
|
||||
@@ -270,38 +269,32 @@ def _seed_editable(db):
|
||||
return lib, user
|
||||
|
||||
|
||||
def _admin_client(db, user, monkeypatch, stub, resync=_resync_ok):
|
||||
def _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_ok):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub)
|
||||
monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
app.dependency_overrides[admin_user] = lambda: user
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
override_dep(admin_user, lambda: user)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_rename_writes_through_to_plex(db, monkeypatch):
|
||||
def test_rename_writes_through_to_plex(db, monkeypatch, override_dep):
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, stub)
|
||||
try:
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "rename_collection" in stub.calls
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _admin_client(db, user, monkeypatch, override_dep, stub)
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "rename_collection" in stub.calls
|
||||
|
||||
|
||||
def test_remove_item_writes_through_to_plex(db, monkeypatch):
|
||||
def test_remove_item_writes_through_to_plex(db, monkeypatch, override_dep):
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, stub)
|
||||
try:
|
||||
r = client.delete("/api/plex/collections/A/items/m1")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "remove_collection_item" in stub.calls
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _admin_client(db, user, monkeypatch, override_dep, stub)
|
||||
r = client.delete("/api/plex/collections/A/items/m1")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "remove_collection_item" in stub.calls
|
||||
|
||||
|
||||
def _resync_create(db, plex, lib, rk):
|
||||
@@ -312,25 +305,22 @@ def _resync_create(db, plex, lib, rk):
|
||||
return col
|
||||
|
||||
|
||||
def test_create_writes_through_to_plex(db, monkeypatch):
|
||||
def test_create_writes_through_to_plex(db, monkeypatch, override_dep):
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_create)
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "create_collection" in stub.calls
|
||||
body = r.json()
|
||||
assert body["id"] == "NEW" # the stub's new ratingKey
|
||||
assert body["editable"] is True # create marks the new collection editable
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create)
|
||||
r = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "create_collection" in stub.calls
|
||||
body = r.json()
|
||||
assert body["id"] == "NEW" # the stub's new ratingKey
|
||||
assert body["editable"] is True # create marks the new collection editable
|
||||
|
||||
|
||||
def test_create_resync_failure_is_422(db, monkeypatch):
|
||||
def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
|
||||
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
|
||||
# the honest "reconcile on the next sync" detail, not a false "write failed".
|
||||
_lib, user = _seed_editable(db)
|
||||
@@ -339,34 +329,28 @@ def test_create_resync_failure_is_422(db, monkeypatch):
|
||||
def _boom(db_, plex, lib_, rk):
|
||||
raise PlexError("read failed")
|
||||
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_boom)
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
assert "reconcile" in r.json()["detail"].lower()
|
||||
assert "create_collection" in stub.calls
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_boom)
|
||||
r = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
||||
assert "create_collection" in stub.calls
|
||||
|
||||
|
||||
def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
|
||||
def test_mutation_failure_returns_422_write_failed(db, monkeypatch, override_dep):
|
||||
# The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
|
||||
# is never reached (stub.calls holds only the mutating call).
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub(fail_on="rename_collection")
|
||||
client = _admin_client(db, user, monkeypatch, stub)
|
||||
try:
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert "write failed" in r.json()["detail"].lower()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _admin_client(db, user, monkeypatch, override_dep, stub)
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == {"code": "refused", "reason": "plex_write_failed"}
|
||||
|
||||
|
||||
def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeypatch):
|
||||
def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeypatch, override_dep):
|
||||
# The mutation landed on Plex; only the follow-up refresh READ failed. We must (a) NOT claim a
|
||||
# write failure and NOT use a 5xx (which the frontend treats as offline) → 422 with a "reconcile
|
||||
# on the next sync" detail, and (b) roll back any partial local upsert the resync had flushed.
|
||||
@@ -385,21 +369,18 @@ def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeyp
|
||||
db_.flush()
|
||||
raise PlexError("read failed")
|
||||
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_boom)
|
||||
try:
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert "reconcile" in r.json()["detail"].lower()
|
||||
assert "rename_collection" in stub.calls # the write DID happen
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_boom)
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
||||
assert "rename_collection" in stub.calls # the write DID happen
|
||||
row = (
|
||||
db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
|
||||
)
|
||||
assert row.title == "My Coll" # the partial resync upsert was rolled back
|
||||
|
||||
|
||||
def test_show_collection_gets_montage_art_from_shows(db, monkeypatch):
|
||||
def test_show_collection_gets_montage_art_from_shows(db, monkeypatch, override_dep):
|
||||
# A show-library collection must get its montage from PlexShow members (else it renders blank) — the
|
||||
# A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration).
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
@@ -431,7 +412,9 @@ def test_show_collection_gets_montage_art_from_shows(db, monkeypatch):
|
||||
db.commit()
|
||||
card = next(
|
||||
c
|
||||
for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
|
||||
for c in _get(db, user, monkeypatch, override_dep, "/api/plex/collections").json()[
|
||||
"collections"
|
||||
]
|
||||
if c["id"] == "S"
|
||||
)
|
||||
assert card["art"] == ["s2", "s1"] # show members, best-rated first
|
||||
|
||||
@@ -191,14 +191,11 @@ def test_enrich_people_batch_persists_detail(db):
|
||||
# ---- item_detail serving (DB lane) ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
def _user(db, email):
|
||||
@@ -208,7 +205,7 @@ def _user(db, email):
|
||||
return u
|
||||
|
||||
|
||||
def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
|
||||
def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch, override_dep):
|
||||
"""An enriched movie serves cast/IMDb/tagline from the DB row even when the live Plex call raises —
|
||||
only markers/streams (which need the live meta) degrade."""
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
@@ -237,7 +234,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/item/m1")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["imdb_rating"] == 8.7
|
||||
@@ -249,7 +246,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
|
||||
assert body["markers"] == [] # live fetch failed → markers degrade, but detail survived
|
||||
|
||||
|
||||
def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
|
||||
def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch, override_dep):
|
||||
"""A not-yet-enriched movie is filled from the live meta on view (and persisted for next time)."""
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
@@ -261,7 +258,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/item/m1")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["imdb_id"] == "tt0068646"
|
||||
db.refresh(mv)
|
||||
@@ -270,7 +267,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
|
||||
assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"]
|
||||
|
||||
|
||||
def test_item_detail_episode_serves_rich_from_live(db, monkeypatch):
|
||||
def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep):
|
||||
"""Episodes are never enriched (not in the grid, no HU overlay), so their detail is parsed from the
|
||||
live meta the endpoint fetches for markers/streams."""
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
@@ -288,7 +285,7 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch):
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/item/e1")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/e1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
|
||||
@@ -297,7 +294,7 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch):
|
||||
assert ep.full_cast is None # episodes are not persisted/enriched
|
||||
|
||||
|
||||
def test_show_detail_serves_from_row(db, monkeypatch):
|
||||
def test_show_detail_serves_from_row(db, monkeypatch, override_dep):
|
||||
"""A show serves cast/IMDb from the DB row; `related` failing (Plex down) must not drop the detail."""
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib)
|
||||
@@ -324,7 +321,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
|
||||
raise RuntimeError("plex unavailable")
|
||||
|
||||
monkeypatch.setattr(detail_routes, "_show_enrichment", _boom)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/show/s1")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/s1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()["show"]
|
||||
assert body["imdb_rating"] == 9.1
|
||||
@@ -334,7 +331,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
|
||||
assert resp.json()["related"] == []
|
||||
|
||||
|
||||
def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch):
|
||||
def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch, override_dep):
|
||||
"""A not-yet-enriched show is filled from the live meta on first view (imdb/full_cast persisted +
|
||||
stamped) so subsequent views serve from the row."""
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
@@ -348,7 +345,7 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch):
|
||||
|
||||
# _show_enrichment returns (live meta, no related) → the fill branch runs.
|
||||
monkeypatch.setattr(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, []))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/show/s1")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/s1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["show"]["imdb_id"] == "tt0068646"
|
||||
db.refresh(show)
|
||||
|
||||
@@ -120,24 +120,21 @@ def _seed(db):
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
client = TestClient(app) # no context manager: skip lifespan
|
||||
return client.get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
client = TestClient(app) # no context manager: skip lifespan
|
||||
return client.get(path)
|
||||
|
||||
|
||||
def _ids(cards):
|
||||
return [c["id"] for c in cards]
|
||||
|
||||
|
||||
def test_discover_shape_and_recently_added_order(db, monkeypatch):
|
||||
def test_discover_shape_and_recently_added_order(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/discover?scope=both")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both")
|
||||
assert resp.status_code == 200, resp.text
|
||||
d = resp.json()
|
||||
assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"}
|
||||
@@ -148,19 +145,19 @@ def test_discover_shape_and_recently_added_order(db, monkeypatch):
|
||||
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
|
||||
|
||||
|
||||
def test_genre_tiles_carry_poster_art(db, monkeypatch):
|
||||
def test_genre_tiles_carry_poster_art(db, monkeypatch, override_dep):
|
||||
# Each genre tile carries up to 4 poster rating_keys for the 2×2 collage (top_art_keys): the top
|
||||
# movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||||
art = {g["value"]: g["art"] for g in d["genres"]}
|
||||
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
|
||||
assert art["Comedy"] == ["m2"]
|
||||
|
||||
|
||||
def test_continue_watching_movies_and_shows(db, monkeypatch):
|
||||
def test_continue_watching_movies_and_shows(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||||
ids = _ids(d["continue_watching"])
|
||||
# The in-progress movie and the show with an in-progress episode are both present; the watched
|
||||
# anchor movie (m1) is not. Order is most-recently-progressed first (the show's ep beats m3).
|
||||
@@ -168,7 +165,7 @@ def test_continue_watching_movies_and_shows(db, monkeypatch):
|
||||
assert "m1" not in ids
|
||||
|
||||
|
||||
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch):
|
||||
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch, override_dep):
|
||||
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
|
||||
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
|
||||
# A hidden started movie must be excluded.
|
||||
@@ -207,13 +204,15 @@ def test_continue_watching_null_status_in_hidden_out(db, monkeypatch):
|
||||
db.commit()
|
||||
|
||||
ids = _ids(
|
||||
_get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()["continue_watching"]
|
||||
_get(db, user, monkeypatch, override_dep, "/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
|
||||
|
||||
|
||||
def test_continue_watching_excludes_fully_watched_show(db, monkeypatch):
|
||||
def test_continue_watching_excludes_fully_watched_show(db, monkeypatch, override_dep):
|
||||
# A show whose every episode is watched is DONE, not in-progress: the `watched < total` guard must
|
||||
# keep it off the Continue-watching rail (a bare `watched > 0` would wrongly resurface it).
|
||||
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
@@ -248,14 +247,16 @@ def test_continue_watching_excludes_fully_watched_show(db, monkeypatch):
|
||||
db.commit()
|
||||
|
||||
ids = _ids(
|
||||
_get(db, user, monkeypatch, "/api/plex/discover?scope=show").json()["continue_watching"]
|
||||
_get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=show").json()[
|
||||
"continue_watching"
|
||||
]
|
||||
)
|
||||
assert "shw" not in ids # fully watched → excluded from continue-watching
|
||||
|
||||
|
||||
def test_because_you_watched_resolves_in_library_recs(db, monkeypatch):
|
||||
def test_because_you_watched_resolves_in_library_recs(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||||
bw = d["because_you_watched"]
|
||||
assert bw is not None
|
||||
assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie"
|
||||
@@ -263,34 +264,36 @@ def test_because_you_watched_resolves_in_library_recs(db, monkeypatch):
|
||||
assert _ids(bw["items"]) == ["m2"]
|
||||
|
||||
|
||||
def test_because_you_watched_null_without_history(db, monkeypatch):
|
||||
def test_because_you_watched_null_without_history(db, monkeypatch, override_dep):
|
||||
_seed(db)
|
||||
fresh = User(email="fresh-discover@example.com")
|
||||
db.add(fresh)
|
||||
db.commit()
|
||||
d = _get(db, fresh, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
d = _get(db, fresh, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||||
# A user with no watched-and-enriched movie gets no recommendation rail.
|
||||
assert d["because_you_watched"] is None
|
||||
assert d["continue_watching"] == []
|
||||
|
||||
|
||||
def test_surprise_scope_and_bound(db, monkeypatch):
|
||||
def test_surprise_scope_and_bound(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&limit=2")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&limit=2")
|
||||
assert resp.status_code == 200, resp.text
|
||||
items = resp.json()["items"]
|
||||
assert len(items) <= 2
|
||||
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
|
||||
|
||||
|
||||
def test_recently_watched_movies_and_episodes(db, monkeypatch):
|
||||
def test_recently_watched_movies_and_episodes(db, monkeypatch, override_dep):
|
||||
# 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()
|
||||
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"]
|
||||
rw = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()[
|
||||
"recently_watched"
|
||||
]
|
||||
by_id = {c["id"]: c for c in rw}
|
||||
assert "m1" in by_id and by_id["m1"]["type"] == "movie"
|
||||
assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode"
|
||||
@@ -299,11 +302,13 @@ def test_recently_watched_movies_and_episodes(db, monkeypatch):
|
||||
assert set(_ids(rw)) == {"m1", "sh1e2"}
|
||||
|
||||
|
||||
def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch):
|
||||
def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch, override_dep):
|
||||
# Surprise surfaces only UNSEEN titles: the watched movie (m1) is out; the unwatched (m2), the
|
||||
# in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind.
|
||||
user = _seed(db)
|
||||
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=both").json()["items"]
|
||||
items = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=both").json()[
|
||||
"items"
|
||||
]
|
||||
ids = _ids(items)
|
||||
assert "m1" not in ids # watched → excluded
|
||||
assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles
|
||||
@@ -311,19 +316,21 @@ def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch):
|
||||
assert len([c for c in items if c["type"] == "show"]) <= 3
|
||||
|
||||
|
||||
def test_discover_scope_movie_excludes_shows(db, monkeypatch):
|
||||
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()
|
||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
|
||||
assert all(c["type"] == "movie" for c in d["recently_added"])
|
||||
# The show drops out of continue watching under the movie scope.
|
||||
assert _ids(d["continue_watching"]) == ["m3"]
|
||||
|
||||
|
||||
def test_filters_narrow_every_discover_rail(db, monkeypatch):
|
||||
def test_filters_narrow_every_discover_rail(db, monkeypatch, override_dep):
|
||||
# The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama
|
||||
# filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop.
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both&genres=Drama").json()
|
||||
d = _get(
|
||||
db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both&genres=Drama"
|
||||
).json()
|
||||
# Recently-added: m1 + m3 (Drama) survive; Comedy m2 and the genre-less show sh1 are filtered out.
|
||||
assert set(_ids(d["recently_added"])) == {"m1", "m3"}
|
||||
# Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded).
|
||||
@@ -334,10 +341,10 @@ def test_filters_narrow_every_discover_rail(db, monkeypatch):
|
||||
assert d["because_you_watched"] is None
|
||||
|
||||
|
||||
def test_filters_narrow_surprise(db, monkeypatch):
|
||||
def test_filters_narrow_surprise(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy").json()[
|
||||
"items"
|
||||
]
|
||||
items = _get(
|
||||
db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&genres=Comedy"
|
||||
).json()["items"]
|
||||
# Only the one Comedy movie qualifies under the filter.
|
||||
assert _ids(items) == ["m2"]
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow, User
|
||||
|
||||
|
||||
def test_facets_returns_studio_vocabulary(db, monkeypatch):
|
||||
def test_facets_returns_studio_vocabulary(db, monkeypatch, override_dep):
|
||||
"""E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker."""
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
@@ -33,18 +33,15 @@ def test_facets_returns_studio_vocabulary(db, monkeypatch):
|
||||
db.add(user)
|
||||
db.commit()
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
resp = TestClient(app).get("/api/plex/facets?scope=movie")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
resp = TestClient(app).get("/api/plex/facets?scope=movie")
|
||||
assert resp.status_code == 200, resp.text
|
||||
studios = {s["value"]: s["count"] for s in resp.json()["studios"]}
|
||||
assert studios == {"Pixar": 2, "A24": 1}
|
||||
|
||||
|
||||
def test_facets_survives_json_null_genres(db, monkeypatch):
|
||||
def test_facets_survives_json_null_genres(db, monkeypatch, override_dep):
|
||||
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
@@ -61,13 +58,10 @@ def test_facets_survives_json_null_genres(db, monkeypatch):
|
||||
db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 's1'"))
|
||||
db.commit()
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
|
||||
resp = client.get("/api/plex/facets?scope=show")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
|
||||
resp = client.get("/api/plex/facets?scope=show")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
# The scalar-null show contributes no genres — and, crucially, doesn't abort the whole endpoint.
|
||||
|
||||
@@ -100,14 +100,11 @@ def _seed(db):
|
||||
return {"user": user}
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
def _ids(body):
|
||||
@@ -115,55 +112,75 @@ def _ids(body):
|
||||
|
||||
|
||||
class TestFieldScopedSearch:
|
||||
def test_everywhere_matches_every_facet(self, db, monkeypatch):
|
||||
def test_everywhere_matches_every_facet(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json()
|
||||
body = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=all"
|
||||
).json()
|
||||
# title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out
|
||||
assert _ids(body) == ["mp", "ms", "mt"]
|
||||
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
|
||||
|
||||
def test_field_all_is_identical_to_default(self, db, monkeypatch):
|
||||
def test_field_all_is_identical_to_default(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json()
|
||||
scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json()
|
||||
default = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr"
|
||||
).json()
|
||||
scoped = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=all"
|
||||
).json()
|
||||
assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"]
|
||||
assert default["total"] == scoped["total"]
|
||||
assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]]
|
||||
|
||||
def test_title_scope_narrows_to_title_match(self, db, monkeypatch):
|
||||
def test_title_scope_narrows_to_title_match(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=title").json()
|
||||
body = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=title"
|
||||
).json()
|
||||
assert _ids(body) == ["mt"]
|
||||
assert body["total"] == 1
|
||||
assert body["episodes"] == [] # non-episode field scopes suppress the episode section
|
||||
|
||||
def test_people_scope_narrows_to_cast_match(self, db, monkeypatch):
|
||||
def test_people_scope_narrows_to_cast_match(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=people").json()
|
||||
body = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=people"
|
||||
).json()
|
||||
assert _ids(body) == ["mp"]
|
||||
|
||||
def test_summary_scope_narrows_to_description_match(self, db, monkeypatch):
|
||||
def test_summary_scope_narrows_to_description_match(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=summary").json()
|
||||
body = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=summary"
|
||||
).json()
|
||||
assert _ids(body) == ["ms"]
|
||||
|
||||
def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch):
|
||||
def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode").json()
|
||||
body = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=episode"
|
||||
).json()
|
||||
assert body["items"] == []
|
||||
assert body["total"] == 0
|
||||
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
|
||||
|
||||
def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch):
|
||||
def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(
|
||||
db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode&scope=movie"
|
||||
db,
|
||||
s["user"],
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"/api/plex/library?q=zephyr&field=episode&scope=movie",
|
||||
).json()
|
||||
# movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty
|
||||
assert body["items"] == []
|
||||
assert body["episodes"] == []
|
||||
|
||||
def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch):
|
||||
def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=bogus").json()
|
||||
body = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=zephyr&field=bogus"
|
||||
).json()
|
||||
assert _ids(body) == ["mp", "ms", "mt"]
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""The near-end checkpoint that finishes a Plex item.
|
||||
|
||||
- Exactly one scrobble per finish, even when two near-end checkpoints race (the 10 s tick and the
|
||||
debounced `seeked` flush arrive together): the transition is decided by a conditional write, not a
|
||||
read taken before it.
|
||||
- The finish supersedes every resume push still in flight, so none can land at Plex after the
|
||||
scrobble and revive a resume point on a watched item.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import PlexItem, PlexLibrary, PlexState
|
||||
from app.routes.plex import playback
|
||||
|
||||
|
||||
def _item(db) -> PlexItem:
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
item = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A Movie")
|
||||
db.add(item)
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
def test_two_racing_finishes_flip_the_row_once(db, make_user):
|
||||
user = make_user()
|
||||
item = _item(db)
|
||||
db.add(PlexState(user_id=user.id, item_id=item.id, status="new", position_seconds=5000))
|
||||
db.commit()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
first, second = SessionLocal(), SessionLocal()
|
||||
try:
|
||||
assert playback._mark_finished(first, user.id, item.id, now) is True # holds the row lock
|
||||
out: list[bool] = []
|
||||
racer = threading.Thread(
|
||||
target=lambda: out.append(playback._mark_finished(second, user.id, item.id, now))
|
||||
)
|
||||
racer.start()
|
||||
racer.join(0.5)
|
||||
assert racer.is_alive(), "the second writer must wait on the first one's row lock"
|
||||
first.commit()
|
||||
racer.join(5)
|
||||
second.commit()
|
||||
finally:
|
||||
first.close()
|
||||
second.close()
|
||||
assert out == [False], "the second checkpoint saw the committed finish: no second scrobble"
|
||||
|
||||
|
||||
def test_a_first_ever_finish_inserts_and_counts_once(db, make_user):
|
||||
user = make_user()
|
||||
item = _item(db)
|
||||
now = datetime.now(timezone.utc)
|
||||
assert playback._mark_finished(db, user.id, item.id, now) is True
|
||||
db.commit()
|
||||
assert playback._mark_finished(db, user.id, item.id, now + timedelta(seconds=10)) is False
|
||||
db.commit()
|
||||
st = db.execute(select(PlexState).where(PlexState.item_id == item.id)).scalars().one()
|
||||
assert (st.status, st.position_seconds) == ("watched", 0)
|
||||
|
||||
|
||||
def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_user, monkeypatch):
|
||||
user = make_user()
|
||||
item = _item(db)
|
||||
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
|
||||
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
|
||||
# A resume push was scheduled a moment ago and has not reached Plex yet.
|
||||
assert playback._should_push_resume(user.id, item.id, earlier, final=True)
|
||||
|
||||
r = as_user(user).post(
|
||||
"/api/plex/item/m1/progress", json={"position_seconds": 2995, "duration_seconds": 3000}
|
||||
)
|
||||
assert r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
|
||||
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|
||||
@@ -38,6 +38,9 @@ class _Proc:
|
||||
return 0
|
||||
|
||||
|
||||
VIEWER = 41 # the id of the account the test client is signed in as
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, session_id: str, directory, running: bool = False, audio_langs=None):
|
||||
self.id = session_id
|
||||
@@ -59,14 +62,11 @@ def sessions(monkeypatch):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
def client(monkeypatch, override_dep):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
user = User(email="hls@example.com")
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
yield TestClient(app)
|
||||
finally:
|
||||
app.dependency_overrides.pop(current_user, None)
|
||||
user = User(id=VIEWER, email="hls@example.com")
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, sessions):
|
||||
@@ -74,12 +74,23 @@ def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, se
|
||||
d.mkdir()
|
||||
(d / "seg_11.ts").write_bytes(b"") # its predecessor, so seg_12 is the next one
|
||||
(d / "seg_12.ts").write_bytes(b"mine")
|
||||
sessions["m1"] = _Session("sid-a", d)
|
||||
sessions[(VIEWER, "m1")] = _Session("sid-a", d)
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.content == b"mine"
|
||||
|
||||
|
||||
def test_another_viewers_session_of_the_same_item_is_not_this_viewers(client, tmp_path, sessions):
|
||||
"""Sessions are per viewer: a second account playing the same film has its own, and knowing its
|
||||
id does not make it yours — resolution is by (viewer, item) before the id is compared."""
|
||||
d = tmp_path / "m1_u7_0_0_+0.00"
|
||||
d.mkdir()
|
||||
(d / "index.m3u8").write_bytes(b"#EXTM3U")
|
||||
sessions[(7, "m1")] = _Session("sid-a", d)
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/index.m3u8")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
|
||||
client, tmp_path, sessions
|
||||
):
|
||||
@@ -89,7 +100,7 @@ def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
|
||||
theirs.mkdir()
|
||||
(theirs / "seg_11.ts").write_bytes(b"")
|
||||
(theirs / "seg_12.ts").write_bytes(b"theirs")
|
||||
sessions["m1"] = _Session("sid-b", theirs)
|
||||
sessions[(VIEWER, "m1")] = _Session("sid-b", theirs)
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
||||
assert r.status_code == 404, r.text
|
||||
assert b"theirs" not in r.content
|
||||
@@ -105,7 +116,7 @@ def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
|
||||
monkeypatch.setattr(
|
||||
plex_stream,
|
||||
"start_session",
|
||||
lambda *a, **k: started.append(a[2] if len(a) > 2 else 0.0),
|
||||
lambda *a, **k: started.append(a[3] if len(a) > 3 else 0.0),
|
||||
)
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8")
|
||||
assert r.status_code == 404, r.text
|
||||
@@ -122,7 +133,7 @@ def test_a_segment_that_will_never_arrive_fails_at_once(client, tmp_path, sessio
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_0.ts").write_bytes(b"x") # the session is producing; seg_99999 is simply not next
|
||||
sessions["m1"] = _Session("sid-a", d, running=True)
|
||||
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
|
||||
started = time.monotonic()
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts")
|
||||
elapsed = time.monotonic() - started
|
||||
@@ -150,7 +161,7 @@ def test_a_multi_audio_segment_beyond_the_region_fails_at_once_too(
|
||||
d.mkdir()
|
||||
(d / "seg_0_0.ts").write_bytes(b"x")
|
||||
(d / "seg_a0_0.ts").write_bytes(b"x")
|
||||
sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
|
||||
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
|
||||
started = time.monotonic()
|
||||
r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}")
|
||||
elapsed = time.monotonic() - started
|
||||
@@ -166,7 +177,7 @@ def test_a_multi_audio_segment_whose_predecessor_exists_is_served(client, tmp_pa
|
||||
d.mkdir()
|
||||
(d / "seg_a0_0.ts").write_bytes(b"x")
|
||||
(d / "seg_a0_1.ts").write_bytes(b"audio")
|
||||
sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
|
||||
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_a0_1.ts")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.content == b"audio"
|
||||
@@ -179,7 +190,7 @@ def test_a_name_the_session_cannot_produce_fails_at_once(client, tmp_path, sessi
|
||||
its playlists and its segments and nothing else, so any other name is answerable now."""
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
sessions["m1"] = _Session("sid-a", d, running=True)
|
||||
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
|
||||
started = time.monotonic()
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/nope.m3u8")
|
||||
elapsed = time.monotonic() - started
|
||||
@@ -194,7 +205,7 @@ def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions,
|
||||
subtitle routes were bounded for; the answer is the same 404 any other unproducible name gets."""
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
sessions["m1"] = _Session("sid-a", d, running=True)
|
||||
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
|
||||
r = client.get(f"/api/plex/stream/m1/hls/sid-a/seg_{'9' * digits}.ts")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
@@ -209,7 +220,7 @@ def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_11.ts").write_bytes(b"")
|
||||
sessions["m1"] = _Session("sid-a", d)
|
||||
sessions[(VIEWER, "m1")] = _Session("sid-a", d)
|
||||
# The file existed when the wait checked, and is gone by the time the route reads it.
|
||||
monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True)
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
||||
@@ -224,7 +235,7 @@ def test_a_dead_producer_is_not_waited_out(client, tmp_path, sessions):
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_5.ts").write_bytes(b"x")
|
||||
sessions["m1"] = _Session("sid-a", d, running=False)
|
||||
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=False)
|
||||
started = time.monotonic()
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_6.ts")
|
||||
elapsed = time.monotonic() - started
|
||||
@@ -294,10 +305,10 @@ def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, c
|
||||
30.0 s and `seg_a0_0.ts` 20.1 s to answer 404 (deep-review round 7)."""
|
||||
single = tmp_path / "m1_0_0_+0.00"
|
||||
single.mkdir()
|
||||
sessions["m1"] = _Session("sid-a", single, running=True)
|
||||
sessions[(VIEWER, "m1")] = _Session("sid-a", single, running=True)
|
||||
multi = tmp_path / "m2_0_multi_+0.00"
|
||||
multi.mkdir()
|
||||
sessions["m2"] = _Session("sid-b", multi, running=True, audio_langs=["hu", "en"])
|
||||
sessions[(VIEWER, "m2")] = _Session("sid-b", multi, running=True, audio_langs=["hu", "en"])
|
||||
|
||||
# Each of these belongs to the other shape (or to no shape at all) and must be refused without
|
||||
# a wait — the assertion is the ELAPSED time as much as the status.
|
||||
@@ -315,8 +326,8 @@ def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, c
|
||||
)
|
||||
|
||||
# And the control: the names each session CAN write are still waited for, not refused.
|
||||
assert plex_stream.artifact_may_arrive(sessions["m1"], "index.m3u8")
|
||||
assert plex_stream.artifact_may_arrive(sessions["m1"], "seg_0.ts")
|
||||
assert plex_stream.artifact_may_arrive(sessions["m2"], "master.m3u8")
|
||||
assert plex_stream.artifact_may_arrive(sessions["m2"], "stream_a1.m3u8")
|
||||
assert plex_stream.artifact_may_arrive(sessions["m2"], "seg_a1_0.ts")
|
||||
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m1")], "index.m3u8")
|
||||
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m1")], "seg_0.ts")
|
||||
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "master.m3u8")
|
||||
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "stream_a1.m3u8")
|
||||
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "seg_a1_0.ts")
|
||||
|
||||
@@ -41,34 +41,33 @@ def _seed(db):
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
def _titles(resp):
|
||||
return {i["title"] for i in resp.json()["items"]}
|
||||
|
||||
|
||||
def test_keyword_filter_narrows_to_matching_rows(db, monkeypatch):
|
||||
def test_keyword_filter_narrows_to_matching_rows(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=heist")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&keywords=heist")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _titles(resp) == {"Heist Movie"}
|
||||
|
||||
|
||||
def test_no_keyword_filter_returns_all(db, monkeypatch):
|
||||
def test_no_keyword_filter_returns_all(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie")
|
||||
assert _titles(resp) == {"Heist Movie", "Space Movie", "No Keywords Movie"}
|
||||
|
||||
|
||||
def test_unknown_keyword_matches_nothing(db, monkeypatch):
|
||||
def test_unknown_keyword_matches_nothing(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=nonesuch")
|
||||
resp = _get(
|
||||
db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&keywords=nonesuch"
|
||||
)
|
||||
assert resp.json()["items"] == []
|
||||
|
||||
@@ -56,54 +56,55 @@ def _seed(db):
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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:
|
||||
client = TestClient(app) # no context manager: skip lifespan
|
||||
return client.get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
client = TestClient(app) # no context manager: skip lifespan
|
||||
return client.get(path)
|
||||
|
||||
|
||||
def _titles(resp):
|
||||
return {i["title"] for i in resp.json()["items"]}
|
||||
|
||||
|
||||
def test_library_serves_active_language_titles(db, monkeypatch):
|
||||
def test_library_serves_active_language_titles(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
hu = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu")
|
||||
en = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en")
|
||||
hu = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu")
|
||||
en = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=en")
|
||||
# HU serves the twin's Hungarian title; the EN-only row falls back to its English title.
|
||||
assert _titles(hu) == {"Magyar Cím", "EN Only"}
|
||||
assert _titles(en) == {"English Title", "EN Only"}
|
||||
|
||||
|
||||
def test_facets_vocabulary_is_language_specific(db, monkeypatch):
|
||||
def test_facets_vocabulary_is_language_specific(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
hu = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=hu")
|
||||
en = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=en")
|
||||
hu = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=hu")
|
||||
en = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=en")
|
||||
hu_genres = {g["value"] for g in hu.json()["genres"]}
|
||||
en_genres = {g["value"] for g in en.json()["genres"]}
|
||||
assert "Dráma" in hu_genres and "Drama" not in hu_genres
|
||||
assert "Drama" in en_genres and "Dráma" not in en_genres
|
||||
|
||||
|
||||
def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch):
|
||||
def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
# The HU genre chip filters the HU column; the same string against the EN column matches nothing.
|
||||
hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&genres=Dráma")
|
||||
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en&genres=Dráma")
|
||||
hit = _get(
|
||||
db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu&genres=Dráma"
|
||||
)
|
||||
miss = _get(
|
||||
db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=en&genres=Dráma"
|
||||
)
|
||||
assert _titles(hit) == {"Magyar Cím"}
|
||||
assert miss.json()["items"] == []
|
||||
|
||||
|
||||
def test_item_detail_language_and_rating_split(db, monkeypatch):
|
||||
def test_item_detail_language_and_rating_split(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/item/m1?lang=hu")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1?lang=hu")
|
||||
body = resp.json()
|
||||
assert body["title"] == "Magyar Cím"
|
||||
assert body["summary"] == "Magyar összefoglaló."
|
||||
@@ -112,15 +113,15 @@ def test_item_detail_language_and_rating_split(db, monkeypatch):
|
||||
assert body["rating"] == 6.5
|
||||
|
||||
|
||||
def test_hu_search_still_finds_en_only_titles(db, monkeypatch):
|
||||
def test_hu_search_still_finds_en_only_titles(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
# m2 ("EN Only") has no HU twin, so search_vector_hu carries no title tokens for it. HU-mode search
|
||||
# must still match it via the EN vector (vector_match's union) — findable by its English title.
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&q=Only")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu&q=Only")
|
||||
assert "EN Only" in _titles(resp)
|
||||
|
||||
|
||||
def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch):
|
||||
def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch, override_dep):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
@@ -142,7 +143,7 @@ def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch):
|
||||
user = User(email="rating@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=7")
|
||||
hit = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&rating_min=7")
|
||||
assert "High IMDb" in _titles(hit)
|
||||
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=9")
|
||||
miss = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&rating_min=9")
|
||||
assert "High IMDb" not in _titles(miss)
|
||||
|
||||
@@ -111,44 +111,53 @@ def _seed(db):
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
def _ids(resp):
|
||||
return [i["id"] for i in resp.json()["items"]]
|
||||
|
||||
|
||||
def test_movies_ordered_by_last_watched_desc(db, monkeypatch):
|
||||
def test_movies_ordered_by_last_watched_desc(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(
|
||||
db, user, monkeypatch, "/api/plex/library?scope=movie&sort=lastwatched&sort_dir=desc"
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"/api/plex/library?scope=movie&sort=lastwatched&sort_dir=desc",
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# Newest resume, then the watched-only (coalesce → watched_at), then the older resume, then never.
|
||||
assert _ids(resp) == ["mo_new", "mo_watched", "mo_old", "mo_never"]
|
||||
|
||||
|
||||
def test_shows_ordered_by_last_watched_desc(db, monkeypatch):
|
||||
def test_shows_ordered_by_last_watched_desc(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(
|
||||
db, user, monkeypatch, "/api/plex/library?scope=show&sort=lastwatched&sort_dir=desc"
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"/api/plex/library?scope=show&sort=lastwatched&sort_dir=desc",
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# Recent resume first, then the watched-only show, then the never-watched one (nullslast).
|
||||
assert _ids(resp) == ["sh_recent", "sh_watched", "sh_never"]
|
||||
|
||||
|
||||
def test_last_watched_mixed_scope_nullslast(db, monkeypatch):
|
||||
def test_last_watched_mixed_scope_nullslast(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
resp = _get(
|
||||
db, user, monkeypatch, "/api/plex/library?scope=both&sort=lastwatched&sort_dir=desc"
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"/api/plex/library?scope=both&sort=lastwatched&sort_dir=desc",
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
ids = _ids(resp)
|
||||
|
||||
@@ -25,17 +25,14 @@ from app.plex.query import content_libs
|
||||
NAME = "Robert De Niro"
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
|
||||
|
||||
|
||||
def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
|
||||
def test_person_detail_returns_cast_and_director_credits(db, monkeypatch, override_dep):
|
||||
movies_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
shows_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
off_lib = PlexLibrary(plex_key="3", title="Off", kind="movie", enabled=False)
|
||||
@@ -89,7 +86,7 @@ def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
|
||||
db.add(PlexState(user_id=user.id, item_id=heat.id, status="watched"))
|
||||
db.commit()
|
||||
|
||||
resp = _get(db, user, monkeypatch, f"/api/plex/person/{NAME.replace(' ', '%20')}")
|
||||
resp = _get(db, user, monkeypatch, override_dep, f"/api/plex/person/{NAME.replace(' ', '%20')}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
|
||||
@@ -106,7 +103,7 @@ def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
|
||||
assert body["shows"][0]["type"] == "show"
|
||||
|
||||
|
||||
def test_person_detail_unknown_name_is_empty(db, monkeypatch):
|
||||
def test_person_detail_unknown_name_is_empty(db, monkeypatch, override_dep):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
@@ -123,7 +120,7 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch):
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/person/Ghost%20Actor")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Ghost%20Actor")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null).
|
||||
|
||||
@@ -168,17 +168,14 @@ def test_enrich_person_noop_without_tmdb_id(db, monkeypatch):
|
||||
assert plex_tmdb.enrich_person(db, person) is False
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch):
|
||||
def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, override_dep):
|
||||
# The bio fetch runs OFF the request path (a background task after the response), so the serve never
|
||||
# makes a live TMDB call. An un-enriched person (tmdb_person_id set + tmdb_enriched_at None) schedules
|
||||
# the enrich and the page still renders immediately with whatever's in the row (bio still null).
|
||||
@@ -196,7 +193,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch):
|
||||
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
|
||||
people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open
|
||||
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/person/Lazy P")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Lazy P")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["biography"] is None # not enriched yet — the bio lands by the next open
|
||||
assert len(scheduled) == 1 # the enrich was scheduled off the request path
|
||||
@@ -260,7 +257,7 @@ def test_should_schedule_enrich_dedups_within_cooldown():
|
||||
people._enrich_inflight.clear()
|
||||
|
||||
|
||||
def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch):
|
||||
def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch, override_dep):
|
||||
# An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -284,13 +281,13 @@ def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch):
|
||||
scheduled: list[int] = []
|
||||
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
|
||||
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/person/Done P")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Done P")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["biography"] == "Bio."
|
||||
assert scheduled == [] # already enriched → no background work
|
||||
|
||||
|
||||
def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch):
|
||||
def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch, override_dep):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
@@ -327,7 +324,7 @@ def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch):
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/person/Actor X")
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Actor X")
|
||||
assert resp.status_code == 200, resp.text
|
||||
j = resp.json()
|
||||
assert j["biography"] == "Bio here."
|
||||
|
||||
@@ -347,47 +347,41 @@ def _seed_classes(db):
|
||||
return lib
|
||||
|
||||
|
||||
def _client(db, user, monkeypatch):
|
||||
def _client(db, user, monkeypatch, override_dep):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_library_filters_by_playback_class(db, monkeypatch):
|
||||
def test_library_filters_by_playback_class(db, monkeypatch, override_dep):
|
||||
_seed_classes(db)
|
||||
user = User(email="pclass@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch)
|
||||
try:
|
||||
# Software-only transcodes.
|
||||
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||
assert sw.status_code == 200, sw.text
|
||||
assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
|
||||
# HW transcode + Direct Play, OR-combined.
|
||||
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
|
||||
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
|
||||
# The HW card carries the transcode_hw flag for the 4-way badge.
|
||||
card = next(i for i in both.json()["items"] if i["id"] == "thw")
|
||||
assert card["transcode_hw"] is True
|
||||
# No filter → everything.
|
||||
allm = client.get("/api/plex/library?scope=movie")
|
||||
assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _client(db, user, monkeypatch, override_dep)
|
||||
# Software-only transcodes.
|
||||
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||
assert sw.status_code == 200, sw.text
|
||||
assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
|
||||
# HW transcode + Direct Play, OR-combined.
|
||||
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
|
||||
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
|
||||
# The HW card carries the transcode_hw flag for the 4-way badge.
|
||||
card = next(i for i in both.json()["items"] if i["id"] == "thw")
|
||||
assert card["transcode_hw"] is True
|
||||
# No filter → everything.
|
||||
allm = client.get("/api/plex/library?scope=movie")
|
||||
assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
|
||||
|
||||
|
||||
def test_facets_count_playback_classes_in_severity_order(db, monkeypatch):
|
||||
def test_facets_count_playback_classes_in_severity_order(db, monkeypatch, override_dep):
|
||||
_seed_classes(db)
|
||||
user = User(email="pclassfacet@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch)
|
||||
try:
|
||||
resp = client.get("/api/plex/facets?scope=movie")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _client(db, user, monkeypatch, override_dep)
|
||||
resp = client.get("/api/plex/facets?scope=movie")
|
||||
assert resp.status_code == 200, resp.text
|
||||
playable = resp.json()["playable"]
|
||||
# Fixed good→heavy order (not by count), one per seeded class.
|
||||
@@ -400,7 +394,7 @@ def test_facets_count_playback_classes_in_severity_order(db, monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
|
||||
def test_unprobed_transcode_folds_into_sw(db, monkeypatch, override_dep):
|
||||
# transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's
|
||||
# CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with
|
||||
# software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the
|
||||
@@ -422,20 +416,17 @@ def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
|
||||
user = User(email="pnull@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch)
|
||||
try:
|
||||
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
|
||||
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
|
||||
assert hw.json()["items"] == [] # never HW
|
||||
facets = client.get("/api/plex/facets?scope=movie")
|
||||
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
|
||||
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _client(db, user, monkeypatch, override_dep)
|
||||
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
|
||||
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
|
||||
assert hw.json()["items"] == [] # never HW
|
||||
facets = client.get("/api/plex/facets?scope=movie")
|
||||
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
|
||||
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
|
||||
|
||||
|
||||
def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch):
|
||||
def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch, override_dep):
|
||||
# A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion),
|
||||
# otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting
|
||||
# 'direct' must still return all four classes at their full, unnarrowed counts.
|
||||
@@ -443,17 +434,14 @@ def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch):
|
||||
user = User(email="pself@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch)
|
||||
try:
|
||||
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _client(db, user, monkeypatch, override_dep)
|
||||
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
|
||||
assert resp.status_code == 200, resp.text
|
||||
counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
|
||||
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
|
||||
|
||||
|
||||
def test_active_playable_narrows_the_other_facets(db, monkeypatch):
|
||||
def test_active_playable_narrows_the_other_facets(db, monkeypatch, override_dep):
|
||||
# The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so
|
||||
# e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a
|
||||
# software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000.
|
||||
@@ -485,11 +473,8 @@ def test_active_playable_narrows_the_other_facets(db, monkeypatch):
|
||||
user = User(email="pnarrow@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch)
|
||||
try:
|
||||
wide = client.get("/api/plex/facets?scope=movie").json()
|
||||
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client = _client(db, user, monkeypatch, override_dep)
|
||||
wide = client.get("/api/plex/facets?scope=movie").json()
|
||||
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
|
||||
assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range
|
||||
assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset
|
||||
|
||||
@@ -48,14 +48,14 @@ def _user(db, email="play@example.com"):
|
||||
return u
|
||||
|
||||
|
||||
def _post(db, user, monkeypatch, rating_key, *, captured):
|
||||
def _post(db, user, monkeypatch, override_dep, rating_key, *, captured):
|
||||
"""Drive the endpoint with ffmpeg stubbed out. `start_session` records the `transcode` flag it
|
||||
was called with into `captured` and returns a stand-in session echoing it back."""
|
||||
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
||||
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
|
||||
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
|
||||
|
||||
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
|
||||
def fake_start(db_, it, user_id, start, audio, aoff, multi, transcode=False):
|
||||
captured["transcode"] = transcode
|
||||
return SimpleNamespace(
|
||||
id="sid",
|
||||
@@ -67,20 +67,17 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
|
||||
)
|
||||
|
||||
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
|
||||
|
||||
|
||||
def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch):
|
||||
def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch, override_dep):
|
||||
# A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session.
|
||||
it = _item(db, playable="transcode")
|
||||
user = _user(db)
|
||||
captured: dict = {}
|
||||
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||
resp = _post(db, user, monkeypatch, override_dep, it.rating_key, captured=captured)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["mode"] == "hls"
|
||||
@@ -88,12 +85,12 @@ def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch):
|
||||
assert captured["transcode"] is True # start_session was told to re-encode
|
||||
|
||||
|
||||
def test_remux_item_stays_a_stream_copy_session(db, monkeypatch):
|
||||
def test_remux_item_stays_a_stream_copy_session(db, monkeypatch, override_dep):
|
||||
# A remux (h264) item takes the SAME HLS path but must NOT re-encode.
|
||||
it = _item(db, playable="remux", rating_key="m2")
|
||||
user = _user(db, "remux@example.com")
|
||||
captured: dict = {}
|
||||
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||
resp = _post(db, user, monkeypatch, override_dep, it.rating_key, captured=captured)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["mode"] == "hls"
|
||||
@@ -101,12 +98,12 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch):
|
||||
assert captured["transcode"] is False
|
||||
|
||||
|
||||
def test_direct_item_never_opens_a_session(db, monkeypatch):
|
||||
def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep):
|
||||
# A browser-native file is served raw — start_session is never reached, so no transcode decision.
|
||||
it = _item(db, playable="direct", rating_key="m3")
|
||||
user = _user(db, "direct@example.com")
|
||||
captured: dict = {}
|
||||
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||
resp = _post(db, user, monkeypatch, override_dep, it.rating_key, captured=captured)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["mode"] == "direct"
|
||||
assert "transcode" not in captured # start_session not called
|
||||
|
||||
@@ -96,27 +96,26 @@ def _seed(db):
|
||||
return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show}
|
||||
|
||||
|
||||
def _request(db, user, monkeypatch, method, path, json=None):
|
||||
def _request(db, user, monkeypatch, override_dep, method, path, json=None):
|
||||
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:
|
||||
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
||||
return client.request(method, path, json=json)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
||||
return client.request(method, path, json=json)
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
return _request(db, user, monkeypatch, "GET", path)
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
return _request(db, user, monkeypatch, override_dep, "GET", path)
|
||||
|
||||
|
||||
class TestUnifiedLibrary:
|
||||
def test_both_scope_unions_movies_and_shows_ordered_by_added(self, db, monkeypatch):
|
||||
def test_both_scope_unions_movies_and_shows_ordered_by_added(
|
||||
self, db, monkeypatch, override_dep
|
||||
):
|
||||
s = _seed(db)
|
||||
r = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=both")
|
||||
r = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=both")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["total"] == 3 # 2 movies + 1 show, unioned
|
||||
@@ -127,43 +126,53 @@ class TestUnifiedLibrary:
|
||||
assert show_item["season_count"] == 1
|
||||
assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched"
|
||||
|
||||
def test_scope_movie_only(self, db, monkeypatch):
|
||||
def test_scope_movie_only(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=movie").json()
|
||||
body = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=movie"
|
||||
).json()
|
||||
assert body["total"] == 2
|
||||
assert all(i["type"] == "movie" for i in body["items"])
|
||||
|
||||
def test_scope_show_only(self, db, monkeypatch):
|
||||
def test_scope_show_only(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=show").json()
|
||||
body = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=show").json()
|
||||
assert [i["id"] for i in body["items"]] == ["s1"]
|
||||
assert body["items"][0]["season_count"] == 1
|
||||
|
||||
def test_watch_state_branches(self, db, monkeypatch):
|
||||
def test_watch_state_branches(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
# watched: only movie m1 (the show is in_progress, not fully watched)
|
||||
watched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=watched").json()
|
||||
watched = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=watched"
|
||||
).json()
|
||||
assert [i["id"] for i in watched["items"]] == ["m1"]
|
||||
# in_progress: only the show (no movie is in progress)
|
||||
inprog = _get(db, s["user"], monkeypatch, "/api/plex/library?show=in_progress").json()
|
||||
inprog = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=in_progress"
|
||||
).json()
|
||||
assert [i["id"] for i in inprog["items"]] == ["s1"]
|
||||
# unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means
|
||||
# 0 watched AND 0 in-progress episodes, and e1 is in-progress. (Locks the actual branch.)
|
||||
unwatched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=unwatched").json()
|
||||
unwatched = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=unwatched"
|
||||
).json()
|
||||
assert [i["id"] for i in unwatched["items"]] == ["m2"]
|
||||
|
||||
def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch):
|
||||
def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=charlie").json()
|
||||
body = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=charlie").json()
|
||||
# only the show title matches in the item grid; the movies don't contain "charlie"
|
||||
assert [i["id"] for i in body["items"]] == ["s1"]
|
||||
assert body["total"] == 1
|
||||
# matching episodes come back grouped in the separate episodes list
|
||||
assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"]
|
||||
|
||||
def test_pagination_caps_items_not_total(self, db, monkeypatch):
|
||||
def test_pagination_caps_items_not_total(self, db, monkeypatch, override_dep):
|
||||
s = _seed(db)
|
||||
body = _get(db, s["user"], monkeypatch, "/api/plex/library?limit=2&offset=0").json()
|
||||
body = _get(
|
||||
db, s["user"], monkeypatch, override_dep, "/api/plex/library?limit=2&offset=0"
|
||||
).json()
|
||||
assert body["total"] == 3
|
||||
assert [i["id"] for i in body["items"]] == [
|
||||
"s1",
|
||||
@@ -267,7 +276,7 @@ class TestFacetsWithData:
|
||||
JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only
|
||||
covers the null-genre guard; this locks the aggregated numbers with real data."""
|
||||
|
||||
def test_facet_aggregation(self, db, monkeypatch):
|
||||
def test_facet_aggregation(self, db, monkeypatch, override_dep):
|
||||
movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add_all([movie_lib, show_lib])
|
||||
@@ -311,7 +320,7 @@ class TestFacetsWithData:
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
body = _get(db, user, monkeypatch, "/api/plex/facets?scope=both").json()
|
||||
body = _get(db, user, monkeypatch, override_dep, "/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,
|
||||
@@ -332,7 +341,7 @@ class TestPlaylistAndCollectionEndpoints:
|
||||
def _movie(self, db, lib_id, rk, title):
|
||||
return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title)
|
||||
|
||||
def test_playlist_crud_roundtrip(self, db, monkeypatch):
|
||||
def test_playlist_crud_roundtrip(self, db, monkeypatch, override_dep):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
@@ -348,6 +357,7 @@ class TestPlaylistAndCollectionEndpoints:
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"POST",
|
||||
"/api/plex/playlists",
|
||||
{"title": "Faves", "item_rating_key": "m1"},
|
||||
@@ -357,7 +367,9 @@ class TestPlaylistAndCollectionEndpoints:
|
||||
assert created.json()["item_count"] == 1
|
||||
|
||||
# list -> the playlist with its count
|
||||
listed = _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"]
|
||||
listed = _get(db, user, monkeypatch, override_dep, "/api/plex/playlists").json()[
|
||||
"playlists"
|
||||
]
|
||||
assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)]
|
||||
|
||||
# add m2, then reorder to [m2, m1]
|
||||
@@ -365,6 +377,7 @@ class TestPlaylistAndCollectionEndpoints:
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"POST",
|
||||
f"/api/plex/playlists/{pid}/items",
|
||||
{"item_rating_key": "m2"},
|
||||
@@ -374,24 +387,30 @@ class TestPlaylistAndCollectionEndpoints:
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"PUT",
|
||||
f"/api/plex/playlists/{pid}/order",
|
||||
{"item_rating_keys": ["m2", "m1"]},
|
||||
)
|
||||
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json()
|
||||
detail = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
|
||||
assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order
|
||||
|
||||
# remove m1 -> one item left
|
||||
rm = _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}/items/m1")
|
||||
rm = _request(
|
||||
db, user, monkeypatch, override_dep, "DELETE", f"/api/plex/playlists/{pid}/items/m1"
|
||||
)
|
||||
assert rm.status_code == 200, rm.text
|
||||
after = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json()
|
||||
after = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
|
||||
assert [it["id"] for it in after["items"]] == ["m2"]
|
||||
|
||||
# delete the playlist -> gone from the list
|
||||
_request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}")
|
||||
assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == []
|
||||
_request(db, user, monkeypatch, override_dep, "DELETE", f"/api/plex/playlists/{pid}")
|
||||
assert (
|
||||
_get(db, user, monkeypatch, override_dep, "/api/plex/playlists").json()["playlists"]
|
||||
== []
|
||||
)
|
||||
|
||||
def test_playlist_bulk_add_and_remove(self, db, monkeypatch):
|
||||
def test_playlist_bulk_add_and_remove(self, db, monkeypatch, override_dep):
|
||||
# Covers the bulk endpoints (add-bulk / remove-bulk) and pins the remove-bulk delete's
|
||||
# synchronize_session=False path, which the single add/remove roundtrip never exercises.
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
@@ -402,38 +421,42 @@ class TestPlaylistAndCollectionEndpoints:
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists", {"title": "Bulk"})
|
||||
created = _request(
|
||||
db, user, monkeypatch, override_dep, "POST", "/api/plex/playlists", {"title": "Bulk"}
|
||||
)
|
||||
pid = created.json()["id"]
|
||||
|
||||
added = _request(
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"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}
|
||||
assert added.json() == {"ok": True, "added": 3, "item_count": 3}
|
||||
|
||||
removed = _request(
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"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}
|
||||
assert removed.json() == {"ok": True, "removed": 1, "item_count": 2}
|
||||
|
||||
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json()
|
||||
detail = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
|
||||
assert [it["id"] for it in detail["items"]] == [
|
||||
"m1",
|
||||
"m3",
|
||||
] # input order preserved, m2 gone
|
||||
|
||||
def test_collections_list(self, db, monkeypatch):
|
||||
def test_collections_list(self, db, monkeypatch, override_dep):
|
||||
from app.models import PlexCollection
|
||||
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
@@ -450,13 +473,13 @@ class TestPlaylistAndCollectionEndpoints:
|
||||
db.commit()
|
||||
|
||||
# unified scope (no library param) -> most-populated first
|
||||
body = _get(db, user, monkeypatch, "/api/plex/collections").json()
|
||||
body = _get(db, user, monkeypatch, override_dep, "/api/plex/collections").json()
|
||||
assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"]
|
||||
# name filter
|
||||
filtered = _get(db, user, monkeypatch, "/api/plex/collections?q=pix").json()
|
||||
filtered = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?q=pix").json()
|
||||
assert [c["title"] for c in filtered["collections"]] == ["Pixar"]
|
||||
|
||||
def test_item_state_watched_then_new(self, db, monkeypatch):
|
||||
def test_item_state_watched_then_new(self, db, monkeypatch, override_dep):
|
||||
# No PlexLink -> the background Plex push is a no-op; only the local PlexState path runs, which
|
||||
# is what the conversion touched (the st lookup + the delete on ->new).
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
@@ -468,7 +491,13 @@ class TestPlaylistAndCollectionEndpoints:
|
||||
db.commit()
|
||||
|
||||
watched = _request(
|
||||
db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "watched"}
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"POST",
|
||||
"/api/plex/item/m1/state",
|
||||
{"status": "watched"},
|
||||
)
|
||||
assert watched.status_code == 200, watched.text
|
||||
assert watched.json()["status"] == "watched"
|
||||
@@ -476,7 +505,15 @@ class TestPlaylistAndCollectionEndpoints:
|
||||
assert row.status == "watched"
|
||||
|
||||
# -> new deletes the state row
|
||||
back = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "new"})
|
||||
back = _request(
|
||||
db,
|
||||
user,
|
||||
monkeypatch,
|
||||
override_dep,
|
||||
"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()
|
||||
|
||||
@@ -108,7 +108,7 @@ class TestSweepOrphans:
|
||||
def test_a_live_sessions_directory_is_never_deleted(self, hls_root):
|
||||
# Registered session, but with an OLD directory — only the live check can save it.
|
||||
d = _session_dir(hls_root, "12345_0_multi_+0.00", age_s=3600)
|
||||
stream._sessions["k"] = _FakeSession(d)
|
||||
stream._sessions[(1, "k")] = _FakeSession(d)
|
||||
assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0
|
||||
assert d.exists()
|
||||
|
||||
@@ -179,10 +179,20 @@ class _CapturingProc:
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
_CapturingProc.calls.append((args, kwargs))
|
||||
self.killed = False
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def terminate(self):
|
||||
self.killed = True
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
|
||||
|
||||
def _venc(cmd):
|
||||
"""The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
|
||||
@@ -495,7 +505,7 @@ class TestColdStartWarmup:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", spy)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
||||
s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
|
||||
assert s is not None
|
||||
# seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS.
|
||||
assert waited == [f"seg_{i}.ts" for i in range(stream._WARMUP_SEGMENTS)]
|
||||
@@ -508,7 +518,7 @@ class TestColdStartWarmup:
|
||||
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", spy)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
||||
s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
|
||||
assert s is not None
|
||||
assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2
|
||||
|
||||
@@ -522,7 +532,7 @@ class TestColdStartWarmup:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", spy)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
||||
s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
|
||||
assert s is not None
|
||||
assert waited == ["seg_0.ts"]
|
||||
|
||||
@@ -548,6 +558,7 @@ class TestTranscodeStartSession:
|
||||
"plex_transcode_crf": 18,
|
||||
"plex_transcode_max_height": 480,
|
||||
"plex_max_transcodes": 4,
|
||||
"plex_max_sessions": 8,
|
||||
}[k],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -573,7 +584,7 @@ class TestTranscodeStartSession:
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||
)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
|
||||
db=None, user_id=7, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is False
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
@@ -590,7 +601,7 @@ class TestTranscodeStartSession:
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||
)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is False
|
||||
assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
|
||||
@@ -609,7 +620,7 @@ class TestTranscodeStartSession:
|
||||
# tonemap=True on the session, still on the GPU (hw=True) and never niced.
|
||||
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is True
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
@@ -635,7 +646,7 @@ class TestTranscodeStartSession:
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert (
|
||||
s is not None and s.transcode is True and s.hw is False
|
||||
@@ -666,7 +677,7 @@ class TestTranscodeStartSession:
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is False and s.tonemap is False
|
||||
assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn
|
||||
@@ -699,17 +710,17 @@ class TestTranscodeStartSession:
|
||||
def wait_then_supersede(path, timeout=20.0, proc=None):
|
||||
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
||||
# (now-killed) HW proc produced nothing.
|
||||
stream._sessions["12345"] = newer
|
||||
stream._sessions[(7, "12345")] = newer
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
|
||||
assert len(_CapturingProc.calls) == 1
|
||||
assert s is newer # yielded the currently-registered session, did not clobber it
|
||||
assert stream._sessions["12345"] is newer
|
||||
assert stream._sessions[(7, "12345")] is newer
|
||||
|
||||
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
|
||||
self, capturing_env, monkeypatch
|
||||
@@ -727,16 +738,16 @@ class TestTranscodeStartSession:
|
||||
|
||||
def wait_then_cull(path, timeout=20.0, proc=None):
|
||||
captured["s"] = stream._sessions[
|
||||
"12345"
|
||||
(7, "12345")
|
||||
] # the HW session the initial _spawn just registered
|
||||
del stream._sessions[
|
||||
"12345"
|
||||
(7, "12345")
|
||||
] # simulate _enforce_cap dropping the LRU under cap pressure
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", wait_then_cull)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert (
|
||||
len(_CapturingProc.calls) == 1
|
||||
@@ -752,7 +763,9 @@ class TestTranscodeStartSession:
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||
)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False
|
||||
s = stream.start_session(
|
||||
db=None, user_id=7, item=_FakeItem(), start_s=0.0
|
||||
) # transcode defaults False
|
||||
assert s is not None and s.transcode is False
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "copy"
|
||||
@@ -896,7 +909,7 @@ class TestAudioLoudnorm:
|
||||
)
|
||||
if multi:
|
||||
monkeypatch.setattr(stream, "_probe_audio_langs", lambda src: ["hun", "eng"])
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0, multi=multi)
|
||||
s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0, multi=multi)
|
||||
assert s is not None
|
||||
(cmd, *_), _ = _CapturingProc.calls[0]
|
||||
return s, cmd
|
||||
@@ -945,3 +958,51 @@ class TestAudioLoudnorm:
|
||||
# sweep no longer recognises is one that never gets cleaned up.
|
||||
s, _ = self._spawn(monkeypatch, on=on)
|
||||
assert _SESSION_DIR_RE.match(s.dir.name)
|
||||
|
||||
|
||||
class TestSessionsPerViewer:
|
||||
"""HLS sessions were keyed by the ITEM, so a second account opening the same film killed the
|
||||
first one's session; each then reclaimed and evicted the other. And only transcodes counted
|
||||
toward a cap, so remuxes — each a whole segmented copy of the film on disk — were unbounded."""
|
||||
|
||||
capturing_env = TestTranscodeStartSession.capturing_env
|
||||
|
||||
@staticmethod
|
||||
def _start(monkeypatch, user_id, rating_key="12345"):
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||
)
|
||||
return stream.start_session(
|
||||
db=None, user_id=user_id, item=_FakeItem(rating_key=rating_key), start_s=0.0
|
||||
)
|
||||
|
||||
def test_two_viewers_of_one_film_each_keep_their_own_session(self, capturing_env, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_sessions", {})
|
||||
a = self._start(monkeypatch, user_id=1)
|
||||
b = self._start(monkeypatch, user_id=2)
|
||||
assert stream._sessions == {(1, "12345"): a, (2, "12345"): b}
|
||||
assert not a.proc.killed
|
||||
assert a.dir != b.dir and _SESSION_DIR_RE.match(a.dir.name)
|
||||
|
||||
def test_one_account_holds_at_most_two_sessions(self, capturing_env, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_sessions", {})
|
||||
first = self._start(monkeypatch, user_id=1, rating_key="1")
|
||||
first.last_access -= 60 # the least recently used of the three
|
||||
self._start(monkeypatch, user_id=1, rating_key="2")
|
||||
self._start(monkeypatch, user_id=1, rating_key="3")
|
||||
assert sorted(stream._sessions) == [(1, "2"), (1, "3")]
|
||||
assert first.proc.killed
|
||||
|
||||
def test_remux_sessions_count_toward_the_overall_ceiling(self, capturing_env, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_sessions", {})
|
||||
monkeypatch.setattr(
|
||||
stream.sysconfig,
|
||||
"get_int",
|
||||
lambda db, k: {"plex_max_transcodes": 4, "plex_max_sessions": 3}[k],
|
||||
)
|
||||
oldest = self._start(monkeypatch, user_id=1)
|
||||
oldest.last_access -= 60
|
||||
for uid in (2, 3, 4):
|
||||
self._start(monkeypatch, user_id=uid)
|
||||
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
|
||||
assert oldest.proc.killed and not oldest.transcode
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_a_suspended_account_cannot_drive_the_subtitle_extractor(
|
||||
db, make_user, override_dep, monkeypatch, tmp_path
|
||||
):
|
||||
user = make_user(is_suspended=True)
|
||||
override_dep(get_db, lambda: db)
|
||||
override_dep(get_db, db.info["request"])
|
||||
monkeypatch.setattr(app_state, "_configured_cache", True)
|
||||
# A validly SIGNED cookie for that account — which is all the old gate looked at.
|
||||
for mod in (auth, playback):
|
||||
|
||||
@@ -412,6 +412,54 @@ def test_a_full_pass_that_died_is_not_cached_as_the_whole_track(monkeypatch):
|
||||
assert (after["state"], after["code"]) == ("unavailable", "extract_failed")
|
||||
|
||||
|
||||
def test_a_retried_full_pass_resumes_where_every_track_already_reaches(monkeypatch):
|
||||
"""A pass that ran out of time keeps what it reached, but the retry an hour later started again
|
||||
at 0 — so a file too big to read inside `EXTRACT_TIMEOUT_S` never completed. It now starts at
|
||||
the end of the span every track has from the start (less `WINDOW_PRE_S`, so a cue across the
|
||||
join is read whole), and finishing from there completes the track."""
|
||||
calls = _stub_item(monkeypatch, clean=False, cues=("39:58.000", "40:00.000"))
|
||||
_manual_pool(monkeypatch)
|
||||
subtitles._extract_and_publish("98614", 3, None)
|
||||
assert calls[-1] == ([0, 2], None, None), "the first pass reads from the head"
|
||||
calls = _stub_item(monkeypatch, clean=True, cues=("00:05.000", "00:07.000"))
|
||||
subtitles._extract_and_publish("98614", 3, None)
|
||||
assert calls == [([0, 2], 2370.0, None)]
|
||||
for sub_ord in (1, 3):
|
||||
assert subtitles.read_meta(subtitles.cache_key("98614", sub_ord))[1] is True
|
||||
assert "00:39:35.000 --> 00:39:37.000" in (
|
||||
subtitles.cache_read(subtitles.cache_key("98614", 3)) or ""
|
||||
), "the resumed read's cues sit at absolute time (2370 + 5 s)"
|
||||
|
||||
|
||||
def test_a_full_pass_starts_at_the_head_while_any_track_lacks_it(monkeypatch):
|
||||
"""One read feeds every embedded track, so the least-covered one decides: a window elsewhere in
|
||||
the film, or one track with the head and another without, still means reading from 0."""
|
||||
calls = _stub_item(monkeypatch)
|
||||
_manual_pool(monkeypatch)
|
||||
subtitles._extract_and_publish("98614", 3, 3570.0) # a window mid-film, both tracks
|
||||
subtitles.publish(subtitles.cache_key("98614", 1), _vtt(), (0.0, 600.0), False)
|
||||
subtitles._extract_and_publish("98614", 3, None)
|
||||
assert calls[-1] == ([0, 2], None, None)
|
||||
|
||||
|
||||
def test_the_status_says_whether_the_track_is_still_growing(monkeypatch):
|
||||
"""`partial` alone kept the player polling every 2 s for the rest of the film once the full
|
||||
pass had failed and backed off — a state that never reaches `ready` on its own."""
|
||||
_stub_item(monkeypatch, clean=True, cues=("00:05.000", "00:07.000"))
|
||||
pool = _manual_pool(monkeypatch)
|
||||
subtitles._extract_and_publish("98614", 3, 0.0)
|
||||
assert subtitles.ensure("98614", 3, at=10.0)["growing"] is True
|
||||
pool.jobs.clear()
|
||||
_stub_item(monkeypatch, clean=False, cues=("39:58.000", "40:00.000"))
|
||||
subtitles._extract_and_publish("98614", 3, None)
|
||||
assert subtitles.ensure("98614", 3, at=100.0) == {
|
||||
"state": "partial",
|
||||
"code": None,
|
||||
"gen": subtitles.generation(subtitles.cache_key("98614", 3)),
|
||||
"growing": False,
|
||||
}
|
||||
|
||||
|
||||
def test_polls_that_follow_the_playhead_join_one_window(monkeypatch):
|
||||
"""The poll sends the LIVE position, so it moves a second per poll. A window keyed on that
|
||||
position was a new job every second — another read of the file per poll until the first landed."""
|
||||
@@ -642,8 +690,8 @@ def test_a_scrubbing_viewer_cannot_queue_a_window_per_stop(monkeypatch):
|
||||
|
||||
def test_nothing_more_is_queued_while_the_extractor_is_saturated(monkeypatch):
|
||||
pool = _manual_pool(monkeypatch)
|
||||
for rk in range(20):
|
||||
subtitles.ensure(str(rk), 0, at=0.0)
|
||||
for rk in range(20): # twenty viewers, so no one account's share is what stops the passes
|
||||
subtitles.ensure(str(rk), 0, at=0.0, viewer=rk)
|
||||
windows = [a for _, a in pool.jobs if a[3] is not None]
|
||||
assert len(windows) == subtitles._MAX_WINDOWS_INFLIGHT
|
||||
assert len(pool.jobs) - len(windows) == subtitles._MAX_FULL_INFLIGHT
|
||||
@@ -797,6 +845,25 @@ def test_a_window_lines_up_with_the_full_pass_on_a_real_file(tmp_path, monkeypat
|
||||
assert merged.count("-->") == 30, "a window cue survived beside its full-pass twin"
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_a_full_pass_resumed_mid_file_lines_up_on_a_real_file(tmp_path, monkeypatch):
|
||||
"""A retried full pass seeks like a window does but reads to the end (no `-t`): the cue already
|
||||
running at its start must come out on time, not shifted to 0 with everything after it."""
|
||||
src = _make_mkv(tmp_path)
|
||||
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path / "cache")
|
||||
head, clean = subtitles._ffmpeg_extract(str(src), [0], None, 30.0)
|
||||
assert clean
|
||||
rest, clean = subtitles._ffmpeg_extract(str(src), [0], 21.3, None)
|
||||
assert clean
|
||||
key = subtitles.cache_key("1", 0)
|
||||
subtitles.publish(key, head[0], (0.0, 30.0), False)
|
||||
subtitles.publish(key, subtitles.shift_vtt(rest[0], -21.3), (21.3, subtitles._FULL_END_S), True)
|
||||
merged = subtitles.cache_read(key) or ""
|
||||
assert "00:00:22.000 --> 00:00:23.500\nline 11" in merged, merged
|
||||
assert "00:00:58.000 --> 00:00:59.500\nline 29" in merged, merged
|
||||
assert merged.count("-->") == 30, "a cue across the join was kept twice or lost"
|
||||
|
||||
|
||||
# --- the third deep-review round ----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -904,6 +971,27 @@ def test_a_window_is_not_refused_while_full_passes_hold_their_slots(monkeypatch)
|
||||
assert full.jobs == [], "past its bound no full pass is queued"
|
||||
|
||||
|
||||
def test_one_account_cannot_hold_every_full_pass_slot(monkeypatch):
|
||||
"""The full-pass slots are global and minutes long each: one account opening subtitles on film
|
||||
after film held them all, and nobody else's track ever completed. Past its share an account
|
||||
still gets windows — what makes a track watchable now — and the next account still gets a pass."""
|
||||
win, full = _ManualPool(), _ManualPool()
|
||||
monkeypatch.setattr(subtitles, "_WIN_POOL", win)
|
||||
monkeypatch.setattr(subtitles, "_FULL_POOL", full)
|
||||
monkeypatch.setattr(subtitles, "_extract_and_publish", lambda *a: None)
|
||||
for rk in range(subtitles._MAX_FULL_PER_USER + 1):
|
||||
subtitles.ensure(str(1000 + rk), 1, at=600.0, viewer=7)
|
||||
assert len(full.jobs) == subtitles._MAX_FULL_PER_USER
|
||||
assert len(win.jobs) == subtitles._MAX_FULL_PER_USER + 1, (
|
||||
"the account over its share still gets windows"
|
||||
)
|
||||
subtitles.ensure("2000", 1, at=600.0, viewer=8)
|
||||
assert len(full.jobs) == subtitles._MAX_FULL_PER_USER + 1, "another account is not shut out"
|
||||
full.run_all() # the account's passes end: its share is free again
|
||||
subtitles.ensure("3000", 1, at=600.0, viewer=7)
|
||||
assert any(a[1] == "3000" and a[3] is None for _, a in full.jobs)
|
||||
|
||||
|
||||
def test_a_poll_after_shutdown_queues_nothing(monkeypatch):
|
||||
pool = _manual_pool(monkeypatch)
|
||||
subtitles.shutdown()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Two rows of CLAUDE.md's canonical-patterns table, held in code rather than prose.
|
||||
|
||||
A rule stated only in a comment drifts: this one had — 63 mutation responses without `ok`, and
|
||||
twelve route helpers that committed on their own, among them an approval whose decision, activation
|
||||
and token landed in three separate transactions. The debt burn of 2026-09 fixed the drift; these keep
|
||||
it fixed.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
ROUTES = Path(__file__).parent.parent / "app" / "routes"
|
||||
MUTATING = {"post", "put", "patch", "delete"}
|
||||
|
||||
# A route helper that commits where it stands, and why the commit has to be there rather than in
|
||||
# the handler. Anything else flushes and leaves the commit to its handler.
|
||||
COMMITTING_HELPERS = {
|
||||
"auth.py:_issue_token": "the token must be durable before the link that carries it is mailed",
|
||||
"auth.py:_consume_token": "burning a single-use token is its own unit: a later failure must "
|
||||
"not hand the token back",
|
||||
"auth.py:_complete_link": "the tail of the Google callback route, which answers with its result",
|
||||
"auth.py:_register_account": "a background task with its own session, i.e. a job wrapper",
|
||||
"search.py:_ingest_candidates": "stages around YouTube calls: the stubs must survive an enrich "
|
||||
"that fails",
|
||||
"search.py:_classify_shorts": "the last stage of that ingest, after its probes",
|
||||
"auth.py:upsert_pending_invite": "commits only when it writes — the contract its three "
|
||||
"callers rely on (`_register_account` persists its own flushed user because a no-op here "
|
||||
"commits nothing)",
|
||||
"messages.py:ensure_welcome": "the websocket push after it must announce a row the client "
|
||||
"can already fetch",
|
||||
}
|
||||
|
||||
|
||||
def _modules():
|
||||
for path in sorted(ROUTES.rglob("*.py")):
|
||||
yield path.relative_to(ROUTES).as_posix(), ast.parse(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
FUNCTIONS = (ast.FunctionDef, ast.AsyncFunctionDef)
|
||||
|
||||
# The calls a mutation may answer with in place of an acknowledgement: each describes the resource
|
||||
# it wrote (or is the logout's cookie-clearing response). A domain function's REPORT dict is not one
|
||||
# of them — `{"synced": n}` is an acknowledgement, and gets spread into `{"ok": True, **report}`.
|
||||
SERIALIZERS = {
|
||||
"JSONResponse",
|
||||
"_collection_card",
|
||||
"_playlist_card",
|
||||
"_serialize",
|
||||
"_serialize_demo",
|
||||
"_serialize_job",
|
||||
"_serialize_msg",
|
||||
"_serialize_profile",
|
||||
"_serialize_user",
|
||||
"_summary",
|
||||
"admin_get_quota",
|
||||
"describe",
|
||||
"owner_view",
|
||||
}
|
||||
|
||||
|
||||
def _called(node: ast.Call) -> str:
|
||||
f = node.func
|
||||
return f.id if isinstance(f, ast.Name) else getattr(f, "attr", "")
|
||||
|
||||
|
||||
def _is_route(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
||||
return any(
|
||||
isinstance(d, ast.Call) and getattr(d.func, "attr", "") in MUTATING | {"get", "websocket"}
|
||||
for d in fn.decorator_list
|
||||
)
|
||||
|
||||
|
||||
def _is_mutation_route(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
||||
return any(
|
||||
isinstance(d, ast.Call) and getattr(d.func, "attr", "") in MUTATING
|
||||
for d in fn.decorator_list
|
||||
)
|
||||
|
||||
|
||||
def test_every_mutation_answers_ok():
|
||||
"""`{"ok": true, …}` (or `{"deleted": id}`) for every mutation, sync or async — one field a
|
||||
client can read the same way everywhere. A serializer's return (SERIALIZERS) is exempt: it
|
||||
describes a resource, not an acknowledgement. A dict built elsewhere — returned by name, or
|
||||
straight from a domain call — is not: both hid acknowledgements without `ok` from a
|
||||
literal-only scan, so such a dict is spread into a literal."""
|
||||
missing = []
|
||||
for name, tree in _modules():
|
||||
for fn in ast.walk(tree):
|
||||
if not (isinstance(fn, FUNCTIONS) and _is_mutation_route(fn)):
|
||||
continue
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.Return):
|
||||
continue
|
||||
if isinstance(node.value, ast.Call) and _called(node.value) in SERIALIZERS:
|
||||
continue
|
||||
keys = (
|
||||
{k.value for k in node.value.keys if isinstance(k, ast.Constant)}
|
||||
if isinstance(node.value, ast.Dict)
|
||||
else set()
|
||||
)
|
||||
if not keys & {"ok", "deleted"}:
|
||||
missing.append(f"{name}:{node.lineno} {fn.name}")
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_route_helpers_flush_and_leave_the_commit_to_the_handler():
|
||||
"""Every function in a route module that is not itself a route is a helper — whatever its name
|
||||
(two public ones committed unseen while this looked only at `_`-names) and sync or async."""
|
||||
committing = set()
|
||||
for name, tree in _modules():
|
||||
for fn in ast.walk(tree):
|
||||
if isinstance(fn, FUNCTIONS) and not _is_route(fn):
|
||||
if any(
|
||||
isinstance(n, ast.Call) and getattr(n.func, "attr", "") == "commit"
|
||||
for n in ast.walk(fn)
|
||||
):
|
||||
committing.add(f"{name}:{fn.name}")
|
||||
assert committing == set(COMMITTING_HELPERS)
|
||||
@@ -11,12 +11,12 @@ from app.main import app
|
||||
from app.models import User
|
||||
|
||||
|
||||
def _client(db, user, monkeypatch):
|
||||
def _client(db, user, monkeypatch, override_dep):
|
||||
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
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user) # require_human resolves through this
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -27,56 +27,43 @@ def _user(db):
|
||||
return u
|
||||
|
||||
|
||||
def test_views_are_scoped_by_source(db, monkeypatch):
|
||||
def test_views_are_scoped_by_source(db, monkeypatch, override_dep):
|
||||
user = _user(db)
|
||||
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"}
|
||||
)
|
||||
assert f.status_code == 200 and p.status_code == 200, (f.text, p.text)
|
||||
c = _client(db, user, monkeypatch, override_dep)
|
||||
# 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"})
|
||||
assert f.status_code == 200 and p.status_code == 200, (f.text, p.text)
|
||||
|
||||
feed = c.get("/api/saved-views?source=feed").json()
|
||||
plex = c.get("/api/saved-views?source=plex").json()
|
||||
assert [v["name"] for v in feed] == ["Fresh"]
|
||||
assert [v["name"] for v in plex] == ["Fresh"]
|
||||
assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2}
|
||||
# A source-less GET keeps the old feed-only behaviour.
|
||||
assert [v["id"] for v in c.get("/api/saved-views").json()] == [v["id"] for v in feed]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
feed = c.get("/api/saved-views?source=feed").json()
|
||||
plex = c.get("/api/saved-views?source=plex").json()
|
||||
assert [v["name"] for v in feed] == ["Fresh"]
|
||||
assert [v["name"] for v in plex] == ["Fresh"]
|
||||
assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2}
|
||||
# A source-less GET keeps the old feed-only behaviour.
|
||||
assert [v["id"] for v in c.get("/api/saved-views").json()] == [v["id"] for v in feed]
|
||||
|
||||
|
||||
def test_default_is_per_source(db, monkeypatch):
|
||||
def test_default_is_per_source(db, monkeypatch, override_dep):
|
||||
user = _user(db)
|
||||
try:
|
||||
c = _client(db, user, monkeypatch)
|
||||
fid = c.post(
|
||||
"/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"}
|
||||
).json()["id"]
|
||||
pid = c.post(
|
||||
"/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"}
|
||||
).json()["id"]
|
||||
c.patch(f"/api/saved-views/{fid}", json={"is_default": True})
|
||||
c.patch(f"/api/saved-views/{pid}", json={"is_default": True})
|
||||
# Making the Plex view default must NOT clear the feed view's default (different source).
|
||||
assert c.get("/api/saved-views?source=feed").json()[0]["is_default"] is True
|
||||
assert c.get("/api/saved-views?source=plex").json()[0]["is_default"] is True
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
c = _client(db, user, monkeypatch, override_dep)
|
||||
fid = c.post("/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"}).json()[
|
||||
"id"
|
||||
]
|
||||
pid = c.post("/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"}).json()[
|
||||
"id"
|
||||
]
|
||||
c.patch(f"/api/saved-views/{fid}", json={"is_default": True})
|
||||
c.patch(f"/api/saved-views/{pid}", json={"is_default": True})
|
||||
# Making the Plex view default must NOT clear the feed view's default (different source).
|
||||
assert c.get("/api/saved-views?source=feed").json()[0]["is_default"] is True
|
||||
assert c.get("/api/saved-views?source=plex").json()[0]["is_default"] is True
|
||||
|
||||
|
||||
def test_duplicate_name_within_a_source_conflicts(db, monkeypatch):
|
||||
def test_duplicate_name_within_a_source_conflicts(db, monkeypatch, override_dep):
|
||||
user = _user(db)
|
||||
try:
|
||||
c = _client(db, user, monkeypatch)
|
||||
a = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
|
||||
b = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
|
||||
assert a.status_code == 200
|
||||
assert b.status_code == 409 # same (user, source, name)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
c = _client(db, user, monkeypatch, override_dep)
|
||||
a = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
|
||||
b = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
|
||||
assert a.status_code == 200
|
||||
assert b.status_code == 409 # same (user, source, name)
|
||||
|
||||
@@ -96,7 +96,7 @@ def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db):
|
||||
assert c.enriched_at is None
|
||||
|
||||
|
||||
def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch):
|
||||
def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, override_dep):
|
||||
# Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422
|
||||
# + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry,
|
||||
# burning a second quota-costed search), and the session is rolled back. Here the page fetch succeeds
|
||||
@@ -145,12 +145,9 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(search_mod, "_ingest_candidates", _boom)
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[require_human] = lambda: user
|
||||
try:
|
||||
resp = TestClient(app).get("/api/search/youtube?q=test+term")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(require_human, lambda: user)
|
||||
resp = TestClient(app).get("/api/search/youtube?q=test+term")
|
||||
|
||||
assert resp.status_code == 422, resp.text
|
||||
assert "YouTube search failed" in resp.json()["detail"]
|
||||
assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"}
|
||||
|
||||
@@ -220,7 +220,7 @@ def test_registering_again_re_sends_the_link_instead_of_sending_nothing(db, clie
|
||||
# The old behaviour was to return in silence: same cheerful "check your inbox", no mail. That is
|
||||
# the most likely explanation for the 2026-09-03 confirmation mail that "never arrived" — none
|
||||
# was ever sent. The response stays uniform; only the dead end is gone.
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||
assert r.status_code == 200 and r.json() == {"ok": True, "status": "ok"}
|
||||
assert len(sent) == 2
|
||||
assert sent[1][0][0] == "twice@example.com"
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""An action YouTube refuses answers a 422 the UI can translate — never a raw 500 — and says what the
|
||||
user can do about it (`routes/_common.youtube_refusal`). E9 F1: the two manual syncs had no handler
|
||||
at all, so a quota or transport failure opened the blocking "Server error" dialog."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.auth import has_read_scope
|
||||
from app.models import OAuthToken
|
||||
from app.youtube import client as yt
|
||||
from app.youtube.client import YouTubeError, refusal_reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "reason"),
|
||||
[
|
||||
(YouTubeError("m", reason="quotaExceeded", path="playlists"), "quota"),
|
||||
(YouTubeError("m", reason="backendError,dailyLimitExceeded", path="videos"), "quota"),
|
||||
(YouTubeError("m", reason="invalid_grant", path="oauth2/token"), "reconnect"),
|
||||
# The token endpoint failing any other way leaves the grant alone — and a reconnect would
|
||||
# not help: Google's bad minute, a rate limit, a misconfigured client.
|
||||
(YouTubeError("m", reason="backend_error", path="oauth2/token"), "unavailable"),
|
||||
(YouTubeError("m", reason="<unparseable>", path="oauth2/token"), "unavailable"),
|
||||
(YouTubeError("m", reason="invalid_client", path="oauth2/token"), "unavailable"),
|
||||
(YouTubeError("No refresh token; user must re-authenticate"), "reconnect"),
|
||||
(YouTubeError("m", reason="transport"), "unavailable"),
|
||||
(YouTubeError("m", reason="backendError", path="subscriptions"), "unavailable"),
|
||||
],
|
||||
)
|
||||
def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason):
|
||||
assert refusal_reason(exc) == reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("route", "target"),
|
||||
[
|
||||
("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"),
|
||||
("/api/playlists/sync-youtube", "app.routes.playlists.sync_user_playlists"),
|
||||
],
|
||||
)
|
||||
def test_a_manual_sync_youtube_refuses_is_a_422_not_a_500(
|
||||
db, as_user, make_user, monkeypatch, route, target
|
||||
):
|
||||
user = make_user()
|
||||
db.commit()
|
||||
module, name = target.rsplit(".", 1)
|
||||
|
||||
def refused(*a, **k):
|
||||
raise YouTubeError("GET subscriptions -> 403: quotaExceeded", reason="quotaExceeded")
|
||||
|
||||
monkeypatch.setattr(f"{module}.has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(f"{module}.{name}", refused)
|
||||
|
||||
r = as_user(user).post(route)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == {"code": "youtube", "reason": "quota"}
|
||||
|
||||
|
||||
def test_a_revoked_grant_is_forgotten_so_the_account_can_reconnect(db, make_user, monkeypatch):
|
||||
"""Google's `invalid_grant` is final. Kept, the grant read as live in Settings — with no connect
|
||||
button — while every call failed; cleared, the connect button comes back."""
|
||||
user = make_user()
|
||||
db.add(
|
||||
OAuthToken(
|
||||
user_id=user.id,
|
||||
refresh_token_enc="x",
|
||||
scopes="openid https://www.googleapis.com/auth/youtube.readonly",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
assert has_read_scope(user)
|
||||
|
||||
resp = httpx.Response(
|
||||
400,
|
||||
json={"error": "invalid_grant", "error_description": "Token has been expired or revoked."},
|
||||
request=httpx.Request("POST", yt.GOOGLE_TOKEN_URL),
|
||||
)
|
||||
c = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub
|
||||
c.db, c.user = db, user
|
||||
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
|
||||
monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "")
|
||||
monkeypatch.setattr(c, "_send", lambda *a, **k: resp)
|
||||
|
||||
with pytest.raises(YouTubeError) as caught:
|
||||
c._access_token()
|
||||
assert refusal_reason(caught.value) == "reconnect"
|
||||
|
||||
db.expire_all()
|
||||
assert not has_read_scope(db.get(type(user), user.id))
|
||||
|
||||
|
||||
def test_any_other_token_refusal_keeps_the_grant(monkeypatch):
|
||||
"""Only `invalid_grant` is final: a 5xx from the token endpoint is Google having a bad minute —
|
||||
and the user is told to try again, not to reconnect an account that still reads as connected."""
|
||||
resp = httpx.Response(
|
||||
503, json={"error": "backend_error"}, request=httpx.Request("POST", yt.GOOGLE_TOKEN_URL)
|
||||
)
|
||||
tok = SimpleNamespace(access_token=None, expiry=None, refresh_token_enc="x", scopes="s")
|
||||
c = yt.YouTubeClient.__new__(yt.YouTubeClient)
|
||||
c.db = SimpleNamespace(add=lambda o: None, commit=lambda: None)
|
||||
c.user = SimpleNamespace(id=1, token=tok)
|
||||
monkeypatch.setattr(yt, "decrypt_optional", lambda value: None)
|
||||
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
|
||||
monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "")
|
||||
monkeypatch.setattr(c, "_send", lambda *a, **k: resp)
|
||||
|
||||
with pytest.raises(YouTubeError) as caught:
|
||||
c._access_token()
|
||||
assert tok.scopes == "s"
|
||||
assert refusal_reason(caught.value) == "unavailable"
|
||||
+174
-7
@@ -44,6 +44,130 @@ const networkProperties = [
|
||||
},
|
||||
];
|
||||
|
||||
// A `//` or `/* */` inside JSX children is not a comment — it is TEXT, rendered on the page. One
|
||||
// slipped in when a return was wrapped in a fragment (Playlists.tsx) and only a visual check caught
|
||||
// it: tsc, eslint and every test stayed green. `eslint-plugin-react` has this rule, but it would bring
|
||||
// ~1800 lockfile lines of dependencies for ten lines of logic, so it lives here.
|
||||
const jsxCommentText = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: { text: "A comment inside JSX children renders as text. Wrap it in {/* */}." },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
JSXText(node) {
|
||||
if (/^\s*(\/\/|\/\*)/m.test(node.value)) context.report({ node, messageId: "text" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
// A raw `<button disabled>` drops a focused keyboard user to <body> the moment its caller disables it
|
||||
// (a mutation pending, a list at its end, a form gone clean) — measured in Chrome, and ~60 buttons in
|
||||
// this app are disabled by their own click. `ui/FocusSafeButton` (and `ui/Button` on top of it)
|
||||
// keeps the focus; this is what stops a new raw one from reopening the hole.
|
||||
const focusSafeDisabled = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Use ui/FocusSafeButton (or ui/Button).",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
JSXOpeningElement(node) {
|
||||
if (node.name.type !== "JSXIdentifier" || node.name.name !== "button") return;
|
||||
if (node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "disabled"))
|
||||
context.report({ node, messageId: "raw" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
|
||||
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
|
||||
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
|
||||
// nothing looks at an attribute. A URL-shaped placeholder is not a word, so it passes.
|
||||
const A11Y_TEXT = new Set([
|
||||
"aria-label",
|
||||
"aria-description",
|
||||
"aria-placeholder",
|
||||
"aria-roledescription",
|
||||
"aria-valuetext",
|
||||
"title",
|
||||
"placeholder",
|
||||
"alt",
|
||||
]);
|
||||
const literalText = (v) => {
|
||||
if (!v) return null;
|
||||
if (v.type === "Literal") return typeof v.value === "string" ? v.value : null;
|
||||
if (v.type !== "JSXExpressionContainer") return null;
|
||||
const e = v.expression;
|
||||
if (e.type === "Literal") return typeof e.value === "string" ? e.value : null;
|
||||
if (e.type === "TemplateLiteral" && e.expressions.length === 0) return e.quasis[0].value.cooked;
|
||||
return null;
|
||||
};
|
||||
const literalA11yText = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: { literal: "`{{name}}` is read or shown to the user: translate it with t()." },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
JSXAttribute(node) {
|
||||
const name = node.name.type === "JSXIdentifier" ? node.name.name : null;
|
||||
if (!name || !A11Y_TEXT.has(name)) return;
|
||||
const text = literalText(node.value);
|
||||
if (text && /[A-Za-z]{2,}/.test(text) && !/^https?:/.test(text))
|
||||
context.report({ node, messageId: "literal", data: { name } });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
// dnd-kit's screen-reader text is hard-coded English and names an item by its id; three of the four
|
||||
// sortable lists shipped it that way under a Hungarian UI. `ui/sortableA11y` is the one place that
|
||||
// replaces it, and this is what keeps the next sortable list from skipping it.
|
||||
const sortableA11y = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
context: "A <DndContext> needs `accessibility={useSortableA11y(...)}` (ui/sortableA11y).",
|
||||
item: "Use `useSortableItem` from ui/sortableA11y: it announces the role in the user's language.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const own = context.filename.split("\\").join("/").endsWith("components/ui/sortableA11y.ts");
|
||||
return {
|
||||
JSXOpeningElement(node) {
|
||||
if (node.name.type !== "JSXIdentifier" || node.name.name !== "DndContext") return;
|
||||
if (
|
||||
!node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "accessibility")
|
||||
)
|
||||
context.report({ node, messageId: "context" });
|
||||
},
|
||||
ImportDeclaration(node) {
|
||||
if (own || node.source.value !== "@dnd-kit/sortable") return;
|
||||
for (const sp of node.specifiers)
|
||||
if (sp.type === "ImportSpecifier" && sp.imported.name === "useSortable")
|
||||
context.report({ node: sp, messageId: "item" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const localRules = {
|
||||
rules: {
|
||||
"sortable-a11y": sortableA11y,
|
||||
"jsx-comment-text": jsxCommentText,
|
||||
"focus-safe-disabled": focusSafeDisabled,
|
||||
"literal-a11y-text": literalA11yText,
|
||||
},
|
||||
};
|
||||
|
||||
// A px-frozen BOX utility (the no-restricted-syntax block below): an esquery regex, as a string.
|
||||
// `[\d.]+`, not `\d+`: a fractional `text-[9.5px]` slipped past the integer-only pattern.
|
||||
const BOX_PX =
|
||||
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
|
||||
const BOX_PX_MESSAGE =
|
||||
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
||||
{
|
||||
@@ -56,12 +180,17 @@ export default tseslint.config(
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
local: localRules,
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: "error",
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"local/jsx-comment-text": "error",
|
||||
"local/focus-safe-disabled": "error",
|
||||
"local/literal-a11y-text": "error",
|
||||
"local/sortable-a11y": "error",
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
||||
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
||||
@@ -83,22 +212,49 @@ export default tseslint.config(
|
||||
"error",
|
||||
{ allowShortCircuit: true, allowTernary: true },
|
||||
],
|
||||
// R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT scale
|
||||
// with the Settings --font-scale slider while its rem neighbours do; use `text-2xs`/`text-3xs`
|
||||
// or a `text-[Nrem]` arbitrary instead. Matches both plain strings and template-literal chunks;
|
||||
// rem arbitraries (text-[0.75rem]) don't match (no "px"). Font-size `text-` only — not w-/h-/p-.
|
||||
// Source-shape rules, each with its reason right above it (contract-tested in
|
||||
// lib/*.contract.test.ts).
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
// `enabled:` sees only the native attribute; a FocusSafeButton that holds the focus is
|
||||
// disabled by aria-disabled, so a bare `enabled:hover:` lit it up on hover as if it worked.
|
||||
{
|
||||
selector: "Literal[value=/text-\\[\\d+px\\]/]",
|
||||
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
|
||||
message:
|
||||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
||||
},
|
||||
{
|
||||
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
|
||||
message:
|
||||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
||||
},
|
||||
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
|
||||
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
|
||||
// 2026-09 debt burn replaced them, and this keeps the next one out.
|
||||
{
|
||||
selector: "JSXOpeningElement[name.name='select']",
|
||||
message:
|
||||
"No native <select>: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.",
|
||||
},
|
||||
// R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT
|
||||
// scale with the Settings --font-scale slider while its rem neighbours do; use
|
||||
// `text-2xs`/`text-3xs` or a `text-[Nrem]` arbitrary instead. Matches both plain strings and
|
||||
// template-literal chunks; rem arbitraries (text-[0.75rem]) don't match (no "px").
|
||||
{
|
||||
selector: "Literal[value=/text-\\[[\\d.]+px\\]/]",
|
||||
message:
|
||||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
||||
},
|
||||
{
|
||||
selector: "TemplateElement[value.raw=/text-\\[\\d+px\\]/]",
|
||||
selector: "TemplateElement[value.raw=/text-\\[[\\d.]+px\\]/]",
|
||||
message:
|
||||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
||||
},
|
||||
// …and the same for a BOX: a `w-[150px]` stays 150px while the text inside it grows with the
|
||||
// slider, so the label re-wraps or clips at large sizes. Use the spacing scale (`w-37.5` is
|
||||
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
|
||||
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||||
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -173,10 +329,21 @@ export default tseslint.config(
|
||||
files: [owner],
|
||||
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
|
||||
})),
|
||||
{
|
||||
// The two that ARE the raw button: the primitive itself, and the popover's menu row, whose
|
||||
// native `disabled` is what floating-ui's roving focus reads to skip it (see ActionMenu).
|
||||
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
|
||||
rules: { "local/focus-safe-disabled": "off" },
|
||||
},
|
||||
{
|
||||
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
|
||||
// …and render throwaway markup whose labels no user ever hears.
|
||||
files: ["src/**/*.test.{ts,tsx}"],
|
||||
rules: { "no-restricted-globals": "off", "no-restricted-properties": "off" },
|
||||
rules: {
|
||||
"no-restricted-globals": "off",
|
||||
"no-restricted-properties": "off",
|
||||
"local/literal-a11y-text": "off",
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
);
|
||||
|
||||
+1
-10
@@ -20,7 +20,6 @@ import { usePref } from "./lib/usePref";
|
||||
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
||||
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext";
|
||||
import { useChannelLayout } from "./components/channelsContext";
|
||||
import { useFeedView, useFeedViewActions } from "./components/feedViewContext";
|
||||
import { moduleDef, moduleOrder } from "./lib/modules";
|
||||
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
|
||||
@@ -71,11 +70,6 @@ export default function App() {
|
||||
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// The channel manager turns its top fade OFF for the table (its sticky header sits at the scroller
|
||||
// top) but wants it ON for the card layout (no sticky header) — so the fade depends on the layout.
|
||||
// Deliberately the NARROW layout context: subscribing to the manager's whole state here would
|
||||
// re-render the app root on every keystroke in its search box.
|
||||
const channelLayout = useChannelLayout();
|
||||
// The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared
|
||||
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
|
||||
const view = useFeedView();
|
||||
@@ -325,7 +319,7 @@ export default function App() {
|
||||
const me = meQuery.data!;
|
||||
const pageGate = moduleDef(page).gate;
|
||||
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
|
||||
const { Component: PageContent, fadeTop, maxWidth } = PAGE_CONTENT[activePage];
|
||||
const { Component: PageContent, maxWidth } = PAGE_CONTENT[activePage];
|
||||
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
||||
|
||||
return (
|
||||
@@ -392,9 +386,6 @@ export default function App() {
|
||||
<PageShell
|
||||
scrollKey={normalScrollKey}
|
||||
maxWidth={maxWidth}
|
||||
// Channels: the registry disables the top fade for its sticky-header table, but the card
|
||||
// layout has no sticky header, so re-enable it there.
|
||||
fadeTop={activePage === "channels" ? channelLayout === "cards" : (fadeTop ?? true)}
|
||||
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
||||
banners={
|
||||
<>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
</span>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass w-[480px] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
className="glass w-120 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
||||
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
||||
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { copyText } from "../lib/clipboard";
|
||||
import { formatDateTime } from "../lib/format";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
// one account lives (E12 · S5, design option C).
|
||||
@@ -195,17 +196,17 @@ export default function AccountPanel({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{a.can_activate && (
|
||||
<button
|
||||
<FocusSafeButton
|
||||
onClick={() => activate.mutate()}
|
||||
disabled={activate.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
>
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
)}
|
||||
|
||||
<button
|
||||
<FocusSafeButton
|
||||
onClick={() => resend.mutate()}
|
||||
// The demo account's address is never confirmed, and the server refuses to mail it — as
|
||||
// it refuses it a set-password link below (deep-review E11 round 3).
|
||||
@@ -222,9 +223,9 @@ export default function AccountPanel({
|
||||
{a.email_configured && a.email_verified && (
|
||||
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
||||
)}
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
|
||||
<button
|
||||
<FocusSafeButton
|
||||
onClick={() => issue.mutate()}
|
||||
disabled={issue.isPending || user.is_demo}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
@@ -233,7 +234,7 @@ export default function AccountPanel({
|
||||
<span className="flex-1 text-left">
|
||||
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
||||
</span>
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useAccountPersistedState } from "../lib/storage";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
||||
@@ -728,7 +729,7 @@ function AdminInvites() {
|
||||
: t("settings.invites.inviteHint")
|
||||
}
|
||||
>
|
||||
<button
|
||||
<FocusSafeButton
|
||||
onClick={() => onInvite(i)}
|
||||
disabled={invite.isPending}
|
||||
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
|
||||
@@ -739,7 +740,7 @@ function AdminInvites() {
|
||||
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
|
||||
>
|
||||
<Mails className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* An entry with an account behind it stays read-only: deleting it would revoke that
|
||||
@@ -759,14 +760,14 @@ function AdminInvites() {
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<button
|
||||
<FocusSafeButton
|
||||
onClick={() => onDelete(i)}
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -876,7 +877,7 @@ function AdminDemo() {
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<Tooltip hint={t("settings.demo.resetHint")}>
|
||||
<button
|
||||
<FocusSafeButton
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
@@ -893,7 +894,7 @@ function AdminDemo() {
|
||||
>
|
||||
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
|
||||
{t("settings.demo.reset")}
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The palette's DOMAIN half: which rows exist and what they do. The list/filter/keyboard shell
|
||||
// (`ui/CommandPalette`) has its own tests, so it is stubbed here to hand back the groups it was given —
|
||||
// what this file pins is the content: modules gated by the account, hidden channels left out, and the
|
||||
// two `run`s whose ORDER matters.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { CommandGroup } from "./ui/CommandPalette";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
let groups: CommandGroup[] = [];
|
||||
vi.mock("./ui/CommandPalette", () => ({
|
||||
default: (props: { groups: CommandGroup[] }) => {
|
||||
groups = props.groups;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const channels = vi.fn();
|
||||
const playlists = vi.fn();
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: {
|
||||
channels: (...a: unknown[]) => channels(...a),
|
||||
playlists: (...a: unknown[]) => playlists(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
let me: Record<string, unknown> | undefined;
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => me }));
|
||||
vi.mock("../lib/keymap", () => ({
|
||||
usePaletteOpen: () => true,
|
||||
closePalette: vi.fn(),
|
||||
toggleKeyboardHelp: vi.fn(),
|
||||
}));
|
||||
|
||||
const calls: string[] = [];
|
||||
const setPage = vi.fn((page: string, then?: () => void) => {
|
||||
calls.push(`setPage:${page}`);
|
||||
then?.();
|
||||
});
|
||||
vi.mock("./navigationContext", () => ({
|
||||
useNavigationActions: () => ({ setPage, openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./playlistsContext", () => ({
|
||||
usePlaylistsActions: () => ({
|
||||
setSelectedId: (id: number) => calls.push(`select:${id}`),
|
||||
}),
|
||||
}));
|
||||
vi.mock("./prefsContext", () => ({
|
||||
usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./savedViewsContext", () => ({
|
||||
useSavedViews: () => ({ views: [], apply: vi.fn() }),
|
||||
}));
|
||||
const requestSurprise = vi.fn();
|
||||
vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() }));
|
||||
|
||||
import AppCommandPalette from "./AppCommandPalette";
|
||||
|
||||
const user = (over: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
role: "user",
|
||||
is_demo: false,
|
||||
plex_enabled: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AppCommandPalette />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const group = (id: string) => groups.find((g) => g.id === id);
|
||||
const ids = (id: string) => group(id)?.items.map((i) => i.id) ?? [];
|
||||
|
||||
beforeEach(() => {
|
||||
groups = [];
|
||||
calls.length = 0;
|
||||
me = user();
|
||||
setPage.mockClear();
|
||||
requestSurprise.mockClear();
|
||||
channels.mockReset().mockResolvedValue([]);
|
||||
playlists.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AppCommandPalette", () => {
|
||||
it("offers only the modules this account can open, in nav order, and Settings", () => {
|
||||
mount();
|
||||
expect(ids("modules")).toContain("page:feed");
|
||||
expect(ids("modules")).not.toContain("page:plex");
|
||||
expect(ids("modules")).not.toContain("page:config"); // admin-only
|
||||
expect(ids("modules").slice(-1)).toEqual(["page:settings"]);
|
||||
|
||||
cleanup();
|
||||
me = user({ role: "admin", plex_enabled: true });
|
||||
mount();
|
||||
expect(ids("modules")).toContain("page:plex");
|
||||
expect(ids("modules")).toContain("page:config");
|
||||
});
|
||||
|
||||
it("leaves out the channels the user has hidden", async () => {
|
||||
channels.mockResolvedValue([
|
||||
{ id: "UC1", title: "Shown", handle: "@shown", hidden: false },
|
||||
{ id: "UC2", title: "Hidden", handle: "@hidden", hidden: true },
|
||||
]);
|
||||
mount();
|
||||
await waitFor(() => expect(group("channels")).toBeTruthy());
|
||||
expect(ids("channels")).toEqual(["channel:UC1"]);
|
||||
});
|
||||
|
||||
it("selects the playlist BEFORE switching to the page, so it opens on that list", async () => {
|
||||
playlists.mockResolvedValue([{ id: 7, name: "Later", item_count: 3 }]);
|
||||
mount();
|
||||
await waitFor(() => expect(group("playlists")).toBeTruthy());
|
||||
group("playlists")!.items[0]!.run();
|
||||
expect(calls).toEqual(["select:7", "setPage:playlists"]);
|
||||
});
|
||||
|
||||
it("asks for the surprise once the navigation to Home has happened, not before", () => {
|
||||
mount();
|
||||
const surprise = group("actions")!.items.find((i) => i.id === "action:surprise")!;
|
||||
// Over an open player the push lands a moment after the call, and the token must name the
|
||||
// entry that push makes (lib/homeIntent) — so it rides `setPage`'s `then`, not the next line.
|
||||
setPage.mockImplementationOnce((page: string) => void calls.push(`setPage:${page}`));
|
||||
surprise.run();
|
||||
expect(requestSurprise).not.toHaveBeenCalled();
|
||||
const then = setPage.mock.lastCall?.[1];
|
||||
expect(setPage.mock.lastCall?.[0]).toBe("home");
|
||||
then?.();
|
||||
expect(requestSurprise).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -135,10 +135,11 @@ export default function AppCommandPalette() {
|
||||
label: t("palette.actions.surprise"),
|
||||
icon: <Sparkles className="w-4 h-4" />,
|
||||
keywords: t("home.title"),
|
||||
run: () => {
|
||||
setPage("home");
|
||||
requestSurprise();
|
||||
},
|
||||
// The token is minted on the entry the push made (see lib/homeIntent), and asks for the
|
||||
// SURPRISE tier: the command is labelled "Meglepetés", and the reroll's plain next press
|
||||
// is answered by whichever tier the rotation reaches — for anyone with a shelf, "A
|
||||
// polcodról" under a command that promised a surprise (owner's call, 2026-09-19).
|
||||
run: () => setPage("home", requestSurprise),
|
||||
},
|
||||
{
|
||||
id: "action:shortcuts",
|
||||
|
||||
@@ -23,6 +23,7 @@ import Pager from "./ui/Pager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { useChannelLayout } from "./channelsContext";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
|
||||
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
|
||||
@@ -107,18 +108,16 @@ export default function ChannelDiscovery({
|
||||
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
|
||||
}
|
||||
>
|
||||
<button
|
||||
// Busy blocks the click, not the focus: a focused button that turns natively `disabled`
|
||||
// drops the keyboard to <body> (the rule and its measurement: ui/Button).
|
||||
onClick={() => !subscribe.isPending && onSubscribe(c)}
|
||||
<FocusSafeButton
|
||||
// Busy blocks the click, not the focus (FocusSafeButton).
|
||||
onClick={() => onSubscribe(c)}
|
||||
disabled={!canWrite}
|
||||
aria-disabled={subscribe.isPending || undefined}
|
||||
aria-busy={subscribe.isPending || undefined}
|
||||
busy={subscribe.isPending}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{t("channels.discovery.subscribe")}
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { Button } from "./ui/Button";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||
@@ -445,6 +446,7 @@ export default function ChannelPage({
|
||||
onPriority={(d) => bumpPriority(channelId, d)}
|
||||
onSet={(v) => setPriority(channelId, v)}
|
||||
size="md"
|
||||
hint={t("channels.row.priorityHint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -479,13 +481,11 @@ export default function ChannelPage({
|
||||
</Button>
|
||||
)}
|
||||
{!me.is_demo && (
|
||||
<button
|
||||
// Busy blocks the click, not the focus: a focused button that turns natively
|
||||
// `disabled` drops the keyboard to <body> (the rule and its measurement: ui/Button).
|
||||
// Nor does a busy toggle light up on hover, as if a click would do something.
|
||||
onClick={() => !block.isPending && block.mutate()}
|
||||
aria-disabled={block.isPending || undefined}
|
||||
aria-busy={block.isPending || undefined}
|
||||
<FocusSafeButton
|
||||
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
|
||||
// light up on hover, as if a click would do something.
|
||||
onClick={() => block.mutate()}
|
||||
busy={block.isPending}
|
||||
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||
@@ -495,16 +495,15 @@ export default function ChannelPage({
|
||||
}`}
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
)}
|
||||
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
|
||||
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
|
||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<button
|
||||
onClick={() => !toggleHidden.isPending && toggleHidden.mutate()}
|
||||
aria-disabled={toggleHidden.isPending || undefined}
|
||||
aria-busy={toggleHidden.isPending || undefined}
|
||||
<FocusSafeButton
|
||||
onClick={() => toggleHidden.mutate()}
|
||||
busy={toggleHidden.isPending}
|
||||
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
||||
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||
@@ -514,7 +513,7 @@ export default function ChannelPage({
|
||||
}`}
|
||||
>
|
||||
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
)}
|
||||
{!me.is_demo &&
|
||||
!ch?.blocked &&
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
@@ -218,7 +219,7 @@ function TagEditorMenu({
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div ref={fade.ref} style={fade.style} className="max-h-[240px] overflow-y-auto no-scrollbar">
|
||||
<div ref={fade.ref} style={fade.style} className="max-h-60 overflow-y-auto no-scrollbar">
|
||||
{userTags.map((tg) => {
|
||||
const on = attached.has(tg.id);
|
||||
return (
|
||||
@@ -267,7 +268,7 @@ function TagEditorMenu({
|
||||
placeholder={t("channels.tags.createPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<FocusSafeButton
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
@@ -276,7 +277,7 @@ function TagEditorMenu({
|
||||
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import FilterSelect from "./ui/FilterSelect";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
||||
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
||||
@@ -205,14 +206,14 @@ export default function ConfigPanel() {
|
||||
with no error anywhere — say so next to the fields that fix it. */}
|
||||
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
|
||||
<Tooltip hint={t("config.testEmailHint")}>
|
||||
<button
|
||||
<FocusSafeButton
|
||||
onClick={() => testEmail.mutate()}
|
||||
disabled={testEmail.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("config.testEmail")}
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
@@ -220,14 +221,14 @@ export default function ConfigPanel() {
|
||||
{activeGroup === "plex" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<Tooltip hint={t("config.plexTestHint")}>
|
||||
<button
|
||||
<FocusSafeButton
|
||||
onClick={() => testPlex.mutate()}
|
||||
disabled={testPlex.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("config.plexTest")}
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
||||
{plexResult && (
|
||||
|
||||
@@ -13,6 +13,7 @@ import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import { inputCls } from "./ui/form";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
|
||||
// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from
|
||||
// the Download Center. Enqueues via the API and points the user at the Download Center.
|
||||
@@ -65,18 +66,17 @@ export default function DownloadDialog({
|
||||
<Modal title={t("downloads.dialog.title")} onClose={onClose}>
|
||||
{title && <p className="text-sm text-muted mb-4 line-clamp-2">{title}</p>}
|
||||
|
||||
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.profile")}</label>
|
||||
<select
|
||||
value={chosen ?? ""}
|
||||
onChange={(e) => setProfileId(Number(e.target.value))}
|
||||
className={inputCls}
|
||||
>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="block text-sm font-medium mb-1" aria-hidden="true">
|
||||
{t("downloads.dialog.profile")}
|
||||
</span>
|
||||
{/* The user's own list, of any length: a menu, not a segmented bar (owner's pick, 2026-09-19). */}
|
||||
<FilterSelect
|
||||
label={t("downloads.dialog.profile")}
|
||||
hideLabel
|
||||
selected={chosen == null ? [] : [String(chosen)]}
|
||||
onChange={([v]) => v && setProfileId(Number(v))}
|
||||
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManage(true)}
|
||||
|
||||
@@ -50,6 +50,8 @@ import FilterSelect from "./ui/FilterSelect";
|
||||
import ItemCount from "./ui/ItemCount";
|
||||
import ViewSwitcher from "./ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
|
||||
@@ -94,6 +96,9 @@ function matchesView(status: VideoStatus, show: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
|
||||
const YT_COUNTS = [20, 40, 60, 100] as const;
|
||||
|
||||
export default function Feed({
|
||||
filters,
|
||||
setFilters,
|
||||
@@ -440,21 +445,18 @@ export default function Feed({
|
||||
<span className="text-xs text-muted">
|
||||
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
||||
</span>
|
||||
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
|
||||
{t("feed.yt.show")}
|
||||
<select
|
||||
value={ytCount}
|
||||
onChange={(e) => setYtCount(Number(e.target.value))}
|
||||
{/* Four fixed counts: the house's segmented control, not a native <select> (owner's
|
||||
pick, 2026-09-19). The caption is the group's name, the hint its description. */}
|
||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
|
||||
<span aria-hidden="true">{t("feed.yt.show")}</span>
|
||||
<SegmentedToggle
|
||||
ariaLabel={t("feed.yt.show")}
|
||||
title={t("feed.yt.showHint")}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
>
|
||||
{[20, 40, 60, 100].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
value={String(ytCount)}
|
||||
onChange={(v) => setYtCount(Number(v))}
|
||||
options={YT_COUNTS.map((n) => ({ value: String(n), label: String(n) }))}
|
||||
/>
|
||||
</div>
|
||||
{/* Sorts the results already on screen (see sortYtResults) — it never re-queries, so it
|
||||
costs nothing and works the same on both search sources. */}
|
||||
<SortControl
|
||||
@@ -516,7 +518,7 @@ export default function Feed({
|
||||
/>
|
||||
{ytQuery.hasNextPage && (
|
||||
<div className="text-center pt-4">
|
||||
<button
|
||||
<FocusSafeButton
|
||||
onClick={() => ytQuery.fetchNextPage()}
|
||||
disabled={ytQuery.isFetchingNextPage}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
|
||||
@@ -526,7 +528,7 @@ export default function Feed({
|
||||
: zeroQuota
|
||||
? t("feed.yt.loadMoreFree")
|
||||
: t("feed.yt.loadMore")}
|
||||
</button>
|
||||
</FocusSafeButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { usePrefs } from "./prefsContext";
|
||||
@@ -15,7 +16,8 @@ const LS_KEY = "siftlode.devGlassTuner";
|
||||
|
||||
type Slider = {
|
||||
k: string;
|
||||
label: string;
|
||||
/** Its name under `settings.tuner.slider.*` — every user-facing word here is translated. */
|
||||
id: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
@@ -26,11 +28,11 @@ type Slider = {
|
||||
// def = the BAKED baseline in index.css (:root). "Current" preset + the export diff both key off it.
|
||||
// def = the default rendered baseline = the "glass over image" (backdrop-on) tier from index.css.
|
||||
const SLIDERS: Slider[] = [
|
||||
{ k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 10 },
|
||||
{ k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 },
|
||||
{ k: "--glass-blur", id: "blur", min: 0, max: 40, step: 1, unit: "px", def: 10 },
|
||||
{ k: "--glass-saturate", id: "saturate", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 },
|
||||
{
|
||||
k: "--glass-dark-bright",
|
||||
label: "Dark brightness",
|
||||
id: "darkBright",
|
||||
min: 1,
|
||||
max: 2,
|
||||
step: 0.05,
|
||||
@@ -39,7 +41,7 @@ const SLIDERS: Slider[] = [
|
||||
},
|
||||
{
|
||||
k: "--glass-surface-alpha",
|
||||
label: "Panel opacity",
|
||||
id: "surfaceAlpha",
|
||||
min: 40,
|
||||
max: 100,
|
||||
step: 1,
|
||||
@@ -48,7 +50,7 @@ const SLIDERS: Slider[] = [
|
||||
},
|
||||
{
|
||||
k: "--glass-card-alpha",
|
||||
label: "Card opacity",
|
||||
id: "cardAlpha",
|
||||
min: 40,
|
||||
max: 100,
|
||||
step: 1,
|
||||
@@ -57,7 +59,7 @@ const SLIDERS: Slider[] = [
|
||||
},
|
||||
{
|
||||
k: "--glass-menu-alpha",
|
||||
label: "Menu opacity",
|
||||
id: "menuAlpha",
|
||||
min: 40,
|
||||
max: 100,
|
||||
step: 1,
|
||||
@@ -66,36 +68,35 @@ const SLIDERS: Slider[] = [
|
||||
},
|
||||
{
|
||||
k: "--glass-border-alpha",
|
||||
label: "Border opacity",
|
||||
id: "borderAlpha",
|
||||
min: 20,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit: "%",
|
||||
def: 80,
|
||||
},
|
||||
{ k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 25 },
|
||||
{ k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 15 },
|
||||
{ k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 },
|
||||
{ k: "--ambient", label: "Ambient intensity", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 },
|
||||
{ k: "--glass-edge", id: "edge", min: 0, max: 60, step: 1, unit: "%", def: 25 },
|
||||
{ k: "--glass-inset", id: "inset", min: 0, max: 40, step: 1, unit: "%", def: 15 },
|
||||
{ k: "--glass-scrim", id: "scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 },
|
||||
{ k: "--ambient", id: "ambient", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 },
|
||||
];
|
||||
|
||||
const PALETTE: { k: string; label: string }[] = [
|
||||
{ k: "--bg", label: "Background" },
|
||||
{ k: "--surface", label: "Surface" },
|
||||
{ k: "--card", label: "Card" },
|
||||
{ k: "--border", label: "Border" },
|
||||
{ k: "--fg", label: "Text" },
|
||||
{ k: "--muted", label: "Muted" },
|
||||
{ k: "--accent", label: "Accent" },
|
||||
const PALETTE: { k: string; id: string }[] = [
|
||||
{ k: "--bg", id: "bg" },
|
||||
{ k: "--surface", id: "surface" },
|
||||
{ k: "--card", id: "card" },
|
||||
{ k: "--border", id: "border" },
|
||||
{ k: "--fg", id: "fg" },
|
||||
{ k: "--muted", id: "muted" },
|
||||
{ k: "--accent", id: "accent" },
|
||||
];
|
||||
|
||||
// Each preset resets every slider to its default, then applies these overrides.
|
||||
const PRESETS: { id: string; name: string; hint: string; over: Record<string, number> }[] = [
|
||||
{ id: "current", name: "Glass/image", hint: "The default (over image)", over: {} },
|
||||
// Named under `settings.tuner.preset.<id>`.
|
||||
const PRESETS: { id: string; over: Record<string, number> }[] = [
|
||||
{ id: "current", over: {} },
|
||||
{
|
||||
id: "solid",
|
||||
name: "Solid (flat)",
|
||||
hint: "As shown w/ image off",
|
||||
over: {
|
||||
"--glass-blur": 20,
|
||||
"--glass-dark-bright": 1.25,
|
||||
@@ -109,8 +110,6 @@ const PRESETS: { id: string; name: string; hint: string; over: Record<string, nu
|
||||
},
|
||||
{
|
||||
id: "readable",
|
||||
name: "Max readable",
|
||||
hint: "Solid + strong scrim",
|
||||
over: {
|
||||
"--glass-edge": 42,
|
||||
"--glass-scrim": 45,
|
||||
@@ -141,6 +140,7 @@ function loadSaved(): Saved {
|
||||
export default function GlassTuner() {
|
||||
// Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider.
|
||||
const enabled = usePrefs().theme.showTuner;
|
||||
const { t } = useTranslation();
|
||||
const initial = useMemo(loadSaved, []);
|
||||
const [vars, setVars] = useState<Record<string, number>>(() => {
|
||||
const base: Record<string, number> = {};
|
||||
@@ -276,23 +276,26 @@ export default function GlassTuner() {
|
||||
onClick={() => persistOpen(true)}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
className="glass-menu glass-hover rounded-l-xl rounded-r-none px-2 py-3 text-xs font-semibold tracking-wide text-fg"
|
||||
title="Open the glass tuner"
|
||||
title={t("settings.tuner.open")}
|
||||
>
|
||||
<span style={{ writingMode: "vertical-rl" }}>◐ GLASS</span>
|
||||
<span style={{ writingMode: "vertical-rl" }}>{t("settings.tuner.tab")}</span>
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<div
|
||||
style={{ pointerEvents: "auto" }}
|
||||
className="glass-menu rounded-l-2xl rounded-r-none w-[300px] max-h-[82vh] flex flex-col overflow-hidden"
|
||||
className="glass-menu rounded-l-2xl rounded-r-none w-75 max-h-[82vh] flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||
<span className="text-sm font-semibold text-fg">◐ Glass Tuner</span>
|
||||
<span className="text-3xs uppercase tracking-wider text-muted">lab</span>
|
||||
<span className="text-sm font-semibold text-fg">{t("settings.tuner.title")}</span>
|
||||
<span className="text-3xs uppercase tracking-wider text-muted">
|
||||
{t("settings.tuner.lab")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => persistOpen(false)}
|
||||
className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
|
||||
title="Collapse"
|
||||
title={t("settings.tuner.collapse")}
|
||||
aria-label={t("settings.tuner.collapse")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -302,18 +305,22 @@ export default function GlassTuner() {
|
||||
{/* Treatment presets */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-3xs uppercase tracking-wider text-muted font-semibold">
|
||||
Dark treatment
|
||||
{t("settings.tuner.treatment")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => applyPreset(p.over)}
|
||||
title={p.hint}
|
||||
title={t(`settings.tuner.preset.${p.id}.hint`)}
|
||||
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-left text-xs text-fg"
|
||||
>
|
||||
<b className="block font-semibold">{p.name}</b>
|
||||
<span className="text-muted text-3xs">{p.hint}</span>
|
||||
<b className="block font-semibold">
|
||||
{t(`settings.tuner.preset.${p.id}.name`)}
|
||||
</b>
|
||||
<span className="text-muted text-3xs">
|
||||
{t(`settings.tuner.preset.${p.id}.hint`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -321,17 +328,18 @@ export default function GlassTuner() {
|
||||
|
||||
{/* Tab switch */}
|
||||
<div className="flex gap-1.5">
|
||||
{(["glass", "palette"] as const).map((t) => (
|
||||
{(["glass", "palette"] as const).map((id) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${
|
||||
tab === t
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
aria-pressed={tab === id}
|
||||
className={`flex-1 rounded-lg px-2 py-1 text-xs ${
|
||||
tab === id
|
||||
? "bg-accent text-(--accent-fg) font-semibold"
|
||||
: "glass-card text-muted"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
{t(`settings.tuner.tabs.${id}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -342,6 +350,7 @@ export default function GlassTuner() {
|
||||
<Ctl
|
||||
key={s.k}
|
||||
s={s}
|
||||
label={t(`settings.tuner.slider.${s.id}`)}
|
||||
value={vars[s.k] ?? s.def}
|
||||
onChange={(v) => setVar(s.k, v)}
|
||||
/>
|
||||
@@ -352,8 +361,7 @@ export default function GlassTuner() {
|
||||
{tab === "palette" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-3xs text-muted leading-relaxed">
|
||||
Overrides the live scheme (inline on <html>). Reset clears these back to
|
||||
the scheme.
|
||||
{t("settings.tuner.paletteHint")}
|
||||
</p>
|
||||
{PALETTE.map((p) => (
|
||||
<label key={p.k} className="flex items-center gap-2 text-xs text-fg">
|
||||
@@ -363,7 +371,7 @@ export default function GlassTuner() {
|
||||
onChange={(e) => setColor(p.k, e.target.value)}
|
||||
className="w-8 h-6 rounded-sm border border-border bg-transparent cursor-pointer"
|
||||
/>
|
||||
<span className="flex-1">{p.label}</span>
|
||||
<span className="flex-1">{t(`settings.tuner.color.${p.id}`)}</span>
|
||||
<code className="text-3xs text-muted">{swatches[p.k]}</code>
|
||||
</label>
|
||||
))}
|
||||
@@ -373,30 +381,30 @@ export default function GlassTuner() {
|
||||
{/* Export */}
|
||||
<div className="flex flex-col gap-1.5 pt-1 border-t border-border/50">
|
||||
<div className="text-3xs uppercase tracking-wider text-muted font-semibold">
|
||||
Export → bake in
|
||||
{t("settings.tuner.export")}
|
||||
</div>
|
||||
<button
|
||||
onClick={syncToTheme}
|
||||
title="Load the values of the theme you're currently viewing (light or dark) into the sliders"
|
||||
title={t("settings.tuner.syncHint")}
|
||||
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-xs text-fg text-left"
|
||||
>
|
||||
⟳ Sync to current theme
|
||||
{t("settings.tuner.sync")}
|
||||
</button>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={copy}
|
||||
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-semibold bg-accent text-(--accent-fg)"
|
||||
>
|
||||
{copied ? "Copied ✓" : "Copy CSS"}
|
||||
{copied ? t("settings.tuner.copied") : t("settings.tuner.copy")}
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="glass-card glass-hover rounded-lg px-3 py-1.5 text-xs text-fg"
|
||||
>
|
||||
Reset
|
||||
{t("settings.tuner.reset")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-[9.5px] leading-relaxed bg-(--bg) border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
|
||||
<pre className="text-[0.59375rem] leading-relaxed bg-(--bg) border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
|
||||
{cssOut}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -408,11 +416,28 @@ export default function GlassTuner() {
|
||||
);
|
||||
}
|
||||
|
||||
function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: number) => void }) {
|
||||
function Ctl({
|
||||
s,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
s: Slider;
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
// The label names the number box, and the range beside it says the same thing: both controls
|
||||
// are one setting, and the label was not tied to either before (a screen reader read "spin
|
||||
// button" and "slider" with no name at all).
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 gap-y-1">
|
||||
<label className="text-xs text-fg">{s.label}</label>
|
||||
<label htmlFor={id} className="text-xs text-fg">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
min={s.min}
|
||||
max={s.max}
|
||||
@@ -428,6 +453,7 @@ function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: n
|
||||
step={s.step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
aria-label={label}
|
||||
className="col-span-2 w-full accent-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useNavigation } from "./navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
||||
import { useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { PaletteLauncher } from "./PaletteLauncher";
|
||||
|
||||
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
|
||||
// has one, and the per-user sync chip at the right end.
|
||||
@@ -42,7 +43,8 @@ const Header = forwardRef<
|
||||
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
// Per-page SearchBar wiring — null for modules without a search.
|
||||
let search: ReactNode = null;
|
||||
// A page without a search field still gets the palette's entry (PaletteLauncher).
|
||||
let search: ReactNode = <PaletteLauncher />;
|
||||
if (page === "feed") {
|
||||
const trimmed = filters.q.trim();
|
||||
search = (
|
||||
|
||||
@@ -52,6 +52,7 @@ vi.mock("../lib/api", () => ({
|
||||
downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it
|
||||
},
|
||||
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
|
||||
HERO_TIERS: ["resume", "comfort", "surprise"],
|
||||
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
|
||||
}));
|
||||
|
||||
@@ -609,8 +610,38 @@ describe("Home", () => {
|
||||
);
|
||||
expect(await screen.findByText("Rerolled")).toBeTruthy();
|
||||
expect(discoverPick).toHaveBeenCalledTimes(1);
|
||||
// …and on the press count the server reads as "which tier answers first".
|
||||
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 1);
|
||||
// …and on the press count the server reads as "which tier answers first": 2 leads with the
|
||||
// SURPRISE tier, which is what a command labelled "Meglepetés" promised. A plain next press (1)
|
||||
// led with the shelf, so anyone with one got "A polcodról" instead (owner's call, 2026-09-19).
|
||||
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2);
|
||||
});
|
||||
|
||||
// The rotation carries on from the requested tier: the page's own button after a palette surprise
|
||||
// is the NEXT tier (resume), not a second surprise and not a jump back to press 1.
|
||||
it("lets the button's next press carry the rotation on from the requested tier", async () => {
|
||||
discoverPick.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
|
||||
});
|
||||
requestSurprise();
|
||||
mount();
|
||||
expect(await screen.findByText("Rerolled")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
expect(discoverPick.mock.lastCall?.[3]).toBe(3);
|
||||
});
|
||||
|
||||
// A token minted for a navigation that never mounted Home (a failed lazy chunk, a Back before
|
||||
// the module arrived) must not replay on the NEXT visit: it names the history entry it was
|
||||
// requested for, and Home mounting on any other entry drops it.
|
||||
it("drops a surprise request that was made for another history entry", async () => {
|
||||
requestSurprise();
|
||||
window.history.pushState(null, "");
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
expect(discoverPick).not.toHaveBeenCalled();
|
||||
// …and it is spent, not merely skipped: the entry it named does not revive it either.
|
||||
expect(takeSurprise()).toBe(false);
|
||||
});
|
||||
|
||||
it("says the shelf is empty rather than hiding it", async () => {
|
||||
|
||||
@@ -61,6 +61,7 @@ vi.mock("../lib/api", () => ({
|
||||
},
|
||||
HttpError: class extends Error {},
|
||||
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
|
||||
HERO_TIERS: ["resume", "comfort", "surprise"],
|
||||
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
|
||||
}));
|
||||
|
||||
@@ -210,10 +211,7 @@ describe("Home — the comfort shelf's context menu", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-asks the server when a move fails instead of restoring a snapshot", async () => {
|
||||
// Writing a captured `before` back was wrong twice: two overlapping moves each reverted to their
|
||||
// own snapshot and the shelf settled on an order neither side held, and `before` is the WHOLE
|
||||
// payload, so the rollback also discarded any rail refresh that landed mid-flight.
|
||||
it("re-asks the server when a move fails", async () => {
|
||||
reorderComfortPins.mockRejectedValue(new Error("nope"));
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
@@ -223,6 +221,51 @@ describe("Home — the comfort shelf's context menu", () => {
|
||||
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("puts the shown order back when a move is refused, even if the re-read fails too", async () => {
|
||||
// Only a re-read used to undo a refused move — and when that read failed for the same reason the
|
||||
// write did, react-query kept the last data: the shelf showed the order the user had just been
|
||||
// told was not saved. With one write at a time, the order shown before it IS the server's.
|
||||
reorderComfortPins.mockRejectedValue(new Error("nope"));
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
discover.mockRejectedValue(new Error("still down"));
|
||||
fireEvent.contextMenu(screen.getByText("Comfort A"));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText(/^Comfort [ABC]$/).map((e) => e.textContent)).toEqual([
|
||||
"Comfort A",
|
||||
"Comfort B",
|
||||
"Comfort C",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes one move at a time: the rows wait, and say so, while one is being saved", async () => {
|
||||
reorderComfortPins.mockReturnValue(new Promise(() => {}));
|
||||
discover.mockResolvedValueOnce(payload()).mockReturnValue(new Promise(() => {}));
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort A"));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledTimes(1));
|
||||
fireEvent.contextMenu(screen.getByText("Comfort C"));
|
||||
// The first row carries the note, so its text is its label followed by the note.
|
||||
expect(item("home.shelf.moveFirst" + "home.shelf.moveSaving").disabled).toBe(true);
|
||||
expect(item("home.shelf.moveLeft").disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("moves what the rail SHOWS while a new budget is still loading", async () => {
|
||||
// Under keepPreviousData the rail keeps the previous budget's payload on screen while the new
|
||||
// key holds nothing yet; the move read that empty cache, found no tile and did nothing.
|
||||
mount();
|
||||
await screen.findByText("Comfort A");
|
||||
discover.mockReturnValue(new Promise(() => {})); // the new budget never arrives
|
||||
fireEvent.click(screen.getByText("home.budget.short"));
|
||||
fireEvent.contextMenu(screen.getByText("Comfort A"));
|
||||
fireEvent.click(item("home.shelf.moveRight"));
|
||||
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledWith([7, 3, 9]));
|
||||
});
|
||||
|
||||
it("unpins with the pin id the shelf card carries", async () => {
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort C"));
|
||||
@@ -260,6 +303,34 @@ describe("Home — the comfort shelf's context menu", () => {
|
||||
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(11));
|
||||
});
|
||||
|
||||
// A comfort hero resolved from a pinned SHOW is that show's next episode, which is not itself
|
||||
// pinned: the menu looked the episode up, missed, and offered "Pin" under "From your shelf" —
|
||||
// taking it created a second pin. The card now names the pin it came from, and the row acts on
|
||||
// THAT (owner's call, 2026-09-19: "Unpin").
|
||||
it("unpins the SHOW a comfort hero's episode was resolved from", async () => {
|
||||
const show = {
|
||||
...base("ps1", "Pinned show"),
|
||||
source: "plex",
|
||||
kind: "show",
|
||||
video: null,
|
||||
pin_id: 21,
|
||||
};
|
||||
const episode = {
|
||||
...base("ps1e2", "Next episode"),
|
||||
source: "plex",
|
||||
kind: "episode",
|
||||
video: null,
|
||||
pinned_from: { source: "plex", kind: "show", id: "ps1", title: "Pinned show" },
|
||||
};
|
||||
discover.mockResolvedValue({ ...payload(), hero: { tier: "comfort", card: episode } });
|
||||
comfortPins.mockResolvedValue({ items: [...shelf, show] });
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByText("Next episode"));
|
||||
fireEvent.click(await waitFor(() => item("home.pin.remove")));
|
||||
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(21));
|
||||
expect(addComfortPin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says so rather than guessing while the shelf is still loading", async () => {
|
||||
// "Not pinned" and "not known yet" must not render as the same row: the pin label is only
|
||||
// trustworthy once the shelf has answered.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type DiscoverBudget,
|
||||
type DiscoverCard,
|
||||
type DiscoverPayload,
|
||||
HERO_TIERS,
|
||||
type HeroTier,
|
||||
type PlexCard,
|
||||
type Video,
|
||||
@@ -33,7 +34,7 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import VideoCard from "./VideoCard";
|
||||
@@ -65,7 +66,8 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
|
||||
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
|
||||
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
|
||||
// drift from it.
|
||||
// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
|
||||
// see ui/README): the card also takes it as its MEASURED width, which must be real pixels.
|
||||
const YT_SLOT = 200;
|
||||
|
||||
// How many already-offered cards the reroll remembers. Matched to the server's own cap
|
||||
@@ -94,6 +96,7 @@ function Rail({
|
||||
onToggleSave,
|
||||
onToggleWatched,
|
||||
onMove,
|
||||
moveSaving = false,
|
||||
}: {
|
||||
title: string;
|
||||
ariaLabel: string;
|
||||
@@ -105,8 +108,11 @@ function Rail({
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onToggleWatched: (card: PlexCard) => void;
|
||||
/** Only the comfort shelf is orderable. Given, this rail's menu grows the four move rows; omitted,
|
||||
* its tiles offer the plain pin instead. */
|
||||
onMove?: (card: DiscoverCard, to: ShelfMove) => void;
|
||||
* its tiles offer the plain pin instead. `rendered` is the order this rail is SHOWING — the one a
|
||||
* move must act on (see `moveInShelf`). */
|
||||
onMove?: (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => void;
|
||||
/** A reorder is being written: the move rows wait for it (one write at a time). */
|
||||
moveSaving?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
|
||||
@@ -137,8 +143,9 @@ function Rail({
|
||||
icon: <Icon className="w-4 h-4" />,
|
||||
separatorBefore: i === 0,
|
||||
// The same rule the move itself runs, so a row is never enabled onto a no-op.
|
||||
disabled: shelfMoveTarget(shelfIdx, to, cards.length) < 0,
|
||||
onSelect: () => onMove(target, to),
|
||||
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
|
||||
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
|
||||
onSelect: () => onMove(target, to, cards),
|
||||
})),
|
||||
{
|
||||
id: "shelf-unpin",
|
||||
@@ -158,6 +165,10 @@ function Rail({
|
||||
: pin.item(pinTargetForCard(target), target.title)),
|
||||
];
|
||||
|
||||
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
|
||||
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
|
||||
const menuFor = useItemTriggers(cards, menu.triggerProps);
|
||||
|
||||
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
|
||||
// you have not pinned anything teaches nothing about how to fill it.
|
||||
if (!cards.length) {
|
||||
@@ -178,7 +189,7 @@ function Rail({
|
||||
{cards.map((c, i) => (
|
||||
<div
|
||||
key={`${c.source}:${c.kind}:${c.id}`}
|
||||
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
|
||||
className={c.aspect === "poster" ? "w-37.5 shrink-0" : "shrink-0"}
|
||||
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
|
||||
>
|
||||
{c.video ? (
|
||||
@@ -190,10 +201,7 @@ function Rail({
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
||||
// Bound to the UNIFIED card, not the native payload the card component holds: the
|
||||
// menu acts on the shelf ENTRY (its pin id, its place in the order), which the
|
||||
// `Video` inside it does not carry.
|
||||
menuProps={() => menu.triggerProps(c)}
|
||||
menuProps={menuFor[i]}
|
||||
/>
|
||||
) : c.plex ? (
|
||||
<PlexPosterCard
|
||||
@@ -202,7 +210,7 @@ function Rail({
|
||||
onOpen={() => onPlay(c)}
|
||||
onInfo={() => onInfo(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
menuProps={() => menu.triggerProps(c)}
|
||||
menuProps={menuFor[i]}
|
||||
/>
|
||||
) : (
|
||||
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
|
||||
@@ -343,7 +351,14 @@ function Hero({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ComfortPinMenu menu={pinMenu} target={pinTargetForCard} title={(c) => c.title} />
|
||||
{/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself
|
||||
pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin.
|
||||
The owner's call (2026-09-19): the row unpins the show. */}
|
||||
<ComfortPinMenu
|
||||
menu={pinMenu}
|
||||
target={(c) => pinTargetForCard(c.pinned_from ?? c)}
|
||||
title={(c) => (c.pinned_from ?? c).title}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -592,13 +607,26 @@ export default function Home() {
|
||||
);
|
||||
|
||||
// Shelf reorder, from the comfort rail's menu. Optimistic on THIS budget's cached payload so the
|
||||
// tile moves under the pointer, then written; a failure re-asks the server rather than leaving the
|
||||
// page showing an order the server never accepted (the same lesson the playlist reorder learned).
|
||||
// tile moves under the pointer, then written.
|
||||
//
|
||||
// It acts on the order the rail RENDERED, not on a fresh read of this budget's cache: during a
|
||||
// budget switch the rail still shows the previous budget's payload (`keepPreviousData`) while the
|
||||
// new key holds nothing yet, so `from` came back -1 and the move silently did nothing — the
|
||||
// "looks available, then no-ops" row `shelfMoveTarget` exists to rule out.
|
||||
//
|
||||
// ONE write at a time (`shelfSaving`; the move rows wait with a note). That is what makes a
|
||||
// failure's rollback right: with no overlapping move, "the order the rail showed before this move"
|
||||
// is the order the server still holds, so a refused move puts it back instead of leaving the
|
||||
// rejected order on screen whenever the follow-up read failed too. (Two overlapping moves, each
|
||||
// reverting to its own snapshot, settled on an order neither side ever held — why the rollback
|
||||
// used to be a re-read only.)
|
||||
const [shelfSaving, setShelfSaving] = useState(false);
|
||||
const shelfSavingRef = useRef(false);
|
||||
const moveInShelf = useCallback(
|
||||
(card: DiscoverCard, to: ShelfMove) => {
|
||||
(card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => {
|
||||
if (shelfSavingRef.current) return;
|
||||
const key = qk.discover(budget);
|
||||
const before = qc.getQueryData<DiscoverPayload>(key);
|
||||
const cards = before?.comfort ?? [];
|
||||
const cards = rendered;
|
||||
const from = cards.findIndex((c) => c.pin_id === card.pin_id);
|
||||
const target = shelfMoveTarget(from, to, cards.length);
|
||||
if (target < 0) return;
|
||||
@@ -611,48 +639,63 @@ export default function Home() {
|
||||
// type, and a short list would be rejected by the server rather than silently reordering half
|
||||
// the shelf.
|
||||
const ids = next.map((c) => c.pin_id).filter((id): id is number => id != null);
|
||||
shelfSavingRef.current = true;
|
||||
setShelfSaving(true);
|
||||
void api
|
||||
.reorderComfortPins(ids)
|
||||
.catch((e: unknown) => notifyActionError(e, t("home.shelf.moveFailed")))
|
||||
.catch((e: unknown) => {
|
||||
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: cards } : old));
|
||||
notifyActionError(e, t("home.shelf.moveFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
shelfSavingRef.current = false;
|
||||
setShelfSaving(false);
|
||||
// The shelf is also the pin menus' source of truth everywhere else in the app.
|
||||
void qc.invalidateQueries({ queryKey: qk.comfortPins() });
|
||||
// And the PREFIX, not this budget's key — which is what actually re-asks the server, and
|
||||
// is what the comment above this callback promises. Restoring the captured snapshot
|
||||
// instead was wrong twice over: two overlapping moves each reverted to their own
|
||||
// snapshot, so the shelf settled on an order neither side ever held; and `before` is the
|
||||
// whole payload, so a rollback also threw away any rail refresh that had landed while the
|
||||
// request was in flight. On the success path the prefix matters for a second reason — the
|
||||
// shelf is budget-independent server-side but cached per budget, so without it the next
|
||||
// chip click renders the pre-move order and a move from there writes it back.
|
||||
// And the PREFIX, not this budget's key: the shelf is budget-independent server-side but
|
||||
// cached per budget, so without it the next chip click renders the pre-move order and a
|
||||
// move from there writes it back. (The rollback above restores only `comfort`, so a rail
|
||||
// refresh that landed meanwhile is kept.)
|
||||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
});
|
||||
},
|
||||
[budget, qc, t],
|
||||
);
|
||||
|
||||
const reroll = useCallback(() => {
|
||||
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
|
||||
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
|
||||
// would look like a dead button).
|
||||
if (heroKey)
|
||||
setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
|
||||
setPress((n) => n + 1);
|
||||
setSeed((prev) => {
|
||||
let next = prev;
|
||||
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
|
||||
return next;
|
||||
});
|
||||
}, [heroKey]);
|
||||
const reroll = useCallback(
|
||||
(lead?: HeroTier) => {
|
||||
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
|
||||
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
|
||||
// would look like a dead button).
|
||||
if (heroKey)
|
||||
setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
|
||||
// `lead` asks for a tier: the next press whose rotation leads with it. The rotation carries on
|
||||
// from there, so the button's following press is the tier after it, as it would have been.
|
||||
setPress((n) => {
|
||||
let next = n + 1;
|
||||
// Guarded on membership: a click handler passed straight in would hand over its event
|
||||
// as `lead`, and this loop would never end.
|
||||
if (lead && HERO_TIERS.includes(lead))
|
||||
while (HERO_TIERS[next % HERO_TIERS.length] !== lead) next += 1;
|
||||
return next;
|
||||
});
|
||||
setSeed((prev) => {
|
||||
let next = prev;
|
||||
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[heroKey],
|
||||
);
|
||||
|
||||
// "Meglepetés" from the command palette: the request is made on another page, so it waits as a
|
||||
// token until this module's chunk has actually mounted (see lib/homeIntent). The subscribed value
|
||||
// is what WAKES this effect; `takeSurprise()` is what decides — it clears the token and answers
|
||||
// false to every later pass, including StrictMode's second mount invocation and the re-runs
|
||||
// `reroll`'s changing identity causes.
|
||||
// `reroll`'s changing identity causes — and to a token minted for another history entry.
|
||||
const surpriseRequest = useSurpriseRequest();
|
||||
useEffect(() => {
|
||||
if (surpriseRequest && takeSurprise()) reroll();
|
||||
if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
|
||||
}, [surpriseRequest, reroll]);
|
||||
|
||||
if (q.isPending) return <LoadingState />;
|
||||
@@ -693,7 +736,7 @@ export default function Home() {
|
||||
tier={hero.tier}
|
||||
card={hero.card}
|
||||
onPlay={onPlay}
|
||||
onReroll={reroll}
|
||||
onReroll={() => reroll()}
|
||||
rerolling={rerolling}
|
||||
/>
|
||||
) : (
|
||||
@@ -720,7 +763,7 @@ export default function Home() {
|
||||
type="button"
|
||||
icon={<RotateCcw className="h-4 w-4" />}
|
||||
loading={rerolling}
|
||||
onClick={reroll}
|
||||
onClick={() => reroll()}
|
||||
>
|
||||
{t("home.hero.reroll")}
|
||||
</Button>
|
||||
@@ -740,6 +783,7 @@ export default function Home() {
|
||||
onToggleSave={onToggleSave}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onMove={moveInShelf}
|
||||
moveSaving={shelfSaving}
|
||||
/>
|
||||
{!resumeConsumed && (
|
||||
<Rail
|
||||
|
||||
@@ -156,6 +156,23 @@ describe("NavigationProvider · never under an open overlay", () => {
|
||||
expect(window.history.state).toMatchObject({ sfPage: "plex" });
|
||||
});
|
||||
|
||||
// `then` belongs to the NEW entry (the palette's "Meglepetés" token names it — lib/homeIntent), so
|
||||
// over an open player it must wait for the push, and with nothing to push it still runs.
|
||||
it("runs setPage's `then` after the push, on the entry the push made", async () => {
|
||||
const { result } = render();
|
||||
await openOverlay();
|
||||
const seen: unknown[] = [];
|
||||
expect(result.current.page).not.toBe("plex");
|
||||
act(() => result.current.setPage("plex", () => seen.push(window.history.state?.sfPage)));
|
||||
expect(seen).toEqual([]);
|
||||
await settle();
|
||||
await settle();
|
||||
expect(seen).toEqual(["plex"]);
|
||||
|
||||
act(() => result.current.setPage("plex", () => seen.push("again")));
|
||||
expect(seen).toEqual(["plex", "again"]);
|
||||
});
|
||||
|
||||
// Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player
|
||||
// closes, and its entry is reclaimed rather than orphaned under a push that never happened.
|
||||
it("setPage to the current page closes the overlay and pushes nothing", async () => {
|
||||
|
||||
@@ -57,13 +57,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
channelViewRef.current = channelView;
|
||||
|
||||
const setPage = useCallback(
|
||||
(next: Page) =>
|
||||
(next: Page, then?: () => void) =>
|
||||
// Never under an open overlay — see the interface doc. The refs are read when the push
|
||||
// actually happens, which over an open player is a moment after this call.
|
||||
closeOverlaysThenNavigate(() => {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === pageRef.current && !channelViewRef.current) return;
|
||||
if (next === pageRef.current && !channelViewRef.current) {
|
||||
then?.();
|
||||
return;
|
||||
}
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
@@ -95,6 +98,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
then?.();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Ctrl/Cmd+K had no visible entry point (debt burn B28). The owner's pick: a key cap in the header's
|
||||
// search field, and a "Jump to…" pill in its place on a page that has no search field.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
let page = "home";
|
||||
vi.mock("./navigationContext", () => ({ useNavigation: () => ({ page }) }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ q: "", sort: "newest" }),
|
||||
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./playlistsContext", () => ({
|
||||
usePlaylists: () => ({ search: "" }),
|
||||
usePlaylistsActions: () => ({ setSearch: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./channelsContext", () => ({
|
||||
useChannels: () => ({ search: "" }),
|
||||
useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./SyncStatus", () => ({ default: () => null }));
|
||||
|
||||
import Header from "./Header";
|
||||
import { closePalette, usePaletteOpen } from "../lib/keymap";
|
||||
import type { Me } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
act(() => closePalette());
|
||||
});
|
||||
|
||||
function mount() {
|
||||
let open = false;
|
||||
function Probe() {
|
||||
open = usePaletteOpen();
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<>
|
||||
<Header me={{ is_demo: false, plex_enabled: false } as Me} onYtSearch={() => {}} />
|
||||
<Probe />
|
||||
</>,
|
||||
);
|
||||
return () => open;
|
||||
}
|
||||
|
||||
describe("the command palette's visible entry", () => {
|
||||
it("is a key cap inside the search field, naming the chord the palette row binds", () => {
|
||||
page = "feed";
|
||||
const isOpen = mount();
|
||||
const cap = screen.getByRole("button", { name: /^palette\.open \((Ctrl|⌘) K\)$/ });
|
||||
// Label in Name: the name carries what the cap shows, so "click Ctrl K" finds it.
|
||||
expect(cap.getAttribute("aria-label")).toContain(cap.textContent);
|
||||
// …and a tooltip equal to that name, so a screen reader does not read it again as a description.
|
||||
expect(cap.getAttribute("title")).toBe(cap.getAttribute("aria-label"));
|
||||
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||
fireEvent.click(cap);
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("is a Jump-to pill on a page with no search field", () => {
|
||||
page = "home";
|
||||
const isOpen = mount();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
// Named by what it shows ("Jump to…" and its key cap), not by a label that replaced it.
|
||||
const pill = screen.getByRole("button", { name: /^palette\.title ?(Ctrl|⌘) K$/ });
|
||||
expect(pill.getAttribute("title")).toBe("palette.open");
|
||||
fireEvent.click(pill);
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user