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 | ||
|
|
1cfb19bdc7 | ||
|
|
90c30d596f | ||
|
|
7ed6ccf171 | ||
|
|
ce30b99327 | ||
|
|
a7891d03c5 | ||
|
|
038da5374f | ||
|
|
92979fa920 | ||
|
|
2d5bd275fa | ||
|
|
49fe56b6e7 | ||
|
|
1fa6a0bea8 | ||
|
|
582f20fab6 | ||
|
|
6cb8231a9f | ||
|
|
6bbf532b70 | ||
|
|
2fa5c8c394 | ||
|
|
373415a2eb | ||
|
|
582c033e1c | ||
|
|
ef652ee5d8 | ||
|
|
250e3a9f90 | ||
|
|
6096dd6927 | ||
|
|
51e98bfc38 | ||
|
|
3ee00fb051 | ||
|
|
29094f5f51 | ||
|
|
16a44dec79 | ||
|
|
4e3e68d6e6 | ||
|
|
eb29f22eb7 | ||
|
|
8f6f89832f | ||
|
|
1c49870a7a | ||
|
|
142593083f | ||
|
|
8cfab498cf | ||
|
|
b5996a0e56 | ||
|
|
5e208353e3 | ||
|
|
66dbd0af81 |
@@ -7,6 +7,82 @@ 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).
|
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.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- A card's menu (right-click or long press) now offers the card's own actions — watched, save, hide, reset — beside the comfort-shelf pin.
|
||||||
|
- Settings → Account shows your role and your last sign-in. 0.88 announced the last sign-in in your account panel; it is actually there now.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- When YouTube's feed for a channel stays dark for hours, as it did for 393 of 396 channels on 18–19 September, the channel's newest uploads are read through the YouTube API instead, at most once every 2 hours per channel (adjustable by an administrator, 0 turns it off).
|
||||||
|
- A tab left open across a release no longer falls to the crash screen when it opens something it had not loaded yet: it reloads onto the new version once.
|
||||||
|
- Back from a request's lines in the Server log returns to the view the pivot was made from, search text included, as 0.90 promised.
|
||||||
|
- Back to Home keeps the question you asked (the time budget and the hero you rerolled to), and the keyboard lands on the card you opened, even when the rail's cards moved or several share a name.
|
||||||
|
- Browser tabs are named after the page again: signed out, and on the privacy and terms pages.
|
||||||
|
- On a phone, the feed and Plex toolbars wrap instead of running past the edge, and so do notifications.
|
||||||
|
- The countdown of a raised diagnostics level counts from the moment it was raised, not from when the page was opened.
|
||||||
|
- The download box's placeholder names every site it takes, Facebook included.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- The screens' own polling reads (the unread badges, the download list, the scheduler) are logged at debug level in the server's log and in the browser's copy of it, where they made up about 95% of the lines.
|
||||||
|
- An account with no YouTube channel no longer writes a warning every six hours, and a scheduler job that fails carries an event code.
|
||||||
|
- Two adversarial review rounds: 15 findings raised, 11 survived, all fixed, and every regression test was proven to fail with its own fix removed.
|
||||||
|
|
||||||
|
## 0.90.0 — 2026-09-19
|
||||||
|
|
||||||
|
The server's log gets a screen of its own: an admin can now read what the API, the downloader and the browsers wrote down, filter it, and follow one request or job from start to end.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Admin → Server log: the stored log, newest first, with a search, level chips, and source, account, event and period filters. A line opens in place to its full record; its request id or job id narrows the list to everything that request or job wrote.
|
||||||
|
- Filters live in the address bar, so a filtered view is a link, and Back brings the previous view back.
|
||||||
|
- Live follows the newest lines every few seconds; Copy puts the loaded lines on the clipboard, oldest first. A failed read says so instead of quietly freezing the list.
|
||||||
|
- Both log viewers (Server log and My log) can be walked with j/k and a line opened with Enter.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- New videos no longer arrive late from channels whose YouTube feed answers 404 or 500 now and then: a feed is tried again before the channel counts as failed.
|
||||||
|
- A % or _ typed into a search is searched for as itself, not as a wildcard.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- The console's own reads are logged at debug level, so watching the log does not fill it.
|
||||||
|
- Two adversarial review rounds: 19 findings raised, 12 survived, all fixed, and every regression test was proven to fail with its own fix removed.
|
||||||
|
|
||||||
## 0.89.0 — 2026-09-19
|
## 0.89.0 — 2026-09-19
|
||||||
|
|
||||||
A release for the log rather than for the screen: what the server writes down when something fails is now complete, said once, and free of what you downloaded or searched for.
|
A release for the log rather than for the screen: what the server writes down when something fails is now complete, said once, and free of what you downloaded or searched for.
|
||||||
|
|||||||
@@ -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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||||
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
| 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 |
|
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
| 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 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 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 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 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 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) |
|
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add channels.feed_fallback_at — when a channel's uploads were last read through the API because
|
||||||
|
its RSS feed would not answer
|
||||||
|
|
||||||
|
Revision ID: 0088_channels_feed_fallback_at
|
||||||
|
Revises: 0087_users_diagnostics_level
|
||||||
|
Create Date: 2026-09-19
|
||||||
|
|
||||||
|
YouTube's RSS feeds go dark for hours at a time, per channel (2026-09-18/19: 393 of 396 answered 404
|
||||||
|
for four hours; the same channel answers 404 to every retry). The RSS poll then reads the newest
|
||||||
|
uploads through the Data API instead, at a quota cost, and at most once per `rss_fallback_hours` per
|
||||||
|
channel: this column is that clock. NULL = never needed it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0088_channels_feed_fallback_at"
|
||||||
|
down_revision: str | None = "0087_users_diagnostics_level"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"channels", sa.Column("feed_fallback_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("channels", "feed_fallback_at")
|
||||||
+6
-8
@@ -14,6 +14,7 @@ from app.db import get_db
|
|||||||
from app.models import Invite, LogEvent, User
|
from app.models import Invite, LogEvent, User
|
||||||
from app.security import decrypt_optional
|
from app.security import decrypt_optional
|
||||||
from app.utils import canonical_email
|
from app.utils import canonical_email
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
PASSWORD_MIN_LEN = 10
|
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:
|
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if user is None or not user.is_active or user.is_suspended:
|
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.
|
# Suspended/deactivated mid-session → end it so the block takes effect at once.
|
||||||
_end_account_session(request, user_id, is_default)
|
_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
|
# 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
|
# 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.
|
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
|
||||||
epochs = request.session.get("epochs") or {}
|
epochs = request.session.get("epochs") or {}
|
||||||
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
||||||
_end_account_session(request, user_id, is_default)
|
_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
|
# 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.
|
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
|
||||||
default_id = request.session.get("user_id")
|
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
|
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."""
|
`require_human` because writing to YouTube needs a real, non-demo identity."""
|
||||||
if not has_write_scope(user):
|
if not has_write_scope(user):
|
||||||
raise HTTPException(
|
raise refused(403, "youtube_editing_off")
|
||||||
status_code=403,
|
|
||||||
detail="Enable YouTube editing in Settings first.",
|
|
||||||
)
|
|
||||||
return user
|
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
|
"""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."""
|
admin gate — every admin route/action depends on this rather than re-checking the role."""
|
||||||
if user.role != "admin":
|
if user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise refused(403, "admin_only")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ class Settings(BaseSettings):
|
|||||||
# Recent-first backfill: how far back to fetch on the first pass per channel.
|
# Recent-first backfill: how far back to fetch on the first pass per channel.
|
||||||
backfill_recent_max_videos: int = 100
|
backfill_recent_max_videos: int = 100
|
||||||
backfill_recent_max_days: int = 365
|
backfill_recent_max_days: int = 365
|
||||||
|
# A channel whose RSS feed has not answered for this many hours gets its newest uploads read
|
||||||
|
# through the Data API instead (1 unit), at most once per this many hours. 0 = never.
|
||||||
|
rss_fallback_hours: int = 2
|
||||||
|
|
||||||
# Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below
|
# Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below
|
||||||
# this duration (and not livestreams) are probed; longer videos are never Shorts.
|
# this duration (and not livestreams) are probed; longer videos are never Shorts.
|
||||||
@@ -312,6 +315,10 @@ class Settings(BaseSettings):
|
|||||||
# re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve
|
# 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.
|
# and remux (stream-copy) are not capped by this.
|
||||||
plex_max_transcodes: int = 4
|
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) ---
|
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
|
||||||
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
|
# 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
|
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ def pick(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _comfort() -> dict | 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):
|
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
|
||||||
return card
|
return card
|
||||||
return None
|
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 —
|
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
|
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
|
unwatched item. That buys the property the whole surface exists for: one click, no intermediate page,
|
||||||
short and hand-curated, and it buys the property the whole surface exists for: one click, no
|
no second decision. Channels and playlists are resolved for the whole shelf at once (`_Batch`), so the
|
||||||
intermediate page, no second decision.
|
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
|
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.
|
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 import func, select
|
||||||
from sqlalchemy.orm import Session
|
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
|
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]:
|
def list_pins(db: Session, user: User) -> list[ComfortPin]:
|
||||||
return list(
|
return list(
|
||||||
db.execute(
|
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
|
"""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)."""
|
cascade removes the pin itself, so this is only the window between a delete and a refetch)."""
|
||||||
card: dict | None = None
|
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
|
nxt = dict(card) # a video plays itself
|
||||||
|
|
||||||
elif pin.channel_id is not None:
|
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:
|
if ch is None:
|
||||||
return None
|
return None
|
||||||
card = cards.from_channel(ch, queries.channel_unwatched_count(db, user, ch.id))
|
count, row = batch.channel_summary.get(ch.id, (0, None))
|
||||||
row = queries.channel_next_video(db, user, ch.id)
|
card = cards.from_channel(ch, count)
|
||||||
nxt = cards.from_video(row) if row else None
|
nxt = cards.from_video(row) if row else None
|
||||||
|
|
||||||
elif pin.plex_item_id is not 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
|
nxt = cards.from_plex(ep) if ep else None
|
||||||
|
|
||||||
elif pin.playlist_id is not 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:
|
if pl is None:
|
||||||
return 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)
|
card = cards.from_playlist(pl, count, row.get("thumbnail_url") if row else None)
|
||||||
nxt = cards.from_video(row) 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
|
return card
|
||||||
|
|
||||||
|
|
||||||
def shelf(db: Session, user: User) -> list[dict]:
|
_PLEX_COLUMNS = ("plex_item_id", "plex_show_id")
|
||||||
"""The whole shelf, in the user's own order."""
|
|
||||||
|
|
||||||
|
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 = []
|
out = []
|
||||||
for pin in list_pins(db, user):
|
pins = list_pins(db, user)
|
||||||
card = _shelf_card(db, user, pin)
|
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:
|
if card is not None:
|
||||||
out.append(card)
|
out.append(card)
|
||||||
return out
|
return out
|
||||||
@@ -171,5 +251,17 @@ def shelf(db: Session, user: User) -> list[dict]:
|
|||||||
|
|
||||||
def playables(shelf_cards: list[dict]) -> 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
|
"""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."""
|
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")]
|
|
||||||
|
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.cards import leaf_card, movie_card, show_card
|
||||||
from app.plex.query import apply_show_state_filter, content_libs
|
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 —
|
# How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
|
||||||
# the two surfaces should feel like the same app, and a rail is a rail.
|
# (`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
|
RAIL_LIMIT = 20
|
||||||
|
|
||||||
# The feed parameters a Home rail runs with. Spelled out rather than borrowed from the route's
|
# 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]
|
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:
|
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.
|
"""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
|
return serialize(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
def channel_unwatched_count(db: Session, user: User, channel_id: str) -> int:
|
def channels_summary(
|
||||||
"""How many unwatched videos a pinned channel is holding — the tile's only honest promise, since a
|
db: Session, user: User, channel_ids: list[str]
|
||||||
channel has no runtime of its own."""
|
) -> dict[str, tuple[int, dict | None]]:
|
||||||
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all")
|
"""For each pinned channel: (unwatched count, its newest unwatched video) — the tile's only honest
|
||||||
query, _ = filtered_query(db, user, **params)
|
promise, since a channel has no runtime of its own, and what "put on that channel" resolves to.
|
||||||
return int(db.execute(select(func.count()).select_from(query.subquery())).scalar() or 0)
|
|
||||||
|
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]:
|
def playlists_summary(
|
||||||
"""(item count, the row to play next) for a pinned playlist — its first not-yet-watched item, or
|
db: Session, user: User, playlist_ids: list[int]
|
||||||
its first item once the whole list is watched. The same row doubles as the tile's artwork, so a
|
) -> dict[int, tuple[int, dict | None]]:
|
||||||
shelf of playlists is scannable instead of a row of blank boxes."""
|
"""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)
|
st = aliased(VideoState)
|
||||||
rows = db.execute(
|
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(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
||||||
.where(PlaylistItem.playlist_id == playlist_id)
|
.where(PlaylistItem.playlist_id.in_(playlist_ids))
|
||||||
.order_by(PlaylistItem.position.asc())
|
.order_by(PlaylistItem.playlist_id, PlaylistItem.position.asc())
|
||||||
).all()
|
).all()
|
||||||
if not rows:
|
grouped: dict[int, list] = {}
|
||||||
return 0, None
|
for r in rows:
|
||||||
nxt = next((r for r in rows if (r.status or "new") not in ("watched", "hidden")), rows[0])
|
grouped.setdefault(r._playlist, []).append(r)
|
||||||
return len(rows), serialize(nxt)
|
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 ──────────────────────────────────────────────────────────────────────────────────────────
|
# ── Plex ──────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from app.models import (
|
|||||||
Video,
|
Video,
|
||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
from app.utils import iso, to_tsquery_str
|
from app.utils import iso, like_escape, to_tsquery_str
|
||||||
|
|
||||||
|
|
||||||
def _channel_url(channel_id: str, handle: str | None) -> str:
|
def _channel_url(channel_id: str, handle: str | None) -> str:
|
||||||
@@ -272,8 +272,9 @@ def filtered_query(
|
|||||||
# `unaccent(channels.title) ILIKE '%q%'` straight into the per-video predicate defeated the
|
# `unaccent(channels.title) ILIKE '%q%'` straight into the per-video predicate defeated the
|
||||||
# search_vector GIN — the planner detoasted every non-matching tsvector to test the `@@`
|
# search_vector GIN — the planner detoasted every non-matching tsvector to test the `@@`
|
||||||
# (~2.6s/search on the prod-sized set); channel_id.in_(...) keeps the GIN as a BitmapOr branch
|
# (~2.6s/search on the prod-sized set); channel_id.in_(...) keeps the GIN as a BitmapOr branch
|
||||||
# (PERF-12). Empty match -> false() so the OR collapses to the text match alone.
|
# (PERF-12). Empty match -> false() so the OR collapses to the text match alone. The query
|
||||||
like = func.unaccent(f"%{q}%")
|
# is a literal inside the pattern: `_` in `my_channel` is not a wildcard.
|
||||||
|
like = func.unaccent(f"%{like_escape(q)}%")
|
||||||
matching_channel_ids = (
|
matching_channel_ids = (
|
||||||
db.execute(select(Channel.id).where(func.unaccent(Channel.title).ilike(like)))
|
db.execute(select(Channel.id).where(func.unaccent(Channel.title).ilike(like)))
|
||||||
.scalars()
|
.scalars()
|
||||||
|
|||||||
+33
-4
@@ -638,9 +638,32 @@ def current_source() -> str:
|
|||||||
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
||||||
|
|
||||||
# Paths whose success is logged at DEBUG rather than INFO: the container healthcheck runs every 15s
|
# Paths whose success is logged at DEBUG rather than INFO: the container healthcheck runs every 15s
|
||||||
# (5.7k lines/day saying nothing) and hashed static assets are served by the hundred per page load.
|
# (5.7k lines/day saying nothing), hashed static assets are served by the hundred per page load, and
|
||||||
# A FAILURE on either still logs at WARNING/ERROR — this lowers the floor, it does not hide.
|
# the admin log console reads the stored log — its live tail every few seconds, so at INFO the log
|
||||||
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/")
|
# would fill with the console watching itself. A FAILURE on any of them still logs at WARNING/ERROR
|
||||||
|
# — this lowers the floor, it does not hide.
|
||||||
|
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/", "/api/admin/logs")
|
||||||
|
|
||||||
|
# The reads the frontend POLLS (a `useLiveQuery`, the `me` heartbeat, the sync status, the subtitle
|
||||||
|
# progress), by route template, GET only — logged at DEBUG like the prefixes above. One open tab asked
|
||||||
|
# for the two unread counts, `/api/me` and the download index every 30 s, and at INFO those were ~95%
|
||||||
|
# of the stored log (measured on prod 2026-09-19): 11k lines a day per tab burying every line someone
|
||||||
|
# caused. The same route answered with a failure still logs at WARNING/ERROR.
|
||||||
|
_POLLED_ROUTES = frozenset(
|
||||||
|
{
|
||||||
|
"/api/me",
|
||||||
|
"/api/me/notifications/unread_count",
|
||||||
|
"/api/messages/unread_count",
|
||||||
|
"/api/downloads",
|
||||||
|
"/api/downloads/index",
|
||||||
|
"/api/downloads/usage",
|
||||||
|
"/api/sync/my-status",
|
||||||
|
"/api/admin/scheduler",
|
||||||
|
"/api/admin/downloads",
|
||||||
|
"/api/admin/downloads/storage",
|
||||||
|
"/api/plex/subtitle/{rating_key}/{sub_ord}/status",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Not `siftlode.logs`: this is the replacement for uvicorn's ACCESS log, and `siftlode.http` is
|
# Not `siftlode.logs`: this is the replacement for uvicorn's ACCESS log, and `siftlode.http` is
|
||||||
# what an operator pins a level on when they want request lines and nothing else. The module it
|
# what an operator pins a level on when they want request lines and nothing else. The module it
|
||||||
@@ -687,7 +710,9 @@ async def log_requests(request, call_next):
|
|||||||
raise
|
raise
|
||||||
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
||||||
status = response.status_code
|
status = response.status_code
|
||||||
quiet = request.url.path.startswith(_QUIET_PREFIXES)
|
quiet = request.url.path.startswith(_QUIET_PREFIXES) or (
|
||||||
|
request.method == "GET" and _route_template(request) in _POLLED_ROUTES
|
||||||
|
)
|
||||||
if status >= 500:
|
if status >= 500:
|
||||||
level = logging.ERROR
|
level = logging.ERROR
|
||||||
elif status >= 400:
|
elif status >= 400:
|
||||||
@@ -713,6 +738,10 @@ async def log_requests(request, call_next):
|
|||||||
extra={**event("http.request", **attrs), **_request_owner(request, level)},
|
extra={**event("http.request", **attrs), **_request_owner(request, level)},
|
||||||
)
|
)
|
||||||
response.headers["X-Request-Id"] = rid
|
response.headers["X-Request-Id"] = rid
|
||||||
|
# The browser logs its own copy of this line (frontend lib/http.ts) and must pick the same level:
|
||||||
|
# it learns "this one is quiet" here instead of keeping a second copy of the polled-route list.
|
||||||
|
if level == logging.DEBUG:
|
||||||
|
response.headers["X-Log-Quiet"] = "1"
|
||||||
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
|
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
|
||||||
# Only a MATCHED route's: a template never carries a path parameter's value (a share token stays
|
# Only a MATCHED route's: a template never carries a path parameter's value (a share token stays
|
||||||
# `{token}`), while the raw-path fallback of an unmatched request could — the browser then
|
# `{token}`), while the raw-path fallback of an unmatched request could — the browser then
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from app.plex import stream as plex_stream
|
|||||||
from app.plex import subtitles as plex_subtitles
|
from app.plex import subtitles as plex_subtitles
|
||||||
from app.routes import (
|
from app.routes import (
|
||||||
admin,
|
admin,
|
||||||
|
admin_logs as admin_logs_routes,
|
||||||
audit as audit_routes,
|
audit as audit_routes,
|
||||||
auth as auth_routes,
|
auth as auth_routes,
|
||||||
channels,
|
channels,
|
||||||
@@ -285,6 +286,7 @@ app.include_router(admin.router)
|
|||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
app.include_router(scheduler_routes.router)
|
app.include_router(scheduler_routes.router)
|
||||||
app.include_router(audit_routes.router)
|
app.include_router(audit_routes.router)
|
||||||
|
app.include_router(admin_logs_routes.router)
|
||||||
app.include_router(quota.router)
|
app.include_router(quota.router)
|
||||||
app.include_router(version.router)
|
app.include_router(version.router)
|
||||||
app.include_router(setup_routes.router)
|
app.include_router(setup_routes.router)
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ class Channel(Base, TimestampMixin):
|
|||||||
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# When the newest uploads were last read through the Data API because the RSS feed would not
|
||||||
|
# answer (`sync/runner.py` `run_feed_fallback`): the once-per-`rss_fallback_hours` clock.
|
||||||
|
feed_fallback_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
||||||
backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
# True when this channel only entered the catalog because a live YouTube search
|
# True when this channel only entered the catalog because a live YouTube search
|
||||||
|
|||||||
@@ -298,10 +298,6 @@ class LibraryFilters:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def like_escape(s: str) -> str:
|
|
||||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Watch-state scope (shared by the /library grid AND the /facets aggregation) ---------------
|
# --- Watch-state scope (shared by the /library grid AND the /facets aggregation) ---------------
|
||||||
#
|
#
|
||||||
# The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user
|
# The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ def rollup(cards: list[dict]) -> dict:
|
|||||||
]
|
]
|
||||||
resume = None
|
resume = None
|
||||||
if inprog:
|
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:
|
else:
|
||||||
resume = next((c for c in cards if c.get("status") != "watched"), None)
|
resume = next((c for c in cards if c.get("status") != "watched"), None)
|
||||||
return {
|
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.
|
# its index for that one. See `segment_name` for why this is a constant and not a comment.
|
||||||
_VIDEO_VARIANT = "0"
|
_VIDEO_VARIANT = "0"
|
||||||
_SESSION_IDLE_S = 600 # reap a session with no access for this long
|
_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);
|
# A session directory is named `{rating_key}_u{user_id}_{int(start_s)}_{tag}_{aoff:+.2f}` (see
|
||||||
# before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names
|
# 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
|
# 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
|
# 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.
|
# 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
|
# 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
|
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
|
||||||
# and someone else's data.
|
# 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
|
# 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
|
# 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".
|
# 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
|
_MTIME_GRACE_S = 2.0
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_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:
|
class HlsSession:
|
||||||
@@ -102,16 +115,17 @@ class HlsSession:
|
|||||||
transcode: bool = False,
|
transcode: bool = False,
|
||||||
hw: bool = False,
|
hw: bool = False,
|
||||||
tonemap: bool = False,
|
tonemap: bool = False,
|
||||||
|
user_id: int = 0,
|
||||||
):
|
):
|
||||||
# Unguessable per-session id, carried in the URLs the client is handed. Sessions are keyed
|
# Unguessable per-session id, carried in the URLs the client is handed. A seek-restart
|
||||||
# by rating_key alone, so a seek-restart or a SECOND viewer of the same item replaces the
|
# replaces the viewer's registered session for the item — and the old player's next request
|
||||||
# registered one — and the old viewer's next request for `seg_12.ts`, resolved by rating_key,
|
# 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
|
||||||
# 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
|
# 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,
|
# (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.
|
# which the player recovers from by restarting at the playhead.
|
||||||
self.id = secrets.token_urlsafe(9)
|
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.dir = directory
|
||||||
self.proc = proc
|
self.proc = proc
|
||||||
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
|
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)
|
shutil.rmtree(s.dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _enforce_cap(cap: int) -> None:
|
def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
|
||||||
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux
|
"""Caller holds _lock. Three bounds, applied after a session is registered, each dropping the
|
||||||
# (stream-copy) and direct-serve are cheap and deliberately uncapped (see config.py:
|
least recently accessed sessions over it:
|
||||||
# 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.
|
* this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
|
||||||
tx = [(key, s) for key, s in _sessions.items() if s.transcode]
|
box evicts itself before anyone else;
|
||||||
if len(tx) <= cap:
|
* TRANSCODE sessions (plex_max_transcodes): the CPU/GPU-heavy ones;
|
||||||
return
|
* EVERY session (plex_max_sessions). A remux is cheap on the CPU but not on the disk: each one is a
|
||||||
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]:
|
whole segmented copy of the film under `_HLS_ROOT`, written faster than realtime. Counted only
|
||||||
# Somebody's playback stops here. The route turns the missing session into a retryable
|
by the transcode cap, N distinct items were N ffmpegs and N film-sized scratch directories.
|
||||||
# 404 and the player restarts at the playhead, so from the server nothing failed — and
|
|
||||||
# "my film keeps rebuffering" has no other explanation anywhere.
|
Somebody's playback stops at each drop. The route turns the missing session into a retryable 404
|
||||||
log.info(
|
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
|
||||||
"a transcode session was dropped to stay under the cap",
|
rebuffering" has no other explanation anywhere, hence the line."""
|
||||||
extra=logs.event("plex.stream.session_culled", key=key, cap=cap, sessions=len(tx)),
|
|
||||||
)
|
def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
|
||||||
_kill(s)
|
if len(pool) <= cap:
|
||||||
_sessions.pop(key, None)
|
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(
|
def start_session(
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
item: PlexItem,
|
item: PlexItem,
|
||||||
|
user_id: int,
|
||||||
start_s: float,
|
start_s: float,
|
||||||
audio_ord: int | None = None,
|
audio_ord: int | None = None,
|
||||||
aoff: float = 0.0,
|
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.
|
# session respawn (the HW->SW fallback) must not pick up a mid-playback config change.
|
||||||
loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm")
|
loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm")
|
||||||
key = item.rating_key
|
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))
|
start_s = max(0.0, float(start_s))
|
||||||
audio_langs = _probe_audio_langs(src) if multi else []
|
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
|
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
|
# 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).
|
# 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 "")
|
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:
|
def _seg(i: int) -> Path:
|
||||||
return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
|
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.
|
# 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
|
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
||||||
with _lock:
|
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
|
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:
|
if old is not None:
|
||||||
_kill(old)
|
_kill(old)
|
||||||
shutil.rmtree(directory, ignore_errors=True)
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
@@ -753,8 +786,9 @@ def start_session(
|
|||||||
transcode=transcode,
|
transcode=transcode,
|
||||||
hw=hw,
|
hw=hw,
|
||||||
tonemap=tonemap,
|
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 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
|
# — 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.
|
# now it was only inferable from the ffmpeg command nobody keeps.
|
||||||
@@ -770,9 +804,13 @@ def start_session(
|
|||||||
start_s=round(start_s, 1),
|
start_s=round(start_s, 1),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
||||||
# at least 1 so playback can't be capped to zero.
|
# capped to zero.
|
||||||
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
_enforce_caps(
|
||||||
|
user_id,
|
||||||
|
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
||||||
|
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
||||||
|
)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
s = _spawn(*_select_encode(db, item, transcode, src))
|
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
|
# 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" /
|
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
|
||||||
# "Not available yet") the client re-requests.
|
# "Not available yet") the client re-requests.
|
||||||
return current_session(key) or s
|
return current_session(user_id, key) or s
|
||||||
s = respawned
|
s = respawned
|
||||||
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||||
if ready:
|
if ready:
|
||||||
@@ -829,9 +867,9 @@ def start_session(
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def current_session(key: str) -> HlsSession | None:
|
def current_session(user_id: int, key: str) -> HlsSession | None:
|
||||||
with _lock:
|
with _lock:
|
||||||
s = _sessions.get(key)
|
s = _sessions.get((user_id, key))
|
||||||
if s is not None:
|
if s is not None:
|
||||||
s.last_access = time.time()
|
s.last_access = time.time()
|
||||||
return s
|
return s
|
||||||
@@ -1060,21 +1098,21 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
dropped = 0
|
dropped = 0
|
||||||
with _lock:
|
with _lock:
|
||||||
for key, s in list(_sessions.items()):
|
for reg, s in list(_sessions.items()):
|
||||||
done = s.proc.poll() is not None
|
done = s.proc.poll() is not None
|
||||||
idle = now - s.last_access
|
idle = now - s.last_access
|
||||||
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
||||||
_kill(s)
|
_kill(s)
|
||||||
_sessions.pop(key, None)
|
_sessions.pop(reg, None)
|
||||||
dropped += 1
|
dropped += 1
|
||||||
return dropped
|
return dropped
|
||||||
|
|
||||||
|
|
||||||
def touch_session(key: str) -> None:
|
def touch_session(user_id: int, key: str) -> None:
|
||||||
"""Say a viewer is still there for `key`, without fetching anything. `last_access` used to move
|
"""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
|
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."""
|
viewer's session alive was to keep EVERY abandoned one just as long."""
|
||||||
with _lock:
|
with _lock:
|
||||||
s = _sessions.get(key)
|
s = _sessions.get((user_id, key))
|
||||||
if s is not None:
|
if s is not None:
|
||||||
s.last_access = time.time()
|
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.
|
# which time something has finished.
|
||||||
_MAX_WINDOWS_INFLIGHT = 8
|
_MAX_WINDOWS_INFLIGHT = 8
|
||||||
_MAX_FULL_INFLIGHT = 6
|
_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
|
_MAX_TRACK_WINDOWS = 2
|
||||||
|
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
# Queued or running jobs → the absolute span a window job reads (None for a full pass).
|
# Queued or running jobs → the absolute span a window job reads (None for a full pass).
|
||||||
_INFLIGHT: dict[tuple, tuple[float, float] | None] = {}
|
_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…).
|
# Track-level failures: a track that cannot be served at all (no item, a bitmap track, Plex down…).
|
||||||
_FAILURES: dict[str, tuple[str, float]] = {}
|
_FAILURES: dict[str, tuple[str, float]] = {}
|
||||||
# rating_key → when its full pass last failed (see FULL_RETRY_S).
|
# rating_key → when its full pass last failed (see FULL_RETRY_S).
|
||||||
@@ -515,6 +522,7 @@ def reset_state() -> None:
|
|||||||
global _STOPPING
|
global _STOPPING
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
_INFLIGHT.clear()
|
_INFLIGHT.clear()
|
||||||
|
_FULL_OWNERS.clear()
|
||||||
_FAILURES.clear()
|
_FAILURES.clear()
|
||||||
_FULL_FAILED.clear()
|
_FULL_FAILED.clear()
|
||||||
_WIN_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))
|
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.
|
"""State of one subtitle track, scheduling whatever extraction is still missing. NEVER blocks.
|
||||||
|
|
||||||
``ready`` = fully extracted · ``partial`` = usable, covers ``at``, still growing · ``preparing``
|
``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:
|
if edge - at <= LOOKAHEAD_S and edge < _FULL_END_S:
|
||||||
probe = edge + 1.0
|
probe = edge + 1.0
|
||||||
want_window = not _window_failed(key, probe)
|
want_window = not _window_failed(key, probe)
|
||||||
_schedule(rating_key, sub_ord, probe, want_window=want_window, want_full=not full_failed)
|
_schedule(
|
||||||
return {"state": "partial" if covered else "preparing", "code": None, "gen": gen}
|
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]:
|
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(
|
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:
|
) -> None:
|
||||||
"""Kick off the jobs this track still needs: the window that makes it watchable now, and the one
|
"""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
|
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)
|
ident = ("win", rating_key, sub_ord, w_start)
|
||||||
_INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S)
|
_INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S)
|
||||||
jobs.append((ident, w_start))
|
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
|
_INFLIGHT[("full", rating_key)] = None
|
||||||
|
_FULL_OWNERS[rating_key] = viewer
|
||||||
jobs.append((("full", rating_key), None))
|
jobs.append((("full", rating_key), None))
|
||||||
for ident, window_start in jobs:
|
for ident, window_start in jobs:
|
||||||
pool = _FULL_POOL if window_start is None else _WIN_POOL
|
pool = _FULL_POOL if window_start is None else _WIN_POOL
|
||||||
@@ -757,6 +792,8 @@ def _run(
|
|||||||
finally:
|
finally:
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
_INFLIGHT.pop(ident, None)
|
_INFLIGHT.pop(ident, None)
|
||||||
|
if ident[0] == "full":
|
||||||
|
_FULL_OWNERS.pop(rating_key, None)
|
||||||
|
|
||||||
|
|
||||||
def _pass_failed(
|
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)
|
for i, s in enumerate(subs)
|
||||||
if s.get("index") is not None and not _is_bitmap(s)
|
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
|
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
|
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():
|
for emb_ord, vtt in texts.items():
|
||||||
key = cache_key(rating_key, targets[emb_ord])
|
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
|
# 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))
|
_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:
|
def _sidecar_vtt(db: Session, stream: dict) -> str:
|
||||||
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
|
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
@@ -884,9 +942,10 @@ def _ffmpeg_extract(
|
|||||||
cmd += ["-map", f"0:s:{emb_ord}"]
|
cmd += ["-map", f"0:s:{emb_ord}"]
|
||||||
if length is not None:
|
if length is not None:
|
||||||
cmd += ["-t", f"{length:.3f}"]
|
cmd += ["-t", f"{length:.3f}"]
|
||||||
|
if start:
|
||||||
# The input seek lands on the keyframe at or BEFORE `start`, so a cue already running
|
# 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
|
# 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
|
# 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
|
# 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).
|
# times are exact on the zero-based clock (`_vtt_ts_to_s` reads the sign).
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class QuotaAction:
|
|||||||
PLAYLISTS_DELETE = "playlists_delete"
|
PLAYLISTS_DELETE = "playlists_delete"
|
||||||
VIDEOS_BACKFILL_RECENT = "videos_backfill_recent"
|
VIDEOS_BACKFILL_RECENT = "videos_backfill_recent"
|
||||||
VIDEOS_BACKFILL_FULL = "videos_backfill_full"
|
VIDEOS_BACKFILL_FULL = "videos_backfill_full"
|
||||||
|
VIDEOS_FEED_FALLBACK = "videos_feed_fallback"
|
||||||
VIDEOS_ENRICH = "videos_enrich"
|
VIDEOS_ENRICH = "videos_enrich"
|
||||||
VIDEOS_LOOKUP = "videos_lookup"
|
VIDEOS_LOOKUP = "videos_lookup"
|
||||||
VIDEOS_SEARCH = "videos_search"
|
VIDEOS_SEARCH = "videos_search"
|
||||||
|
|||||||
@@ -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.db import Base
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
from app.youtube.client import YouTubeError, refusal_reason
|
||||||
|
|
||||||
M = TypeVar("M", bound=Base)
|
M = TypeVar("M", bound=Base)
|
||||||
|
|
||||||
@@ -45,9 +46,20 @@ def owned_or_404(
|
|||||||
) -> M:
|
) -> M:
|
||||||
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
|
"""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.
|
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
|
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
|
||||||
`_own_playlist_or_404`, the saved-views owner check)."""
|
`_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)
|
obj = get_or_404(db, model, id_, what=what)
|
||||||
if getattr(obj, owner_attr) != user.id:
|
if getattr(obj, owner_attr) != user.id:
|
||||||
raise not_found(what)
|
raise not_found(what)
|
||||||
return obj
|
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,
|
send_verification_link,
|
||||||
)
|
)
|
||||||
from app.utils import canonical_email, iso, valid_email
|
from app.utils import canonical_email, iso, valid_email
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
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)
|
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:
|
if user is not None and not user.is_active:
|
||||||
user.is_active = True
|
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:
|
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
|
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
||||||
# mailing a rejection to an already-active account.
|
# mailing a rejection to an already-active account.
|
||||||
if inv.status != "pending":
|
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.status = "approved" if approved else "denied"
|
||||||
inv.decided_at = datetime.now(timezone.utc)
|
inv.decided_at = datetime.now(timezone.utc)
|
||||||
inv.decided_by = admin.email
|
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,
|
target_id=inv.id,
|
||||||
summary=inv.email,
|
summary=inv.email,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.flush() # the handler commits, with the activation that goes with it
|
||||||
return inv
|
return inv
|
||||||
|
|
||||||
|
|
||||||
@@ -210,6 +211,9 @@ def approve_invite(
|
|||||||
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
||||||
lang = mailtext.lang_for_email(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)
|
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])))
|
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||||
|
|
||||||
|
|
||||||
@@ -226,6 +230,7 @@ def deny_invite(
|
|||||||
background.add_task(
|
background.add_task(
|
||||||
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
|
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])))
|
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."""
|
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
||||||
email = body.email.strip().lower()
|
email = body.email.strip().lower()
|
||||||
if not valid_email(email):
|
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
|
# 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@),
|
# 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.
|
# 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.status = "approved"
|
||||||
inv.decided_at = datetime.now(timezone.utc)
|
inv.decided_at = datetime.now(timezone.utc)
|
||||||
inv.decided_by = admin.email
|
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(
|
audit.record(
|
||||||
db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email
|
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)
|
_activate_user_by_email(db, email)
|
||||||
|
db.commit()
|
||||||
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
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."""
|
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")
|
inv = get_or_404(db, Invite, invite_id, what="invite")
|
||||||
if _accounts_for(db, [inv.email]):
|
if _accounts_for(db, [inv.email]):
|
||||||
raise HTTPException(
|
raise refused(409, "address_has_account")
|
||||||
status_code=409,
|
|
||||||
detail="That address already has an account — delete the account instead.",
|
|
||||||
)
|
|
||||||
still_allowed_by_env = _env_allowed(inv.email)
|
still_allowed_by_env = _env_allowed(inv.email)
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
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."""
|
account, and can't remove the last remaining admin. Emails the user when it actually changes."""
|
||||||
role = body.role
|
role = body.role
|
||||||
if role not in ("user", "admin"):
|
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")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
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:
|
if target.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
raise refused(400, "own_role")
|
||||||
if (
|
if (
|
||||||
target.role == "admin"
|
target.role == "admin"
|
||||||
and role == "user"
|
and role == "user"
|
||||||
and not target.is_suspended
|
and not target.is_suspended
|
||||||
and count_admins(db, active_only=True) <= 1
|
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
|
changed = target.role != role
|
||||||
if changed:
|
if changed:
|
||||||
audit.record(
|
audit.record(
|
||||||
@@ -493,16 +497,16 @@ def set_user_suspended(
|
|||||||
suspended = body.suspended
|
suspended = body.suspended
|
||||||
target = get_or_404(db, User, user_id, what="user")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
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:
|
if target.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
|
raise refused(400, "own_suspend")
|
||||||
if (
|
if (
|
||||||
suspended
|
suspended
|
||||||
and target.role == "admin"
|
and target.role == "admin"
|
||||||
and not target.is_suspended
|
and not target.is_suspended
|
||||||
and count_admins(db, active_only=True) <= 1
|
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
|
was_suspended = target.is_suspended
|
||||||
if was_suspended != suspended:
|
if was_suspended != suspended:
|
||||||
audit.record(
|
audit.record(
|
||||||
@@ -537,17 +541,15 @@ def admin_delete_user(
|
|||||||
not the demo account, not yourself (use Settings → Account), and not the last admin."""
|
not the demo account, not yourself (use Settings → Account), and not the last admin."""
|
||||||
target = get_or_404(db, User, user_id, what="user")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
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:
|
if target.id == admin.id:
|
||||||
raise HTTPException(
|
raise refused(400, "own_delete")
|
||||||
status_code=400, detail="Delete your own account from Settings → Account."
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
target.role == "admin"
|
target.role == "admin"
|
||||||
and not target.is_suspended
|
and not target.is_suspended
|
||||||
and count_admins(db, active_only=True) <= 1
|
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.
|
# Record BEFORE the purge — the target row (and its email) is gone afterwards.
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -691,7 +693,7 @@ def activate_user(
|
|||||||
`_activate_user_by_email`). What was missing was any way to know they had to."""
|
`_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")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
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:
|
if target.is_active:
|
||||||
return _serialize_user(target) # idempotent: a double click is not an error
|
return _serialize_user(target) # idempotent: a double click is not an error
|
||||||
if not is_allowed(db, target.email):
|
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."""
|
sign-in). Idempotent — re-adding an existing email just returns it."""
|
||||||
email = body.email.strip().lower()
|
email = body.email.strip().lower()
|
||||||
if not valid_email(email):
|
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()
|
row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none()
|
||||||
if row is None:
|
if row is None:
|
||||||
row = DemoWhitelist(
|
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, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded"
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"reset": True, "playlists_seeded": seeded}
|
return {"ok": True, "reset": True, "playlists_seeded": seeded}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/purge-discovery")
|
@router.post("/purge-discovery")
|
||||||
@@ -854,4 +856,4 @@ def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get
|
|||||||
after=result,
|
after=result,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return result
|
return {"ok": True, **result}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""The admin diagnostics console's read side (E22.6): query `log_events`, the server's stored log.
|
||||||
|
|
||||||
|
Admin-only and read-only — rows leave the table through the retention job (`log_retention_days`),
|
||||||
|
never through here. Reading is not audited: it changes nothing, and the console's live tail polls,
|
||||||
|
so a row per read would bury the audit log's real entries.
|
||||||
|
|
||||||
|
What a row holds was redacted when it was written (`logs.record_fields`, and the ingest route for a
|
||||||
|
browser's rows), so this serves the columns as they are; a value the redactor took out reads as
|
||||||
|
`[redacted]` / `[email]`, which is the point — the console shows that something was there.
|
||||||
|
|
||||||
|
Paging is a keyset over `(ts desc, id desc)`: a browser row's `ts` is clamped into its consent
|
||||||
|
window, so `ts` alone is not unique, and an offset would skip or repeat rows while the writer adds
|
||||||
|
new ones at the top. The live tail re-reads the first page rather than asking for "ids after N":
|
||||||
|
several processes write this table, and a batch's ids are taken when it inserts, not when it
|
||||||
|
commits, so a slower batch can land below an id the tail already passed — and would never show.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import Text, and_, cast, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import admin_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import LogEvent, User
|
||||||
|
from app.utils import iso, like_escape
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
|
||||||
|
|
||||||
|
PAGE_MAX = 200
|
||||||
|
# A facet list is a chip row, not a report: past this many distinct events the rest are reachable
|
||||||
|
# through the free-text search, and the response stays small whatever a self-host logs.
|
||||||
|
FACET_EVENTS_MAX = 100
|
||||||
|
|
||||||
|
Level = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||||
|
# The filters a facet can leave out of its own count (`LogFilters.conditions`).
|
||||||
|
Facet = Literal["levels", "sources", "user", "event"]
|
||||||
|
# `none` selects the rows that have no account / no event code (a scheduler line, an anonymous
|
||||||
|
# request) — a real value to filter on, not the absence of a filter.
|
||||||
|
NONE = "none"
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(value: datetime | None) -> datetime | None:
|
||||||
|
"""A bound without a zone is UTC — the column is `timestamptz`, and a naive value would be read
|
||||||
|
in the database session's zone instead."""
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_cursor(row: LogEvent) -> str:
|
||||||
|
return base64.urlsafe_b64encode(json.dumps([row.ts.isoformat(), row.id]).encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||||
|
try:
|
||||||
|
ts, row_id = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
||||||
|
return _utc(datetime.fromisoformat(ts)), int(row_id)
|
||||||
|
except (binascii.Error, json.JSONDecodeError, ValueError, TypeError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(row: LogEvent) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"ts": iso(row.ts),
|
||||||
|
"level": row.level,
|
||||||
|
"source": row.source,
|
||||||
|
"logger": row.logger,
|
||||||
|
"event": row.event,
|
||||||
|
"msg": row.msg,
|
||||||
|
"attrs": row.attrs,
|
||||||
|
"exc": row.exc,
|
||||||
|
"request_id": row.request_id,
|
||||||
|
"user_id": row.user_id,
|
||||||
|
"job_id": row.job_id,
|
||||||
|
"debug_session_id": row.debug_session_id,
|
||||||
|
"version": row.version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
|
||||||
|
"""The accounts a page names, by id → address. An id with no account left (erased after the
|
||||||
|
line was written) is simply absent, and the client shows the bare id."""
|
||||||
|
if not user_ids:
|
||||||
|
return {}
|
||||||
|
rows = db.execute(select(User.id, User.email).where(User.id.in_(user_ids))).all()
|
||||||
|
return {str(uid): email for uid, email in rows}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LogFilters:
|
||||||
|
"""The console's filters, shared by the page and its facet counts."""
|
||||||
|
|
||||||
|
levels: list[str]
|
||||||
|
sources: list[str]
|
||||||
|
user: str | None
|
||||||
|
event: str | None
|
||||||
|
request_id: str | None
|
||||||
|
job_id: int | None
|
||||||
|
q: str | None
|
||||||
|
since: datetime | None
|
||||||
|
until: datetime | None
|
||||||
|
|
||||||
|
def conditions(self, skip: Facet | None = None) -> list:
|
||||||
|
"""The WHERE conditions, ANDed by the caller. `skip` leaves one filter out: a facet counts
|
||||||
|
its own values under every OTHER filter, so picking a level does not zero the other
|
||||||
|
levels' counts (the way back), while a request pivot does narrow them (honest counts)."""
|
||||||
|
out = []
|
||||||
|
if self.levels and skip != "levels":
|
||||||
|
out.append(LogEvent.level.in_(self.levels))
|
||||||
|
if self.sources and skip != "sources":
|
||||||
|
out.append(LogEvent.source.in_(self.sources))
|
||||||
|
if self.user is not None and skip != "user":
|
||||||
|
out.append(
|
||||||
|
LogEvent.user_id.is_(None)
|
||||||
|
if self.user == NONE
|
||||||
|
else LogEvent.user_id == int(self.user)
|
||||||
|
)
|
||||||
|
if self.event is not None and skip != "event":
|
||||||
|
out.append(
|
||||||
|
LogEvent.event.is_(None) if self.event == NONE else LogEvent.event == self.event
|
||||||
|
)
|
||||||
|
if self.request_id is not None:
|
||||||
|
out.append(LogEvent.request_id == self.request_id)
|
||||||
|
if self.job_id is not None:
|
||||||
|
out.append(LogEvent.job_id == self.job_id)
|
||||||
|
if self.since is not None:
|
||||||
|
out.append(LogEvent.ts >= _utc(self.since))
|
||||||
|
if self.until is not None:
|
||||||
|
out.append(LogEvent.ts < _utc(self.until))
|
||||||
|
if self.q and self.q.strip():
|
||||||
|
like = f"%{like_escape(self.q.strip())}%"
|
||||||
|
out.append(
|
||||||
|
or_(
|
||||||
|
LogEvent.msg.ilike(like),
|
||||||
|
LogEvent.event.ilike(like),
|
||||||
|
LogEvent.request_id.ilike(like),
|
||||||
|
LogEvent.exc.ilike(like),
|
||||||
|
cast(LogEvent.attrs, Text).ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def log_filters(
|
||||||
|
levels: list[Level] = Query(default=[]),
|
||||||
|
sources: list[str] = Query(default=[], max_length=8),
|
||||||
|
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"),
|
||||||
|
event: str | None = Query(
|
||||||
|
default=None, max_length=64, pattern=r"^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$"
|
||||||
|
),
|
||||||
|
request_id: str | None = Query(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$"),
|
||||||
|
job_id: int | None = Query(default=None, ge=1),
|
||||||
|
q: str | None = Query(default=None, max_length=200),
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
) -> LogFilters:
|
||||||
|
return LogFilters(list(levels), list(sources), user, event, request_id, job_id, q, since, until)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_logs(
|
||||||
|
filters: LogFilters = Depends(log_filters),
|
||||||
|
before: str | None = Query(default=None, max_length=200),
|
||||||
|
limit: int = Query(default=100, ge=1, le=PAGE_MAX),
|
||||||
|
_: User = Depends(admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""One page of rows, newest first, matching every filter given (they AND together); `before`
|
||||||
|
continues from a previous page's `next_cursor`."""
|
||||||
|
query = select(LogEvent).where(*filters.conditions())
|
||||||
|
if before is not None:
|
||||||
|
ts, row_id = _decode_cursor(before)
|
||||||
|
# The `ts <=` conjunct is implied by the OR; it is there so the planner can bound the
|
||||||
|
# `ts` index scan instead of filtering every newer row first.
|
||||||
|
query = query.where(
|
||||||
|
and_(
|
||||||
|
LogEvent.ts <= ts,
|
||||||
|
or_(LogEvent.ts < ts, and_(LogEvent.ts == ts, LogEvent.id < row_id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# One more than asked, to know whether there is a next page without a second count query.
|
||||||
|
rows = (
|
||||||
|
db.execute(query.order_by(LogEvent.ts.desc(), LogEvent.id.desc()).limit(limit + 1))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
more = len(rows) > limit
|
||||||
|
rows = rows[:limit]
|
||||||
|
return {
|
||||||
|
"items": [_serialize(r) for r in rows],
|
||||||
|
"accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}),
|
||||||
|
"next_cursor": _encode_cursor(rows[-1]) if more else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/facets")
|
||||||
|
def log_facets(
|
||||||
|
filters: LogFilters = Depends(log_filters),
|
||||||
|
_: User = Depends(admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""What the filter chips offer, with counts: the levels, sources and accounts present, and the
|
||||||
|
most frequent events — each counted under every filter but its own (`LogFilters.conditions`).
|
||||||
|
`total` is the "all levels" count: every filter but the level one."""
|
||||||
|
|
||||||
|
def counts(col, skip: Facet, cap: int | None = None) -> list[dict]:
|
||||||
|
n = func.count().label("n")
|
||||||
|
stmt = select(col, n).where(*filters.conditions(skip)).group_by(col).order_by(n.desc(), col)
|
||||||
|
if cap is not None:
|
||||||
|
stmt = stmt.limit(cap)
|
||||||
|
return [
|
||||||
|
{"value": NONE if value is None else value, "count": count}
|
||||||
|
for value, count in db.execute(stmt).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
users = counts(LogEvent.user_id, "user")
|
||||||
|
total = select(func.count()).select_from(LogEvent).where(*filters.conditions("levels"))
|
||||||
|
return {
|
||||||
|
"levels": counts(LogEvent.level, "levels"),
|
||||||
|
"sources": counts(LogEvent.source, "sources"),
|
||||||
|
"events": counts(LogEvent.event, "event", FACET_EVENTS_MAX),
|
||||||
|
"users": [{**u, "value": str(u["value"])} for u in users],
|
||||||
|
"accounts": _accounts(db, {u["value"] for u in users if u["value"] != NONE}),
|
||||||
|
"total": db.scalar(total) or 0,
|
||||||
|
}
|
||||||
@@ -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
|
n = db.execute(delete(AuditLog)).rowcount or 0
|
||||||
audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries")
|
audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries")
|
||||||
db.commit()
|
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.ratelimit import RateLimiter, client_key
|
||||||
from app.security import encrypt, hash_password, hash_token, verify_password
|
from app.security import encrypt, hash_password, hash_token, verify_password
|
||||||
from app.utils import canonical_email, valid_email
|
from app.utils import canonical_email, valid_email
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
VERIFY_TTL = timedelta(hours=24)
|
VERIFY_TTL = timedelta(hours=24)
|
||||||
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
|
# 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")
|
userinfo = token.get("userinfo")
|
||||||
if not userinfo or not userinfo.get("sub"):
|
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"]
|
sub = userinfo["sub"]
|
||||||
email = (userinfo.get("email") or "").lower()
|
email = (userinfo.get("email") or "").lower()
|
||||||
@@ -653,15 +654,15 @@ async def request_access(
|
|||||||
endpoint can't be probed for throttling either."""
|
endpoint can't be probed for throttling either."""
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
if not valid_email(email):
|
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)):
|
if not _request_access_limiter.allow(client_key(request)):
|
||||||
return {"status": "pending"}
|
return {"ok": True, "status": "pending"}
|
||||||
if is_allowed(db, email):
|
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)
|
req = upsert_pending_invite(db, email, body.lang)
|
||||||
if req.is_new:
|
if req.is_new:
|
||||||
notify_admins_of_request(db, background, email)
|
notify_admins_of_request(db, background, email)
|
||||||
return {"status": "pending"}
|
return {"ok": True, "status": "pending"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/demo")
|
@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
|
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."""
|
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
||||||
if not _demo_limiter.allow(client_key(request)):
|
if not _demo_limiter.allow(client_key(request)):
|
||||||
return {"authenticated": False}
|
return {"ok": True, "authenticated": False}
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
if valid_email(email) and is_demo_allowed(db, email):
|
if valid_email(email) and is_demo_allowed(db, email):
|
||||||
demo = get_or_create_demo_user(db)
|
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)
|
_remember_epoch(request, demo)
|
||||||
# The demo key IS an address, so it is not in the line — see the denied branch above.
|
# 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))
|
log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id))
|
||||||
return {"authenticated": True}
|
return {"ok": True, "authenticated": True}
|
||||||
return {"authenticated": False}
|
return {"ok": True, "authenticated": False}
|
||||||
|
|
||||||
|
|
||||||
def _establish_session(request: Request, user: User) -> None:
|
def _establish_session(request: Request, user: User) -> None:
|
||||||
@@ -815,23 +816,20 @@ def register(
|
|||||||
password = body.password or ""
|
password = body.password or ""
|
||||||
# Input validation is safe to reveal (independent of whether the email exists).
|
# Input validation is safe to reveal (independent of whether the email exists).
|
||||||
if not valid_email(email):
|
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:
|
if len(password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||||
status_code=400,
|
|
||||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
|
||||||
)
|
|
||||||
if not sysconfig.get_bool(db, "allow_registration"):
|
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)):
|
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
|
# 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
|
# 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
|
# 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.
|
# 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)
|
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:
|
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
|
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."""
|
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)):
|
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()
|
email = (body.email or "").strip().lower()
|
||||||
if valid_email(email):
|
if valid_email(email):
|
||||||
background.add_task(_resend_verify_if_eligible, 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:
|
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
|
"""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."""
|
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)):
|
if not _reset_limiter.allow(client_key(request)):
|
||||||
return {"status": "ok"}
|
return {"ok": True, "status": "ok"}
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
# 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
|
# 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.
|
# password account — no timing-based enumeration. Any valid email schedules the same task.
|
||||||
if valid_email(email):
|
if valid_email(email):
|
||||||
background.add_task(_send_reset_if_eligible, 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:
|
def _send_reset_if_eligible(email: str) -> None:
|
||||||
@@ -1293,13 +1291,10 @@ def set_password(
|
|||||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||||
new_password = body.password or ""
|
new_password = body.password or ""
|
||||||
if len(new_password) < PASSWORD_MIN_LEN:
|
if len(new_password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||||
status_code=400,
|
|
||||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
|
||||||
)
|
|
||||||
if user.password_hash:
|
if user.password_hash:
|
||||||
if not verify_password(body.current_password or "", 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_hash = hash_password(new_password)
|
||||||
user.password_revoked_at = None # same as the reset path: a password exists again
|
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
|
# 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 datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -25,12 +25,13 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
Video,
|
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.explore import explore_ingest_page
|
||||||
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
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)."""
|
backfill quota reserve; blocked for the demo account (it spends shared quota)."""
|
||||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||||
if _is_blocked(db, user, channel_id):
|
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():
|
if quota.backfill_reserve_reached():
|
||||||
raise HTTPException(
|
raise refused(429, "quota_reserve")
|
||||||
status_code=429,
|
|
||||||
detail="The shared daily quota reserve is reached — try exploring later.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
|
# 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.
|
# 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)
|
channel = db.get(Channel, channel_id)
|
||||||
result = explore_ingest_page(db, yt, channel, page_token)
|
result = explore_ingest_page(db, yt, channel, page_token)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"YouTube couldn't load this channel: {exc}")
|
raise youtube_refusal(exc)
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"ingested": result["ingested"],
|
"ingested": result["ingested"],
|
||||||
"next_page_token": result["next_page_token"],
|
"next_page_token": result["next_page_token"],
|
||||||
@@ -507,6 +506,7 @@ def update_channel(
|
|||||||
background.add_task(backfill_channel_now, channel_id, user.id)
|
background.add_task(backfill_channel_now, channel_id, user.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"id": channel_id,
|
"id": channel_id,
|
||||||
"priority": sub.priority,
|
"priority": sub.priority,
|
||||||
"hidden": sub.hidden,
|
"hidden": sub.hidden,
|
||||||
@@ -541,7 +541,7 @@ def reset_backfill(
|
|||||||
db.commit()
|
db.commit()
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||||
run_recent_backfill(db, [channel], max_channels=1)
|
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")
|
@router.post("/{channel_id}/subscribe")
|
||||||
@@ -562,7 +562,7 @@ def subscribe(
|
|||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
|
raise refused(409, "already_subscribed")
|
||||||
with (
|
with (
|
||||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE),
|
quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE),
|
||||||
YouTubeClient(db, user) as yt,
|
YouTubeClient(db, user) as yt,
|
||||||
@@ -583,9 +583,7 @@ def subscribe(
|
|||||||
if "subscriptionDuplicate" in exc.reason.split(","):
|
if "subscriptionDuplicate" in exc.reason.split(","):
|
||||||
yt_sub_id = ""
|
yt_sub_id = ""
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise youtube_refusal(exc)
|
||||||
status_code=422, detail=f"YouTube couldn't subscribe you: {exc}"
|
|
||||||
)
|
|
||||||
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
|
# 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
|
# 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
|
# 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)
|
.values(via_explore=False)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"subscribed": channel_id}
|
return {"ok": True, "subscribed": channel_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{channel_id}/subscription")
|
@router.delete("/{channel_id}/subscription")
|
||||||
@@ -636,10 +634,7 @@ def unsubscribe(
|
|||||||
should Hide instead."""
|
should Hide instead."""
|
||||||
sub = _user_subscription(db, user, channel_id)
|
sub = _user_subscription(db, user, channel_id)
|
||||||
if not sub.yt_subscription_id:
|
if not sub.yt_subscription_id:
|
||||||
raise HTTPException(
|
raise refused(400, "no_subscription_id")
|
||||||
status_code=400,
|
|
||||||
detail="No YouTube subscription id on record — sync subscriptions first.",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE),
|
quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE),
|
||||||
@@ -649,10 +644,10 @@ def unsubscribe(
|
|||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
||||||
# a transient "connection lost" gateway blip.
|
# 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.delete(sub)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"unsubscribed": channel_id}
|
return {"ok": True, "unsubscribed": channel_id}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/blocked/list")
|
@router.get("/blocked/list")
|
||||||
@@ -693,7 +688,7 @@ def block_channel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"blocked": channel_id}
|
return {"ok": True, "blocked": channel_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{channel_id}/block")
|
@router.delete("/{channel_id}/block")
|
||||||
@@ -708,7 +703,7 @@ def unblock_channel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"unblocked": channel_id}
|
return {"ok": True, "unblocked": channel_id}
|
||||||
|
|
||||||
|
|
||||||
class AttachTagIn(BaseModel):
|
class AttachTagIn(BaseModel):
|
||||||
@@ -743,7 +738,7 @@ def attach_tag(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
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}")
|
@router.delete("/{channel_id}/tags/{tag_id}")
|
||||||
@@ -763,4 +758,4 @@ def detach_tag(
|
|||||||
if row is not None:
|
if row is not None:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
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
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -17,6 +17,9 @@ from app.audit import AuditAction
|
|||||||
from app.auth import admin_user
|
from app.auth import admin_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import User
|
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"])
|
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
||||||
|
|
||||||
@@ -59,19 +62,16 @@ def set_config(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
s = sysconfig.spec(key)
|
s = sysconfig.spec(key)
|
||||||
if s is None:
|
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:
|
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():
|
if s.secret and not sysconfig.secrets_manageable():
|
||||||
raise HTTPException(
|
raise refused(400, "no_encryption_key")
|
||||||
status_code=400,
|
|
||||||
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
|
||||||
)
|
|
||||||
old = None if s.secret else sysconfig.get(db, key)
|
old = None if s.secret else sysconfig.get(db, key)
|
||||||
try:
|
try:
|
||||||
sysconfig.set_value(db, key, body.value)
|
sysconfig.set_value(db, key, body.value)
|
||||||
except (TypeError, ValueError) as exc:
|
except sysconfig.InvalidSetting as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
raise setting_refused(exc) from None
|
||||||
# Never log a secret's value — only that it was changed, by whom. Non-secret values are logged
|
# 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
|
# 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
|
# 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:
|
) -> dict:
|
||||||
s = sysconfig.spec(key)
|
s = sysconfig.spec(key)
|
||||||
if s is None:
|
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)
|
old = None if s.secret else sysconfig.get(db, key)
|
||||||
sysconfig.reset(db, key)
|
sysconfig.reset(db, key)
|
||||||
audit.record(
|
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 —
|
"""Send a test email to the requesting admin using the current (DB or env) SMTP config —
|
||||||
lets the admin verify the settings actually work."""
|
lets the admin verify the settings actually work."""
|
||||||
if not email_mod.email_enabled():
|
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))
|
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
||||||
if not sent:
|
if not sent:
|
||||||
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.")
|
raise refused(422, "smtp_failed")
|
||||||
return {"sent": True, "to": user.email}
|
return {"ok": True, "sent": True, "to": user.email}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ thing it exists to remove.
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
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 import pins, rails
|
||||||
from app.discover.budget import Budget, DEFAULT_BUDGET
|
from app.discover.budget import Budget, DEFAULT_BUDGET
|
||||||
from app.models import ComfortPin, User
|
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"])
|
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
|
`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."""
|
are not queried twice for a single page load."""
|
||||||
resume = rails.continue_watching(db, user)
|
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 {
|
return {
|
||||||
"budget": budget,
|
"budget": budget,
|
||||||
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf),
|
"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."""
|
constraint is the mechanism, not the message."""
|
||||||
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
|
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown item")
|
raise not_found("item")
|
||||||
column, value = resolved
|
column, value = resolved
|
||||||
|
|
||||||
existing = db.execute(
|
existing = db.execute(
|
||||||
@@ -157,7 +159,7 @@ def add_pin(
|
|||||||
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
|
# 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
|
# 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.
|
# 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": winner.id, "created": False}
|
||||||
return {"ok": True, "id": pin.id, "created": True}
|
return {"ok": True, "id": pin.id, "created": True}
|
||||||
|
|
||||||
@@ -180,18 +182,11 @@ def reorder_pins(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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
|
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
|
tiles claiming the same slot the way an interrupted sequence of single moves can. The list may be
|
||||||
this user's are ignored rather than 404'd — a stale tab replaying an order that includes a pin
|
PARTIAL (hidden Plex pins, a stale tab) — `pins.reorder` keeps what it leaves out in place."""
|
||||||
deleted meanwhile should reorder what still exists, not fail wholesale."""
|
pins.reorder(db, user, body.ids)
|
||||||
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)
|
|
||||||
)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -38,10 +38,11 @@ from app.models import (
|
|||||||
MediaAsset,
|
MediaAsset,
|
||||||
User,
|
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.security import hash_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||||
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-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."""
|
as a raw URL for yt-dlp's generic extractor."""
|
||||||
raw = (raw or "").strip()
|
raw = (raw or "").strip()
|
||||||
if not raw:
|
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):
|
if _VIDEO_ID.match(raw):
|
||||||
return "youtube", raw
|
return "youtube", raw
|
||||||
if raw.startswith(("http://", "https://")):
|
if raw.startswith(("http://", "https://")):
|
||||||
@@ -93,7 +94,7 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
|||||||
if _VIDEO_ID.match(vid):
|
if _VIDEO_ID.match(vid):
|
||||||
return "youtube", vid
|
return "youtube", vid
|
||||||
return "url", raw
|
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:
|
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||||
@@ -166,15 +167,8 @@ def _serialize_job(db: Session, job: DownloadJob) -> dict:
|
|||||||
return _serialize(job, asset)
|
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:
|
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
|
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
|
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
|
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
|
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."""
|
(`service.finalize_if_running`), so the two can never deadlock."""
|
||||||
while True:
|
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:
|
if asset_id is not None:
|
||||||
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
||||||
job = db.execute(
|
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)
|
.execution_options(populate_existing=True)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if job is None:
|
if job is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown download")
|
raise not_found("download")
|
||||||
if job.asset_id == asset_id:
|
if job.asset_id == asset_id:
|
||||||
return job
|
return job
|
||||||
db.rollback() # another request moved the job to another asset meanwhile: lock that one
|
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()
|
name = body.name.strip()
|
||||||
spec = body.spec
|
spec = body.spec
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="A profile name is required.")
|
raise refused(400, "profile_name_required")
|
||||||
maxpos = db.execute(
|
maxpos = db.execute(
|
||||||
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
|
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
|
||||||
DownloadProfile.user_id == user.id
|
DownloadProfile.user_id == user.id
|
||||||
@@ -268,16 +262,16 @@ def update_profile(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
p = db.get(DownloadProfile, profile_id)
|
p = db.get(DownloadProfile, profile_id)
|
||||||
if p is None or p.is_builtin or p.user_id != user.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
|
sent = body.model_fields_set
|
||||||
if "name" in sent:
|
if "name" in sent:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="A profile name is required.")
|
raise refused(400, "profile_name_required")
|
||||||
p.name = name[:80]
|
p.name = name[:80]
|
||||||
if "spec" in sent:
|
if "spec" in sent:
|
||||||
if body.spec is None:
|
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
|
p.spec = body.spec
|
||||||
db.commit()
|
db.commit()
|
||||||
return _serialize_profile(p)
|
return _serialize_profile(p)
|
||||||
@@ -289,7 +283,7 @@ def delete_profile(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
p = db.get(DownloadProfile, profile_id)
|
p = db.get(DownloadProfile, profile_id)
|
||||||
if p is None or p.is_builtin or p.user_id != user.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.delete(p)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": profile_id}
|
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
|
"""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."""
|
never echoed back, logged, or audited — only the fact that it changed."""
|
||||||
if not dlcreds.is_supported_host(host_key):
|
if not dlcreds.is_supported_host(host_key):
|
||||||
raise HTTPException(status_code=404, detail="Unknown host")
|
raise not_found("host")
|
||||||
try:
|
try:
|
||||||
cleaned = dlcreds.validate_cookies(body.cookies)
|
cleaned = dlcreds.validate_cookies(body.cookies)
|
||||||
except dlcreds.InvalidCookies as e:
|
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)
|
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not dlcreds.is_supported_host(host_key):
|
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):
|
if dlcreds.delete_credential(db, user.id, host_key):
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -362,7 +356,7 @@ def _resolve_spec(
|
|||||||
if profile_id is not None:
|
if profile_id is not None:
|
||||||
p = db.get(DownloadProfile, profile_id)
|
p = db.get(DownloadProfile, profile_id)
|
||||||
if p is None or (not p.is_builtin and p.user_id != user.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
|
return p.spec, p.id
|
||||||
if spec is not None:
|
if spec is not None:
|
||||||
return spec, None
|
return spec, None
|
||||||
@@ -373,7 +367,7 @@ def _resolve_spec(
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if fallback is 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
|
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 file is only read, never modified, so editing a shared video is safe."""
|
||||||
source_job = _accessible_job(db, user, body.source_job_id)
|
source_job = _accessible_job(db, user, body.source_job_id)
|
||||||
if source_job.status != "done" or source_job.asset_id is None:
|
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
|
spec = body.edit_spec
|
||||||
display_name = (body.display_name or "").strip() or None
|
display_name = (body.display_name or "").strip() or None
|
||||||
try:
|
try:
|
||||||
@@ -614,7 +608,7 @@ def update_download_meta(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a download's display metadata: title, channel name + link, and extra reference URLs.
|
"""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."""
|
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
|
sent = body.model_fields_set
|
||||||
if "display_name" in sent:
|
if "display_name" in sent:
|
||||||
job.display_name = ((body.display_name or "").strip())[:255] or None
|
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"):
|
if job.job_kind in ("edit", "upload"):
|
||||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
# 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.
|
# 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"):
|
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)
|
service.redownload(db, job)
|
||||||
return _serialize_job(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:
|
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown download")
|
raise not_found("download")
|
||||||
if job.user_id == user.id:
|
if job.user_id == user.id:
|
||||||
return job
|
return job
|
||||||
shared = db.execute(
|
shared = db.execute(
|
||||||
@@ -708,7 +702,7 @@ def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
|||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
if shared is None:
|
if shared is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown download")
|
raise not_found("download")
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
@@ -722,10 +716,10 @@ def download_file(
|
|||||||
job = _accessible_job(db, user, job_id)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
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:
|
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)
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="File is no longer available.")
|
raise not_found("file")
|
||||||
|
|
||||||
filename = storage.download_filename(
|
filename = storage.download_filename(
|
||||||
job.display_name or asset.title or asset.source_ref, asset.container, path
|
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)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
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:
|
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)
|
meta = editmod.ensure_storyboard(asset, settings.download_root)
|
||||||
if meta is None:
|
if meta is None:
|
||||||
return {"available": False}
|
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)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or not asset.poster_path:
|
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)
|
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="No poster.")
|
raise not_found("poster")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
@@ -799,10 +793,10 @@ def storyboard_image(
|
|||||||
job = _accessible_job(db, user, job_id)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or not asset.storyboard_path:
|
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)
|
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="No filmstrip.")
|
raise not_found("filmstrip")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
@@ -820,12 +814,12 @@ def share_download(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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:
|
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()
|
target = body.email.strip().lower()
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=400, detail="A recipient email is required.")
|
raise refused(400, "recipient_required")
|
||||||
recipient = db.execute(
|
recipient = db.execute(
|
||||||
select(User).where(func.lower(User.email) == target)
|
select(User).where(func.lower(User.email) == target)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
@@ -834,7 +828,7 @@ def share_download(
|
|||||||
if not is_messageable_user(recipient):
|
if not is_messageable_user(recipient):
|
||||||
raise not_found("recipient")
|
raise not_found("recipient")
|
||||||
if recipient.id == user.id:
|
if recipient.id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="That's already your download.")
|
raise refused(400, "share_own")
|
||||||
exists = db.execute(
|
exists = db.execute(
|
||||||
select(DownloadShare.id).where(
|
select(DownloadShare.id).where(
|
||||||
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
|
||||||
@@ -853,7 +847,7 @@ def share_download(
|
|||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"shared_with": recipient.email}
|
return {"ok": True, "shared_with": recipient.email}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{job_id}/share/{email}")
|
@router.delete("/{job_id}/share/{email}")
|
||||||
@@ -863,7 +857,9 @@ def unshare_download(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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(
|
recipient = db.execute(
|
||||||
select(User).where(func.lower(User.email) == email.strip().lower())
|
select(User).where(func.lower(User.email) == email.strip().lower())
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
@@ -895,7 +891,7 @@ def remove_shared_with_me(
|
|||||||
if row is not None:
|
if row is not None:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"removed": job_id}
|
return {"ok": True, "removed": job_id}
|
||||||
|
|
||||||
|
|
||||||
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
# --- 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:
|
def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
|
||||||
link = db.get(DownloadLink, link_id)
|
link = db.get(DownloadLink, link_id)
|
||||||
if link is None:
|
if link is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown link")
|
raise not_found("link")
|
||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
if job is None or job.user_id != user.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
|
return link
|
||||||
|
|
||||||
|
|
||||||
@@ -944,7 +940,7 @@ def _link_expiry(days: int | None) -> datetime | None:
|
|||||||
def list_links(
|
def list_links(
|
||||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
_own_job(db, user, job_id)
|
owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(DownloadLink)
|
select(DownloadLink)
|
||||||
@@ -975,9 +971,9 @@ def create_link(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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:
|
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()
|
pw = body.password.strip()
|
||||||
link = DownloadLink(
|
link = DownloadLink(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
|
|||||||
+13
-12
@@ -1,6 +1,6 @@
|
|||||||
from datetime import date, datetime, timezone
|
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 pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
@@ -29,10 +29,11 @@ from app.models import (
|
|||||||
Video,
|
Video,
|
||||||
VideoState,
|
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.sync.videos import parse_iso8601_duration
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["feed"])
|
router = APIRouter(prefix="/api", tags=["feed"])
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ def get_feed(
|
|||||||
vals, cursor_id = decode_cursor(keys, cursor)
|
vals, cursor_id = decode_cursor(keys, cursor)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# `from None`: the ValueError is an expected bad-input signal, not an error to chain.
|
# `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):
|
for k, v in zip(keys, vals):
|
||||||
k["value"] = v
|
k["value"] = v
|
||||||
query = query.where(keyset_predicate(keys, cursor_id))
|
query = query.where(keyset_predicate(keys, cursor_id))
|
||||||
@@ -235,7 +236,7 @@ def set_video_state(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
status = body.status
|
status = body.status
|
||||||
if status not in VALID_STATES:
|
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")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
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.
|
# or moved this row. The state the caller asked for is the state that holds either way.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
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
|
# 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
|
# 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.execute(stmt)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"video_id": video_id, "status": status}
|
return {"ok": True, "video_id": video_id, "status": status}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/videos/{video_id}/state")
|
@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.
|
# silent-ok: the row went under the delete, which is the outcome the delete wanted.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
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):
|
class VideoProgressIn(BaseModel):
|
||||||
@@ -361,7 +362,7 @@ def set_video_progress(
|
|||||||
# silent-ok: exactly the race the comment above describes; the response stays true.
|
# silent-ok: exactly the race the comment above describes; the response stays true.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {"video_id": video_id, "position_seconds": 0}
|
return {"ok": True, "video_id": video_id, "position_seconds": 0}
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
row = VideoState(user_id=user.id, video_id=video_id)
|
row = VideoState(user_id=user.id, video_id=video_id)
|
||||||
@@ -369,7 +370,7 @@ def set_video_progress(
|
|||||||
row.position_seconds = position
|
row.position_seconds = position
|
||||||
row.progress_updated_at = datetime.now(timezone.utc)
|
row.progress_updated_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
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}")
|
@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
|
# 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.
|
# demo account can't reach it — it just sees "unknown" for a video we don't already store.
|
||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise not_found("video")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
@@ -411,9 +412,9 @@ def get_video_detail(
|
|||||||
):
|
):
|
||||||
items = yt.get_videos([video_id])
|
items = yt.get_videos([video_id])
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}")
|
raise youtube_refusal(exc)
|
||||||
if not items:
|
if not items:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise not_found("video")
|
||||||
|
|
||||||
snippet = items[0].get("snippet", {})
|
snippet = items[0].get("snippet", {})
|
||||||
stats = items[0].get("statistics", {})
|
stats = items[0].get("statistics", {})
|
||||||
|
|||||||
+14
-13
@@ -23,6 +23,9 @@ from app import logs
|
|||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Invite, User
|
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"])
|
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||||
|
|
||||||
@@ -69,7 +72,7 @@ def switch_account(
|
|||||||
here) are allowed, and access is re-checked in case the invite was revoked since."""
|
here) are allowed, and access is re-checked in case the invite was revoked since."""
|
||||||
target = body.user_id
|
target = body.user_id
|
||||||
if target not in (request.session.get("account_ids") or []):
|
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
|
# 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
|
# 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
|
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
|
||||||
@@ -77,22 +80,20 @@ def switch_account(
|
|||||||
u = db.get(User, target)
|
u = db.get(User, target)
|
||||||
if u is None:
|
if u is None:
|
||||||
drop_from_wallet(request.session, target)
|
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):
|
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:
|
if not u.is_active:
|
||||||
drop_from_wallet(request.session, target)
|
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:
|
if u.is_suspended:
|
||||||
drop_from_wallet(request.session, target)
|
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.
|
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
||||||
epochs = request.session.get("epochs") or {}
|
epochs = request.session.get("epochs") or {}
|
||||||
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
||||||
drop_from_wallet(request.session, target)
|
drop_from_wallet(request.session, target)
|
||||||
raise HTTPException(
|
raise refused(403, "account_signed_out")
|
||||||
status_code=403, detail="That account was signed out elsewhere — sign in again."
|
|
||||||
)
|
|
||||||
request.session["user_id"] = target
|
request.session["user_id"] = target
|
||||||
return {"ok": True, "user_id": target}
|
return {"ok": True, "user_id": target}
|
||||||
|
|
||||||
@@ -138,6 +139,9 @@ def get_me(
|
|||||||
user.google_email if user.google_email and user.google_email != user.email else None
|
user.google_email if user.google_email and user.google_email != user.email else None
|
||||||
),
|
),
|
||||||
"has_password": user.password_hash is not None,
|
"has_password": user.password_hash is not None,
|
||||||
|
# The account's latest sign-in on any device — shown in Settings → Account, so a sign-in the
|
||||||
|
# user does not recognise is something they can see (null: none since 0.88, or the demo).
|
||||||
|
"last_login_at": iso(user.last_login_at),
|
||||||
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
|
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
|
||||||
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
||||||
"google_enabled": google_enabled(db),
|
"google_enabled": google_enabled(db),
|
||||||
@@ -177,10 +181,7 @@ def delete_my_account(
|
|||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
|
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
|
||||||
if user.role == "admin" and count_admins(db) <= 1:
|
if user.role == "admin" and count_admins(db) <= 1:
|
||||||
raise HTTPException(
|
raise refused(400, "only_admin_delete")
|
||||||
status_code=400,
|
|
||||||
detail="You're the only admin — promote another admin before deleting your account.",
|
|
||||||
)
|
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
# 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.
|
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
||||||
@@ -213,7 +214,7 @@ def update_preferences(
|
|||||||
user.preferences = merged
|
user.preferences = merged
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"preferences": merged}
|
return {"ok": True, "preferences": merged}
|
||||||
|
|
||||||
|
|
||||||
class DiagnosticsIn(BaseModel):
|
class DiagnosticsIn(BaseModel):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ routing and unread counts.
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy import and_, case, delete, func, or_, select
|
from sqlalchemy import and_, case, delete, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -29,6 +29,8 @@ from app.realtime import manager
|
|||||||
from app.security import verify_password
|
from app.security import verify_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/messages", tags=["messages"])
|
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
|
"""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."""
|
key would orphan every existing conversation key, so re-setup is rejected here."""
|
||||||
if db.get(MessageKey, user.id) is not None:
|
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(
|
db.add(
|
||||||
MessageKey(
|
MessageKey(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -164,7 +166,7 @@ def setup_keys(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"configured": True}
|
return {"ok": True, "configured": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/keys/reset")
|
@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
|
less (Google-only) account has nothing to re-verify, so it relies on its live session plus the
|
||||||
client-side danger confirmation."""
|
client-side danger confirmation."""
|
||||||
if not _reset_key_limiter.allow(str(user.id)):
|
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(
|
if user.password_hash and not verify_password(
|
||||||
payload.current_password or "", user.password_hash
|
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
|
# 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.
|
# fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit.
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -202,7 +204,7 @@ def reset_keys(
|
|||||||
if key is not None:
|
if key is not None:
|
||||||
db.delete(key)
|
db.delete(key)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"configured": False}
|
return {"ok": True, "configured": False}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/keys/{user_id}")
|
@router.get("/keys/{user_id}")
|
||||||
@@ -223,10 +225,10 @@ def public_key(
|
|||||||
p = db.get(User, user_id)
|
p = db.get(User, user_id)
|
||||||
has_history = db.scalar(select(Message.id).where(cond).limit(1))
|
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):
|
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)
|
k = db.get(MessageKey, user_id)
|
||||||
if k is None:
|
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}
|
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.
|
# 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))
|
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):
|
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)
|
partner = _serialize_user(p)
|
||||||
msgs = (
|
msgs = (
|
||||||
db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all()
|
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
|
"""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)."""
|
produced, then pushes it live to the recipient (and the sender's other devices)."""
|
||||||
if not _send_limiter.allow(f"msg:{user.id}"):
|
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()
|
ct = (payload.ciphertext or "").strip()
|
||||||
iv = (payload.iv or "").strip()
|
iv = (payload.iv or "").strip()
|
||||||
if not ct or not iv:
|
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:
|
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:
|
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)
|
recipient = db.get(User, payload.recipient_id)
|
||||||
if not is_messageable_user(recipient):
|
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:
|
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)
|
m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv)
|
||||||
db.add(m)
|
db.add(m)
|
||||||
db.commit()
|
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
|
state are personal UI state with no quota or YouTube involvement, so the shared demo account
|
||||||
manages its own inbox normally."""
|
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 import func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ from app.db import get_db
|
|||||||
from app.models import Notification, User
|
from app.models import Notification, User
|
||||||
from app.notifications import trim_read
|
from app.notifications import trim_read
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.routes._common import owned_or_404
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/me/notifications", tags=["notifications"])
|
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}
|
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")
|
@router.post("/{notification_id}/read")
|
||||||
def mark_read(
|
def mark_read(
|
||||||
notification_id: int,
|
notification_id: int,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
n = _owned(db, notification_id, user)
|
n = owned_or_404(db, Notification, notification_id, user, what="notification")
|
||||||
n.read = True
|
n.read = True
|
||||||
db.commit()
|
db.commit()
|
||||||
trim_read(db, user.id)
|
trim_read(db, user.id)
|
||||||
@@ -110,7 +104,7 @@ def dismiss(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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).
|
# Dismiss implies read (it no longer counts toward the unread badge).
|
||||||
n.dismissed = True
|
n.dismissed = True
|
||||||
n.read = 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
|
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."""
|
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select, and_
|
from sqlalchemy import func, select, and_
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
@@ -23,7 +22,7 @@ from app.sync.playlists import (
|
|||||||
sync_user_playlists,
|
sync_user_playlists,
|
||||||
)
|
)
|
||||||
from app.ratelimit import RateLimiter
|
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
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
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"})
|
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:
|
def _refused(status: int, reason: str) -> HTTPException:
|
||||||
"""A playlist action the server refuses, as a reason the UI translates
|
"""A playlist action the server refuses, as a reason the UI translates
|
||||||
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
|
(`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 /
|
`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"
|
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
|
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."""
|
this list changed", which is what the rail promises the reader.
|
||||||
pl.updated_at = datetime.now(timezone.utc)
|
|
||||||
|
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:
|
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:
|
if mark_dirty:
|
||||||
recompute_dirty(db, pl)
|
recompute_dirty(db, pl)
|
||||||
_touch(pl)
|
_touch(pl)
|
||||||
db.commit()
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +124,7 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
|
|||||||
if mark_dirty:
|
if mark_dirty:
|
||||||
recompute_dirty(db, pl)
|
recompute_dirty(db, pl)
|
||||||
_touch(pl)
|
_touch(pl)
|
||||||
db.commit()
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -280,7 +278,7 @@ def _watch_later(db: Session, user: User) -> Playlist:
|
|||||||
position=_next_playlist_position(db, user.id),
|
position=_next_playlist_position(db, user.id),
|
||||||
)
|
)
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.commit()
|
db.flush() # the id, for the item the caller adds next; the handler commits
|
||||||
return pl
|
return pl
|
||||||
|
|
||||||
|
|
||||||
@@ -295,7 +293,8 @@ def add_watch_later(
|
|||||||
get_or_404(db, Video, video_id, what="video")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
pl = _watch_later(db, user)
|
pl = _watch_later(db, user)
|
||||||
_add_item(db, pl, video_id, mark_dirty=False)
|
_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}")
|
@router.delete("/watch-later/{video_id}")
|
||||||
@@ -309,7 +308,8 @@ def remove_watch_later(
|
|||||||
)
|
)
|
||||||
if pl is not None:
|
if pl is not None:
|
||||||
_remove_item(db, pl, video_id, mark_dirty=False)
|
_remove_item(db, pl, video_id, mark_dirty=False)
|
||||||
return {"saved": False}
|
db.commit()
|
||||||
|
return {"ok": True, "saved": False}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync-youtube")
|
@router.post("/sync-youtube")
|
||||||
@@ -322,8 +322,12 @@ def sync_youtube(
|
|||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
raise _refused(403, "youtube_read_scope")
|
raise _refused(403, "youtube_read_scope")
|
||||||
_check_pull_cooldown(user)
|
_check_pull_cooldown(user)
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
try:
|
||||||
return sync_user_playlists(db, user)
|
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")
|
@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
|
"""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
|
edits (and clearing the dirty flag). The user-facing 'undo all my changes' for a list
|
||||||
once the in-session undo history is gone."""
|
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:
|
if not pl.yt_playlist_id:
|
||||||
raise _refused(400, "not_on_youtube")
|
raise _refused(400, "not_on_youtube")
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
@@ -343,16 +347,16 @@ def revert_youtube(
|
|||||||
_check_pull_cooldown(user)
|
_check_pull_cooldown(user)
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
return repull_playlist(db, user, pl)
|
return {"ok": True, **repull_playlist(db, user, pl)}
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _refused(422, "youtube_failed")
|
raise youtube_refusal(exc)
|
||||||
|
|
||||||
|
|
||||||
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
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
|
"""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."""
|
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":
|
if pl.kind == "watch_later":
|
||||||
raise _refused(400, "watch_later_not_pushable")
|
raise _refused(400, "watch_later_not_pushable")
|
||||||
return pl
|
return pl
|
||||||
@@ -371,8 +375,8 @@ def push_plan(
|
|||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
plan = plan_merge(db, user, pl)
|
plan = plan_merge(db, user, pl)
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
raise _refused(422, "youtube_failed")
|
raise youtube_refusal(exc)
|
||||||
plan["remaining_today"] = quota.remaining_today(db)
|
plan["remaining_today"] = quota.remaining_today(db)
|
||||||
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
||||||
return plan
|
return plan
|
||||||
@@ -403,10 +407,10 @@ def push(
|
|||||||
plan = plan_merge(db, user, pl, live=live)
|
plan = plan_merge(db, user, pl, live=live)
|
||||||
if plan["units_estimate"] > quota.remaining_today(db):
|
if plan["units_estimate"] > quota.remaining_today(db):
|
||||||
raise _refused(429, "quota_exceeded")
|
raise _refused(429, "quota_exceeded")
|
||||||
return merge_playlist(db, user, pl, live=live)
|
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _refused(422, "youtube_failed")
|
raise youtube_refusal(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{playlist_id}")
|
@router.get("/{playlist_id}")
|
||||||
@@ -415,7 +419,7 @@ def get_playlist(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
state = aliased(VideoState)
|
state = aliased(VideoState)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(*feed_columns(user, state))
|
select(*feed_columns(user, state))
|
||||||
@@ -447,7 +451,7 @@ def rename_playlist(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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:
|
if "name" in body.model_fields_set:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
@@ -484,7 +488,7 @@ def delete_playlist(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
if pl.kind == "watch_later":
|
if pl.kind == "watch_later":
|
||||||
raise _refused(400, "watch_later_undeletable")
|
raise _refused(400, "watch_later_undeletable")
|
||||||
if on_youtube:
|
if on_youtube:
|
||||||
@@ -496,8 +500,8 @@ def delete_playlist(
|
|||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
||||||
with YouTubeClient(db, user) as yt:
|
with YouTubeClient(db, user) as yt:
|
||||||
yt.delete_playlist(pl.yt_playlist_id)
|
yt.delete_playlist(pl.yt_playlist_id)
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
raise _refused(422, "youtube_failed")
|
raise youtube_refusal(exc)
|
||||||
db.delete(pl) # items cascade
|
db.delete(pl) # items cascade
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": playlist_id}
|
return {"deleted": playlist_id}
|
||||||
@@ -510,11 +514,12 @@ def add_item(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
video_id = body.video_id
|
video_id = body.video_id
|
||||||
get_or_404(db, Video, video_id, what="video")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
_add_item(db, pl, video_id, mark_dirty=True)
|
_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}")
|
@router.delete("/{playlist_id}/items/{video_id}")
|
||||||
@@ -524,9 +529,10 @@ def remove_item(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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)
|
_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):
|
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
|
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
|
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)."""
|
(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
|
# 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.
|
# the list on screen — which is still there.
|
||||||
dst = owned_or_404(db, Playlist, body.target_playlist_id, user, what="move_target")
|
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
|
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if already is not None:
|
dst_changed = False
|
||||||
db.delete(item) # the uq_playlist_video constraint would reject a second row anyway
|
if already is None:
|
||||||
else:
|
|
||||||
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
# 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.
|
# survives the move, and there is no window where the video belongs to neither list. The
|
||||||
item.playlist_id = dst.id
|
# check above is not a lock: a second tab's add or the scheduler's YouTube merge can insert
|
||||||
item.position = _next_item_position(db, dst.id)
|
# the same (playlist, video) in between, and uq_playlist_video then refuses the re-parent.
|
||||||
# Both sides changed, so both get the dirty recompute and the timestamp — the asymmetric
|
# That is the already-present outcome arriving late, not a failure — answer it the same way
|
||||||
# version of this (touching only the source) is the recurring smell CLAUDE.md warns about.
|
# 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, src)
|
||||||
recompute_dirty(db, dst)
|
|
||||||
_touch(src)
|
_touch(src)
|
||||||
_touch(dst)
|
if dst_changed:
|
||||||
|
recompute_dirty(db, dst)
|
||||||
|
_touch(dst)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
|
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),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
order = body.video_ids
|
order = body.video_ids
|
||||||
items = {
|
items = {
|
||||||
it.video_id: it
|
it.video_id: it
|
||||||
@@ -625,4 +645,4 @@ def reorder_items(
|
|||||||
recompute_dirty(db, pl)
|
recompute_dirty(db, pl)
|
||||||
_touch(pl)
|
_touch(pl)
|
||||||
db.commit()
|
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."""
|
"""Shared helpers, serializers and constants for the plex route package."""
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
from app.models import PlexItem
|
from app.models import PlexItem
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ log = logs.get_logger(__name__)
|
|||||||
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||||
it = by_rating_key(db, PlexItem, rating_key)
|
it = by_rating_key(db, PlexItem, rating_key)
|
||||||
if it is None:
|
if it is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
raise not_found("plex_item")
|
||||||
return it
|
return it
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
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 import watch_sync as plex_watch
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
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()
|
info = plex.server_info()
|
||||||
sections = plex.sections()
|
sections = plex.sections()
|
||||||
media_check = _media_check(db, plex, sections)
|
media_check = _media_check(db, plex, sections)
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise refused(400, "plex_not_configured") from None
|
||||||
except PlexError as e:
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"server_name": info.get("friendlyName") or info.get("title1") or "Plex",
|
"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:
|
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
|
"""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."""
|
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:
|
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
|
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."""
|
"Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode."""
|
||||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
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
|
enabled = body.enabled
|
||||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||||
if link is None:
|
if link is None:
|
||||||
@@ -141,7 +149,7 @@ def watch_link_set(
|
|||||||
result: dict = {}
|
result: dict = {}
|
||||||
if enabled and not link.initial_import_done:
|
if enabled and not link.initial_import_done:
|
||||||
result = plex_watch.import_owner_watch_state(db, link)
|
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")
|
@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."""
|
intersection; Siftlode-only states are preserved. Idempotent."""
|
||||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||||
if link is None or not link.sync_enabled:
|
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)
|
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 import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -24,7 +25,12 @@ from app.plex import sync as plex_sync
|
|||||||
from app.plex.cards import movie_card, show_card
|
from app.plex.cards import movie_card, show_card
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.plex.query import content_libs, like_escape, runtime_by_key, top_art_keys
|
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
|
# 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).
|
# members (Plex shows one cert on the collection; we don't store it, so derive it).
|
||||||
@@ -233,7 +239,7 @@ def collection_detail(
|
|||||||
endpoints below."""
|
endpoints below."""
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
raise not_found("collection")
|
||||||
movies = (
|
movies = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexItem)
|
select(PlexItem)
|
||||||
@@ -324,9 +330,9 @@ def collection_detail(
|
|||||||
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
raise not_found("collection")
|
||||||
if not _collection_editable(col):
|
if not _collection_editable(col):
|
||||||
raise HTTPException(status_code=403, detail="This collection is read-only")
|
raise refused(403, "collection_read_only")
|
||||||
return col
|
return col
|
||||||
|
|
||||||
|
|
||||||
@@ -361,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
|
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
|
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."""
|
action-error toast, which is what both of these are — the app answered, it didn't go unreachable."""
|
||||||
if wrote:
|
log.warning(
|
||||||
detail = (
|
"a Plex collection write did not settle",
|
||||||
"Collection updated in Plex, but refreshing the local copy failed; "
|
extra=logs.event(
|
||||||
f"it will reconcile on the next sync ({e})"
|
"plex.collections.write_failed", wrote=wrote, reason=type(e).__name__, error=str(e)
|
||||||
)
|
),
|
||||||
else:
|
)
|
||||||
detail = f"Plex write failed: {e}"
|
return refused(422, "collection_pending_sync") if wrote else refused(422, "plex_write_failed")
|
||||||
return HTTPException(status_code=422, detail=detail)
|
|
||||||
|
|
||||||
|
|
||||||
class CreateCollectionIn(BaseModel):
|
class CreateCollectionIn(BaseModel):
|
||||||
@@ -388,7 +393,7 @@ def create_collection(
|
|||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
seed = body.item_rating_key
|
seed = body.item_rating_key
|
||||||
if not title or not seed:
|
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:
|
if library:
|
||||||
lib = (
|
lib = (
|
||||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
||||||
@@ -398,7 +403,7 @@ def create_collection(
|
|||||||
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
|
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
|
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||||
if lib is None:
|
if lib is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown library")
|
raise not_found("library")
|
||||||
_reject_duplicate_name(db, lib.id, title)
|
_reject_duplicate_name(db, lib.id, title)
|
||||||
wrote = False
|
wrote = False
|
||||||
try:
|
try:
|
||||||
@@ -472,7 +477,7 @@ def collection_rename(
|
|||||||
col = _editable_collection_or_403(db, rating_key)
|
col = _editable_collection_or_403(db, rating_key)
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
if not title:
|
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)
|
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
|
||||||
lib = db.get(PlexLibrary, col.library_id)
|
lib = db.get(PlexLibrary, col.library_id)
|
||||||
wrote = False
|
wrote = False
|
||||||
@@ -501,7 +506,7 @@ def collection_delete(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
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}
|
return {"deleted": rating_key}
|
||||||
|
|
||||||
|
|
||||||
@@ -520,13 +525,10 @@ def collection_set_editable(
|
|||||||
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
|
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
raise not_found("collection")
|
||||||
want = body.editable
|
want = body.editable
|
||||||
if want and (col.smart or _collection_source(col) != "collection"):
|
if want and (col.smart or _collection_source(col) != "collection"):
|
||||||
raise HTTPException(
|
raise refused(400, "collection_smart")
|
||||||
status_code=400,
|
|
||||||
detail="Smart / auto-list collections can't be made editable",
|
|
||||||
)
|
|
||||||
col.editable = want
|
col.editable = want
|
||||||
db.commit()
|
db.commit()
|
||||||
return _collection_card(col)
|
return _collection_card(col)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import time
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
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.plex.lookups import by_rating_key
|
||||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
from app.routes.plex._shared import _norm_lang, item_or_404
|
||||||
from app.routes.plex.collections import _collection_source
|
from app.routes.plex.collections import _collection_source
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ def show_detail(
|
|||||||
hu = _norm_lang(lang) == "hu"
|
hu = _norm_lang(lang) == "hu"
|
||||||
sh = by_rating_key(db, PlexShow, rating_key)
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
raise not_found("show")
|
||||||
seasons = (
|
seasons = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexSeason)
|
select(PlexSeason)
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ from app.plex.query import (
|
|||||||
meta_filter_conds,
|
meta_filter_conds,
|
||||||
top_art_keys,
|
top_art_keys,
|
||||||
)
|
)
|
||||||
|
from app.discover.queries import RAIL_LIMIT
|
||||||
from app.plex.tmdb import resolve_recs
|
from app.plex.tmdb import resolve_recs
|
||||||
from app.routes.plex._shared import _norm_lang
|
from app.routes.plex._shared import _norm_lang
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
RAIL_LIMIT = 20
|
|
||||||
GENRE_TILES = 12
|
GENRE_TILES = 12
|
||||||
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
|
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 fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import current_user, resolved_user_id
|
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 import watch_sync as plex_watch
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.routes.plex._shared import item_or_404
|
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"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ def _require_session(request: Request) -> int:
|
|||||||
low-sensitivity; a valid session is enough."""
|
low-sensitivity; a valid session is enough."""
|
||||||
uid, _ = resolved_user_id(request)
|
uid, _ = resolved_user_id(request)
|
||||||
if not uid:
|
if not uid:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
|
||||||
@@ -77,15 +80,17 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
|||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
key = imgcache.key_for(db, str(rating_key), v)
|
key = imgcache.key_for(db, str(rating_key), v)
|
||||||
if not key:
|
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
|
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise refused(400, "plex_not_configured") from None
|
||||||
try:
|
try:
|
||||||
with plex:
|
with plex:
|
||||||
data, ct = plex.image_bytes(key, w, h)
|
data, ct = plex.image_bytes(key, w, h)
|
||||||
except PlexError as e:
|
# silent-ok: a byte proxy for an <img>; the browser shows the placeholder and no reader sees the
|
||||||
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
# 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)
|
imgcache.write(cache_key, data, ct)
|
||||||
return imgcache.response(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
|
`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."""
|
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}")
|
@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."""
|
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
|
||||||
_require_session(request)
|
_require_session(request)
|
||||||
if not u.startswith(plex_people.PERSON_IMG_HOST):
|
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()
|
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
|
||||||
hit = imgcache.read(cache_key)
|
hit = imgcache.read(cache_key)
|
||||||
if hit:
|
if hit:
|
||||||
@@ -223,7 +228,7 @@ def person_image(u: str, request: Request) -> Response:
|
|||||||
r = c.get(u)
|
r = c.get(u)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
except httpx.HTTPError:
|
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")
|
ct = r.headers.get("content-type", "image/jpeg")
|
||||||
imgcache.write(cache_key, r.content, ct)
|
imgcache.write(cache_key, r.content, ct)
|
||||||
return imgcache.response(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):
|
class ItemProgressIn(BaseModel):
|
||||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
||||||
# one into a 500.
|
# 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
|
# 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
|
# 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.
|
# 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))
|
position = max(0, int(body.position_seconds))
|
||||||
duration = int(body.duration_seconds)
|
duration = int(body.duration_seconds)
|
||||||
final = body.final
|
final = body.final
|
||||||
now = datetime.now(timezone.utc)
|
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 = (
|
st = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
||||||
@@ -342,28 +394,6 @@ def item_progress(
|
|||||||
.scalars()
|
.scalars()
|
||||||
.first()
|
.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 position < _PROGRESS_MIN_S:
|
||||||
if st is not None and st.status == "new":
|
if st is not None and st.status == "new":
|
||||||
@@ -372,7 +402,7 @@ def item_progress(
|
|||||||
st.position_seconds = 0
|
st.position_seconds = 0
|
||||||
st.progress_updated_at = now
|
st.progress_updated_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"position_seconds": 0}
|
return {"ok": True, "position_seconds": 0}
|
||||||
|
|
||||||
if st is None:
|
if st is None:
|
||||||
st = PlexState(user_id=user.id, item_id=it.id)
|
st = PlexState(user_id=user.id, item_id=it.id)
|
||||||
@@ -397,7 +427,7 @@ def item_progress(
|
|||||||
duration * 1000,
|
duration * 1000,
|
||||||
intent=now,
|
intent=now,
|
||||||
)
|
)
|
||||||
return {"position_seconds": position}
|
return {"ok": True, "position_seconds": position}
|
||||||
|
|
||||||
|
|
||||||
class ItemStateIn(BaseModel):
|
class ItemStateIn(BaseModel):
|
||||||
@@ -418,7 +448,7 @@ def item_state(
|
|||||||
it = item_or_404(db, rating_key)
|
it = item_or_404(db, rating_key)
|
||||||
status = body.status
|
status = body.status
|
||||||
if status not in ("new", "watched", "hidden"):
|
if status not in ("new", "watched", "hidden"):
|
||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
raise refused(400, "invalid_status")
|
||||||
st = (
|
st = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
||||||
@@ -443,7 +473,7 @@ def item_state(
|
|||||||
db.commit()
|
db.commit()
|
||||||
if mirrored_on_plex:
|
if mirrored_on_plex:
|
||||||
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
||||||
return {"status": "new"}
|
return {"ok": True, "status": "new"}
|
||||||
if st is None:
|
if st is None:
|
||||||
st = PlexState(user_id=user.id, item_id=it.id)
|
st = PlexState(user_id=user.id, item_id=it.id)
|
||||||
db.add(st)
|
db.add(st)
|
||||||
@@ -455,7 +485,7 @@ def item_state(
|
|||||||
db.commit()
|
db.commit()
|
||||||
if status == "watched" and prev_status != "watched":
|
if status == "watched" and prev_status != "watched":
|
||||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||||
return {"status": status}
|
return {"ok": True, "status": status}
|
||||||
|
|
||||||
|
|
||||||
def _bulk_state(
|
def _bulk_state(
|
||||||
@@ -497,7 +527,9 @@ def _bulk_state(
|
|||||||
db.delete(st)
|
db.delete(st)
|
||||||
if prev != "new":
|
if prev != "new":
|
||||||
to_push.append((e.id, e.rating_key, "unwatched"))
|
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:
|
if pushable and to_push:
|
||||||
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
# 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.
|
# 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."""
|
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
|
||||||
sh = by_rating_key(db, PlexShow, rating_key)
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
raise not_found("show")
|
||||||
watched = body.watched
|
watched = body.watched
|
||||||
eps = (
|
eps = (
|
||||||
db.execute(select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode"))
|
db.execute(select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode"))
|
||||||
@@ -528,7 +560,8 @@ def show_state(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
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")
|
@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."""
|
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
|
||||||
se = by_rating_key(db, PlexSeason, rating_key)
|
se = by_rating_key(db, PlexSeason, rating_key)
|
||||||
if se is None:
|
if se is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex season")
|
raise not_found("season")
|
||||||
watched = body.watched
|
watched = body.watched
|
||||||
eps = (
|
eps = (
|
||||||
db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode"))
|
db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode"))
|
||||||
@@ -550,7 +583,8 @@ def season_state(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
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")
|
@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.
|
# 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:
|
if it.playable == "direct" and audio is None and not aoff and not multi:
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"mode": "direct",
|
"mode": "direct",
|
||||||
"url": f"/api/plex/stream/{it.rating_key}/file",
|
"url": f"/api/plex/stream/{it.rating_key}/file",
|
||||||
"start_s": 0.0,
|
"start_s": 0.0,
|
||||||
}
|
}
|
||||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise refused(404, "media_missing")
|
||||||
s = plex_stream.start_session(db, it, start, audio, aoff, multi, transcode=transcode)
|
s = plex_stream.start_session(db, it, user.id, start, audio, aoff, multi, transcode=transcode)
|
||||||
if s is None:
|
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
|
# 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.
|
# 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
|
# `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
|
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
|
||||||
# segment streams are otherwise identical.
|
# segment streams are otherwise identical.
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"mode": "hls",
|
"mode": "hls",
|
||||||
"start_s": s.media_start_s,
|
"start_s": s.media_start_s,
|
||||||
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
|
# 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)
|
it = item_or_404(db, rating_key)
|
||||||
src = plex_paths.local_media_path(db, it.file_path)
|
src = plex_paths.local_media_path(db, it.file_path)
|
||||||
if src is None:
|
if src is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise refused(404, "media_missing")
|
||||||
if download:
|
if download:
|
||||||
# A clean, safe download name from the title + the real file extension.
|
# A clean, safe download name from the title + the real file extension.
|
||||||
ext = src.suffix or (f".{it.container}" if it.container else "")
|
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.
|
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
|
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
|
(viewer, rating_key) and a seek-restart replaces the viewer's registered one, while the DIRECTORY
|
||||||
one, while the DIRECTORY is per (key, offset, tag) — so resolving by rating_key handed the old
|
is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new
|
||||||
viewer the new session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
|
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
|
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.
|
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
|
||||||
or ".." in filename
|
or ".." in filename
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise not_found("file")
|
||||||
s = plex_stream.current_session(str(rating_key))
|
s = plex_stream.current_session(user.id, str(rating_key))
|
||||||
if s is None or s.id != session_id:
|
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
|
path = s.dir / filename
|
||||||
# Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
|
# 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
|
# 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
|
# past the produced region — or a deliberate flood of hopeless names — from parking threadpool
|
||||||
# workers and their DB connections (see `artifact_may_arrive`).
|
# workers and their DB connections (see `artifact_may_arrive`).
|
||||||
if not plex_stream.artifact_may_arrive(s, filename):
|
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
|
timeout = 30 if ext == ".m3u8" else 20
|
||||||
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
|
# `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
|
# 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
|
# 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).
|
# past the produced region was enough to do it (round 5).
|
||||||
if not plex_stream.wait_for(path, timeout, proc=s.proc):
|
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:
|
try:
|
||||||
content = path.read_bytes()
|
content = path.read_bytes()
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -699,7 +735,7 @@ def stream_hls_file(
|
|||||||
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
|
# 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
|
# 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.
|
# 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(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
media_type=_HLS_CT[ext],
|
media_type=_HLS_CT[ext],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import delete, func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -19,15 +19,12 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.plex.cards import leaf_card
|
from app.plex.cards import leaf_card
|
||||||
from app.plex.lookups import by_rating_key
|
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"])
|
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:
|
def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": pl.id,
|
"id": pl.id,
|
||||||
@@ -120,7 +117,7 @@ def create_playlist(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
if not title:
|
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)
|
pl = PlexPlaylist(user_id=user.id, title=title)
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -140,7 +137,7 @@ def create_playlist(
|
|||||||
def playlist_detail(
|
def playlist_detail(
|
||||||
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> 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()
|
rows = db.execute(_playlist_item_query(pid)).all()
|
||||||
items = [it for (_pi, it) in rows]
|
items = [it for (_pi, it) in rows]
|
||||||
states = {
|
states = {
|
||||||
@@ -183,10 +180,10 @@ def rename_playlist(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist_or_404(db, pid, user)
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
if not title:
|
if not title:
|
||||||
raise HTTPException(status_code=400, detail="title is required")
|
raise refused(400, "title_required")
|
||||||
pl.title = title
|
pl.title = title
|
||||||
db.commit()
|
db.commit()
|
||||||
count = (
|
count = (
|
||||||
@@ -203,7 +200,7 @@ def rename_playlist(
|
|||||||
def delete_playlist(
|
def delete_playlist(
|
||||||
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist_or_404(db, pid, user)
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||||
db.delete(pl)
|
db.delete(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": pid}
|
return {"deleted": pid}
|
||||||
@@ -220,10 +217,10 @@ def playlist_add_item(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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)
|
it = by_rating_key(db, PlexItem, body.item_rating_key)
|
||||||
if it is None:
|
if it is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown item")
|
raise not_found("item")
|
||||||
exists = (
|
exists = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexPlaylistItem).where(
|
select(PlexPlaylistItem).where(
|
||||||
@@ -252,7 +249,7 @@ def playlist_remove_item(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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)
|
it = by_rating_key(db, PlexItem, item_rating_key)
|
||||||
if it is not None:
|
if it is not None:
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -297,7 +294,7 @@ def playlist_add_bulk(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
|
"""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."""
|
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)
|
items = _items_by_rks(db, body.item_rating_keys)
|
||||||
existing = set(
|
existing = set(
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -328,7 +325,7 @@ def playlist_add_bulk(
|
|||||||
)
|
)
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
return {"added": added, "item_count": total}
|
return {"ok": True, "added": added, "item_count": total}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/playlists/{pid}/items/remove-bulk")
|
@router.post("/playlists/{pid}/items/remove-bulk")
|
||||||
@@ -340,7 +337,7 @@ def playlist_remove_bulk(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
|
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
|
||||||
removed + the new total."""
|
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)]
|
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
|
||||||
removed = 0
|
removed = 0
|
||||||
if ids:
|
if ids:
|
||||||
@@ -359,7 +356,7 @@ def playlist_remove_bulk(
|
|||||||
)
|
)
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
return {"removed": removed, "item_count": total}
|
return {"ok": True, "removed": removed, "item_count": total}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/playlists/{pid}/order")
|
@router.put("/playlists/{pid}/order")
|
||||||
@@ -370,7 +367,7 @@ def reorder_playlist(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
|
"""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
|
order = body.item_rating_keys
|
||||||
id_by_rk = {
|
id_by_rk = {
|
||||||
it.rating_key: it.id
|
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).
|
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 fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -19,6 +19,8 @@ from app.downloads import storage
|
|||||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||||
from app.ratelimit import RateLimiter, client_key
|
from app.ratelimit import RateLimiter, client_key
|
||||||
from app.security import verify_password
|
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"])
|
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:
|
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||||
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
||||||
if link is None or linksmod.is_expired(link):
|
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
|
return link
|
||||||
|
|
||||||
|
|
||||||
@@ -45,12 +47,12 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
|
|||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
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:
|
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
|
# 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.
|
# loads a player for a missing file — meta and file agree.
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
raise refused(404, "video_gone")
|
||||||
return job, asset
|
return job, asset
|
||||||
|
|
||||||
|
|
||||||
@@ -100,22 +102,24 @@ def watch_unlock(
|
|||||||
link = _link_or_404(db, token)
|
link = _link_or_404(db, token)
|
||||||
viewer = f"{token}|{client_key(request)}"
|
viewer = f"{token}|{client_key(request)}"
|
||||||
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
|
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:
|
if not link.password_hash:
|
||||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"needs_password": False,
|
"needs_password": False,
|
||||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||||
}
|
}
|
||||||
if not verify_password(body.password, link.password_hash):
|
if not verify_password(body.password, link.password_hash):
|
||||||
_unlock_failures.allow(token)
|
_unlock_failures.allow(token)
|
||||||
raise HTTPException(status_code=403, detail="Wrong password.")
|
raise refused(403, "wrong_share_password")
|
||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
link.view_count += 1
|
link.view_count += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
grant = linksmod.make_grant(token, link.password_version)
|
grant = linksmod.make_grant(token, link.password_version)
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"needs_password": False,
|
"needs_password": False,
|
||||||
**linksmod.public_meta(
|
**linksmod.public_meta(
|
||||||
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
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."""
|
`allow_download` link serves it as an attachment. Password links require a valid grant."""
|
||||||
link = _link_or_404(db, token)
|
link = _link_or_404(db, token)
|
||||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
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)
|
asset = _ready_asset(db, link)
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||||
if path is None:
|
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)
|
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
|
# 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."""
|
and the link-preview image). Password links require a valid grant, like the file."""
|
||||||
link = _link_or_404(db, token)
|
link = _link_or_404(db, token)
|
||||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
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)
|
job, asset = _ready_target(db, link)
|
||||||
if not asset.poster_path:
|
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)
|
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="No poster.")
|
raise not_found("poster")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -8,6 +8,7 @@ from app.auth import require_human
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import SavedView, User
|
from app.models import SavedView, User
|
||||||
from app.routes._common import owned_or_404
|
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
|
# 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
|
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list
|
||||||
@@ -66,7 +67,7 @@ def create_view(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name is required")
|
raise refused(400, "name_required")
|
||||||
source = _norm_source(body.source)
|
source = _norm_source(body.source)
|
||||||
# Position is per (user, source), so each module's views number 0..N independently.
|
# Position is per (user, source), so each module's views number 0..N independently.
|
||||||
maxpos = db.execute(
|
maxpos = db.execute(
|
||||||
@@ -86,14 +87,10 @@ def create_view(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
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)
|
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):
|
class UpdateViewIn(BaseModel):
|
||||||
# PATCH: presence decides what's touched (read via model_fields_set). filters, when sent,
|
# 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).
|
# 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),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
view = _own_view(db, user, view_id)
|
view = owned_or_404(db, SavedView, view_id, user, what="view")
|
||||||
sent = body.model_fields_set
|
sent = body.model_fields_set
|
||||||
if "name" in sent:
|
if "name" in sent:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
raise refused(400, "name_required")
|
||||||
view.name = name[:80]
|
view.name = name[:80]
|
||||||
if "filters" in sent:
|
if "filters" in sent:
|
||||||
if body.filters is None:
|
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
|
view.filters = body.filters
|
||||||
if "is_default" in sent:
|
if "is_default" in sent:
|
||||||
make_default = bool(body.is_default)
|
make_default = bool(body.is_default)
|
||||||
@@ -138,7 +135,7 @@ def update_view(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
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)
|
return _serialize(view)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,7 +145,7 @@ def delete_view(
|
|||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
view = _own_view(db, user, view_id)
|
view = owned_or_404(db, SavedView, view_id, user, what="view")
|
||||||
db.delete(view)
|
db.delete(view)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": view_id}
|
return {"deleted": view_id}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
|
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
|
||||||
and the shared quota picture."""
|
and the shared quota picture."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -25,6 +25,8 @@ from app.scheduler import (
|
|||||||
from app.sync.explore import followed_channel
|
from app.sync.explore import followed_channel
|
||||||
from app.sync.runner import estimate_deep_backfill
|
from app.sync.runner import estimate_deep_backfill
|
||||||
from app.routes._common import not_found
|
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"])
|
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
||||||
|
|
||||||
@@ -46,10 +48,7 @@ def set_job_interval(
|
|||||||
raise not_found("job")
|
raise not_found("job")
|
||||||
minutes = body.interval_minutes
|
minutes = body.interval_minutes
|
||||||
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
||||||
raise HTTPException(
|
raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL)
|
||||||
status_code=400,
|
|
||||||
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
|
|
||||||
)
|
|
||||||
applied = apply_interval(db, job_id, minutes)
|
applied = apply_interval(db, job_id, minutes)
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -61,7 +60,7 @@ def set_job_interval(
|
|||||||
after={"interval_minutes": applied},
|
after={"interval_minutes": applied},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"id": job_id, "interval_minutes": applied}
|
return {"ok": True, "id": job_id, "interval_minutes": applied}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jobs/{job_id}/run")
|
@router.post("/jobs/{job_id}/run")
|
||||||
@@ -77,14 +76,14 @@ def run_job_now(
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise not_found("job")
|
raise not_found("job")
|
||||||
if result == "already_running":
|
if result == "already_running":
|
||||||
raise HTTPException(status_code=409, detail="Job is already running")
|
raise refused(409, "job_running")
|
||||||
return {"id": job_id, "started": True}
|
return {"ok": True, "id": job_id, "started": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/run-all")
|
@router.post("/run-all")
|
||||||
def run_all_now(user: User = Depends(admin_user)) -> dict:
|
def run_all_now(user: User = Depends(admin_user)) -> dict:
|
||||||
"""Trigger every job that isn't already running. Returns the ids actually started."""
|
"""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):
|
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.
|
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
|
||||||
try:
|
try:
|
||||||
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
||||||
except (TypeError, ValueError) as exc:
|
except sysconfig.InvalidSetting as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
raise setting_refused(exc) from None
|
||||||
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -113,7 +112,7 @@ def set_maintenance_settings(
|
|||||||
after={"revalidate_batch": new},
|
after={"revalidate_batch": new},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"revalidate_batch": new}
|
return {"ok": True, "revalidate_batch": new}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ dropped from the search response up front)."""
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, delete, func, or_, select, update
|
from sqlalchemy import and_, delete, func, or_, select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
@@ -38,11 +38,14 @@ from app.models import (
|
|||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
from app.feedquery import feed_columns, serialize
|
from app.feedquery import feed_columns, serialize
|
||||||
|
from app.utils import like_escape
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details
|
||||||
from app.sync.videos import insert_stubs, apply_video_details, parse_dt
|
from app.sync.videos import insert_stubs, apply_video_details, parse_dt
|
||||||
from app.youtube import search_scrape
|
from app.youtube import search_scrape
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
from app.youtube.shorts import make_client, probe_is_short
|
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__)
|
log = logs.get_logger(__name__)
|
||||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||||
@@ -224,6 +227,25 @@ def _new_first(db: Session, user: User, ids: list[str]) -> list[str]:
|
|||||||
return [i for i in ids if i not in have] + [i for i in ids if i in have]
|
return [i for i in ids if i not in have] + [i for i in ids if i in have]
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_term(db: Session, video_ids: list[str], term: str) -> None:
|
||||||
|
"""Append `term` to each video's space-joined `search_terms`, unless it is already one of them.
|
||||||
|
|
||||||
|
"Already one of them" is a whole-term match, as a literal: a substring test skipped `cat` on a
|
||||||
|
video already found for `category`, and an unescaped one read the `_` in `my_channel` as a
|
||||||
|
wildcard — either way the term was never folded in, and local search lost that relevance."""
|
||||||
|
padded = func.concat(" ", func.coalesce(Video.search_terms, ""), " ")
|
||||||
|
db.execute(
|
||||||
|
update(Video)
|
||||||
|
.where(Video.id.in_(video_ids))
|
||||||
|
.where(padded.notilike(f"% {like_escape(term)} %"))
|
||||||
|
.values(
|
||||||
|
search_terms=func.trim(
|
||||||
|
func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/youtube")
|
@router.get("/youtube")
|
||||||
def search_youtube(
|
def search_youtube(
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
@@ -238,7 +260,7 @@ def search_youtube(
|
|||||||
page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further."""
|
page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further."""
|
||||||
term = (q or "").strip()
|
term = (q or "").strip()
|
||||||
if not term:
|
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))
|
limit = max(1, min(limit, 100))
|
||||||
|
|
||||||
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
||||||
@@ -255,20 +277,14 @@ def search_youtube(
|
|||||||
"a live search was refused: the per-user daily cap is spent",
|
"a live search was refused: the per-user daily cap is spent",
|
||||||
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit),
|
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit),
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise refused(429, "search_daily_limit", limit=daily_limit)
|
||||||
status_code=429,
|
|
||||||
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
|
|
||||||
)
|
|
||||||
# The 100-unit budget pre-check only applies to the API source.
|
# The 100-unit budget pre-check only applies to the API source.
|
||||||
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
||||||
log.info(
|
log.info(
|
||||||
"a live search was refused: the shared YouTube budget is spent",
|
"a live search was refused: the shared YouTube budget is spent",
|
||||||
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
|
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise refused(429, "quota_spent")
|
||||||
status_code=429,
|
|
||||||
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
|
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
|
||||||
blocked = set(
|
blocked = set(
|
||||||
@@ -293,9 +309,14 @@ def search_youtube(
|
|||||||
# dropout (auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY),
|
# 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
|
# 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)
|
# on a JSON endpoint = 422 + message; reserve 5xx for the raw byte-proxies. (B-core-1)
|
||||||
raise HTTPException(
|
# A Data API failure is logged where it happened; the scraper's is not, and its text
|
||||||
status_code=422, detail=f"YouTube search failed: {exc}"
|
# no longer rides the answer — so it is said here.
|
||||||
) from exc
|
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.
|
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
|
||||||
candidates = [
|
candidates = [
|
||||||
@@ -337,9 +358,7 @@ def search_youtube(
|
|||||||
)
|
)
|
||||||
if collected:
|
if collected:
|
||||||
break # keep what earlier pages already gathered
|
break # keep what earlier pages already gathered
|
||||||
raise HTTPException(
|
raise youtube_refusal(exc) from exc
|
||||||
status_code=422, detail=f"YouTube search failed: {exc}"
|
|
||||||
) from exc
|
|
||||||
next_cursor = page["next_page_token"]
|
next_cursor = page["next_page_token"]
|
||||||
# The API source pages cost 100 units each — never auto-page; one page per request.
|
# 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:
|
if source == "api" or len(collected) >= limit or not next_cursor:
|
||||||
@@ -364,16 +383,7 @@ def search_youtube(
|
|||||||
.values([{"user_id": user.id, "video_id": vid} for vid in ordered])
|
.values([{"user_id": user.id, "video_id": vid} for vid in ordered])
|
||||||
.on_conflict_do_nothing(index_elements=["user_id", "video_id"])
|
.on_conflict_do_nothing(index_elements=["user_id", "video_id"])
|
||||||
)
|
)
|
||||||
db.execute(
|
_fold_term(db, ordered, term)
|
||||||
update(Video)
|
|
||||||
.where(Video.id.in_(ordered))
|
|
||||||
.where(func.coalesce(Video.search_terms, "").notilike(f"%{term}%"))
|
|
||||||
.values(
|
|
||||||
search_terms=func.trim(
|
|
||||||
func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
ordered = _new_first(db, user, ordered)
|
ordered = _new_first(db, user, ordered)
|
||||||
@@ -401,7 +411,7 @@ def clear_search(
|
|||||||
find. Kept videos and organically-owned ones are left untouched."""
|
find. Kept videos and organically-owned ones are left untouched."""
|
||||||
ids = body.video_ids
|
ids = body.video_ids
|
||||||
if not ids:
|
if not ids:
|
||||||
return {"removed": 0}
|
return {"ok": True, "removed": 0}
|
||||||
db.execute(
|
db.execute(
|
||||||
delete(SearchFind).where(SearchFind.user_id == user.id, SearchFind.video_id.in_(ids))
|
delete(SearchFind).where(SearchFind.user_id == user.id, SearchFind.video_id.in_(ids))
|
||||||
)
|
)
|
||||||
@@ -421,4 +431,4 @@ def clear_search(
|
|||||||
)
|
)
|
||||||
).rowcount
|
).rowcount
|
||||||
db.commit()
|
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 typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -21,6 +21,8 @@ from app.auth import PASSWORD_MIN_LEN
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.refusal import setting_refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||||
|
|
||||||
@@ -31,9 +33,9 @@ def require_setup(
|
|||||||
) -> Session:
|
) -> Session:
|
||||||
"""Gate for setup steps. Returns the db session for the handler to reuse."""
|
"""Gate for setup steps. Returns the db session for the handler to reuse."""
|
||||||
if state.is_configured(db):
|
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):
|
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
|
return db
|
||||||
|
|
||||||
|
|
||||||
@@ -66,11 +68,9 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
|
|||||||
email = body.email.strip().lower()
|
email = body.email.strip().lower()
|
||||||
password = body.password
|
password = body.password
|
||||||
if "@" not in email:
|
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:
|
if len(password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||||
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
|
|
||||||
)
|
|
||||||
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
|
# 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
|
# 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
|
# 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] = []
|
saved: list[str] = []
|
||||||
for key, value in settings_map.items():
|
for key, value in settings_map.items():
|
||||||
if sysconfig.spec(key) is None:
|
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 == "":
|
if value is None or value == "":
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
sysconfig.set_value(db, key, value)
|
sysconfig.set_value(db, key, value)
|
||||||
except (ValueError, RuntimeError) as exc:
|
except sysconfig.InvalidSetting as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise setting_refused(exc) from None
|
||||||
saved.append(key)
|
saved.append(key)
|
||||||
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
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):
|
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."""
|
"""Send a test email to confirm the SMTP settings the wizard just saved."""
|
||||||
to = body.to.strip()
|
to = body.to.strip()
|
||||||
if "@" not in to:
|
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)):
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +133,6 @@ def setup_finish(db: Session = Depends(require_setup)) -> dict:
|
|||||||
invalidates the setup token and disables the wizard)."""
|
invalidates the setup token and disables the wizard)."""
|
||||||
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
|
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
|
||||||
if not has_admin:
|
if not has_admin:
|
||||||
raise HTTPException(status_code=400, detail="Create the admin account first.")
|
raise refused(400, "admin_first")
|
||||||
state.mark_configured(db)
|
state.mark_configured(db)
|
||||||
return {"ok": True}
|
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.auth import admin_user, current_user, has_read_scope, require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
|
from app.routes._common import youtube_refusal
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
from app.sync.runner import estimate_deep_backfill
|
from app.sync.runner import estimate_deep_backfill
|
||||||
from app.sync.subscriptions import import_subscriptions
|
from app.sync.subscriptions import import_subscriptions
|
||||||
from app.scheduler import running_job_ids
|
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"])
|
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:
|
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
||||||
raise HTTPException(
|
raise refused(403, "youtube_read_scope")
|
||||||
status_code=403,
|
|
||||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
|
||||||
)
|
|
||||||
if not _sync_limiter.allow(str(user.id)):
|
if not _sync_limiter.allow(str(user.id)):
|
||||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||||
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
try:
|
||||||
result = import_subscriptions(db, user)
|
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_used_estimate"] = m.used
|
||||||
result["quota_remaining_today"] = m.remaining
|
result["quota_remaining_today"] = m.remaining
|
||||||
return result
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
@@ -137,7 +143,7 @@ def deep_all(
|
|||||||
update(Subscription).where(Subscription.user_id == user.id).values(deep_requested=on)
|
update(Subscription).where(Subscription.user_id == user.id).values(deep_requested=on)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"updated": result.rowcount, "deep_requested": on}
|
return {"ok": True, "updated": result.rowcount, "deep_requested": on}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pause")
|
@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)
|
state.set_sync_paused(db, True)
|
||||||
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
|
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"paused": True}
|
return {"ok": True, "paused": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resume")
|
@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)
|
state.set_sync_paused(db, False)
|
||||||
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
|
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
|
||||||
db.commit()
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -9,6 +9,7 @@ from app.db import get_db
|
|||||||
from app.models import ChannelTag, Tag, User
|
from app.models import ChannelTag, Tag, User
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404
|
||||||
from app.sync.tagsuggest import compute_tag_suggestions
|
from app.sync.tagsuggest import compute_tag_suggestions
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ def create_tag(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
if not name:
|
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.
|
# 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"
|
category = body.category if body.category in ("language", "topic", "other") else "other"
|
||||||
tag = Tag(
|
tag = Tag(
|
||||||
@@ -74,13 +75,14 @@ def create_tag(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=409, detail=f"You already have a tag named “{name}”.")
|
raise refused(409, "tag_name_taken", name=name)
|
||||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": tag.id,
|
||||||
def _own_tag(db: Session, user: User, tag_id: int) -> Tag:
|
"name": tag.name,
|
||||||
# System tags (user_id NULL) are not user-editable → owned_or_404 404s them too.
|
"color": tag.color,
|
||||||
return owned_or_404(db, Tag, tag_id, user, what="tag")
|
"category": tag.category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TagUpdateIn(BaseModel):
|
class TagUpdateIn(BaseModel):
|
||||||
@@ -97,12 +99,13 @@ def update_tag(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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
|
sent = body.model_fields_set
|
||||||
if "name" in sent:
|
if "name" in sent:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
raise refused(400, "name_required")
|
||||||
tag.name = name
|
tag.name = name
|
||||||
if "color" in sent:
|
if "color" in sent:
|
||||||
tag.color = body.color
|
tag.color = body.color
|
||||||
@@ -110,8 +113,14 @@ def update_tag(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=409, detail=f"You already have a tag named “{tag.name}”.")
|
raise refused(409, "tag_name_taken", name=tag.name)
|
||||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": tag.id,
|
||||||
|
"name": tag.name,
|
||||||
|
"color": tag.color,
|
||||||
|
"category": tag.category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tag_id}")
|
@router.delete("/{tag_id}")
|
||||||
@@ -120,7 +129,8 @@ def delete_tag(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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.delete(tag) # ChannelTag rows cascade on tag delete.
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": tag_id}
|
return {"deleted": tag_id}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ def _job(name: str, fn) -> None:
|
|||||||
_audit_run(db, actor_id, name, "ok", summary, result)
|
_audit_run(db, actor_id, name, "ok", summary, result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.exception("job %s failed", name)
|
log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name))
|
||||||
err = str(exc) or exc.__class__.__name__
|
err = str(exc) or exc.__class__.__name__
|
||||||
_record(name, status="error", last_finished=_now_iso(), last_error=err, progress=None)
|
_record(name, status="error", last_finished=_now_iso(), last_error=err, progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
|
|||||||
@@ -670,11 +670,19 @@ def sync_all_playlists(db: Session) -> dict:
|
|||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
total += sync_user_playlists(db, user).get("synced", 0)
|
total += sync_user_playlists(db, user).get("synced", 0)
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.warning("Playlist sync failed for user %s", user.id)
|
log.warning(
|
||||||
|
"Playlist sync failed for user %s",
|
||||||
|
user.id,
|
||||||
|
extra=logs.event("sync.playlists.failed", user_id=user.id, reason=exc.reason),
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.exception("Playlist sync crashed for user %s", user.id)
|
log.exception(
|
||||||
|
"Playlist sync crashed for user %s",
|
||||||
|
user.id,
|
||||||
|
extra=logs.event("sync.playlists.crashed", user_id=user.id),
|
||||||
|
)
|
||||||
progress.report(i, len(users), "playlist_sync")
|
progress.report(i, len(users), "playlist_sync")
|
||||||
return {"users": len(users), "playlists_synced": total}
|
return {"users": len(users), "playlists_synced": total}
|
||||||
|
|||||||
+126
-5
@@ -9,8 +9,9 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, or_, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, progress, quota, sysconfig
|
from app import logs, progress, quota, sysconfig
|
||||||
@@ -25,11 +26,13 @@ from app.sync.videos import (
|
|||||||
backfill_channel_deep,
|
backfill_channel_deep,
|
||||||
backfill_channel_recent,
|
backfill_channel_recent,
|
||||||
enrich_pending,
|
enrich_pending,
|
||||||
|
insert_stubs,
|
||||||
reconcile_full_history,
|
reconcile_full_history,
|
||||||
refresh_live,
|
refresh_live,
|
||||||
run_shorts_classification,
|
run_shorts_classification,
|
||||||
|
stub_from_playlist_item,
|
||||||
)
|
)
|
||||||
from app.youtube.client import YouTubeClient
|
from app.youtube.client import YouTubeClient, YouTubeError, is_quota_refusal
|
||||||
from app.youtube.rss import RssError, fetch_channel_feed
|
from app.youtube.rss import RssError, fetch_channel_feed
|
||||||
|
|
||||||
# RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded.
|
# RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded.
|
||||||
@@ -85,6 +88,9 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
|||||||
# of 2026-09-16 into 11k warnings in four hours; each channel's own line is DEBUG now.
|
# of 2026-09-16 into 11k warnings in four hours; each channel's own line is DEBUG now.
|
||||||
reasons: Counter[str] = Counter()
|
reasons: Counter[str] = Counter()
|
||||||
examples: list[str] = []
|
examples: list[str] = []
|
||||||
|
# Channels whose FEED would not answer (not those whose rows failed to apply: the API would
|
||||||
|
# hand the same rows to the same broken write) — the feed fallback's candidates.
|
||||||
|
unread: list[str] = []
|
||||||
|
|
||||||
def fail(reason: str, detail: str, *, unexpected: str | None = None) -> None:
|
def fail(reason: str, detail: str, *, unexpected: str | None = None) -> None:
|
||||||
"""Count a channel that did not get polled. `unexpected` is the message for a failure that
|
"""Count a channel that did not get polled. `unexpected` is the message for a failure that
|
||||||
@@ -120,6 +126,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
|||||||
except RssError as exc:
|
except RssError as exc:
|
||||||
log.debug("RSS poll skipped for channel %s: %s", channel.id, exc)
|
log.debug("RSS poll skipped for channel %s: %s", channel.id, exc)
|
||||||
fail(exc.reason, f"{channel.id}: {exc}")
|
fail(exc.reason, f"{channel.id}: {exc}")
|
||||||
|
unread.append(channel.id)
|
||||||
# silent-ok: `fail(unexpected=…)` is the report — it logs at ERROR with the
|
# silent-ok: `fail(unexpected=…)` is the report — it logs at ERROR with the
|
||||||
# traceback, once per reason per poll, which is the flood control this loop needs.
|
# traceback, once per reason per poll, which is the flood control this loop needs.
|
||||||
except Exception as exc: # noqa: BLE001 — one channel must not stop the poll
|
except Exception as exc: # noqa: BLE001 — one channel must not stop the poll
|
||||||
@@ -143,17 +150,24 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
|||||||
# One place that advances the counter, whatever happened above.
|
# One place that advances the counter, whatever happened above.
|
||||||
done += 1
|
done += 1
|
||||||
progress.report(done, total, "rss_poll")
|
progress.report(done, total, "rss_poll")
|
||||||
|
fallback = run_feed_fallback(db, unread)
|
||||||
if failed:
|
if failed:
|
||||||
|
# The fallback's count rides on this line because this is the one the stored log keeps (an
|
||||||
|
# INFO line outside a request never reaches `log_events`): the console should show whether
|
||||||
|
# the uploads came in another way.
|
||||||
log.warning(
|
log.warning(
|
||||||
"RSS poll: %d/%d channels could not be polled",
|
"RSS poll: %d/%d channels could not be polled; %d read through the API instead",
|
||||||
failed,
|
failed,
|
||||||
total,
|
total,
|
||||||
|
fallback["read"],
|
||||||
extra=logs.event(
|
extra=logs.event(
|
||||||
"sync.rss.failed",
|
"sync.rss.failed",
|
||||||
failed=failed,
|
failed=failed,
|
||||||
total=total,
|
total=total,
|
||||||
reasons=dict(reasons),
|
reasons=dict(reasons),
|
||||||
examples=examples,
|
examples=examples,
|
||||||
|
fallback_read=fallback["read"],
|
||||||
|
fallback_new=fallback["new"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if total and failed == total:
|
if total and failed == total:
|
||||||
@@ -164,8 +178,115 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
|||||||
# subscriptions "all channels failed" is also what ONE dead feed id looks like, and a red
|
# subscriptions "all channels failed" is also what ONE dead feed id looks like, and a red
|
||||||
# card the user can't act on is its own kind of dishonest. `channel: HTTP 404` says
|
# card the user can't act on is its own kind of dishonest. `channel: HTTP 404` says
|
||||||
# "fix this subscription"; `Connection refused` says "the egress is down".
|
# "fix this subscription"; `Connection refused` says "the egress is down".
|
||||||
raise RuntimeError(f"RSS poll failed for all {total} channels — first: {first_error}")
|
# The fallback's count rides along: a red card during an RSS outage should still say
|
||||||
return {"new": new, "failed": failed}
|
# whether the uploads are arriving another way.
|
||||||
|
raise RuntimeError(
|
||||||
|
f"RSS poll failed for all {total} channels — first: {first_error}"
|
||||||
|
f" (read through the API instead: {fallback['read']})"
|
||||||
|
)
|
||||||
|
return {"new": new + fallback["new"], "failed": failed, "fallback": fallback["read"]}
|
||||||
|
|
||||||
|
|
||||||
|
def run_feed_fallback(db: Session, channel_ids: list[str]) -> dict[str, int]:
|
||||||
|
"""Read the newest uploads through the Data API for the channels among `channel_ids` whose feed
|
||||||
|
has not answered for `rss_fallback_hours` — and that were not read this way within as long.
|
||||||
|
|
||||||
|
YouTube's RSS goes dark per channel for hours at a time (2026-09-18/19: 393 of 396 feeds answered
|
||||||
|
404 for four hours, and a retry of the same channel gets the same 404), and nothing else reads a
|
||||||
|
followed channel's new uploads: recent backfill runs once per channel, ever. One page of the
|
||||||
|
uploads playlist is 1 unit, so a whole day of total outage costs 12 units per channel at the
|
||||||
|
default 2 hours; it yields to the backfill reserve like every other background read. The clock is
|
||||||
|
`last_rss_at` or `feed_fallback_at`, whichever is later, so a channel is read at most once per
|
||||||
|
window whether its feed came back in between or not. A read the API REFUSED stamps the clock
|
||||||
|
too: a terminated channel's uploads playlist answers 404 for good, and without the stamp it
|
||||||
|
would be charged again on every 20-minute poll instead of once per window. A failure that is not
|
||||||
|
about the channel (no connection, no token, the quota spent) stamps nothing and ends the round."""
|
||||||
|
hours = sysconfig.get_int(db, "rss_fallback_hours")
|
||||||
|
if hours <= 0 or not channel_ids:
|
||||||
|
return {"read": 0, "new": 0, "failed": 0}
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
last_read = func.greatest(Channel.last_rss_at, Channel.feed_fallback_at)
|
||||||
|
due = (
|
||||||
|
db.execute(
|
||||||
|
select(Channel)
|
||||||
|
.where(
|
||||||
|
Channel.id.in_(channel_ids),
|
||||||
|
Channel.uploads_playlist_id.is_not(None),
|
||||||
|
or_(last_read.is_(None), last_read < now - timedelta(hours=hours)),
|
||||||
|
)
|
||||||
|
.order_by(Channel.id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
user = get_service_user(db) if due else None
|
||||||
|
if user is None:
|
||||||
|
return {"read": 0, "new": 0, "failed": 0}
|
||||||
|
read = new = failed = 0
|
||||||
|
with (
|
||||||
|
quota.attribute(None, quota.QuotaAction.VIDEOS_FEED_FALLBACK),
|
||||||
|
YouTubeClient(db, user) as yt,
|
||||||
|
):
|
||||||
|
for channel in due:
|
||||||
|
if quota.backfill_reserve_reached():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
data = yt.get_playlist_items(channel.uploads_playlist_id, None)
|
||||||
|
# silent-ok: counted, and the one summary line below names the count — the client
|
||||||
|
# already logged the call's own failure (`youtube.api.error`).
|
||||||
|
except YouTubeError as exc:
|
||||||
|
failed += 1
|
||||||
|
# Not an answer about THIS channel — no connection, no token, no quota: every
|
||||||
|
# further channel would fail the same way, and none of them has used its window.
|
||||||
|
if exc.path != "playlistItems" or is_quota_refusal(exc):
|
||||||
|
break
|
||||||
|
_stamp_fallback_attempt(db, channel.id)
|
||||||
|
continue
|
||||||
|
rows = [
|
||||||
|
s
|
||||||
|
for s in (stub_from_playlist_item(it, channel.id) for it in data.get("items", []))
|
||||||
|
if s is not None
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
new += insert_stubs(db, rows)
|
||||||
|
channel.feed_fallback_at = datetime.now(timezone.utc)
|
||||||
|
db.add(channel)
|
||||||
|
db.commit()
|
||||||
|
except Exception: # noqa: BLE001 — one channel must not stop the rest
|
||||||
|
db.rollback()
|
||||||
|
log.exception(
|
||||||
|
"Feed fallback could not store channel %s",
|
||||||
|
channel.id,
|
||||||
|
extra=logs.event("sync.rss.fallback_failed"),
|
||||||
|
)
|
||||||
|
failed += 1
|
||||||
|
_stamp_fallback_attempt(db, channel.id)
|
||||||
|
continue
|
||||||
|
read += 1
|
||||||
|
log.info(
|
||||||
|
"Feed fallback: read %d of %d channels through the API, %d new videos",
|
||||||
|
read,
|
||||||
|
len(due),
|
||||||
|
new,
|
||||||
|
extra=logs.event("sync.rss.fallback", read=read, due=len(due), new=new, failed=failed),
|
||||||
|
)
|
||||||
|
return {"read": read, "new": new, "failed": failed}
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp_fallback_attempt(db: Session, channel_id: str) -> None:
|
||||||
|
"""Start a channel's fallback window after a read that did not land, so it waits the window out
|
||||||
|
like a read that did. Its own commit: the failure already rolled the channel's work back."""
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
update(Channel)
|
||||||
|
.where(Channel.id == channel_id)
|
||||||
|
.values(feed_fallback_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
# silent-ok: the failure that brought us here was already logged and counted; an unstamped
|
||||||
|
# channel is only read again at the next poll.
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
|
||||||
def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
|
def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||||||
ConfigSpec(
|
ConfigSpec(
|
||||||
"backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500
|
"backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500
|
||||||
),
|
),
|
||||||
|
ConfigSpec("rss_fallback_hours", "int", "backfill", "rss_fallback_hours", min=0, max=168),
|
||||||
# --- Shorts probe ---
|
# --- Shorts probe ---
|
||||||
ConfigSpec(
|
ConfigSpec(
|
||||||
"shorts_probe_max_seconds", "int", "shorts", "shorts_probe_max_seconds", min=1, max=3_600
|
"shorts_probe_max_seconds", "int", "shorts", "shorts_probe_max_seconds", min=1, max=3_600
|
||||||
@@ -175,6 +176,7 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||||||
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
|
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.)
|
# (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_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.
|
# Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback.
|
||||||
# crf 0–51 (x264 range); max_height 0 = source.
|
# crf 0–51 (x264 range); max_height 0 = source.
|
||||||
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
|
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
|
||||||
@@ -210,11 +212,23 @@ def _default(s: ConfigSpec):
|
|||||||
return getattr(settings, s.default_attr)
|
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:
|
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)
|
option = canonical_choice(s.choices, raw)
|
||||||
if option is None:
|
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
|
return option
|
||||||
|
|
||||||
|
|
||||||
@@ -304,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."""
|
(B-core-10). The staged value is visible to a same-session `get()` before the commit."""
|
||||||
s = _BY_KEY[key]
|
s = _BY_KEY[key]
|
||||||
if s.type == "int":
|
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:
|
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:
|
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)
|
raw = str(v)
|
||||||
elif s.type == "bool":
|
elif s.type == "bool":
|
||||||
raw = "true" if value else "false"
|
raw = "true" if value else "false"
|
||||||
@@ -320,7 +337,7 @@ def set_value(db: Session, key: str, value) -> None:
|
|||||||
stored, encrypted = raw, False
|
stored, encrypted = raw, False
|
||||||
if s.secret:
|
if s.secret:
|
||||||
if not secrets_manageable():
|
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
|
stored, encrypted = security.encrypt(raw) or "", True
|
||||||
|
|
||||||
row = db.get(SystemConfig, key)
|
row = db.get(SystemConfig, key)
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ def to_tsquery_str(q: str) -> str | None:
|
|||||||
return " & ".join(tokens)
|
return " & ".join(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def like_escape(s: str) -> str:
|
||||||
|
"""`s` as a LITERAL inside a LIKE/ILIKE pattern: `%` and `_` are wildcards there, and `\\` is
|
||||||
|
Postgres's default escape character, so all three are escaped. Every pattern built from text a
|
||||||
|
person typed goes through this — unescaped, a search for `100%` or `my_channel` matches rows it
|
||||||
|
should not."""
|
||||||
|
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
def normalize_description(text: str | None) -> str | None:
|
def normalize_description(text: str | None) -> str | None:
|
||||||
"""Strip whitespace-only lines from a CHANNEL description before storing. YouTube channel
|
"""Strip whitespace-only lines from a CHANNEL description before storing. YouTube channel
|
||||||
descriptions pad their contact/link blocks with runs of blank lines; keeping them wastes storage,
|
descriptions pad their contact/link blocks with runs of blank lines; keeping them wastes storage,
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ so they don't depend on a specific user's token; subscriptions.list?mine=true al
|
|||||||
uses OAuth.
|
uses OAuth.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -25,11 +27,15 @@ class YouTubeError(Exception):
|
|||||||
|
|
||||||
`reason` is the API's own error reason code (`quotaExceeded`, `subscriptionDuplicate`,
|
`reason` is the API's own error reason code (`quotaExceeded`, `subscriptionDuplicate`,
|
||||||
`videoNotFound`, …) when the body carried one. Branch on THAT, never on the message: the
|
`videoNotFound`, …) when the body carried one. Branch on THAT, never on the message: the
|
||||||
message is prose, it changes under us, and it is deliberately the poorer of the two here."""
|
message is prose, it changes under us, and it is deliberately the poorer of the two here.
|
||||||
|
|
||||||
def __init__(self, message: str, *, reason: str = ""):
|
`path` is the API endpoint that ANSWERED with the failure (`playlistItems`); empty when no
|
||||||
|
endpoint did — the request never got through (`transport`) or the token step refused first."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, reason: str = "", path: str = ""):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
|
||||||
def _error_reason(resp: httpx.Response) -> str:
|
def _error_reason(resp: httpx.Response) -> str:
|
||||||
@@ -50,7 +56,9 @@ def _error_reason(resp: httpx.Response) -> str:
|
|||||||
return reasons or "<none>"
|
return reasons or "<none>"
|
||||||
|
|
||||||
|
|
||||||
def _upstream_failure(method: str, path: str, resp: httpx.Response) -> YouTubeError:
|
def _upstream_failure(
|
||||||
|
method: str, path: str, resp: httpx.Response, expected: frozenset[str] = frozenset()
|
||||||
|
) -> YouTubeError:
|
||||||
"""Report one upstream failure and build the error to raise. Every YouTube response that is
|
"""Report one upstream failure and build the error to raise. Every YouTube response that is
|
||||||
not a success goes through here — the Data API's reads, writes and deletes and the OAuth
|
not a success goes through here — the Data API's reads, writes and deletes and the OAuth
|
||||||
token refresh — so what may appear in a log line cannot drift apart between them, and the
|
token refresh — so what may appear in a log line cannot drift apart between them, and the
|
||||||
@@ -59,7 +67,11 @@ def _upstream_failure(method: str, path: str, resp: httpx.Response) -> YouTubeEr
|
|||||||
response at all is `_send`'s, with the reason `transport`.) The body is logged only under
|
response at all is `_send`'s, with the reason `transport`.) The body is logged only under
|
||||||
the requesting user's own Teljes consent: it is the request quoted back."""
|
the requesting user's own Teljes consent: it is the request quoted back."""
|
||||||
reason = _error_reason(resp)
|
reason = _error_reason(resp)
|
||||||
log.warning(
|
# A reason the caller named as an answer rather than a failure (`channelNotFound` on a `mine`
|
||||||
|
# read: the account simply has no channel) is still raised, but it is not a warning — it came
|
||||||
|
# back every six hours for an account that will never have one.
|
||||||
|
log.log(
|
||||||
|
logging.DEBUG if reason in expected else logging.WARNING,
|
||||||
"YouTube %s %s -> %s (%s)",
|
"YouTube %s %s -> %s (%s)",
|
||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
@@ -80,7 +92,45 @@ def _upstream_failure(method: str, path: str, resp: httpx.Response) -> YouTubeEr
|
|||||||
# log-pii-ok: only under this user's own Teljes consent, which is what it is for
|
# log-pii-ok: only under this user's own Teljes consent, which is what it is for
|
||||||
extra=logs.event("youtube.api.body", body=resp.text[:500]),
|
extra=logs.event("youtube.api.body", body=resp.text[:500]),
|
||||||
)
|
)
|
||||||
return YouTubeError(f"{method} {path} -> {resp.status_code}: {reason}", reason=reason)
|
return YouTubeError(
|
||||||
|
f"{method} {path} -> {resp.status_code}: {reason}", reason=reason, path=path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# The `mine` reads' answer for a Google account that has no YouTube channel.
|
||||||
|
_NO_CHANNEL = frozenset({"channelNotFound"})
|
||||||
|
|
||||||
|
# The reasons that mean the project's quota is spent: every further call today gets the same answer.
|
||||||
|
_QUOTA_REASONS = frozenset({"quotaExceeded", "dailyLimitExceeded"})
|
||||||
|
|
||||||
|
|
||||||
|
def is_quota_refusal(exc: YouTubeError) -> bool:
|
||||||
|
"""Whether the API refused for want of quota. `reason` can list several codes, comma-joined."""
|
||||||
|
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]:
|
def _chunks(items: list, size: int) -> Iterator[list]:
|
||||||
@@ -156,7 +206,10 @@ class YouTubeClient:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
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()
|
data = resp.json()
|
||||||
access = data["access_token"]
|
access = data["access_token"]
|
||||||
tok.access_token = encrypt(access)
|
tok.access_token = encrypt(access)
|
||||||
@@ -166,8 +219,34 @@ class YouTubeClient:
|
|||||||
log.info("Refreshed access token for user %s", self.user.id)
|
log.info("Refreshed access token for user %s", self.user.id)
|
||||||
return access
|
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 ---
|
# --- core request ---
|
||||||
def _get(self, path: str, params: dict, cost: int = 1, allow_key: bool = True) -> dict:
|
def _get(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
params: dict,
|
||||||
|
cost: int = 1,
|
||||||
|
allow_key: bool = True,
|
||||||
|
expected: frozenset[str] = frozenset(),
|
||||||
|
) -> dict:
|
||||||
p = dict(params)
|
p = dict(params)
|
||||||
headers = {}
|
headers = {}
|
||||||
api_key = sysconfig.get_str(self.db, "youtube_api_key")
|
api_key = sysconfig.get_str(self.db, "youtube_api_key")
|
||||||
@@ -178,7 +257,7 @@ class YouTubeClient:
|
|||||||
resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
|
resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
|
||||||
quota.record_usage(cost)
|
quota.record_usage(cost)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise _upstream_failure("GET", path, resp)
|
raise _upstream_failure("GET", path, resp, expected)
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
# --- endpoints ---
|
# --- endpoints ---
|
||||||
@@ -218,7 +297,14 @@ class YouTubeClient:
|
|||||||
params = {"part": "snippet,contentDetails", "mine": "true", "maxResults": 50}
|
params = {"part": "snippet,contentDetails", "mine": "true", "maxResults": 50}
|
||||||
if page_token:
|
if page_token:
|
||||||
params["pageToken"] = page_token
|
params["pageToken"] = page_token
|
||||||
data = self._get("playlists", params, cost=1, allow_key=False)
|
try:
|
||||||
|
data = self._get("playlists", params, cost=1, allow_key=False, expected=_NO_CHANNEL)
|
||||||
|
except YouTubeError as exc:
|
||||||
|
# A Google account without a YouTube channel has no playlists, and YouTube says
|
||||||
|
# so as a 404. That is an empty list, not a failed sync.
|
||||||
|
if exc.reason in _NO_CHANNEL:
|
||||||
|
return
|
||||||
|
raise
|
||||||
for item in data.get("items", []):
|
for item in data.get("items", []):
|
||||||
sn = item.get("snippet", {})
|
sn = item.get("snippet", {})
|
||||||
yield {
|
yield {
|
||||||
|
|||||||
+38
-10
@@ -1,6 +1,7 @@
|
|||||||
"""Free per-channel RSS feed reader (no API quota). Returns the channel's most recent
|
"""Free per-channel RSS feed reader (no API quota). Returns the channel's most recent
|
||||||
~15 uploads as lightweight stubs for fast fresh-video detection."""
|
~15 uploads as lightweight stubs for fast fresh-video detection."""
|
||||||
|
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
@@ -8,6 +9,18 @@ import httpx
|
|||||||
|
|
||||||
RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
||||||
|
|
||||||
|
# The waits before a second and a third try. YouTube's feed endpoint answers the SAME channel with
|
||||||
|
# 404, 500 and 200 in turn (measured 2026-09-19: 15 of 24 channels failed a first request, 16 of them
|
||||||
|
# had succeeded by the fifth, one second apart), so one failure is not an answer. A feed that is
|
||||||
|
# really gone costs two more requests per poll. A TIMEOUT is not tried again: the flaky endpoint
|
||||||
|
# answers fast, and a request that hung for `TIMEOUT` says the way out is hanging — three of them
|
||||||
|
# per channel would stretch a poll over a dead egress to three times its interval.
|
||||||
|
RETRY_DELAYS = (1.0, 2.0)
|
||||||
|
TIMEOUT = 20.0
|
||||||
|
# A 404 is in the set because that is how the flaky endpoint fails most often; a 403/410 is not,
|
||||||
|
# and neither is anything else that says the request itself is wrong.
|
||||||
|
_RETRYABLE_STATUS = frozenset({404, 429, 500, 502, 503, 504})
|
||||||
|
|
||||||
|
|
||||||
class RssError(Exception):
|
class RssError(Exception):
|
||||||
"""The feed could not be read (transport failure or a non-200 answer).
|
"""The feed could not be read (transport failure or a non-200 answer).
|
||||||
@@ -24,21 +37,36 @@ class RssError(Exception):
|
|||||||
self.reason = reason
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
def fetch_channel_feed(channel_id: str) -> list[dict]:
|
def _get_feed(channel_id: str) -> bytes:
|
||||||
"""The channel's recent uploads as stubs. `[]` means the feed really is empty; a failure
|
"""The feed's body, after up to `1 + len(RETRY_DELAYS)` tries; the LAST failure is raised."""
|
||||||
raises RssError so the caller can leave the channel un-stamped and log it."""
|
|
||||||
url = RSS_URL.format(channel_id=channel_id)
|
url = RSS_URL.format(channel_id=channel_id)
|
||||||
try:
|
for attempt in range(len(RETRY_DELAYS) + 1):
|
||||||
resp = httpx.get(url, timeout=20.0, headers={"User-Agent": "Siftlode/1.0"})
|
if attempt:
|
||||||
except httpx.HTTPError as exc:
|
time.sleep(RETRY_DELAYS[attempt - 1])
|
||||||
raise RssError(f"RSS fetch failed for {channel_id}: {exc}", "transport") from exc
|
try:
|
||||||
if resp.status_code != 200:
|
resp = httpx.get(url, timeout=TIMEOUT, headers={"User-Agent": "Siftlode/1.0"})
|
||||||
raise RssError(
|
# silent-ok: not silent — the last failure is raised after the final try.
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
error = RssError(f"RSS fetch failed for {channel_id}: {exc}", "transport")
|
||||||
|
error.__cause__ = exc
|
||||||
|
if isinstance(exc, httpx.TimeoutException):
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.content
|
||||||
|
error = RssError(
|
||||||
f"RSS fetch for {channel_id} returned HTTP {resp.status_code}",
|
f"RSS fetch for {channel_id} returned HTTP {resp.status_code}",
|
||||||
f"http_{resp.status_code}",
|
f"http_{resp.status_code}",
|
||||||
)
|
)
|
||||||
|
if resp.status_code not in _RETRYABLE_STATUS:
|
||||||
|
break
|
||||||
|
raise error
|
||||||
|
|
||||||
parsed = feedparser.parse(resp.content)
|
|
||||||
|
def fetch_channel_feed(channel_id: str) -> list[dict]:
|
||||||
|
"""The channel's recent uploads as stubs. `[]` means the feed really is empty; a failure
|
||||||
|
(after the retries) raises RssError so the caller can leave the channel un-stamped and log it."""
|
||||||
|
parsed = feedparser.parse(_get_feed(channel_id))
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for entry in parsed.entries:
|
for entry in parsed.entries:
|
||||||
video_id = entry.get("yt_videoid")
|
video_id = entry.get("yt_videoid")
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ from alembic import command
|
|||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from fastapi.testclient import TestClient
|
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.models # noqa: F401 — register every model on Base.metadata
|
||||||
import app.state as app_state
|
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
|
@pytest.fixture
|
||||||
def db(_schema):
|
def db(_schema):
|
||||||
"""A real session against the test Postgres, on a clean (empty) database. The code under test
|
"""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()
|
_truncate_all()
|
||||||
session = SessionLocal()
|
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:
|
try:
|
||||||
yield session
|
yield session
|
||||||
finally:
|
finally:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
session.close()
|
session.close()
|
||||||
|
assert unfinished == [], "a handler left a write uncommitted"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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.
|
`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
|
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)."""
|
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)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -187,7 +232,7 @@ def as_user(db, override_dep, _configured):
|
|||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
return registry[uid]
|
return registry[uid]
|
||||||
|
|
||||||
override_dep(get_db, lambda: db)
|
override_dep(get_db, db.info["request"])
|
||||||
override_dep(current_user, _resolver)
|
override_dep(current_user, _resolver)
|
||||||
|
|
||||||
def _as(user: User) -> TestClient:
|
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 (
|
assert (
|
||||||
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none() is None
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""The admin diagnostics console's query API (E22.6 S1), DB-lane."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import insert
|
||||||
|
|
||||||
|
from app.models import LogEvent
|
||||||
|
|
||||||
|
T0 = datetime(2026, 9, 19, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(**kw) -> dict:
|
||||||
|
row = {
|
||||||
|
"ts": T0,
|
||||||
|
"level": "INFO",
|
||||||
|
"sev": 9,
|
||||||
|
"source": "api",
|
||||||
|
"logger": "siftlode.http",
|
||||||
|
"event": "http.request",
|
||||||
|
"msg": "GET /api/feed 200",
|
||||||
|
"attrs": None,
|
||||||
|
"exc": None,
|
||||||
|
"request_id": None,
|
||||||
|
"user_id": None,
|
||||||
|
"job_id": None,
|
||||||
|
"version": "0.90.0",
|
||||||
|
}
|
||||||
|
row.update(kw)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _add(db, *rows: dict) -> None:
|
||||||
|
db.execute(insert(LogEvent), list(rows))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin(db, make_user, as_user):
|
||||||
|
return as_user(make_user(email="admin@example.com", role="admin"))
|
||||||
|
|
||||||
|
|
||||||
|
def _msgs(resp) -> list[str]:
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return [it["msg"] for it in resp.json()["items"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_an_admin_reads_the_log(db, make_user, as_user, client):
|
||||||
|
_add(db, _row())
|
||||||
|
assert as_user(make_user()).get("/api/admin/logs").status_code == 403
|
||||||
|
assert as_user(make_user()).get("/api/admin/logs/facets").status_code == 403
|
||||||
|
assert client.get("/api/admin/logs").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_first_and_every_row_once_across_pages_even_on_equal_timestamps(db, admin):
|
||||||
|
# A browser row's `ts` is clamped, so several rows share one: the id breaks the tie.
|
||||||
|
_add(db, *[_row(msg=f"same {i}") for i in range(5)], _row(ts=T0 - timedelta(1), msg="older"))
|
||||||
|
|
||||||
|
seen, cursor = [], None
|
||||||
|
while True:
|
||||||
|
resp = admin.get(
|
||||||
|
"/api/admin/logs", params={"limit": 2, "before": cursor} if cursor else {"limit": 2}
|
||||||
|
)
|
||||||
|
seen += _msgs(resp)
|
||||||
|
cursor = resp.json()["next_cursor"]
|
||||||
|
if cursor is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
assert seen == ["same 4", "same 3", "same 2", "same 1", "same 0", "older"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("params", "expected"),
|
||||||
|
[
|
||||||
|
({"levels": ["ERROR", "WARNING"]}, {"err", "warn"}),
|
||||||
|
({"sources": ["worker"]}, {"err"}),
|
||||||
|
({"user": "1"}, {"mine"}),
|
||||||
|
({"user": "none"}, {"err", "warn", "plain"}),
|
||||||
|
({"event": "download.job.failed"}, {"err"}),
|
||||||
|
({"event": "none"}, {"warn"}),
|
||||||
|
({"request_id": "req-abcdef12"}, {"mine"}),
|
||||||
|
({"job_id": 42}, {"err"}),
|
||||||
|
({"q": "FORBIDDEN"}, {"err"}), # in attrs, any case
|
||||||
|
({"q": "KeyError"}, {"warn"}), # in the stack
|
||||||
|
({"q": "100%"}, {"plain"}), # a literal, not a wildcard
|
||||||
|
({"since": (T0 - timedelta(hours=1)).isoformat()}, {"mine", "err", "warn"}),
|
||||||
|
({"until": (T0 - timedelta(hours=1)).isoformat()}, {"plain", "decoy"}),
|
||||||
|
({"levels": ["INFO"], "user": "none"}, {"plain"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_each_filter_selects_exactly_its_rows(db, admin, make_user, params, expected):
|
||||||
|
# user 1 is the admin the fixture made
|
||||||
|
_add(
|
||||||
|
db,
|
||||||
|
_row(msg="mine", user_id=1, request_id="req-abcdef12"),
|
||||||
|
_row(
|
||||||
|
msg="err",
|
||||||
|
level="ERROR",
|
||||||
|
sev=17,
|
||||||
|
source="worker",
|
||||||
|
event="download.job.failed",
|
||||||
|
job_id=42,
|
||||||
|
attrs={"code": "forbidden"},
|
||||||
|
),
|
||||||
|
_row(msg="warn", level="WARNING", sev=13, event=None, exc="Traceback …\nKeyError: 'x'"),
|
||||||
|
_row(msg="plain", ts=T0 - timedelta(hours=2), attrs={"note": "100% done"}),
|
||||||
|
# Matched by no filter above but `until`: another account, and `100%` read as a pattern.
|
||||||
|
_row(msg="decoy", ts=T0 - timedelta(hours=3), attrs={"note": "1000 done"}, user_id=99),
|
||||||
|
)
|
||||||
|
assert set(_msgs(admin.get("/api/admin/logs", params=params))) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_page_names_its_accounts_and_leaves_an_erased_one_bare(db, admin):
|
||||||
|
_add(db, _row(user_id=1), _row(user_id=77))
|
||||||
|
|
||||||
|
assert admin.get("/api/admin/logs").json()["accounts"] == {"1": "admin@example.com"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"params",
|
||||||
|
[
|
||||||
|
{"before": "not-a-cursor"},
|
||||||
|
{"before": "WyJ4IiwgMV0="}, # ["x", 1]: decodes, not a timestamp
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_bad_cursor_is_a_400_with_a_code(db, admin, params):
|
||||||
|
resp = admin.get("/api/admin/logs", params=params)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["detail"]["code"] == "bad_cursor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_facets_count_the_window_and_name_the_rows_without_a_value(db, admin):
|
||||||
|
_add(
|
||||||
|
db,
|
||||||
|
_row(user_id=1),
|
||||||
|
_row(user_id=1, level="ERROR", sev=17, event="sync.rss.failed"),
|
||||||
|
_row(event=None),
|
||||||
|
_row(ts=T0 - timedelta(days=2), source="worker"),
|
||||||
|
)
|
||||||
|
|
||||||
|
body = admin.get(
|
||||||
|
"/api/admin/logs/facets", params={"since": (T0 - timedelta(days=1)).isoformat()}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert body["total"] == 3
|
||||||
|
assert body["levels"] == [{"value": "INFO", "count": 2}, {"value": "ERROR", "count": 1}]
|
||||||
|
assert body["sources"] == [{"value": "api", "count": 3}]
|
||||||
|
assert body["events"] == [
|
||||||
|
{"value": "http.request", "count": 1},
|
||||||
|
{"value": "sync.rss.failed", "count": 1},
|
||||||
|
{"value": "none", "count": 1},
|
||||||
|
]
|
||||||
|
assert body["users"] == [{"value": "1", "count": 2}, {"value": "none", "count": 1}]
|
||||||
|
assert body["accounts"] == {"1": "admin@example.com"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_facet_counts_under_every_other_filter_but_not_its_own(db, admin):
|
||||||
|
_add(
|
||||||
|
db,
|
||||||
|
_row(request_id="req-aaaaaaaa"),
|
||||||
|
_row(request_id="req-aaaaaaaa", level="ERROR", sev=17, source="worker"),
|
||||||
|
_row(request_id="req-bbbbbbbb", level="ERROR", sev=17),
|
||||||
|
_row(request_id="req-bbbbbbbb"),
|
||||||
|
)
|
||||||
|
|
||||||
|
body = admin.get(
|
||||||
|
"/api/admin/logs/facets", params={"request_id": "req-aaaaaaaa", "levels": ["ERROR"]}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
# The pivot narrows every count; the picked level does not zero its sibling (the way back).
|
||||||
|
assert body["levels"] == [{"value": "ERROR", "count": 1}, {"value": "INFO", "count": 1}]
|
||||||
|
assert body["total"] == 2
|
||||||
|
# Every other facet counts under the level filter too.
|
||||||
|
assert body["sources"] == [{"value": "worker", "count": 1}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("params", "facet", "expected"),
|
||||||
|
[
|
||||||
|
({"sources": ["worker"]}, "sources", {"api", "worker"}),
|
||||||
|
({"user": "1"}, "users", {"1", "none"}),
|
||||||
|
({"event": "sync.rss.failed"}, "events", {"http.request", "sync.rss.failed"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_every_facet_keeps_its_siblings_under_its_own_filter(db, admin, params, facet, expected):
|
||||||
|
# The way back from a pick: the picked facet still offers the values it was picked from.
|
||||||
|
_add(db, _row(user_id=1), _row(source="worker", event="sync.rss.failed"))
|
||||||
|
|
||||||
|
body = admin.get("/api/admin/logs/facets", params=params).json()
|
||||||
|
|
||||||
|
assert {f["value"] for f in body[facet]} == expected
|
||||||
@@ -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})
|
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()
|
db.expire_all()
|
||||||
user = db.execute(_select(User).where(User.email == "newbie@example.com")).scalar_one_or_none()
|
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"
|
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)
|
_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).
|
# 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)
|
ch = Channel(id="UC_nsl", title="N/S/L Channel", published_at=_PUB)
|
||||||
db.add(ch)
|
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).
|
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: me
|
override_dep(current_user, lambda: me)
|
||||||
try:
|
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
@@ -56,7 +53,7 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("hidden", [True, False])
|
@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
|
# 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).
|
# 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)
|
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()
|
db.commit()
|
||||||
|
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: me
|
override_dep(current_user, lambda: me)
|
||||||
try:
|
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
assert resp.json()["hidden"] is hidden
|
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.
|
# 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)
|
ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB)
|
||||||
me = User(email="hidns@example.com")
|
me = User(email="hidns@example.com")
|
||||||
@@ -87,12 +81,9 @@ def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatc
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: me
|
override_dep(current_user, lambda: me)
|
||||||
try:
|
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
assert resp.json()["hidden"] is False
|
assert resp.json()["hidden"] is False
|
||||||
@@ -115,33 +106,30 @@ def _setup_channel_and_sub(db, *, email, is_demo=False):
|
|||||||
return ch, user
|
return ch, user
|
||||||
|
|
||||||
|
|
||||||
def _patch_deep_requested(db, monkeypatch, user):
|
def _patch_deep_requested(db, monkeypatch, override_dep, user):
|
||||||
calls: list[tuple] = []
|
calls: list[tuple] = []
|
||||||
# background.add_task runs after the response in TestClient; record what would be scheduled.
|
# 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(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a))
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
|
||||||
resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
return resp, calls
|
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")
|
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.status_code == 200, resp.text
|
||||||
assert resp.json()["deep_requested"] is True
|
assert resp.json()["deep_requested"] is True
|
||||||
# scheduled exactly once, with (channel_id, user_id) — the args backfill_channel_now needs.
|
# scheduled exactly once, with (channel_id, user_id) — the args backfill_channel_now needs.
|
||||||
assert calls == [(ch.id, me.id)]
|
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).
|
# 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)
|
_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 resp.status_code == 200, resp.text
|
||||||
assert calls == [], "demo must not schedule an immediate backfill"
|
assert calls == [], "demo must not schedule an immediate backfill"
|
||||||
# …and deep_requested is not persisted on the shared demo subscription either.
|
# …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
|
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).
|
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
|
||||||
ch = Channel(
|
ch = Channel(
|
||||||
id="UC_tags_test",
|
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).
|
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: me
|
override_dep(current_user, lambda: me)
|
||||||
try:
|
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
|
||||||
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
|
resp = client.get(f"/api/channels/{ch.id}")
|
||||||
resp = client.get(f"/api/channels/{ch.id}")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
assert resp.json()["tag_ids"] == [mine.id]
|
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"
|
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):
|
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
|
"""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."""
|
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}")
|
r = c.delete(f"/api/discover/pins/{pin_id}")
|
||||||
assert r.status_code == 200 and r.json() == {"deleted": pin_id}
|
assert r.status_code == 200 and r.json() == {"deleted": pin_id}
|
||||||
assert c.get("/api/discover/pins").json()["items"] == []
|
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
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
def _get(db, user, monkeypatch, path):
|
def _get(db, user, monkeypatch, override_dep, path):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
state, "_configured_cache", True
|
state, "_configured_cache", True
|
||||||
) # the request gate 503s an unconfigured app
|
) # the request gate 503s an unconfigured app
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
||||||
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
return client.get(path)
|
||||||
return client.get(path)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
user = User(email="feedcursor@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
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.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
|
# 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.
|
# cursor path, not a globally-broken request). A fresh user has no subscriptions → empty feed.
|
||||||
user = User(email="feednocursor@example.com")
|
user = User(email="feednocursor@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
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.status_code == 200
|
||||||
assert resp.json()["items"] == []
|
assert resp.json()["items"] == []
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""The RSS poll's API fallback (`sync/runner.run_feed_fallback`), DB-lane: which channels are read
|
||||||
|
through the Data API when their feed stays dark, and how often."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import sysconfig
|
||||||
|
from app.models import Channel, Video
|
||||||
|
from app.sync import runner
|
||||||
|
from app.youtube.client import YouTubeError
|
||||||
|
|
||||||
|
NOW = datetime.now(timezone.utc)
|
||||||
|
_PUB = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeYt:
|
||||||
|
# playlist id -> the reason its read fails with (the tests put entries here)
|
||||||
|
failing: dict[str, str] = {}
|
||||||
|
|
||||||
|
def __init__(self, calls):
|
||||||
|
self.calls = calls
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_playlist_items(self, playlist_id, page_token=None):
|
||||||
|
self.calls.append(playlist_id)
|
||||||
|
if playlist_id in self.failing:
|
||||||
|
reason = self.failing[playlist_id]
|
||||||
|
# A request that never got through has no endpoint that answered.
|
||||||
|
path = "" if reason == "transport" else "playlistItems"
|
||||||
|
raise YouTubeError("refused", reason=reason, path=path)
|
||||||
|
cid = "UC" + playlist_id[2:]
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"contentDetails": {"videoId": f"{cid}-v1", "videoPublishedAt": NOW.isoformat()},
|
||||||
|
"snippet": {"title": "new upload"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def calls(monkeypatch):
|
||||||
|
made: list[str] = []
|
||||||
|
monkeypatch.setattr(runner, "get_service_user", lambda db: object())
|
||||||
|
monkeypatch.setattr(runner, "YouTubeClient", lambda db, user: _FakeYt(made))
|
||||||
|
monkeypatch.setattr(runner.quota, "backfill_reserve_reached", lambda: False)
|
||||||
|
monkeypatch.setattr(_FakeYt, "failing", {})
|
||||||
|
return made
|
||||||
|
|
||||||
|
|
||||||
|
def _channel(db, cid, *, rss=None, fallback=None, uploads=True):
|
||||||
|
ch = Channel(
|
||||||
|
id=cid,
|
||||||
|
title=cid,
|
||||||
|
published_at=_PUB,
|
||||||
|
uploads_playlist_id=("UU" + cid[2:]) if uploads else None,
|
||||||
|
last_rss_at=rss,
|
||||||
|
feed_fallback_at=fallback,
|
||||||
|
)
|
||||||
|
db.add(ch)
|
||||||
|
db.commit()
|
||||||
|
return ch
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_feed_dark_past_the_window_is_read_through_the_api(db, calls):
|
||||||
|
_channel(db, "UCdark", rss=NOW - timedelta(hours=3))
|
||||||
|
|
||||||
|
out = runner.run_feed_fallback(db, ["UCdark"])
|
||||||
|
|
||||||
|
assert out == {"read": 1, "new": 1, "failed": 0}
|
||||||
|
assert calls == ["UUdark"]
|
||||||
|
assert db.get(Video, "UCdark-v1") is not None
|
||||||
|
assert db.get(Channel, "UCdark").feed_fallback_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("rss", "fallback"),
|
||||||
|
[
|
||||||
|
(NOW - timedelta(minutes=30), None), # the feed answered recently: a blip, not an outage
|
||||||
|
(NOW - timedelta(hours=5), NOW - timedelta(minutes=30)), # already read this window
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_channel_read_within_the_window_is_left_alone(db, calls, rss, fallback):
|
||||||
|
_channel(db, "UCrecent", rss=rss, fallback=fallback)
|
||||||
|
|
||||||
|
assert runner.run_feed_fallback(db, ["UCrecent"])["read"] == 0
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_window_is_the_admins_and_zero_turns_it_off(db, calls):
|
||||||
|
_channel(db, "UCdark", rss=NOW - timedelta(hours=3))
|
||||||
|
|
||||||
|
sysconfig.set_value(db, "rss_fallback_hours", 4)
|
||||||
|
db.commit()
|
||||||
|
assert runner.run_feed_fallback(db, ["UCdark"])["read"] == 0
|
||||||
|
|
||||||
|
sysconfig.set_value(db, "rss_fallback_hours", 0)
|
||||||
|
db.commit()
|
||||||
|
_channel(db, "UCnever")
|
||||||
|
assert runner.run_feed_fallback(db, ["UCdark", "UCnever"])["read"] == 0
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_named_channels_and_only_those_with_an_uploads_playlist(db, calls):
|
||||||
|
_channel(db, "UCdark")
|
||||||
|
_channel(db, "UCother")
|
||||||
|
_channel(db, "UCnolist", uploads=False)
|
||||||
|
|
||||||
|
assert runner.run_feed_fallback(db, ["UCdark", "UCnolist"])["read"] == 1
|
||||||
|
assert calls == ["UUdark"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_yields_to_the_backfill_reserve(db, calls, monkeypatch):
|
||||||
|
_channel(db, "UCdark")
|
||||||
|
monkeypatch.setattr(runner.quota, "backfill_reserve_reached", lambda: True)
|
||||||
|
|
||||||
|
assert runner.run_feed_fallback(db, ["UCdark"])["read"] == 0
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_read_that_fails_waits_the_window_out_too(db, calls):
|
||||||
|
# A terminated channel: its feed 404s and so does its uploads playlist, for good. Without a
|
||||||
|
# stamp it was charged on every 20-minute poll instead of once per window.
|
||||||
|
_channel(db, "UCgone", rss=NOW - timedelta(hours=3))
|
||||||
|
_FakeYt.failing["UUgone"] = "playlistNotFound"
|
||||||
|
|
||||||
|
assert runner.run_feed_fallback(db, ["UCgone"]) == {"read": 0, "new": 0, "failed": 1}
|
||||||
|
assert db.get(Channel, "UCgone").feed_fallback_at is not None
|
||||||
|
assert runner.run_feed_fallback(db, ["UCgone"])["failed"] == 0
|
||||||
|
assert calls == ["UUgone"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reason", ["quotaExceeded", "transport"])
|
||||||
|
def test_a_failure_not_about_the_channel_ends_the_round_and_costs_no_window(db, calls, reason):
|
||||||
|
# Out of quota, or no connection: every further channel would fail the same way, and a channel
|
||||||
|
# that was never answered about keeps its turn for the next poll.
|
||||||
|
_channel(db, "UCa")
|
||||||
|
_channel(db, "UCb")
|
||||||
|
_FakeYt.failing["UUa"] = reason
|
||||||
|
|
||||||
|
assert runner.run_feed_fallback(db, ["UCa", "UCb"])["failed"] == 1
|
||||||
|
assert calls == ["UUa"]
|
||||||
|
assert db.get(Channel, "UCa").feed_fallback_at is None
|
||||||
@@ -27,7 +27,7 @@ def test_the_rss_poll_skips_channels_nobody_follows(db, make_user, monkeypatch):
|
|||||||
polled: list[str] = []
|
polled: list[str] = []
|
||||||
monkeypatch.setattr(runner, "fetch_channel_feed", lambda cid: polled.append(cid) or [])
|
monkeypatch.setattr(runner, "fetch_channel_feed", lambda cid: polled.append(cid) or [])
|
||||||
monkeypatch.setattr(runner, "apply_rss_feed", lambda db, ch, entries: 0)
|
monkeypatch.setattr(runner, "apply_rss_feed", lambda db, ch, entries: 0)
|
||||||
assert runner.run_rss_poll(db) == {"new": 0, "failed": 0}
|
assert runner.run_rss_poll(db) == {"new": 0, "failed": 0, "fallback": 0}
|
||||||
assert sorted(polled) == ["UC_explored", "UC_sub"]
|
assert sorted(polled) == ["UC_explored", "UC_sub"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions):
|
||||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||||
sessions["12345"] = s
|
sessions[(1, "12345")] = s
|
||||||
stream.touch_session("12345")
|
stream.touch_session(1, "12345")
|
||||||
assert stream.reap_idle() == 0
|
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) -------------------------------
|
# --- 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
|
return it
|
||||||
|
|
||||||
|
|
||||||
def _client(db, user):
|
def _client(db, user, override_dep):
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
return TestClient(app)
|
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
|
"""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."""
|
`last_access` never moved on its own — but it does keep checkpointing its position."""
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
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.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||||
sessions["paused1"] = s
|
sessions[(user.id, "paused1")] = s
|
||||||
try:
|
resp = _client(db, user, override_dep).post(
|
||||||
resp = _client(db, user).post(
|
f"/api/plex/item/{it.rating_key}/progress",
|
||||||
f"/api/plex/item/{it.rating_key}/progress",
|
json={"position_seconds": 600, "duration_seconds": 3000, "final": False},
|
||||||
json={"position_seconds": 600, "duration_seconds": 3000, "final": False},
|
)
|
||||||
)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there"
|
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(
|
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
|
"""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
|
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.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
paused = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
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(
|
return SimpleNamespace(
|
||||||
id="sid",
|
id="sid",
|
||||||
media_start_s=start,
|
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)
|
monkeypatch.setattr(stream, "start_session", fake_start)
|
||||||
try:
|
resp = _client(db, user, override_dep).post(f"/api/plex/stream/{started.rating_key}/session")
|
||||||
resp = _client(db, user).post(f"/api/plex/stream/{started.rating_key}/session")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
assert resp.status_code == 200, resp.text
|
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"})
|
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)
|
demo = auth_routes.get_or_create_demo_user(db)
|
||||||
db.refresh(demo)
|
db.refresh(demo)
|
||||||
assert demo.last_login_at is None
|
assert demo.last_login_at is None
|
||||||
@@ -95,3 +95,13 @@ def test_the_admin_user_list_carries_last_login(as_user, db, make_user):
|
|||||||
|
|
||||||
assert rows["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30")
|
assert rows["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30")
|
||||||
assert rows[never.email]["last_login_at"] is None
|
assert rows[never.email]["last_login_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_account_sees_its_own_last_sign_in(client, db, make_user):
|
||||||
|
# Settings → Account shows it (prod UAT 2026-09-19: only the admin's user list did).
|
||||||
|
make_user(email="me@example.com", password=_PW)
|
||||||
|
client.post("/auth/password-login", json={"email": "me@example.com", "password": _PW})
|
||||||
|
|
||||||
|
stamp = client.get("/api/me").json()["last_login_at"]
|
||||||
|
|
||||||
|
assert _recent(datetime.fromisoformat(stamp))
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""What a person types is a literal inside a LIKE pattern, never a wildcard (DB-lane).
|
||||||
|
|
||||||
|
`%` and `_` are LIKE wildcards. The feed's channel-name match and the live search's term fold both
|
||||||
|
built their pattern from the typed text as it was, so `my_channel` also matched `myXchannel`, and a
|
||||||
|
term already present as part of a longer word was never folded in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models import Channel, Subscription, Video
|
||||||
|
from app.routes.search import _fold_term
|
||||||
|
|
||||||
|
|
||||||
|
def _video(vid: str, ch: str, title: str, search_terms: str | None = None) -> Video:
|
||||||
|
return Video(
|
||||||
|
id=vid,
|
||||||
|
channel_id=ch,
|
||||||
|
published_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
title=title,
|
||||||
|
is_short=False,
|
||||||
|
live_status="none",
|
||||||
|
via_search=False,
|
||||||
|
via_explore=False,
|
||||||
|
search_terms=search_terms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_underscore_in_a_feed_search_matches_only_itself(db, as_user, make_user):
|
||||||
|
user = make_user()
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Channel(id="UCunder", title="build_log daily", subscriber_count=1),
|
||||||
|
Channel(id="UClookalike", title="buildXlog daily", subscriber_count=1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Subscription(user_id=user.id, channel_id=ch, priority=0, hidden=False)
|
||||||
|
for ch in ("UCunder", "UClookalike")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
_video("vidUnder", "UCunder", "Nothing in common"),
|
||||||
|
_video("vidLook", "UClookalike", "Nothing in common either"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = as_user(user).get("/api/feed?q=build_log&show=all&limit=50")
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert {it["id"] for it in resp.json()["items"]} == {"vidUnder"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_term_is_folded_in_unless_it_is_already_a_whole_term(db):
|
||||||
|
db.add(Channel(id="UCfold", title="Fold", subscriber_count=1))
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
_video("vidWord", "UCfold", "a", search_terms="category"),
|
||||||
|
_video("vidWild", "UCfold", "b", search_terms="catXdog"),
|
||||||
|
_video("vidHas", "UCfold", "c", search_terms="old cat_dog new"),
|
||||||
|
_video("vidNone", "UCfold", "d"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
_fold_term(db, ["vidWord", "vidWild", "vidHas", "vidNone"], "cat")
|
||||||
|
_fold_term(db, ["vidWild", "vidHas"], "cat_dog")
|
||||||
|
db.commit()
|
||||||
|
db.expire_all()
|
||||||
|
|
||||||
|
terms = dict(db.execute(select(Video.id, Video.search_terms)).all())
|
||||||
|
assert terms == {
|
||||||
|
"vidWord": "category cat",
|
||||||
|
"vidWild": "catXdog cat cat_dog",
|
||||||
|
"vidHas": "old cat_dog new cat",
|
||||||
|
"vidNone": "cat",
|
||||||
|
}
|
||||||
@@ -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"),
|
request=httpx.Request("POST", "https://oauth2.googleapis.com/token"),
|
||||||
)
|
)
|
||||||
client = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub
|
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(
|
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_optional", lambda value: None)
|
||||||
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
|
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
@@ -213,6 +213,20 @@ def _client() -> TestClient:
|
|||||||
def _health():
|
def _health():
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/admin/logs/facets")
|
||||||
|
def _log_console(fail: bool = False):
|
||||||
|
if fail:
|
||||||
|
raise HTTPException(status_code=400, detail={"code": "bad_cursor"})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/messages/unread_count")
|
||||||
|
def _unread():
|
||||||
|
return {"count": 0}
|
||||||
|
|
||||||
|
@app.post("/api/messages/unread_count")
|
||||||
|
def _unread_write():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/boom")
|
@app.get("/boom")
|
||||||
def _boom():
|
def _boom():
|
||||||
raise RuntimeError("kaboom")
|
raise RuntimeError("kaboom")
|
||||||
@@ -307,6 +321,43 @@ def test_the_healthcheck_is_logged_at_debug_so_it_cannot_drown_the_log(sink):
|
|||||||
assert len(sink.lines) == before
|
assert len(sink.lines) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_log_consoles_own_reads_are_debug_but_its_failures_are_not(sink):
|
||||||
|
http = logging.getLogger("siftlode.http")
|
||||||
|
http.setLevel(logs.TRACE)
|
||||||
|
client = _client()
|
||||||
|
client.get("/api/admin/logs/facets")
|
||||||
|
assert sink.objects()[-1]["level"] == "DEBUG"
|
||||||
|
client.get("/api/admin/logs/facets?fail=1")
|
||||||
|
assert sink.objects()[-1]["level"] == "WARNING"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_polled_read_is_debug_a_write_to_the_same_route_is_not(sink):
|
||||||
|
# One open tab polled the unread counts, /api/me and the download index every 30 s: at INFO
|
||||||
|
# that was ~95% of the stored log on prod.
|
||||||
|
http = logging.getLogger("siftlode.http")
|
||||||
|
http.setLevel(logs.TRACE)
|
||||||
|
client = _client()
|
||||||
|
r = client.get("/api/messages/unread_count")
|
||||||
|
assert sink.objects()[-1]["level"] == "DEBUG"
|
||||||
|
# ...and says so, for the browser's copy of the line (frontend lib/http.ts).
|
||||||
|
assert r.headers["X-Log-Quiet"] == "1"
|
||||||
|
r = client.post("/api/messages/unread_count")
|
||||||
|
assert sink.objects()[-1]["level"] == "INFO"
|
||||||
|
assert "X-Log-Quiet" not in r.headers
|
||||||
|
client.get("/api/videos/abc")
|
||||||
|
assert sink.objects()[-1]["level"] == "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_polled_route_is_a_route_the_app_has():
|
||||||
|
# The list names route TEMPLATES; one renamed on the router side would silently fall back to
|
||||||
|
# INFO, so the list is held to the app's own routes.
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
# The schema, not `app.routes`: FastAPI 0.141 keeps an included router behind a wrapper there.
|
||||||
|
gets = {path for path, ops in app.openapi()["paths"].items() if "get" in ops}
|
||||||
|
assert logs._POLLED_ROUTES - gets == set()
|
||||||
|
|
||||||
|
|
||||||
def test_a_failing_request_is_logged_by_status_and_an_unhandled_error_is_logged_at_all(sink):
|
def test_a_failing_request_is_logged_by_status_and_an_unhandled_error_is_logged_at_all(sink):
|
||||||
client = _client()
|
client = _client()
|
||||||
client.get("/api/videos/missing/deeper") # 404 from the router
|
client.get("/api/videos/missing/deeper") # 404 from the router
|
||||||
|
|||||||
@@ -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, src.id).updated_at > old
|
||||||
assert db.get(Playlist, dst.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):
|
def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
|
||||||
user = make_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()
|
rows = as_user(user).get("/api/playlists").json()
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
assert rows[0]["updated_at"] # the rail cannot sort by a field the API never sends
|
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
|
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."""
|
"""Drive the endpoint with the Plex link satisfied and the background push recorded."""
|
||||||
pushes: list[tuple] = []
|
pushes: list[tuple] = []
|
||||||
monkeypatch.setattr(state, "_configured_cache", True) # pass the instance-setup request gate
|
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",
|
"push_state_to_plex",
|
||||||
lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)),
|
lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)),
|
||||||
)
|
)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"})
|
||||||
resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"})
|
assert resp.status_code == 200, resp.text
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
return pushes
|
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.
|
# The regression: status "new" + a position IS the in-progress shape, and it must push.
|
||||||
it, user = _seed(db, status="new", position=92)
|
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
|
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.
|
# 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)
|
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
|
# `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
|
# endpoint's own docstring. The old status-only guard pushed here anyway, un-marking an item on
|
||||||
# Plex that the user had merely hidden.
|
# Plex that the user had merely hidden.
|
||||||
it, user = _seed(db, status="hidden", position=0)
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _get(db, user, monkeypatch, path):
|
def _get(db, user, monkeypatch, override_dep, path):
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
return TestClient(app).get(path)
|
||||||
return TestClient(app).get(path)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _order(r) -> list[str]:
|
def _order(r) -> list[str]:
|
||||||
return [i["title"] for i in r.json()["items"]]
|
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."""
|
"""The order the page had before it grew a control — a franchise reads in release order."""
|
||||||
user = _seed(db)
|
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 r.status_code == 200
|
||||||
assert _order(r) == ["Zulu", "Mike", "Series", "Alpha"]
|
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)
|
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"]
|
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"]
|
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".
|
# The show has no rating, so it sinks — it is not "rated 0".
|
||||||
assert _order(r) == ["Zulu", "Mike", "Alpha", "Series"]
|
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
|
"""The show leg needs the rollup's `resumed_at`; with only a status map this key would order
|
||||||
every show as never-watched."""
|
every show as never-watched."""
|
||||||
user = _seed(db)
|
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.
|
# Series (episode resumed NOW) > Zulu (watched 2 days ago) > the two never-touched, title-asc.
|
||||||
assert _order(r) == ["Series", "Zulu", "Alpha", "Mike"]
|
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
|
"""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."""
|
filter chip have nothing to show but `17045` until these are resolved."""
|
||||||
user = _seed(db)
|
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 r.status_code == 200
|
||||||
assert [c["title"] for c in r.json()["collections"]] == ["Franchise"]
|
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
|
"""`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."""
|
200 KB answer to a question about zero ids."""
|
||||||
user = _seed(db)
|
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.status_code == 200
|
||||||
assert r.json()["collections"] == []
|
assert r.json()["collections"] == []
|
||||||
|
|||||||
@@ -58,44 +58,44 @@ def _seed(db):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _get(db, user, monkeypatch, path):
|
def _get(db, user, monkeypatch, override_dep, path):
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
return TestClient(app).get(path)
|
||||||
return TestClient(app).get(path)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _titles(resp):
|
def _titles(resp):
|
||||||
return {i["title"] for i in resp.json()["items"]}
|
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)
|
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"}
|
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)
|
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"}
|
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)
|
user = _seed(db)
|
||||||
resp = _get(
|
resp = _get(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
override_dep,
|
||||||
"/api/plex/library?scope=movie&collections=A,B&collection_mode=all",
|
"/api/plex/library?scope=movie&collections=A,B&collection_mode=all",
|
||||||
)
|
)
|
||||||
assert _titles(resp) == {"A and B"}
|
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.
|
# 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)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
@@ -127,14 +127,16 @@ def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch):
|
|||||||
db.commit()
|
db.commit()
|
||||||
card = next(
|
card = next(
|
||||||
c
|
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"
|
if c["id"] == "A"
|
||||||
)
|
)
|
||||||
assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0)
|
assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0)
|
||||||
assert card["total_runtime_s"] == 5400 # 3600 + 1800
|
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
|
# 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).
|
# rejected 409 — BEFORE any Plex call (the guard runs first, so the DB lane can assert it).
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
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")
|
user = User(email="dup@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
app.dependency_overrides[admin_user] = lambda: user
|
override_dep(admin_user, lambda: user)
|
||||||
try:
|
r = TestClient(app).post(
|
||||||
r = TestClient(app).post(
|
"/api/plex/collections",
|
||||||
"/api/plex/collections",
|
json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
|
||||||
json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
|
)
|
||||||
)
|
assert r.status_code == 409, r.text
|
||||||
assert r.status_code == 409, r.text
|
assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"}
|
||||||
assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"}
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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) +
|
# The Collection info page: hero aggregates (year range, most-restrictive cert, total runtime) +
|
||||||
# members ordered by release year then title.
|
# members ordered by release year then title.
|
||||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
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")
|
user = User(email="cd@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
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["title"] == "Coll A" and d["summary"] == "S"
|
||||||
assert d["year_min"] == 1992 and d["year_max"] == 1996
|
assert d["year_min"] == 1992 and d["year_max"] == 1996
|
||||||
assert d["content_rating"] == "PG" # most restrictive of {G, PG}
|
assert d["content_rating"] == "PG" # most restrictive of {G, PG}
|
||||||
@@ -270,38 +269,32 @@ def _seed_editable(db):
|
|||||||
return lib, user
|
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(state, "_configured_cache", True)
|
||||||
monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub)
|
monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub)
|
||||||
monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync)
|
monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
app.dependency_overrides[admin_user] = lambda: user
|
override_dep(admin_user, lambda: user)
|
||||||
return TestClient(app)
|
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)
|
_lib, user = _seed_editable(db)
|
||||||
stub = _CollStub()
|
stub = _CollStub()
|
||||||
client = _admin_client(db, user, monkeypatch, stub)
|
client = _admin_client(db, user, monkeypatch, override_dep, stub)
|
||||||
try:
|
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
assert r.status_code == 200, r.text
|
||||||
assert r.status_code == 200, r.text
|
assert "rename_collection" in stub.calls
|
||||||
assert "rename_collection" in stub.calls
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
_lib, user = _seed_editable(db)
|
||||||
stub = _CollStub()
|
stub = _CollStub()
|
||||||
client = _admin_client(db, user, monkeypatch, stub)
|
client = _admin_client(db, user, monkeypatch, override_dep, stub)
|
||||||
try:
|
r = client.delete("/api/plex/collections/A/items/m1")
|
||||||
r = client.delete("/api/plex/collections/A/items/m1")
|
assert r.status_code == 200, r.text
|
||||||
assert r.status_code == 200, r.text
|
assert "remove_collection_item" in stub.calls
|
||||||
assert "remove_collection_item" in stub.calls
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _resync_create(db, plex, lib, rk):
|
def _resync_create(db, plex, lib, rk):
|
||||||
@@ -312,25 +305,22 @@ def _resync_create(db, plex, lib, rk):
|
|||||||
return col
|
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)
|
_lib, user = _seed_editable(db)
|
||||||
stub = _CollStub()
|
stub = _CollStub()
|
||||||
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_create)
|
client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create)
|
||||||
try:
|
r = client.post(
|
||||||
r = client.post(
|
"/api/plex/collections",
|
||||||
"/api/plex/collections",
|
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
)
|
||||||
)
|
assert r.status_code == 200, r.text
|
||||||
assert r.status_code == 200, r.text
|
assert "create_collection" in stub.calls
|
||||||
assert "create_collection" in stub.calls
|
body = r.json()
|
||||||
body = r.json()
|
assert body["id"] == "NEW" # the stub's new ratingKey
|
||||||
assert body["id"] == "NEW" # the stub's new ratingKey
|
assert body["editable"] is True # create marks the new collection editable
|
||||||
assert body["editable"] is True # create marks the new collection editable
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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 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".
|
# the honest "reconcile on the next sync" detail, not a false "write failed".
|
||||||
_lib, user = _seed_editable(db)
|
_lib, user = _seed_editable(db)
|
||||||
@@ -339,34 +329,28 @@ def test_create_resync_failure_is_422(db, monkeypatch):
|
|||||||
def _boom(db_, plex, lib_, rk):
|
def _boom(db_, plex, lib_, rk):
|
||||||
raise PlexError("read failed")
|
raise PlexError("read failed")
|
||||||
|
|
||||||
client = _admin_client(db, user, monkeypatch, stub, resync=_boom)
|
client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_boom)
|
||||||
try:
|
r = client.post(
|
||||||
r = client.post(
|
"/api/plex/collections",
|
||||||
"/api/plex/collections",
|
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
)
|
||||||
)
|
assert r.status_code == 422, r.text
|
||||||
assert r.status_code == 422, r.text
|
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
||||||
assert "reconcile" in r.json()["detail"].lower()
|
assert "create_collection" in stub.calls
|
||||||
assert "create_collection" in stub.calls
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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
|
# 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).
|
# is never reached (stub.calls holds only the mutating call).
|
||||||
_lib, user = _seed_editable(db)
|
_lib, user = _seed_editable(db)
|
||||||
stub = _CollStub(fail_on="rename_collection")
|
stub = _CollStub(fail_on="rename_collection")
|
||||||
client = _admin_client(db, user, monkeypatch, stub)
|
client = _admin_client(db, user, monkeypatch, override_dep, stub)
|
||||||
try:
|
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
assert r.status_code == 422, r.text
|
||||||
assert r.status_code == 422, r.text
|
assert r.json()["detail"] == {"code": "refused", "reason": "plex_write_failed"}
|
||||||
assert "write failed" in r.json()["detail"].lower()
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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
|
# 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
|
# 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.
|
# 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()
|
db_.flush()
|
||||||
raise PlexError("read failed")
|
raise PlexError("read failed")
|
||||||
|
|
||||||
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_boom)
|
client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_boom)
|
||||||
try:
|
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
assert r.status_code == 422, r.text
|
||||||
assert r.status_code == 422, r.text
|
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
||||||
assert "reconcile" in r.json()["detail"].lower()
|
assert "rename_collection" in stub.calls # the write DID happen
|
||||||
assert "rename_collection" in stub.calls # the write DID happen
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
row = (
|
row = (
|
||||||
db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
|
db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
|
||||||
)
|
)
|
||||||
assert row.title == "My Coll" # the partial resync upsert was rolled back
|
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
|
# 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).
|
# 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)
|
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()
|
db.commit()
|
||||||
card = next(
|
card = next(
|
||||||
c
|
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"
|
if c["id"] == "S"
|
||||||
)
|
)
|
||||||
assert card["art"] == ["s2", "s1"] # show members, best-rated first
|
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) ---------------------------------------------------------------
|
# ---- 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
|
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
return TestClient(app).get(path)
|
||||||
return TestClient(app).get(path)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _user(db, email):
|
def _user(db, email):
|
||||||
@@ -208,7 +205,7 @@ def _user(db, email):
|
|||||||
return u
|
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 —
|
"""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."""
|
only markers/streams (which need the live meta) degrade."""
|
||||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
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()
|
db.commit()
|
||||||
|
|
||||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True))
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["imdb_rating"] == 8.7
|
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
|
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)."""
|
"""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)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
@@ -261,7 +258,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
|
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.status_code == 200, resp.text
|
||||||
assert resp.json()["imdb_id"] == "tt0068646"
|
assert resp.json()["imdb_id"] == "tt0068646"
|
||||||
db.refresh(mv)
|
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"]
|
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
|
"""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."""
|
live meta the endpoint fetches for markers/streams."""
|
||||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
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()
|
db.commit()
|
||||||
|
|
||||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
|
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
|
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."""
|
"""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)
|
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
@@ -324,7 +321,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
|
|||||||
raise RuntimeError("plex unavailable")
|
raise RuntimeError("plex unavailable")
|
||||||
|
|
||||||
monkeypatch.setattr(detail_routes, "_show_enrichment", _boom)
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()["show"]
|
body = resp.json()["show"]
|
||||||
assert body["imdb_rating"] == 9.1
|
assert body["imdb_rating"] == 9.1
|
||||||
@@ -334,7 +331,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
|
|||||||
assert resp.json()["related"] == []
|
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 +
|
"""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."""
|
stamped) so subsequent views serve from the row."""
|
||||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
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.
|
# _show_enrichment returns (live meta, no related) → the fill branch runs.
|
||||||
monkeypatch.setattr(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, []))
|
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.status_code == 200, resp.text
|
||||||
assert resp.json()["show"]["imdb_id"] == "tt0068646"
|
assert resp.json()["show"]["imdb_id"] == "tt0068646"
|
||||||
db.refresh(show)
|
db.refresh(show)
|
||||||
|
|||||||
@@ -120,24 +120,21 @@ def _seed(db):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _get(db, user, monkeypatch, path):
|
def _get(db, user, monkeypatch, override_dep, path):
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
client = TestClient(app) # no context manager: skip lifespan
|
||||||
client = TestClient(app) # no context manager: skip lifespan
|
return client.get(path)
|
||||||
return client.get(path)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _ids(cards):
|
def _ids(cards):
|
||||||
return [c["id"] for c in 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)
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
d = resp.json()
|
d = resp.json()
|
||||||
assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"}
|
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
|
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
|
# 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.
|
# movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
|
||||||
user = _seed(db)
|
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"]}
|
art = {g["value"]: g["art"] for g in d["genres"]}
|
||||||
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
|
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
|
||||||
assert art["Comedy"] == ["m2"]
|
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)
|
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"])
|
ids = _ids(d["continue_watching"])
|
||||||
# The in-progress movie and the show with an in-progress episode are both present; the watched
|
# 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).
|
# 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
|
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
|
# 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.
|
# (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.
|
# A hidden started movie must be excluded.
|
||||||
@@ -207,13 +204,15 @@ def test_continue_watching_null_status_in_hidden_out(db, monkeypatch):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
ids = _ids(
|
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 "mn" in ids # NULL-status started movie IS in progress
|
||||||
assert "mh" not in ids # hidden is excluded
|
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
|
# 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).
|
# 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)
|
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()
|
db.commit()
|
||||||
|
|
||||||
ids = _ids(
|
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
|
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)
|
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"]
|
bw = d["because_you_watched"]
|
||||||
assert bw is not None
|
assert bw is not None
|
||||||
assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie"
|
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"]
|
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)
|
_seed(db)
|
||||||
fresh = User(email="fresh-discover@example.com")
|
fresh = User(email="fresh-discover@example.com")
|
||||||
db.add(fresh)
|
db.add(fresh)
|
||||||
db.commit()
|
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.
|
# A user with no watched-and-enriched movie gets no recommendation rail.
|
||||||
assert d["because_you_watched"] is None
|
assert d["because_you_watched"] is None
|
||||||
assert d["continue_watching"] == []
|
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)
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
items = resp.json()["items"]
|
items = resp.json()["items"]
|
||||||
assert len(items) <= 2
|
assert len(items) <= 2
|
||||||
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
|
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 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.
|
# the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode.
|
||||||
user = _seed(db)
|
user = _seed(db)
|
||||||
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e2")).scalars().one()
|
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.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW))
|
||||||
db.commit()
|
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}
|
by_id = {c["id"]: c for c in rw}
|
||||||
assert "m1" in by_id and by_id["m1"]["type"] == "movie"
|
assert "m1" in by_id and by_id["m1"]["type"] == "movie"
|
||||||
assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode"
|
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"}
|
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
|
# 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.
|
# in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind.
|
||||||
user = _seed(db)
|
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)
|
ids = _ids(items)
|
||||||
assert "m1" not in ids # watched → excluded
|
assert "m1" not in ids # watched → excluded
|
||||||
assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles
|
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
|
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)
|
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"])
|
assert all(c["type"] == "movie" for c in d["recently_added"])
|
||||||
# The show drops out of continue watching under the movie scope.
|
# The show drops out of continue watching under the movie scope.
|
||||||
assert _ids(d["continue_watching"]) == ["m3"]
|
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
|
# 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.
|
# filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop.
|
||||||
user = _seed(db)
|
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.
|
# 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"}
|
assert set(_ids(d["recently_added"])) == {"m1", "m3"}
|
||||||
# Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded).
|
# 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
|
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)
|
user = _seed(db)
|
||||||
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy").json()[
|
items = _get(
|
||||||
"items"
|
db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&genres=Comedy"
|
||||||
]
|
).json()["items"]
|
||||||
# Only the one Comedy movie qualifies under the filter.
|
# Only the one Comedy movie qualifies under the filter.
|
||||||
assert _ids(items) == ["m2"]
|
assert _ids(items) == ["m2"]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from app.main import app
|
|||||||
from app.models import PlexItem, PlexLibrary, PlexShow, User
|
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."""
|
"""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)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
@@ -33,18 +33,15 @@ def test_facets_returns_studio_vocabulary(db, monkeypatch):
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
resp = TestClient(app).get("/api/plex/facets?scope=movie")
|
||||||
resp = TestClient(app).get("/api/plex/facets?scope=movie")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
studios = {s["value"]: s["count"] for s in resp.json()["studios"]}
|
studios = {s["value"]: s["count"] for s in resp.json()["studios"]}
|
||||||
assert studios == {"Pixar": 2, "A24": 1}
|
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)
|
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
db.flush()
|
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.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 's1'"))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
|
||||||
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
|
resp = client.get("/api/plex/facets?scope=show")
|
||||||
resp = client.get("/api/plex/facets?scope=show")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
# The scalar-null show contributes no genres — and, crucially, doesn't abort the whole endpoint.
|
# 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}
|
return {"user": user}
|
||||||
|
|
||||||
|
|
||||||
def _get(db, user, monkeypatch, path):
|
def _get(db, user, monkeypatch, override_dep, path):
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
return TestClient(app).get(path)
|
||||||
return TestClient(app).get(path)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _ids(body):
|
def _ids(body):
|
||||||
@@ -115,55 +112,75 @@ def _ids(body):
|
|||||||
|
|
||||||
|
|
||||||
class TestFieldScopedSearch:
|
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)
|
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
|
# title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out
|
||||||
assert _ids(body) == ["mp", "ms", "mt"]
|
assert _ids(body) == ["mp", "ms", "mt"]
|
||||||
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
|
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)
|
s = _seed(db)
|
||||||
default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json()
|
default = _get(
|
||||||
scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json()
|
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 _ids(default) == _ids(scoped) == ["mp", "ms", "mt"]
|
||||||
assert default["total"] == scoped["total"]
|
assert default["total"] == scoped["total"]
|
||||||
assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]]
|
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)
|
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 _ids(body) == ["mt"]
|
||||||
assert body["total"] == 1
|
assert body["total"] == 1
|
||||||
assert body["episodes"] == [] # non-episode field scopes suppress the episode section
|
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)
|
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"]
|
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)
|
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"]
|
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)
|
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["items"] == []
|
||||||
assert body["total"] == 0
|
assert body["total"] == 0
|
||||||
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
|
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)
|
s = _seed(db)
|
||||||
body = _get(
|
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()
|
).json()
|
||||||
# movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty
|
# movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty
|
||||||
assert body["items"] == []
|
assert body["items"] == []
|
||||||
assert body["episodes"] == []
|
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)
|
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"]
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
VIEWER = 41 # the id of the account the test client is signed in as
|
||||||
|
|
||||||
|
|
||||||
class _Session:
|
class _Session:
|
||||||
def __init__(self, session_id: str, directory, running: bool = False, audio_langs=None):
|
def __init__(self, session_id: str, directory, running: bool = False, audio_langs=None):
|
||||||
self.id = session_id
|
self.id = session_id
|
||||||
@@ -59,14 +62,11 @@ def sessions(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(monkeypatch):
|
def client(monkeypatch, override_dep):
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
user = User(email="hls@example.com")
|
user = User(id=VIEWER, email="hls@example.com")
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
return TestClient(app)
|
||||||
yield TestClient(app)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.pop(current_user, None)
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, sessions):
|
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.mkdir()
|
||||||
(d / "seg_11.ts").write_bytes(b"") # its predecessor, so seg_12 is the next one
|
(d / "seg_11.ts").write_bytes(b"") # its predecessor, so seg_12 is the next one
|
||||||
(d / "seg_12.ts").write_bytes(b"mine")
|
(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")
|
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert r.content == b"mine"
|
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(
|
def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
|
||||||
client, tmp_path, sessions
|
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.mkdir()
|
||||||
(theirs / "seg_11.ts").write_bytes(b"")
|
(theirs / "seg_11.ts").write_bytes(b"")
|
||||||
(theirs / "seg_12.ts").write_bytes(b"theirs")
|
(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")
|
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
||||||
assert r.status_code == 404, r.text
|
assert r.status_code == 404, r.text
|
||||||
assert b"theirs" not in r.content
|
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(
|
monkeypatch.setattr(
|
||||||
plex_stream,
|
plex_stream,
|
||||||
"start_session",
|
"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")
|
r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8")
|
||||||
assert r.status_code == 404, r.text
|
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 = tmp_path / "m1_0_0_+0.00"
|
||||||
d.mkdir()
|
d.mkdir()
|
||||||
(d / "seg_0.ts").write_bytes(b"x") # the session is producing; seg_99999 is simply not next
|
(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()
|
started = time.monotonic()
|
||||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts")
|
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts")
|
||||||
elapsed = time.monotonic() - started
|
elapsed = time.monotonic() - started
|
||||||
@@ -150,7 +161,7 @@ def test_a_multi_audio_segment_beyond_the_region_fails_at_once_too(
|
|||||||
d.mkdir()
|
d.mkdir()
|
||||||
(d / "seg_0_0.ts").write_bytes(b"x")
|
(d / "seg_0_0.ts").write_bytes(b"x")
|
||||||
(d / "seg_a0_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()
|
started = time.monotonic()
|
||||||
r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}")
|
r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}")
|
||||||
elapsed = time.monotonic() - started
|
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.mkdir()
|
||||||
(d / "seg_a0_0.ts").write_bytes(b"x")
|
(d / "seg_a0_0.ts").write_bytes(b"x")
|
||||||
(d / "seg_a0_1.ts").write_bytes(b"audio")
|
(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")
|
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_a0_1.ts")
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert r.content == b"audio"
|
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."""
|
its playlists and its segments and nothing else, so any other name is answerable now."""
|
||||||
d = tmp_path / "m1_0_0_+0.00"
|
d = tmp_path / "m1_0_0_+0.00"
|
||||||
d.mkdir()
|
d.mkdir()
|
||||||
sessions["m1"] = _Session("sid-a", d, running=True)
|
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
r = client.get("/api/plex/stream/m1/hls/sid-a/nope.m3u8")
|
r = client.get("/api/plex/stream/m1/hls/sid-a/nope.m3u8")
|
||||||
elapsed = time.monotonic() - started
|
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."""
|
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 = tmp_path / "m1_0_0_+0.00"
|
||||||
d.mkdir()
|
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")
|
r = client.get(f"/api/plex/stream/m1/hls/sid-a/seg_{'9' * digits}.ts")
|
||||||
assert r.status_code == 404, r.text
|
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 = tmp_path / "m1_0_0_+0.00"
|
||||||
d.mkdir()
|
d.mkdir()
|
||||||
(d / "seg_11.ts").write_bytes(b"")
|
(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.
|
# 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)
|
monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True)
|
||||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
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 = tmp_path / "m1_0_0_+0.00"
|
||||||
d.mkdir()
|
d.mkdir()
|
||||||
(d / "seg_5.ts").write_bytes(b"x")
|
(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()
|
started = time.monotonic()
|
||||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_6.ts")
|
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_6.ts")
|
||||||
elapsed = time.monotonic() - started
|
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)."""
|
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 = tmp_path / "m1_0_0_+0.00"
|
||||||
single.mkdir()
|
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 = tmp_path / "m2_0_multi_+0.00"
|
||||||
multi.mkdir()
|
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
|
# 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.
|
# 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.
|
# 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[(VIEWER, "m1")], "index.m3u8")
|
||||||
assert plex_stream.artifact_may_arrive(sessions["m1"], "seg_0.ts")
|
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m1")], "seg_0.ts")
|
||||||
assert plex_stream.artifact_may_arrive(sessions["m2"], "master.m3u8")
|
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "master.m3u8")
|
||||||
assert plex_stream.artifact_may_arrive(sessions["m2"], "stream_a1.m3u8")
|
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "stream_a1.m3u8")
|
||||||
assert plex_stream.artifact_may_arrive(sessions["m2"], "seg_a1_0.ts")
|
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "seg_a1_0.ts")
|
||||||
|
|||||||
@@ -41,34 +41,33 @@ def _seed(db):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _get(db, user, monkeypatch, path):
|
def _get(db, user, monkeypatch, override_dep, path):
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
return TestClient(app).get(path)
|
||||||
return TestClient(app).get(path)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _titles(resp):
|
def _titles(resp):
|
||||||
return {i["title"] for i in resp.json()["items"]}
|
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)
|
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 resp.status_code == 200, resp.text
|
||||||
assert _titles(resp) == {"Heist Movie"}
|
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)
|
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"}
|
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)
|
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"] == []
|
assert resp.json()["items"] == []
|
||||||
|
|||||||
@@ -56,54 +56,55 @@ def _seed(db):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _get(db, user, monkeypatch, path):
|
def _get(db, user, monkeypatch, override_dep, path):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
state, "_configured_cache", True
|
state, "_configured_cache", True
|
||||||
) # the request gate 503s an unconfigured app
|
) # the request gate 503s an unconfigured app
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
client = TestClient(app) # no context manager: skip lifespan
|
||||||
client = TestClient(app) # no context manager: skip lifespan
|
return client.get(path)
|
||||||
return client.get(path)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _titles(resp):
|
def _titles(resp):
|
||||||
return {i["title"] for i in resp.json()["items"]}
|
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)
|
user = _seed(db)
|
||||||
hu = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu")
|
hu = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu")
|
||||||
en = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en")
|
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.
|
# 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(hu) == {"Magyar Cím", "EN Only"}
|
||||||
assert _titles(en) == {"English Title", "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)
|
user = _seed(db)
|
||||||
hu = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=hu")
|
hu = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=hu")
|
||||||
en = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=en")
|
en = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=en")
|
||||||
hu_genres = {g["value"] for g in hu.json()["genres"]}
|
hu_genres = {g["value"] for g in hu.json()["genres"]}
|
||||||
en_genres = {g["value"] for g in en.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 "Dráma" in hu_genres and "Drama" not in hu_genres
|
||||||
assert "Drama" in en_genres and "Dráma" not in en_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)
|
user = _seed(db)
|
||||||
# The HU genre chip filters the HU column; the same string against the EN column matches nothing.
|
# 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")
|
hit = _get(
|
||||||
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en&genres=Dráma")
|
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 _titles(hit) == {"Magyar Cím"}
|
||||||
assert miss.json()["items"] == []
|
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)
|
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()
|
body = resp.json()
|
||||||
assert body["title"] == "Magyar Cím"
|
assert body["title"] == "Magyar Cím"
|
||||||
assert body["summary"] == "Magyar összefoglaló."
|
assert body["summary"] == "Magyar összefoglaló."
|
||||||
@@ -112,15 +113,15 @@ def test_item_detail_language_and_rating_split(db, monkeypatch):
|
|||||||
assert body["rating"] == 6.5
|
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)
|
user = _seed(db)
|
||||||
# m2 ("EN Only") has no HU twin, so search_vector_hu carries no title tokens for it. HU-mode search
|
# 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.
|
# 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)
|
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)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -142,7 +143,7 @@ def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch):
|
|||||||
user = User(email="rating@example.com")
|
user = User(email="rating@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
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)
|
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)
|
assert "High IMDb" not in _titles(miss)
|
||||||
|
|||||||
@@ -111,44 +111,53 @@ def _seed(db):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _get(db, user, monkeypatch, path):
|
def _get(db, user, monkeypatch, override_dep, path):
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
return TestClient(app).get(path)
|
||||||
return TestClient(app).get(path)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _ids(resp):
|
def _ids(resp):
|
||||||
return [i["id"] for i in resp.json()["items"]]
|
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)
|
user = _seed(db)
|
||||||
resp = _get(
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
# Newest resume, then the watched-only (coalesce → watched_at), then the older resume, then never.
|
# 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"]
|
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)
|
user = _seed(db)
|
||||||
resp = _get(
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
# Recent resume first, then the watched-only show, then the never-watched one (nullslast).
|
# Recent resume first, then the watched-only show, then the never-watched one (nullslast).
|
||||||
assert _ids(resp) == ["sh_recent", "sh_watched", "sh_never"]
|
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)
|
user = _seed(db)
|
||||||
resp = _get(
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
ids = _ids(resp)
|
ids = _ids(resp)
|
||||||
|
|||||||
@@ -25,17 +25,14 @@ from app.plex.query import content_libs
|
|||||||
NAME = "Robert De Niro"
|
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
|
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
|
||||||
return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
movies_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
shows_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", 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)
|
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.add(PlexState(user_id=user.id, item_id=heat.id, status="watched"))
|
||||||
db.commit()
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
|
|
||||||
@@ -106,7 +103,7 @@ def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
|
|||||||
assert body["shows"][0]["type"] == "show"
|
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)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -123,7 +120,7 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch):
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
# An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null).
|
# 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
|
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)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
return TestClient(app).get(path)
|
||||||
return TestClient(app).get(path)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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
|
# 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
|
# 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).
|
# 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))
|
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
|
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.status_code == 200, resp.text
|
||||||
assert resp.json()["biography"] is None # not enriched yet — the bio lands by the next open
|
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
|
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()
|
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.
|
# An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch.
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -284,13 +281,13 @@ def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch):
|
|||||||
scheduled: list[int] = []
|
scheduled: list[int] = []
|
||||||
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
|
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.status_code == 200, resp.text
|
||||||
assert resp.json()["biography"] == "Bio."
|
assert resp.json()["biography"] == "Bio."
|
||||||
assert scheduled == [] # already enriched → no background work
|
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)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -327,7 +324,7 @@ def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch):
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
j = resp.json()
|
j = resp.json()
|
||||||
assert j["biography"] == "Bio here."
|
assert j["biography"] == "Bio here."
|
||||||
|
|||||||
@@ -347,47 +347,41 @@ def _seed_classes(db):
|
|||||||
return lib
|
return lib
|
||||||
|
|
||||||
|
|
||||||
def _client(db, user, monkeypatch):
|
def _client(db, user, monkeypatch, override_dep):
|
||||||
monkeypatch.setattr(state, "_configured_cache", True)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
return TestClient(app)
|
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)
|
_seed_classes(db)
|
||||||
user = User(email="pclass@example.com")
|
user = User(email="pclass@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
client = _client(db, user, monkeypatch)
|
client = _client(db, user, monkeypatch, override_dep)
|
||||||
try:
|
# Software-only transcodes.
|
||||||
# Software-only transcodes.
|
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||||
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
assert sw.status_code == 200, sw.text
|
||||||
assert sw.status_code == 200, sw.text
|
assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
|
||||||
assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
|
# HW transcode + Direct Play, OR-combined.
|
||||||
# HW transcode + Direct Play, OR-combined.
|
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
|
||||||
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
|
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
|
||||||
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
|
# The HW card carries the transcode_hw flag for the 4-way badge.
|
||||||
# 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")
|
||||||
card = next(i for i in both.json()["items"] if i["id"] == "thw")
|
assert card["transcode_hw"] is True
|
||||||
assert card["transcode_hw"] is True
|
# No filter → everything.
|
||||||
# No filter → everything.
|
allm = client.get("/api/plex/library?scope=movie")
|
||||||
allm = client.get("/api/plex/library?scope=movie")
|
assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
|
||||||
assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
_seed_classes(db)
|
||||||
user = User(email="pclassfacet@example.com")
|
user = User(email="pclassfacet@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
client = _client(db, user, monkeypatch)
|
client = _client(db, user, monkeypatch, override_dep)
|
||||||
try:
|
resp = client.get("/api/plex/facets?scope=movie")
|
||||||
resp = client.get("/api/plex/facets?scope=movie")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
playable = resp.json()["playable"]
|
playable = resp.json()["playable"]
|
||||||
# Fixed good→heavy order (not by count), one per seeded class.
|
# 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
|
# 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
|
# 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
|
# 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")
|
user = User(email="pnull@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
client = _client(db, user, monkeypatch)
|
client = _client(db, user, monkeypatch, override_dep)
|
||||||
try:
|
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||||
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
|
||||||
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
|
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
|
||||||
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
|
assert hw.json()["items"] == [] # never HW
|
||||||
assert hw.json()["items"] == [] # never HW
|
facets = client.get("/api/plex/facets?scope=movie")
|
||||||
facets = client.get("/api/plex/facets?scope=movie")
|
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
|
||||||
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
|
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
|
||||||
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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),
|
# 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
|
# 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.
|
# '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")
|
user = User(email="pself@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
client = _client(db, user, monkeypatch)
|
client = _client(db, user, monkeypatch, override_dep)
|
||||||
try:
|
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
|
||||||
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
|
counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
|
||||||
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
|
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
|
# 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
|
# 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.
|
# 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")
|
user = User(email="pnarrow@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
client = _client(db, user, monkeypatch)
|
client = _client(db, user, monkeypatch, override_dep)
|
||||||
try:
|
wide = client.get("/api/plex/facets?scope=movie").json()
|
||||||
wide = client.get("/api/plex/facets?scope=movie").json()
|
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
|
||||||
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range
|
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
|
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
|
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
|
"""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."""
|
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(state, "_configured_cache", True) # pass the request gate
|
||||||
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
|
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
|
||||||
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
|
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
|
captured["transcode"] = transcode
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
id="sid",
|
id="sid",
|
||||||
@@ -67,20 +67,17 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
|
|||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
|
||||||
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
# A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session.
|
||||||
it = _item(db, playable="transcode")
|
it = _item(db, playable="transcode")
|
||||||
user = _user(db)
|
user = _user(db)
|
||||||
captured: dict = {}
|
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.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["mode"] == "hls"
|
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
|
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.
|
# A remux (h264) item takes the SAME HLS path but must NOT re-encode.
|
||||||
it = _item(db, playable="remux", rating_key="m2")
|
it = _item(db, playable="remux", rating_key="m2")
|
||||||
user = _user(db, "remux@example.com")
|
user = _user(db, "remux@example.com")
|
||||||
captured: dict = {}
|
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.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["mode"] == "hls"
|
assert body["mode"] == "hls"
|
||||||
@@ -101,12 +98,12 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch):
|
|||||||
assert captured["transcode"] is False
|
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.
|
# A browser-native file is served raw — start_session is never reached, so no transcode decision.
|
||||||
it = _item(db, playable="direct", rating_key="m3")
|
it = _item(db, playable="direct", rating_key="m3")
|
||||||
user = _user(db, "direct@example.com")
|
user = _user(db, "direct@example.com")
|
||||||
captured: dict = {}
|
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.status_code == 200, resp.text
|
||||||
assert resp.json()["mode"] == "direct"
|
assert resp.json()["mode"] == "direct"
|
||||||
assert "transcode" not in captured # start_session not called
|
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}
|
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(
|
monkeypatch.setattr(
|
||||||
state, "_configured_cache", True
|
state, "_configured_cache", True
|
||||||
) # the request gate 503s an unconfigured app
|
) # the request gate 503s an unconfigured app
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user
|
override_dep(current_user, lambda: user)
|
||||||
try:
|
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
||||||
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
return client.request(method, path, json=json)
|
||||||
return client.request(method, path, json=json)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _get(db, user, monkeypatch, path):
|
def _get(db, user, monkeypatch, override_dep, path):
|
||||||
return _request(db, user, monkeypatch, "GET", path)
|
return _request(db, user, monkeypatch, override_dep, "GET", path)
|
||||||
|
|
||||||
|
|
||||||
class TestUnifiedLibrary:
|
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)
|
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
|
assert r.status_code == 200, r.text
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["total"] == 3 # 2 movies + 1 show, unioned
|
assert body["total"] == 3 # 2 movies + 1 show, unioned
|
||||||
@@ -127,43 +126,53 @@ class TestUnifiedLibrary:
|
|||||||
assert show_item["season_count"] == 1
|
assert show_item["season_count"] == 1
|
||||||
assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched"
|
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)
|
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 body["total"] == 2
|
||||||
assert all(i["type"] == "movie" for i in body["items"])
|
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)
|
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 [i["id"] for i in body["items"]] == ["s1"]
|
||||||
assert body["items"][0]["season_count"] == 1
|
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)
|
s = _seed(db)
|
||||||
# watched: only movie m1 (the show is in_progress, not fully watched)
|
# 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"]
|
assert [i["id"] for i in watched["items"]] == ["m1"]
|
||||||
# in_progress: only the show (no movie is in progress)
|
# 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"]
|
assert [i["id"] for i in inprog["items"]] == ["s1"]
|
||||||
# unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means
|
# 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.)
|
# 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"]
|
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)
|
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"
|
# 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 [i["id"] for i in body["items"]] == ["s1"]
|
||||||
assert body["total"] == 1
|
assert body["total"] == 1
|
||||||
# matching episodes come back grouped in the separate episodes list
|
# matching episodes come back grouped in the separate episodes list
|
||||||
assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"]
|
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)
|
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 body["total"] == 3
|
||||||
assert [i["id"] for i in body["items"]] == [
|
assert [i["id"] for i in body["items"]] == [
|
||||||
"s1",
|
"s1",
|
||||||
@@ -267,7 +276,7 @@ class TestFacetsWithData:
|
|||||||
JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only
|
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."""
|
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)
|
movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
db.add_all([movie_lib, show_lib])
|
db.add_all([movie_lib, show_lib])
|
||||||
@@ -311,7 +320,7 @@ class TestFacetsWithData:
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
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 {g["value"]: g["count"] for g in body["genres"]} == {"Comedy": 2, "Drama": 2}
|
||||||
assert {c["value"]: c["count"] for c in body["content_ratings"]} == {
|
assert {c["value"]: c["count"] for c in body["content_ratings"]} == {
|
||||||
"PG": 1,
|
"PG": 1,
|
||||||
@@ -332,7 +341,7 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
def _movie(self, db, lib_id, rk, title):
|
def _movie(self, db, lib_id, rk, title):
|
||||||
return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=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)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -348,6 +357,7 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
override_dep,
|
||||||
"POST",
|
"POST",
|
||||||
"/api/plex/playlists",
|
"/api/plex/playlists",
|
||||||
{"title": "Faves", "item_rating_key": "m1"},
|
{"title": "Faves", "item_rating_key": "m1"},
|
||||||
@@ -357,7 +367,9 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
assert created.json()["item_count"] == 1
|
assert created.json()["item_count"] == 1
|
||||||
|
|
||||||
# list -> the playlist with its count
|
# 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)]
|
assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)]
|
||||||
|
|
||||||
# add m2, then reorder to [m2, m1]
|
# add m2, then reorder to [m2, m1]
|
||||||
@@ -365,6 +377,7 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
override_dep,
|
||||||
"POST",
|
"POST",
|
||||||
f"/api/plex/playlists/{pid}/items",
|
f"/api/plex/playlists/{pid}/items",
|
||||||
{"item_rating_key": "m2"},
|
{"item_rating_key": "m2"},
|
||||||
@@ -374,24 +387,30 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
override_dep,
|
||||||
"PUT",
|
"PUT",
|
||||||
f"/api/plex/playlists/{pid}/order",
|
f"/api/plex/playlists/{pid}/order",
|
||||||
{"item_rating_keys": ["m2", "m1"]},
|
{"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
|
assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order
|
||||||
|
|
||||||
# remove m1 -> one item left
|
# 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
|
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"]
|
assert [it["id"] for it in after["items"]] == ["m2"]
|
||||||
|
|
||||||
# delete the playlist -> gone from the list
|
# delete the playlist -> gone from the list
|
||||||
_request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}")
|
_request(db, user, monkeypatch, override_dep, "DELETE", f"/api/plex/playlists/{pid}")
|
||||||
assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == []
|
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
|
# 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.
|
# synchronize_session=False path, which the single add/remove roundtrip never exercises.
|
||||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
@@ -402,38 +421,42 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
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"]
|
pid = created.json()["id"]
|
||||||
|
|
||||||
added = _request(
|
added = _request(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
override_dep,
|
||||||
"POST",
|
"POST",
|
||||||
f"/api/plex/playlists/{pid}/items/bulk",
|
f"/api/plex/playlists/{pid}/items/bulk",
|
||||||
{"item_rating_keys": ["m1", "m2", "m3"]},
|
{"item_rating_keys": ["m1", "m2", "m3"]},
|
||||||
)
|
)
|
||||||
assert added.status_code == 200, added.text
|
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(
|
removed = _request(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
override_dep,
|
||||||
"POST",
|
"POST",
|
||||||
f"/api/plex/playlists/{pid}/items/remove-bulk",
|
f"/api/plex/playlists/{pid}/items/remove-bulk",
|
||||||
{"item_rating_keys": ["m2"]},
|
{"item_rating_keys": ["m2"]},
|
||||||
)
|
)
|
||||||
assert removed.status_code == 200, removed.text
|
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"]] == [
|
assert [it["id"] for it in detail["items"]] == [
|
||||||
"m1",
|
"m1",
|
||||||
"m3",
|
"m3",
|
||||||
] # input order preserved, m2 gone
|
] # 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
|
from app.models import PlexCollection
|
||||||
|
|
||||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
@@ -450,13 +473,13 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# unified scope (no library param) -> most-populated first
|
# 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"]
|
assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"]
|
||||||
# name filter
|
# 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"]
|
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
|
# 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).
|
# is what the conversion touched (the st lookup + the delete on ->new).
|
||||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
@@ -468,7 +491,13 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
watched = _request(
|
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.status_code == 200, watched.text
|
||||||
assert watched.json()["status"] == "watched"
|
assert watched.json()["status"] == "watched"
|
||||||
@@ -476,7 +505,15 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
assert row.status == "watched"
|
assert row.status == "watched"
|
||||||
|
|
||||||
# -> new deletes the state row
|
# -> 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 back.status_code == 200, back.text
|
||||||
assert (
|
assert (
|
||||||
db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first()
|
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):
|
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.
|
# 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)
|
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 stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0
|
||||||
assert d.exists()
|
assert d.exists()
|
||||||
|
|
||||||
@@ -179,10 +179,20 @@ class _CapturingProc:
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
_CapturingProc.calls.append((args, kwargs))
|
_CapturingProc.calls.append((args, kwargs))
|
||||||
|
self.killed = False
|
||||||
|
|
||||||
def poll(self):
|
def poll(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
self.killed = True
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self.killed = True
|
||||||
|
|
||||||
|
|
||||||
def _venc(cmd):
|
def _venc(cmd):
|
||||||
"""The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
|
"""The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
|
||||||
@@ -495,7 +505,7 @@ class TestColdStartWarmup:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr(stream, "wait_for", spy)
|
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 s is not None
|
||||||
# seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS.
|
# 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)]
|
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
|
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
|
||||||
|
|
||||||
monkeypatch.setattr(stream, "wait_for", spy)
|
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 s is not None
|
||||||
assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2
|
assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2
|
||||||
|
|
||||||
@@ -522,7 +532,7 @@ class TestColdStartWarmup:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
monkeypatch.setattr(stream, "wait_for", spy)
|
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 s is not None
|
||||||
assert waited == ["seg_0.ts"]
|
assert waited == ["seg_0.ts"]
|
||||||
|
|
||||||
@@ -548,6 +558,7 @@ class TestTranscodeStartSession:
|
|||||||
"plex_transcode_crf": 18,
|
"plex_transcode_crf": 18,
|
||||||
"plex_transcode_max_height": 480,
|
"plex_transcode_max_height": 480,
|
||||||
"plex_max_transcodes": 4,
|
"plex_max_transcodes": 4,
|
||||||
|
"plex_max_sessions": 8,
|
||||||
}[k],
|
}[k],
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -573,7 +584,7 @@ class TestTranscodeStartSession:
|
|||||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||||
)
|
)
|
||||||
s = stream.start_session(
|
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
|
assert s is not None and s.transcode is True and s.hw is False
|
||||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||||
@@ -590,7 +601,7 @@ class TestTranscodeStartSession:
|
|||||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
)
|
)
|
||||||
s = stream.start_session(
|
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 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
|
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.
|
# 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))
|
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
|
||||||
s = stream.start_session(
|
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
|
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]
|
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||||
@@ -635,7 +646,7 @@ class TestTranscodeStartSession:
|
|||||||
|
|
||||||
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||||
s = stream.start_session(
|
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 (
|
assert (
|
||||||
s is not None and s.transcode is True and s.hw is False
|
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)
|
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||||
s = stream.start_session(
|
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 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
|
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):
|
def wait_then_supersede(path, timeout=20.0, proc=None):
|
||||||
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
||||||
# (now-killed) HW proc produced nothing.
|
# (now-killed) HW proc produced nothing.
|
||||||
stream._sessions["12345"] = newer
|
stream._sessions[(7, "12345")] = newer
|
||||||
return False
|
return False
|
||||||
|
|
||||||
monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
|
monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
|
||||||
s = stream.start_session(
|
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.
|
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
|
||||||
assert len(_CapturingProc.calls) == 1
|
assert len(_CapturingProc.calls) == 1
|
||||||
assert s is newer # yielded the currently-registered session, did not clobber it
|
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(
|
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
|
||||||
self, capturing_env, monkeypatch
|
self, capturing_env, monkeypatch
|
||||||
@@ -727,16 +738,16 @@ class TestTranscodeStartSession:
|
|||||||
|
|
||||||
def wait_then_cull(path, timeout=20.0, proc=None):
|
def wait_then_cull(path, timeout=20.0, proc=None):
|
||||||
captured["s"] = stream._sessions[
|
captured["s"] = stream._sessions[
|
||||||
"12345"
|
(7, "12345")
|
||||||
] # the HW session the initial _spawn just registered
|
] # the HW session the initial _spawn just registered
|
||||||
del stream._sessions[
|
del stream._sessions[
|
||||||
"12345"
|
(7, "12345")
|
||||||
] # simulate _enforce_cap dropping the LRU under cap pressure
|
] # simulate _enforce_cap dropping the LRU under cap pressure
|
||||||
return False
|
return False
|
||||||
|
|
||||||
monkeypatch.setattr(stream, "wait_for", wait_then_cull)
|
monkeypatch.setattr(stream, "wait_for", wait_then_cull)
|
||||||
s = stream.start_session(
|
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 (
|
assert (
|
||||||
len(_CapturingProc.calls) == 1
|
len(_CapturingProc.calls) == 1
|
||||||
@@ -752,7 +763,9 @@ class TestTranscodeStartSession:
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
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
|
assert s is not None and s.transcode is False
|
||||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||||
assert _venc(cmd) == "copy"
|
assert _venc(cmd) == "copy"
|
||||||
@@ -896,7 +909,7 @@ class TestAudioLoudnorm:
|
|||||||
)
|
)
|
||||||
if multi:
|
if multi:
|
||||||
monkeypatch.setattr(stream, "_probe_audio_langs", lambda src: ["hun", "eng"])
|
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
|
assert s is not None
|
||||||
(cmd, *_), _ = _CapturingProc.calls[0]
|
(cmd, *_), _ = _CapturingProc.calls[0]
|
||||||
return s, cmd
|
return s, cmd
|
||||||
@@ -945,3 +958,51 @@ class TestAudioLoudnorm:
|
|||||||
# sweep no longer recognises is one that never gets cleaned up.
|
# sweep no longer recognises is one that never gets cleaned up.
|
||||||
s, _ = self._spawn(monkeypatch, on=on)
|
s, _ = self._spawn(monkeypatch, on=on)
|
||||||
assert _SESSION_DIR_RE.match(s.dir.name)
|
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
|
db, make_user, override_dep, monkeypatch, tmp_path
|
||||||
):
|
):
|
||||||
user = make_user(is_suspended=True)
|
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)
|
monkeypatch.setattr(app_state, "_configured_cache", True)
|
||||||
# A validly SIGNED cookie for that account — which is all the old gate looked at.
|
# A validly SIGNED cookie for that account — which is all the old gate looked at.
|
||||||
for mod in (auth, playback):
|
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")
|
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):
|
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
|
"""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."""
|
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):
|
def test_nothing_more_is_queued_while_the_extractor_is_saturated(monkeypatch):
|
||||||
pool = _manual_pool(monkeypatch)
|
pool = _manual_pool(monkeypatch)
|
||||||
for rk in range(20):
|
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)
|
subtitles.ensure(str(rk), 0, at=0.0, viewer=rk)
|
||||||
windows = [a for _, a in pool.jobs if a[3] is not None]
|
windows = [a for _, a in pool.jobs if a[3] is not None]
|
||||||
assert len(windows) == subtitles._MAX_WINDOWS_INFLIGHT
|
assert len(windows) == subtitles._MAX_WINDOWS_INFLIGHT
|
||||||
assert len(pool.jobs) - len(windows) == subtitles._MAX_FULL_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"
|
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 ----------------------------------------------------------------
|
# --- 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"
|
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):
|
def test_a_poll_after_shutdown_queues_nothing(monkeypatch):
|
||||||
pool = _manual_pool(monkeypatch)
|
pool = _manual_pool(monkeypatch)
|
||||||
subtitles.shutdown()
|
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
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
def _client(db, user, monkeypatch):
|
def _client(db, user, monkeypatch, override_dep):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
state, "_configured_cache", True
|
state, "_configured_cache", True
|
||||||
) # the request gate 503s an unconfigured app
|
) # the request gate 503s an unconfigured app
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[current_user] = lambda: user # require_human resolves through this
|
override_dep(current_user, lambda: user) # require_human resolves through this
|
||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -27,56 +27,43 @@ def _user(db):
|
|||||||
return u
|
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)
|
user = _user(db)
|
||||||
try:
|
c = _client(db, user, monkeypatch, override_dep)
|
||||||
c = _client(db, user, monkeypatch)
|
# Same NAME under two sources is allowed (unique is per user+source now).
|
||||||
# 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"})
|
||||||
f = c.post(
|
p = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"b": 2}, "source": "plex"})
|
||||||
"/api/saved-views", json={"name": "Fresh", "filters": {"a": 1}, "source": "feed"}
|
assert f.status_code == 200 and p.status_code == 200, (f.text, p.text)
|
||||||
)
|
|
||||||
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()
|
feed = c.get("/api/saved-views?source=feed").json()
|
||||||
plex = c.get("/api/saved-views?source=plex").json()
|
plex = c.get("/api/saved-views?source=plex").json()
|
||||||
assert [v["name"] for v in feed] == ["Fresh"]
|
assert [v["name"] for v in feed] == ["Fresh"]
|
||||||
assert [v["name"] for v in plex] == ["Fresh"]
|
assert [v["name"] for v in plex] == ["Fresh"]
|
||||||
assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2}
|
assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2}
|
||||||
# A source-less GET keeps the old feed-only behaviour.
|
# 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]
|
assert [v["id"] for v in c.get("/api/saved-views").json()] == [v["id"] for v in feed]
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_is_per_source(db, monkeypatch):
|
def test_default_is_per_source(db, monkeypatch, override_dep):
|
||||||
user = _user(db)
|
user = _user(db)
|
||||||
try:
|
c = _client(db, user, monkeypatch, override_dep)
|
||||||
c = _client(db, user, monkeypatch)
|
fid = c.post("/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"}).json()[
|
||||||
fid = c.post(
|
"id"
|
||||||
"/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"}
|
]
|
||||||
).json()["id"]
|
pid = c.post("/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"}).json()[
|
||||||
pid = c.post(
|
"id"
|
||||||
"/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/{fid}", json={"is_default": True})
|
c.patch(f"/api/saved-views/{pid}", 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).
|
||||||
# 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=feed").json()[0]["is_default"] is True
|
assert c.get("/api/saved-views?source=plex").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()
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
user = _user(db)
|
||||||
try:
|
c = _client(db, user, monkeypatch, override_dep)
|
||||||
c = _client(db, user, monkeypatch)
|
a = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
|
||||||
a = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
|
b = 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 a.status_code == 200
|
assert b.status_code == 409 # same (user, source, name)
|
||||||
assert b.status_code == 409 # same (user, source, name)
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db):
|
|||||||
assert c.enriched_at is None
|
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
|
# 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,
|
# + 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
|
# 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)
|
monkeypatch.setattr(search_mod, "_ingest_candidates", _boom)
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = lambda: db
|
override_dep(get_db, db.info["request"])
|
||||||
app.dependency_overrides[require_human] = lambda: user
|
override_dep(require_human, lambda: user)
|
||||||
try:
|
resp = TestClient(app).get("/api/search/youtube?q=test+term")
|
||||||
resp = TestClient(app).get("/api/search/youtube?q=test+term")
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
assert resp.status_code == 422, resp.text
|
assert resp.status_code == 422, resp.text
|
||||||
assert "YouTube search failed" in resp.json()["detail"]
|
assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"}
|
||||||
|
|||||||
@@ -112,11 +112,15 @@ class TestRssPollResult:
|
|||||||
def _run(self, monkeypatch, *, fetch, apply_, channels=("UC1", "UC2")):
|
def _run(self, monkeypatch, *, fetch, apply_, channels=("UC1", "UC2")):
|
||||||
monkeypatch.setattr(runner, "fetch_channel_feed", fetch)
|
monkeypatch.setattr(runner, "fetch_channel_feed", fetch)
|
||||||
monkeypatch.setattr(runner, "apply_rss_feed", apply_)
|
monkeypatch.setattr(runner, "apply_rss_feed", apply_)
|
||||||
|
# The API fallback has its own DB-lane tests (test_feed_fallback.py); here it reads nothing.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
runner, "run_feed_fallback", lambda db, ids: {"read": 0, "new": 0, "failed": 0}
|
||||||
|
)
|
||||||
return runner.run_rss_poll(_StubDb(), [_StubChannel(c) for c in channels])
|
return runner.run_rss_poll(_StubDb(), [_StubChannel(c) for c in channels])
|
||||||
|
|
||||||
def test_a_healthy_run_reports_no_failures(self, monkeypatch):
|
def test_a_healthy_run_reports_no_failures(self, monkeypatch):
|
||||||
out = self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=lambda db, ch, e: 1)
|
out = self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=lambda db, ch, e: 1)
|
||||||
assert out == {"new": 2, "failed": 0}
|
assert out == {"new": 2, "failed": 0, "fallback": 0}
|
||||||
|
|
||||||
def test_unreadable_feeds_are_counted(self, monkeypatch):
|
def test_unreadable_feeds_are_counted(self, monkeypatch):
|
||||||
# One dead channel among healthy ones: counted, but the run itself is still a run.
|
# One dead channel among healthy ones: counted, but the run itself is still a run.
|
||||||
@@ -128,7 +132,7 @@ class TestRssPollResult:
|
|||||||
out = self._run(
|
out = self._run(
|
||||||
monkeypatch, fetch=fetch, apply_=lambda db, ch, e: 1, channels=("UC1", "UC_dead")
|
monkeypatch, fetch=fetch, apply_=lambda db, ch, e: 1, channels=("UC1", "UC_dead")
|
||||||
)
|
)
|
||||||
assert out == {"new": 1, "failed": 1}
|
assert out == {"new": 1, "failed": 1, "fallback": 0}
|
||||||
|
|
||||||
def test_apply_failures_are_counted_too(self, monkeypatch):
|
def test_apply_failures_are_counted_too(self, monkeypatch):
|
||||||
# A per-row write failure (a constraint clash on one channel) while the rest store fine.
|
# A per-row write failure (a constraint clash on one channel) while the rest store fine.
|
||||||
@@ -143,7 +147,9 @@ class TestRssPollResult:
|
|||||||
apply_=apply_,
|
apply_=apply_,
|
||||||
channels=("UC1", "UC_bad"),
|
channels=("UC1", "UC_bad"),
|
||||||
)
|
)
|
||||||
assert out == {"new": 1, "failed": 1}, "an unstored channel must not read as ok"
|
assert out == {"new": 1, "failed": 1, "fallback": 0}, (
|
||||||
|
"an unstored channel must not read as ok"
|
||||||
|
)
|
||||||
|
|
||||||
def test_many_failures_write_one_warning_with_counts_not_one_per_channel(
|
def test_many_failures_write_one_warning_with_counts_not_one_per_channel(
|
||||||
self, monkeypatch, caplog
|
self, monkeypatch, caplog
|
||||||
@@ -163,7 +169,7 @@ class TestRssPollResult:
|
|||||||
|
|
||||||
caplog.set_level(logging.DEBUG, logger="siftlode.sync")
|
caplog.set_level(logging.DEBUG, logger="siftlode.sync")
|
||||||
out = self._run(monkeypatch, fetch=fetch, apply_=lambda db, ch, e: 1, channels=channels)
|
out = self._run(monkeypatch, fetch=fetch, apply_=lambda db, ch, e: 1, channels=channels)
|
||||||
assert out == {"new": 5, "failed": 35}
|
assert out == {"new": 5, "failed": 35, "fallback": 0}
|
||||||
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
|
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||||
assert len(warnings) == 1
|
assert len(warnings) == 1
|
||||||
assert warnings[0].event == "sync.rss.failed"
|
assert warnings[0].event == "sync.rss.failed"
|
||||||
@@ -195,7 +201,11 @@ class TestRssPollResult:
|
|||||||
|
|
||||||
def test_a_quiet_hour_is_distinguishable_from_an_outage(self, monkeypatch):
|
def test_a_quiet_hour_is_distinguishable_from_an_outage(self, monkeypatch):
|
||||||
quiet = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0)
|
quiet = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0)
|
||||||
assert quiet == {"new": 0, "failed": 0} # same "new", different "failed" — that's the point
|
assert quiet == {
|
||||||
|
"new": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"fallback": 0,
|
||||||
|
} # same "new", different "failed" — that's the point
|
||||||
|
|
||||||
def test_a_total_outage_raises_instead_of_returning_ok(self, monkeypatch):
|
def test_a_total_outage_raises_instead_of_returning_ok(self, monkeypatch):
|
||||||
# Every feed unreachable (the tinyproxy-lost-the-boot-race incident). Returning a result
|
# Every feed unreachable (the tinyproxy-lost-the-boot-race incident). Returning a result
|
||||||
@@ -225,10 +235,43 @@ class TestRssPollResult:
|
|||||||
with pytest.raises(RuntimeError, match="read-only transaction"):
|
with pytest.raises(RuntimeError, match="read-only transaction"):
|
||||||
self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=boom)
|
self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=boom)
|
||||||
|
|
||||||
|
def test_only_the_unreadable_feeds_go_to_the_api_and_what_it_reads_counts(
|
||||||
|
self, monkeypatch, caplog
|
||||||
|
):
|
||||||
|
# A feed that answered 404 is the fallback's; a channel whose rows failed to STORE is not —
|
||||||
|
# the API would hand the same rows to the same broken write.
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fetch(cid):
|
||||||
|
if cid == "UC_dark":
|
||||||
|
raise rss.RssError("404", "http_404")
|
||||||
|
return [{"id": "v"}]
|
||||||
|
|
||||||
|
def apply_(db, ch, entries):
|
||||||
|
if ch.id == "UC_badrow":
|
||||||
|
raise RuntimeError("constraint")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def fallback(db, ids):
|
||||||
|
seen.extend(ids)
|
||||||
|
return {"read": 1, "new": 3, "failed": 0}
|
||||||
|
|
||||||
|
monkeypatch.setattr(runner, "fetch_channel_feed", fetch)
|
||||||
|
monkeypatch.setattr(runner, "apply_rss_feed", apply_)
|
||||||
|
monkeypatch.setattr(runner, "run_feed_fallback", fallback)
|
||||||
|
out = runner.run_rss_poll(
|
||||||
|
_StubDb(), [_StubChannel(c) for c in ("UC_ok", "UC_dark", "UC_badrow")]
|
||||||
|
)
|
||||||
|
assert seen == ["UC_dark"]
|
||||||
|
assert out == {"new": 4, "failed": 2, "fallback": 1}
|
||||||
|
# ...and the one line the stored log keeps says so (an INFO line outside a request is not).
|
||||||
|
(warning,) = [r for r in caplog.records if getattr(r, "event", None) == "sync.rss.failed"]
|
||||||
|
assert warning.attrs["fallback_read"] == 1 and warning.attrs["fallback_new"] == 3
|
||||||
|
|
||||||
def test_no_channels_at_all_is_not_an_outage(self, monkeypatch):
|
def test_no_channels_at_all_is_not_an_outage(self, monkeypatch):
|
||||||
# A fresh instance with nothing subscribed must not report an error every poll.
|
# A fresh instance with nothing subscribed must not report an error every poll.
|
||||||
out = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0, channels=())
|
out = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0, channels=())
|
||||||
assert out == {"new": 0, "failed": 0}
|
assert out == {"new": 0, "failed": 0, "fallback": 0}
|
||||||
|
|
||||||
|
|
||||||
class TestRssErrorVsEmpty:
|
class TestRssErrorVsEmpty:
|
||||||
@@ -246,6 +289,61 @@ class TestRssErrorVsEmpty:
|
|||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(rss.httpx, "get", fake_get)
|
monkeypatch.setattr(rss.httpx, "get", fake_get)
|
||||||
|
monkeypatch.setattr(rss.time, "sleep", lambda s: None)
|
||||||
|
|
||||||
|
def _answers(self, monkeypatch, *answers):
|
||||||
|
"""The endpoint answers each try with the next of `answers` (a status or an exception);
|
||||||
|
returns the list of tries made and the waits between them."""
|
||||||
|
tries, waits = [], []
|
||||||
|
queue = list(answers)
|
||||||
|
|
||||||
|
def fake_get(url, **_):
|
||||||
|
answer = queue.pop(0)
|
||||||
|
tries.append(answer)
|
||||||
|
if isinstance(answer, Exception):
|
||||||
|
raise answer
|
||||||
|
body = self.FEED if answer == 200 else b""
|
||||||
|
return httpx.Response(answer, content=body, request=httpx.Request("GET", url))
|
||||||
|
|
||||||
|
monkeypatch.setattr(rss.httpx, "get", fake_get)
|
||||||
|
monkeypatch.setattr(rss.time, "sleep", waits.append)
|
||||||
|
return tries, waits
|
||||||
|
|
||||||
|
FEED = b"""<?xml version="1.0"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:yt="http://www.youtube.com/xml/schemas/2015">
|
||||||
|
<entry><yt:videoId>abc123</yt:videoId><title>Hello</title></entry>
|
||||||
|
</feed>"""
|
||||||
|
|
||||||
|
def test_a_feed_that_fails_and_then_answers_is_read(self, monkeypatch):
|
||||||
|
# The endpoint's measured behaviour: the same channel 404s, 500s, then answers.
|
||||||
|
tries, waits = self._answers(monkeypatch, 404, 500, 200)
|
||||||
|
assert [e["id"] for e in rss.fetch_channel_feed("UC_flaky")] == ["abc123"]
|
||||||
|
assert (len(tries), waits) == (3, list(rss.RETRY_DELAYS))
|
||||||
|
|
||||||
|
def test_a_dropped_connection_is_tried_again_too(self, monkeypatch):
|
||||||
|
tries, _ = self._answers(monkeypatch, httpx.ConnectError("reset"), 200)
|
||||||
|
assert rss.fetch_channel_feed("UC_blip")
|
||||||
|
assert len(tries) == 2
|
||||||
|
|
||||||
|
def test_a_timeout_is_not_tried_again(self, monkeypatch):
|
||||||
|
# A hung request already cost the full timeout; retrying it would triple a poll over a
|
||||||
|
# dead egress.
|
||||||
|
tries, waits = self._answers(monkeypatch, httpx.ReadTimeout("hung"), 200)
|
||||||
|
with pytest.raises(rss.RssError) as err:
|
||||||
|
rss.fetch_channel_feed("UC_hung")
|
||||||
|
assert (len(tries), waits, err.value.reason) == (1, [], "transport")
|
||||||
|
|
||||||
|
def test_a_feed_that_keeps_failing_raises_its_last_failure(self, monkeypatch):
|
||||||
|
tries, _ = self._answers(monkeypatch, 500, 404, 404)
|
||||||
|
with pytest.raises(rss.RssError) as err:
|
||||||
|
rss.fetch_channel_feed("UC_gone")
|
||||||
|
assert (len(tries), err.value.reason) == (3, "http_404")
|
||||||
|
|
||||||
|
def test_a_refusal_is_not_tried_again(self, monkeypatch):
|
||||||
|
tries, waits = self._answers(monkeypatch, 403)
|
||||||
|
with pytest.raises(rss.RssError) as err:
|
||||||
|
rss.fetch_channel_feed("UC_forbidden")
|
||||||
|
assert (len(tries), waits, err.value.reason) == (1, [], "http_403")
|
||||||
|
|
||||||
def test_non_200_raises(self, monkeypatch):
|
def test_non_200_raises(self, monkeypatch):
|
||||||
self._patch(monkeypatch, status=404)
|
self._patch(monkeypatch, status=404)
|
||||||
|
|||||||
@@ -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 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
|
# 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.
|
# 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 len(sent) == 2
|
||||||
assert sent[1][0][0] == "twice@example.com"
|
assert sent[1][0][0] == "twice@example.com"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""A Google account without a YouTube channel: its `mine` playlists read answers 404
|
||||||
|
`channelNotFound`. That is an empty list, not a failed sync — it came back as a warning every six
|
||||||
|
hours on prod for an account that will never have a channel."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.youtube import client as yt
|
||||||
|
|
||||||
|
|
||||||
|
def _client(monkeypatch, status: int, reason: str) -> yt.YouTubeClient:
|
||||||
|
resp = httpx.Response(
|
||||||
|
status,
|
||||||
|
json={"error": {"code": status, "errors": [{"reason": reason, "message": "prose"}]}},
|
||||||
|
request=httpx.Request("GET", f"{yt.API_BASE}/playlists"),
|
||||||
|
)
|
||||||
|
c = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub
|
||||||
|
c.db = None
|
||||||
|
monkeypatch.setattr(yt.sysconfig, "get_str", lambda db, key: "")
|
||||||
|
monkeypatch.setattr(yt.quota, "record_usage", lambda cost: None)
|
||||||
|
monkeypatch.setattr(c, "_access_token", lambda: "t")
|
||||||
|
monkeypatch.setattr(c, "_send", lambda *a, **k: resp)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_channel_is_an_empty_playlist_list_and_no_warning(monkeypatch, caplog):
|
||||||
|
caplog.set_level(logging.DEBUG, logger="siftlode")
|
||||||
|
assert list(_client(monkeypatch, 404, "channelNotFound").iter_my_playlists()) == []
|
||||||
|
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||||
|
|
||||||
|
|
||||||
|
def test_any_other_failure_still_raises_and_warns(monkeypatch, caplog):
|
||||||
|
caplog.set_level(logging.DEBUG, logger="siftlode")
|
||||||
|
with pytest.raises(yt.YouTubeError):
|
||||||
|
list(_client(monkeypatch, 403, "quotaExceeded").iter_my_playlists())
|
||||||
|
assert [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||||
@@ -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(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
||||||
{
|
{
|
||||||
@@ -56,12 +180,17 @@ export default tseslint.config(
|
|||||||
plugins: {
|
plugins: {
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
"react-refresh": reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
|
local: localRules,
|
||||||
},
|
},
|
||||||
linterOptions: {
|
linterOptions: {
|
||||||
reportUnusedDisableDirectives: "error",
|
reportUnusedDisableDirectives: "error",
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.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 }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
// 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.
|
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
||||||
@@ -83,22 +212,49 @@ export default tseslint.config(
|
|||||||
"error",
|
"error",
|
||||||
{ allowShortCircuit: true, allowTernary: true },
|
{ allowShortCircuit: true, allowTernary: true },
|
||||||
],
|
],
|
||||||
// R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT scale
|
// Source-shape rules, each with its reason right above it (contract-tested in
|
||||||
// with the Settings --font-scale slider while its rem neighbours do; use `text-2xs`/`text-3xs`
|
// lib/*.contract.test.ts).
|
||||||
// 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-.
|
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"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:
|
message:
|
||||||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
"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:
|
message:
|
||||||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
"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],
|
files: [owner],
|
||||||
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
|
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.
|
// 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}"],
|
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,
|
prettier,
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user