Compare commits
43
Commits
4b6db47083
...
v0.109.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5ceec8a1 | ||
|
|
e788326247 | ||
|
|
38c71b7b4f | ||
|
|
cd5b26efa7 | ||
|
|
0892a1a400 | ||
|
|
b443ac0c84 | ||
|
|
ad05932217 | ||
|
|
6ae7825a14 | ||
|
|
cde5a98fd2 | ||
|
|
e83c5e2d97 | ||
|
|
6a0c1d377f | ||
|
|
4ef963b005 | ||
|
|
0b24f2d8fa | ||
|
|
d5867bacca | ||
|
|
63a0fecc8b | ||
|
|
fdf8076013 | ||
|
|
6287fc9cf8 | ||
|
|
3c6c23507b | ||
|
|
ef99f7b5c7 | ||
|
|
94418a087e | ||
|
|
28b77fed1e | ||
|
|
50764a916d | ||
|
|
f21cdfa156 | ||
|
|
d6dada7fe5 | ||
|
|
6645caa157 | ||
|
|
59ec4a28ba | ||
|
|
dc4f4f5143 | ||
|
|
909732b3c1 | ||
|
|
215f022fe3 | ||
|
|
6ea750a698 | ||
|
|
c5f121139b | ||
|
|
b04b2cbd62 | ||
|
|
3a5212cd8a | ||
|
|
b376373af5 | ||
|
|
8206384c91 | ||
|
|
e27656ee7e | ||
|
|
d73cdcee97 | ||
|
|
5ce292cd28 | ||
|
|
ad6ccbac05 | ||
|
|
531eb2eef1 | ||
|
|
2cf1b688d9 | ||
|
|
3b0e365e26 | ||
|
|
533571f572 |
@@ -7,6 +7,48 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.109.2 — 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Plex player: when the server restarts mid-episode (an update), playback carries on from the buffer and picks up where you are once it is back, instead of showing "Playback couldn't start" and stopping where the buffer ended.
|
||||
|
||||
## 0.109.1 — 2026-10-23
|
||||
|
||||
### New
|
||||
|
||||
- Development: the frontend is one folder per page (its page, rail, store and definition), with the layers held by lint and the shared /watch page held apart by a contract test; the header knows no page, and the feed and Plex each keep their own saved views.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Saved views: a view you applied and then changed is still active after a visit to another page, in the feed and in Plex.
|
||||
|
||||
## 0.109.0 — 2026-10-21
|
||||
|
||||
### New
|
||||
|
||||
- Accounts: a browser is signed in to one account at a time. The account menu's "Sign in as someone else" replaces it; the account list and switcher are gone. After this update everyone signs in once more.
|
||||
- Settings follow you: the Home start page, the channel manager and Plex playlist layouts, the discovery sorts and a dismissed onboarding or release banner are saved to your account, on every device. Performance mode stays per device.
|
||||
- Development: every key the browser stores declares its kind, held by a contract test; one Google connect route serves the sign-in and the YouTube access grants.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Accounts: a tab still showing another account than the one signed in now reloads as that one and says why; a failed "Sign in as someone else" says what happened.
|
||||
- A link whose path starts with // no longer opens the error screen.
|
||||
- A member following a link to an admin page lands on the feed, with the rail and the address bar in step.
|
||||
- Google: an unknown access request is refused instead of quietly asking for read access, and a YouTube grant on an instance without Google says so.
|
||||
|
||||
## 0.108.1 — 2026-10-20
|
||||
|
||||
### New
|
||||
|
||||
- Development: the backend is split into four units (downloads, reader, catalog, Plex), each owning its tables. A contract test holds what each one reads and writes, and that every query names whose rows it asks for.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Watch later: saving the first two videos at once no longer makes two lists. An existing second list is merged into the first, its videos and its comfort-shelf pin with it.
|
||||
- Playback: the first resume point of a video or a Plex item no longer fails when it is saved together with the watched mark, a whole season marked at once, or a Plex sync.
|
||||
|
||||
## 0.108.0 — 2026-10-18
|
||||
|
||||
### New
|
||||
|
||||
@@ -66,52 +66,70 @@ how it gets built. Concretely:
|
||||
`downloads/`, `reader/`, `discover/`, `catalog/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `apitokens`, `quota`, `jobruns`,
|
||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`,
|
||||
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
|
||||
entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module
|
||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
||||
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
||||
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
|
||||
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
|
||||
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
|
||||
`lib/api/*` slices over `core.req<T>`.
|
||||
The domain is four **units**, each owning its tables: Capture (`downloads/`), Reader (`reader/`, `discover/`, the playlist
|
||||
sync), Catalog (`catalog/`, the `sync/` ingests) and Plex (`plex/`). A unit's route does no database work: it reads through
|
||||
the unit's `queries`, writes through its `service` (which flushes) and commits. A unit's code reads another unit's tables
|
||||
only along a declared edge and writes them only through that unit's service; core reaches a unit's tables through its
|
||||
`queries`. **Fence-held, with no exemptions** (`backend/tests/test_unit_fences.py`) — as far as an AST sees: what it
|
||||
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
|
||||
[`backend/app/README.md`](backend/app/README.md) with the conventions.
|
||||
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
|
||||
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
|
||||
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
|
||||
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
|
||||
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
|
||||
`lib/urlState` and its entry there, plus its panel in `lib/panelLayout` if it has a rail) →
|
||||
**`features/<name>/`** (what several pages share: video cards, filters, downloads, the player, navigation) → the
|
||||
**platform layer `components/ui/`** (domain-free primitives: the popover engine and its menus, `Modal`/`Overlay`,
|
||||
`Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`,
|
||||
…) → `lib/`. The direction is one-way and **lint-enforced**, not just described (`local/layers`, with
|
||||
its own test): an import only goes down; another module is reached only through its **`index.ts`** (its
|
||||
public face — the stores, mounted at the root and read app-wide, which `features/` may read too) or
|
||||
lazily by `import()`; a platform component may not import a domain API slice, and `lib/` nothing above
|
||||
it, not even a type. A lazily imported page, rail or dialog that a static import drags onto the boot
|
||||
path fails the build (`chunkGuard.ts`). A new generic component goes in `ui/`; see
|
||||
[`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||
Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server
|
||||
prefs; API through `lib/api/*` slices over `core.req<T>`.
|
||||
|
||||
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
|
||||
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
|
||||
|
||||
## Canonical patterns — when you have a choice, pick THIS
|
||||
|
||||
| Area | Use | Not |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
|
||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` and `jobruns` own their session) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)`; a YouTube write action (subscribe, sync, …): `notifyYouTubeActionError` — a 403 offers Connect | a bare `catch {}` or `notify()` with no error attached |
|
||||
| FE floating or full-screen layer | `ui/Overlay` (portals to `<body>`) or the popover engine (`AnchoredPopover` portals itself); stacking by the z-index scale (`z-menu` … `z-popover`, docs/DESIGN.md "Layers") | one rendered in place inside a virtualized row or a masked/transformed scroller — clipped or painted over; a magic `z-[n]` |
|
||||
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
|
||||
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
||||
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
||||
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
||||
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+); a handler or timer: `useLatest` / `useStableCallback` | a render-phase `ref.current = x` — the React Compiler skips the component |
|
||||
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
|
||||
| FE popover / scroll-fade ref | destructure it: `const { setReference } = pop.refs`, then `ref={setReference}` | `ref={pop.refs.setReference}` / `ref={fade.ref}` — the compiler then treats the whole object as a ref and skips |
|
||||
| FE async handler with cleanup | `try { … } catch { … }`, then the cleanup after it (steps that `return` early go in their own function); a conditional, loop or `?.` the `try` needs goes before it or into a module-level helper | `try/finally`, or a `?:` / `??` / loop / `?.` inside `try` — the React Compiler skips the whole component (and `react-hooks/todo` misses most of these) |
|
||||
| Area | Use | Not |
|
||||
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
|
||||
| Ownership check | `userscope.owned_by` (a filter) / `owned_row` (a row by id): a unit's `queries.own_x` returns None and its route answers `not_found`; a core route `owned_or_404`. A composite rule (own or shared, built-in or own) says so in its `queries` | an inline owner filter — `Model.user_id == …`, an alias's, `.in_()`, `filter_by(user_id=…)` (fence-held, every module) — or a `db.get` and a compare where `owned_row` fits |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||
| Transaction commit | the handler commits; a unit's `service` and every helper `flush` (fence-held, every module) | a helper that commits — only a listed one (what owns its session, or a core step that must land whatever its caller does next), each with its reason: the fence's `UNIT_COMMITS` / `CORE_COMMITS`, a route's `COMMITTING_HELPERS` (`test_route_conventions`) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE browser storage | a key in `storage.ts` with its kind (contract-held): a `pref` through `usePref` (the server's, follows the account); `device` / `view` / `cache` this browser's; `public` kept for the /watch page | a `"siftlode.x"` literal; a preference kept only in `localStorage` (it stays on one device) |
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||
| FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it |
|
||||
| FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` |
|
||||
| FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it |
|
||||
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)`; a YouTube write action (subscribe, sync, …): `notifyYouTubeActionError` — a 403 offers Connect | a bare `catch {}` or `notify()` with no error attached |
|
||||
| FE floating or full-screen layer | `ui/Overlay` (portals to `<body>`) or the popover engine (`AnchoredPopover` portals itself); stacking by the z-index scale (`z-menu` … `z-popover`, docs/DESIGN.md "Layers") | one rendered in place inside a virtualized row or a masked/transformed scroller — clipped or painted over; a magic `z-[n]` |
|
||||
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
|
||||
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
||||
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
||||
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
||||
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+); a handler or timer: `useLatest` / `useStableCallback` | a render-phase `ref.current = x` — the React Compiler skips the component |
|
||||
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
|
||||
| FE popover / scroll-fade ref | destructure it: `const { setReference } = pop.refs`, then `ref={setReference}` | `ref={pop.refs.setReference}` / `ref={fade.ref}` — the compiler then treats the whole object as a ref and skips |
|
||||
| FE async handler with cleanup | `try { … } catch { … }`, then the cleanup after it (steps that `return` early go in their own function); a conditional, loop or `?.` the `try` needs goes before it or into a module-level helper | `try/finally`, or a `?:` / `??` / loop / `?.` inside `try` — the React Compiler skips the whole component (and `react-hooks/todo` misses most of these) |
|
||||
|
||||
Not repeated here, because a lint rule refuses the wrong one and its message names the right one:
|
||||
buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native
|
||||
|
||||
@@ -650,121 +650,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/plex/browse.py": [
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 26,
|
||||
"endColumn": 61,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 26,
|
||||
"endColumn": 61,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportOptionalSubscript",
|
||||
"range": {
|
||||
"startColumn": 11,
|
||||
"endColumn": 13,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportOptionalSubscript",
|
||||
"range": {
|
||||
"startColumn": 25,
|
||||
"endColumn": 27,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportOptionalSubscript",
|
||||
"range": {
|
||||
"startColumn": 11,
|
||||
"endColumn": 13,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportOptionalSubscript",
|
||||
"range": {
|
||||
"startColumn": 25,
|
||||
"endColumn": 27,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportOptionalSubscript",
|
||||
"range": {
|
||||
"startColumn": 15,
|
||||
"endColumn": 17,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportOptionalSubscript",
|
||||
"range": {
|
||||
"startColumn": 28,
|
||||
"endColumn": 30,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportOptionalSubscript",
|
||||
"range": {
|
||||
"startColumn": 15,
|
||||
"endColumn": 17,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportOptionalSubscript",
|
||||
"range": {
|
||||
"startColumn": 28,
|
||||
"endColumn": 30,
|
||||
"lineCount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/plex/collections.py": [
|
||||
{
|
||||
"code": "reportAssignmentType",
|
||||
"range": {
|
||||
"startColumn": 11,
|
||||
"endColumn": 39,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 76,
|
||||
"endColumn": 80,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 75,
|
||||
"endColumn": 79,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 80,
|
||||
"endColumn": 84,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
@@ -822,108 +708,6 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/plex/discover.py": [
|
||||
{
|
||||
"code": "reportCallIssue",
|
||||
"range": {
|
||||
"startColumn": 4,
|
||||
"endColumn": 63,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 18,
|
||||
"endColumn": 48,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportCallIssue",
|
||||
"range": {
|
||||
"startColumn": 4,
|
||||
"endColumn": 63,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 18,
|
||||
"endColumn": 48,
|
||||
"lineCount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/plex/playback.py": [
|
||||
{
|
||||
"code": "reportAttributeAccessIssue",
|
||||
"range": {
|
||||
"startColumn": 6,
|
||||
"endColumn": 14,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 51,
|
||||
"endColumn": 54,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 51,
|
||||
"endColumn": 54,
|
||||
"lineCount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/plex/playlists.py": [
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 77,
|
||||
"endColumn": 83,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 77,
|
||||
"endColumn": 83,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 8,
|
||||
"endColumn": 9,
|
||||
"lineCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 8,
|
||||
"endColumn": 9,
|
||||
"lineCount": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportAttributeAccessIssue",
|
||||
"range": {
|
||||
"startColumn": 10,
|
||||
"endColumn": 18,
|
||||
"lineCount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/scheduler.py": [
|
||||
{
|
||||
"code": "reportOptionalMemberAccess",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""One Watch later list per user, held by the database
|
||||
|
||||
Revision ID: 0112_one_watch_later
|
||||
Revises: 0111_audit_request_id
|
||||
Create Date: 2026-10-20
|
||||
|
||||
Watch later was made on a user's first save by a read then an insert, so two first saves at once
|
||||
each made a list, and the reader then picked between them at random. A partial unique index holds
|
||||
"one per user" now. An instance that already has a second list keeps the oldest: the others' videos
|
||||
move into it (after its own, in their order; one it already holds is not added twice), a Home pin
|
||||
of one of them moves to it (unless it is pinned itself; of several, the first on the shelf), then
|
||||
they go — a pin left on them goes with them, as the kept list already holds one.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0112_one_watch_later"
|
||||
down_revision: str | None = "0111_audit_request_id"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
_EXTRA = """
|
||||
SELECT p.id, k.keep_id
|
||||
FROM playlists p
|
||||
JOIN (
|
||||
SELECT user_id, min(id) AS keep_id FROM playlists WHERE kind = 'watch_later' GROUP BY user_id
|
||||
) k ON k.user_id = p.user_id
|
||||
WHERE p.kind = 'watch_later' AND p.id <> k.keep_id
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
INSERT INTO playlist_items (playlist_id, video_id, position, added_at)
|
||||
SELECT e.keep_id, i.video_id,
|
||||
(SELECT coalesce(max(position), -1) FROM playlist_items WHERE playlist_id = e.keep_id)
|
||||
+ row_number() OVER (PARTITION BY e.keep_id ORDER BY i.playlist_id, i.position, i.id),
|
||||
i.added_at
|
||||
FROM playlist_items i
|
||||
JOIN ({_EXTRA}) e ON e.id = i.playlist_id
|
||||
ON CONFLICT ON CONSTRAINT uq_playlist_video DO NOTHING
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE comfort_pins c SET playlist_id = m.keep_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (e.keep_id) p.id AS pin_id, e.keep_id
|
||||
FROM comfort_pins p
|
||||
JOIN ({_EXTRA}) e ON e.id = p.playlist_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM comfort_pins k WHERE k.playlist_id = e.keep_id)
|
||||
ORDER BY e.keep_id, p.position, p.id
|
||||
) m
|
||||
WHERE c.id = m.pin_id
|
||||
"""
|
||||
)
|
||||
op.execute(f"DELETE FROM playlists WHERE id IN (SELECT id FROM ({_EXTRA}) e)")
|
||||
op.create_index(
|
||||
"uq_playlist_watch_later",
|
||||
"playlists",
|
||||
["user_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("kind = 'watch_later'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_playlist_watch_later", table_name="playlists")
|
||||
+56
-16
@@ -1,9 +1,8 @@
|
||||
# Backend conventions
|
||||
|
||||
The rules below are already followed across `backend/app` — they lived only in a few module
|
||||
docstrings (`userscope.py`, `routes/_common.py`) until this file collected them (E3 S3,
|
||||
B-core-20). When you add code, match these so the next reader doesn't have to guess "which pattern do
|
||||
I copy".
|
||||
docstrings (`userscope.py`, `routes/_common.py`) until this file collected them. When you add code,
|
||||
match these so the next reader doesn't have to guess "which pattern do I copy".
|
||||
|
||||
## Module layering
|
||||
|
||||
@@ -13,12 +12,13 @@ Three layers, by *who* runs the code — not by feature:
|
||||
response models, status-code mapping, serialization. It owns logic that runs *only* on a request.
|
||||
Business logic that a request happens to trigger does **not** live here if anything else runs it too.
|
||||
|
||||
2. **`plex/`, `downloads/`, `sync/`, `issues/` — domain logic shared by more than one caller.** Anything the
|
||||
**scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the
|
||||
route, the worker, and the scheduler share one definition. Examples from this codebase:
|
||||
`downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is
|
||||
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline;
|
||||
`issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
|
||||
2. **The units and the other domain packages — logic shared by more than one caller.** A unit's
|
||||
database work (below), and anything the **scheduler or the worker** runs (not just a request), lives
|
||||
in a domain package, so the route, the worker, and the scheduler share one definition. Examples from
|
||||
this codebase: `downloads/service.py` owns enqueue + the job state transitions (routes call it);
|
||||
`plex/sync.py` is run by both the admin route and the scheduler; `downloads/pipeline.py` is the
|
||||
worker's child pipeline; `issues/bundle.py` is the one definition of a bug report's files, for the
|
||||
zip export and the tracker.
|
||||
|
||||
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth` (with `apitokens`, a machine's
|
||||
folder-scoped way into the Files library), `sysconfig`, `quota`, `audit`, `jobruns` (every
|
||||
@@ -34,19 +34,59 @@ Three layers, by *who* runs the code — not by feature:
|
||||
a layer — into the relevant domain package, or a flat core module if it's cross-cutting. Don't
|
||||
pre-abstract for a single caller; don't leave shared logic duplicated once there are two.
|
||||
|
||||
## Units
|
||||
|
||||
The domain is four units, each owning its tables (`models.py` in its package):
|
||||
|
||||
| Unit | Package | Its routes |
|
||||
| --- | --- | --- |
|
||||
| Capture | `downloads/`, the worker | `downloads.py`, `files.py`, `public.py` |
|
||||
| Reader | `reader/`, `discover/`, `feedquery.py`, the playlist sync and tag suggestions in `sync/` | `feed.py`, `playlists.py`, `saved_views.py`, `discover.py`, `tags.py` |
|
||||
| Catalog | `catalog/`, the ingests in `sync/` | `channels.py`, `search.py`, `sync.py` |
|
||||
| Plex | `plex/` | `plex/` |
|
||||
|
||||
- **A unit's route does no database work.** It validates, reads through the unit's `queries.py`,
|
||||
writes through its `service.py`, serializes, and commits. It may name a unit's model as a type
|
||||
(under `TYPE_CHECKING`), never use one as a value, and it passes a kind (`"movie"`), not a model, to
|
||||
a query.
|
||||
- **A unit's code keeps to its edges.** It reads another unit's tables only along a declared edge
|
||||
(Capture and Reader read Catalog's and Plex's: the feed joins `Video` with `VideoState` in one
|
||||
query) and writes them only through the owner's service. Core code reaches a unit's tables through
|
||||
that unit's `queries`.
|
||||
- **Whose a row is, `userscope` says** — in every module, core included: `owned_by(Model.user_id,
|
||||
user)` for a filter, `owned_row(db, Model, id, user)` for a row by id (someone else's row is the
|
||||
same answer as a missing one). A unit's `queries.own_x` returns None and its route answers
|
||||
`not_found`; a core route calls `owned_or_404`. A rule `owned_row` cannot say — a job its owner
|
||||
shared, a built-in profile nobody owns, a folder read under `FOR SHARE` — is written out in the
|
||||
unit's own code, beside the reason.
|
||||
|
||||
All of it is held by `tests/test_unit_fences.py`, with no exemptions: the unit map, each unit's
|
||||
packages and edges (`UNIT_PACKAGES`, `READS`) and what may commit (`UNIT_COMMITS`, `CORE_COMMITS`)
|
||||
are listed there. It reads the code's AST, so it holds the forms written here: an owner column
|
||||
compared inline (a model's, an import alias's or an `aliased()` one's, by `==`, `!=`, `.in_()`, or a
|
||||
`filter_by(user_id=…)`), a model built or an `insert`/`update`/`delete` (under any import name, of a
|
||||
model or its `__table__`) across units, and a `.commit()` call off the lists all fail it. What an AST
|
||||
cannot tell is review's to catch: an ORM write on another unit's loaded row (an attribute, a
|
||||
`db.delete`), SQL in `text()`, a column or model reached another way (bound to a variable, through
|
||||
`__table__.c` or a module path), and a commit made by a `begin()` block or a `commit` handed in.
|
||||
|
||||
## Transaction ownership
|
||||
|
||||
This follows CLAUDE.md's canonical rule verbatim — **"the handler/job-wrapper commits; helpers flush
|
||||
(only `quota` and `jobruns` own their session)"** — so read it that way, not as an exception to it. The commit belongs
|
||||
to the **operation owner**; anything called *inside* another transaction only stages its change
|
||||
This follows CLAUDE.md's canonical rule — **the handler commits; a unit's `service` and every helper
|
||||
flush** — so read it that way, not as an exception to it. The commit belongs to the **operation
|
||||
owner**; anything called *inside* another transaction only stages its change
|
||||
(`db.add` / `flush`) and lets the owner commit, so the whole operation is one transaction.
|
||||
`audit.record()` is the reference: it adds the audit row and the caller commits it together with the
|
||||
mutation it records, so an audit entry can never exist without its change (or vice-versa).
|
||||
|
||||
- The **operation owner** is usually the route handler. A `service.*` operation the handler delegates a
|
||||
whole transition to (e.g. `service.cancel`) *is* that owner and commits its one transition — it isn't
|
||||
a mid-operation helper. A helper that owns its OWN short-lived session (the worker's `jobdb` writes,
|
||||
`jobruns`' run rows — the same carve-out CLAUDE.md grants `quota`) commits that session. Everything else flushes.
|
||||
- The **operation owner** is the route handler, or a job. A unit's `service` flushes even when it
|
||||
makes a whole transition (`service.cancel`): the handler commits it, together with whatever else the
|
||||
request changed. What owns its OWN session commits it: `quota` and `jobruns`' run rows, the
|
||||
scheduler's jobs, a unit's jobs (the worker's `jobdb` writes, the syncs and ingests that commit per
|
||||
page or batch so a later failure loses nothing already stored), and a few core steps that must land
|
||||
whatever their caller does next (a token's last-used stamp, a refreshed YouTube grant) — the fence's
|
||||
`UNIT_COMMITS` and `CORE_COMMITS` name each with its reason, `test_route_conventions` a route's own
|
||||
committing helpers. Everything else flushes.
|
||||
- `SessionLocal` is `expire_on_commit=False`: after a raw `UPDATE` + commit, an ORM object you already
|
||||
loaded is **stale**. Read the fresh value from the row (RETURNING, or a re-`SELECT`), not the ORM
|
||||
attribute — see `service.decr_ref_count`.
|
||||
|
||||
@@ -26,6 +26,7 @@ from fastapi import Depends, Request
|
||||
from sqlalchemy import delete, or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.userscope import owned_by
|
||||
from app import audit, debugsession, logs
|
||||
from app.audit import AuditAction
|
||||
from app.auth import current_user
|
||||
@@ -223,7 +224,9 @@ def revoke_all(db: Session, user_id: int) -> int:
|
||||
change, "log out everywhere", a revoked untrusted password — ends its tokens too: one minted
|
||||
from a stolen session must not outlive the owner's answer to that theft. Returns how many."""
|
||||
gone = (
|
||||
db.execute(delete(ApiToken).where(ApiToken.user_id == user_id).returning(ApiToken.id))
|
||||
db.execute(
|
||||
delete(ApiToken).where(owned_by(ApiToken.user_id, user_id)).returning(ApiToken.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -110,10 +110,9 @@ def record(
|
||||
|
||||
def gc(db: Session, retention_days: int) -> int:
|
||||
"""Delete audit rows older than `retention_days`. Returns the number removed. A
|
||||
retention_days <= 0 disables pruning (keep forever)."""
|
||||
retention_days <= 0 disables pruning (keep forever). Flushes; the job commits."""
|
||||
if retention_days <= 0:
|
||||
return 0
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
result = db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff))
|
||||
db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
+49
-89
@@ -4,6 +4,7 @@ from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from app.userscope import owned_by
|
||||
from app import debugsession
|
||||
from app import email as email_mod
|
||||
from app import logs
|
||||
@@ -24,8 +25,6 @@ def operator_contact() -> str | None:
|
||||
return next(iter(sorted(settings.admin_email_set)), None)
|
||||
|
||||
|
||||
MAX_SESSION_ACCOUNTS = 6
|
||||
|
||||
# OAuth scope vocabulary — the atoms + the request bundles, kept together as core. has_*_scope check
|
||||
# the atoms against a user's stored grant; the *_SCOPES bundles are what the login/callback endpoints
|
||||
# request (imported by app/routes/auth.py).
|
||||
@@ -230,7 +229,7 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
# request's access line, a queued row, a later background task would otherwise put rows back
|
||||
# under the id after this DELETE (logs.forget_user).
|
||||
logs.forget_user(user_id)
|
||||
db.execute(delete(LogEvent).where(LogEvent.user_id == user_id))
|
||||
db.execute(delete(LogEvent).where(owned_by(LogEvent.user_id, user_id)))
|
||||
# Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the
|
||||
# canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose
|
||||
# account was just erased — and prod really does carry approved pairs for one mailbox.
|
||||
@@ -252,94 +251,63 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
background.add_task(email_mod.send_account_deleted, email, lang, operator_contact())
|
||||
|
||||
|
||||
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
||||
def session_user_id(conn: HTTPConnection) -> int | None:
|
||||
"""The account this browser session signed in as, read from the signed cookie alone (no DB).
|
||||
One account per session: signing in as another replaces it (`routes.auth._establish_session`)."""
|
||||
return conn.session.get("user_id")
|
||||
|
||||
|
||||
def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int | None, bool]:
|
||||
"""Which account this connection acts as, and whether that's the session's default account.
|
||||
Takes an HTTPConnection rather than a Request: it reads only the session and the headers.
|
||||
|
||||
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
|
||||
plus a default `user_id`. A caller may name its account with the X-Siftlode-Account header (or
|
||||
`?account=`), but ONLY an account in the wallet — so a tab can't impersonate an account that
|
||||
never authenticated here. A connection that names no account acts as the cookie default.
|
||||
|
||||
A connection naming an account NOT in the wallet acts as nobody (`None`) — typically a tab still
|
||||
pinned to an account that was signed out in another tab. It must not fall back to the default:
|
||||
that tab would go on acting as an account it never showed, its player checkpoints and pref saves
|
||||
landing there and its Sign out signing THAT account out. Only the bootstrap /api/me probe passes
|
||||
`strict=False` and gets the default, which is how such a tab learns who it now is (the frontend
|
||||
re-pins it and reloads). An account whose session ended while it stayed in the wallet (deleted,
|
||||
suspended, revoked) leaves the wallet when it is refused (`_session_user`), so a tab pinned to it
|
||||
takes the same way back.
|
||||
"""
|
||||
default_id = conn.session.get("user_id")
|
||||
wallet = conn.session.get("account_ids") or []
|
||||
# Header for normal XHR; ?account= for a context that can't set headers (a plain <a> file
|
||||
# download). Both are wallet-gated below.
|
||||
hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account")
|
||||
if hdr:
|
||||
try:
|
||||
hid = int(hdr)
|
||||
except ValueError:
|
||||
# Under `strict` this becomes a 401 and the tab authenticates as nobody, which reads
|
||||
# in the browser exactly like a session that expired. The value itself stays out —
|
||||
# it came from a request, and `wallet` says which accounts were actually on offer.
|
||||
log.debug(
|
||||
"the active-account header could not be read as an id",
|
||||
extra=logs.event("auth.account_header.invalid", accounts=len(wallet)),
|
||||
)
|
||||
hid = None
|
||||
if hid is not None and hid in wallet:
|
||||
return hid, hid == default_id
|
||||
if strict:
|
||||
return None, False
|
||||
return default_id, True
|
||||
def stamp_session(request: Request, user: User) -> None:
|
||||
"""Record the account's current `session_epoch` in the signed cookie, so `current_user` can later
|
||||
reject the cookie once the epoch moves (password reset/change, or "log out everywhere")."""
|
||||
request.session["epoch"] = user.session_epoch or 0
|
||||
|
||||
|
||||
def drop_from_wallet(session: dict, user_id: int) -> None:
|
||||
"""Take one account out of this browser's wallet (`account_ids`); the session default is untouched."""
|
||||
session["account_ids"] = [a for a in (session.get("account_ids") or []) if a != user_id]
|
||||
# The account a tab was built for, sent with every request it makes (`identityHeaders`, the
|
||||
# frontend). The cookie alone says who is asking, and one browser holds one account: a sign-in as
|
||||
# someone else in another tab replaces it under every open tab. A tab still showing the previous
|
||||
# account would otherwise act as the new one — its player checkpoints, pref saves, Sign out and
|
||||
# Delete account landing there — until it noticed. It never selects an account; a mismatch is
|
||||
# refused, and the tab reloads as whoever is signed in now.
|
||||
EXPECTED_ACCOUNT_HEADER = "x-siftlode-expect-account"
|
||||
|
||||
|
||||
def _end_account_session(request: Request, user_id: int, is_default: bool) -> None:
|
||||
"""An account whose session here is no longer valid. Only nuke the whole session when it is the
|
||||
SESSION DEFAULT; a per-tab account just leaves the wallet (other tabs' default may still be valid).
|
||||
Leaving is what lets a tab pinned to it recover: its next /api/me names an account no longer signed
|
||||
in here and gets the default. Left in, it answered "signed out" — the landing page, with the default
|
||||
still signed in, and no way off it but closing the tab."""
|
||||
if is_default:
|
||||
request.session.clear()
|
||||
else:
|
||||
drop_from_wallet(request.session, user_id)
|
||||
def built_for_another_account(user_id: int, expected: str | None) -> bool:
|
||||
"""Whether a tab naming `expected` is showing another account than the one signed in. No name is
|
||||
no claim (a caller from before this check, a script): only a mismatch is refused, and the
|
||||
session stays — it is the other tab's sign-in, and it holds. The one comparison for every way a
|
||||
tab names its account: this header on a request, `expect` on a navigation."""
|
||||
if expected is None or expected == str(user_id):
|
||||
return False
|
||||
log.info(
|
||||
"a request from a tab built for another account was refused",
|
||||
extra=logs.event("auth.session.account_changed", user_id=user_id),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
return _session_user(request, db, *resolved_user_id(request))
|
||||
user = _session_user(request, db, session_user_id(request))
|
||||
if built_for_another_account(user.id, request.headers.get(EXPECTED_ACCOUNT_HEADER)):
|
||||
raise refused(401, "account_changed")
|
||||
return 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) -> User:
|
||||
if not user_id:
|
||||
raise refused(401, "not_authenticated")
|
||||
user = db.get(User, user_id)
|
||||
if user is None or not user.is_active or user.is_suspended:
|
||||
# Suspended/deactivated mid-session → end it so the block takes effect at once.
|
||||
_end_account_session(request, user_id, is_default)
|
||||
request.session.clear()
|
||||
raise refused(401, "not_authenticated")
|
||||
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
|
||||
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
|
||||
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
|
||||
epochs = request.session.get("epochs") or {}
|
||||
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
||||
_end_account_session(request, user_id, is_default)
|
||||
# A cookie whose epoch is behind the account's (revoked by a password reset/change or "log out
|
||||
# everywhere") is refused — and so is one with no epoch at all, which only a build before the
|
||||
# one-account session wrote: its holder signs in once more.
|
||||
epoch = request.session.get("epoch")
|
||||
if epoch is None or int(epoch) != (user.session_epoch or 0):
|
||||
request.session.clear()
|
||||
raise refused(401, "not_authenticated")
|
||||
# Always keep the session default in the switchable list — covers sessions created before
|
||||
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
|
||||
default_id = request.session.get("user_id")
|
||||
accounts = request.session.get("account_ids") or []
|
||||
if default_id and default_id not in accounts:
|
||||
accounts.append(default_id)
|
||||
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
||||
# Attribute this request's log lines to the account. Here, not in the request middleware:
|
||||
# this is the one place a session becomes an account, and the middleware runs outside both the
|
||||
# session middleware and this task's context. `request.state` is shared across middleware layers
|
||||
@@ -357,25 +325,17 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
|
||||
def optional_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||
"""Like `current_user` but returns None instead of raising 401 when there's no valid session.
|
||||
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
|
||||
the browser console (a non-2xx fetch is a console error no matter how the app handles it).
|
||||
The one lenient resolution (`strict=False`): a tab naming an account no longer signed in here gets
|
||||
the default account, which is how it learns who it now is — see `resolved_user_id`. That includes an
|
||||
account refused right here, which `_session_user` has just taken out of the wallet."""
|
||||
user_id, is_default = resolved_user_id(request, strict=False)
|
||||
the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
|
||||
user_id = session_user_id(request)
|
||||
if user_id is None:
|
||||
return None
|
||||
try:
|
||||
return _session_user(request, db, user_id, is_default)
|
||||
# silent-ok: this is the anonymous case as much as the refused one — every landing page hit
|
||||
# comes through here. The second attempt below is the handling, and it reports.
|
||||
return _session_user(request, db, user_id)
|
||||
except HTTPException:
|
||||
if is_default:
|
||||
return None
|
||||
try:
|
||||
return _session_user(request, db, *resolved_user_id(request, strict=False))
|
||||
except HTTPException:
|
||||
# Both the pinned account AND the default one were refused, so the tab is signed out
|
||||
# without being told why — and it got here from a session that named a real account.
|
||||
# A session that named an account, refused: the browser is signed out without being told
|
||||
# why, so say it here.
|
||||
log.info(
|
||||
"a session was resolved to nobody: neither the named nor the default account held",
|
||||
"a session was refused: its account no longer holds",
|
||||
extra=logs.event("auth.session.probe_rejected", user_id=user_id),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -26,6 +26,7 @@ from uuid import uuid4
|
||||
from sqlalchemy import and_, delete, func, not_, select, update
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.userscope import owned_by, owned_row
|
||||
from app import diagnostics
|
||||
from app.models import DebugScreenshot, DebugSession, LogEvent, User
|
||||
|
||||
@@ -76,7 +77,9 @@ def open_session(db: Session, user: User) -> DebugSession | None:
|
||||
|
||||
def _unclosed(db: Session, user: User) -> DebugSession | None:
|
||||
return db.scalar(
|
||||
select(DebugSession).where(DebugSession.user_id == user.id, DebugSession.ended_at.is_(None))
|
||||
select(DebugSession).where(
|
||||
owned_by(DebugSession.user_id, user), DebugSession.ended_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -126,12 +129,8 @@ def from_header(db: Session, user: User, value: str | None) -> DebugSession | No
|
||||
"""The session a request names, if it is the caller's own and still live; else None."""
|
||||
if not value or len(value) != _ID_LEN or not all(c in "0123456789abcdef" for c in value):
|
||||
return None
|
||||
session = db.get(DebugSession, value)
|
||||
if (
|
||||
session is None
|
||||
or session.user_id != user.id
|
||||
or not is_live(session, datetime.now(timezone.utc))
|
||||
):
|
||||
session = owned_row(db, DebugSession, value, user)
|
||||
if session is None or not is_live(session, datetime.now(timezone.utc)):
|
||||
return None
|
||||
return session
|
||||
|
||||
@@ -354,7 +353,7 @@ def stored_screenshot_bytes(db: Session, user_id: int) -> int:
|
||||
db.scalar(
|
||||
select(func.coalesce(func.sum(DebugScreenshot.size), 0))
|
||||
.join(DebugSession, DebugSession.id == DebugScreenshot.session_id)
|
||||
.where(DebugSession.user_id == user_id)
|
||||
.where(owned_by(DebugSession.user_id, user_id))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
@@ -22,7 +22,8 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.discover import cards, queries
|
||||
from app.models import PlexItem, PlexShow, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexShow
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.reader.models import ComfortPin, Playlist
|
||||
from app.plex import rollup
|
||||
@@ -71,11 +72,11 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
|
||||
# such pin target", which is the same answer this function gives for one that does not exist.
|
||||
except ValueError:
|
||||
return None
|
||||
pl = db.get(Playlist, pid)
|
||||
# A playlist is per-user, unlike the other four (which are shared catalog rows whose PRIVACY
|
||||
# lives in the state tables). Someone else's list must read as "no such thing", not as a 403 —
|
||||
# the same reason `owned_or_404` 404s rather than 403s.
|
||||
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
|
||||
pl = owned_row(db, Playlist, pid, user)
|
||||
return (col, pl.id) if pl is not None else None
|
||||
|
||||
|
||||
def reorder(db: Session, user: User, ids: list[int]) -> None:
|
||||
|
||||
@@ -6,7 +6,7 @@ Two rules shape this module:
|
||||
**Reuse the query layer, not the route.** The YouTube half calls `feedquery.filtered_query` /
|
||||
`sort_keys`, so the Home surface inherits the feed's own definitions of subscribed, blocked,
|
||||
explore-visible and in-progress for free, and cannot drift from them. The Plex half calls
|
||||
`plex.rollup` and the `plex.cards` serializers rather than `routes/plex/discover.py`: that route
|
||||
`plex.rollup` and the `plex.cards` serializers rather than `plex/discover.py`: that read-model
|
||||
narrows EVERY rail by the Plex sidebar's `LibraryFilters`, which is right for a surface that HAS a
|
||||
sidebar and wrong for a landing page that does not.
|
||||
|
||||
@@ -29,7 +29,8 @@ from app.feedquery import (
|
||||
serialize,
|
||||
sort_keys,
|
||||
)
|
||||
from app.models import PlexItem, PlexShow, PlexState, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexShow, PlexState
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.reader.models import PlaylistItem, VideoState
|
||||
from app.plex import rollup
|
||||
@@ -38,7 +39,7 @@ from app.plex.query import apply_show_state_filter, content_libs
|
||||
from app.userscope import owned_by
|
||||
|
||||
# How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
|
||||
# (`routes/plex/discover.py` imports it): the two surfaces should feel like the same app, and a rail
|
||||
# (`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
|
||||
|
||||
@@ -92,7 +93,7 @@ def yt_in_progress(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[tup
|
||||
q = (
|
||||
select(*feed_columns(user, st), st.progress_updated_at.label("progressed_at"))
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
.join(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
||||
.join(st, and_(st.video_id == Video.id, owned_by(st.user_id, user)))
|
||||
.where(
|
||||
st.position_seconds > 0,
|
||||
st.status.notin_(("watched", "hidden")),
|
||||
@@ -135,7 +136,7 @@ def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
|
||||
row = db.execute(
|
||||
select(*feed_columns(user, st))
|
||||
.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, owned_by(st.user_id, user)))
|
||||
.where(Video.id == video_id)
|
||||
).first()
|
||||
return serialize(row) if row is not None else None
|
||||
@@ -178,7 +179,7 @@ def playlists_summary(
|
||||
select(*feed_columns(user, st), PlaylistItem.playlist_id.label("_playlist"))
|
||||
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
||||
.outerjoin(st, and_(st.video_id == Video.id, owned_by(st.user_id, user)))
|
||||
.where(PlaylistItem.playlist_id.in_(playlist_ids))
|
||||
.order_by(PlaylistItem.playlist_id, PlaylistItem.position.asc())
|
||||
).all()
|
||||
@@ -220,7 +221,7 @@ def plex_in_progress(
|
||||
) -> list[tuple[object, dict]]:
|
||||
"""In-progress movies + partially-watched shows as (progressed_at, card).
|
||||
|
||||
Mirrors `routes/plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a
|
||||
Mirrors `plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a
|
||||
NULL status still counts as started, so `notin_` alone would drop it), and the same shared
|
||||
`rollup.agg_subquery` + `apply_show_state_filter` for shows, so the two rails cannot disagree on
|
||||
what "in progress" means. What it does NOT inherit is that route's `LibraryFilters` narrowing:
|
||||
|
||||
@@ -11,7 +11,8 @@ from sqlalchemy.orm import Session
|
||||
from app import sysconfig
|
||||
from app.discover import cards, queries
|
||||
from app.discover.queries import RAIL_LIMIT
|
||||
from app.models import PlexShow, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexShow
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.rollup import NO_TS
|
||||
|
||||
@@ -75,7 +76,7 @@ def surprise(
|
||||
) -> list[dict]:
|
||||
"""The hero's draw pool: unseen things that fit the budget, from both sources under ONE seed.
|
||||
|
||||
Interleaved rather than concatenated — the same alternation `routes/plex/discover.py` uses for its
|
||||
Interleaved rather than concatenated — the same alternation `plex/discover.py` uses for its
|
||||
movie/show split — so a short pool never comes back all-YouTube or all-Plex just because one side
|
||||
happened to be queried first.
|
||||
|
||||
|
||||
@@ -328,9 +328,14 @@ def create_own(db: Session, user_id: int, name: str) -> FileFolder:
|
||||
return folder
|
||||
|
||||
|
||||
def renamable(folder: FileFolder) -> bool:
|
||||
"""Only a folder of the user's own making takes a name: a source folder's is its origin's."""
|
||||
return folder.kind == FolderKind.OWN
|
||||
|
||||
|
||||
def rename(db: Session, folder: FileFolder, name: str) -> None:
|
||||
"""Rename one of the user's own folders (a source folder's name is its origin's)."""
|
||||
if folder.kind != FolderKind.OWN:
|
||||
if not renamable(folder):
|
||||
raise FolderError("file_folder_not_own")
|
||||
folder.name = name
|
||||
try:
|
||||
|
||||
@@ -28,7 +28,8 @@ from app.downloads.jobdb import (
|
||||
)
|
||||
from app.downloads.stated import StatedFailure
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX, PlexItem
|
||||
from app.models import DISPLAY_TEXT_MAX
|
||||
from app.plex.models import PlexItem
|
||||
from app.downloads.models import DownloadJob, MediaAsset
|
||||
from app.plex import lookups as plex_lookups
|
||||
from app.plex import paths as plex_paths
|
||||
|
||||
@@ -296,13 +296,11 @@ def own_link(db: Session, user: User, link_id: int) -> DownloadLink | None:
|
||||
if link is None:
|
||||
return None
|
||||
target = (
|
||||
db.get(DownloadJob, link.job_id)
|
||||
owned_row(db, DownloadJob, link.job_id, user)
|
||||
if link.job_id is not None
|
||||
else db.get(FileFolder, link.folder_id)
|
||||
else owned_row(db, FileFolder, link.folder_id, user)
|
||||
)
|
||||
if target is None or target.user_id != user.id:
|
||||
return None
|
||||
return link
|
||||
return link if target is not None else None
|
||||
|
||||
|
||||
def links_to(
|
||||
|
||||
@@ -16,6 +16,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import app.tables # noqa: F401 — a fresh interpreter: every table, before the first query
|
||||
from app import logs
|
||||
from app import logsink
|
||||
from app.config import settings
|
||||
|
||||
@@ -24,7 +24,8 @@ from app.downloads import credentials
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import folders, formats, mediakind, quota, storage
|
||||
from app.downloads.status import HOLDING, JobKind, JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX, PlexItem, PlexShow, User
|
||||
from app.models import DISPLAY_TEXT_MAX, User
|
||||
from app.plex.models import PlexItem, PlexShow
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.downloads.models import (
|
||||
DownloadJob,
|
||||
|
||||
@@ -212,7 +212,7 @@ def filtered_query(
|
||||
# exactly. 'unwatched'/'all' include row-less 'new' videos, so they keep the outer join.
|
||||
state_required = show in ("watched", "hidden", "in_progress")
|
||||
join_state = query.join if state_required else query.outerjoin
|
||||
query = join_state(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
||||
query = join_state(state, and_(state.video_id == Video.id, owned_by(state.user_id, user)))
|
||||
|
||||
# Hide videos the maintenance job has flagged as currently unplayable (deleted/private/
|
||||
# abandoned). They're either deleted after a grace period or unhidden if they recover;
|
||||
|
||||
@@ -28,6 +28,7 @@ log = logs.get_logger(__name__)
|
||||
from app import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first
|
||||
from app.config import settings # noqa: E402 — logging is configured first
|
||||
from app.db import SessionLocal, engine # noqa: E402 — logging is configured first
|
||||
import app.tables # noqa: E402 — every table, before the first query
|
||||
from app.downloads import service as download_service # noqa: E402 — logging is configured first
|
||||
from app.downloads import storage # noqa: E402 — logging is configured first
|
||||
from app.plex import stream as plex_stream # noqa: E402 — logging is configured first
|
||||
|
||||
+1
-439
@@ -5,10 +5,8 @@ from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Computed,
|
||||
Date,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
@@ -20,7 +18,7 @@ from sqlalchemy import (
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
|
||||
from app.db import Base
|
||||
@@ -514,439 +512,3 @@ class ApiTokenGrant(Base):
|
||||
can_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
can_write: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
can_delete: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
|
||||
# --- Plex integration (optional) -------------------------------------------------------------
|
||||
# A dedicated, self-contained catalog mirrored from a locally-reachable Plex server. Playback is
|
||||
# from the LOCAL physical file (Plex is metadata only); per-user watch state lives in plex_states
|
||||
# so it works for users without a Plex account. Mirrors the app's "shared catalog, private
|
||||
# per-user state" model. The weighted FTS search_vector uses the same unaccent_simple config as
|
||||
# `videos` (migrations 0031/0032).
|
||||
|
||||
|
||||
class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A mirrored Plex library section. Only `enabled` sections are exposed in the feed.
|
||||
`plex_key` is the section id on the Plex server."""
|
||||
|
||||
__tablename__ = "plex_libraries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
plex_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show"
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# HU-overlay support. `metadata_lang` tags the section's metadata language. An OVERLAY
|
||||
# library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable
|
||||
# on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`).
|
||||
metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en")
|
||||
overlay_of: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("plex_libraries.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
|
||||
|
||||
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A TV show (grandparent of episodes) — holds poster + summary for the drill-down page, plus
|
||||
the same filterable metadata as movies (genres/rating/content_rating/studio/people) so the TV
|
||||
grid can be filtered/sorted like the movie grid. All mirrored cheaply from the show listing."""
|
||||
|
||||
__tablename__ = "plex_shows"
|
||||
__table_args__ = (
|
||||
Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"),
|
||||
Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"),
|
||||
Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"),
|
||||
Index("ix_plex_shows_genres_hu_gin", "genres_hu", postgresql_using="gin"),
|
||||
# ix_plex_shows_collections_gin already created with collections (migration 0047).
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
library_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
# Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay
|
||||
# twin during sync. Indexed for the guid->row overlay match.
|
||||
guid: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
year: Mapped[int | None] = mapped_column(Integer)
|
||||
thumb_key: Mapped[str | None] = mapped_column(
|
||||
String(512)
|
||||
) # Plex thumb path (proxied, not stored)
|
||||
art_key: Mapped[str | None] = mapped_column(String(512))
|
||||
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
|
||||
collection_keys: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # Plex collections this show is in
|
||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
|
||||
rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb)
|
||||
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
||||
studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio
|
||||
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date
|
||||
# none_as_null: an empty tag list must persist as SQL NULL, not JSON `null`. Without it a Python
|
||||
# None round-trips as a JSON scalar that jsonb_array_elements_text() can't unnest ("cannot extract
|
||||
# elements from a scalar") — the trap a metadata-poor library sprang on /facets. Same on plex_items.
|
||||
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
people_text: Mapped[str | None] = mapped_column(
|
||||
Text
|
||||
) # cast+directors, folded into search_vector
|
||||
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
|
||||
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||
imdb_rating: Mapped[float | None] = mapped_column(
|
||||
Float
|
||||
) # IMDb-specific score (vs `rating` = audienceRating)
|
||||
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||
tagline: Mapped[str | None] = mapped_column(Text)
|
||||
full_cast: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # ordered [{name, role, thumb}]
|
||||
# --- TMDB enrichment: see the identical block on plex_items for the full rationale. Additive;
|
||||
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
|
||||
tmdb_id: Mapped[int | None] = mapped_column(Integer)
|
||||
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
|
||||
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
|
||||
budget: Mapped[int | None] = mapped_column(BigInteger)
|
||||
revenue: Mapped[int | None] = mapped_column(BigInteger)
|
||||
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
crew: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # [{name, job, tmdb_id, thumb}]
|
||||
studios: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # TMDB production companies [str]
|
||||
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched,
|
||||
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
|
||||
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
|
||||
summary_hu: Mapped[str | None] = mapped_column(Text)
|
||||
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
search_vector: Mapped[object | None] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed(
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
||||
persisted=True,
|
||||
),
|
||||
)
|
||||
search_vector_hu: Mapped[object | None] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed(
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
|
||||
persisted=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PlexSeason(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A season under a show — used to order episodes for prev/next stepping."""
|
||||
|
||||
__tablename__ = "plex_seasons"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
show_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(String(255))
|
||||
season_number: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
||||
|
||||
|
||||
class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A playable LEAF — a movie or a single episode. This is what the feed lists and the player
|
||||
plays. Movies carry `library_id` and no parents; episodes carry show/season refs +
|
||||
season/episode numbers. `file_path` is the Plex-side absolute path of the physical media,
|
||||
mapped through `plex_path_map` to a local mount at playback time. `playable` is the
|
||||
ffprobe-derived browser-compatibility class (direct | remux | transcode)."""
|
||||
|
||||
__tablename__ = "plex_items"
|
||||
__table_args__ = (
|
||||
Index("ix_plex_items_show_order", "show_id", "season_number", "episode_number"),
|
||||
Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
|
||||
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
|
||||
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
|
||||
Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"),
|
||||
Index("ix_plex_items_genres_hu_gin", "genres_hu", postgresql_using="gin"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
library_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode"
|
||||
# Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during
|
||||
# sync; indexed for the guid->row match. Episodes carry it too but it's unused for them.
|
||||
guid: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
show_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
season_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("plex_seasons.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
season_number: Mapped[int | None] = mapped_column(Integer)
|
||||
episode_number: Mapped[int | None] = mapped_column(Integer)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
year: Mapped[int | None] = mapped_column(Integer)
|
||||
duration_s: Mapped[int | None] = mapped_column(Integer)
|
||||
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
||||
art_key: Mapped[str | None] = mapped_column(String(512))
|
||||
cast: Mapped[list | None] = mapped_column(JSON) # [role names]
|
||||
# Physical media (from the Plex Part).
|
||||
file_path: Mapped[str | None] = mapped_column(Text) # Plex-side absolute path
|
||||
part_key: Mapped[str | None] = mapped_column(String(255)) # /library/parts/... (fallback fetch)
|
||||
container: Mapped[str | None] = mapped_column(String(16))
|
||||
codec_video: Mapped[str | None] = mapped_column(String(32))
|
||||
codec_audio: Mapped[str | None] = mapped_column(String(32))
|
||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
||||
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
|
||||
playable: Mapped[str | None] = mapped_column(String(12), index=True)
|
||||
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a
|
||||
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
|
||||
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
|
||||
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
|
||||
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
|
||||
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
|
||||
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
|
||||
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
|
||||
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
|
||||
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
|
||||
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
|
||||
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
|
||||
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
|
||||
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
|
||||
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
|
||||
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
|
||||
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
|
||||
# filters (GIN-indexed) and double as content signals for a future watch-habit recommender.
|
||||
rating: Mapped[float | None] = mapped_column(Float, index=True)
|
||||
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
||||
studio: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True)
|
||||
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
# rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings).
|
||||
collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
|
||||
# finds titles by a person's name (and ranks them above summary-only mentions).
|
||||
people_text: Mapped[str | None] = mapped_column(Text)
|
||||
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
|
||||
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||
imdb_rating: Mapped[float | None] = mapped_column(
|
||||
Float
|
||||
) # IMDb-specific score (vs `rating` = audienceRating)
|
||||
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||
tagline: Mapped[str | None] = mapped_column(Text)
|
||||
full_cast: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # ordered [{name, role, thumb}]
|
||||
# --- TMDB enrichment: matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB
|
||||
# backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating
|
||||
# above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched;
|
||||
# `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an
|
||||
# unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). ---
|
||||
tmdb_id: Mapped[int | None] = mapped_column(Integer)
|
||||
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
|
||||
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
|
||||
budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none)
|
||||
revenue: Mapped[int | None] = mapped_column(BigInteger)
|
||||
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
crew: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # [{name, job, tmdb_id, thumb}]
|
||||
studios: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # TMDB production companies [str]
|
||||
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
# [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time.
|
||||
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
backdrops: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # TMDB backdrop file_paths [str] (crossfade pool)
|
||||
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched
|
||||
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
|
||||
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
|
||||
summary_hu: Mapped[str | None] = mapped_column(Text)
|
||||
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
search_vector: Mapped[object | None] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed(
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
||||
persisted=True,
|
||||
),
|
||||
)
|
||||
search_vector_hu: Mapped[object | None] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed(
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
|
||||
persisted=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the
|
||||
actors currently in the filter) can show a photo + count without a live per-card metadata fetch.
|
||||
Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the
|
||||
periodic rich re-sync — since the cheap section listing that drives the main sync only carries the
|
||||
top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library
|
||||
removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned
|
||||
metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the
|
||||
row); this table is the people's own facts: the headshot link, the TMDB link and the TMDB bio
|
||||
(the headshot FILE lives in the image cache, filled by the people_enrich job)."""
|
||||
|
||||
__tablename__ = "plex_people"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
|
||||
# TMDB person id, linked by name from a title's TMDB credits (the /person
|
||||
# biography fetch builds on it). NULL until a TMDB-enriched title names this person.
|
||||
tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
|
||||
# --- TMDB person enrichment: fetched from /person/{tmdb_person_id} by the people_enrich job,
|
||||
# or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a
|
||||
# no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id,
|
||||
# media_type, title}], resolved to library rows by guid at serve time (like recommendations). ---
|
||||
biography: Mapped[str | None] = mapped_column(Text)
|
||||
# TMDB's Hungarian biography, from the same request's `translations`; a Hungarian reader is
|
||||
# served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and
|
||||
# keywords have no Hungarian on TMDB, so they stay in the instance language.
|
||||
biography_hu: Mapped[str | None] = mapped_column(Text)
|
||||
birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is
|
||||
deathday: Mapped[str | None] = mapped_column(String(10))
|
||||
place_of_birth: Mapped[str | None] = mapped_column(Text)
|
||||
known_for_department: Mapped[str | None] = mapped_column(String(64))
|
||||
also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
known_for: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # [{tmdb_id, media_type, title}]
|
||||
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
|
||||
sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
|
||||
sync so reads never hit Plex's slow collection queries. Membership is stored as `collection_keys`
|
||||
on the member movies (plex_items) / shows (plex_shows). `smart` = Plex's query-based flag;
|
||||
`editable` = we may write changes back to Plex (default False = read-only; set for curated ones)."""
|
||||
|
||||
__tablename__ = "plex_collections"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
library_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
||||
child_count: Mapped[int | None] = mapped_column(Integer)
|
||||
smart: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
editable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PlexState(Base, UpdatedAtMixin):
|
||||
"""Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so
|
||||
it works for users WITHOUT a Plex account (the whole point of the access-layer design).
|
||||
`synced_to_plex` marks whether this state was pushed back to a linked Plex account."""
|
||||
|
||||
__tablename__ = "plex_states"
|
||||
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
||||
position_seconds: Mapped[int] = mapped_column(
|
||||
Integer, default=0, server_default="0", nullable=False
|
||||
)
|
||||
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
synced_to_plex: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
|
||||
class PlexLink(Base, UpdatedAtMixin):
|
||||
"""Links a Siftlode user to a Plex account for two-way watch-state sync (one row per user).
|
||||
|
||||
For the owner (the MVP scope) the row uses the server's admin token — `uses_admin=True`,
|
||||
`plex_token_enc=None` — so no separate Plex login is needed; the admin token already reads/writes
|
||||
the owner account's watch state. `plex_account_id` is that Plex account's numeric id, used to
|
||||
filter the shared watch-history feed to this user (Phase C). Reserved for later: a friend can link
|
||||
their OWN Plex account via PIN OAuth, storing a personal `plex_token_enc` (uses_admin=False) — the
|
||||
sync loop then iterates link rows uniformly.
|
||||
|
||||
`initial_import_done` guards the one-time "Plex is master" import; `last_watch_sync_at` is the
|
||||
high-water mark for the incremental Plex→Siftlode pass."""
|
||||
|
||||
__tablename__ = "plex_link"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
uses_admin: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
plex_token_enc: Mapped[str | None] = mapped_column(Text) # P5b friend tokens (encrypted)
|
||||
plex_account_id: Mapped[int | None] = mapped_column(Integer)
|
||||
plex_username: Mapped[str | None] = mapped_column(String(255))
|
||||
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
initial_import_done: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
last_watch_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
linked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for
|
||||
users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a
|
||||
playlist is owned by one user and its items are ordered. Optional Plex-direction sync is a later
|
||||
phase (`plex_rating_key` set once pushed back to the owner's Plex account)."""
|
||||
|
||||
__tablename__ = "plex_playlists"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
plex_rating_key: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
|
||||
class PlexPlaylistItem(Base):
|
||||
"""One ordered entry in a playlist. `position` (0-based) orders playback."""
|
||||
|
||||
__tablename__ = "plex_playlist_items"
|
||||
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
playlist_id: Mapped[int] = mapped_column(ForeignKey("plex_playlists.id", ondelete="CASCADE"))
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
|
||||
@@ -9,6 +9,7 @@ table can't grow without bound for a heavy user.
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.userscope import owned_by
|
||||
from app.models import Notification
|
||||
|
||||
# Keep at most this many READ notifications per user; older read ones are pruned. Unread
|
||||
@@ -48,11 +49,13 @@ def create_notification(
|
||||
|
||||
def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int:
|
||||
"""Delete a user's oldest READ notifications beyond `cap`. Returns the number removed.
|
||||
Idempotent and cheap; safe to call after marking things read."""
|
||||
Idempotent and cheap; safe to call after marking things read. Flushes first, so a notice the
|
||||
caller has just marked read counts; the caller commits."""
|
||||
db.flush()
|
||||
ids = (
|
||||
db.execute(
|
||||
select(Notification.id)
|
||||
.where(Notification.user_id == user_id, Notification.read.is_(True))
|
||||
.where(owned_by(Notification.user_id, user_id), Notification.read.is_(True))
|
||||
.order_by(Notification.created_at.desc())
|
||||
.offset(cap)
|
||||
)
|
||||
@@ -63,5 +66,4 @@ def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int:
|
||||
return 0
|
||||
# One bulk DELETE rather than a get+delete per row (the ids are already in hand).
|
||||
db.execute(delete(Notification).where(Notification.id.in_(ids)))
|
||||
db.commit()
|
||||
return len(ids)
|
||||
|
||||
@@ -5,7 +5,7 @@ router. Pure functions — no DB, no request state."""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
from app.models import PlexItem, PlexShow, PlexState
|
||||
from app.plex.models import PlexItem, PlexShow, PlexState
|
||||
|
||||
|
||||
class ShowState(NamedTuple):
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""What a collection is to the app: where it comes from, whether it may be edited, its card, its
|
||||
effective content rating, and how its members order — shared by the collection pages, the info
|
||||
page's collection strips and the admin editor."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from app.plex.models import PlexCollection
|
||||
|
||||
# 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).
|
||||
_CERT_RANK = {
|
||||
"G": 1,
|
||||
"TV-Y": 1,
|
||||
"TV-Y7": 1,
|
||||
"TV-G": 1,
|
||||
"PG": 2,
|
||||
"TV-PG": 2,
|
||||
"PG-13": 3,
|
||||
"TV-14": 3,
|
||||
"R": 4,
|
||||
"TV-MA": 4,
|
||||
"NC-17": 5,
|
||||
}
|
||||
|
||||
|
||||
def collection_cert(certs: list[str | None]) -> str | None:
|
||||
ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c]
|
||||
return max(ranked, key=lambda x: x[1])[0] if ranked else None
|
||||
|
||||
|
||||
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
|
||||
|
||||
|
||||
def collection_source(col: PlexCollection) -> str:
|
||||
first = (col.title or "").strip().split(" ", 1)[0].lower()
|
||||
if first in _STRIP_PROVIDERS:
|
||||
return first
|
||||
if col.smart:
|
||||
return "smart"
|
||||
return "collection"
|
||||
|
||||
|
||||
def collection_editable(c: PlexCollection) -> bool:
|
||||
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
|
||||
smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex."""
|
||||
return bool(c.editable) and not c.smart and collection_source(c) == "collection"
|
||||
|
||||
|
||||
def collection_card(c: PlexCollection) -> dict:
|
||||
return {
|
||||
"id": c.rating_key,
|
||||
"title": c.title,
|
||||
"summary": c.summary,
|
||||
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
|
||||
"child_count": c.child_count,
|
||||
"smart": c.smart,
|
||||
"source": collection_source(c),
|
||||
"editable": c.editable,
|
||||
"can_edit": collection_editable(
|
||||
c
|
||||
), # whether the edit controls should be offered (admin UI)
|
||||
}
|
||||
|
||||
|
||||
class Member(NamedTuple):
|
||||
"""One collection member flattened to everything the Sort control can order by. The page's two
|
||||
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
|
||||
ordering is a pure list sort over these, which is also what makes it unit-testable."""
|
||||
|
||||
kind: str # "movie" | "show"
|
||||
obj: Any # PlexItem | PlexShow, carried through to the card serializer
|
||||
title: str # lower-cased; never NULL (the column is not nullable)
|
||||
year: int | None
|
||||
rating: float | None
|
||||
added: datetime | None
|
||||
release: date | None
|
||||
duration: int | None # movies only — a show has no single runtime
|
||||
last_watched: datetime | None
|
||||
|
||||
|
||||
# Sort key -> what to read off a Member. Pinned to `PLEX_SORT_KEYS` by a test: the collection page
|
||||
# shows the SAME Sort control as the browse grid, so a key this map is missing would be an option
|
||||
# that silently does nothing here.
|
||||
MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = {
|
||||
"added": lambda m: m.added,
|
||||
"lastwatched": lambda m: m.last_watched,
|
||||
"release": lambda m: m.release,
|
||||
"year": lambda m: m.year,
|
||||
"rating": lambda m: m.rating,
|
||||
"duration": lambda m: m.duration,
|
||||
"title": lambda m: m.title,
|
||||
}
|
||||
|
||||
# What the page ordered by before it had a control, and still its default: a film series wants to be
|
||||
# read in release order, not by whenever Plex happened to ingest it.
|
||||
DEFAULT_MEMBER_SORT = "year"
|
||||
|
||||
|
||||
def sort_members(
|
||||
members: list[Member], sort: str, sort_dir: str, alpha: dict[str, int] | None = None
|
||||
) -> list[Member]:
|
||||
"""Order a collection's members by one of `PLEX_SORT_KEYS`.
|
||||
|
||||
Two properties worth stating because they are easy to get wrong with a plain `reverse=True`:
|
||||
members with no value for the key sink to the END in BOTH directions (matching the browse grid's
|
||||
`nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve
|
||||
title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside
|
||||
it. An unknown key falls back to the page's default instead of returning an arbitrary order.
|
||||
|
||||
`alpha` ranks each title in the reader's alphabet (`collation.sorted_words`); without it, titles
|
||||
compare as Python strings, which puts every accented initial after "z"."""
|
||||
by_title = (lambda m: alpha[m.title]) if alpha is not None else (lambda m: m.title)
|
||||
key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT])
|
||||
if key is MEMBER_SORT_KEYS["title"]:
|
||||
key = by_title
|
||||
ordered = sorted(members, key=by_title) # stable secondary, applied first
|
||||
have = [m for m in ordered if key(m) is not None]
|
||||
missing = [m for m in ordered if key(m) is None]
|
||||
have.sort(key=key, reverse=sort_dir != "asc")
|
||||
return have + missing
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that
|
||||
the detail endpoints serve from the DB row.
|
||||
|
||||
Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so
|
||||
the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent.
|
||||
`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row
|
||||
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
||||
Shared by the live info-page path (`plex.service.enrich_movie` / `enrich_show`) and the background
|
||||
rich re-sync (plex.sync) so the two never diverge — the same one-parser rule that keeps `people.py`'s
|
||||
cast handling consistent. `rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY
|
||||
fields onto a movie/show row (genres/directors/studio/content_rating already land from the cheap
|
||||
listing at `_apply_facet_fields`)."""
|
||||
|
||||
from app.plex import people
|
||||
from app.plex.plaintext import plain_text
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Plex Discover's reads: the rail-surface read-model.
|
||||
|
||||
A consolidated payload for the dedicated Discover landing — a stack of horizontal rails:
|
||||
Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a
|
||||
separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the
|
||||
recs the TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and
|
||||
every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit
|
||||
their `art` + rating/summary for free). The rails carry EN metadata (like the other non-grid
|
||||
card paths); the HU overlay coalesces only in the main grid.
|
||||
|
||||
The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the
|
||||
Discover surface behaves like the Browse grid — the same `LibraryFilters` / `meta_filter_conds` the
|
||||
grid uses, applied to each rail's movie + show query (a rail may legitimately come back empty, which
|
||||
the client hides). Genre-language matches the grid; duration is movie-only.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.collation import sorted_words
|
||||
from app.discover.queries import RAIL_LIMIT
|
||||
from app.models import User
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import SHOW_NEW, display_title, leaf_card, movie_card, show_card
|
||||
from app.plex.models import PlexItem, PlexShow, PlexState
|
||||
from app.plex.query import (
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
apply_show_state_filter,
|
||||
content_libs,
|
||||
genres_col,
|
||||
meta_filter_conds,
|
||||
top_art_keys,
|
||||
)
|
||||
from app.plex.tmdb import resolve_recs
|
||||
from app.userscope import owned_by
|
||||
|
||||
GENRE_TILES = 12
|
||||
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
|
||||
|
||||
|
||||
def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
|
||||
"""The movie/show library ids visible for this scope (empty list = out of scope)."""
|
||||
movie_ids, show_ids = content_libs(db)
|
||||
return (
|
||||
movie_ids if scope in ("movie", "both") else [],
|
||||
show_ids if scope in ("show", "both") else [],
|
||||
)
|
||||
|
||||
|
||||
def _movie_meta(q, p: dict, lang: str, f: LibraryFilters):
|
||||
"""Apply the shared metadata filters + movie-only duration to a movie query (same as the grid)."""
|
||||
q = apply_meta_filters(q, PlexItem, p, lang)
|
||||
if f.duration_min is not None:
|
||||
q = q.where(PlexItem.duration_s >= f.duration_min)
|
||||
if f.duration_max is not None:
|
||||
q = q.where(PlexItem.duration_s <= f.duration_max)
|
||||
return q
|
||||
|
||||
|
||||
def _movie_cards(db: Session, uid: int, items: list[PlexItem], lang: str) -> list[dict]:
|
||||
sts = rollup.item_state_map(db, uid, [it.id for it in items])
|
||||
return [movie_card(it, sts.get(it.id), lang=lang) for it in items]
|
||||
|
||||
|
||||
def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]:
|
||||
statuses = rollup.state_map(db, uid, [s.id for s in shows])
|
||||
return [show_card(s, statuses.get(s.id, SHOW_NEW), lang=lang) for s in shows]
|
||||
|
||||
|
||||
def _recently_added(
|
||||
db: Session,
|
||||
uid: int,
|
||||
movie_ids: list[int],
|
||||
show_ids: list[int],
|
||||
p: dict,
|
||||
lang: str,
|
||||
f: LibraryFilters,
|
||||
) -> list[dict]:
|
||||
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
|
||||
movies = (
|
||||
db.execute(
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)
|
||||
),
|
||||
p,
|
||||
lang,
|
||||
f,
|
||||
)
|
||||
.order_by(PlexItem.added_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if movie_ids
|
||||
else []
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
apply_meta_filters(
|
||||
select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang
|
||||
)
|
||||
.order_by(PlexShow.added_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if show_ids
|
||||
else []
|
||||
)
|
||||
# Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the
|
||||
# per-card state lookups stay batched (no N+1). Same `or NO_TS` key as the two rails below — a
|
||||
# review found this one still on the older `(is not None, value)` form, i.e. one rule in two
|
||||
# spellings, with the two comments explaining it differently.
|
||||
tagged = [(m.added_at, "movie", m) for m in movies] + [(s.added_at, "show", s) for s in shows]
|
||||
tagged.sort(key=lambda t: t[0] or rollup.NO_TS, reverse=True)
|
||||
top = tagged[:RAIL_LIMIT]
|
||||
# Key the serialized cards back by rating_key (globally unique in Plex — a movie and a show never
|
||||
# share one), so the merge doesn't depend on object identity staying alive.
|
||||
top_movies = [o for _, kind, o in top if kind == "movie"]
|
||||
top_shows = [o for _, kind, o in top if kind == "show"]
|
||||
mcards = {
|
||||
m.rating_key: c
|
||||
for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang), strict=True)
|
||||
}
|
||||
scards = {
|
||||
s.rating_key: c
|
||||
for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang), strict=True)
|
||||
}
|
||||
return [mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] for _, kind, o in top]
|
||||
|
||||
|
||||
def _recently_watched(
|
||||
db: Session,
|
||||
uid: int,
|
||||
movie_ids: list[int],
|
||||
show_ids: list[int],
|
||||
p: dict,
|
||||
lang: str,
|
||||
f: LibraryFilters,
|
||||
) -> list[dict]:
|
||||
"""Recently watched rail: the titles the user last FULLY watched, newest first. A movie is its own
|
||||
card; a show contributes the specific EPISODE watched (carrying its show title). Movies honour the
|
||||
sidebar filters (like the other rails); episodes carry no filterable metadata, so they don't."""
|
||||
rows: list[tuple[datetime | None, dict]] = [] # (watched_at, card)
|
||||
if movie_ids:
|
||||
mq = (
|
||||
select(PlexItem, PlexState)
|
||||
.join(
|
||||
PlexState, and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid))
|
||||
)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
)
|
||||
for it, st in db.execute(
|
||||
_movie_meta(mq, p, lang, f)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all():
|
||||
rows.append((st.watched_at, leaf_card(it, st, lang=lang)))
|
||||
if show_ids:
|
||||
eq = (
|
||||
select(PlexItem, PlexState, PlexShow)
|
||||
.join(
|
||||
PlexState, and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid))
|
||||
)
|
||||
.join(PlexShow, PlexShow.id == PlexItem.show_id)
|
||||
.where(
|
||||
PlexItem.kind == "episode",
|
||||
PlexItem.library_id.in_(show_ids),
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
)
|
||||
for ep, st, sh in db.execute(eq).all():
|
||||
card = leaf_card(
|
||||
ep, st, show_title=display_title(sh, lang), show_rk=sh.rating_key, lang=lang
|
||||
)
|
||||
rows.append((st.watched_at, card))
|
||||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
|
||||
|
||||
def _continue_watching(
|
||||
db: Session,
|
||||
uid: int,
|
||||
movie_ids: list[int],
|
||||
show_ids: list[int],
|
||||
p: dict,
|
||||
lang: str,
|
||||
f: LibraryFilters,
|
||||
) -> list[dict]:
|
||||
"""Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first.
|
||||
A movie's resume time is its own `progress_updated_at`; a show's is the max across its episodes."""
|
||||
rows: list[tuple[datetime | None, dict]] = [] # (resumed_at, card)
|
||||
|
||||
if movie_ids:
|
||||
mq = (
|
||||
select(PlexItem, PlexState)
|
||||
.join(
|
||||
PlexState,
|
||||
and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid)),
|
||||
)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexState.position_seconds > 0,
|
||||
# A NULL status is still "started" (mirrors apply_movie_state_filter's in_progress) —
|
||||
# `notin_` alone would drop it, since `NULL NOT IN (...)` is NULL, not TRUE.
|
||||
or_(
|
||||
PlexState.status.is_(None),
|
||||
PlexState.status.notin_(["watched", "hidden"]),
|
||||
),
|
||||
)
|
||||
)
|
||||
mrows = db.execute(
|
||||
_movie_meta(mq, p, lang, f)
|
||||
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all()
|
||||
for it, st in mrows:
|
||||
rows.append((st.progress_updated_at, movie_card(it, st, lang=lang)))
|
||||
|
||||
if show_ids:
|
||||
# Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus
|
||||
# its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
|
||||
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
|
||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||
sq = apply_show_state_filter(
|
||||
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
|
||||
prog, prog.c.show_id == PlexShow.id
|
||||
),
|
||||
prog.c.total,
|
||||
prog.c.watched,
|
||||
prog.c.inprog,
|
||||
"in_progress",
|
||||
).where(PlexShow.library_id.in_(show_ids))
|
||||
srows = db.execute(
|
||||
apply_meta_filters(sq, PlexShow, p, lang)
|
||||
.order_by(prog.c.resumed_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all()
|
||||
for sh, resumed_at, total, watched, inprog in srows:
|
||||
state = rollup.show_state(total, watched, inprog)
|
||||
rows.append((resumed_at, show_card(sh, state, lang=lang)))
|
||||
|
||||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
|
||||
|
||||
def _genre_tiles(
|
||||
db: Session, movie_ids: list[int], show_ids: list[int], p: dict, lang: str
|
||||
) -> list[dict]:
|
||||
"""The most-common genres across the scope, as {value, count} tiles → tap to filter the grid.
|
||||
The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid.
|
||||
The active metadata filters narrow the counts too, so the tiles agree with the filtered rails."""
|
||||
counts: dict[str, int] = {}
|
||||
for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)):
|
||||
if not ids:
|
||||
continue
|
||||
gcol = genres_col(model, lang)
|
||||
base = select(func.jsonb_array_elements_text(gcol).label("g")).where(
|
||||
model.library_id.in_(ids),
|
||||
func.jsonb_typeof(gcol) == "array",
|
||||
*meta_filter_conds(model, p, lang),
|
||||
)
|
||||
if model is PlexItem:
|
||||
base = base.where(model.kind == "movie")
|
||||
expanded = base.subquery()
|
||||
for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all():
|
||||
counts[g] = counts.get(g, 0) + c
|
||||
alpha = {w: i for i, w in enumerate(sorted_words(db, list(counts), lang))}
|
||||
top = sorted(counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))[:GENRE_TILES]
|
||||
# A 2×2 poster collage per tile: the top movies in that genre (color hash is the client fallback).
|
||||
art = top_art_keys(
|
||||
db,
|
||||
PlexItem,
|
||||
movie_ids,
|
||||
genres_col(PlexItem, lang),
|
||||
[g for g, _ in top],
|
||||
extra_conds=(PlexItem.kind == "movie", *meta_filter_conds(PlexItem, p, lang)),
|
||||
)
|
||||
return [{"value": g, "count": c, "art": art.get(g, [])} for g, c in top]
|
||||
|
||||
|
||||
def _because_you_watched(
|
||||
db: Session,
|
||||
uid: int,
|
||||
movie_ids: list[int],
|
||||
show_ids: list[int],
|
||||
p: dict,
|
||||
lang: str,
|
||||
f: LibraryFilters,
|
||||
) -> dict | None:
|
||||
"""Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the
|
||||
recs that resolve to OUR library (by guid) become cards — the rest aren't actionable here. Returns
|
||||
None (rail hidden) when there's no enriched watch history or none of its recs are in the library.
|
||||
The metadata filters narrow the resolved recs (not the anchor pick) so the rail matches the grid."""
|
||||
if not movie_ids:
|
||||
return None
|
||||
anchor = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.join(
|
||||
PlexState,
|
||||
and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid)),
|
||||
)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexState.status == "watched",
|
||||
PlexItem.recommendations.isnot(None),
|
||||
)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(1)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
if anchor is None:
|
||||
return None
|
||||
resolved = resolve_recs(db, anchor.recommendations, lang=lang)
|
||||
# Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself.
|
||||
rks = [
|
||||
r["rating_key"]
|
||||
for r in resolved
|
||||
if r.get("in_library") and r.get("rating_key") != anchor.rating_key
|
||||
]
|
||||
if not rks:
|
||||
return None
|
||||
movies = {
|
||||
m.rating_key: m
|
||||
for m in db.execute(
|
||||
_movie_meta(
|
||||
select(PlexItem).where(PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)),
|
||||
p,
|
||||
lang,
|
||||
f,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
}
|
||||
shows = {
|
||||
s.rating_key: s
|
||||
for s in db.execute(
|
||||
apply_meta_filters(
|
||||
select(PlexShow).where(PlexShow.rating_key.in_(rks)), PlexShow, p, lang
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
}
|
||||
ordered_movies = [movies[rk] for rk in rks if rk in movies]
|
||||
ordered_shows = [shows[rk] for rk in rks if rk in shows]
|
||||
# rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
|
||||
mcards = {
|
||||
m.rating_key: c
|
||||
for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang), strict=True)
|
||||
}
|
||||
scards = {
|
||||
s.rating_key: c
|
||||
for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang), strict=True)
|
||||
}
|
||||
items: list[dict] = []
|
||||
for rk in rks:
|
||||
if rk in mcards:
|
||||
items.append(mcards[rk])
|
||||
elif rk in scards:
|
||||
items.append(scards[rk])
|
||||
if not items:
|
||||
return None
|
||||
return {
|
||||
"anchor": {"id": anchor.rating_key, "title": display_title(anchor, lang)},
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def discover_page(db: Session, user: User, scope: str, lang: str, f: LibraryFilters) -> dict:
|
||||
"""The Discover landing payload — every rail in one pass (serve-from-DB), narrowed by the active
|
||||
sidebar filters. Empty rails come back as empty lists (the client hides them); `because_you_watched`
|
||||
is null when there's no anchor. `lang` is already normalised."""
|
||||
p = f.as_dict()
|
||||
movie_ids, show_ids = _scope_libs(db, scope)
|
||||
return {
|
||||
"scope": scope,
|
||||
"continue_watching": _continue_watching(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"recently_watched": _recently_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"genres": _genre_tiles(db, movie_ids, show_ids, p, lang),
|
||||
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
}
|
||||
|
||||
|
||||
def _draw(db: Session, stmt, key_col, per: int, exclude: list[str]) -> list:
|
||||
"""`per` random rows of `stmt`, the previous draw's (`exclude`) left out while the pool allows: a
|
||||
reshuffle over a small filtered pool kept dealing the same few titles. What the fresh rows leave
|
||||
short is topped up from the excluded ones, so a pool smaller than a draw still fills the rail."""
|
||||
fresh = (
|
||||
db.execute(stmt.where(key_col.not_in(exclude)).order_by(func.random()).limit(per))
|
||||
.scalars()
|
||||
.all()
|
||||
if exclude
|
||||
else []
|
||||
)
|
||||
if len(fresh) == per:
|
||||
return list(fresh)
|
||||
taken = [key_col.not_in([r.rating_key for r in fresh])] if fresh else []
|
||||
rest = db.execute(stmt.where(*taken).order_by(func.random()).limit(per - len(fresh))).scalars()
|
||||
return [*fresh, *rest.all()]
|
||||
|
||||
|
||||
def _kept(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], keep: list[str], lang: str
|
||||
) -> list[dict]:
|
||||
"""The `keep` titles' cards in their given order, as they stand now: a title watched since the draw
|
||||
stays in it, marked watched, and one gone from the scope's libraries drops out."""
|
||||
movies = (
|
||||
db.execute(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexItem.rating_key.in_(keep),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if movie_ids
|
||||
else []
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
select(PlexShow).where(PlexShow.library_id.in_(show_ids), PlexShow.rating_key.in_(keep))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if show_ids
|
||||
else []
|
||||
)
|
||||
cards = {
|
||||
c["id"]: c
|
||||
for c in [
|
||||
*_movie_cards(db, uid, list(movies), lang),
|
||||
*_show_cards(db, uid, list(shows), lang),
|
||||
]
|
||||
}
|
||||
return [cards[k] for k in keep if k in cards]
|
||||
|
||||
|
||||
def surprise(
|
||||
db: Session,
|
||||
user: User,
|
||||
scope: str,
|
||||
lang: str,
|
||||
limit: int,
|
||||
exclude: list[str],
|
||||
keep: list[str],
|
||||
f: LibraryFilters,
|
||||
) -> dict:
|
||||
"""A fresh random sample for the reshuffling Surprise-me rail (a random draw each call), narrowed by
|
||||
the active filters. Only titles the user has NOT seen — a watched movie / a fully-watched show is
|
||||
excluded (the point is to surface something to watch). Capped at SURPRISE_PER_KIND movies +
|
||||
SURPRISE_PER_KIND shows, drawn separately then interleaved so a lopsided library doesn't crowd out
|
||||
one type."""
|
||||
p = f.as_dict()
|
||||
movie_ids, show_ids = _scope_libs(db, scope)
|
||||
if keep:
|
||||
return {"scope": scope, "items": _kept(db, user.id, movie_ids, show_ids, keep, lang)}
|
||||
per = min(limit, SURPRISE_PER_KIND)
|
||||
movies = []
|
||||
if movie_ids:
|
||||
# Exclude movies this user has watched.
|
||||
watched_movie = (
|
||||
select(PlexState.id)
|
||||
.where(
|
||||
PlexState.item_id == PlexItem.id,
|
||||
owned_by(PlexState.user_id, user),
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
movies = _draw(
|
||||
db,
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
~watched_movie,
|
||||
),
|
||||
p,
|
||||
lang,
|
||||
f,
|
||||
),
|
||||
PlexItem.rating_key,
|
||||
per,
|
||||
exclude,
|
||||
)
|
||||
shows = []
|
||||
if show_ids:
|
||||
# Exclude fully-watched shows (LEFT JOIN so a never-started show — no rollup row — still counts
|
||||
# as unseen). `total IS NULL / == 0 / watched < total` == "not fully watched".
|
||||
prog = rollup.agg_subquery(db, user.id).subquery()
|
||||
not_fully_watched = or_(
|
||||
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||||
)
|
||||
shows = _draw(
|
||||
db,
|
||||
apply_meta_filters(
|
||||
select(PlexShow)
|
||||
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||||
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||||
PlexShow,
|
||||
p,
|
||||
lang,
|
||||
),
|
||||
PlexShow.rating_key,
|
||||
per,
|
||||
exclude,
|
||||
)
|
||||
mcards = _movie_cards(db, user.id, movies, lang)
|
||||
scards = _show_cards(db, user.id, shows, lang)
|
||||
# Interleave movie/show cards, then trim to `limit`.
|
||||
items: list[dict] = []
|
||||
for i in range(max(len(mcards), len(scards))):
|
||||
if i < len(mcards):
|
||||
items.append(mcards[i])
|
||||
if i < len(scards):
|
||||
items.append(scards[i])
|
||||
return {"scope": scope, "items": items[:limit]}
|
||||
@@ -15,7 +15,7 @@ from fastapi import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
||||
from app.plex.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
||||
from app.plex.cache import CACHE_DIR
|
||||
from app.plex.lookups import by_rating_key
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
"""The Plex browse grid's reads: the unified movies + shows feed, its search, and its facets.
|
||||
|
||||
A user's watch state is joined through `userscope`; the feed and its facets share every filter
|
||||
(`plex.query`), so a facet count is the size of what the grid would show.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, and_, cast, func, literal, null, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.collation import collated, sorted_words
|
||||
from app.models import User
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import norm_lang
|
||||
from app.plex.models import PlexItem, PlexShow, PlexState
|
||||
from app.plex.query import (
|
||||
FIELD_COLUMNS,
|
||||
PLAYBACK_CLASSES,
|
||||
TS_CONFIG,
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
apply_movie_state_filter,
|
||||
apply_show_state_filter,
|
||||
content_libs,
|
||||
field_recheck,
|
||||
genres_col,
|
||||
meta_filter_conds,
|
||||
movie_status_case,
|
||||
playable_conds,
|
||||
playback_class_expr,
|
||||
show_status_case,
|
||||
split_csv,
|
||||
summary_col,
|
||||
title_col,
|
||||
vector_col,
|
||||
vector_match,
|
||||
)
|
||||
from app.userscope import owned_by
|
||||
from app.utils import to_tsquery_str
|
||||
|
||||
|
||||
def sort_column(u, sort: str, lang: str = "en"):
|
||||
"""The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline
|
||||
in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control
|
||||
offers but this cannot order would quietly fall back to `added_at` and look like a broken control.
|
||||
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts
|
||||
in the reader's alphabet (`collation`)."""
|
||||
return {
|
||||
"added": u.c.added_at,
|
||||
"title": collated(u.c.title, lang),
|
||||
"year": u.c.year,
|
||||
"rating": u.c.rating,
|
||||
"duration": u.c.duration_s,
|
||||
"release": u.c.rel,
|
||||
"lastwatched": u.c.last_watched,
|
||||
}.get(sort, u.c.added_at)
|
||||
|
||||
|
||||
def library_page(
|
||||
db: Session,
|
||||
user: User,
|
||||
*,
|
||||
scope: str,
|
||||
q: str | None,
|
||||
field: str,
|
||||
sort: str,
|
||||
sort_dir: str,
|
||||
show: str,
|
||||
lang: str,
|
||||
offset: int,
|
||||
limit: int,
|
||||
f: LibraryFilters,
|
||||
) -> tuple[int, list, list]:
|
||||
"""One page of the unified cross-library browse: (total, rows, episodes). `rows` mixes movies
|
||||
and shows (`kind` says which), scoped to movie|show|both, with the shared filters/search/watch
|
||||
state (a show's state is the aggregate of its episodes). On a search that also matches episodes
|
||||
(and shows are in scope), `episodes` holds the matching ones as (item, state, show) — the
|
||||
grouped 'Episodes' section. `lang` is already normalised."""
|
||||
p = f.as_dict()
|
||||
movie_lib_ids, show_lib_ids = content_libs(db)
|
||||
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
||||
|
||||
ts = to_tsquery_str(q) if q else None
|
||||
tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None
|
||||
if field not in FIELD_COLUMNS and field != "all":
|
||||
field = "all"
|
||||
# `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for
|
||||
# the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter.
|
||||
grid_field = field != "episode"
|
||||
recheck = field_recheck if tsq is not None and field not in ("all", "episode") else None
|
||||
|
||||
selects = []
|
||||
if want_movies and grid_field:
|
||||
st = aliased(PlexState)
|
||||
m_status = movie_status_case(st)
|
||||
mq = (
|
||||
select(
|
||||
PlexItem.rating_key.label("rk"),
|
||||
literal("movie").label("kind"),
|
||||
title_col(PlexItem, lang).label("title"),
|
||||
PlexItem.year.label("year"),
|
||||
PlexItem.rating.label("rating"),
|
||||
# Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis.
|
||||
func.left(summary_col(PlexItem, lang), 300).label("summary"),
|
||||
PlexItem.added_at.label("added_at"),
|
||||
PlexItem.originally_available_at.label("rel"),
|
||||
PlexItem.duration_s.label("duration_s"),
|
||||
PlexItem.playable.label("playable"),
|
||||
PlexItem.transcode_hw.label("transcode_hw"),
|
||||
cast(null(), Integer).label("season_count"),
|
||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||
m_status.label("status"),
|
||||
literal(False).label("resumable"),
|
||||
# "last watched" sort key: when the movie was last resumed, else when it was marked
|
||||
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
|
||||
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
||||
(
|
||||
func.ts_rank(vector_col(PlexItem, lang), tsq)
|
||||
if tsq is not None
|
||||
else literal(0.0)
|
||||
).label("rank"),
|
||||
)
|
||||
.outerjoin(st, and_(st.item_id == PlexItem.id, owned_by(st.user_id, user)))
|
||||
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
|
||||
)
|
||||
mq = apply_movie_state_filter(mq, st, show)
|
||||
mq = apply_meta_filters(mq, PlexItem, p, lang)
|
||||
if f.duration_min is not None:
|
||||
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
||||
if f.duration_max is not None:
|
||||
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
||||
# Playback-class filter (movie-only — a show is an aggregate with no single class).
|
||||
mq = mq.where(*playable_conds(split_csv(f.playable)))
|
||||
if tsq is not None:
|
||||
mq = mq.where(vector_match(PlexItem, lang, tsq))
|
||||
if recheck is not None and (narrow := recheck(PlexItem, field, tsq, lang)) is not None:
|
||||
mq = mq.where(narrow)
|
||||
selects.append(mq)
|
||||
if want_shows and grid_field:
|
||||
agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
|
||||
tot = func.coalesce(agg.c.total, 0)
|
||||
wat = func.coalesce(agg.c.watched, 0)
|
||||
inp = func.coalesce(agg.c.inprog, 0)
|
||||
s_status = show_status_case(tot, wat, inp)
|
||||
sq = (
|
||||
select(
|
||||
PlexShow.rating_key.label("rk"),
|
||||
literal("show").label("kind"),
|
||||
title_col(PlexShow, lang).label("title"),
|
||||
PlexShow.year.label("year"),
|
||||
PlexShow.rating.label("rating"),
|
||||
func.left(summary_col(PlexShow, lang), 300).label("summary"),
|
||||
PlexShow.added_at.label("added_at"),
|
||||
PlexShow.originally_available_at.label("rel"),
|
||||
cast(null(), Integer).label("duration_s"),
|
||||
cast(null(), String).label("playable"),
|
||||
cast(null(), Boolean).label("transcode_hw"),
|
||||
PlexShow.child_count.label("season_count"),
|
||||
literal(0).label("position_seconds"),
|
||||
s_status.label("status"),
|
||||
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
|
||||
(inp > 0).label("resumable"),
|
||||
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
|
||||
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
|
||||
agg.c.resumed_at.label("last_watched"),
|
||||
(
|
||||
func.ts_rank(vector_col(PlexShow, lang), tsq)
|
||||
if tsq is not None
|
||||
else literal(0.0)
|
||||
).label("rank"),
|
||||
)
|
||||
.outerjoin(agg, agg.c.show_id == PlexShow.id)
|
||||
.where(PlexShow.library_id.in_(show_lib_ids))
|
||||
)
|
||||
sq = apply_show_state_filter(sq, tot, wat, inp, show)
|
||||
sq = apply_meta_filters(sq, PlexShow, p, lang)
|
||||
if tsq is not None:
|
||||
sq = sq.where(vector_match(PlexShow, lang, tsq))
|
||||
if recheck is not None and (narrow := recheck(PlexShow, field, tsq, lang)) is not None:
|
||||
sq = sq.where(narrow)
|
||||
selects.append(sq)
|
||||
|
||||
total = 0
|
||||
rows: list = []
|
||||
if selects:
|
||||
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
|
||||
u = union.subquery()
|
||||
total = db.scalar(select(func.count()).select_from(u)) or 0
|
||||
|
||||
if tsq is not None:
|
||||
order = [u.c.rank.desc()]
|
||||
else:
|
||||
col = sort_column(u, sort, lang)
|
||||
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
||||
order.append(u.c.rk.desc())
|
||||
rows = list(db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all())
|
||||
|
||||
# Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour)
|
||||
# and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes
|
||||
# target the movie/show grid, so they suppress episodes.
|
||||
eps: list = []
|
||||
if tsq is not None and want_shows and field in ("all", "episode"):
|
||||
ep_st = aliased(PlexState)
|
||||
# In HU an episode is found by its Hungarian title too (the overlay fills it) — and
|
||||
# still by its English one, like `vector_match` does for the grid.
|
||||
if field == "episode":
|
||||
ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
|
||||
ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, ""))
|
||||
else:
|
||||
ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu
|
||||
hit = ep_vec.op("@@")(tsq)
|
||||
if lang == "hu":
|
||||
hit = or_(ep_vec_hu.op("@@")(tsq), hit)
|
||||
ep_vec = ep_vec_hu
|
||||
eps = list(
|
||||
db.execute(
|
||||
select(PlexItem, ep_st, PlexShow)
|
||||
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, owned_by(ep_st.user_id, user)))
|
||||
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
|
||||
.where(
|
||||
PlexItem.kind == "episode",
|
||||
PlexItem.library_id.in_(show_lib_ids),
|
||||
hit,
|
||||
)
|
||||
.order_by(func.ts_rank(ep_vec, tsq).desc())
|
||||
.limit(24)
|
||||
).all()
|
||||
)
|
||||
|
||||
return total, rows, eps
|
||||
|
||||
|
||||
def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: LibraryFilters) -> dict:
|
||||
"""Available filter values for the unified library (scope movie|show|both), ADAPTED to the active
|
||||
filters (faceted search): each group's values/bounds are computed with all the OTHER active
|
||||
filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while
|
||||
a multi-select group still lets you add more. Merged across the scope's libraries; duration is
|
||||
movie-only. (Search text is not factored in — facets track the sidebar filters.) `lang` is
|
||||
normalised here, after the empty answer."""
|
||||
empty = {
|
||||
"genres": [],
|
||||
"content_ratings": [],
|
||||
"studios": [],
|
||||
"playable": [],
|
||||
"year_min": None,
|
||||
"year_max": None,
|
||||
"rating_max": None,
|
||||
"duration_min": None,
|
||||
"duration_max": None,
|
||||
}
|
||||
all_movie_ids, all_show_ids = content_libs(db)
|
||||
movie_ids = all_movie_ids if scope in ("movie", "both") else []
|
||||
show_ids = all_show_ids if scope in ("show", "both") else []
|
||||
if not movie_ids and not show_ids:
|
||||
return empty
|
||||
lang = norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
|
||||
uid = user.id
|
||||
|
||||
# The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a
|
||||
# full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups
|
||||
# (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call.
|
||||
# The same filter on the same rows, so the counts do not change
|
||||
# (test_plex_facets_grid_parity).
|
||||
shows_in_scope: list[int] | None = None
|
||||
if show_ids and show in ("watched", "in_progress", "unwatched"):
|
||||
agg = rollup.agg_subquery(db, uid).subquery()
|
||||
in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id)
|
||||
in_scope = apply_show_state_filter(
|
||||
in_scope,
|
||||
func.coalesce(agg.c.total, 0),
|
||||
func.coalesce(agg.c.watched, 0),
|
||||
func.coalesce(agg.c.inprog, 0),
|
||||
show,
|
||||
)
|
||||
shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids))))
|
||||
|
||||
def wsq(query, model, ids, pp, kind_movie, with_dur):
|
||||
"""Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
|
||||
facet aggregation query, so the facets reflect exactly the visible result set."""
|
||||
query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang))
|
||||
if kind_movie:
|
||||
query = query.where(model.kind == "movie")
|
||||
if with_dur and pp.get("duration_min") is not None:
|
||||
query = query.where(model.duration_s >= pp["duration_min"])
|
||||
if with_dur and pp.get("duration_max") is not None:
|
||||
query = query.where(model.duration_s <= pp["duration_max"])
|
||||
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
|
||||
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
|
||||
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
|
||||
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
|
||||
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
|
||||
st = aliased(PlexState)
|
||||
query = query.outerjoin(st, and_(st.item_id == model.id, owned_by(st.user_id, uid)))
|
||||
query = apply_movie_state_filter(query, st, show)
|
||||
elif shows_in_scope is not None:
|
||||
query = query.where(model.id.in_(shows_in_scope))
|
||||
return query
|
||||
|
||||
genre_counts: dict[str, int] = {}
|
||||
cr_counts: dict[str, int] = {}
|
||||
studio_counts: dict[str, int] = {}
|
||||
playable_counts: dict[str, int] = {}
|
||||
years: list[int] = []
|
||||
ratings: list[float] = []
|
||||
durs: list[int] = []
|
||||
|
||||
def per_table(model, ids, kind_movie: bool) -> None:
|
||||
if not ids:
|
||||
return
|
||||
# Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid
|
||||
# OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we
|
||||
# offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND
|
||||
# the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since
|
||||
# every matching title carries them, so they stay togglable.)
|
||||
genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None}
|
||||
# Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON
|
||||
# `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and
|
||||
# jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") —
|
||||
# so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the
|
||||
# whole facets endpoint. Mirror the exact guard people.py already applies to the same columns.
|
||||
# In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)).
|
||||
gcol = genres_col(model, lang)
|
||||
expanded = (
|
||||
wsq(
|
||||
select(func.jsonb_array_elements_text(gcol).label("g")),
|
||||
model,
|
||||
ids,
|
||||
genre_pp,
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
.where(func.jsonb_typeof(gcol) == "array")
|
||||
.subquery()
|
||||
)
|
||||
for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all():
|
||||
genre_counts[g] = genre_counts.get(g, 0) + c
|
||||
# Content ratings — exclude its own.
|
||||
crq = (
|
||||
wsq(
|
||||
select(model.content_rating, func.count()),
|
||||
model,
|
||||
ids,
|
||||
{**p, "content_ratings": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
.where(model.content_rating.isnot(None))
|
||||
.group_by(model.content_rating)
|
||||
)
|
||||
for cr, c in db.execute(crq).all():
|
||||
cr_counts[cr] = cr_counts.get(cr, 0) + c
|
||||
# Studios — exclude its own (each offered studio is a valid OR-addition that only widens).
|
||||
stq = (
|
||||
wsq(
|
||||
select(model.studio, func.count()),
|
||||
model,
|
||||
ids,
|
||||
{**p, "studios": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
.where(model.studio.isnot(None))
|
||||
.group_by(model.studio)
|
||||
)
|
||||
for s, c in db.execute(stq).all():
|
||||
studio_counts[s] = studio_counts.get(s, 0) + c
|
||||
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
|
||||
if kind_movie:
|
||||
pcls = playback_class_expr()
|
||||
pcq = (
|
||||
wsq(
|
||||
select(pcls.label("cls"), func.count()),
|
||||
model,
|
||||
ids,
|
||||
{**p, "playable": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
.where(pcls.isnot(None))
|
||||
.group_by(pcls)
|
||||
)
|
||||
for cls, c in db.execute(pcq).all():
|
||||
playable_counts[cls] = playable_counts.get(cls, 0) + c
|
||||
# Year bounds — exclude the year filter.
|
||||
by = db.execute(
|
||||
wsq(
|
||||
select(func.min(model.year), func.max(model.year)),
|
||||
model,
|
||||
ids,
|
||||
{**p, "year_min": None, "year_max": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
).one()
|
||||
if by[0] is not None:
|
||||
years.append(by[0])
|
||||
if by[1] is not None:
|
||||
years.append(by[1])
|
||||
# Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the
|
||||
# rating filter and the UI badge use (IMDb preferred, audience fallback).
|
||||
br = db.scalar(
|
||||
wsq(
|
||||
select(func.max(func.coalesce(model.imdb_rating, model.rating))),
|
||||
model,
|
||||
ids,
|
||||
{**p, "rating_min": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
)
|
||||
if br is not None:
|
||||
ratings.append(float(br))
|
||||
# Duration bounds (movies) — exclude the duration filter.
|
||||
if kind_movie:
|
||||
bd = db.execute(
|
||||
wsq(
|
||||
select(func.min(model.duration_s), func.max(model.duration_s)),
|
||||
model,
|
||||
ids,
|
||||
p,
|
||||
kind_movie,
|
||||
False,
|
||||
)
|
||||
).one()
|
||||
if bd[0] is not None:
|
||||
durs.append(bd[0])
|
||||
if bd[1] is not None:
|
||||
durs.append(bd[1])
|
||||
|
||||
per_table(PlexItem, movie_ids, True)
|
||||
per_table(PlexShow, show_ids, False)
|
||||
# Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise
|
||||
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
|
||||
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
|
||||
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
|
||||
for g in split_csv(f.genres):
|
||||
genre_counts.setdefault(g, 0)
|
||||
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
|
||||
for s in split_csv(f.studios):
|
||||
studio_counts.setdefault(s, 0)
|
||||
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
|
||||
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
|
||||
for c in split_csv(f.playable):
|
||||
if c in PLAYBACK_CLASSES:
|
||||
playable_counts.setdefault(c, 0)
|
||||
# Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader.
|
||||
alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))}
|
||||
return {
|
||||
"genres": [
|
||||
{"value": g, "count": c}
|
||||
for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]])
|
||||
],
|
||||
"content_ratings": [
|
||||
{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
|
||||
],
|
||||
"studios": [
|
||||
{"value": s, "count": c}
|
||||
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))
|
||||
],
|
||||
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
|
||||
"playable": [
|
||||
{"value": c, "count": playable_counts[c]}
|
||||
for c in PLAYBACK_CLASSES
|
||||
if c in playable_counts
|
||||
],
|
||||
"year_min": min(years) if years else None,
|
||||
"year_max": max(years) if years else None,
|
||||
"rating_max": max(ratings) if ratings else None,
|
||||
"duration_min": min(durs) if durs else None,
|
||||
"duration_max": max(durs) if durs else None,
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Plex's tables: a self-contained catalog mirrored from a local Plex server — libraries, shows,
|
||||
seasons, items, people, collections, the server's playlists — and each user's private part of it:
|
||||
watch state, the account link, their own playlists. Only this unit writes them; another unit reads
|
||||
them along a declared edge, or asks this unit's service."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Computed,
|
||||
Date,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
from app.models import TimestampMixin, UpdatedAtMixin
|
||||
|
||||
|
||||
# --- Plex integration (optional) -------------------------------------------------------------
|
||||
# A dedicated, self-contained catalog mirrored from a locally-reachable Plex server. Playback is
|
||||
# from the LOCAL physical file (Plex is metadata only); per-user watch state lives in plex_states
|
||||
# so it works for users without a Plex account. Mirrors the app's "shared catalog, private
|
||||
# per-user state" model. The weighted FTS search_vector uses the same unaccent_simple config as
|
||||
# `videos` (migrations 0031/0032).
|
||||
|
||||
|
||||
class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A mirrored Plex library section. Only `enabled` sections are exposed in the feed.
|
||||
`plex_key` is the section id on the Plex server."""
|
||||
|
||||
__tablename__ = "plex_libraries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
plex_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show"
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# HU-overlay support. `metadata_lang` tags the section's metadata language. An OVERLAY
|
||||
# library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable
|
||||
# on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`).
|
||||
metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en")
|
||||
overlay_of: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("plex_libraries.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
|
||||
|
||||
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A TV show (grandparent of episodes) — holds poster + summary for the drill-down page, plus
|
||||
the same filterable metadata as movies (genres/rating/content_rating/studio/people) so the TV
|
||||
grid can be filtered/sorted like the movie grid. All mirrored cheaply from the show listing."""
|
||||
|
||||
__tablename__ = "plex_shows"
|
||||
__table_args__ = (
|
||||
Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"),
|
||||
Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"),
|
||||
Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"),
|
||||
Index("ix_plex_shows_genres_hu_gin", "genres_hu", postgresql_using="gin"),
|
||||
# ix_plex_shows_collections_gin already created with collections (migration 0047).
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
library_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
# Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay
|
||||
# twin during sync. Indexed for the guid->row overlay match.
|
||||
guid: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
year: Mapped[int | None] = mapped_column(Integer)
|
||||
thumb_key: Mapped[str | None] = mapped_column(
|
||||
String(512)
|
||||
) # Plex thumb path (proxied, not stored)
|
||||
art_key: Mapped[str | None] = mapped_column(String(512))
|
||||
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
|
||||
collection_keys: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # Plex collections this show is in
|
||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
|
||||
rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb)
|
||||
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
||||
studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio
|
||||
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date
|
||||
# none_as_null: an empty tag list must persist as SQL NULL, not JSON `null`. Without it a Python
|
||||
# None round-trips as a JSON scalar that jsonb_array_elements_text() can't unnest ("cannot extract
|
||||
# elements from a scalar") — the trap a metadata-poor library sprang on /facets. Same on plex_items.
|
||||
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
people_text: Mapped[str | None] = mapped_column(
|
||||
Text
|
||||
) # cast+directors, folded into search_vector
|
||||
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
|
||||
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||
imdb_rating: Mapped[float | None] = mapped_column(
|
||||
Float
|
||||
) # IMDb-specific score (vs `rating` = audienceRating)
|
||||
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||
tagline: Mapped[str | None] = mapped_column(Text)
|
||||
full_cast: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # ordered [{name, role, thumb}]
|
||||
# --- TMDB enrichment: see the identical block on plex_items for the full rationale. Additive;
|
||||
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
|
||||
tmdb_id: Mapped[int | None] = mapped_column(Integer)
|
||||
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
|
||||
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
|
||||
budget: Mapped[int | None] = mapped_column(BigInteger)
|
||||
revenue: Mapped[int | None] = mapped_column(BigInteger)
|
||||
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
crew: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # [{name, job, tmdb_id, thumb}]
|
||||
studios: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # TMDB production companies [str]
|
||||
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched,
|
||||
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
|
||||
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
|
||||
summary_hu: Mapped[str | None] = mapped_column(Text)
|
||||
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
search_vector: Mapped[object | None] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed(
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
||||
persisted=True,
|
||||
),
|
||||
)
|
||||
search_vector_hu: Mapped[object | None] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed(
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
|
||||
persisted=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PlexSeason(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A season under a show — used to order episodes for prev/next stepping."""
|
||||
|
||||
__tablename__ = "plex_seasons"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
show_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(String(255))
|
||||
season_number: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
||||
|
||||
|
||||
class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A playable LEAF — a movie or a single episode. This is what the feed lists and the player
|
||||
plays. Movies carry `library_id` and no parents; episodes carry show/season refs +
|
||||
season/episode numbers. `file_path` is the Plex-side absolute path of the physical media,
|
||||
mapped through `plex_path_map` to a local mount at playback time. `playable` is the
|
||||
ffprobe-derived browser-compatibility class (direct | remux | transcode)."""
|
||||
|
||||
__tablename__ = "plex_items"
|
||||
__table_args__ = (
|
||||
Index("ix_plex_items_show_order", "show_id", "season_number", "episode_number"),
|
||||
Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
|
||||
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
|
||||
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
|
||||
Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"),
|
||||
Index("ix_plex_items_genres_hu_gin", "genres_hu", postgresql_using="gin"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
library_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode"
|
||||
# Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during
|
||||
# sync; indexed for the guid->row match. Episodes carry it too but it's unused for them.
|
||||
guid: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
show_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
season_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("plex_seasons.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
season_number: Mapped[int | None] = mapped_column(Integer)
|
||||
episode_number: Mapped[int | None] = mapped_column(Integer)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
year: Mapped[int | None] = mapped_column(Integer)
|
||||
duration_s: Mapped[int | None] = mapped_column(Integer)
|
||||
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
||||
art_key: Mapped[str | None] = mapped_column(String(512))
|
||||
cast: Mapped[list | None] = mapped_column(JSON) # [role names]
|
||||
# Physical media (from the Plex Part).
|
||||
file_path: Mapped[str | None] = mapped_column(Text) # Plex-side absolute path
|
||||
part_key: Mapped[str | None] = mapped_column(String(255)) # /library/parts/... (fallback fetch)
|
||||
container: Mapped[str | None] = mapped_column(String(16))
|
||||
codec_video: Mapped[str | None] = mapped_column(String(32))
|
||||
codec_audio: Mapped[str | None] = mapped_column(String(32))
|
||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
||||
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
|
||||
playable: Mapped[str | None] = mapped_column(String(12), index=True)
|
||||
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a
|
||||
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
|
||||
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
|
||||
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
|
||||
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
|
||||
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
|
||||
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
|
||||
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
|
||||
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
|
||||
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
|
||||
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
|
||||
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
|
||||
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
|
||||
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
|
||||
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
|
||||
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
|
||||
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
|
||||
# filters (GIN-indexed) and double as content signals for a future watch-habit recommender.
|
||||
rating: Mapped[float | None] = mapped_column(Float, index=True)
|
||||
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
||||
studio: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True)
|
||||
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
# rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings).
|
||||
collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
|
||||
# finds titles by a person's name (and ranks them above summary-only mentions).
|
||||
people_text: Mapped[str | None] = mapped_column(Text)
|
||||
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
|
||||
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||
imdb_rating: Mapped[float | None] = mapped_column(
|
||||
Float
|
||||
) # IMDb-specific score (vs `rating` = audienceRating)
|
||||
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||
tagline: Mapped[str | None] = mapped_column(Text)
|
||||
full_cast: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # ordered [{name, role, thumb}]
|
||||
# --- TMDB enrichment: matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB
|
||||
# backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating
|
||||
# above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched;
|
||||
# `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an
|
||||
# unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). ---
|
||||
tmdb_id: Mapped[int | None] = mapped_column(Integer)
|
||||
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
|
||||
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
|
||||
budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none)
|
||||
revenue: Mapped[int | None] = mapped_column(BigInteger)
|
||||
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
crew: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # [{name, job, tmdb_id, thumb}]
|
||||
studios: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # TMDB production companies [str]
|
||||
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
# [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time.
|
||||
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
backdrops: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # TMDB backdrop file_paths [str] (crossfade pool)
|
||||
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched
|
||||
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
|
||||
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
|
||||
summary_hu: Mapped[str | None] = mapped_column(Text)
|
||||
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
search_vector: Mapped[object | None] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed(
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
||||
persisted=True,
|
||||
),
|
||||
)
|
||||
search_vector_hu: Mapped[object | None] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed(
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
|
||||
persisted=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the
|
||||
actors currently in the filter) can show a photo + count without a live per-card metadata fetch.
|
||||
Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the
|
||||
periodic rich re-sync — since the cheap section listing that drives the main sync only carries the
|
||||
top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library
|
||||
removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned
|
||||
metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the
|
||||
row); this table is the people's own facts: the headshot link, the TMDB link and the TMDB bio
|
||||
(the headshot FILE lives in the image cache, filled by the people_enrich job)."""
|
||||
|
||||
__tablename__ = "plex_people"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
|
||||
# TMDB person id, linked by name from a title's TMDB credits (the /person
|
||||
# biography fetch builds on it). NULL until a TMDB-enriched title names this person.
|
||||
tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
|
||||
# --- TMDB person enrichment: fetched from /person/{tmdb_person_id} by the people_enrich job,
|
||||
# or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a
|
||||
# no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id,
|
||||
# media_type, title}], resolved to library rows by guid at serve time (like recommendations). ---
|
||||
biography: Mapped[str | None] = mapped_column(Text)
|
||||
# TMDB's Hungarian biography, from the same request's `translations`; a Hungarian reader is
|
||||
# served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and
|
||||
# keywords have no Hungarian on TMDB, so they stay in the instance language.
|
||||
biography_hu: Mapped[str | None] = mapped_column(Text)
|
||||
birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is
|
||||
deathday: Mapped[str | None] = mapped_column(String(10))
|
||||
place_of_birth: Mapped[str | None] = mapped_column(Text)
|
||||
known_for_department: Mapped[str | None] = mapped_column(String(64))
|
||||
also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
known_for: Mapped[list | None] = mapped_column(
|
||||
JSONB(none_as_null=True)
|
||||
) # [{tmdb_id, media_type, title}]
|
||||
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
|
||||
sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
|
||||
sync so reads never hit Plex's slow collection queries. Membership is stored as `collection_keys`
|
||||
on the member movies (plex_items) / shows (plex_shows). `smart` = Plex's query-based flag;
|
||||
`editable` = we may write changes back to Plex (default False = read-only; set for curated ones)."""
|
||||
|
||||
__tablename__ = "plex_collections"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
library_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
||||
child_count: Mapped[int | None] = mapped_column(Integer)
|
||||
smart: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
editable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PlexState(Base, UpdatedAtMixin):
|
||||
"""Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so
|
||||
it works for users WITHOUT a Plex account (the whole point of the access-layer design).
|
||||
`synced_to_plex` marks whether this state was pushed back to a linked Plex account."""
|
||||
|
||||
__tablename__ = "plex_states"
|
||||
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
||||
position_seconds: Mapped[int] = mapped_column(
|
||||
Integer, default=0, server_default="0", nullable=False
|
||||
)
|
||||
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
synced_to_plex: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
|
||||
class PlexLink(Base, UpdatedAtMixin):
|
||||
"""Links a Siftlode user to a Plex account for two-way watch-state sync (one row per user).
|
||||
|
||||
For the owner (the MVP scope) the row uses the server's admin token — `uses_admin=True`,
|
||||
`plex_token_enc=None` — so no separate Plex login is needed; the admin token already reads/writes
|
||||
the owner account's watch state. `plex_account_id` is that Plex account's numeric id, used to
|
||||
filter the shared watch-history feed to this user (Phase C). Reserved for later: a friend can link
|
||||
their OWN Plex account via PIN OAuth, storing a personal `plex_token_enc` (uses_admin=False) — the
|
||||
sync loop then iterates link rows uniformly.
|
||||
|
||||
`initial_import_done` guards the one-time "Plex is master" import; `last_watch_sync_at` is the
|
||||
high-water mark for the incremental Plex→Siftlode pass."""
|
||||
|
||||
__tablename__ = "plex_link"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
uses_admin: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
plex_token_enc: Mapped[str | None] = mapped_column(Text) # P5b friend tokens (encrypted)
|
||||
plex_account_id: Mapped[int | None] = mapped_column(Integer)
|
||||
plex_username: Mapped[str | None] = mapped_column(String(255))
|
||||
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
initial_import_done: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
last_watch_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
linked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for
|
||||
users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a
|
||||
playlist is owned by one user and its items are ordered. Optional Plex-direction sync is a later
|
||||
phase (`plex_rating_key` set once pushed back to the owner's Plex account)."""
|
||||
|
||||
__tablename__ = "plex_playlists"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
plex_rating_key: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
|
||||
class PlexPlaylistItem(Base):
|
||||
"""One ordered entry in a playlist. `position` (0-based) orders playback."""
|
||||
|
||||
__tablename__ = "plex_playlist_items"
|
||||
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
playlist_id: Mapped[int] = mapped_column(ForeignKey("plex_playlists.id", ondelete="CASCADE"))
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexShow
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.client import PlexClient
|
||||
from app.plex.genres import hu_genres
|
||||
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app.models import PlexPerson
|
||||
from app.plex.models import PlexPerson
|
||||
from app.plex import imgcache
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Plex's reads for the HTTP layer: the mirrored rows a page shows, and a user's watch state on them.
|
||||
|
||||
A user's own rows (`PlexState`) are read through `userscope`, so "someone else's row" is the same
|
||||
answer as a missing one. A rating key with a NUL in it names no row (`lookups.by_rating_key`).
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.collation import collated
|
||||
from app.models import User
|
||||
from app.plex import rollup
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.models import (
|
||||
PlexCollection,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexLink,
|
||||
PlexPerson,
|
||||
PlexPlaylist,
|
||||
PlexPlaylistItem,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
)
|
||||
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
||||
from app.userscope import owned_by, owned_row
|
||||
from app.utils import like_escape
|
||||
|
||||
# --- one row ---------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def item(db: Session, rating_key: str) -> PlexItem | None:
|
||||
return by_rating_key(db, PlexItem, rating_key)
|
||||
|
||||
|
||||
def movie(db: Session, rating_key: str) -> PlexItem | None:
|
||||
"""The movie with this key — an episode's key is not one."""
|
||||
it = item(db, rating_key)
|
||||
return it if it is not None and it.kind == "movie" else None
|
||||
|
||||
|
||||
def show(db: Session, rating_key: str) -> PlexShow | None:
|
||||
return by_rating_key(db, PlexShow, rating_key)
|
||||
|
||||
|
||||
def collection(db: Session, rating_key: str) -> PlexCollection | None:
|
||||
return by_rating_key(db, PlexCollection, rating_key)
|
||||
|
||||
|
||||
def season(db: Session, rating_key: str) -> PlexSeason | None:
|
||||
return by_rating_key(db, PlexSeason, rating_key)
|
||||
|
||||
|
||||
def season_episodes(db: Session, se: PlexSeason) -> list[PlexItem]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode")
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def show_by_id(db: Session, show_id: int) -> PlexShow | None:
|
||||
return db.get(PlexShow, show_id)
|
||||
|
||||
|
||||
def season_by_id(db: Session, season_id: int) -> PlexSeason | None:
|
||||
return db.get(PlexSeason, season_id)
|
||||
|
||||
|
||||
def library(db: Session, library_id: int) -> PlexLibrary | None:
|
||||
return db.get(PlexLibrary, library_id)
|
||||
|
||||
|
||||
def library_by_key(db: Session, plex_key: str) -> PlexLibrary | None:
|
||||
return (
|
||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(plex_key)))
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def library_key(db: Session, library_id: int | None) -> str | None:
|
||||
"""The Plex section key for a library id — the info/show page passes it to the admin collection
|
||||
editor (in the unified cross-library view there's no single library prop otherwise)."""
|
||||
if library_id is None:
|
||||
return None
|
||||
lib = db.get(PlexLibrary, library_id)
|
||||
return lib.plex_key if lib else None
|
||||
|
||||
|
||||
def person(db: Session, name: str) -> PlexPerson | None:
|
||||
return db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
|
||||
|
||||
|
||||
# --- a user's watch state --------------------------------------------------------------------
|
||||
|
||||
|
||||
def state(db: Session, user: User | int, item_id: int) -> PlexState | None:
|
||||
return (
|
||||
db.execute(
|
||||
select(PlexState).where(owned_by(PlexState.user_id, user), PlexState.item_id == item_id)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
# --- a show ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def seasons(db: Session, sh: PlexShow) -> list[PlexSeason]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(PlexSeason)
|
||||
.where(PlexSeason.show_id == sh.id)
|
||||
.order_by(PlexSeason.season_number.asc().nullsfirst())
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def episodes(db: Session, sh: PlexShow) -> list[PlexItem]:
|
||||
"""A show's episodes in season/episode order."""
|
||||
return list(
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
|
||||
.order_by(
|
||||
PlexItem.season_number.asc().nullsfirst(),
|
||||
PlexItem.episode_number.asc().nullsfirst(),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def episode_keys(db: Session, show_id: int) -> list[str]:
|
||||
"""The rating keys of a show's episodes, in the order `episodes` gives."""
|
||||
return list(
|
||||
db.execute(
|
||||
select(PlexItem.rating_key)
|
||||
.where(PlexItem.show_id == show_id, PlexItem.kind == "episode")
|
||||
.order_by(
|
||||
PlexItem.season_number.asc().nullsfirst(),
|
||||
PlexItem.episode_number.asc().nullsfirst(),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def shows_by_keys(db: Session, rating_keys: list[str], exclude: int) -> dict[str, PlexShow]:
|
||||
"""The mirrored shows among these keys, but `exclude`, by key."""
|
||||
return {
|
||||
s.rating_key: s
|
||||
for s in db.execute(
|
||||
select(PlexShow).where(PlexShow.rating_key.in_(rating_keys), PlexShow.id != exclude)
|
||||
).scalars()
|
||||
}
|
||||
|
||||
|
||||
# --- libraries -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def browsable_libraries(db: Session, lang: str) -> list[tuple[PlexLibrary, int]]:
|
||||
"""The mirrored libraries a user can browse, each with its title count (movies, or shows). An
|
||||
HU overlay library is left out: it is never browsed, it only feeds its EN twin's Hungarian
|
||||
metadata."""
|
||||
libs = db.execute(
|
||||
select(PlexLibrary)
|
||||
.where(PlexLibrary.overlay_of.is_(None))
|
||||
.order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang))
|
||||
).scalars()
|
||||
out = []
|
||||
for lib in libs:
|
||||
if lib.kind == "movie":
|
||||
count = db.scalar(
|
||||
select(func.count(PlexItem.id)).where(
|
||||
PlexItem.library_id == lib.id, PlexItem.kind == "movie"
|
||||
)
|
||||
)
|
||||
else:
|
||||
count = db.scalar(select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id))
|
||||
out.append((lib, count or 0))
|
||||
return out
|
||||
|
||||
|
||||
# --- people ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def person_photos(db: Session, names: list[str]) -> dict[str, str | None]:
|
||||
return {
|
||||
p.name: p.photo_thumb
|
||||
for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(names))).scalars()
|
||||
}
|
||||
|
||||
|
||||
def cast_count(db: Session, name: str, movie_lib_ids: list[int], show_lib_ids: list[int]) -> int:
|
||||
"""How many movies (in `movie_lib_ids`) and shows (in `show_lib_ids`) name this person in their
|
||||
cast. An empty id list leaves its side out."""
|
||||
count = 0
|
||||
if movie_lib_ids:
|
||||
count += (
|
||||
db.scalar(
|
||||
select(func.count(PlexItem.id)).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_lib_ids),
|
||||
PlexItem.cast_names.contains([name]),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if show_lib_ids:
|
||||
count += (
|
||||
db.scalar(
|
||||
select(func.count(PlexShow.id)).where(
|
||||
PlexShow.library_id.in_(show_lib_ids),
|
||||
PlexShow.cast_names.contains([name]),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def filmography(
|
||||
db: Session,
|
||||
kind: Literal["movie", "show"],
|
||||
name: str,
|
||||
lib_ids: list[int],
|
||||
lang: str,
|
||||
limit: int,
|
||||
) -> list:
|
||||
"""The movies or shows in these libraries whose cast or directors name this person, newest
|
||||
first."""
|
||||
model: type[PlexItem] | type[PlexShow] = PlexItem if kind == "movie" else PlexShow
|
||||
query = select(model).where(
|
||||
model.library_id.in_(lib_ids),
|
||||
or_(model.cast_names.contains([name]), model.directors.contains([name])),
|
||||
)
|
||||
if model is PlexItem:
|
||||
query = query.where(PlexItem.kind == "movie")
|
||||
return list(
|
||||
db.execute(
|
||||
query.order_by(
|
||||
model.year.desc().nullslast(), collated(title_col(model, lang), lang)
|
||||
).limit(limit)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
# --- collections -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def collections_page(
|
||||
db: Session, library: str | None, q: str | None, keys: list[str] | None, lang: str
|
||||
) -> list[PlexCollection]:
|
||||
"""Collections, most-populated first: one library's (by its plex key), else every enabled
|
||||
library's; name-filtered by `q`; narrowed to `keys` when given (an empty list asks for none)."""
|
||||
if library is not None:
|
||||
lib = library_by_key(db, library)
|
||||
if lib is None:
|
||||
return []
|
||||
query = select(PlexCollection).where(PlexCollection.library_id == lib.id)
|
||||
else:
|
||||
enabled_ids = list(
|
||||
db.execute(
|
||||
select(PlexLibrary.id).where(
|
||||
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
if not enabled_ids:
|
||||
return []
|
||||
query = select(PlexCollection).where(PlexCollection.library_id.in_(enabled_ids))
|
||||
term = (q or "").strip()
|
||||
if term:
|
||||
query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%"))
|
||||
if keys is not None:
|
||||
# An explicit but empty `keys=` asks for nothing, which is not the same as no `keys` (every
|
||||
# collection): answered here without a query. The empty IN () would also match nothing.
|
||||
if not keys:
|
||||
return []
|
||||
query = query.where(PlexCollection.rating_key.in_(keys))
|
||||
return list(
|
||||
db.execute(
|
||||
query.order_by(
|
||||
PlexCollection.child_count.desc().nullslast(), collated(PlexCollection.title, lang)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def library_collections(db: Session, library_id: int, lang: str) -> list[PlexCollection]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(PlexCollection)
|
||||
.where(PlexCollection.library_id == library_id)
|
||||
.order_by(collated(PlexCollection.title, lang))
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def collections_by_keys(db: Session, keys: list[str]) -> list[PlexCollection]:
|
||||
return list(
|
||||
db.execute(select(PlexCollection).where(PlexCollection.rating_key.in_(keys))).scalars()
|
||||
)
|
||||
|
||||
|
||||
def collection_named(db: Session, library_id: int, title: str) -> PlexCollection | None:
|
||||
"""A collection of this library with this name, case aside."""
|
||||
return (
|
||||
db.execute(
|
||||
select(PlexCollection).where(
|
||||
PlexCollection.library_id == library_id,
|
||||
func.lower(PlexCollection.title) == title.lower(),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def collection_members(
|
||||
db: Session, rating_key: str, lang: str
|
||||
) -> tuple[list[PlexItem], list[PlexShow]]:
|
||||
"""A collection's movies and shows, each by year then title."""
|
||||
movies = db.execute(
|
||||
select(PlexItem)
|
||||
.where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key]))
|
||||
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
|
||||
).scalars()
|
||||
shows = db.execute(
|
||||
select(PlexShow)
|
||||
.where(PlexShow.collection_keys.contains([rating_key]))
|
||||
.order_by(PlexShow.year.asc().nullslast(), collated(title_col(PlexShow, lang), lang))
|
||||
).scalars()
|
||||
return list(movies), list(shows)
|
||||
|
||||
|
||||
def library_movies_in(
|
||||
db: Session, library_id: int, rating_key: str, lang: str, limit: int
|
||||
) -> list[PlexItem]:
|
||||
"""The movies of one library in this collection, by year then title."""
|
||||
return list(
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.where(
|
||||
PlexItem.library_id == library_id,
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.collection_keys.contains([rating_key]),
|
||||
)
|
||||
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
|
||||
.limit(limit)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def show_rollups(db: Session, user_id: int, show_ids: list[int]) -> list:
|
||||
"""The user's episode rollup (total, watched, in progress, last resumed) of each of these shows
|
||||
that has episodes."""
|
||||
if not show_ids:
|
||||
return []
|
||||
return list(
|
||||
db.execute(
|
||||
rollup.agg_subquery(db, user_id, include_resumed=True).where(
|
||||
PlexItem.show_id.in_(show_ids)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def collection_tiles(
|
||||
db: Session, keys: list[str]
|
||||
) -> tuple[dict[str, list[str]], dict[str, list[str]], dict[str, int]]:
|
||||
"""What each collection's tile shows: the top two movie members' keys, the top two show members'
|
||||
keys (a collection lives in one library, so exactly one side has any), and its movies' total
|
||||
runtime (a show has no single duration)."""
|
||||
movie_ids, show_ids = content_libs(db)
|
||||
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
|
||||
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
|
||||
runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys)
|
||||
return art_m, art_s, runtime
|
||||
|
||||
|
||||
# --- the admin page --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sample_file_path(db: Session) -> str | None:
|
||||
"""Any mirrored item's Plex-side file path — a sample to test the mount and path map against."""
|
||||
return db.scalar(select(PlexItem.file_path).where(PlexItem.file_path.isnot(None)))
|
||||
|
||||
|
||||
def watch_link(db: Session, user: User | int) -> PlexLink | None:
|
||||
"""The user's Plex watch-sync link, if they have one."""
|
||||
return db.execute(select(PlexLink).where(owned_by(PlexLink.user_id, user))).scalars().first()
|
||||
|
||||
|
||||
# --- a user's playlists ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def own_playlist(
|
||||
db: Session, user: User, playlist_id: int, *, lock: bool = False
|
||||
) -> PlexPlaylist | None:
|
||||
"""The user's playlist with this id; someone else's is the same answer as a missing one. `lock`
|
||||
takes it FOR UPDATE: every add, remove and reorder does, so two of them never read the same max
|
||||
position, and the list row is always locked before its items."""
|
||||
return owned_row(db, PlexPlaylist, playlist_id, user, lock=lock)
|
||||
|
||||
|
||||
def playlists_of(db: Session, user: User) -> list[PlexPlaylist]:
|
||||
"""The user's playlists, newest-touched first."""
|
||||
return list(
|
||||
db.execute(
|
||||
select(PlexPlaylist)
|
||||
.where(owned_by(PlexPlaylist.user_id, user))
|
||||
.order_by(PlexPlaylist.updated_at.desc())
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def playlist_count(db: Session, playlist_id: int) -> int:
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(
|
||||
PlexPlaylistItem.playlist_id == playlist_id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def _playlist_rows(playlist_id: int):
|
||||
return (
|
||||
select(PlexPlaylistItem, PlexItem)
|
||||
.join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id)
|
||||
.where(PlexPlaylistItem.playlist_id == playlist_id)
|
||||
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||
)
|
||||
|
||||
|
||||
def playlist_cover(db: Session, playlist_id: int) -> str | None:
|
||||
"""The rating key of a playlist's first item — its cover."""
|
||||
first = db.execute(_playlist_rows(playlist_id)).first()
|
||||
return first[1].rating_key if first else None
|
||||
|
||||
|
||||
def playlist_items(db: Session, playlist_id: int) -> list[PlexItem]:
|
||||
"""A playlist's items in play order."""
|
||||
return [it for _row, it in db.execute(_playlist_rows(playlist_id)).all()]
|
||||
|
||||
|
||||
def playlist_holds(db: Session, playlist_id: int, item_ids: set[int]) -> int:
|
||||
"""How many of these items the playlist holds."""
|
||||
if not item_ids:
|
||||
return 0
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(
|
||||
PlexPlaylistItem.playlist_id == playlist_id,
|
||||
PlexPlaylistItem.item_id.in_(item_ids),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def item_id(db: Session, rating_key: str) -> int | None:
|
||||
"""The id of the mirrored item with this key. Unlike `item`, a NUL in the key reaches the query,
|
||||
which the app answers as a 422 (`main._nul_in_text`), as for every other compared string."""
|
||||
return db.scalar(select(PlexItem.id).where(PlexItem.rating_key == str(rating_key)))
|
||||
|
||||
|
||||
def item_ids(db: Session, rating_keys: list[str]) -> set[int]:
|
||||
"""The ids of the mirrored items with these keys."""
|
||||
if not rating_keys:
|
||||
return set()
|
||||
return set(
|
||||
db.execute(select(PlexItem.id).where(PlexItem.rating_key.in_(rating_keys))).scalars()
|
||||
)
|
||||
|
||||
|
||||
def items_by_keys(db: Session, rating_keys: list) -> list[PlexItem]:
|
||||
"""The mirrored items with these keys, in the given order; an unknown key and a repeat drop out."""
|
||||
order = [str(k) for k in rating_keys if str(k)]
|
||||
if not order:
|
||||
return []
|
||||
by_rk = {
|
||||
it.rating_key: it
|
||||
for it in db.execute(select(PlexItem).where(PlexItem.rating_key.in_(order))).scalars()
|
||||
}
|
||||
seen: set[str] = set()
|
||||
out: list[PlexItem] = []
|
||||
for rk in order:
|
||||
it = by_rk.get(rk)
|
||||
if it is not None and rk not in seen:
|
||||
seen.add(rk)
|
||||
out.append(it)
|
||||
return out
|
||||
|
||||
|
||||
def shows_by_ids(db: Session, show_ids: set[int]) -> dict[int, PlexShow]:
|
||||
if not show_ids:
|
||||
return {}
|
||||
return {
|
||||
sh.id: sh for sh in db.execute(select(PlexShow).where(PlexShow.id.in_(show_ids))).scalars()
|
||||
}
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy import and_, case, func, or_, select, type_coerce
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PlexItem, PlexLibrary
|
||||
from app.plex.models import PlexItem, PlexLibrary
|
||||
from app.plex.genres import hu_genre
|
||||
|
||||
TS_CONFIG = "public.unaccent_simple"
|
||||
@@ -236,7 +236,7 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
|
||||
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
|
||||
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
|
||||
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
|
||||
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill).
|
||||
# Public — it is the shared source of truth imported by plex.library (facet order + zero-fill).
|
||||
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import PlexItem, PlexState
|
||||
from app.models import User
|
||||
from app.userscope import owned_by
|
||||
from app.plex.models import PlexItem, PlexState
|
||||
from app.plex.cards import ShowState
|
||||
|
||||
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
|
||||
@@ -92,7 +94,7 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
|
||||
)
|
||||
return (
|
||||
select(*cols)
|
||||
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
||||
.outerjoin(ps, and_(ps.item_id == PlexItem.id, owned_by(ps.user_id, user_id)))
|
||||
.where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
|
||||
.group_by(PlexItem.show_id)
|
||||
)
|
||||
@@ -105,7 +107,7 @@ def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, ShowS
|
||||
return {r.show_id: show_state(r.total, r.watched, r.inprog) for r in rows}
|
||||
|
||||
|
||||
def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
|
||||
def item_state_map(db: Session, user_id: User | int, item_ids: list[int]) -> dict[int, PlexState]:
|
||||
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
|
||||
leaf analogue of `state_map` (which rolls a show's episodes up to a `ShowState`). One batched
|
||||
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
|
||||
@@ -114,6 +116,8 @@ def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int,
|
||||
return {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id.in_(item_ids))
|
||||
select(PlexState).where(
|
||||
owned_by(PlexState.user_id, user_id), PlexState.item_id.in_(item_ids)
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
"""Plex's writes for the HTTP layer, one function per use case. None of them commits: the handler
|
||||
does, so a page that fails to save an enrichment still renders, and a Plex write and its local
|
||||
mirror land together.
|
||||
|
||||
The rest of the mirror's writes stay with the sync (`plex.sync`): a collection's targeted re-sync
|
||||
after a write-back, its local cleanup after a delete.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import CursorResult, delete, func, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app.db import SessionLocal
|
||||
from app.plex import detail as plex_detail
|
||||
from app.plex import people as plex_people
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.models import User
|
||||
from app.plex import queries, rollup
|
||||
from app.plex.models import (
|
||||
PlexCollection,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexLink,
|
||||
PlexPerson,
|
||||
PlexPlaylist,
|
||||
PlexPlaylistItem,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
)
|
||||
from app.userscope import owned_by
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
|
||||
def enrich_show(db: Session, sh: PlexShow, meta: dict) -> None:
|
||||
"""Fill a not-yet-enriched show from its live metadata: the full cast (filterable), IMDb and
|
||||
tagline, stamped so later views serve from the row."""
|
||||
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta))
|
||||
plex_detail.apply_rich_detail(sh, meta)
|
||||
sh.people_enriched_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def enrich_movie(db: Session, it: PlexItem, meta: dict) -> None:
|
||||
"""The same for a movie. The listing sync stored only its top ~3 cast; the live metadata
|
||||
carries all of them."""
|
||||
cast = plex_people.cast_from_meta(meta)
|
||||
if cast:
|
||||
plex_people.enrich_row_people(db, it, cast)
|
||||
plex_detail.apply_rich_detail(it, meta)
|
||||
it.people_enriched_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def set_collection_editable(col: PlexCollection, editable: bool) -> None:
|
||||
col.editable = editable
|
||||
|
||||
|
||||
def keep_created_collection(
|
||||
db: Session, lib: PlexLibrary, rating_key: str, title: str, seed: str
|
||||
) -> None:
|
||||
"""A collection Plex made, whose follow-up read failed: its row is kept, editable. The full sync
|
||||
preserves `editable` but creates a missing row read-only, so the collection would drop out of
|
||||
every "add to" list; and the duplicate-name check reads these rows, so a retry of the same name
|
||||
would make a second one on Plex. Plex made it with exactly the seed in it, so the seed's row says
|
||||
so too — otherwise the title showed unticked in the collection it was just added to, and the
|
||||
collection empty, until that sync."""
|
||||
db.add(
|
||||
PlexCollection(
|
||||
rating_key=rating_key, library_id=lib.id, title=title, editable=True, child_count=1
|
||||
)
|
||||
)
|
||||
Model = PlexItem if lib.kind == "movie" else PlexShow
|
||||
if row := by_rating_key(db, Model, seed):
|
||||
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rating_key]))
|
||||
db.flush()
|
||||
|
||||
|
||||
def enrich_person_bg(person_id: int) -> None:
|
||||
"""Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs
|
||||
AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's
|
||||
"no live TMDB call at serve time" invariant). The request's session is already closed by then, so
|
||||
open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the
|
||||
next open, and the bio lands from the row by then."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
person = db.get(PlexPerson, person_id)
|
||||
if person is not None:
|
||||
plex_tmdb.enrich_person(db, person)
|
||||
except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user
|
||||
db.rollback()
|
||||
log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def set_watch_link(db: Session, user: User, enabled: bool) -> PlexLink:
|
||||
"""Turn the user's two-way watch sync on or off, creating their link on first use. The link uses
|
||||
the server admin token (`uses_admin`), so it needs no Plex login of its own."""
|
||||
link = queries.watch_link(db, user)
|
||||
if link is None:
|
||||
link = PlexLink(user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc))
|
||||
db.add(link)
|
||||
link.sync_enabled = enabled
|
||||
db.flush()
|
||||
return link
|
||||
|
||||
|
||||
# --- a user's playlists ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_playlist(db: Session, user: User, title: str, seed: PlexItem | None) -> PlexPlaylist:
|
||||
"""A new playlist of the user's, holding `seed` when one is given."""
|
||||
pl = PlexPlaylist(user_id=user.id, title=title)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
if seed is not None:
|
||||
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=seed.id, position=0))
|
||||
db.flush()
|
||||
return pl
|
||||
|
||||
|
||||
def rename_playlist(pl: PlexPlaylist, title: str) -> None:
|
||||
pl.title = title
|
||||
|
||||
|
||||
def delete_playlist(db: Session, pl: PlexPlaylist) -> None:
|
||||
db.delete(pl)
|
||||
db.flush()
|
||||
|
||||
|
||||
def _touch(pl: PlexPlaylist) -> None:
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _last_position(db: Session, playlist_id: int) -> int:
|
||||
return db.execute(
|
||||
select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where(
|
||||
PlexPlaylistItem.playlist_id == playlist_id
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def add_item(db: Session, pl: PlexPlaylist, it: PlexItem) -> None:
|
||||
"""Append `it` to the (locked) playlist; an item already in it stays where it is."""
|
||||
exists = db.execute(
|
||||
select(PlexPlaylistItem.id).where(
|
||||
PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id == it.id
|
||||
)
|
||||
).first()
|
||||
if exists is None:
|
||||
pos = _last_position(db, pl.id) + 1
|
||||
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=pos))
|
||||
_touch(pl)
|
||||
db.flush()
|
||||
|
||||
|
||||
def remove_item(db: Session, pl: PlexPlaylist, it: PlexItem) -> None:
|
||||
db.execute(
|
||||
delete(PlexPlaylistItem).where(
|
||||
PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id == it.id
|
||||
)
|
||||
)
|
||||
_touch(pl)
|
||||
db.flush()
|
||||
|
||||
|
||||
def add_items(db: Session, pl: PlexPlaylist, items: list[PlexItem]) -> int:
|
||||
"""Append many items in their given order (a whole season or show); those already in the list
|
||||
are skipped. Says how many were added."""
|
||||
existing = set(
|
||||
db.execute(
|
||||
select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pl.id)
|
||||
).scalars()
|
||||
)
|
||||
pos = _last_position(db, pl.id)
|
||||
added = 0
|
||||
for it in items:
|
||||
if it.id in existing:
|
||||
continue
|
||||
pos += 1
|
||||
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=pos))
|
||||
existing.add(it.id)
|
||||
added += 1
|
||||
if added:
|
||||
_touch(pl)
|
||||
db.flush()
|
||||
return added
|
||||
|
||||
|
||||
def insert_items_at(db: Session, pl: PlexPlaylist, placed: list[tuple[int, PlexItem]]) -> int:
|
||||
"""Insert new rows at their indices (ascending, so each index is where the row stood when the
|
||||
rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item
|
||||
already in the list does not move: the add stays idempotent."""
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(PlexPlaylistItem)
|
||||
.where(PlexPlaylistItem.playlist_id == pl.id)
|
||||
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||
).scalars()
|
||||
)
|
||||
present = {r.item_id for r in rows}
|
||||
added = 0
|
||||
for at, it in sorted(placed, key=lambda p: p[0]):
|
||||
if it.id in present:
|
||||
continue
|
||||
row = PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0)
|
||||
db.add(row)
|
||||
rows.insert(min(at, len(rows)), row)
|
||||
present.add(it.id)
|
||||
added += 1
|
||||
for i, r in enumerate(rows):
|
||||
r.position = i
|
||||
if added:
|
||||
_touch(pl)
|
||||
db.flush()
|
||||
return added
|
||||
|
||||
|
||||
def remove_items(db: Session, pl: PlexPlaylist, item_ids: list[int]) -> int:
|
||||
"""Remove these items from the playlist; says how many were there."""
|
||||
if not item_ids:
|
||||
return 0
|
||||
result = db.execute(
|
||||
delete(PlexPlaylistItem).where(
|
||||
PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id.in_(item_ids)
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
removed = result.rowcount if isinstance(result, CursorResult) else 0
|
||||
if removed:
|
||||
_touch(pl)
|
||||
db.flush()
|
||||
return removed
|
||||
|
||||
|
||||
def reorder_playlist(db: Session, pl: PlexPlaylist, rating_keys: list[str]) -> None:
|
||||
"""Set the play order from an explicit list of item keys (0-based positions)."""
|
||||
id_by_rk = {
|
||||
it.rating_key: it.id
|
||||
for it in db.execute(
|
||||
select(PlexItem).where(PlexItem.rating_key.in_(rating_keys or [""]))
|
||||
).scalars()
|
||||
}
|
||||
rows = {
|
||||
r.item_id: r
|
||||
for r in db.execute(
|
||||
select(PlexPlaylistItem).where(PlexPlaylistItem.playlist_id == pl.id)
|
||||
).scalars()
|
||||
}
|
||||
pos = 0
|
||||
seen: set[int] = set()
|
||||
for rk in rating_keys:
|
||||
row = rows.get(id_by_rk.get(rk, -1))
|
||||
if row is not None and row.item_id not in seen:
|
||||
row.position = pos
|
||||
pos += 1
|
||||
seen.add(row.item_id)
|
||||
# Rows the payload omitted (added in another tab meanwhile) keep their relative order after
|
||||
# the given ones, so no two rows share a position (the YouTube reorder does the same).
|
||||
for row in sorted(
|
||||
(r for iid, r in rows.items() if iid not in seen), key=lambda r: (r.position, r.id)
|
||||
):
|
||||
row.position = pos
|
||||
pos += 1
|
||||
_touch(pl)
|
||||
db.flush()
|
||||
|
||||
|
||||
# --- a user's watch state --------------------------------------------------------------------
|
||||
|
||||
|
||||
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 = owned_by(PlexState.user_id, user_id) & (PlexState.item_id == item_id)
|
||||
result = db.execute(update(PlexState).where(mine, PlexState.status != "watched").values(values))
|
||||
if isinstance(result, CursorResult) and result.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
|
||||
|
||||
|
||||
def state_row(
|
||||
db: Session,
|
||||
user: User | int,
|
||||
item_id: int,
|
||||
st: PlexState | None = None,
|
||||
*,
|
||||
synced: bool = False,
|
||||
) -> PlexState:
|
||||
"""The user's state row on this item, made if the read before found none. Made by the insert
|
||||
itself (ON CONFLICT DO NOTHING) and then read: the first checkpoint of an item and the `final`
|
||||
flush beside it routinely both read "none", and a plain add let the second one fail on
|
||||
`uq_plex_user_item` — a 500, and the push it was carrying lost. Every first row goes through
|
||||
here: a checkpoint, a status, a season marked at once, a pull from Plex. `synced` is what a row
|
||||
made now says about Plex; a row another writer made first keeps its own."""
|
||||
if st is not None:
|
||||
return st
|
||||
db.execute(
|
||||
pg_insert(PlexState)
|
||||
.values(
|
||||
user_id=user if isinstance(user, int) else user.id,
|
||||
item_id=item_id,
|
||||
synced_to_plex=synced,
|
||||
)
|
||||
.on_conflict_do_nothing(constraint="uq_plex_user_item")
|
||||
)
|
||||
made = queries.state(db, user, item_id)
|
||||
assert made is not None # inserted now, or committed by the request that won
|
||||
return made
|
||||
|
||||
|
||||
def clear_progress(db: Session, st: PlexState | None, now: datetime) -> None:
|
||||
"""A checkpoint too early to keep: a state that held nothing else goes, any other forgets its
|
||||
position."""
|
||||
if st is not None and st.status == "new":
|
||||
db.delete(st)
|
||||
elif st is not None:
|
||||
st.position_seconds = 0
|
||||
st.progress_updated_at = now
|
||||
db.flush()
|
||||
|
||||
|
||||
def checkpoint(
|
||||
db: Session,
|
||||
user: User,
|
||||
item_id: int,
|
||||
st: PlexState | None,
|
||||
position: int,
|
||||
now: datetime,
|
||||
*,
|
||||
dirty: bool,
|
||||
) -> PlexState:
|
||||
"""Record a resume position. `dirty` marks it not yet mirrored to Plex — set before a push, so
|
||||
the reconcile re-pushes it if the push fails and the push can settle it once Plex confirms."""
|
||||
st = state_row(db, user, item_id, st)
|
||||
st.position_seconds = position
|
||||
st.progress_updated_at = now
|
||||
if dirty:
|
||||
st.synced_to_plex = False
|
||||
db.flush()
|
||||
return st
|
||||
|
||||
|
||||
def forget_state(db: Session, st: PlexState) -> None:
|
||||
db.delete(st)
|
||||
db.flush()
|
||||
|
||||
|
||||
def set_status(
|
||||
db: Session, user: User, item_id: int, st: PlexState | None, status: str
|
||||
) -> PlexState:
|
||||
"""Set a watched or hidden status; watched also clears the resume point."""
|
||||
st = state_row(db, user, item_id, st)
|
||||
st.status = status
|
||||
if status == "watched":
|
||||
st.watched_at = datetime.now(timezone.utc)
|
||||
st.position_seconds = 0
|
||||
st.synced_to_plex = False
|
||||
db.flush()
|
||||
return st
|
||||
|
||||
|
||||
def bulk_state(
|
||||
db: Session, user_id: int, eps: list[PlexItem], watched: bool
|
||||
) -> list[tuple[int, str, str]]:
|
||||
"""Mark every episode in `eps` watched (or unwatched) for a user and say what changed, as the
|
||||
(item id, rating key, action) pushes a linked Plex account needs. Unwatching keeps the user's
|
||||
private "hidden" marks."""
|
||||
now = datetime.now(timezone.utc)
|
||||
existing = rollup.item_state_map(db, user_id, [e.id for e in eps])
|
||||
to_push: list[tuple[int, str, str]] = []
|
||||
for e in eps:
|
||||
st = existing.get(e.id)
|
||||
prev = st.status if st is not None else "new"
|
||||
if watched:
|
||||
st = state_row(db, user_id, e.id, st)
|
||||
prev = st.status # a first checkpoint beside this may have made it meanwhile
|
||||
st.status = "watched"
|
||||
st.watched_at = now
|
||||
st.position_seconds = 0
|
||||
st.synced_to_plex = False
|
||||
if prev != "watched":
|
||||
to_push.append((e.id, e.rating_key, "watched"))
|
||||
else: # → new: drop any state (but keep the user's private "hidden" marks)
|
||||
if st is not None and prev != "hidden":
|
||||
db.delete(st)
|
||||
if prev != "new":
|
||||
to_push.append((e.id, e.rating_key, "unwatched"))
|
||||
db.flush()
|
||||
return to_push
|
||||
@@ -33,7 +33,7 @@ from sqlalchemy.orm import Session as DbSession
|
||||
from app import logs, sysconfig
|
||||
from app.config import settings
|
||||
from app.downloads.edit import encode_thread_cap
|
||||
from app.models import PlexItem
|
||||
from app.plex.models import PlexItem
|
||||
from app.plex import paths
|
||||
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app.db import SessionLocal
|
||||
from app.models import PlexItem
|
||||
from app.plex.models import PlexItem
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex.cache import CACHE_DIR
|
||||
from app.plex.client import PlexClient, PlexError
|
||||
|
||||
@@ -21,7 +21,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
|
||||
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
|
||||
from app.plex import detail, overlay, people
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.genres import hu_genres
|
||||
|
||||
@@ -22,7 +22,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app.config import settings
|
||||
from app.models import PlexItem, PlexPerson, PlexShow
|
||||
from app.plex.models import PlexItem, PlexPerson, PlexShow
|
||||
from app.plex.cards import display_title
|
||||
from app.plex.people import people_by_credits, warm_portraits
|
||||
|
||||
@@ -619,9 +619,9 @@ def enrich_person(db: Session, person: PlexPerson) -> bool:
|
||||
if not person.tmdb_person_id or not sysconfig.get_str(db, "tmdb_api_key"):
|
||||
return False
|
||||
try:
|
||||
# Called from a background task (off the request path — see people._enrich_person_bg), so it no
|
||||
# longer blocks a serve; still cap the outbound TMDB call with a short timeout so a hanging TMDB
|
||||
# doesn't tie up a threadpool worker for the default 30s.
|
||||
# Called from a background task (off the request path — see `plex.service.enrich_person_bg`),
|
||||
# so it no longer blocks a serve; still cap the outbound TMDB call with a short timeout so a
|
||||
# hanging TMDB doesn't tie up a threadpool worker for the default 30s.
|
||||
with TmdbClient(db, timeout=8.0) as tmdb:
|
||||
_fetch_person_into(tmdb, person)
|
||||
except TmdbError as exc:
|
||||
|
||||
@@ -29,8 +29,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app.db import SessionLocal
|
||||
from app.models import PlexItem, PlexLibrary, PlexLink, PlexState
|
||||
from app.userscope import owned_by
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexLink, PlexState
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex import queries
|
||||
from app.plex.service import state_row
|
||||
from app.plex.sync import (
|
||||
_TYPE_EPISODE,
|
||||
_TYPE_MOVIE,
|
||||
@@ -196,7 +199,7 @@ def link_for_push(db: Session, user_id: int) -> PlexLink | None:
|
||||
"""The active owner watch-push link for this user, or None when push is off / not the owner.
|
||||
Push is gated on `initial_import_done` so we never push a half-known local state over Plex's
|
||||
authoritative one before the one-time "Plex is master" import has reconciled them."""
|
||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user_id)).scalars().first()
|
||||
link = db.execute(select(PlexLink).where(owned_by(PlexLink.user_id, user_id))).scalars().first()
|
||||
if link and link.uses_admin and link.sync_enabled and link.initial_import_done:
|
||||
return link
|
||||
return None
|
||||
@@ -251,13 +254,7 @@ def push_state_to_plex(
|
||||
# Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be
|
||||
# nothing to flag; that's fine (unscrobble still happened). Skip the flag if the row was
|
||||
# re-edited since we captured `expect_ts` (leave it dirty for its own push / the reconcile).
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
st = queries.state(db, user_id, item_id)
|
||||
if st is not None and _row_matches(st, expect_ts):
|
||||
st.synced_to_plex = True
|
||||
db.commit()
|
||||
@@ -318,7 +315,7 @@ def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -
|
||||
if synced_ids:
|
||||
db.execute(
|
||||
update(PlexState)
|
||||
.where(PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids))
|
||||
.where(owned_by(PlexState.user_id, user_id), PlexState.item_id.in_(synced_ids))
|
||||
.values(synced_to_plex=True)
|
||||
)
|
||||
db.commit()
|
||||
@@ -391,13 +388,7 @@ def _pull_apply(
|
||||
"""Bring one Plex-side positive signal into Siftlode under last-write-wins. Returns
|
||||
"watched"/"in_progress" if it changed the row, else None. Never clears a row (un-watch is the
|
||||
full reconcile's job) — this only pulls in watched/resume."""
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
st = queries.state(db, user_id, item_id)
|
||||
if _same_state(st, status, pos):
|
||||
if st is not None and not st.synced_to_plex:
|
||||
st.synced_to_plex = True # already matches Plex → just settle the flag, no churn
|
||||
@@ -418,8 +409,9 @@ def _pull_apply(
|
||||
# "almost always already pushed" assumption above excludes.
|
||||
return None
|
||||
if st is None:
|
||||
st = PlexState(user_id=user_id, item_id=item_id)
|
||||
db.add(st)
|
||||
st = state_row(db, user_id, item_id, synced=True)
|
||||
if not st.synced_to_plex: # a local change made it first, beside this pull: the guard above
|
||||
return None
|
||||
if status == "watched":
|
||||
st.status, st.position_seconds, st.watched_at, st.progress_updated_at = (
|
||||
"watched",
|
||||
@@ -449,7 +441,7 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
|
||||
dirty = (
|
||||
db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == link.user_id,
|
||||
owned_by(PlexState.user_id, link.user_id),
|
||||
PlexState.synced_to_plex.is_(False),
|
||||
PlexState.status != "hidden",
|
||||
)
|
||||
@@ -505,7 +497,7 @@ def _reconcile_inprogress_movies(
|
||||
and aren't affected). Note: like the rest of this job, `metadata()` reads the TOKEN account (the
|
||||
owner) — a HTPC playing under a managed Plex user (accountID>1) is out of scope here as elsewhere."""
|
||||
conds = [
|
||||
PlexState.user_id == link.user_id,
|
||||
owned_by(PlexState.user_id, link.user_id),
|
||||
PlexState.status.notin_(("watched", "hidden")),
|
||||
PlexState.position_seconds > _PROGRESS_MIN_S,
|
||||
PlexItem.kind == "movie",
|
||||
@@ -627,7 +619,7 @@ def _clear_unwatched(db: Session, user_id: int, plex_states: dict, settled: set[
|
||||
select(PlexState)
|
||||
.join(PlexItem, PlexItem.id == PlexState.item_id)
|
||||
.join(PlexLibrary, PlexLibrary.id == PlexItem.library_id)
|
||||
.where(PlexState.user_id == user_id, PlexLibrary.plex_key.in_(settled))
|
||||
.where(owned_by(PlexState.user_id, user_id), PlexLibrary.plex_key.in_(settled))
|
||||
)
|
||||
for st in rows.scalars().all():
|
||||
if st.item_id in plex_states or st.status == "hidden" or not st.synced_to_plex:
|
||||
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.userscope import owned_by
|
||||
from app import logs, sysconfig
|
||||
from app.db import SessionLocal
|
||||
from app.models import ApiQuotaUsage, QuotaEvent
|
||||
@@ -173,7 +174,7 @@ def actions_today(db: Session, user_id: int, action: str) -> int:
|
||||
select(func.count())
|
||||
.select_from(QuotaEvent)
|
||||
.where(
|
||||
QuotaEvent.user_id == user_id,
|
||||
owned_by(QuotaEvent.user_id, user_id),
|
||||
QuotaEvent.action == action,
|
||||
QuotaEvent.created_at >= pacific_day_start_utc(),
|
||||
)
|
||||
|
||||
@@ -11,12 +11,14 @@ from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -93,6 +95,15 @@ class Playlist(Base, TimestampMixin, UpdatedAtMixin):
|
||||
sync phases; the foundation phase only uses local playlists.)"""
|
||||
|
||||
__tablename__ = "playlists"
|
||||
__table_args__ = (
|
||||
# One Watch later per user, held by the database: two first saves at once each made one.
|
||||
Index(
|
||||
"uq_playlist_watch_later",
|
||||
"user_id",
|
||||
unique=True,
|
||||
postgresql_where=text("kind = 'watch_later'"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
|
||||
@@ -9,7 +9,7 @@ unique key, a row lock taken before a presence check. The comfort shelf's writes
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import exists, func, select, update
|
||||
from sqlalchemy import exists, func, select, text, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -239,24 +239,33 @@ def clear_state(db: Session, user: User, video_id: str) -> None:
|
||||
|
||||
def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
|
||||
"""The player's resume point. 0 clears it: a row holding nothing else goes, one with a status
|
||||
(watched, hidden) keeps it with the position zeroed. The status itself is never changed here."""
|
||||
row = queries.video_state(db, user, video_id)
|
||||
(watched, hidden) keeps it with the position zeroed. The status itself is never changed here.
|
||||
A position is one upsert on (user, video): the first checkpoint and the end-of-playback mark
|
||||
beside it both read "no row", and a plain add let the second one fail on `uq_user_video`."""
|
||||
now = datetime.now(timezone.utc)
|
||||
if not position:
|
||||
if row is None:
|
||||
return
|
||||
if row.status == "new":
|
||||
db.delete(row)
|
||||
else:
|
||||
row.position_seconds = 0
|
||||
row.progress_updated_at = now
|
||||
db.flush()
|
||||
if position:
|
||||
db.execute(
|
||||
pg_insert(VideoState)
|
||||
.values(
|
||||
user_id=user.id,
|
||||
video_id=video_id,
|
||||
position_seconds=position,
|
||||
progress_updated_at=now,
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
constraint="uq_user_video",
|
||||
set_={"position_seconds": position, "progress_updated_at": now, "updated_at": now},
|
||||
)
|
||||
)
|
||||
return
|
||||
row = queries.video_state(db, user, video_id)
|
||||
if row is None:
|
||||
row = VideoState(user_id=user.id, video_id=video_id)
|
||||
db.add(row)
|
||||
row.position_seconds = position
|
||||
row.progress_updated_at = now
|
||||
return
|
||||
if row.status == "new":
|
||||
db.delete(row)
|
||||
else:
|
||||
row.position_seconds = 0
|
||||
row.progress_updated_at = now
|
||||
db.flush()
|
||||
|
||||
|
||||
@@ -319,17 +328,25 @@ def create_playlist(db: Session, user: User, name: str) -> Playlist:
|
||||
|
||||
|
||||
def watch_later(db: Session, user: User) -> Playlist:
|
||||
"""The user's built-in Watch later playlist, created on first use."""
|
||||
"""The user's built-in Watch later playlist, created on first use. Created by the insert itself
|
||||
(ON CONFLICT DO NOTHING on the one-per-user index) and then read: the first two saves at once
|
||||
both read "none", and each used to make a list of its own."""
|
||||
pl = queries.watch_later(db, user)
|
||||
if pl is None:
|
||||
pl = Playlist(
|
||||
user_id=user.id,
|
||||
name="Watch later",
|
||||
kind="watch_later",
|
||||
position=_next_playlist_position(db, user.id),
|
||||
db.execute(
|
||||
pg_insert(Playlist)
|
||||
.values(
|
||||
user_id=user.id,
|
||||
name="Watch later",
|
||||
kind="watch_later",
|
||||
position=_next_playlist_position(db, user.id),
|
||||
)
|
||||
.on_conflict_do_nothing(
|
||||
index_elements=["user_id"], index_where=text("kind = 'watch_later'")
|
||||
)
|
||||
)
|
||||
db.add(pl)
|
||||
db.flush() # the id, for the item the caller adds next
|
||||
pl = queries.watch_later(db, user)
|
||||
assert pl is not None # inserted now, or committed by the request that won
|
||||
return pl
|
||||
|
||||
|
||||
|
||||
@@ -89,8 +89,9 @@ def owned_or_404(
|
||||
) -> M:
|
||||
"""Fetch a row by primary key that `user` owns, or raise 404 (`lock`: see `get_or_404`). A row owned by someone
|
||||
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
|
||||
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
|
||||
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
|
||||
A core route calls it directly. A unit's route cannot (it may not name a unit's model as a value):
|
||||
it asks its unit's `queries.own_x`, which goes through the same `owned_row`, and answers
|
||||
`not_found` itself."""
|
||||
obj = owned_row(db, model, id_, user, owner_attr=owner_attr, lock=lock)
|
||||
if obj is None:
|
||||
raise not_found(what)
|
||||
|
||||
+107
-108
@@ -5,9 +5,10 @@ helpers) stays in app/auth.py, imported below."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import NamedTuple
|
||||
from typing import Literal, NamedTuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from authlib.common.security import generate_token
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
@@ -16,6 +17,7 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.userscope import owned_by
|
||||
from app import apitokens
|
||||
from app import email as email_mod
|
||||
from app import logs
|
||||
@@ -24,10 +26,11 @@ from app import sysconfig
|
||||
from app.auth import (
|
||||
_YOUTUBE_SCOPES,
|
||||
BASE_SCOPES,
|
||||
MAX_SESSION_ACCOUNTS,
|
||||
EXPECTED_ACCOUNT_HEADER,
|
||||
PASSWORD_MIN_LEN,
|
||||
READ_SCOPES,
|
||||
WRITE_SCOPES,
|
||||
built_for_another_account,
|
||||
current_user,
|
||||
env_admin_canonicals,
|
||||
env_allowed_canonicals,
|
||||
@@ -36,7 +39,9 @@ from app.auth import (
|
||||
is_allowed,
|
||||
log,
|
||||
operator_contact,
|
||||
resolved_user_id,
|
||||
optional_current_user,
|
||||
session_user_id,
|
||||
stamp_session,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal, get_db
|
||||
@@ -319,6 +324,25 @@ class SetPasswordIn(BaseModel):
|
||||
current_password: str | None = None
|
||||
|
||||
|
||||
# A connect in flight, in the session: `{state, uid, explicit}`. It names the OAuth state of the flow
|
||||
# that set it, because the session is the browser's, not the flow's: a callback coming back with
|
||||
# another state (an older flow, which authlib then refuses) must not spend the marker of the connect
|
||||
# still open, or that connect would return as a plain sign-in — signing someone else in, or storing
|
||||
# a YouTube grant on whichever account the Google identity already belongs to.
|
||||
_LINK_MARKER = "oauth_link"
|
||||
|
||||
# Where a navigation from a tab showing another account than the one signed in lands: the app, which
|
||||
# boots as the account signed in now and says why (the frontend's `ACCOUNT_CHANGED_FLAG`).
|
||||
ACCOUNT_CHANGED_LANDING = "/?account=changed"
|
||||
|
||||
|
||||
def _take_link_marker(request: Request) -> dict | None:
|
||||
marker = request.session.get(_LINK_MARKER)
|
||||
if not isinstance(marker, dict) or marker.get("state") != request.query_params.get("state"):
|
||||
return None
|
||||
return request.session.pop(_LINK_MARKER)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request, db: Session = Depends(get_db)):
|
||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||
@@ -329,6 +353,10 @@ async def login(request: Request, db: Session = Depends(get_db)):
|
||||
# Google sign-in isn't configured on this instance — land back on the login page (the UI
|
||||
# hides the button, so this is just a safety net for a stale/direct hit).
|
||||
return RedirectResponse(url="/")
|
||||
# A sign-in, never a link. Starting it retires every earlier flow's state (authlib keeps only the
|
||||
# newest), so a connect's marker left from before — abandoned at Google, or still open in
|
||||
# another tab — names a flow that can no longer finish.
|
||||
request.session.pop(_LINK_MARKER, None)
|
||||
return await client.authorize_redirect(
|
||||
request,
|
||||
_oauth_redirect_uri(request),
|
||||
@@ -349,24 +377,23 @@ async def callback(
|
||||
if client is None:
|
||||
return RedirectResponse(url="/")
|
||||
# Linking mode: an already-authenticated account is attaching this Google identity (a
|
||||
# password account enabling YouTube or connecting SSO). The markers are set by /auth/link and
|
||||
# /auth/upgrade; when present we attach to the current user instead of identifying by sub.
|
||||
# Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange
|
||||
# throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link
|
||||
# attempt. Popping up front means an aborted link flow leaves no residue in the session.
|
||||
link_uid = request.session.pop("oauth_link_uid", None)
|
||||
link_explicit = request.session.pop("oauth_link_explicit", False)
|
||||
# password account enabling YouTube or connecting SSO). The marker is set by
|
||||
# /auth/google/connect; when it is this flow's we attach to the current user instead of
|
||||
# identifying by sub. Taken BEFORE the token exchange: if the user aborts Google's consent screen
|
||||
# the exchange throws below, and a marker left behind would silently turn the NEXT clean sign-in
|
||||
# into a link attempt. A connect that never comes back here at all is cleared by /auth/login.
|
||||
link = _take_link_marker(request)
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
# The user cancelled or denied consent at Google — land back with a friendly notice instead
|
||||
# of a raw 400 JSON error page. An already-signed-in user (adding another account via
|
||||
# /auth/login, or linking/upgrading via /auth/link|upgrade) never sees the pre-auth login
|
||||
# banner, so surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on
|
||||
# the login page with the oauth_cancelled banner instead. Keyed on the live session, not on
|
||||
# link_uid, so the add-account path (no marker) is covered too.
|
||||
# of a raw 400 JSON error page. An already-signed-in user (signing in again via /auth/login,
|
||||
# or connecting via /auth/google/connect) never sees the pre-auth login banner, so
|
||||
# surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on the login page
|
||||
# with the oauth_cancelled banner instead. Keyed on the live session, not on link_uid, so a
|
||||
# sign-in started while signed in (no marker) is covered too.
|
||||
log.info("Google OAuth cancelled/failed: %s", exc.error)
|
||||
if request.session.get("user_id") is not None:
|
||||
if session_user_id(request) is not None:
|
||||
return RedirectResponse(url="/?oauth=cancelled")
|
||||
return RedirectResponse(url="/?login=oauth_cancelled")
|
||||
|
||||
@@ -377,8 +404,8 @@ async def callback(
|
||||
sub = userinfo["sub"]
|
||||
email = (userinfo.get("email") or "").lower()
|
||||
|
||||
if link_uid is not None:
|
||||
return _complete_link(request, db, link_uid, link_explicit, sub, userinfo, token)
|
||||
if link is not None:
|
||||
return _complete_link(request, db, link["uid"], link["explicit"], sub, userinfo, token)
|
||||
|
||||
if not is_allowed(db, email):
|
||||
# No uid to name — having no account here is the branch. And the address is left out on
|
||||
@@ -579,19 +606,16 @@ def _complete_link(
|
||||
token: dict,
|
||||
):
|
||||
"""Attach the just-authorized Google identity to the already-signed-in account that started
|
||||
the link/upgrade. No is_allowed gate — they're already an active user. Refuses to hijack a
|
||||
the connect. No is_allowed gate — they're already an active user. Refuses to hijack a
|
||||
Google identity owned by another account, or to silently swap a different linked identity.
|
||||
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
|
||||
a YouTube upgrade — the caller pops it up front so an aborted flow leaves nothing behind."""
|
||||
# SEC-11: the marker must name an account signed into THIS browser — the session default OR any
|
||||
# account in the wallet — not only the default. A tab acting as a secondary account links Google to
|
||||
# ITSELF (the marker was set from current_user, which resolves the ?account= it navigated with), so
|
||||
# accepting wallet membership stops the identity/token landing on the default account A instead of B.
|
||||
accounts = request.session.get("account_ids") or []
|
||||
if link_uid != request.session.get("user_id") and link_uid not in accounts:
|
||||
return RedirectResponse(url="/?link=error")
|
||||
user = db.get(User, link_uid)
|
||||
if user is None:
|
||||
`explicit` (from the link marker) distinguishes a "Connect Google" click from a YouTube grant —
|
||||
the caller takes the marker up front so an aborted flow leaves nothing behind."""
|
||||
# The marker must name the account still signed into THIS browser, and that session must still
|
||||
# hold: a sign-in as someone else between the start and the callback replaced it (the identity
|
||||
# must not land on the newcomer), and a session revoked meanwhile ("log out everywhere", a
|
||||
# password change, a suspension) must not finish what it started.
|
||||
user = optional_current_user(request, db)
|
||||
if user is None or user.id != link_uid:
|
||||
return RedirectResponse(url="/?link=error")
|
||||
other = db.execute(
|
||||
select(User).where(User.google_sub == sub, User.id != user.id)
|
||||
@@ -611,45 +635,31 @@ def _complete_link(
|
||||
_store_token(db, user, token)
|
||||
db.commit()
|
||||
log.info("Google linked: uid=%s scopes=%s", user.id, token.get("scope"))
|
||||
# An explicit "Connect Google" lands on Settings with a confirmation; a YouTube upgrade
|
||||
# An explicit "Connect Google" lands on Settings with a confirmation; a YouTube grant
|
||||
# (wizard / Settings access rows) returns to the app so the onboarding flow resumes as before.
|
||||
return RedirectResponse(url="/?link=ok" if explicit else "/")
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Sign the requesting tab's active account out of this browser (removing it from the wallet).
|
||||
Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging
|
||||
out in one tab doesn't disturb another tab running a different account. If the account being
|
||||
removed was the session default, promote the most recent remaining account (or clear the
|
||||
session when none remain)."""
|
||||
# A tab still pinned to an account already signed out in another tab resolves to nobody, so it
|
||||
# signs nobody out — it used to fall back to the default and sign THAT account out in its place.
|
||||
active, is_default = resolved_user_id(request)
|
||||
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
|
||||
request.session["account_ids"] = remaining
|
||||
if is_default:
|
||||
if remaining:
|
||||
request.session["user_id"] = remaining[-1]
|
||||
return JSONResponse({"ok": True, "switched": True})
|
||||
request.session.clear()
|
||||
return JSONResponse({"ok": True, "switched": False})
|
||||
# A non-default (per-tab) account signed out: it's gone from the wallet and the session default
|
||||
# is untouched. The tab drops its own override and falls back to the default on reload.
|
||||
return JSONResponse({"ok": True, "switched": False})
|
||||
"""Sign this browser out: the session holds one account, and it ends — unless the tab asking was
|
||||
built for another account than the one signed in now (a sign-in as someone else in another tab):
|
||||
its Sign out would end THAT person's session (see `EXPECTED_ACCOUNT_HEADER`)."""
|
||||
signed_in = session_user_id(request)
|
||||
if signed_in is not None and built_for_another_account(
|
||||
signed_in, request.headers.get(EXPECTED_ACCOUNT_HEADER)
|
||||
):
|
||||
raise refused(401, "account_changed")
|
||||
request.session.clear()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
def _establish_session(request: Request, user: User) -> None:
|
||||
"""Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
|
||||
and password login, so both join the same account switcher and both stamp `last_login_at` (the
|
||||
caller commits). Multi-session: every account that authenticated in this browser is remembered so
|
||||
the user can switch between them; only accounts that actually proved themselves HERE land in that
|
||||
wallet (it is the proof they may be switched to)."""
|
||||
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
|
||||
accounts.append(user.id)
|
||||
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
||||
and password login, so both stamp `last_login_at` (the caller commits). One account per session:
|
||||
the sign-in replaces whoever was signed in here before."""
|
||||
request.session["user_id"] = user.id
|
||||
_remember_epoch(request, user)
|
||||
stamp_session(request, user)
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@@ -1124,7 +1134,7 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
# Burn any other outstanding reset tokens for this user.
|
||||
for row in db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.user_id == user.id,
|
||||
owned_by(AuthToken.user_id, user),
|
||||
AuthToken.kind.in_(_PASSWORD_TOKEN_KINDS),
|
||||
AuthToken.used_at.is_(None),
|
||||
)
|
||||
@@ -1134,15 +1144,6 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _remember_epoch(request: Request, user: User) -> None:
|
||||
"""Record this account's current `session_epoch` in the signed cookie (SA4), so `current_user`
|
||||
can later reject the cookie if the account's epoch was bumped (password reset/change, or
|
||||
"log out everywhere"). Per-account so one account's revocation doesn't evict the others."""
|
||||
epochs = dict(request.session.get("epochs") or {})
|
||||
epochs[str(user.id)] = user.session_epoch
|
||||
request.session["epochs"] = epochs
|
||||
|
||||
|
||||
def bump_session_epoch(db: Session, user: User) -> None:
|
||||
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
|
||||
minted before the bump carry a now-stale epoch and are rejected by `current_user`. The account's
|
||||
@@ -1219,35 +1220,11 @@ def logout_others(
|
||||
# session at a newer epoch than the DB (see set_password).
|
||||
bump_session_epoch(db, user)
|
||||
db.commit()
|
||||
_remember_epoch(request, user)
|
||||
stamp_session(request, user)
|
||||
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/link")
|
||||
async def link_google(
|
||||
request: Request,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
|
||||
the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback
|
||||
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
|
||||
client = google_oauth(db)
|
||||
if client is None:
|
||||
return RedirectResponse(url="/?link=error")
|
||||
request.session["oauth_link_uid"] = user.id
|
||||
request.session["oauth_link_explicit"] = True
|
||||
return await client.authorize_redirect(
|
||||
request,
|
||||
_oauth_redirect_uri(request),
|
||||
access_type="offline",
|
||||
prompt="select_account",
|
||||
include_granted_scopes="true",
|
||||
scope=BASE_SCOPES,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/set-password")
|
||||
def set_password(
|
||||
body: SetPasswordIn,
|
||||
@@ -1271,35 +1248,57 @@ def set_password(
|
||||
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
|
||||
bump_session_epoch(db, user)
|
||||
db.commit()
|
||||
_remember_epoch(request, user)
|
||||
stamp_session(request, user)
|
||||
log.info("Password set/changed: uid=%s", user.id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/upgrade")
|
||||
async def upgrade(
|
||||
# What each Google connect asks Google for. `identity` attaches a Google sign-in to the account
|
||||
# (Settings → Connect Google) and lets the person pick which Google account; `read` and `write` grant
|
||||
# YouTube access (the onboarding wizard, the Settings access rows) and always show the consent screen,
|
||||
# so a scope granted earlier and since revoked at Google is asked for again.
|
||||
_CONNECT: dict[str, tuple[str, str]] = {
|
||||
"identity": (BASE_SCOPES, "select_account"),
|
||||
"read": (READ_SCOPES, "consent"),
|
||||
"write": (WRITE_SCOPES, "consent"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/google/connect")
|
||||
async def google_connect(
|
||||
request: Request,
|
||||
access: str = "read",
|
||||
access: Literal["identity", "read", "write"],
|
||||
expect: str | None = None,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
||||
read-only (enough to build the feed); `access=write` additionally grants management
|
||||
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
|
||||
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
|
||||
the least-privileged read scope."""
|
||||
"""Attach Google to the signed-in account: its identity, or YouTube read/write access on top.
|
||||
The shared callback sees the link marker and attaches what Google returns to THIS account —
|
||||
without it a password account (no google_sub) would get a brand-new, separate Google user
|
||||
created instead. It stores whatever scopes Google grants, so `can_read` / `can_write` flip on
|
||||
once granted.
|
||||
|
||||
`expect` is the account the tab was built for. A navigation carries no headers, so the check
|
||||
every request gets (`EXPECTED_ACCOUNT_HEADER`) rides the query here: a tab still showing the
|
||||
account signed in before would otherwise attach a Google identity to the one signed in now."""
|
||||
if built_for_another_account(user.id, expect):
|
||||
return RedirectResponse(url=ACCOUNT_CHANGED_LANDING)
|
||||
client = google_oauth(db)
|
||||
if client is None:
|
||||
return RedirectResponse(url="/")
|
||||
# Attach the grant to THIS account in the callback. Without it a password account (no
|
||||
# google_sub) would get a brand-new, separate Google user created instead of being linked.
|
||||
request.session["oauth_link_uid"] = user.id
|
||||
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
||||
return RedirectResponse(url="/?link=error")
|
||||
scope, prompt = _CONNECT[access]
|
||||
state = generate_token(48)
|
||||
request.session[_LINK_MARKER] = {
|
||||
"state": state,
|
||||
"uid": user.id,
|
||||
"explicit": access == "identity",
|
||||
}
|
||||
return await client.authorize_redirect(
|
||||
request,
|
||||
_oauth_redirect_uri(request),
|
||||
state=state,
|
||||
access_type="offline",
|
||||
prompt="consent",
|
||||
prompt=prompt,
|
||||
include_granted_scopes="true",
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.userscope import owned_by
|
||||
from app import audit, debugsession, logs, logsink
|
||||
from app.audit import AuditAction
|
||||
from app.auth import current_user
|
||||
@@ -95,7 +96,7 @@ def stop_session(
|
||||
def list_sessions(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
sessions = db.scalars(
|
||||
select(DebugSession)
|
||||
.where(DebugSession.user_id == user.id)
|
||||
.where(owned_by(DebugSession.user_id, user))
|
||||
.order_by(DebugSession.started_at.desc())
|
||||
.limit(LIST_MAX)
|
||||
).all()
|
||||
@@ -219,6 +220,7 @@ def send_issue(sid: str, user: User = Depends(current_user), db: Session = Depen
|
||||
raise refused(409, "issue_sending")
|
||||
new = session.issue_url is None
|
||||
if not new and not debugsession.unfinished(session):
|
||||
db.commit() # nothing to send: the lock ends here, not when the request's session closes
|
||||
return {"ok": True, "session": _view(db, session)}
|
||||
key = str(user.id)
|
||||
if new:
|
||||
|
||||
@@ -375,6 +375,9 @@ def update_folder(
|
||||
folder = own_folder(db, user, folder_id)
|
||||
sent = body.model_fields_set
|
||||
if "name" in sent:
|
||||
# Which folder may be renamed is answered before what the name is, as it always was.
|
||||
if not folders.renamable(folder):
|
||||
raise refused(409, "file_folder_not_own")
|
||||
try:
|
||||
folders.rename(db, folder, _folder_name(body.name or ""))
|
||||
except folders.FolderError as e:
|
||||
|
||||
+10
-81
@@ -9,11 +9,9 @@ from app.auth import (
|
||||
count_admins,
|
||||
count_pending_activations,
|
||||
current_user,
|
||||
drop_from_wallet,
|
||||
google_enabled,
|
||||
has_read_scope,
|
||||
has_write_scope,
|
||||
is_allowed,
|
||||
optional_current_user,
|
||||
purge_user,
|
||||
)
|
||||
@@ -26,79 +24,10 @@ from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
from app.utils import iso
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
|
||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||
|
||||
|
||||
@router.get("/accounts")
|
||||
def my_accounts(
|
||||
request: Request,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""The accounts that have authenticated in this browser session (switchable without a
|
||||
new Google sign-in). The active one is flagged."""
|
||||
ids = request.session.get("account_ids") or [user.id]
|
||||
rows = {u.id: u for u in db.execute(select(User).where(User.id.in_(ids))).scalars()}
|
||||
out = []
|
||||
for uid in ids: # preserve recency order from the session
|
||||
u = rows.get(uid)
|
||||
if u is not None:
|
||||
out.append(
|
||||
{
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"display_name": u.display_name,
|
||||
"avatar_url": u.avatar_url,
|
||||
"active": u.id == user.id,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class SwitchAccountIn(BaseModel):
|
||||
user_id: int
|
||||
|
||||
|
||||
@router.post("/switch")
|
||||
def switch_account(
|
||||
body: SwitchAccountIn,
|
||||
request: Request,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Switch the active account to another one already authenticated in this browser. No
|
||||
Google round-trip — but only accounts present in the session list (proof they signed in
|
||||
here) are allowed, and access is re-checked in case the invite was revoked since."""
|
||||
target = body.user_id
|
||||
if target not in (request.session.get("account_ids") or []):
|
||||
raise refused(403, "not_your_account")
|
||||
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
|
||||
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
|
||||
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
|
||||
# wallet session, logging out every account here. (B-core-9)
|
||||
u = db.get(User, target)
|
||||
if u is None:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise not_found("account")
|
||||
if not is_allowed(db, u.email):
|
||||
raise refused(403, "account_no_access")
|
||||
if not u.is_active:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise refused(403, "account_no_access")
|
||||
if u.is_suspended:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise refused(403, "account_suspended")
|
||||
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
||||
epochs = request.session.get("epochs") or {}
|
||||
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
||||
drop_from_wallet(request.session, target)
|
||||
raise refused(403, "account_signed_out")
|
||||
request.session["user_id"] = target
|
||||
return {"ok": True, "user_id": target}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_me(
|
||||
user: User | None = Depends(optional_current_user), db: Session = Depends(get_db)
|
||||
@@ -187,17 +116,11 @@ def delete_my_account(
|
||||
surfaces)."""
|
||||
if user.role == "admin" and count_admins(db) <= 1:
|
||||
raise refused(400, "only_admin_delete")
|
||||
user_id = user.id
|
||||
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
||||
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
||||
# admin delete path.
|
||||
purge_user(db, user, background)
|
||||
# Drop this account from the browser session; switch to another signed-in account if any.
|
||||
remaining = [a for a in (request.session.get("account_ids") or []) if a != user_id]
|
||||
if remaining:
|
||||
request.session["account_ids"] = remaining
|
||||
request.session["user_id"] = remaining[-1]
|
||||
else:
|
||||
request.session.clear()
|
||||
# The session's one account is gone: so is the session.
|
||||
request.session.clear()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@@ -213,7 +136,13 @@ def update_preferences(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
# Merge so partial updates don't wipe other keys.
|
||||
# Merge so partial updates don't wipe other keys — over the stored row read again under a row
|
||||
# lock: a client saves keys one at a time (several at once on a boot that moves this browser's
|
||||
# copies to the account), and two merges over the same earlier read would keep only the last.
|
||||
# FOR NO KEY UPDATE, as bump_session_epoch: it still orders two merges, and a transaction that
|
||||
# only added a row referring to the account (a playlist pull, then minutes of YouTube) does not
|
||||
# hold the save up.
|
||||
db.refresh(user, with_for_update={"key_share": True})
|
||||
merged = dict(user.preferences or {})
|
||||
merged.update(preferences.root)
|
||||
user.preferences = merged
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.userscope import owned_by
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import Notification, User
|
||||
@@ -36,7 +37,7 @@ def list_notifications(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
where = [Notification.user_id == user.id]
|
||||
where = [owned_by(Notification.user_id, user)]
|
||||
if not include_dismissed:
|
||||
where.append(Notification.dismissed.is_(False))
|
||||
total = db.scalar(select(func.count()).select_from(Notification).where(*where)) or 0
|
||||
@@ -61,7 +62,7 @@ def unread_count(user: User = Depends(current_user), db: Session = Depends(get_d
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(
|
||||
Notification.user_id == user.id,
|
||||
owned_by(Notification.user_id, user),
|
||||
Notification.read.is_(False),
|
||||
Notification.dismissed.is_(False),
|
||||
)
|
||||
@@ -77,8 +78,8 @@ def mark_read(
|
||||
) -> dict:
|
||||
n = owned_or_404(db, Notification, notification_id, user, what="notification")
|
||||
n.read = True
|
||||
db.commit()
|
||||
trim_read(db, user.id)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -86,11 +87,11 @@ def mark_read(
|
||||
def mark_all_read(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
db.execute(
|
||||
update(Notification)
|
||||
.where(Notification.user_id == user.id, Notification.read.is_(False))
|
||||
.where(owned_by(Notification.user_id, user), Notification.read.is_(False))
|
||||
.values(read=True)
|
||||
)
|
||||
db.commit()
|
||||
trim_read(db, user.id)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -113,7 +114,7 @@ def clear_all(user: User = Depends(current_user), db: Session = Depends(get_db))
|
||||
"""Dismiss every (still-visible) notification for this user."""
|
||||
db.execute(
|
||||
update(Notification)
|
||||
.where(Notification.user_id == user.id, Notification.dismissed.is_(False))
|
||||
.where(owned_by(Notification.user_id, user), Notification.dismissed.is_(False))
|
||||
.values(dismissed=True, read=True)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"""Shared helpers, serializers and constants for the plex route package."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app.models import PlexItem
|
||||
from app.plex import queries
|
||||
from app.plex.cards import norm_lang as _norm_lang # noqa: F401 — the package's historic name
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes._common import not_found
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.plex.models import PlexItem
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
|
||||
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||
it = by_rating_key(db, PlexItem, rating_key)
|
||||
def item_or_404(db: Session, rating_key: str) -> "PlexItem":
|
||||
it = queries.item(db, rating_key)
|
||||
if it is None:
|
||||
raise not_found("plex_item")
|
||||
return it
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
"""Plex admin: connection test, catalog sync, and owner watch-link config."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLink,
|
||||
User,
|
||||
)
|
||||
from app.models import User
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex import queries, service
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex import watch_sync as plex_watch
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.utils import iso
|
||||
from app.refusal import refused
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.plex.models import PlexLink
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
@@ -69,7 +68,7 @@ def _sample_plex_path(db: Session, plex: PlexClient, sections: list[dict]) -> st
|
||||
"""A real Plex-side media file path to validate the mount + path-map against. Prefers the
|
||||
already-mirrored catalog (cheap); before the first sync, pulls one movie leaf from an enabled
|
||||
section straight from Plex."""
|
||||
fp = db.scalar(select(PlexItem.file_path).where(PlexItem.file_path.isnot(None)))
|
||||
fp = queries.sample_file_path(db)
|
||||
if fp:
|
||||
return fp
|
||||
wanted = plex_sync._enabled_section_keys(db)
|
||||
@@ -106,7 +105,7 @@ def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -
|
||||
return {"ok": True, **plex_sync.sync(db)}
|
||||
|
||||
|
||||
def _link_dict(link: PlexLink | None) -> dict:
|
||||
def _link_dict(link: "PlexLink | None") -> dict:
|
||||
return {
|
||||
"linked": link is not None,
|
||||
"sync_enabled": bool(link and link.sync_enabled),
|
||||
@@ -119,8 +118,7 @@ def _link_dict(link: PlexLink | None) -> dict:
|
||||
@router.get("/watch/link")
|
||||
def watch_link_status(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""This user's Plex watch-sync link status (drives the Settings toggle)."""
|
||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||
return _link_dict(link)
|
||||
return _link_dict(queries.watch_link(db, user))
|
||||
|
||||
|
||||
class WatchLinkIn(BaseModel):
|
||||
@@ -139,11 +137,7 @@ def watch_link_set(
|
||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||
raise refused(409, "plex_disabled")
|
||||
enabled = body.enabled
|
||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||
if link is None:
|
||||
link = PlexLink(user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc))
|
||||
db.add(link)
|
||||
link.sync_enabled = enabled
|
||||
link = service.set_watch_link(db, user, enabled)
|
||||
db.commit()
|
||||
|
||||
result: dict = {}
|
||||
@@ -156,7 +150,7 @@ def watch_link_set(
|
||||
def watch_import_now(user: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""Admin: (re-)run the "Plex is master" import for this account — Plex re-wins on the
|
||||
intersection; Siftlode-only states are preserved. Idempotent."""
|
||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||
link = queries.watch_link(db, user)
|
||||
if link is None or not link.sync_enabled:
|
||||
raise refused(409, "watch_sync_off")
|
||||
result = plex_watch.import_owner_watch_state(db, link)
|
||||
|
||||
@@ -1,110 +1,30 @@
|
||||
"""Plex browse: the library list, the unified cross-library browse feed, and its faceted filters."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Integer,
|
||||
String,
|
||||
and_,
|
||||
cast,
|
||||
func,
|
||||
literal,
|
||||
null,
|
||||
or_,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.mailtext import lang_for_user
|
||||
from app.collation import collated, sorted_words
|
||||
from app import sysconfig
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.mailtext import lang_for_user
|
||||
from app.models import User
|
||||
from app.plex import library as plex_library
|
||||
from app.plex import queries
|
||||
from app.plex.cards import display_title, episode_card
|
||||
from app.plex.query import (
|
||||
FIELD_COLUMNS,
|
||||
PLAYBACK_CLASSES,
|
||||
TS_CONFIG,
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
apply_movie_state_filter,
|
||||
apply_show_state_filter,
|
||||
content_libs,
|
||||
field_recheck,
|
||||
genres_col,
|
||||
meta_filter_conds,
|
||||
movie_status_case,
|
||||
playable_conds,
|
||||
playback_class_expr,
|
||||
show_status_case,
|
||||
split_csv,
|
||||
summary_col,
|
||||
title_col,
|
||||
vector_col,
|
||||
vector_match,
|
||||
)
|
||||
from app.plex.query import LibraryFilters
|
||||
from app.routes.plex._shared import _norm_lang
|
||||
from app.utils import to_tsquery_str
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
|
||||
def sort_column(u, sort: str, lang: str = "en"):
|
||||
"""The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline
|
||||
in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control
|
||||
offers but this cannot order would quietly fall back to `added_at` and look like a broken control.
|
||||
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts
|
||||
in the reader's alphabet (`collation`)."""
|
||||
return {
|
||||
"added": u.c.added_at,
|
||||
"title": collated(u.c.title, lang),
|
||||
"year": u.c.year,
|
||||
"rating": u.c.rating,
|
||||
"duration": u.c.duration_s,
|
||||
"release": u.c.rel,
|
||||
"lastwatched": u.c.last_watched,
|
||||
}.get(sort, u.c.added_at)
|
||||
|
||||
|
||||
@router.get("/libraries")
|
||||
def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""The mirrored libraries the user can browse (drives the Plex scope selector). HU overlay
|
||||
libraries are excluded — they're not browsable, they only feed the EN twin's Hungarian metadata."""
|
||||
libs = (
|
||||
db.execute(
|
||||
select(PlexLibrary)
|
||||
.where(PlexLibrary.overlay_of.is_(None))
|
||||
.order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang_for_user(user)))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for lib in libs:
|
||||
if lib.kind == "movie":
|
||||
count = db.scalar(
|
||||
select(func.count(PlexItem.id)).where(
|
||||
PlexItem.library_id == lib.id, PlexItem.kind == "movie"
|
||||
)
|
||||
)
|
||||
else:
|
||||
count = db.scalar(select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id))
|
||||
out.append(
|
||||
{
|
||||
"key": lib.plex_key,
|
||||
"title": lib.title,
|
||||
"kind": lib.kind,
|
||||
"count": count or 0,
|
||||
}
|
||||
)
|
||||
out = [
|
||||
{"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count}
|
||||
for lib, count in queries.browsable_libraries(db, lang_for_user(user))
|
||||
]
|
||||
return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out}
|
||||
|
||||
|
||||
@@ -129,193 +49,60 @@ def unified_library(
|
||||
episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section)."""
|
||||
offset = max(0, offset)
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
movie_lib_ids, show_lib_ids = content_libs(db)
|
||||
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
||||
|
||||
ts = to_tsquery_str(q) if q else None
|
||||
tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None
|
||||
if field not in FIELD_COLUMNS and field != "all":
|
||||
field = "all"
|
||||
# `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for
|
||||
# the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter.
|
||||
grid_field = field != "episode"
|
||||
recheck = field_recheck if tsq is not None and field not in ("all", "episode") else None
|
||||
|
||||
selects = []
|
||||
if want_movies and grid_field:
|
||||
st = aliased(PlexState)
|
||||
m_status = movie_status_case(st)
|
||||
mq = (
|
||||
select(
|
||||
PlexItem.rating_key.label("rk"),
|
||||
literal("movie").label("kind"),
|
||||
title_col(PlexItem, lang).label("title"),
|
||||
PlexItem.year.label("year"),
|
||||
PlexItem.rating.label("rating"),
|
||||
# Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis.
|
||||
func.left(summary_col(PlexItem, lang), 300).label("summary"),
|
||||
PlexItem.added_at.label("added_at"),
|
||||
PlexItem.originally_available_at.label("rel"),
|
||||
PlexItem.duration_s.label("duration_s"),
|
||||
PlexItem.playable.label("playable"),
|
||||
PlexItem.transcode_hw.label("transcode_hw"),
|
||||
cast(null(), Integer).label("season_count"),
|
||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||
m_status.label("status"),
|
||||
literal(False).label("resumable"),
|
||||
# "last watched" sort key: when the movie was last resumed, else when it was marked
|
||||
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
|
||||
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
||||
(
|
||||
func.ts_rank(vector_col(PlexItem, lang), tsq)
|
||||
if tsq is not None
|
||||
else literal(0.0)
|
||||
).label("rank"),
|
||||
)
|
||||
.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
|
||||
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
|
||||
)
|
||||
mq = apply_movie_state_filter(mq, st, show)
|
||||
mq = apply_meta_filters(mq, PlexItem, p, lang)
|
||||
if f.duration_min is not None:
|
||||
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
||||
if f.duration_max is not None:
|
||||
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
||||
# Playback-class filter (movie-only — a show is an aggregate with no single class).
|
||||
mq = mq.where(*playable_conds(split_csv(f.playable)))
|
||||
if tsq is not None:
|
||||
mq = mq.where(vector_match(PlexItem, lang, tsq))
|
||||
if recheck is not None:
|
||||
mq = mq.where(recheck(PlexItem, field, tsq, lang))
|
||||
selects.append(mq)
|
||||
if want_shows and grid_field:
|
||||
agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
|
||||
tot = func.coalesce(agg.c.total, 0)
|
||||
wat = func.coalesce(agg.c.watched, 0)
|
||||
inp = func.coalesce(agg.c.inprog, 0)
|
||||
s_status = show_status_case(tot, wat, inp)
|
||||
sq = (
|
||||
select(
|
||||
PlexShow.rating_key.label("rk"),
|
||||
literal("show").label("kind"),
|
||||
title_col(PlexShow, lang).label("title"),
|
||||
PlexShow.year.label("year"),
|
||||
PlexShow.rating.label("rating"),
|
||||
func.left(summary_col(PlexShow, lang), 300).label("summary"),
|
||||
PlexShow.added_at.label("added_at"),
|
||||
PlexShow.originally_available_at.label("rel"),
|
||||
cast(null(), Integer).label("duration_s"),
|
||||
cast(null(), String).label("playable"),
|
||||
cast(null(), Boolean).label("transcode_hw"),
|
||||
PlexShow.child_count.label("season_count"),
|
||||
literal(0).label("position_seconds"),
|
||||
s_status.label("status"),
|
||||
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
|
||||
(inp > 0).label("resumable"),
|
||||
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
|
||||
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
|
||||
agg.c.resumed_at.label("last_watched"),
|
||||
(
|
||||
func.ts_rank(vector_col(PlexShow, lang), tsq)
|
||||
if tsq is not None
|
||||
else literal(0.0)
|
||||
).label("rank"),
|
||||
)
|
||||
.outerjoin(agg, agg.c.show_id == PlexShow.id)
|
||||
.where(PlexShow.library_id.in_(show_lib_ids))
|
||||
)
|
||||
sq = apply_show_state_filter(sq, tot, wat, inp, show)
|
||||
sq = apply_meta_filters(sq, PlexShow, p, lang)
|
||||
if tsq is not None:
|
||||
sq = sq.where(vector_match(PlexShow, lang, tsq))
|
||||
if recheck is not None:
|
||||
sq = sq.where(recheck(PlexShow, field, tsq, lang))
|
||||
selects.append(sq)
|
||||
|
||||
total, rows, eps = plex_library.library_page(
|
||||
db,
|
||||
user,
|
||||
scope=scope,
|
||||
q=q,
|
||||
field=field,
|
||||
sort=sort,
|
||||
sort_dir=sort_dir,
|
||||
show=show,
|
||||
lang=lang,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
f=f,
|
||||
)
|
||||
items = []
|
||||
total = 0
|
||||
if selects:
|
||||
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
|
||||
u = union.subquery()
|
||||
total = db.scalar(select(func.count()).select_from(u)) or 0
|
||||
|
||||
if tsq is not None:
|
||||
order = [u.c.rank.desc()]
|
||||
else:
|
||||
col = sort_column(u, sort, lang)
|
||||
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
||||
order.append(u.c.rk.desc())
|
||||
rows = db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all()
|
||||
|
||||
for r in rows:
|
||||
if r.kind == "movie":
|
||||
items.append(
|
||||
{
|
||||
"id": r.rk,
|
||||
"type": "movie",
|
||||
"title": r.title,
|
||||
"year": r.year,
|
||||
"rating": r.rating,
|
||||
"summary": r.summary,
|
||||
"duration_seconds": r.duration_s,
|
||||
"thumb": f"/api/plex/image/{r.rk}",
|
||||
"playable": r.playable,
|
||||
"transcode_hw": r.transcode_hw,
|
||||
"status": r.status,
|
||||
"position_seconds": r.position_seconds,
|
||||
}
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
{
|
||||
"id": r.rk,
|
||||
"type": "show",
|
||||
"title": r.title,
|
||||
"year": r.year,
|
||||
"rating": r.rating,
|
||||
"summary": r.summary,
|
||||
"thumb": f"/api/plex/image/{r.rk}",
|
||||
"season_count": r.season_count,
|
||||
"status": r.status,
|
||||
"resumable": r.resumable,
|
||||
}
|
||||
)
|
||||
|
||||
# Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour)
|
||||
# and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes
|
||||
# target the movie/show grid, so they suppress episodes.
|
||||
episodes = []
|
||||
if tsq is not None and want_shows and field in ("all", "episode"):
|
||||
ep_st = aliased(PlexState)
|
||||
# In HU an episode is found by its Hungarian title too (the overlay fills it) — and
|
||||
# still by its English one, like `vector_match` does for the grid.
|
||||
if field == "episode":
|
||||
ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
|
||||
ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, ""))
|
||||
else:
|
||||
ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu
|
||||
hit = ep_vec.op("@@")(tsq)
|
||||
if lang == "hu":
|
||||
hit = or_(ep_vec_hu.op("@@")(tsq), hit)
|
||||
ep_vec = ep_vec_hu
|
||||
eps = db.execute(
|
||||
select(PlexItem, ep_st, PlexShow)
|
||||
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id))
|
||||
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
|
||||
.where(
|
||||
PlexItem.kind == "episode",
|
||||
PlexItem.library_id.in_(show_lib_ids),
|
||||
hit,
|
||||
for r in rows:
|
||||
if r.kind == "movie":
|
||||
items.append(
|
||||
{
|
||||
"id": r.rk,
|
||||
"type": "movie",
|
||||
"title": r.title,
|
||||
"year": r.year,
|
||||
"rating": r.rating,
|
||||
"summary": r.summary,
|
||||
"duration_seconds": r.duration_s,
|
||||
"thumb": f"/api/plex/image/{r.rk}",
|
||||
"playable": r.playable,
|
||||
"transcode_hw": r.transcode_hw,
|
||||
"status": r.status,
|
||||
"position_seconds": r.position_seconds,
|
||||
}
|
||||
)
|
||||
.order_by(func.ts_rank(ep_vec, tsq).desc())
|
||||
.limit(24)
|
||||
).all()
|
||||
for e, s, sh in eps:
|
||||
card = episode_card(e, s, lang=lang)
|
||||
card["show_title"] = display_title(sh, lang) if sh is not None else None
|
||||
episodes.append(card)
|
||||
else:
|
||||
items.append(
|
||||
{
|
||||
"id": r.rk,
|
||||
"type": "show",
|
||||
"title": r.title,
|
||||
"year": r.year,
|
||||
"rating": r.rating,
|
||||
"summary": r.summary,
|
||||
"thumb": f"/api/plex/image/{r.rk}",
|
||||
"season_count": r.season_count,
|
||||
"status": r.status,
|
||||
"resumable": r.resumable,
|
||||
}
|
||||
)
|
||||
|
||||
episodes = []
|
||||
for e, s, sh in eps:
|
||||
card = episode_card(e, s, lang=lang)
|
||||
card["show_title"] = display_title(sh, lang) if sh is not None else None
|
||||
episodes.append(card)
|
||||
|
||||
return {
|
||||
"scope": scope,
|
||||
@@ -341,237 +128,4 @@ def facets(
|
||||
filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while
|
||||
a multi-select group still lets you add more. Merged across the scope's libraries; duration is
|
||||
movie-only. (Search text is not factored in — facets track the sidebar filters.)"""
|
||||
empty = {
|
||||
"genres": [],
|
||||
"content_ratings": [],
|
||||
"studios": [],
|
||||
"playable": [],
|
||||
"year_min": None,
|
||||
"year_max": None,
|
||||
"rating_max": None,
|
||||
"duration_min": None,
|
||||
"duration_max": None,
|
||||
}
|
||||
all_movie_ids, all_show_ids = content_libs(db)
|
||||
movie_ids = all_movie_ids if scope in ("movie", "both") else []
|
||||
show_ids = all_show_ids if scope in ("show", "both") else []
|
||||
if not movie_ids and not show_ids:
|
||||
return empty
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
|
||||
uid = user.id
|
||||
|
||||
# The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a
|
||||
# full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups
|
||||
# (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call.
|
||||
# The same filter on the same rows, so the counts do not change
|
||||
# (test_plex_facets_grid_parity).
|
||||
shows_in_scope: list[int] | None = None
|
||||
if show_ids and show in ("watched", "in_progress", "unwatched"):
|
||||
agg = rollup.agg_subquery(db, uid).subquery()
|
||||
in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id)
|
||||
in_scope = apply_show_state_filter(
|
||||
in_scope,
|
||||
func.coalesce(agg.c.total, 0),
|
||||
func.coalesce(agg.c.watched, 0),
|
||||
func.coalesce(agg.c.inprog, 0),
|
||||
show,
|
||||
)
|
||||
shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids))))
|
||||
|
||||
def wsq(query, model, ids, pp, kind_movie, with_dur):
|
||||
"""Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
|
||||
facet aggregation query, so the facets reflect exactly the visible result set."""
|
||||
query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang))
|
||||
if kind_movie:
|
||||
query = query.where(model.kind == "movie")
|
||||
if with_dur and pp.get("duration_min") is not None:
|
||||
query = query.where(model.duration_s >= pp["duration_min"])
|
||||
if with_dur and pp.get("duration_max") is not None:
|
||||
query = query.where(model.duration_s <= pp["duration_max"])
|
||||
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
|
||||
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
|
||||
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
|
||||
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
|
||||
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
|
||||
st = aliased(PlexState)
|
||||
query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid))
|
||||
query = apply_movie_state_filter(query, st, show)
|
||||
elif shows_in_scope is not None:
|
||||
query = query.where(model.id.in_(shows_in_scope))
|
||||
return query
|
||||
|
||||
genre_counts: dict[str, int] = {}
|
||||
cr_counts: dict[str, int] = {}
|
||||
studio_counts: dict[str, int] = {}
|
||||
playable_counts: dict[str, int] = {}
|
||||
years: list[int] = []
|
||||
ratings: list[float] = []
|
||||
durs: list[int] = []
|
||||
|
||||
def per_table(model, ids, kind_movie: bool) -> None:
|
||||
if not ids:
|
||||
return
|
||||
# Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid
|
||||
# OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we
|
||||
# offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND
|
||||
# the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since
|
||||
# every matching title carries them, so they stay togglable.)
|
||||
genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None}
|
||||
# Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON
|
||||
# `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and
|
||||
# jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") —
|
||||
# so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the
|
||||
# whole facets endpoint. Mirror the exact guard people.py already applies to the same columns.
|
||||
# In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)).
|
||||
gcol = genres_col(model, lang)
|
||||
expanded = (
|
||||
wsq(
|
||||
select(func.jsonb_array_elements_text(gcol).label("g")),
|
||||
model,
|
||||
ids,
|
||||
genre_pp,
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
.where(func.jsonb_typeof(gcol) == "array")
|
||||
.subquery()
|
||||
)
|
||||
for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all():
|
||||
genre_counts[g] = genre_counts.get(g, 0) + c
|
||||
# Content ratings — exclude its own.
|
||||
crq = (
|
||||
wsq(
|
||||
select(model.content_rating, func.count()),
|
||||
model,
|
||||
ids,
|
||||
{**p, "content_ratings": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
.where(model.content_rating.isnot(None))
|
||||
.group_by(model.content_rating)
|
||||
)
|
||||
for cr, c in db.execute(crq).all():
|
||||
cr_counts[cr] = cr_counts.get(cr, 0) + c
|
||||
# Studios — exclude its own (each offered studio is a valid OR-addition that only widens).
|
||||
stq = (
|
||||
wsq(
|
||||
select(model.studio, func.count()),
|
||||
model,
|
||||
ids,
|
||||
{**p, "studios": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
.where(model.studio.isnot(None))
|
||||
.group_by(model.studio)
|
||||
)
|
||||
for s, c in db.execute(stq).all():
|
||||
studio_counts[s] = studio_counts.get(s, 0) + c
|
||||
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
|
||||
if kind_movie:
|
||||
pcls = playback_class_expr()
|
||||
pcq = (
|
||||
wsq(
|
||||
select(pcls.label("cls"), func.count()),
|
||||
model,
|
||||
ids,
|
||||
{**p, "playable": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
.where(pcls.isnot(None))
|
||||
.group_by(pcls)
|
||||
)
|
||||
for cls, c in db.execute(pcq).all():
|
||||
playable_counts[cls] = playable_counts.get(cls, 0) + c
|
||||
# Year bounds — exclude the year filter.
|
||||
by = db.execute(
|
||||
wsq(
|
||||
select(func.min(model.year), func.max(model.year)),
|
||||
model,
|
||||
ids,
|
||||
{**p, "year_min": None, "year_max": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
).first()
|
||||
if by[0] is not None:
|
||||
years.append(by[0])
|
||||
if by[1] is not None:
|
||||
years.append(by[1])
|
||||
# Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the
|
||||
# rating filter and the UI badge use (IMDb preferred, audience fallback).
|
||||
br = db.scalar(
|
||||
wsq(
|
||||
select(func.max(func.coalesce(model.imdb_rating, model.rating))),
|
||||
model,
|
||||
ids,
|
||||
{**p, "rating_min": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
)
|
||||
if br is not None:
|
||||
ratings.append(float(br))
|
||||
# Duration bounds (movies) — exclude the duration filter.
|
||||
if kind_movie:
|
||||
bd = db.execute(
|
||||
wsq(
|
||||
select(func.min(model.duration_s), func.max(model.duration_s)),
|
||||
model,
|
||||
ids,
|
||||
p,
|
||||
kind_movie,
|
||||
False,
|
||||
)
|
||||
).first()
|
||||
if bd[0] is not None:
|
||||
durs.append(bd[0])
|
||||
if bd[1] is not None:
|
||||
durs.append(bd[1])
|
||||
|
||||
per_table(PlexItem, movie_ids, True)
|
||||
per_table(PlexShow, show_ids, False)
|
||||
# Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise
|
||||
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
|
||||
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
|
||||
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
|
||||
for g in split_csv(f.genres):
|
||||
genre_counts.setdefault(g, 0)
|
||||
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
|
||||
for s in split_csv(f.studios):
|
||||
studio_counts.setdefault(s, 0)
|
||||
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
|
||||
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
|
||||
for c in split_csv(f.playable):
|
||||
if c in PLAYBACK_CLASSES:
|
||||
playable_counts.setdefault(c, 0)
|
||||
# Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader.
|
||||
alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))}
|
||||
return {
|
||||
"genres": [
|
||||
{"value": g, "count": c}
|
||||
for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]])
|
||||
],
|
||||
"content_ratings": [
|
||||
{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
|
||||
],
|
||||
"studios": [
|
||||
{"value": s, "count": c}
|
||||
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))
|
||||
],
|
||||
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
|
||||
"playable": [
|
||||
{"value": c, "count": playable_counts[c]}
|
||||
for c in PLAYBACK_CLASSES
|
||||
if c in playable_counts
|
||||
],
|
||||
"year_min": min(years) if years else None,
|
||||
"year_max": max(years) if years else None,
|
||||
"rating_max": max(ratings) if ratings else None,
|
||||
"duration_min": min(durs) if durs else None,
|
||||
"duration_max": max(durs) if durs else None,
|
||||
}
|
||||
return plex_library.facets(db, user, scope=scope, show=show, lang=lang, f=f)
|
||||
|
||||
@@ -1,96 +1,41 @@
|
||||
"""Plex collections: browse mirrored collections + the admin manual-collection editor."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime
|
||||
from typing import Any, NamedTuple
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.mailtext import lang_for_user
|
||||
from app.collation import collated, sorted_words
|
||||
from app.collation import sorted_words
|
||||
from app import logs
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
PlexCollection,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.models import User
|
||||
from app.plex import queries, rollup, service
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
||||
from app.utils import like_escape
|
||||
from app.plex.collections import (
|
||||
DEFAULT_MEMBER_SORT,
|
||||
Member,
|
||||
collection_card,
|
||||
collection_cert,
|
||||
collection_editable,
|
||||
collection_source,
|
||||
sort_members,
|
||||
)
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.plex.models import PlexCollection, PlexState
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
|
||||
# members (Plex shows one cert on the collection; we don't store it, so derive it).
|
||||
_CERT_RANK = {
|
||||
"G": 1,
|
||||
"TV-Y": 1,
|
||||
"TV-Y7": 1,
|
||||
"TV-G": 1,
|
||||
"PG": 2,
|
||||
"TV-PG": 2,
|
||||
"PG-13": 3,
|
||||
"TV-14": 3,
|
||||
"R": 4,
|
||||
"TV-MA": 4,
|
||||
"NC-17": 5,
|
||||
}
|
||||
|
||||
|
||||
def _collection_cert(certs: list[str | None]) -> str | None:
|
||||
ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c]
|
||||
return max(ranked, key=lambda x: x[1])[0] if ranked else None
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
|
||||
|
||||
|
||||
def _collection_source(col: PlexCollection) -> str:
|
||||
first = (col.title or "").strip().split(" ", 1)[0].lower()
|
||||
if first in _STRIP_PROVIDERS:
|
||||
return first
|
||||
if col.smart:
|
||||
return "smart"
|
||||
return "collection"
|
||||
|
||||
|
||||
def _collection_editable(c: PlexCollection) -> bool:
|
||||
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
|
||||
smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex."""
|
||||
return bool(c.editable) and not c.smart and _collection_source(c) == "collection"
|
||||
|
||||
|
||||
def _collection_card(c: PlexCollection) -> dict:
|
||||
return {
|
||||
"id": c.rating_key,
|
||||
"title": c.title,
|
||||
"summary": c.summary,
|
||||
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
|
||||
"child_count": c.child_count,
|
||||
"smart": c.smart,
|
||||
"source": _collection_source(c),
|
||||
"editable": c.editable,
|
||||
"can_edit": _collection_editable(
|
||||
c
|
||||
), # whether the edit controls should be offered (admin UI)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/collections")
|
||||
def collections(
|
||||
@@ -110,64 +55,22 @@ def collections(
|
||||
sidebar token and the applied-filter chip had nothing to render but the raw id. Resolving them
|
||||
through this endpoint costs one small row each; pulling the whole list to find two titles is
|
||||
~200 KB."""
|
||||
if library is not None:
|
||||
lib = (
|
||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(library)))
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
if lib is None:
|
||||
return {"collections": []}
|
||||
query = select(PlexCollection).where(PlexCollection.library_id == lib.id)
|
||||
else:
|
||||
enabled_ids = list(
|
||||
db.execute(
|
||||
select(PlexLibrary.id).where(
|
||||
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
if not enabled_ids:
|
||||
return {"collections": []}
|
||||
query = select(PlexCollection).where(PlexCollection.library_id.in_(enabled_ids))
|
||||
term = (q or "").strip()
|
||||
if term:
|
||||
query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%"))
|
||||
if keys is not None:
|
||||
wanted = [k for k in (keys or "").split(",") if k]
|
||||
# An explicit but empty `keys=` asks for nothing — answer nothing, rather than falling through
|
||||
# to "every collection" (which is what an unguarded empty IN () would have to become).
|
||||
if not wanted:
|
||||
return {"collections": []}
|
||||
query = query.where(PlexCollection.rating_key.in_(wanted))
|
||||
rows = (
|
||||
db.execute(
|
||||
query.order_by(
|
||||
PlexCollection.child_count.desc().nullslast(),
|
||||
collated(PlexCollection.title, lang_for_user(user)),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
wanted = [k for k in keys.split(",") if k] if keys is not None else None
|
||||
rows = queries.collections_page(db, library, q, wanted, lang_for_user(user))
|
||||
# A montage of the top titles per tile + the collection's total runtime. Art unions MOVIE and SHOW
|
||||
# members (a collection lives in one library, so exactly one side has art) — a show collection would
|
||||
# otherwise render blank; the collection's own poster is the next fallback, then the colour hash.
|
||||
# Runtime is movie-only (shows have no single duration).
|
||||
movie_ids, show_ids = content_libs(db)
|
||||
keys = [c.rating_key for c in rows]
|
||||
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
|
||||
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
|
||||
runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys)
|
||||
art_m, art_s, runtime = queries.collection_tiles(db, [c.rating_key for c in rows])
|
||||
|
||||
def _art(c: PlexCollection) -> list[str]:
|
||||
def _art(c: "PlexCollection") -> list[str]:
|
||||
member = art_m.get(c.rating_key) or art_s.get(c.rating_key)
|
||||
return member or ([c.rating_key] if c.thumb_key else [])
|
||||
|
||||
return {
|
||||
"collections": [
|
||||
{
|
||||
**_collection_card(c),
|
||||
**collection_card(c),
|
||||
"art": _art(c),
|
||||
"total_runtime_s": runtime.get(c.rating_key, 0),
|
||||
}
|
||||
@@ -185,89 +88,21 @@ def collections_for_item(
|
||||
"""What a movie or show card's "Add to collection" lists: the editable collections of the title's
|
||||
library (a collection lives in one library, and only a manual one can take a title — the same
|
||||
`can_edit` the editor offers), each saying whether the title is in it already, by name."""
|
||||
movie = by_rating_key(db, PlexItem, item_rating_key)
|
||||
title: PlexItem | PlexShow | None = (
|
||||
movie if movie is not None and movie.kind == "movie" else None
|
||||
) or by_rating_key(db, PlexShow, item_rating_key)
|
||||
movie = queries.movie(db, item_rating_key)
|
||||
title = movie or queries.show(db, item_rating_key)
|
||||
if title is None:
|
||||
raise not_found("item")
|
||||
member_of = set(title.collection_keys or [])
|
||||
rows = (
|
||||
db.execute(
|
||||
select(PlexCollection)
|
||||
.where(PlexCollection.library_id == title.library_id)
|
||||
.order_by(collated(PlexCollection.title, lang_for_user(user)))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
rows = queries.library_collections(db, title.library_id, lang_for_user(user))
|
||||
return {
|
||||
"collections": [
|
||||
{**_collection_card(c), "contains": c.rating_key in member_of}
|
||||
{**collection_card(c), "contains": c.rating_key in member_of}
|
||||
for c in rows
|
||||
if _collection_editable(c)
|
||||
if collection_editable(c)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class Member(NamedTuple):
|
||||
"""One collection member flattened to everything the Sort control can order by. The page's two
|
||||
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
|
||||
ordering is a pure list sort over these, which is also what makes it unit-testable."""
|
||||
|
||||
kind: str # "movie" | "show"
|
||||
obj: Any # PlexItem | PlexShow, carried through to the card serializer
|
||||
title: str # lower-cased; never NULL (the column is not nullable)
|
||||
year: int | None
|
||||
rating: float | None
|
||||
added: datetime | None
|
||||
release: date | None
|
||||
duration: int | None # movies only — a show has no single runtime
|
||||
last_watched: datetime | None
|
||||
|
||||
|
||||
# Sort key -> what to read off a Member. Pinned to `PLEX_SORT_KEYS` by a test: the collection page
|
||||
# shows the SAME Sort control as the browse grid, so a key this map is missing would be an option
|
||||
# that silently does nothing here.
|
||||
MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = {
|
||||
"added": lambda m: m.added,
|
||||
"lastwatched": lambda m: m.last_watched,
|
||||
"release": lambda m: m.release,
|
||||
"year": lambda m: m.year,
|
||||
"rating": lambda m: m.rating,
|
||||
"duration": lambda m: m.duration,
|
||||
"title": lambda m: m.title,
|
||||
}
|
||||
|
||||
# What the page ordered by before it had a control, and still its default: a film series wants to be
|
||||
# read in release order, not by whenever Plex happened to ingest it.
|
||||
DEFAULT_MEMBER_SORT = "year"
|
||||
|
||||
|
||||
def sort_members(
|
||||
members: list[Member], sort: str, sort_dir: str, alpha: dict[str, int] | None = None
|
||||
) -> list[Member]:
|
||||
"""Order a collection's members by one of `PLEX_SORT_KEYS`.
|
||||
|
||||
Two properties worth stating because they are easy to get wrong with a plain `reverse=True`:
|
||||
members with no value for the key sink to the END in BOTH directions (matching the browse grid's
|
||||
`nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve
|
||||
title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside
|
||||
it. An unknown key falls back to the page's default instead of returning an arbitrary order.
|
||||
|
||||
`alpha` ranks each title in the reader's alphabet (`collation.sorted_words`); without it, titles
|
||||
compare as Python strings, which puts every accented initial after "z"."""
|
||||
by_title = (lambda m: alpha[m.title]) if alpha is not None else (lambda m: m.title)
|
||||
key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT])
|
||||
if key is MEMBER_SORT_KEYS["title"]:
|
||||
key = by_title
|
||||
ordered = sorted(members, key=by_title) # stable secondary, applied first
|
||||
have = [m for m in ordered if key(m) is not None]
|
||||
missing = [m for m in ordered if key(m) is None]
|
||||
have.sort(key=key, reverse=sort_dir != "asc")
|
||||
return have + missing
|
||||
|
||||
|
||||
@router.get("/collections/{rating_key}")
|
||||
def collection_detail(
|
||||
rating_key: str,
|
||||
@@ -283,45 +118,20 @@ def collection_detail(
|
||||
what this page did before it had a control. Read-only here; edits go through the admin mutation
|
||||
endpoints below."""
|
||||
lang = norm_lang(lang)
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
col = queries.collection(db, rating_key)
|
||||
if col is None:
|
||||
raise not_found("collection")
|
||||
movies = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key]))
|
||||
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
select(PlexShow)
|
||||
.where(PlexShow.collection_keys.contains([rating_key]))
|
||||
.order_by(PlexShow.year.asc().nullslast(), collated(title_col(PlexShow, lang), lang))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
movies, shows = queries.collection_members(db, rating_key, lang)
|
||||
mstate = rollup.item_state_map(db, user.id, [m.id for m in movies])
|
||||
# ONE rollup pass over the show members, for both the aggregate badge status and `resumed_at` —
|
||||
# the browse grid's "last watched" for a show (the newest resume/watched across its episodes). The
|
||||
# cheaper `rollup.state_map` gives only the status, which would leave `lastwatched` unorderable on
|
||||
# a show collection while the control still offered it.
|
||||
show_rows = (
|
||||
db.execute(
|
||||
rollup.agg_subquery(db, user.id, include_resumed=True).where(
|
||||
PlexItem.show_id.in_([s.id for s in shows])
|
||||
)
|
||||
).all()
|
||||
if shows
|
||||
else []
|
||||
)
|
||||
show_rows = queries.show_rollups(db, user.id, [s.id for s in shows])
|
||||
sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows}
|
||||
sresumed = {r.show_id: r.resumed_at for r in show_rows}
|
||||
|
||||
def _movie_last_watched(st: PlexState | None) -> datetime | None:
|
||||
def _movie_last_watched(st: "PlexState | None") -> datetime | None:
|
||||
# Same definition as the browse grid's: when it was last resumed, else when it was marked
|
||||
# watched (coalesce(progress_updated_at, watched_at)).
|
||||
return (st.progress_updated_at or st.watched_at) if st else None
|
||||
@@ -365,8 +175,8 @@ def collection_detail(
|
||||
years = [m.year for m in members if m.year is not None]
|
||||
certs = [m.content_rating for m in movies] + [s.content_rating for s in shows]
|
||||
return {
|
||||
**_collection_card(col),
|
||||
"content_rating": _collection_cert(certs),
|
||||
**collection_card(col),
|
||||
"content_rating": collection_cert(certs),
|
||||
"year_min": min(years) if years else None,
|
||||
"year_max": max(years) if years else None,
|
||||
"total_runtime_s": sum(m.duration_s or 0 for m in movies),
|
||||
@@ -374,11 +184,11 @@ def collection_detail(
|
||||
}
|
||||
|
||||
|
||||
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
def _editable_collection_or_403(db: Session, rating_key: str) -> "PlexCollection":
|
||||
col = queries.collection(db, rating_key)
|
||||
if col is None:
|
||||
raise not_found("collection")
|
||||
if not _collection_editable(col):
|
||||
if not collection_editable(col):
|
||||
raise refused(403, "collection_read_only")
|
||||
return col
|
||||
|
||||
@@ -389,16 +199,7 @@ def _reject_duplicate_name(
|
||||
"""No two collections in a section may share a name: Plex CONFLATES same-named collections'
|
||||
membership (both rating_keys resolve to one merged member list — a real footgun), so forbid it up
|
||||
front on create + rename rather than mirror the mess."""
|
||||
dup = (
|
||||
db.execute(
|
||||
select(PlexCollection).where(
|
||||
PlexCollection.library_id == library_id,
|
||||
func.lower(PlexCollection.title) == title.lower(),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
dup = queries.collection_named(db, library_id, title)
|
||||
if dup is not None and dup.rating_key != exclude_rk:
|
||||
# Structured, so the bilingual UI says it in the reader's language (it was toasted in
|
||||
# Hungarian beside an English error dialog saying the same thing).
|
||||
@@ -441,15 +242,13 @@ def create_collection(
|
||||
seed = body.item_rating_key
|
||||
if not title or not seed:
|
||||
raise refused(400, "collection_fields_required")
|
||||
seed_show = by_rating_key(db, PlexShow, seed)
|
||||
seed_show = queries.show(db, seed)
|
||||
if library:
|
||||
lib = (
|
||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
||||
)
|
||||
lib = queries.library_by_key(db, library)
|
||||
else:
|
||||
# The "New collection" flow only knows the seed title → derive its library.
|
||||
seed_item = by_rating_key(db, PlexItem, seed) or seed_show
|
||||
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||
seed_item = queries.item(db, seed) or seed_show
|
||||
lib = queries.library(db, seed_item.library_id) if seed_item else None
|
||||
if lib is None:
|
||||
raise not_found("library")
|
||||
_reject_duplicate_name(db, lib.id, title)
|
||||
@@ -462,28 +261,16 @@ def create_collection(
|
||||
wrote = True
|
||||
new_rk = str(new.get("ratingKey") or "")
|
||||
col = plex_sync.resync_collection(db, plex, lib, new_rk)
|
||||
col.editable = True
|
||||
service.set_collection_editable(col, True)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
if wrote and new_rk:
|
||||
# It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the
|
||||
# full sync preserves `editable` but creates a missing row read-only, so the collection
|
||||
# would drop out of every "add to" list; and the duplicate-name check reads these rows,
|
||||
# so a retry of the same name would make a second one on Plex. Plex made it with exactly
|
||||
# the seed in it, so the seed's row says so too — otherwise the title showed unticked in
|
||||
# the collection it was just added to, and the collection empty, until that sync.
|
||||
db.add(
|
||||
PlexCollection(
|
||||
rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1
|
||||
)
|
||||
)
|
||||
Model = PlexItem if lib.kind == "movie" else PlexShow
|
||||
if row := by_rating_key(db, Model, seed):
|
||||
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk]))
|
||||
# It is on Plex now, and only the follow-up read failed: keep its row (see the service).
|
||||
service.keep_created_collection(db, lib, new_rk, title, seed)
|
||||
db.commit()
|
||||
raise _write_or_pending_error(wrote, e) from e
|
||||
return _collection_card(col)
|
||||
return collection_card(col)
|
||||
|
||||
|
||||
@router.post("/collections/{rating_key}/items/{item_rating_key}")
|
||||
@@ -494,7 +281,7 @@ def collection_add_item(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
col = _editable_collection_or_403(db, rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
lib = queries.library(db, col.library_id)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
@@ -505,7 +292,7 @@ def collection_add_item(
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise _write_or_pending_error(wrote, e) from e
|
||||
return _collection_card(col)
|
||||
return collection_card(col)
|
||||
|
||||
|
||||
@router.delete("/collections/{rating_key}/items/{item_rating_key}")
|
||||
@@ -516,7 +303,7 @@ def collection_remove_item(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
col = _editable_collection_or_403(db, rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
lib = queries.library(db, col.library_id)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
@@ -527,7 +314,7 @@ def collection_remove_item(
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise _write_or_pending_error(wrote, e) from e
|
||||
return _collection_card(col)
|
||||
return collection_card(col)
|
||||
|
||||
|
||||
class CollectionRenameIn(BaseModel):
|
||||
@@ -546,7 +333,7 @@ def collection_rename(
|
||||
if not title:
|
||||
raise refused(400, "title_required")
|
||||
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
lib = queries.library(db, col.library_id)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
@@ -557,7 +344,7 @@ def collection_rename(
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise _write_or_pending_error(wrote, e) from e
|
||||
return _collection_card(col)
|
||||
return collection_card(col)
|
||||
|
||||
|
||||
@router.delete("/collections/{rating_key}")
|
||||
@@ -565,7 +352,7 @@ def collection_delete(
|
||||
rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
col = _editable_collection_or_403(db, rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
lib = queries.library(db, col.library_id)
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.delete_collection(rating_key)
|
||||
@@ -590,12 +377,12 @@ def collection_set_editable(
|
||||
) -> dict:
|
||||
"""Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections
|
||||
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
col = queries.collection(db, rating_key)
|
||||
if col is None:
|
||||
raise not_found("collection")
|
||||
want = body.editable
|
||||
if want and (col.smart or _collection_source(col) != "collection"):
|
||||
if want and (col.smart or collection_source(col) != "collection"):
|
||||
raise refused(400, "collection_smart")
|
||||
col.editable = want
|
||||
service.set_collection_editable(col, want)
|
||||
db.commit()
|
||||
return _collection_card(col)
|
||||
return collection_card(col)
|
||||
|
||||
@@ -4,52 +4,34 @@ import re
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.collation import collated, sorted_words
|
||||
from app.collation import sorted_words
|
||||
from app import logs
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.discover import queries
|
||||
from app.models import (
|
||||
PlexCollection,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.discover import queries as discover_queries
|
||||
from app.models import User
|
||||
from app.plex import detail as plex_detail
|
||||
from app.plex import people as plex_people
|
||||
from app.plex import rollup, subtitles
|
||||
from app.plex.query import title_col
|
||||
from app.plex import queries, rollup, service, subtitles
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.collections import collection_source
|
||||
from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
||||
from app.routes.plex.collections import _collection_source
|
||||
from app.routes._common import not_found
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.plex.models import PlexItem, PlexSeason
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
|
||||
def _lib_key(db: Session, library_id: int | None) -> str | None:
|
||||
"""The Plex section key for a library id — the info/show page passes it to the admin collection
|
||||
editor (in the unified cross-library view there's no single library prop otherwise)."""
|
||||
if library_id is None:
|
||||
return None
|
||||
lib = db.get(PlexLibrary, library_id)
|
||||
return lib.plex_key if lib else None
|
||||
|
||||
|
||||
_SHOW_ENRICH_TTL_S = 300
|
||||
|
||||
_show_enrich_cache: dict[str, tuple[float, dict, list]] = {}
|
||||
@@ -113,39 +95,12 @@ def show_detail(
|
||||
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
|
||||
lang = _norm_lang(lang)
|
||||
hu = lang == "hu"
|
||||
sh = by_rating_key(db, PlexShow, rating_key)
|
||||
sh = queries.show(db, rating_key)
|
||||
if sh is None:
|
||||
raise not_found("show")
|
||||
seasons = (
|
||||
db.execute(
|
||||
select(PlexSeason)
|
||||
.where(PlexSeason.show_id == sh.id)
|
||||
.order_by(PlexSeason.season_number.asc().nullsfirst())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
eps = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
|
||||
.order_by(
|
||||
PlexItem.season_number.asc().nullsfirst(),
|
||||
PlexItem.episode_number.asc().nullsfirst(),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == user.id,
|
||||
PlexState.item_id.in_([e.id for e in eps] or [0]),
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
seasons = queries.seasons(db, sh)
|
||||
eps = queries.episodes(db, sh)
|
||||
states = rollup.item_state_map(db, user, [e.id for e in eps])
|
||||
by_season: dict[int | None, list] = {}
|
||||
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
|
||||
for e in eps:
|
||||
@@ -163,9 +118,7 @@ def show_detail(
|
||||
meta, related_raw = _show_enrichment(db, sh.rating_key)
|
||||
related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id, lang=lang)
|
||||
if sh.people_enriched_at is None and meta:
|
||||
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta))
|
||||
plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views
|
||||
sh.people_enriched_at = datetime.now(timezone.utc)
|
||||
service.enrich_show(db, sh, meta) # imdb/tagline/full_cast for future views
|
||||
try:
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
|
||||
@@ -198,7 +151,7 @@ def show_detail(
|
||||
"year": sh.year,
|
||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
||||
"library": _lib_key(db, sh.library_id),
|
||||
"library": queries.library_key(db, sh.library_id),
|
||||
"content_rating": sh.content_rating,
|
||||
"rating": sh.rating,
|
||||
"genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [],
|
||||
@@ -234,13 +187,13 @@ def show_next(
|
||||
Asked one show at a time, when a card is hovered or played, rather than carried on every card of a
|
||||
grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays.
|
||||
`next` is null for a show with no episodes."""
|
||||
sh = by_rating_key(db, PlexShow, rating_key)
|
||||
sh = queries.show(db, rating_key)
|
||||
if sh is None:
|
||||
raise not_found("show")
|
||||
return {"next": queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
|
||||
return {"next": discover_queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
|
||||
|
||||
|
||||
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
||||
def _season_block(se: "PlexSeason", cards: list[dict]) -> dict:
|
||||
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
||||
season subpage, read from the same cached show-detail payload)."""
|
||||
roll = rollup.rollup(cards)
|
||||
@@ -265,12 +218,7 @@ def _relatedshow_cards(
|
||||
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
|
||||
if not rks:
|
||||
return []
|
||||
shows = {
|
||||
s.rating_key: s
|
||||
for s in db.execute(
|
||||
select(PlexShow).where(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
|
||||
).scalars()
|
||||
}
|
||||
shows = queries.shows_by_keys(db, rks[:60], exclude)
|
||||
statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()])
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
@@ -285,73 +233,36 @@ def _relatedshow_cards(
|
||||
return out
|
||||
|
||||
|
||||
def _collection_strips(db: Session, it: PlexItem, user_id: int, lang: str) -> list[dict]:
|
||||
def _collection_strips(db: Session, it: "PlexItem", user_id: int, lang: str) -> list[dict]:
|
||||
"""The collections this movie belongs to, each with its member titles (playable cards) — the
|
||||
'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
|
||||
keys = it.collection_keys or []
|
||||
if not keys or it.kind != "movie":
|
||||
return []
|
||||
cols = {
|
||||
c.rating_key: c
|
||||
for c in db.execute(
|
||||
select(PlexCollection).where(PlexCollection.rating_key.in_(keys))
|
||||
).scalars()
|
||||
}
|
||||
cols = {c.rating_key: c for c in queries.collections_by_keys(db, keys)}
|
||||
# Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
|
||||
alpha = {w: i for i, w in enumerate(sorted_words(db, [c.title for c in cols.values()], lang))}
|
||||
ordered = sorted(cols.values(), key=lambda c: (c.child_count or 0, alpha[c.title]))
|
||||
out = []
|
||||
for col in ordered:
|
||||
rk = col.rating_key
|
||||
members = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.where(
|
||||
PlexItem.library_id == it.library_id,
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.collection_keys.contains([rk]),
|
||||
)
|
||||
.order_by(
|
||||
PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang)
|
||||
)
|
||||
.limit(60)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
ids = [m.id for m in members]
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0])
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
members = queries.library_movies_in(db, it.library_id, rk, lang, limit=60)
|
||||
states = rollup.item_state_map(db, user_id, [m.id for m in members])
|
||||
out.append(
|
||||
{
|
||||
"id": rk,
|
||||
"title": col.title,
|
||||
"source": _collection_source(col),
|
||||
"source": collection_source(col),
|
||||
"items": [movie_card(m, states.get(m.id), lang=lang) for m in members],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
|
||||
def _episode_nav(db: Session, it: "PlexItem") -> tuple[str | None, str | None]:
|
||||
if it.kind != "episode" or it.show_id is None:
|
||||
return None, None
|
||||
keys = list(
|
||||
db.execute(
|
||||
select(PlexItem.rating_key)
|
||||
.where(PlexItem.show_id == it.show_id, PlexItem.kind == "episode")
|
||||
.order_by(
|
||||
PlexItem.season_number.asc().nullsfirst(),
|
||||
PlexItem.episode_number.asc().nullsfirst(),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
keys = queries.episode_keys(db, it.show_id)
|
||||
try:
|
||||
i = keys.index(it.rating_key)
|
||||
except ValueError:
|
||||
@@ -398,13 +309,7 @@ def item_detail(
|
||||
prev/next, and the per-user resume position + watch status."""
|
||||
lang = _norm_lang(lang)
|
||||
it = item_or_404(db, rating_key)
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
st = queries.state(db, user, it.id)
|
||||
|
||||
# Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/
|
||||
# tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only
|
||||
@@ -485,11 +390,7 @@ def item_detail(
|
||||
# only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable
|
||||
# and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show.
|
||||
if it.kind == "movie" and it.people_enriched_at is None and meta:
|
||||
cast = plex_people.cast_from_meta(meta)
|
||||
if cast:
|
||||
plex_people.enrich_row_people(db, it, cast)
|
||||
plex_detail.apply_rich_detail(it, meta)
|
||||
it.people_enriched_at = datetime.now(timezone.utc)
|
||||
service.enrich_movie(db, it, meta)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
|
||||
@@ -522,8 +423,8 @@ def item_detail(
|
||||
rich = plex_detail.rich_detail(meta)
|
||||
|
||||
prev_id, next_id = _episode_nav(db, it)
|
||||
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||
season = db.get(PlexSeason, it.season_id) if show and it.season_id else None
|
||||
show = queries.show_by_id(db, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||
season = queries.season_by_id(db, it.season_id) if show and it.season_id else None
|
||||
return {
|
||||
"id": it.rating_key,
|
||||
"kind": it.kind,
|
||||
@@ -538,7 +439,7 @@ def item_detail(
|
||||
"video_bit_depth": it.video_bit_depth,
|
||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||
"library": _lib_key(db, it.library_id),
|
||||
"library": queries.library_key(db, it.library_id),
|
||||
"cast": rich.get("cast", []),
|
||||
"imdb_rating": rich.get("imdb_rating"),
|
||||
"imdb_id": rich.get("imdb_id"),
|
||||
|
||||
@@ -1,391 +1,18 @@
|
||||
"""Plex Discover: the rail-surface read-model.
|
||||
|
||||
A consolidated payload for the dedicated Discover landing — a stack of horizontal rails:
|
||||
Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a
|
||||
separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the
|
||||
recs the TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and
|
||||
every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit
|
||||
their `art` + rating/summary for free). The rails carry EN metadata (like the other non-grid
|
||||
card paths); the HU overlay coalesces only in the main grid.
|
||||
|
||||
The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the
|
||||
Discover surface behaves like the Browse grid — the same `LibraryFilters` / `meta_filter_conds` the
|
||||
grid uses, applied to each rail's movie + show query (a rail may legitimately come back empty, which
|
||||
the client hides). Genre-language matches the grid; duration is movie-only.
|
||||
"""
|
||||
"""Plex Discover: the rail-surface landing and its Surprise-me draw (`plex.discover`)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.collation import sorted_words
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import PlexItem, PlexShow, PlexState, User
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import (
|
||||
SHOW_NEW,
|
||||
display_title,
|
||||
leaf_card,
|
||||
movie_card,
|
||||
show_card,
|
||||
)
|
||||
from app.plex.query import (
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
apply_show_state_filter,
|
||||
content_libs,
|
||||
genres_col,
|
||||
meta_filter_conds,
|
||||
top_art_keys,
|
||||
)
|
||||
from app.discover.queries import RAIL_LIMIT
|
||||
from app.plex.tmdb import resolve_recs
|
||||
from app.models import User
|
||||
from app.plex import discover as plex_discover
|
||||
from app.plex.discover import SURPRISE_PER_KIND
|
||||
from app.plex.query import LibraryFilters
|
||||
from app.routes.plex._shared import _norm_lang
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
GENRE_TILES = 12
|
||||
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
|
||||
|
||||
|
||||
def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
|
||||
"""The movie/show library ids visible for this scope (empty list = out of scope)."""
|
||||
movie_ids, show_ids = content_libs(db)
|
||||
return (
|
||||
movie_ids if scope in ("movie", "both") else [],
|
||||
show_ids if scope in ("show", "both") else [],
|
||||
)
|
||||
|
||||
|
||||
def _movie_meta(q, p: dict, lang: str, f: LibraryFilters):
|
||||
"""Apply the shared metadata filters + movie-only duration to a movie query (same as the grid)."""
|
||||
q = apply_meta_filters(q, PlexItem, p, lang)
|
||||
if f.duration_min is not None:
|
||||
q = q.where(PlexItem.duration_s >= f.duration_min)
|
||||
if f.duration_max is not None:
|
||||
q = q.where(PlexItem.duration_s <= f.duration_max)
|
||||
return q
|
||||
|
||||
|
||||
def _movie_cards(db: Session, uid: int, items: list[PlexItem], lang: str) -> list[dict]:
|
||||
sts = rollup.item_state_map(db, uid, [it.id for it in items])
|
||||
return [movie_card(it, sts.get(it.id), lang=lang) for it in items]
|
||||
|
||||
|
||||
def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]:
|
||||
statuses = rollup.state_map(db, uid, [s.id for s in shows])
|
||||
return [show_card(s, statuses.get(s.id, SHOW_NEW), lang=lang) for s in shows]
|
||||
|
||||
|
||||
def _recently_added(
|
||||
db: Session,
|
||||
uid: int,
|
||||
movie_ids: list[int],
|
||||
show_ids: list[int],
|
||||
p: dict,
|
||||
lang: str,
|
||||
f: LibraryFilters,
|
||||
) -> list[dict]:
|
||||
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
|
||||
movies = (
|
||||
db.execute(
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)
|
||||
),
|
||||
p,
|
||||
lang,
|
||||
f,
|
||||
)
|
||||
.order_by(PlexItem.added_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if movie_ids
|
||||
else []
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
apply_meta_filters(
|
||||
select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang
|
||||
)
|
||||
.order_by(PlexShow.added_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if show_ids
|
||||
else []
|
||||
)
|
||||
# Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the
|
||||
# per-card state lookups stay batched (no N+1). Same `or NO_TS` key as the two rails below — a
|
||||
# review found this one still on the older `(is not None, value)` form, i.e. one rule in two
|
||||
# spellings, with the two comments explaining it differently.
|
||||
tagged = [(m.added_at, "movie", m) for m in movies] + [(s.added_at, "show", s) for s in shows]
|
||||
tagged.sort(key=lambda t: t[0] or rollup.NO_TS, reverse=True)
|
||||
top = tagged[:RAIL_LIMIT]
|
||||
# Key the serialized cards back by rating_key (globally unique in Plex — a movie and a show never
|
||||
# share one), so the merge doesn't depend on object identity staying alive.
|
||||
top_movies = [o for _, kind, o in top if kind == "movie"]
|
||||
top_shows = [o for _, kind, o in top if kind == "show"]
|
||||
mcards = {
|
||||
m.rating_key: c
|
||||
for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang), strict=True)
|
||||
}
|
||||
scards = {
|
||||
s.rating_key: c
|
||||
for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang), strict=True)
|
||||
}
|
||||
return [mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] for _, kind, o in top]
|
||||
|
||||
|
||||
def _recently_watched(
|
||||
db: Session,
|
||||
uid: int,
|
||||
movie_ids: list[int],
|
||||
show_ids: list[int],
|
||||
p: dict,
|
||||
lang: str,
|
||||
f: LibraryFilters,
|
||||
) -> list[dict]:
|
||||
"""Recently watched rail: the titles the user last FULLY watched, newest first. A movie is its own
|
||||
card; a show contributes the specific EPISODE watched (carrying its show title). Movies honour the
|
||||
sidebar filters (like the other rails); episodes carry no filterable metadata, so they don't."""
|
||||
rows: list[tuple[object, dict]] = [] # (watched_at, card)
|
||||
if movie_ids:
|
||||
mq = (
|
||||
select(PlexItem, PlexState)
|
||||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
)
|
||||
for it, st in db.execute(
|
||||
_movie_meta(mq, p, lang, f)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all():
|
||||
rows.append((st.watched_at, leaf_card(it, st, lang=lang)))
|
||||
if show_ids:
|
||||
eq = (
|
||||
select(PlexItem, PlexState, PlexShow)
|
||||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.join(PlexShow, PlexShow.id == PlexItem.show_id)
|
||||
.where(
|
||||
PlexItem.kind == "episode",
|
||||
PlexItem.library_id.in_(show_ids),
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
)
|
||||
for ep, st, sh in db.execute(eq).all():
|
||||
card = leaf_card(
|
||||
ep, st, show_title=display_title(sh, lang), show_rk=sh.rating_key, lang=lang
|
||||
)
|
||||
rows.append((st.watched_at, card))
|
||||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
|
||||
|
||||
def _continue_watching(
|
||||
db: Session,
|
||||
uid: int,
|
||||
movie_ids: list[int],
|
||||
show_ids: list[int],
|
||||
p: dict,
|
||||
lang: str,
|
||||
f: LibraryFilters,
|
||||
) -> list[dict]:
|
||||
"""Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first.
|
||||
A movie's resume time is its own `progress_updated_at`; a show's is the max across its episodes."""
|
||||
rows: list[tuple[object, dict]] = [] # (resumed_at, card)
|
||||
|
||||
if movie_ids:
|
||||
mq = (
|
||||
select(PlexItem, PlexState)
|
||||
.join(
|
||||
PlexState,
|
||||
and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid),
|
||||
)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexState.position_seconds > 0,
|
||||
# A NULL status is still "started" (mirrors apply_movie_state_filter's in_progress) —
|
||||
# `notin_` alone would drop it, since `NULL NOT IN (...)` is NULL, not TRUE.
|
||||
or_(
|
||||
PlexState.status.is_(None),
|
||||
PlexState.status.notin_(["watched", "hidden"]),
|
||||
),
|
||||
)
|
||||
)
|
||||
mrows = db.execute(
|
||||
_movie_meta(mq, p, lang, f)
|
||||
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all()
|
||||
for it, st in mrows:
|
||||
rows.append((st.progress_updated_at, movie_card(it, st, lang=lang)))
|
||||
|
||||
if show_ids:
|
||||
# Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus
|
||||
# its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
|
||||
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
|
||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||
sq = apply_show_state_filter(
|
||||
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
|
||||
prog, prog.c.show_id == PlexShow.id
|
||||
),
|
||||
prog.c.total,
|
||||
prog.c.watched,
|
||||
prog.c.inprog,
|
||||
"in_progress",
|
||||
).where(PlexShow.library_id.in_(show_ids))
|
||||
srows = db.execute(
|
||||
apply_meta_filters(sq, PlexShow, p, lang)
|
||||
.order_by(prog.c.resumed_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all()
|
||||
for sh, resumed_at, total, watched, inprog in srows:
|
||||
state = rollup.show_state(total, watched, inprog)
|
||||
rows.append((resumed_at, show_card(sh, state, lang=lang)))
|
||||
|
||||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
|
||||
|
||||
def _genre_tiles(
|
||||
db: Session, movie_ids: list[int], show_ids: list[int], p: dict, lang: str
|
||||
) -> list[dict]:
|
||||
"""The most-common genres across the scope, as {value, count} tiles → tap to filter the grid.
|
||||
The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid.
|
||||
The active metadata filters narrow the counts too, so the tiles agree with the filtered rails."""
|
||||
counts: dict[str, int] = {}
|
||||
for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)):
|
||||
if not ids:
|
||||
continue
|
||||
gcol = genres_col(model, lang)
|
||||
base = select(func.jsonb_array_elements_text(gcol).label("g")).where(
|
||||
model.library_id.in_(ids),
|
||||
func.jsonb_typeof(gcol) == "array",
|
||||
*meta_filter_conds(model, p, lang),
|
||||
)
|
||||
if model is PlexItem:
|
||||
base = base.where(model.kind == "movie")
|
||||
expanded = base.subquery()
|
||||
for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all():
|
||||
counts[g] = counts.get(g, 0) + c
|
||||
alpha = {w: i for i, w in enumerate(sorted_words(db, list(counts), lang))}
|
||||
top = sorted(counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))[:GENRE_TILES]
|
||||
# A 2×2 poster collage per tile: the top movies in that genre (color hash is the client fallback).
|
||||
art = top_art_keys(
|
||||
db,
|
||||
PlexItem,
|
||||
movie_ids,
|
||||
genres_col(PlexItem, lang),
|
||||
[g for g, _ in top],
|
||||
extra_conds=(PlexItem.kind == "movie", *meta_filter_conds(PlexItem, p, lang)),
|
||||
)
|
||||
return [{"value": g, "count": c, "art": art.get(g, [])} for g, c in top]
|
||||
|
||||
|
||||
def _because_you_watched(
|
||||
db: Session,
|
||||
uid: int,
|
||||
movie_ids: list[int],
|
||||
show_ids: list[int],
|
||||
p: dict,
|
||||
lang: str,
|
||||
f: LibraryFilters,
|
||||
) -> dict | None:
|
||||
"""Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the
|
||||
recs that resolve to OUR library (by guid) become cards — the rest aren't actionable here. Returns
|
||||
None (rail hidden) when there's no enriched watch history or none of its recs are in the library.
|
||||
The metadata filters narrow the resolved recs (not the anchor pick) so the rail matches the grid."""
|
||||
if not movie_ids:
|
||||
return None
|
||||
anchor = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.join(
|
||||
PlexState,
|
||||
and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid),
|
||||
)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexState.status == "watched",
|
||||
PlexItem.recommendations.isnot(None),
|
||||
)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(1)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
if anchor is None:
|
||||
return None
|
||||
resolved = resolve_recs(db, anchor.recommendations, lang=lang)
|
||||
# Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself.
|
||||
rks = [
|
||||
r["rating_key"]
|
||||
for r in resolved
|
||||
if r.get("in_library") and r.get("rating_key") != anchor.rating_key
|
||||
]
|
||||
if not rks:
|
||||
return None
|
||||
movies = {
|
||||
m.rating_key: m
|
||||
for m in db.execute(
|
||||
_movie_meta(
|
||||
select(PlexItem).where(PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)),
|
||||
p,
|
||||
lang,
|
||||
f,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
}
|
||||
shows = {
|
||||
s.rating_key: s
|
||||
for s in db.execute(
|
||||
apply_meta_filters(
|
||||
select(PlexShow).where(PlexShow.rating_key.in_(rks)), PlexShow, p, lang
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
}
|
||||
ordered_movies = [movies[rk] for rk in rks if rk in movies]
|
||||
ordered_shows = [shows[rk] for rk in rks if rk in shows]
|
||||
# rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
|
||||
mcards = {
|
||||
m.rating_key: c
|
||||
for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang), strict=True)
|
||||
}
|
||||
scards = {
|
||||
s.rating_key: c
|
||||
for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang), strict=True)
|
||||
}
|
||||
items: list[dict] = []
|
||||
for rk in rks:
|
||||
if rk in mcards:
|
||||
items.append(mcards[rk])
|
||||
elif rk in scards:
|
||||
items.append(scards[rk])
|
||||
if not items:
|
||||
return None
|
||||
return {
|
||||
"anchor": {"id": anchor.rating_key, "title": display_title(anchor, lang)},
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/discover")
|
||||
def discover(
|
||||
@@ -398,72 +25,7 @@ def discover(
|
||||
"""The Discover landing payload — every rail in one pass (serve-from-DB), narrowed by the active
|
||||
sidebar filters. Empty rails come back as empty lists (the client hides them); `because_you_watched`
|
||||
is null when there's no anchor."""
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
movie_ids, show_ids = _scope_libs(db, scope)
|
||||
return {
|
||||
"scope": scope,
|
||||
"continue_watching": _continue_watching(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"recently_watched": _recently_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"genres": _genre_tiles(db, movie_ids, show_ids, p, lang),
|
||||
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
}
|
||||
|
||||
|
||||
def _draw(db: Session, stmt, key_col, per: int, exclude: list[str]) -> list:
|
||||
"""`per` random rows of `stmt`, the previous draw's (`exclude`) left out while the pool allows: a
|
||||
reshuffle over a small filtered pool kept dealing the same few titles. What the fresh rows leave
|
||||
short is topped up from the excluded ones, so a pool smaller than a draw still fills the rail."""
|
||||
fresh = (
|
||||
db.execute(stmt.where(key_col.not_in(exclude)).order_by(func.random()).limit(per))
|
||||
.scalars()
|
||||
.all()
|
||||
if exclude
|
||||
else []
|
||||
)
|
||||
if len(fresh) == per:
|
||||
return list(fresh)
|
||||
taken = [key_col.not_in([r.rating_key for r in fresh])] if fresh else []
|
||||
rest = db.execute(stmt.where(*taken).order_by(func.random()).limit(per - len(fresh))).scalars()
|
||||
return [*fresh, *rest.all()]
|
||||
|
||||
|
||||
def _kept(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], keep: list[str], lang: str
|
||||
) -> list[dict]:
|
||||
"""The `keep` titles' cards in their given order, as they stand now: a title watched since the draw
|
||||
stays in it, marked watched, and one gone from the scope's libraries drops out."""
|
||||
movies = (
|
||||
db.execute(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexItem.rating_key.in_(keep),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if movie_ids
|
||||
else []
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
select(PlexShow).where(PlexShow.library_id.in_(show_ids), PlexShow.rating_key.in_(keep))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if show_ids
|
||||
else []
|
||||
)
|
||||
cards = {
|
||||
c["id"]: c
|
||||
for c in [
|
||||
*_movie_cards(db, uid, list(movies), lang),
|
||||
*_show_cards(db, uid, list(shows), lang),
|
||||
]
|
||||
}
|
||||
return [cards[k] for k in keep if k in cards]
|
||||
return plex_discover.discover_page(db, user, scope, _norm_lang(lang), f)
|
||||
|
||||
|
||||
@router.get("/surprise")
|
||||
@@ -485,69 +47,4 @@ def surprise(
|
||||
excluded (the point is to surface something to watch). Capped at SURPRISE_PER_KIND movies +
|
||||
SURPRISE_PER_KIND shows, drawn separately then interleaved so a lopsided library doesn't crowd out
|
||||
one type."""
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
movie_ids, show_ids = _scope_libs(db, scope)
|
||||
if keep:
|
||||
return {"scope": scope, "items": _kept(db, user.id, movie_ids, show_ids, keep, lang)}
|
||||
per = min(limit, SURPRISE_PER_KIND)
|
||||
movies = []
|
||||
if movie_ids:
|
||||
# Exclude movies this user has watched.
|
||||
watched_movie = (
|
||||
select(PlexState.id)
|
||||
.where(
|
||||
PlexState.item_id == PlexItem.id,
|
||||
PlexState.user_id == user.id,
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
movies = _draw(
|
||||
db,
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
~watched_movie,
|
||||
),
|
||||
p,
|
||||
lang,
|
||||
f,
|
||||
),
|
||||
PlexItem.rating_key,
|
||||
per,
|
||||
exclude,
|
||||
)
|
||||
shows = []
|
||||
if show_ids:
|
||||
# Exclude fully-watched shows (LEFT JOIN so a never-started show — no rollup row — still counts
|
||||
# as unseen). `total IS NULL / == 0 / watched < total` == "not fully watched".
|
||||
prog = rollup.agg_subquery(db, user.id).subquery()
|
||||
not_fully_watched = or_(
|
||||
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||||
)
|
||||
shows = _draw(
|
||||
db,
|
||||
apply_meta_filters(
|
||||
select(PlexShow)
|
||||
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||||
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||||
PlexShow,
|
||||
p,
|
||||
lang,
|
||||
),
|
||||
PlexShow.rating_key,
|
||||
per,
|
||||
exclude,
|
||||
)
|
||||
mcards = _movie_cards(db, user.id, movies, lang)
|
||||
scards = _show_cards(db, user.id, shows, lang)
|
||||
# Interleave movie/show cards, then trim to `limit`.
|
||||
items: list[dict] = []
|
||||
for i in range(max(len(mcards), len(scards))):
|
||||
if i < len(mcards):
|
||||
items.append(mcards[i])
|
||||
if i < len(scards):
|
||||
items.append(scards[i])
|
||||
return {"scope": scope, "items": items[:limit]}
|
||||
return plex_discover.surprise(db, user, scope, _norm_lang(lang), limit, exclude, keep, f)
|
||||
|
||||
@@ -4,26 +4,18 @@ import threading
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.collation import collated
|
||||
from app import logs, sysconfig
|
||||
from app import sysconfig
|
||||
from app.auth import current_user
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexPerson,
|
||||
PlexShow,
|
||||
User,
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
from app.plex import queries, rollup, service
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card
|
||||
from app.plex.query import content_libs, split_csv, title_col
|
||||
from app.plex.query import content_libs, split_csv
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
|
||||
# How many "Known for" titles the person page shows (the stored pool is deeper; see plex.tmdb).
|
||||
@@ -80,35 +72,13 @@ def people_cards(
|
||||
movie_lib_ids, show_lib_ids = content_libs(db)
|
||||
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
||||
photos = {
|
||||
p.name: p.photo_thumb
|
||||
for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(wanted))).scalars()
|
||||
}
|
||||
out = []
|
||||
for nm in wanted:
|
||||
count = 0
|
||||
if want_movies:
|
||||
count += (
|
||||
db.scalar(
|
||||
select(func.count(PlexItem.id)).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_lib_ids),
|
||||
PlexItem.cast_names.contains([nm]),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if want_shows:
|
||||
count += (
|
||||
db.scalar(
|
||||
select(func.count(PlexShow.id)).where(
|
||||
PlexShow.library_id.in_(show_lib_ids),
|
||||
PlexShow.cast_names.contains([nm]),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
out.append({"name": nm, "photo": photos.get(nm), "count": count})
|
||||
photos = queries.person_photos(db, wanted)
|
||||
movies = movie_lib_ids if want_movies else []
|
||||
shows = show_lib_ids if want_shows else []
|
||||
out = [
|
||||
{"name": nm, "photo": photos.get(nm), "count": queries.cast_count(db, nm, movies, shows)}
|
||||
for nm in wanted
|
||||
]
|
||||
return {"people": out}
|
||||
|
||||
|
||||
@@ -144,24 +114,6 @@ def _should_schedule_enrich(person_id: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _enrich_person_bg(person_id: int) -> None:
|
||||
"""Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs
|
||||
AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's
|
||||
"no live TMDB call at serve time" invariant). The request's session is already closed by then, so
|
||||
open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the
|
||||
next open, and the bio lands from the row by then."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
person = db.get(PlexPerson, person_id)
|
||||
if person is not None:
|
||||
plex_tmdb.enrich_person(db, person)
|
||||
except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user
|
||||
db.rollback()
|
||||
log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/person/{name}")
|
||||
def person_detail(
|
||||
name: str,
|
||||
@@ -183,7 +135,7 @@ def person_detail(
|
||||
fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet.
|
||||
"""
|
||||
lang = norm_lang(lang)
|
||||
person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
|
||||
person = queries.person(db, name)
|
||||
# Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path.
|
||||
# Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded)
|
||||
# can't pile background tasks onto the shared threadpool.
|
||||
@@ -197,53 +149,18 @@ def person_detail(
|
||||
and bool(sysconfig.get_str(db, "tmdb_api_key"))
|
||||
)
|
||||
if enriching and person is not None and _should_schedule_enrich(person.id):
|
||||
background_tasks.add_task(_enrich_person_bg, person.id)
|
||||
background_tasks.add_task(service.enrich_person_bg, person.id)
|
||||
movie_lib_ids, show_lib_ids = content_libs(db)
|
||||
|
||||
movies: list[dict] = []
|
||||
if movie_lib_ids:
|
||||
items = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_lib_ids),
|
||||
or_(
|
||||
PlexItem.cast_names.contains([name]),
|
||||
PlexItem.directors.contains([name]),
|
||||
),
|
||||
)
|
||||
.order_by(
|
||||
PlexItem.year.desc().nullslast(), collated(title_col(PlexItem, lang), lang)
|
||||
)
|
||||
.limit(_PERSON_SECTION_MAX)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
items = queries.filmography(db, "movie", name, movie_lib_ids, lang, _PERSON_SECTION_MAX)
|
||||
states = rollup.item_state_map(db, user.id, [it.id for it in items])
|
||||
movies = [movie_card(it, states.get(it.id), lang=lang) for it in items]
|
||||
|
||||
shows: list[dict] = []
|
||||
if show_lib_ids:
|
||||
show_rows = (
|
||||
db.execute(
|
||||
select(PlexShow)
|
||||
.where(
|
||||
PlexShow.library_id.in_(show_lib_ids),
|
||||
or_(
|
||||
PlexShow.cast_names.contains([name]),
|
||||
PlexShow.directors.contains([name]),
|
||||
),
|
||||
)
|
||||
.order_by(
|
||||
PlexShow.year.desc().nullslast(), collated(title_col(PlexShow, lang), lang)
|
||||
)
|
||||
.limit(_PERSON_SECTION_MAX)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
show_rows = queries.filmography(db, "show", name, show_lib_ids, lang, _PERSON_SECTION_MAX)
|
||||
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
||||
shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows]
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Plex playback: image/subtitle proxies, HLS streaming, and watch-state writes."""
|
||||
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
@@ -16,22 +17,13 @@ from fastapi import (
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user, resolved_user_id
|
||||
from app.auth import current_user, session_user_id
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex import imgcache, subtitles
|
||||
from app.models import User
|
||||
from app.plex import imgcache, queries, service, subtitles
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex import people as plex_people
|
||||
from app.plex import stream as plex_stream
|
||||
from app.plex import watch_sync as plex_watch
|
||||
@@ -40,6 +32,9 @@ from app.routes.plex._shared import item_or_404
|
||||
from app.refusal import refused
|
||||
from app.routes._common import ProgressIn, not_found
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.plex.models import PlexItem
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
|
||||
@@ -47,7 +42,7 @@ def _require_session(request: Request) -> int:
|
||||
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT
|
||||
a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are
|
||||
low-sensitivity; a valid session is enough."""
|
||||
uid, _ = resolved_user_id(request)
|
||||
uid = session_user_id(request)
|
||||
if not uid:
|
||||
raise refused(401, "not_authenticated")
|
||||
return uid
|
||||
@@ -280,14 +275,7 @@ def _push_watch(
|
||||
return
|
||||
# Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit
|
||||
# lands before it settles synced_to_plex (which would otherwise strand that newer value).
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
marker = plex_watch._state_marker(st)
|
||||
marker = plex_watch._state_marker(queries.state(db, user_id, item_id))
|
||||
if action == "resume" and intent is not None:
|
||||
background.add_task(
|
||||
_push_resume_guarded, user_id, item_id, rating_key, time_ms, duration_ms, marker, intent
|
||||
@@ -305,37 +293,6 @@ 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(ProgressIn):
|
||||
"""The shared checkpoint (its bounds included), plus whether the player is leaving the item."""
|
||||
|
||||
@@ -371,7 +328,7 @@ def item_progress(
|
||||
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)
|
||||
flipped = service.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
|
||||
@@ -385,33 +342,17 @@ def item_progress(
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||
return {"ok": True, "status": "watched", "position_seconds": 0}
|
||||
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
st = queries.state(db, user, it.id)
|
||||
|
||||
if position < _PROGRESS_MIN_S:
|
||||
if st is not None and st.status == "new":
|
||||
db.delete(st)
|
||||
elif st is not None:
|
||||
st.position_seconds = 0
|
||||
st.progress_updated_at = now
|
||||
service.clear_progress(db, st, now)
|
||||
db.commit()
|
||||
return {"ok": True, "position_seconds": 0}
|
||||
|
||||
if st is None:
|
||||
st = PlexState(user_id=user.id, item_id=it.id)
|
||||
db.add(st)
|
||||
st.position_seconds = position
|
||||
st.progress_updated_at = now
|
||||
push_now = _should_push_resume(user.id, it.id, now, final)
|
||||
if push_now:
|
||||
# Mark dirty before the (background) push so the reconcile knows to re-push if it fails, and so
|
||||
# `push_state_to_plex` can settle synced_to_plex once Plex confirms.
|
||||
st.synced_to_plex = False
|
||||
# Marked dirty before the (background) push so the reconcile knows to re-push if it fails, and so
|
||||
# `push_state_to_plex` can settle synced_to_plex once Plex confirms.
|
||||
service.checkpoint(db, user, it.id, st, position, now, dirty=push_now)
|
||||
db.commit()
|
||||
if push_now:
|
||||
_push_watch(
|
||||
@@ -447,13 +388,7 @@ def item_state(
|
||||
status = body.status
|
||||
if status not in ("new", "watched", "hidden"):
|
||||
raise refused(400, "invalid_status")
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
st = queries.state(db, user, it.id)
|
||||
prev_status = st.status if st is not None else "new"
|
||||
if status == "new":
|
||||
# Does Plex hold anything for this item that the un-mark has to clear? Two things qualify: a
|
||||
@@ -467,19 +402,12 @@ def item_state(
|
||||
st.status == "watched" or (st.position_seconds or 0) > 0
|
||||
)
|
||||
if st is not None:
|
||||
db.delete(st)
|
||||
service.forget_state(db, st)
|
||||
db.commit()
|
||||
if mirrored_on_plex:
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
||||
return {"ok": True, "status": "new"}
|
||||
if st is None:
|
||||
st = PlexState(user_id=user.id, item_id=it.id)
|
||||
db.add(st)
|
||||
st.status = status
|
||||
if status == "watched":
|
||||
st.watched_at = datetime.now(timezone.utc)
|
||||
st.position_seconds = 0
|
||||
st.synced_to_plex = False
|
||||
service.set_status(db, user, it.id, st, status)
|
||||
db.commit()
|
||||
if status == "watched" and prev_status != "watched":
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||
@@ -490,44 +418,15 @@ def _bulk_state(
|
||||
background: BackgroundTasks,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
eps: list[PlexItem],
|
||||
eps: "list[PlexItem]",
|
||||
watched: bool,
|
||||
) -> int:
|
||||
"""Mark every episode in `eps` watched (or unwatched) for a user, mirroring each change to a
|
||||
linked Plex account in the background (best-effort). Returns how many rows actually changed."""
|
||||
now = datetime.now(timezone.utc)
|
||||
existing = {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == user_id,
|
||||
PlexState.item_id.in_([e.id for e in eps] or [0]),
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
linked Plex account in the background (best-effort). Returns how many rows actually changed.
|
||||
The handler commits before the response — which is when the background push runs, so the push
|
||||
always reads the committed marks."""
|
||||
pushable = plex_watch.link_for_push(db, user_id) is not None
|
||||
to_push: list[tuple[int, str, str]] = []
|
||||
for e in eps:
|
||||
st = existing.get(e.id)
|
||||
prev = st.status if st is not None else "new"
|
||||
if watched:
|
||||
if st is None:
|
||||
st = PlexState(user_id=user_id, item_id=e.id)
|
||||
db.add(st)
|
||||
st.status = "watched"
|
||||
st.watched_at = now
|
||||
st.position_seconds = 0
|
||||
st.synced_to_plex = False
|
||||
if prev != "watched":
|
||||
to_push.append((e.id, e.rating_key, "watched"))
|
||||
else: # → new: drop any state (but keep the user's private "hidden" marks)
|
||||
if st is not None and prev != "hidden":
|
||||
db.delete(st)
|
||||
if prev != "new":
|
||||
to_push.append((e.id, e.rating_key, "unwatched"))
|
||||
# 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()
|
||||
to_push = service.bulk_state(db, user_id, eps, watched)
|
||||
if pushable and to_push:
|
||||
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
||||
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
||||
@@ -548,15 +447,11 @@ def show_state(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""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 = queries.show(db, rating_key)
|
||||
if sh is None:
|
||||
raise not_found("show")
|
||||
watched = body.watched
|
||||
eps = (
|
||||
db.execute(select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode"))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
eps = queries.episodes(db, sh)
|
||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||
db.commit()
|
||||
return {"ok": True, "changed": changed, "watched": watched}
|
||||
@@ -571,15 +466,11 @@ def season_state(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""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 = queries.season(db, rating_key)
|
||||
if se is None:
|
||||
raise not_found("season")
|
||||
watched = body.watched
|
||||
eps = (
|
||||
db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode"))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
eps = queries.season_episodes(db, se)
|
||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||
db.commit()
|
||||
return {"ok": True, "changed": changed, "watched": watched}
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
"""Plex playlists: the current user's personal, orderable playlists of playable items."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexPlaylist,
|
||||
PlexPlaylistItem,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.models import User
|
||||
from app.plex import queries, rollup, service
|
||||
from app.plex.cards import display_title, leaf_card, norm_lang
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes._common import owned_or_404, not_found
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.plex.models import PlexPlaylist
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
|
||||
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 {
|
||||
"id": pl.id,
|
||||
"title": pl.title,
|
||||
@@ -36,13 +29,15 @@ def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _playlist_item_query(pid: int):
|
||||
return (
|
||||
select(PlexPlaylistItem, PlexItem)
|
||||
.join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id)
|
||||
.where(PlexPlaylistItem.playlist_id == pid)
|
||||
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||
)
|
||||
def _card_of(db: Session, pl: "PlexPlaylist") -> dict:
|
||||
return _playlist_card(pl, queries.playlist_count(db, pl.id), queries.playlist_cover(db, pl.id))
|
||||
|
||||
|
||||
def _own_playlist(db: Session, user: User, pid: int, *, lock: bool = False) -> "PlexPlaylist":
|
||||
pl = queries.own_playlist(db, user, pid, lock=lock)
|
||||
if pl is None:
|
||||
raise not_found("playlist")
|
||||
return pl
|
||||
|
||||
|
||||
@router.get("/playlists")
|
||||
@@ -56,49 +51,17 @@ def playlists(
|
||||
`contains`=an item rating_key adds `has_item` per playlist (for the single 'add to playlist' dialog).
|
||||
`contains_group`=a comma-separated list of rating_keys adds `group_in` per playlist (how many of the
|
||||
group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog."""
|
||||
contains_id = None
|
||||
if contains:
|
||||
contains_id = db.scalar(select(PlexItem.id).where(PlexItem.rating_key == str(contains)))
|
||||
contains_id = queries.item_id(db, contains) if contains else None
|
||||
group_ids: set[int] | None = None
|
||||
if contains_group is not None:
|
||||
rks = [s for s in (contains_group or "").split(",") if s]
|
||||
group_ids = set(
|
||||
db.execute(select(PlexItem.id).where(PlexItem.rating_key.in_(rks or [""]))).scalars()
|
||||
)
|
||||
group_ids = queries.item_ids(db, [s for s in (contains_group or "").split(",") if s])
|
||||
out = []
|
||||
for pl in db.execute(
|
||||
select(PlexPlaylist)
|
||||
.where(PlexPlaylist.user_id == user.id)
|
||||
.order_by(PlexPlaylist.updated_at.desc())
|
||||
).scalars():
|
||||
count = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pl.id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
first = db.execute(_playlist_item_query(pl.id)).first()
|
||||
card = _playlist_card(pl, count, first[1].rating_key if first else None)
|
||||
for pl in queries.playlists_of(db, user):
|
||||
card = _card_of(db, pl)
|
||||
if contains_id is not None:
|
||||
card["has_item"] = (
|
||||
db.execute(
|
||||
select(PlexPlaylistItem.id).where(
|
||||
PlexPlaylistItem.playlist_id == pl.id,
|
||||
PlexPlaylistItem.item_id == contains_id,
|
||||
)
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
card["has_item"] = queries.playlist_holds(db, pl.id, {contains_id}) > 0
|
||||
if group_ids is not None:
|
||||
card["group_in"] = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(
|
||||
PlexPlaylistItem.playlist_id == pl.id,
|
||||
PlexPlaylistItem.item_id.in_(group_ids or [0]),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
card["group_in"] = queries.playlist_holds(db, pl.id, group_ids)
|
||||
out.append(card)
|
||||
resp: dict = {"playlists": out}
|
||||
if group_ids is not None:
|
||||
@@ -120,19 +83,10 @@ def create_playlist(
|
||||
title = body.title.strip()
|
||||
if not title:
|
||||
raise refused(400, "title_required")
|
||||
pl = PlexPlaylist(user_id=user.id, title=title)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
seed = body.item_rating_key
|
||||
count = 0
|
||||
thumb_rk = None
|
||||
if seed:
|
||||
it = by_rating_key(db, PlexItem, seed)
|
||||
if it is not None:
|
||||
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0))
|
||||
count, thumb_rk = 1, it.rating_key
|
||||
seed = queries.item(db, body.item_rating_key) if body.item_rating_key else None
|
||||
pl = service.create_playlist(db, user, title, seed)
|
||||
db.commit()
|
||||
return _playlist_card(pl, count, thumb_rk)
|
||||
return _playlist_card(pl, 1 if seed else 0, seed.rating_key if seed else None)
|
||||
|
||||
|
||||
@router.get("/playlists/{pid}")
|
||||
@@ -143,26 +97,10 @@ def playlist_detail(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
lang = norm_lang(lang)
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
rows = db.execute(_playlist_item_query(pid)).all()
|
||||
items = [it for (_pi, it) in rows]
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == user.id,
|
||||
PlexState.item_id.in_([it.id for it in items] or [0]),
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
shows = {
|
||||
sh.id: sh
|
||||
for sh in db.execute(
|
||||
select(PlexShow).where(
|
||||
PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0])
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
pl = _own_playlist(db, user, pid)
|
||||
items = queries.playlist_items(db, pl.id)
|
||||
states = rollup.item_state_map(db, user, [it.id for it in items])
|
||||
shows = queries.shows_by_ids(db, {it.show_id for it in items if it.show_id})
|
||||
cards = [
|
||||
leaf_card(
|
||||
it,
|
||||
@@ -187,28 +125,21 @@ def rename_playlist(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
pl = _own_playlist(db, user, pid)
|
||||
title = body.title.strip()
|
||||
if not title:
|
||||
raise refused(400, "title_required")
|
||||
pl.title = title
|
||||
service.rename_playlist(pl, title)
|
||||
db.commit()
|
||||
count = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pl.id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
first = db.execute(_playlist_item_query(pl.id)).first()
|
||||
return _playlist_card(pl, count, first[1].rating_key if first else None)
|
||||
return _playlist_card(pl, queries.playlist_count(db, pl.id), queries.playlist_cover(db, pl.id))
|
||||
|
||||
|
||||
@router.delete("/playlists/{pid}")
|
||||
def delete_playlist(
|
||||
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
db.delete(pl)
|
||||
pl = _own_playlist(db, user, pid)
|
||||
service.delete_playlist(db, pl)
|
||||
db.commit()
|
||||
return {"deleted": pid}
|
||||
|
||||
@@ -225,27 +156,11 @@ def playlist_add_item(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
# Locked (like every add / reorder here): two adds otherwise read the same max position.
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||
it = by_rating_key(db, PlexItem, body.item_rating_key)
|
||||
pl = _own_playlist(db, user, pid, lock=True)
|
||||
it = queries.item(db, body.item_rating_key)
|
||||
if it is None:
|
||||
raise not_found("item")
|
||||
exists = (
|
||||
db.execute(
|
||||
select(PlexPlaylistItem).where(
|
||||
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
if exists is None:
|
||||
maxpos = db.scalar(
|
||||
select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where(
|
||||
PlexPlaylistItem.playlist_id == pid
|
||||
)
|
||||
)
|
||||
db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1))
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
service.add_item(db, pl, it)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -259,15 +174,10 @@ def playlist_remove_item(
|
||||
) -> dict:
|
||||
# Locked like the adds and the reorder: the list row first, then its items — a remove that
|
||||
# locked an item row before a reorder locked the list deadlocked against it.
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||
it = by_rating_key(db, PlexItem, item_rating_key)
|
||||
pl = _own_playlist(db, user, pid, lock=True)
|
||||
it = queries.item(db, item_rating_key)
|
||||
if it is not None:
|
||||
db.execute(
|
||||
delete(PlexPlaylistItem).where(
|
||||
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id
|
||||
)
|
||||
)
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
service.remove_item(db, pl, it)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -276,25 +186,6 @@ class PlexItemKeysIn(BaseModel):
|
||||
item_rating_keys: list[str] = []
|
||||
|
||||
|
||||
def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
|
||||
"""Resolve rating_keys to PlexItems, preserving the input order (drops unknown keys)."""
|
||||
order = [str(k) for k in rks if str(k)]
|
||||
if not order:
|
||||
return []
|
||||
by_rk = {
|
||||
it.rating_key: it
|
||||
for it in db.execute(select(PlexItem).where(PlexItem.rating_key.in_(order))).scalars()
|
||||
}
|
||||
seen: set[str] = set()
|
||||
out: list[PlexItem] = []
|
||||
for rk in order:
|
||||
it = by_rk.get(rk)
|
||||
if it is not None and rk not in seen:
|
||||
seen.add(rk)
|
||||
out.append(it)
|
||||
return out
|
||||
|
||||
|
||||
class PlexItemAddBulkIn(PlexItemKeysIn):
|
||||
# Where each key goes back (the list page's "Undo" of a removal): an index over the
|
||||
# ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows
|
||||
@@ -309,32 +200,6 @@ class PlexItemAddBulkIn(PlexItemKeysIn):
|
||||
return self
|
||||
|
||||
|
||||
def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int:
|
||||
"""Insert new rows at their indices (ascending, so each index is where the row stood when the
|
||||
rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item
|
||||
already in the list does not move: the add stays idempotent."""
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(PlexPlaylistItem)
|
||||
.where(PlexPlaylistItem.playlist_id == pid)
|
||||
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||
).scalars()
|
||||
)
|
||||
present = {r.item_id for r in rows}
|
||||
added = 0
|
||||
for at, it in sorted(placed, key=lambda p: p[0]):
|
||||
if it.id in present:
|
||||
continue
|
||||
row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0)
|
||||
db.add(row)
|
||||
rows.insert(min(at, len(rows)), row)
|
||||
present.add(it.id)
|
||||
added += 1
|
||||
for i, r in enumerate(rows):
|
||||
r.position = i
|
||||
return added
|
||||
|
||||
|
||||
@router.post("/playlists/{pid}/items/bulk")
|
||||
def playlist_add_bulk(
|
||||
pid: int,
|
||||
@@ -345,56 +210,20 @@ def playlist_add_bulk(
|
||||
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
|
||||
order is preserved for the newly-added ones. With `positions`, each key is put back at its index
|
||||
instead of appended. Returns how many were added + the new total."""
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||
pl = _own_playlist(db, user, pid, lock=True)
|
||||
items = queries.items_by_keys(db, body.item_rating_keys)
|
||||
if body.positions is not None:
|
||||
by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)}
|
||||
by_rk = {it.rating_key: it for it in items}
|
||||
placed = [
|
||||
(at, by_rk[str(rk)])
|
||||
for rk, at in zip(body.item_rating_keys, body.positions, strict=True)
|
||||
if str(rk) in by_rk
|
||||
]
|
||||
added = _insert_at(db, pid, placed)
|
||||
if added:
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
total = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"ok": True, "added": added, "item_count": total}
|
||||
items = _items_by_rks(db, body.item_rating_keys)
|
||||
existing = set(
|
||||
db.execute(
|
||||
select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pid)
|
||||
).scalars()
|
||||
)
|
||||
pos = int(
|
||||
db.scalar(
|
||||
select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where(
|
||||
PlexPlaylistItem.playlist_id == pid
|
||||
)
|
||||
)
|
||||
)
|
||||
added = 0
|
||||
for it in items:
|
||||
if it.id in existing:
|
||||
continue
|
||||
pos += 1
|
||||
db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=pos))
|
||||
existing.add(it.id)
|
||||
added += 1
|
||||
if added:
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
added = service.insert_items_at(db, pl, placed)
|
||||
else:
|
||||
added = service.add_items(db, pl, items)
|
||||
db.commit()
|
||||
total = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"ok": True, "added": added, "item_count": total}
|
||||
return {"ok": True, "added": added, "item_count": queries.playlist_count(db, pid)}
|
||||
|
||||
|
||||
@router.post("/playlists/{pid}/items/remove-bulk")
|
||||
@@ -406,26 +235,11 @@ def playlist_remove_bulk(
|
||||
) -> dict:
|
||||
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
|
||||
removed + the new total."""
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
|
||||
removed = 0
|
||||
if ids:
|
||||
removed = db.execute(
|
||||
delete(PlexPlaylistItem).where(
|
||||
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids)
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
).rowcount
|
||||
if removed:
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
pl = _own_playlist(db, user, pid, lock=True)
|
||||
ids = [it.id for it in queries.items_by_keys(db, body.item_rating_keys)]
|
||||
removed = service.remove_items(db, pl, ids)
|
||||
db.commit()
|
||||
total = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"ok": True, "removed": removed, "item_count": total}
|
||||
return {"ok": True, "removed": removed, "item_count": queries.playlist_count(db, pid)}
|
||||
|
||||
|
||||
@router.put("/playlists/{pid}/order")
|
||||
@@ -436,35 +250,7 @@ def reorder_playlist(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||
order = body.item_rating_keys
|
||||
id_by_rk = {
|
||||
it.rating_key: it.id
|
||||
for it in db.execute(
|
||||
select(PlexItem).where(PlexItem.rating_key.in_(order or [""]))
|
||||
).scalars()
|
||||
}
|
||||
rows = {
|
||||
r.item_id: r
|
||||
for r in db.execute(
|
||||
select(PlexPlaylistItem).where(PlexPlaylistItem.playlist_id == pid)
|
||||
).scalars()
|
||||
}
|
||||
pos = 0
|
||||
seen: set[int] = set()
|
||||
for rk in order:
|
||||
row = rows.get(id_by_rk.get(rk, -1))
|
||||
if row is not None and row.item_id not in seen:
|
||||
row.position = pos
|
||||
pos += 1
|
||||
seen.add(row.item_id)
|
||||
# Rows the payload omitted (added in another tab meanwhile) keep their relative order after
|
||||
# the given ones, so no two rows share a position (the YouTube reorder does the same).
|
||||
for row in sorted(
|
||||
(r for iid, r in rows.items() if iid not in seen), key=lambda r: (r.position, r.id)
|
||||
):
|
||||
row.position = pos
|
||||
pos += 1
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
pl = _own_playlist(db, user, pid, lock=True)
|
||||
service.reorder_playlist(db, pl, body.item_rating_keys)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.userscope import owned_by
|
||||
from app import quota
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
@@ -27,7 +28,7 @@ def _sum(db: Session, *conds) -> int:
|
||||
@router.get("/my-usage")
|
||||
def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
mine = QuotaEvent.user_id == user.id
|
||||
mine = owned_by(QuotaEvent.user_id, user)
|
||||
by_action = {
|
||||
action: int(units)
|
||||
for action, units in db.execute(
|
||||
|
||||
@@ -16,6 +16,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.userscope import owned_by
|
||||
from app import apitokens, audit
|
||||
from app.apitokens import GRANT_COLUMNS, Right
|
||||
from app.audit import AuditAction
|
||||
@@ -86,7 +87,7 @@ def _serialize(db: Session, token: ApiToken) -> dict:
|
||||
@router.get("")
|
||||
def list_tokens(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
tokens = (
|
||||
db.execute(select(ApiToken).where(ApiToken.user_id == user.id).order_by(ApiToken.id))
|
||||
db.execute(select(ApiToken).where(owned_by(ApiToken.user_id, user)).order_by(ApiToken.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
@@ -134,7 +135,7 @@ def create_token(
|
||||
if (epoch or 0) != (user.session_epoch or 0):
|
||||
raise refused(401, "not_authenticated")
|
||||
count = db.execute(
|
||||
select(func.count(ApiToken.id)).where(ApiToken.user_id == user.id)
|
||||
select(func.count(ApiToken.id)).where(owned_by(ApiToken.user_id, user))
|
||||
).scalar_one()
|
||||
if count >= MAX_TOKENS:
|
||||
raise refused(409, "api_token_limit", limit=MAX_TOKENS)
|
||||
|
||||
@@ -8,4 +8,5 @@ every mapper — imports this module.
|
||||
import app.catalog.models # Catalog's tables
|
||||
import app.downloads.models # Capture's tables
|
||||
import app.models # the core tables
|
||||
import app.plex.models # Plex's tables
|
||||
import app.reader.models # noqa: F401 — Reader's tables
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Whose a row is, and who an account is to the others — one place for both.
|
||||
|
||||
**Ownership.** A user-owned table says whose a row is in one column (`user_id`, or `to_user_id` for
|
||||
something handed to them). A unit's reads and writes name that column through `owned_by` and fetch a
|
||||
something handed to them). Every read and write names that column through `owned_by` and fetches a
|
||||
row by id through `owned_row`, never with an inline `Model.user_id == …`: the rule "a row owned by
|
||||
someone else is answered like a missing one" then lives here, and a contract test
|
||||
(`test_unit_fences.py`) holds a switched-on unit to it.
|
||||
someone else is answered like a missing one" then lives here. A contract test
|
||||
(`test_unit_fences.py`) holds every module, core included, to the filter's form; a rule this module
|
||||
cannot say (own or shared, built-in or own) is written out where it applies, with its reason.
|
||||
|
||||
**Recipients.** "An active, non-suspended account" — the download share-by-email and the recipient
|
||||
picker filter on it. Kept here so the two forms (SQL clauses for a query, a Python check for a loaded
|
||||
|
||||
@@ -31,6 +31,7 @@ from functools import partial
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import app.tables # noqa: F401 — every table, before the first query
|
||||
from app import logs
|
||||
from app import logsink
|
||||
from app import sysconfig
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
# scans it, blocking. A security fix, or a yt-dlp release YouTube's changes force, may be ONE pin
|
||||
# bumped in place instead of a full regeneration, which would move every other package too — then
|
||||
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
|
||||
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403).
|
||||
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403);
|
||||
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2).
|
||||
APScheduler==3.11.3
|
||||
Authlib==1.7.2
|
||||
Mako==1.4.1
|
||||
@@ -21,7 +22,7 @@ argon2-cffi-bindings==25.1.0
|
||||
argon2-cffi==25.1.0
|
||||
bgutil-ytdlp-pot-provider==1.3.1
|
||||
brotli==1.2.0
|
||||
certifi==2026.7.22
|
||||
certifi==2026.10.19
|
||||
cffi==2.1.1
|
||||
charset-normalizer==3.5.1
|
||||
click==8.4.2
|
||||
|
||||
@@ -105,6 +105,11 @@ def pytest_configure(config):
|
||||
)
|
||||
|
||||
|
||||
def _write_xact(db) -> str | None:
|
||||
"""The id of the open transaction if it has written anything, else None (a read never takes one)."""
|
||||
return db.scalar(text("SELECT pg_current_xact_id_if_assigned()::text"))
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -114,15 +119,16 @@ def _request_dep(db, unfinished: list[str]):
|
||||
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 rolled back, as production's
|
||||
savepoint released inside the handler commits nothing. What the test itself staged or wrote is
|
||||
committed before the request starts — it is the test's business, not the handler's, and a
|
||||
request then begins with no write open, so any write left at its end is the handler's. A request that raises is rolled back, as production's
|
||||
`close()` rolls it back: without that, what a failing handler flushed but never committed stayed
|
||||
readable to the rest of the test — the one state production cannot be in (an issue created
|
||||
on the tracker before a failed upload was kept only by an explicit commit, and nothing saw it
|
||||
missing)."""
|
||||
|
||||
def dep(conn: HTTPConnection):
|
||||
db.flush()
|
||||
db.commit()
|
||||
state = {"dirty": False}
|
||||
|
||||
def flushed(session, ctx):
|
||||
@@ -138,7 +144,12 @@ def _request_dep(db, unfinished: list[str]):
|
||||
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:
|
||||
# Core DML (`db.execute(update(...))`) fires no flush and stages nothing; the database
|
||||
# itself says whether a write is open — the request began with none, and a transaction
|
||||
# that wrote and was not committed holds an id (see `release_read_connection`). A row
|
||||
# lock (`FOR UPDATE`) takes one too: the handler ends what it locked.
|
||||
core_dml = _write_xact(db) is not None
|
||||
if state["dirty"] or staged or core_dml:
|
||||
unfinished.append(f"{conn.scope.get('method', 'WS')} {conn.url.path}")
|
||||
except Exception:
|
||||
db.rollback()
|
||||
@@ -220,7 +231,7 @@ def _configured(monkeypatch):
|
||||
@pytest.fixture
|
||||
def client(db, override_dep, _configured):
|
||||
"""A TestClient wired to the test DB that goes through the REAL auth/session stack — no
|
||||
`current_user` override — so login, the wallet gate and epoch revoke are exercised end to end.
|
||||
`current_user` override — so login, the one-account session and epoch revoke are exercised end to end.
|
||||
Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates
|
||||
the next on this same instance. No lifespan (the scheduler / HLS sweep stay off)."""
|
||||
override_dep(get_db, db.info["request"])
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
"""Auth / session security core (TEST-1), DB-lane.
|
||||
|
||||
The wallet impersonation gate, epoch-based session revocation, single-use tokens and the
|
||||
enumeration-safe uniform 401 are the security mag, and had zero tests (only `_oauth_redirect_uri` was
|
||||
covered). Starlette's SessionMiddleware round-trips the signed cookie under TestClient, so the whole
|
||||
browser-less flow is exercisable. Uses the real auth stack via the `client` fixture (NO current_user
|
||||
override) so login, the wallet gate and epoch revoke are the code under test.
|
||||
One account per browser session, epoch-based session revocation, single-use tokens and the
|
||||
enumeration-safe uniform 401 are the security mag. Starlette's SessionMiddleware round-trips the
|
||||
signed cookie under TestClient, so the whole browser-less flow is exercisable. Uses the real auth
|
||||
stack via the `client` fixture (NO current_user override) so login, the session and epoch revoke are
|
||||
the code under test.
|
||||
|
||||
Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False}` when logged out, not a
|
||||
401. A revoked session is therefore checked on a PROTECTED endpoint (`/api/feed`), which does 401.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from itsdangerous import TimestampSigner
|
||||
|
||||
from app.auth import ACTIVE_ACCOUNT_HEADER
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
|
||||
_PW = "correct horse battery"
|
||||
@@ -46,74 +50,48 @@ def test_wrong_password_and_unknown_email_give_a_byte_identical_401(client, make
|
||||
assert wrong_pw.json() == unknown.json()
|
||||
|
||||
|
||||
def test_a_non_wallet_account_header_falls_back_to_the_default(client, make_user):
|
||||
# The X-Siftlode-Account header may only re-target an account already signed into THIS browser
|
||||
# (in the session wallet). A header naming someone else must be ignored, not honored — otherwise
|
||||
# it's an impersonation gate hole.
|
||||
make_user(email="alice2@example.com", password=_PW)
|
||||
mallory = make_user(email="mallory@example.com", password=_PW)
|
||||
assert _login(client, "alice2@example.com", _PW).status_code == 200
|
||||
me = client.get("/api/me", headers={ACTIVE_ACCOUNT_HEADER: str(mallory.id)})
|
||||
assert me.status_code == 200
|
||||
assert me.json()["email"] == "alice2@example.com" # still Alice, not Mallory
|
||||
def _cookie(payload: dict) -> str:
|
||||
"""A session cookie signed the way SessionMiddleware signs one — for a cookie an older build wrote."""
|
||||
data = base64.b64encode(json.dumps(payload).encode())
|
||||
return TimestampSigner(str(settings.secret_key)).sign(data).decode()
|
||||
|
||||
|
||||
def test_a_tab_pinned_to_an_account_signed_out_elsewhere_acts_as_nobody(client, make_user):
|
||||
# Two accounts in one browser: a tab pinned to Alice, Bob the default. Alice signs out in another
|
||||
# tab. The pinned tab used to go on acting as the default, Bob — its player checkpoints and pref
|
||||
# saves landed in his account, and its own Sign out signed HIM out.
|
||||
alice = make_user(email="alice3@example.com", password=_PW)
|
||||
def test_signing_in_as_another_account_replaces_the_first(client, make_user):
|
||||
# One account per browser session: a second sign-in is a fresh session, not a second account
|
||||
# beside the first — so signing out leaves nobody signed in here.
|
||||
make_user(email="alice3@example.com", password=_PW)
|
||||
make_user(email="bob3@example.com", password=_PW)
|
||||
assert _login(client, "alice3@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob3@example.com", _PW).status_code == 200 # Bob is now the default
|
||||
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
|
||||
assert client.get("/api/feed", headers=tab).status_code == 200
|
||||
assert client.post("/auth/logout", headers=tab).status_code == 200 # Alice signs out
|
||||
assert _login(client, "bob3@example.com", _PW).status_code == 200
|
||||
assert client.get("/api/me").json()["email"] == "bob3@example.com"
|
||||
|
||||
assert client.get("/api/feed", headers=tab).status_code == 401
|
||||
assert client.post("/auth/logout", headers=tab).status_code == 200
|
||||
assert client.get("/api/feed").status_code == 200 # Bob is still signed in, the session intact
|
||||
# The bootstrap probe still answers as the default: how the stale tab learns who it is now.
|
||||
assert client.get("/api/me", headers=tab).json()["email"] == "bob3@example.com"
|
||||
assert client.post("/auth/logout").status_code == 200
|
||||
assert client.get("/api/feed").status_code == 401
|
||||
assert client.get("/api/me").json() == {"authenticated": False}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ended", ["revoked", "suspended"])
|
||||
def test_a_tab_pinned_to_an_account_whose_session_ended_comes_back_as_the_default(
|
||||
client, make_user, db, ended
|
||||
):
|
||||
# A tab pinned to Alice, Bob the default. Alice's session then ends while she is still in this
|
||||
# browser's wallet ("log out everywhere" on another device, or an admin suspends her). The tab's
|
||||
# bootstrap probe used to answer "signed out": the landing page with Bob still signed in, and no way
|
||||
# off it but closing the tab (signing in as anyone else there kept the tab pinned to Alice).
|
||||
def test_no_header_or_parameter_picks_another_account(client, make_user):
|
||||
# Alice signed in here before Bob: nothing a request carries makes it act as her any more.
|
||||
alice = make_user(email="alice4@example.com", password=_PW)
|
||||
make_user(email="bob4@example.com", password=_PW)
|
||||
assert _login(client, "alice4@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob4@example.com", _PW).status_code == 200
|
||||
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
|
||||
assert client.get("/api/feed", headers=tab).status_code == 200
|
||||
if ended == "revoked":
|
||||
alice.session_epoch = (alice.session_epoch or 0) + 1
|
||||
else:
|
||||
alice.is_suspended = True
|
||||
db.commit()
|
||||
me = client.get(f"/api/me?account={alice.id}", headers={"X-Siftlode-Account": str(alice.id)})
|
||||
assert me.json()["email"] == "bob4@example.com"
|
||||
|
||||
assert client.get("/api/me", headers=tab).json()["email"] == "bob4@example.com"
|
||||
assert client.get("/api/feed", headers=tab).status_code == 401 # still nobody, never Bob
|
||||
assert client.get("/api/feed").status_code == 200 # Bob's session is intact
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/me/accounts", "/api/me/switch"])
|
||||
def test_there_is_no_account_list_or_switch(client, make_user, path):
|
||||
make_user(email="alice5@example.com", password=_PW)
|
||||
assert _login(client, "alice5@example.com", _PW).status_code == 200
|
||||
r = client.get(path) if path.endswith("accounts") else client.post(path, json={"user_id": 1})
|
||||
assert r.status_code in (404, 405)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ended", ["revoked", "suspended", "deactivated"])
|
||||
def test_switching_to_an_account_whose_session_ended_refuses_it_and_drops_it_from_the_list(
|
||||
client, make_user, db, monkeypatch, ended
|
||||
):
|
||||
# The account switcher lists this browser's wallet. A revoked account was dropped from it on a refused
|
||||
# switch, but a suspended one stayed, refused on every click; a deactivated one was not refused at all —
|
||||
# made the default, the next request found it inactive and signed every account here out.
|
||||
monkeypatch.setattr("app.routes.me.is_allowed", lambda _db, _email: True) # both hold an invite
|
||||
alice = make_user(email="alice5@example.com", password=_PW)
|
||||
make_user(email="bob5@example.com", password=_PW)
|
||||
assert _login(client, "alice5@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob5@example.com", _PW).status_code == 200
|
||||
def test_a_session_whose_account_ended_signs_the_browser_out(client, make_user, db, ended):
|
||||
alice = make_user(email="alice6@example.com", password=_PW)
|
||||
assert _login(client, "alice6@example.com", _PW).status_code == 200
|
||||
if ended == "revoked":
|
||||
alice.session_epoch = (alice.session_epoch or 0) + 1
|
||||
elif ended == "suspended":
|
||||
@@ -121,10 +99,111 @@ def test_switching_to_an_account_whose_session_ended_refuses_it_and_drops_it_fro
|
||||
else:
|
||||
alice.is_active = False
|
||||
db.commit()
|
||||
assert client.get("/api/feed").status_code == 401
|
||||
assert "user_id" not in _session(client) # the refusal ended the session, not just this request
|
||||
assert client.get("/api/me").json() == {"authenticated": False}
|
||||
|
||||
assert client.post("/api/me/switch", json={"user_id": alice.id}).status_code == 403
|
||||
assert [a["email"] for a in client.get("/api/me/accounts").json()] == ["bob5@example.com"]
|
||||
assert client.get("/api/feed").status_code == 200 # Bob is still signed in
|
||||
|
||||
def test_a_cookie_from_the_multi_account_build_signs_in_again(client, make_user):
|
||||
# The old session held a wallet and one epoch per account. It is not read: whoever holds one
|
||||
# signs in once more.
|
||||
alice = make_user(email="alice7@example.com", password=_PW)
|
||||
old = {"user_id": alice.id, "account_ids": [alice.id], "epochs": {str(alice.id): 0}}
|
||||
client.cookies.set("session", _cookie(old))
|
||||
assert client.get("/api/feed").status_code == 401
|
||||
assert client.get("/api/me").json() == {"authenticated": False}
|
||||
|
||||
|
||||
def test_deleting_the_account_signs_the_browser_out(client, make_user):
|
||||
make_user(email="alice8@example.com", password=_PW)
|
||||
assert _login(client, "alice8@example.com", _PW).status_code == 200
|
||||
assert client.delete("/api/me/account").status_code == 200
|
||||
# The cookie names nobody now: the probe answers signed out without looking the account up.
|
||||
assert "user_id" not in _session(client)
|
||||
|
||||
|
||||
def _session(client) -> dict:
|
||||
"""The decoded payload of the client's session cookie."""
|
||||
raw = client.cookies.get("session")
|
||||
if not raw:
|
||||
return {}
|
||||
data = TimestampSigner(str(settings.secret_key)).unsign(raw.encode())
|
||||
return json.loads(base64.b64decode(data))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("signed_in_as", ["starter", "someone_else"])
|
||||
def test_a_google_link_lands_only_on_the_account_still_signed_in(db, make_user, signed_in_as):
|
||||
# The link marker names who started it; a sign-in as someone else before the callback must not
|
||||
# get that Google identity attached to the account that started it from the other person's session.
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.routes.auth import _complete_link
|
||||
|
||||
starter, other = make_user(), make_user()
|
||||
session = {"user_id": starter.id if signed_in_as == "starter" else other.id, "epoch": 0}
|
||||
request = Request({"type": "http", "session": session, "headers": []})
|
||||
resp = _complete_link(request, db, starter.id, True, "sub-123", {"email": ""}, {"scope": ""})
|
||||
db.refresh(starter)
|
||||
if signed_in_as == "starter":
|
||||
assert resp.headers["location"] == "/?link=ok"
|
||||
assert starter.google_sub == "sub-123"
|
||||
else:
|
||||
assert resp.headers["location"] == "/?link=error"
|
||||
assert starter.google_sub is None
|
||||
|
||||
|
||||
def test_no_app_code_reads_a_second_account():
|
||||
# The multi-account machinery is gone; a module reading its session keys or its header again
|
||||
# would bring back a session that names more than one account.
|
||||
app_dir = Path(__file__).parent.parent / "app"
|
||||
hits = [
|
||||
f"{path.relative_to(app_dir)}: {word}"
|
||||
for path in app_dir.rglob("*.py")
|
||||
for word in ("account_ids", '"epochs"', "x-siftlode-account", 'query_params.get("account")')
|
||||
if word in path.read_text(encoding="utf-8").lower()
|
||||
]
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_a_tab_built_for_the_account_signed_in_before_is_refused(client, make_user):
|
||||
# Two tabs of one browser: one signed in as someone else, so the cookie is Bob's under both. The
|
||||
# tab still showing Alice says so with every request; acting as Bob there would put her player
|
||||
# positions, pref saves and Sign out on his account. Refused, and the session stays Bob's.
|
||||
alice = make_user(email="alice5@example.com", password=_PW)
|
||||
bob = make_user(email="bob5@example.com", password=_PW)
|
||||
assert _login(client, "alice5@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob5@example.com", _PW).status_code == 200
|
||||
|
||||
stale = client.get("/api/feed", headers={"X-Siftlode-Expect-Account": str(alice.id)})
|
||||
assert stale.status_code == 401
|
||||
assert stale.json()["detail"]["reason"] == "account_changed"
|
||||
assert (
|
||||
client.get("/api/feed", headers={"X-Siftlode-Expect-Account": str(bob.id)}).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/api/feed").status_code == 200
|
||||
# Nor may its Sign out end Bob's session.
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/logout", headers={"X-Siftlode-Expect-Account": str(alice.id)}
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
# The probe answers who is signed in now, so the stale tab can reload as Bob.
|
||||
probe = client.get("/api/me", headers={"X-Siftlode-Expect-Account": str(alice.id)})
|
||||
assert probe.json()["email"] == "bob5@example.com"
|
||||
|
||||
|
||||
def test_setting_a_password_keeps_the_caller_signed_in(client, make_user):
|
||||
# The change ends every OTHER session (the epoch moves); the caller is stamped with the new one.
|
||||
make_user(email="dave@example.com", password=_PW)
|
||||
assert _login(client, "dave@example.com", _PW).status_code == 200
|
||||
r = client.post(
|
||||
"/auth/set-password",
|
||||
json={"password": "a brand new passphrase", "current_password": _PW},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/feed").status_code == 200
|
||||
|
||||
|
||||
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
|
||||
|
||||
@@ -8,6 +8,8 @@ drops an unknown id, so the view silently showed the whole feed under its old na
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.catalog.models import Channel
|
||||
from app.db import SessionLocal
|
||||
from app.sync.autotag import run_autotag_all
|
||||
from app.reader.models import ChannelTag, SavedView, Tag
|
||||
from app.reader import service as reader_service
|
||||
|
||||
@@ -66,3 +68,15 @@ def test_a_view_kept_tag_list_larger_than_a_bind_parameter_limit_still_cleans_up
|
||||
db.commit()
|
||||
assert reader_service.prune_orphan_system_tags(db) == 1
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_the_job_commits_its_cleanup(db):
|
||||
"""The prune only flushes; the job ends its own transaction. Read from a session of its own,
|
||||
the orphan is gone only once the job committed — the job's session would see its own delete
|
||||
either way."""
|
||||
orphan = Tag(user_id=None, name="Cooking", category="topic")
|
||||
db.add(orphan)
|
||||
db.commit()
|
||||
assert run_autotag_all(db, only_missing=True)["orphan_tags_removed"] == 1
|
||||
with SessionLocal() as fresh:
|
||||
assert fresh.execute(select(Tag).where(Tag.name == "Cooking")).first() is None
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.downloads import errors as dlerrors
|
||||
from app.downloads import formats, pipeline, quota, runner, service
|
||||
from app.downloads import probe as probemod
|
||||
from app.downloads.edit import INPUT_GUARD
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexShow
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.downloads.models import DownloadJob, MediaAsset
|
||||
from app.plex import paths as plex_paths
|
||||
|
||||
@@ -9,7 +9,8 @@ from app.auth import current_user
|
||||
from app.collation import sorted_words
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexCollection, PlexItem, PlexLibrary
|
||||
from app.catalog.models import Channel, Subscription, Video
|
||||
from app.reader.models import Playlist, PlaylistItem
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -17,7 +17,8 @@ from app.discover import hero, pins, queries, rails
|
||||
from app.discover.budget import BANDS, fits, remaining_seconds
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import leaf_card
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState
|
||||
from app.catalog.models import BlockedChannel, Channel, Subscription, Video
|
||||
from app.reader.models import ComfortPin, Playlist, PlaylistItem, VideoState
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ contradicts the remote's own count is not believed either.
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary
|
||||
from app.plex.models import PlexCollection, PlexItem, PlexLibrary
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.reader.models import Playlist, PlaylistItem
|
||||
from app.plex import sync as plex_sync
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Every process the app runs as sees every table before its first query.
|
||||
|
||||
The units declare their tables in their own `models` modules, and a core model names a unit's by
|
||||
string (`User.subscriptions` → `Subscription`). A fresh interpreter that never imported the unit's
|
||||
module fails to configure its mappers on its first query: the download child once did exactly that,
|
||||
and every job failed, while this suite stayed green — it imports `app.tables` in-process, which hides
|
||||
the gap. So each entry point is imported here in a clean interpreter, and must register the whole
|
||||
schema on its own.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import app.tables # noqa: F401
|
||||
from app.db import Base
|
||||
|
||||
BACKEND = Path(__file__).parent.parent
|
||||
APP = BACKEND / "app"
|
||||
|
||||
# Each `python -m` module the deployment starts, and the API's app module.
|
||||
ENTRY_POINTS = ("app.main", "app.worker", "app.downloads.runner")
|
||||
|
||||
# The table list goes out on one marked line: the API's logging writes to the same stdout, and a
|
||||
# line it logs while importing is not the probe's answer.
|
||||
_MARK = "entry-point-tables:"
|
||||
_PROBE = (
|
||||
"import importlib, json, sys\n"
|
||||
"importlib.import_module(sys.argv[1])\n"
|
||||
"from sqlalchemy.orm import configure_mappers\n"
|
||||
"from app.db import Base\n"
|
||||
"configure_mappers()\n"
|
||||
f"print({_MARK!r} + json.dumps(sorted(Base.metadata.tables)))\n"
|
||||
)
|
||||
|
||||
|
||||
def test_each_entry_point_registers_the_whole_schema_on_its_own():
|
||||
whole = sorted(Base.metadata.tables)
|
||||
for module in ENTRY_POINTS:
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-c", _PROBE, module],
|
||||
cwd=BACKEND,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
assert r.returncode == 0, (module, r.stderr[-2000:])
|
||||
answer = [ln for ln in r.stdout.splitlines() if ln.startswith(_MARK)]
|
||||
assert len(answer) == 1, (module, r.stdout[-2000:])
|
||||
assert json.loads(answer[0].removeprefix(_MARK)) == whole, module
|
||||
|
||||
|
||||
def test_every_module_that_runs_as_a_process_is_listed():
|
||||
mains = set()
|
||||
for path in APP.rglob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if (
|
||||
isinstance(node, ast.If)
|
||||
and isinstance(node.test, ast.Compare)
|
||||
and isinstance(node.test.left, ast.Name)
|
||||
and node.test.left.id == "__name__"
|
||||
):
|
||||
rel = path.relative_to(BACKEND).with_suffix("").as_posix()
|
||||
mains.add(rel.replace("/", "."))
|
||||
assert mains <= set(ENTRY_POINTS), mains - set(ENTRY_POINTS)
|
||||
@@ -124,6 +124,9 @@ def test_only_an_own_folder_is_renamed_and_only_by_its_user(db, make_user, as_us
|
||||
assert clash.json()["detail"]["reason"] == "file_folder_name_taken"
|
||||
no = c.patch(f"/api/files/folders/{src.id}", json={"name": "YouTube 2"})
|
||||
assert no.status_code == 409 and no.json()["detail"]["reason"] == "file_folder_not_own"
|
||||
# Which folder is answered before what the name is: a blank one is still "not yours to name".
|
||||
blank = c.patch(f"/api/files/folders/{src.id}", json={"name": " "})
|
||||
assert blank.status_code == 409 and blank.json()["detail"]["reason"] == "file_folder_not_own"
|
||||
stranger = as_user(make_user()).patch(f"/api/files/folders/{own.id}", json={"name": "X"})
|
||||
assert stranger.status_code == 404
|
||||
db.refresh(own)
|
||||
|
||||
@@ -7,6 +7,7 @@ the refused sign-in that doubles as an access request, and the link flow's confl
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from authlib.integrations.starlette_client import OAuthError
|
||||
from sqlalchemy import select
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
@@ -19,15 +20,20 @@ _PW = "correct horse battery"
|
||||
|
||||
class _FakeGoogle:
|
||||
"""authlib's client, reduced to the two calls the routes make: the redirect out to Google and
|
||||
the token exchange when Google redirects back."""
|
||||
the token exchange when Google redirects back. Like authlib, it honours only the newest flow's
|
||||
state: starting a flow retires every earlier one."""
|
||||
|
||||
def __init__(self, userinfo: dict):
|
||||
self.userinfo = userinfo
|
||||
self.asked: dict = {}
|
||||
|
||||
async def authorize_redirect(self, _request, _redirect_uri, **_kw):
|
||||
async def authorize_redirect(self, _request, _redirect_uri, **kw):
|
||||
self.asked = kw
|
||||
return RedirectResponse(url="/fake-google")
|
||||
|
||||
async def authorize_access_token(self, _request):
|
||||
async def authorize_access_token(self, request):
|
||||
if request.query_params.get("state") != self.asked.get("state"):
|
||||
raise OAuthError(error="mismatching_state")
|
||||
return {"userinfo": self.userinfo, "access_token": "at", "scope": "openid email profile"}
|
||||
|
||||
|
||||
@@ -44,9 +50,13 @@ def google(monkeypatch):
|
||||
return fake
|
||||
|
||||
|
||||
def _sign_in(client, google, **userinfo):
|
||||
def _sign_in(client, google, state: str | None = None, **userinfo):
|
||||
"""Google redirecting back: by default with the state of the flow started last."""
|
||||
google.userinfo = {"email_verified": True, **userinfo}
|
||||
return client.get("/auth/callback", follow_redirects=False)
|
||||
state = state or google.asked.get("state")
|
||||
return client.get(
|
||||
"/auth/callback", params={"state": state} if state else {}, follow_redirects=False
|
||||
)
|
||||
|
||||
|
||||
def _rows(db, model, column, value):
|
||||
@@ -218,12 +228,20 @@ def test_a_refused_sign_in_records_a_request_in_the_reported_language(
|
||||
assert _rows(db, User, User.email, "new@example.com") == []
|
||||
|
||||
|
||||
def _start_link(client, email: str):
|
||||
def _password_login(client, email: str):
|
||||
assert (
|
||||
client.post("/auth/password-login", json={"email": email, "password": _PW}).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/auth/link", follow_redirects=False).headers["location"] == "/fake-google"
|
||||
|
||||
|
||||
def _connect(client, access: str):
|
||||
return client.get(f"/auth/google/connect?access={access}", follow_redirects=False)
|
||||
|
||||
|
||||
def _start_link(client, email: str):
|
||||
_password_login(client, email)
|
||||
assert _connect(client, "identity").headers["location"] == "/fake-google"
|
||||
|
||||
|
||||
def test_linking_an_identity_another_account_holds_is_a_conflict(client, db, google, make_user):
|
||||
@@ -258,3 +276,151 @@ def test_linking_a_free_identity_attaches_it(client, db, google, make_user):
|
||||
assert r.headers["location"] == "/?link=ok"
|
||||
db.refresh(me)
|
||||
assert (me.google_sub, me.google_email) == ("sub-free", "me3.google@example.com")
|
||||
|
||||
|
||||
def test_a_connect_abandoned_at_google_leaves_the_next_sign_in_a_sign_in(
|
||||
client, db, google, make_user, monkeypatch
|
||||
):
|
||||
# A connect left at Google's screen (Back, a closed tab) never reaches the callback that pops
|
||||
# its marker. "Sign in as someone else" afterwards must sign that person in, not link their
|
||||
# Google identity onto the account still signed in here.
|
||||
me = make_user(email="me4@example.com")
|
||||
make_user(email="them4@example.com", google_sub="sub-them4", password=None)
|
||||
monkeypatch.setattr(settings, "allowed_emails", "them4@example.com")
|
||||
_start_link(client, "me4@example.com")
|
||||
assert client.get("/auth/login", follow_redirects=False).headers["location"] == "/fake-google"
|
||||
|
||||
r = _sign_in(client, google, sub="sub-them4", email="them4@example.com")
|
||||
|
||||
assert r.headers["location"] == "/"
|
||||
assert client.get("/api/me").json()["email"] == "them4@example.com"
|
||||
db.refresh(me)
|
||||
assert me.google_sub is None
|
||||
|
||||
|
||||
def test_a_link_whose_session_was_revoked_meanwhile_does_not_finish(client, db, google, make_user):
|
||||
# "Log out everywhere" (or a password change) between the start and the callback: the cookie
|
||||
# still names the account, but its session no longer holds, so it may not attach an identity.
|
||||
me = make_user(email="me5@example.com")
|
||||
_start_link(client, "me5@example.com")
|
||||
me.session_epoch = (me.session_epoch or 0) + 1
|
||||
db.commit()
|
||||
|
||||
r = _sign_in(client, google, sub="sub-late", email="late@example.com")
|
||||
|
||||
assert r.headers["location"] == "/?link=error"
|
||||
db.refresh(me)
|
||||
assert me.google_sub is None
|
||||
|
||||
|
||||
def test_an_older_flow_coming_back_leaves_the_connect_in_flight_a_link(
|
||||
client, db, google, make_user
|
||||
):
|
||||
# Two round-trips from one browser: an older one (another tab's, or Back to Google's screen)
|
||||
# returns after a connect started. Its state is refused, and it must not spend the connect's
|
||||
# marker: the connect itself, coming back, would then land as a plain sign-in.
|
||||
me = make_user(email="me6@example.com")
|
||||
_start_link(client, "me6@example.com")
|
||||
|
||||
older = _sign_in(client, google, state="an-older-flow", sub="sub-6", email="g6@example.com")
|
||||
assert older.headers["location"] == "/?oauth=cancelled"
|
||||
r = _sign_in(client, google, sub="sub-6", email="g6@example.com")
|
||||
|
||||
assert r.headers["location"] == "/?link=ok"
|
||||
db.refresh(me)
|
||||
assert me.google_sub == "sub-6"
|
||||
|
||||
|
||||
def test_a_connect_from_a_tab_showing_another_account_starts_nothing(client, google, make_user):
|
||||
# A navigation carries no headers, so the tab names the account it was built for in the query.
|
||||
# One still showing the account signed in before must not start a link onto the one signed in
|
||||
# now: it lands on the app, which boots as that one and says why.
|
||||
me = make_user(email="me7@example.com")
|
||||
_password_login(client, "me7@example.com")
|
||||
|
||||
r = client.get(
|
||||
f"/auth/google/connect?access=identity&expect={me.id + 1}", follow_redirects=False
|
||||
)
|
||||
|
||||
assert r.headers["location"] == auth_routes.ACCOUNT_CHANGED_LANDING == "/?account=changed"
|
||||
assert google.asked == {}
|
||||
assert client.get("/api/me").json()["email"] == "me7@example.com"
|
||||
|
||||
|
||||
def test_a_connect_naming_the_signed_in_account_goes_to_google(client, google, make_user):
|
||||
me = make_user(email="me8@example.com")
|
||||
_password_login(client, "me8@example.com")
|
||||
|
||||
r = client.get(f"/auth/google/connect?access=identity&expect={me.id}", follow_redirects=False)
|
||||
|
||||
assert r.headers["location"] == "/fake-google"
|
||||
assert google.asked["state"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("access", "scope", "prompt"),
|
||||
[
|
||||
("identity", "openid email profile", "select_account"),
|
||||
("read", auth_routes.READ_SCOPES, "consent"),
|
||||
("write", auth_routes.WRITE_SCOPES, "consent"),
|
||||
],
|
||||
)
|
||||
def test_one_connect_route_asks_google_for_what_the_access_needs(
|
||||
client, google, make_user, access, scope, prompt
|
||||
):
|
||||
make_user(email=f"ask-{access}@example.com")
|
||||
_password_login(client, f"ask-{access}@example.com")
|
||||
|
||||
assert _connect(client, access).headers["location"] == "/fake-google"
|
||||
|
||||
assert (google.asked["scope"], google.asked["prompt"]) == (scope, prompt)
|
||||
assert google.asked["access_type"] == "offline"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access", ["read", "write"])
|
||||
def test_a_youtube_grant_attaches_to_the_password_account_and_returns_to_the_app(
|
||||
client, db, google, make_user, access
|
||||
):
|
||||
me = make_user(email=f"grant-{access}@example.com")
|
||||
_password_login(client, f"grant-{access}@example.com")
|
||||
_connect(client, access)
|
||||
|
||||
r = _sign_in(client, google, sub=f"sub-grant-{access}", email=f"g-{access}@example.com")
|
||||
|
||||
# Back to the app, not the "linked" notice: the onboarding wizard picks up where it was.
|
||||
assert r.headers["location"] == "/"
|
||||
db.refresh(me)
|
||||
assert me.google_sub == f"sub-grant-{access}"
|
||||
assert _rows(db, User, User.google_sub, f"sub-grant-{access}") == [me]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access", ["", "admin", "READ"])
|
||||
def test_an_unknown_access_starts_no_connect(client, google, make_user, access):
|
||||
make_user(email="odd@example.com")
|
||||
_password_login(client, "odd@example.com")
|
||||
|
||||
assert _connect(client, access).status_code == 422
|
||||
assert google.asked == {}
|
||||
|
||||
|
||||
def test_a_connect_needs_a_signed_in_account(client, google):
|
||||
assert _connect(client, "identity").status_code == 401
|
||||
assert google.asked == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access", ["identity", "read", "write"])
|
||||
def test_without_google_configured_a_connect_says_it_failed(client, make_user, monkeypatch, access):
|
||||
monkeypatch.setattr(auth_routes, "google_oauth", lambda _db: None)
|
||||
make_user(email=f"nog-{access}@example.com")
|
||||
_password_login(client, f"nog-{access}@example.com")
|
||||
|
||||
assert _connect(client, access).headers["location"] == "/?link=error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/auth/link", "/auth/upgrade", "/auth/upgrade?access=write"])
|
||||
def test_the_old_connect_routes_are_gone(client, google, make_user, path):
|
||||
make_user(email="old@example.com")
|
||||
_password_login(client, "old@example.com")
|
||||
|
||||
assert client.get(path, follow_redirects=False).status_code == 404
|
||||
assert google.asked == {}
|
||||
|
||||
@@ -28,7 +28,8 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex import stream
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""PUT /api/me/preferences merges the keys it is sent into the stored ones, DB-lane.
|
||||
|
||||
A client saves its preferences a key at a time, and several at once on the boot that moves this
|
||||
browser's copies to the account. Each merge must start from the row as stored when it runs, not as
|
||||
the request first read it — two merges over the same earlier read keep only the last one's keys.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy import event, update
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import User
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def test_a_save_keeps_a_key_another_save_stored_after_this_one_read_the_account(
|
||||
client, db, make_user, override_dep
|
||||
):
|
||||
user = make_user(email="prefs@example.com", password=_PW)
|
||||
assert (
|
||||
client.post("/auth/password-login", json={"email": "prefs@example.com", "password": _PW})
|
||||
).status_code == 200
|
||||
|
||||
def read_then_another_save(request: Request, session: Session = Depends(get_db)) -> User:
|
||||
# The account as this request reads it, then a second save committing in the meantime.
|
||||
caller = current_user(request, session)
|
||||
with SessionLocal() as other:
|
||||
other.execute(
|
||||
update(User)
|
||||
.where(User.id == caller.id)
|
||||
.values(preferences={"seenVersion": "1.2.3"})
|
||||
)
|
||||
other.commit()
|
||||
return caller
|
||||
|
||||
override_dep(current_user, read_then_another_save)
|
||||
r = client.put("/api/me/preferences", json={"startOnHome": False})
|
||||
|
||||
assert r.status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).preferences == {"seenVersion": "1.2.3", "startOnHome": False}
|
||||
|
||||
|
||||
def test_the_merge_reads_the_account_under_a_lock_that_orders_merges_and_nothing_else(
|
||||
client, make_user
|
||||
):
|
||||
# The re-read above is half of it: two merges that both read before either commits would still
|
||||
# keep only the last, unless the read takes a lock the other merge waits for. FOR NO KEY UPDATE
|
||||
# is that lock, and the weakest one: a plain FOR UPDATE would also wait behind any transaction
|
||||
# that only added a row referring to the account (a playlist pull, then minutes of YouTube).
|
||||
make_user(email="prefs-lock@example.com", password=_PW)
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/password-login", json={"email": "prefs-lock@example.com", "password": _PW}
|
||||
)
|
||||
).status_code == 200
|
||||
sent: list[str] = []
|
||||
|
||||
def record(_conn, _cursor, statement, *_rest):
|
||||
sent.append(statement)
|
||||
|
||||
event.listen(Engine, "before_cursor_execute", record)
|
||||
try:
|
||||
assert client.put("/api/me/preferences", json={"startOnHome": False}).status_code == 200
|
||||
finally:
|
||||
event.remove(Engine, "before_cursor_execute", record)
|
||||
|
||||
locks = [s for s in sent if "FROM users" in s and " FOR " in s]
|
||||
assert locks, sent
|
||||
assert all(s.rstrip().endswith("FOR NO KEY UPDATE") for s in locks), locks
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Migration 0112's data step, DB-lane: a user who already has two Watch later lists keeps the oldest,
|
||||
with the other's videos moved in after its own (one it already held is not added twice) and a Home
|
||||
pin of the other moved onto it, and the one-per-user index then refuses a second list.
|
||||
|
||||
The schema half is checked by test_migrations.py; a video or a pin lost on the way is invisible there.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.db import engine
|
||||
|
||||
|
||||
def _lists(conn, user_id: int, n: int) -> list[int]:
|
||||
values = ", ".join(f"(:u, 'Watch later', 'watch_later', {i})" for i in range(n))
|
||||
return list(
|
||||
conn.execute(
|
||||
text(
|
||||
f"INSERT INTO playlists (user_id, name, kind, position) VALUES {values} RETURNING id"
|
||||
),
|
||||
{"u": user_id},
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def _pin(conn, user_id: int, playlist_id: int, position: int) -> None:
|
||||
conn.execute(
|
||||
text("INSERT INTO comfort_pins (user_id, playlist_id, position) VALUES (:u, :p, :pos)"),
|
||||
{"u": user_id, "p": playlist_id, "pos": position},
|
||||
)
|
||||
|
||||
|
||||
def test_a_second_watch_later_is_folded_into_the_first(db, make_user):
|
||||
user, other = make_user(), make_user()
|
||||
db.add(Channel(id="UCm", title="M"))
|
||||
now = datetime.now(timezone.utc)
|
||||
for v in ("v1", "v2", "v3"):
|
||||
db.add(Video(id=v, channel_id="UCm", published_at=now, duration_seconds=60))
|
||||
db.commit()
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0111_audit_request_id")
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
first, second, third = _lists(conn, user.id, 3)
|
||||
theirs, their_second = _lists(conn, other.id, 2)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO playlist_items (playlist_id, video_id, position) VALUES"
|
||||
" (:a, 'v1', 0), (:b, 'v2', 0), (:b, 'v1', 1), (:c, 'v3', 0)"
|
||||
),
|
||||
{"a": first, "b": second, "c": theirs},
|
||||
)
|
||||
# The user pinned two of the copies, the kept one neither: the first on the shelf moves.
|
||||
_pin(conn, user.id, third, 0)
|
||||
_pin(conn, user.id, second, 1)
|
||||
# The other pinned both: the kept one's pin stays, and is the only one.
|
||||
_pin(conn, other.id, their_second, 0)
|
||||
_pin(conn, other.id, theirs, 1)
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
with engine.connect() as conn:
|
||||
lists = conn.execute(
|
||||
text("SELECT id, user_id FROM playlists WHERE kind = 'watch_later' ORDER BY id")
|
||||
).all()
|
||||
assert [tuple(r) for r in lists] == [(first, user.id), (theirs, other.id)]
|
||||
items = conn.execute(
|
||||
text(
|
||||
"SELECT playlist_id, video_id, position FROM playlist_items ORDER BY playlist_id, position"
|
||||
)
|
||||
).all()
|
||||
assert [tuple(r) for r in items] == [(first, "v1", 0), (first, "v2", 1), (theirs, "v3", 0)]
|
||||
pins = conn.execute(
|
||||
text("SELECT user_id, playlist_id, position FROM comfort_pins ORDER BY user_id")
|
||||
).all()
|
||||
assert [tuple(r) for r in pins] == [(user.id, first, 0), (other.id, theirs, 1)]
|
||||
with pytest.raises(IntegrityError), engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO playlists (user_id, name, kind, position)"
|
||||
" VALUES (:u, 'Watch later', 'watch_later', 2)"
|
||||
),
|
||||
{"u": user.id},
|
||||
)
|
||||
@@ -82,3 +82,42 @@ def test_a_request_that_raises_leaves_nothing_it_did_not_commit(db):
|
||||
request.throw(RuntimeError("the handler failed"))
|
||||
assert db.scalar(select(User).where(User.email == "rolled-back-probe@example.com")) is None
|
||||
assert db.info["unfinished"] == [] # a failing request is rolled back, not reported
|
||||
|
||||
|
||||
def test_the_commit_check_sees_a_core_dml_write(db, make_user):
|
||||
"""`db.execute(update(...))` flushes nothing and stages nothing, so the session's own bookkeeping
|
||||
never sees it: a handler whose only write is Core DML and that forgot its commit passed. The
|
||||
database's transaction id is what tells — also when the test itself had a write open before the
|
||||
request, which the handler's write would otherwise share. A committed one must still pass."""
|
||||
import pytest
|
||||
from sqlalchemy import update
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from app.models import User
|
||||
|
||||
user = make_user()
|
||||
|
||||
def drive(commit: bool, *, test_wrote: bool = False) -> None:
|
||||
if test_wrote: # the test's own write, open and uncommitted, as the request starts
|
||||
db.execute(update(User).where(User.id == user.id).values(display_name="test"))
|
||||
conn = HTTPConnection(
|
||||
{"type": "http", "method": "POST", "path": "/probe", "headers": [], "query_string": b""}
|
||||
)
|
||||
request = db.info["request"](conn)
|
||||
session = next(request)
|
||||
session.execute(update(User).where(User.id == user.id).values(display_name="dml"))
|
||||
if commit:
|
||||
session.commit()
|
||||
with pytest.raises(StopIteration):
|
||||
next(request)
|
||||
|
||||
drive(commit=True)
|
||||
assert db.info["unfinished"] == []
|
||||
drive(commit=False)
|
||||
assert db.info["unfinished"] == ["POST /probe"]
|
||||
db.info["unfinished"].clear() # recorded as it should be — not this test's failure
|
||||
drive(commit=True, test_wrote=True)
|
||||
assert db.info["unfinished"] == []
|
||||
drive(commit=False, test_wrote=True)
|
||||
assert db.info["unfinished"] == ["POST /probe"]
|
||||
db.info["unfinished"].clear()
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""The core's owner filters, each held by a second account whose rows must not count, show or change.
|
||||
|
||||
Every one of these reads or writes a user-owned table through `userscope.owned_by`; the fence holds
|
||||
the form (no inline `Model.user_id == …`), these hold that the filter is there at all. Each test gives
|
||||
a second account the same kind of rows, so a filter that went missing answers with theirs. DB lane.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app import debugsession, notifications, quota
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.downloads.models import DownloadJob, DownloadProfile
|
||||
from app.models import DebugScreenshot, DebugSession, Notification, QuotaEvent
|
||||
from app.reader.models import Playlist, PlaylistItem, VideoState
|
||||
from app.routes import tokens as tokens_routes
|
||||
from app.routes.auth import RESET_TTL, _issue_token
|
||||
|
||||
|
||||
def _notify(db, user, n=1, *, read=False):
|
||||
for i in range(n):
|
||||
db.add(Notification(user_id=user.id, type="t", title=f"n{i}", read=read))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _visible(db, user) -> int:
|
||||
return db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(Notification.user_id == user.id, Notification.dismissed.is_(False))
|
||||
)
|
||||
|
||||
|
||||
def test_the_notification_list_shows_only_mine(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
_notify(db, me)
|
||||
_notify(db, other, 2)
|
||||
body = as_user(me).get("/api/me/notifications").json()
|
||||
assert (body["total"], len(body["items"])) == (1, 1)
|
||||
|
||||
|
||||
def test_the_unread_count_counts_only_mine(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
_notify(db, me)
|
||||
_notify(db, other, 2)
|
||||
assert as_user(me).get("/api/me/notifications/unread_count").json() == {"count": 1}
|
||||
|
||||
|
||||
def test_reading_all_reads_only_mine(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
_notify(db, me)
|
||||
_notify(db, other, 2)
|
||||
assert as_user(me).post("/api/me/notifications/read_all").status_code == 200
|
||||
assert as_user(other).get("/api/me/notifications/unread_count").json() == {"count": 2}
|
||||
|
||||
|
||||
def test_clearing_all_dismisses_only_mine(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
_notify(db, me)
|
||||
_notify(db, other, 2)
|
||||
assert as_user(me).post("/api/me/notifications/clear").status_code == 200
|
||||
db.expire_all()
|
||||
assert (_visible(db, me), _visible(db, other)) == (0, 2)
|
||||
|
||||
|
||||
def test_trimming_read_notifications_trims_only_mine(db, make_user):
|
||||
me, other = make_user(), make_user()
|
||||
_notify(db, me, 3, read=True)
|
||||
_notify(db, other, 3, read=True)
|
||||
assert notifications.trim_read(db, me.id, cap=1) == 2
|
||||
db.commit()
|
||||
assert _visible(db, other) == 3
|
||||
|
||||
|
||||
def test_todays_actions_count_only_mine(db, make_user):
|
||||
me, other = make_user(), make_user()
|
||||
quota.log_action(me.id, quota.QuotaAction.VIDEOS_SEARCH)
|
||||
quota.log_action(other.id, quota.QuotaAction.VIDEOS_SEARCH)
|
||||
quota.log_action(other.id, quota.QuotaAction.VIDEOS_SEARCH)
|
||||
assert quota.actions_today(db, me.id, quota.QuotaAction.VIDEOS_SEARCH) == 1
|
||||
|
||||
|
||||
def test_my_usage_sums_only_mine(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
db.add_all(
|
||||
[
|
||||
QuotaEvent(user_id=me.id, action="videos.list", units=5),
|
||||
QuotaEvent(user_id=other.id, action="videos.list", units=7),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
body = as_user(me).get("/api/quota/my-usage").json()
|
||||
assert (body["today"], body["all_time"], body["by_action"]) == (5, 5, {"videos.list": 5})
|
||||
|
||||
|
||||
def test_the_token_ceiling_counts_only_mine(db, make_user, as_user, monkeypatch):
|
||||
monkeypatch.setattr(tokens_routes, "MAX_TOKENS", 1)
|
||||
me, other = make_user(), make_user()
|
||||
first = as_user(other).post("/api/tokens", json={"name": "theirs", "preset": "hub"})
|
||||
assert first.status_code == 200, first.text
|
||||
r = as_user(me).post("/api/tokens", json={"name": "mine", "preset": "hub"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_a_reset_burns_only_my_other_links(client, db, make_user):
|
||||
me, other = make_user(), make_user()
|
||||
mine = _issue_token(db, me, "reset", RESET_TTL)
|
||||
theirs = _issue_token(db, other, "reset", RESET_TTL)
|
||||
db.commit()
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": mine, "password": "a brand new passphrase"}
|
||||
)
|
||||
assert r.json() == {"ok": True}
|
||||
assert client.post("/auth/password-reset/check", json={"token": theirs}).json() == {"ok": True}
|
||||
|
||||
|
||||
def test_screenshot_storage_counts_only_mine(db, make_user):
|
||||
me, other = make_user(), make_user()
|
||||
now = datetime.now(timezone.utc)
|
||||
for i, (user, size) in enumerate([(me, 10), (other, 1000)]):
|
||||
db.add(
|
||||
DebugSession(
|
||||
id=f"s{i}",
|
||||
user_id=user.id,
|
||||
level="DEBUG",
|
||||
started_at=now,
|
||||
until=now + timedelta(hours=1),
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
db.add(
|
||||
DebugScreenshot(
|
||||
session_id=f"s{i}", created_at=now, mime="image/png", size=size, data=b"x" * size
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
assert debugsession.stored_screenshot_bytes(db, me.id) == 10
|
||||
|
||||
|
||||
# --- the units' owner filters, through their routes --------------------------------------------
|
||||
# The same rule one layer up: a second account acting on the SAME video, list or profile id must
|
||||
# reach only its own row. Each test holds the first account's row unchanged afterwards.
|
||||
|
||||
|
||||
def _videos(db, *ids):
|
||||
db.add(Channel(id="UCo", title="O"))
|
||||
db.flush()
|
||||
for vid in ids:
|
||||
db.add(
|
||||
Video(
|
||||
id=vid,
|
||||
channel_id="UCo",
|
||||
published_at=datetime.now(timezone.utc),
|
||||
duration_seconds=3600,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _video_state(db, user, vid="v1"):
|
||||
db.expire_all()
|
||||
row = db.execute(
|
||||
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == vid)
|
||||
).scalar_one_or_none()
|
||||
return None if row is None else (row.status, row.position_seconds)
|
||||
|
||||
|
||||
def test_unmarking_a_video_leaves_another_users_state(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
_videos(db, "v1")
|
||||
db.add(VideoState(user_id=other.id, video_id="v1", status="watched"))
|
||||
db.commit()
|
||||
r = as_user(me).post("/api/videos/v1/state", json={"status": "new"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _video_state(db, other) == ("watched", 0)
|
||||
|
||||
|
||||
def test_clearing_a_video_leaves_another_users_state(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
_videos(db, "v1")
|
||||
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
|
||||
db.commit()
|
||||
assert as_user(me).delete("/api/videos/v1/state").status_code == 200
|
||||
assert _video_state(db, other) == ("hidden", 90)
|
||||
|
||||
|
||||
def test_a_resume_point_is_set_and_cleared_on_my_own_row_only(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
_videos(db, "v1")
|
||||
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
|
||||
db.commit()
|
||||
client = as_user(me)
|
||||
body = {"position_seconds": 600, "duration_seconds": 3600}
|
||||
assert client.post("/api/videos/v1/progress", json=body).status_code == 200
|
||||
assert (_video_state(db, me), _video_state(db, other)) == (("new", 600), ("hidden", 90))
|
||||
body = {"position_seconds": 0, "duration_seconds": 3600}
|
||||
assert client.post("/api/videos/v1/progress", json=body).status_code == 200
|
||||
assert (_video_state(db, me), _video_state(db, other)) == (None, ("hidden", 90))
|
||||
|
||||
|
||||
def _watch_later_ids(db, user):
|
||||
db.expire_all()
|
||||
return sorted(
|
||||
db.execute(
|
||||
select(PlaylistItem.video_id)
|
||||
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
||||
.where(Playlist.user_id == user.id, Playlist.kind == "watch_later")
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def test_watch_later_adds_and_removes_use_my_own_list(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
_videos(db, "v1", "v2")
|
||||
theirs = as_user(other).post("/api/playlists/watch-later", json={"video_id": "v1"}).json()
|
||||
client = as_user(me)
|
||||
mine = client.post("/api/playlists/watch-later", json={"video_id": "v2"}).json()
|
||||
assert mine["playlist_id"] != theirs["playlist_id"]
|
||||
# Their saved video is not on my list, so removing it from mine leaves theirs alone.
|
||||
assert client.delete("/api/playlists/watch-later/v1").status_code == 200
|
||||
assert (_watch_later_ids(db, me), _watch_later_ids(db, other)) == (["v2"], ["v1"])
|
||||
|
||||
|
||||
def test_another_users_private_profile_is_refused_like_a_missing_one(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
theirs = DownloadProfile(user_id=other.id, name="Theirs", spec={"format": "best"})
|
||||
builtin = DownloadProfile(user_id=None, name="Best", spec={"format": "best"}, is_builtin=True)
|
||||
db.add_all([theirs, builtin])
|
||||
db.commit()
|
||||
client = as_user(me)
|
||||
|
||||
def enqueue(profile_id):
|
||||
return client.post(
|
||||
"/api/downloads", json={"source": "dQw4w9WgXcQ", "profile_id": profile_id}
|
||||
)
|
||||
|
||||
missing = enqueue(theirs.id + builtin.id + 1)
|
||||
refused = enqueue(theirs.id)
|
||||
assert (refused.status_code, refused.json()) == (404, missing.json())
|
||||
assert refused.json()["detail"] == {"code": "not_found", "reason": "profile"}
|
||||
db.expire_all()
|
||||
assert db.execute(select(DownloadJob)).first() is None
|
||||
# A built-in is everyone's to use.
|
||||
r = enqueue(builtin.id)
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all()
|
||||
assert db.execute(select(DownloadJob.profile_id)).scalars().all() == [builtin.id]
|
||||
@@ -19,7 +19,7 @@ from datetime import datetime, timezone
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
|
||||
from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.reader.models import Playlist, PlaylistItem
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
|
||||
import app.routes.plex.admin as admin_routes
|
||||
from app import sysconfig
|
||||
from app.models import PlexLink
|
||||
from app.plex.models import PlexLink
|
||||
from app.plex.client import PlexError, PlexNotConfigured
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ and the crossfading hero), but ONLY when the item actually has an `art_key` —
|
||||
resolve to a blank image on every card that tried to use it, so it must be null and let the client fall
|
||||
back to the poster."""
|
||||
|
||||
from app.models import PlexItem, PlexShow
|
||||
from app.plex.models import PlexItem, PlexShow
|
||||
from app.plex.cards import movie_card, show_card
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexState, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexState
|
||||
from app.plex import watch_sync as plex_watch
|
||||
|
||||
|
||||
|
||||
@@ -17,10 +17,11 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
|
||||
from app.plex.query import PLEX_SORT_KEYS
|
||||
from app.routes.plex.browse import sort_column
|
||||
from app.routes.plex.collections import (
|
||||
from app.plex.library import sort_column
|
||||
from app.plex.collections import (
|
||||
DEFAULT_MEMBER_SORT,
|
||||
MEMBER_SORT_KEYS,
|
||||
Member,
|
||||
|
||||
@@ -13,7 +13,8 @@ import app.state as state
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
|
||||
from app.plex.client import PlexError
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes.plex import collections as coll_routes
|
||||
|
||||
@@ -9,7 +9,8 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
|
||||
|
||||
|
||||
def _seed(db):
|
||||
|
||||
@@ -19,7 +19,8 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
|
||||
from app.plex import detail
|
||||
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState
|
||||
|
||||
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexShow
|
||||
|
||||
|
||||
def test_facets_returns_studio_vocabulary(db, monkeypatch, override_dep):
|
||||
|
||||
@@ -15,14 +15,8 @@ the merge is provably behaviour-equivalent (and a drift between grid and facets
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
|
||||
|
||||
SCOPES = ("movie", "show", "both")
|
||||
SHOW_STATES = ("all", "watched", "in_progress", "unwatched")
|
||||
|
||||
@@ -14,13 +14,8 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
User,
|
||||
)
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import PlexItem, PlexLibrary, PlexState
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexState
|
||||
from app.plex import service as plex_service
|
||||
from app.routes.plex import playback
|
||||
|
||||
|
||||
@@ -36,10 +37,12 @@ def test_two_racing_finishes_flip_the_row_once(db, make_user):
|
||||
|
||||
first, second = SessionLocal(), SessionLocal()
|
||||
try:
|
||||
assert playback._mark_finished(first, user.id, item.id, now) is True # holds the row lock
|
||||
assert (
|
||||
plex_service.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))
|
||||
target=lambda: out.append(plex_service.mark_finished(second, user.id, item.id, now))
|
||||
)
|
||||
racer.start()
|
||||
racer.join(0.5)
|
||||
@@ -57,9 +60,9 @@ 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
|
||||
assert plex_service.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
|
||||
assert plex_service.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)
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as app_state
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary
|
||||
from app.plex.models import PlexItem, PlexLibrary
|
||||
from app.plex import imgcache
|
||||
from app.routes.plex import playback
|
||||
|
||||
@@ -48,7 +48,7 @@ def _setup(db, make_user, monkeypatch, tmp_path) -> TestClient:
|
||||
)
|
||||
db.commit()
|
||||
monkeypatch.setattr(app_state, "_configured_cache", True)
|
||||
monkeypatch.setattr(playback, "resolved_user_id", lambda request: (user.id, True))
|
||||
monkeypatch.setattr(playback, "session_user_id", lambda request: user.id)
|
||||
monkeypatch.setattr(playback, "PlexClient", _FakePlex)
|
||||
monkeypatch.setattr(imgcache, "CACHE_DIR", tmp_path)
|
||||
_FakePlex.calls = []
|
||||
|
||||
@@ -7,7 +7,7 @@ Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models import PlexLibrary, PlexShow
|
||||
from app.plex.models import PlexLibrary, PlexShow
|
||||
|
||||
# Mirror of migration 0060's statements (plex_items runs against an empty table here — proving the
|
||||
# SQL is valid for both tables; the assertions below check the seeded plex_shows rows).
|
||||
|
||||
@@ -9,7 +9,8 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary
|
||||
|
||||
|
||||
def _seed(db):
|
||||
|
||||
@@ -14,7 +14,8 @@ from app.db import get_db
|
||||
from app.main import app
|
||||
from app import sysconfig
|
||||
from app.plex.cards import display_summary, display_title
|
||||
from app.models import (
|
||||
from app.models import User
|
||||
from app.plex.models import (
|
||||
PlexCollection,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
@@ -23,7 +24,6 @@ from app.models import (
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState
|
||||
|
||||
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
|
||||
import app.plex.sync as syncmod
|
||||
from app import sysconfig
|
||||
from app.db import engine
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexShow
|
||||
from app.plex.genres import hu_genre, hu_genres
|
||||
from app.plex.overlay import sync_overlay
|
||||
from app.plex.sync import _apply_item, _canonical_guid
|
||||
|
||||
@@ -9,7 +9,8 @@ from datetime import datetime, timezone
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.models import PlexItem, PlexLibrary, PlexPerson, User
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexPerson
|
||||
from app.plex import imgcache
|
||||
from app.plex import people as plex_people
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
@@ -361,7 +362,7 @@ def test_the_person_page_says_a_bio_is_on_its_way(db, monkeypatch, override_dep)
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: None)
|
||||
monkeypatch.setattr(people.service, "enrich_person_bg", lambda pid: None)
|
||||
key = {"value": "key"}
|
||||
monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: key["value"])
|
||||
|
||||
|
||||
@@ -12,14 +12,8 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexPerson,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.models import User
|
||||
from app.plex.models import PlexItem, PlexLibrary, PlexPerson, PlexShow, PlexState
|
||||
from app.plex.query import content_libs
|
||||
|
||||
NAME = "Robert De Niro"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user