Compare commits

...
24 Commits
Author SHA1 Message Date
peter 84af92aa29 release: 0.110.0 (feature/e41-history-import) 2026-10-23 22:48:06 +02:00
peter 420baff463 chore(release): 0.110.0 2026-10-23 22:39:20 +02:00
peter 9c5f81fd94 fix(reader): the Takeout import keeps a later upload, an un-mark and its memory bound
- An ad-hoc request made while its target's run is under way is the run after it: the scheduler
  dropped a second fire of a running id, so a second Takeout file was never imported and its key
  stayed queued until a restart.
- Un-marking or resetting a video is remembered (video_unmarks): an import of an older YouTube view
  no longer marks it watched again; un-marks take the user's Takeout lock.
- One user's failure in the Shorts job's Takeout pass is theirs: the job keeps its own result, the
  others are settled, and a failed lookup spends none of the daily share.
- The upload reads the user's id before giving the read connection back.
- A watched row waits on a discovery video (search or explore) until the catalog keeps it, so a
  mark never pins it against the cleanup jobs.
- The history is decoded a window at a time, a Watch later file over 2 MiB is refused unread, and
  two uploads at once across all users (takeout_server_busy).
- A Watch later list goes in as one presence read and one insert.
2026-10-23 22:07:11 +02:00
peter 9f580fd8c8 fix(reader): the Takeout import holds up under abuse, races and a run it cannot see
- Uploads: one at a time per user and ten an hour, each refused only after its body arrived;
  the history read an entry at a time (32 MiB cap), a Watch later list capped at YouTube's 5000,
  a user's kept videos at 200k; any unreadable shape is a 422, not a 500.
- Quota: Watch later lookups stop at the backfill reserve and at a per-user daily share; what is
  not asked about waits, known videos still go in, and the shorts job retries the rest.
- Staging and settling share one per-user advisory lock (no deadlock); a run settles only the
  rows it read, so a row staged meanwhile is never called gone.
- A failed run's notice names a translated failure, never the upstream's text.
- The status says when an import is queued; the page watches it to its end even when left, then
  reads the feed, its counts and the playlists again.
- Settings: one live region, the zone kept (unavailable) while a run goes, focus handed back,
  a missed drop kept from the browser, an oversize file refused before it is sent.
- Scheduler and run lists show Takeout runs by the importing user.
2026-10-23 20:48:23 +02:00
peter e0b02a0c14 fix(inbox): a Takeout import's notice opens the user's own import
The import's notice reaches a user who may not see the Scheduler, an
admin page, yet its link opened it. It now opens Settings › Account,
where the import's status is; any other run still opens the Scheduler.
The job id is held to the server's by the backend-ids contract. The
notice's Hungarian names the list as the app does elsewhere.
2026-10-23 19:15:06 +02:00
peter 3553eac8c4 feat(settings): upload a Google Takeout export in Settings › Account
A "YouTube history" section under YouTube access: the watch history and
the Watch later csv are dropped or picked (one request each, each file's
outcome said on its own), and the section then says what the import did
- only the counts it produced - or that it is running, failed or was cut
off by a restart. While it runs it asks again until it ends; a failed
re-read shows the failure, not the previous answer.

The drop zone the Files upload had inline is now ui/DropZone, used by
both. The e2e seed clears the account's kept Takeout videos.
2026-10-23 18:51:05 +02:00
peter 5d9952ab1e feat(reader): a Takeout video the catalog learns later is marked watched then; the import's status
A watched video the catalog did not know at import time waits in `takeout_items`. The Shorts job now
settles those rows right after its probe (the only place a video's Short-ness becomes known), every
user's, a commit each: `reader.takeout.apply_waiting`, its count in the job's result.

`GET /api/takeout` is what the settings page reads: the user's last import run (`jobruns.last_run`)
and the kept videos by kind and outcome, "waiting" for one not known yet.
2026-10-23 17:30:28 +02:00
peter f486a69ef2 fix(deps): brotli 1.2.1 (PYSEC-2026-4322) 2026-10-23 17:10:38 +02:00
peter 7b8505af95 feat(reader): a Google Takeout export becomes watch state and Watch later
The YouTube API reads neither a user's watch history nor their Watch later list, so the Takeout
export is the only way in. `POST /api/takeout` takes one file of it (watch-history.json or
Watch later-videos.csv, told apart by content), keeps its videos in `takeout_items` and queues the
user's `takeout_import` run, whose outcome reaches their inbox.

The run marks watched the known videos the Shorts probe has cleared, at the Takeout time; a Short
is not marked, and a state the user set here (watched, hidden, or changed after that time) wins, by
the upsert's own condition. A video the catalog does not know yet waits with no outcome. Watch later
videos are fetched if unknown and appended oldest first.

An ad-hoc run can now ask for a completion notice (`run_ad_hoc(..., notify=True)`).
2026-10-23 17:04:54 +02:00
peter bf5ceec8a1 release: 0.109.2 (bug/plex-player-server-away) 2026-10-23 15:00:27 +02:00
peter e788326247 chore(release): 0.109.2 — a server restart mid-episode is waited out by the Plex player 2026-10-23 14:52:04 +02:00
peter 38c71b7b4f fix(plex): a server that restarts mid-episode is waited out, not reported as a stream that cannot play
During a deploy the gateway answers 502 (or nothing answers at all) until the app is back. The
player took the first such answer for a broken stream: it said "Playback couldn't start" over a
picture still playing from its buffer, stopped loading, and never asked again, so the episode froze
where the buffer ended with the error still up. Now a 502/503/504 or no answer is the server being
away: the buffer keeps playing undisturbed, the session is asked for again where the viewer is
(2, 4, 8, then every 15 s), and the error is said only after three minutes away. A seek or another
item replaces a pending retry. Measured on localdev: a 45 s API stop now resumes at the viewer's
position, where it froze with the error before.
2026-10-23 14:42:17 +02:00
peter cd5b26efa7 release: 0.109.1 (improvement/e44-fe-shell) 2026-10-23 14:21:45 +02:00
peter 0892a1a400 chore(release): 0.109.1 — the frontend in one folder per page with lint-held layers; an edited saved view survives a visit away 2026-10-23 14:13:44 +02:00
peter b443ac0c84 fix(frontend): the layer gates cover what the move left outside them, and each page keeps its own logic
Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it
saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from
any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell,
the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test
gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate
register, with its three imports.

Placement: Plex's chip builder moves into lib beside the feed's, the folder names into
features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset
from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed"
itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic.

The floating header re-measures when one page's pills replace another's in the same commit (the
claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays
across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a
page with a rail adds in lib.
2026-10-23 13:45:40 +02:00
peter ad05932217 docs(frontend): the canonical table and the ui README name the module shape; adding a page is written down
CLAUDE.md gains the header-content, module-store and saved-views rows. docs/development.md says what a
new page is and which tests hold it. components/ui/README.md names every file in the directory, and a
contract test keeps it that way: seventeen had slipped in unlisted. copyItems' comment still claimed it
was a feature file.
2026-10-23 12:52:04 +02:00
peter 6ae7825a14 refactor(frontend): the module stores mount from the registry, and the feed and Plex each own their saved views
A module's definition names its store (feed: filters, view, saved views; Plex: browse state, saved
views; channels, files, playlists their provider), and ModuleStores mounts them at the root in
registry order instead of a hand-nested tree in main.tsx. A contract test holds that every store's
read side is in reach and that no store's static imports leave its own module, so the order cannot
matter.

Saved views were one provider that switched between the feed and Plex by the active page and reset
on each switch. The logic is now one hook (useSavedViewsStore) with an instance per module; the
shared chip, popover and modal read the views their page or rail hands down. A feed view applied
and edited now survives a visit to Plex. Plex's views are still asked for only on its page, and a
module's manage modal still closes when its page is left. The feed's URL-override check applies
only on the feed page, where its filter params live.

The Channels store's tab type moves from the page into the store's context.
2026-10-23 04:59:10 +02:00
peter cde5a98fd2 refactor(frontend): each module defines itself in its folder; rails load lazily and keep their own state
A page used to be listed three times (lib/modules for its label, icon and gate; shell/moduleRegistry for
its page and rail; lib/pageMeta for its title), and App held every rail's layout and collapse flag.
Now each module's `module.ts` says it all — label, icon, gate, its lazy page and rail, its width —
handed out through its index.ts, and features/navigation/moduleRegistry lists them in nav order for
the rail, the palette, the tab title, the reachable set and the shell's render. A contract test holds
the registry to the page ids and each definition to its own folder, with no static import of a page.

Every rail is lazy now, loaded together with its page so a pinned rail never arrives after it and
shoves the content aside; the feed's and the playlists' rails took @dnd-kit off the boot path (boot
428.5 → 393.9 KiB gzip, the App chunk 122 → 32 KiB). A rail reads its own layout and collapse flag
(lib/useRail) and writes a layout change to the `me` cache too, since it now mounts anew on each
visit. App's session effects, its one-shot entry-URL handlers and the admin nudge move into shell
hooks; App is layout. The registry's three layer suppressions are gone: none is left.
2026-10-23 04:00:58 +02:00
peter e83c5e2d97 fix(frontend): the content is padded by the filled header before paint, not when the observer catches up
The header's pills now arrive by portal after the shell has mounted, so the shell's one synchronous
measurement saw an empty header, and the ResizeObserver brought `--hdr-h` up a frame or more later:
meanwhile the content sat under the pills (the feed toolbar jumped 55px on a tablet; e2e caught it).
The measurement re-runs before paint whenever what fills the header changes.
2026-10-23 03:24:23 +02:00
peter 6a0c1d377f fix(frontend): the channels, files and playlists faces stop exporting the stores' read hooks only the old header used 2026-10-23 00:30:14 +02:00
peter 4ef963b005 refactor(frontend): each page fills the floating header itself; the header knows no page
The header used to pick its pills by `page === …` and import five module controls, and Plex's
Discover/Browse toggle needed a context bridge to reach the sub-view that lives in PlexBrowse.
Now the header is an empty band: PageShell hands its root box out as a slot, a page portals its
own pills into it (PageHeader), and the palette's Jump-to pill shows where no page does
(PageHeaderDefault, rendered inside the page's Suspense so a loading page shows no stand-in).
Plex's toggles take the sub-view as a prop; PlexViewBridge and its context are gone, and with
them the header's five layer suppressions.

No visible change: every header pill's box measured identical against the previous build on eight
pages, in Hungarian and English.
2026-10-23 00:23:38 +02:00
peter 0b24f2d8fa fix(frontend): the chunk guard's logic lives in the guard, so no file sits in two TypeScript projects
The guard's logic was a src/lib file the config project also listed. The app project, which
references the config project, then refuses that source on a clean build (TS6305) — while every
local run reused the referenced project's cached output and stayed green; only the Docker image
build failed. The logic is back inside chunkGuard.ts, its test beside it at the frontend root
(vitest now also collects `*.test.ts` there), and a gate contract fails when the config project's
include reaches into src/ again. The guard was re-proved on a build with a leaking re-export.
2026-10-22 23:48:16 +02:00
peter d5867bacca feat(frontend): the folders are layers — an import only goes down, a module is reached through its index.ts
`local/layers` (eslint.config.js) resolves every import to its file and refuses one that goes up
(lib/ → components/ui/ → features/ → modules/ → shell/) or that reaches into another module's own
files. A module's index.ts is its public face: the stores, mounted at the root and read app-wide,
which features/ may read too; a page is still opened lazily by import(). Its contract test probes
every edge, and three mutants of the rule (off, no face check, a door for everyone) each turn it red.

Six modules got that face; the shared channel UI (about, tags, tag manager) moved to
features/channel, the filter chips and the saved-views context to features/filters, and the prefs
store's controller type moved next to the store instead of importing it from its own page. The
header's per-module controls and the eagerly loaded rails keep eight inline suppressions, each
naming why: they go when the header gets slots and every rail loads lazily.

`chunkGuard` fails the production build when a lazily imported module lands in a boot chunk (the
entry, the shell, what they import) — e.g. a page re-exported from an index.ts, which makes its
import() a silent no-op. Proved on a build with that mutant; today's build has none.
2026-10-22 23:30:27 +02:00
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00
485 changed files with 9513 additions and 4178 deletions
+27
View File
@@ -7,6 +7,33 @@ 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.110.0 — 2026-10-23
### New
- YouTube history: upload your Google Takeout export in Settings › Account. What you watched on YouTube is marked watched here (never a Short, never over a state you set), and your YouTube Watch later list joins yours.
- A video the catalog does not know yet is remembered and marked once it arrives. The import's outcome reaches your inbox, and admins see each run on the Scheduler page.
### Fixed
- Scheduler: a channel's full-history backfill asked for again while one is running now runs after it, instead of being silently dropped.
## 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
+19 -8
View File
@@ -73,14 +73,22 @@ only along a declared edge and writes them only through that unit's service; cor
`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: `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).
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>`.
@@ -107,6 +115,9 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE 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 |
+1 -1
View File
@@ -1 +1 @@
0.109.0
0.110.0
@@ -0,0 +1,51 @@
"""A user's Google Takeout export: what they watched on YouTube, and their Watch later list
Revision ID: 0113_takeout_items
Revises: 0112_one_watch_later
Create Date: 2026-10-23
One row per video and kind. A video the catalog does not know yet waits with no outcome, so the id
is not a foreign key; the partial index is what a newly known video looks its waiting rows up by.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0113_takeout_items"
down_revision: str | None = "0112_one_watch_later"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"takeout_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.String(12), nullable=False),
sa.Column("video_id", sa.String(16), nullable=False),
sa.Column("channel_id", sa.String(32), nullable=True),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
sa.Column("outcome", sa.String(12), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("user_id", "kind", "video_id", name="uq_takeout_item"),
sa.CheckConstraint("kind IN ('watched', 'watch_later')", name="ck_takeout_item_kind"),
)
op.create_index(
"ix_takeout_items_waiting",
"takeout_items",
["video_id"],
postgresql_where=sa.text("outcome IS NULL"),
)
def downgrade() -> None:
op.drop_index("ix_takeout_items_waiting", table_name="takeout_items")
op.drop_table("takeout_items")
@@ -0,0 +1,44 @@
"""When a user last took a video back to "new"
Revision ID: 0114_video_unmarks
Revises: 0113_takeout_items
Create Date: 2026-10-23
Un-marking or resetting a video leaves no `video_states` row, so this is what tells an import of the
user's YouTube history a video they un-marked here from one never touched.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0114_video_unmarks"
down_revision: str | None = "0113_takeout_items"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"video_unmarks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"video_id",
sa.String(16),
sa.ForeignKey("videos.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "video_id", name="uq_video_unmark"),
)
op.create_index("ix_video_unmarks_video_id", "video_unmarks", ["video_id"])
def downgrade() -> None:
op.drop_index("ix_video_unmarks_video_id", table_name="video_unmarks")
op.drop_table("video_unmarks")
+8
View File
@@ -72,6 +72,13 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024
# A route belongs here ONLY IF ITS OWN CAP IS LARGER THAN THE DEFAULT. That is why
# `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the
# default, so listing it could only widen what it accepts.
# A Google Takeout file (`reader.takeout`). A real watch history measured 3.7 MB for 9500 views, ~390
# bytes a view, so 32 MiB holds ~86k views. YouTube keeps the history 36 months by default on an
# account made since mid-2020 and for good on an older one, so a heavy viewer's can pass 16 MiB. The
# file's text is decoded a window at a time and read an entry at a time (`takeout._json_list`), so
# it costs its bytes and a window, not a tree of it; the route takes two at once across every user.
TAKEOUT_MAX_BYTES = 32 * 1024 * 1024
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
# Ingesting a local file. The body IS the file now — no multipart envelope to allow for —
# so this ceiling and the route's own cap count exactly the same bytes. `sysconfig`'s
@@ -95,6 +102,7 @@ ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
# sending it at all, off the `upload_max_bytes` that `GET /api/files/usage` reports.
("POST", "/api/files/upload"): settings.download_upload_max_bytes,
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
("POST", "/api/takeout"): TAKEOUT_MAX_BYTES,
}
# The refusal, byte-for-byte what `capped_body`'s `HTTPException(413, {"code": "too_large"})` renders
+10
View File
@@ -119,6 +119,16 @@ def recent_runs(db: Session, jobs: list[str], per_job: int) -> dict[str, list[Jo
return out
def last_run(db: Session, job: str, target: str) -> JobRun | None:
"""The newest run of an ad-hoc `job` on `target` (a user's own import), or None."""
return db.execute(
select(JobRun)
.where(JobRun.job == job, JobRun.target == target)
.order_by(JobRun.started_at.desc(), JobRun.id.desc())
.limit(1)
).scalar_one_or_none()
def gc(db: Session, retention_days: int) -> int:
"""Delete finished runs older than `retention_days` (0 = keep forever). A run still going is
never reaped. The caller commits."""
+8
View File
@@ -42,6 +42,14 @@ class RateLimiter:
with self._lock:
return sum(c for t, c in self._hits.get(key, []) if t > cutoff) >= self.max_events
def room(self, key: str) -> float:
"""How much cost ``key`` may still spend in the current window, WITHOUT counting anything —
for work counted only once it has succeeded: ask it first, ``allow`` what was done."""
cutoff = time.monotonic() - self.window
with self._lock:
used = sum(c for t, c in self._hits.get(key, []) if t > cutoff)
return max(0.0, self.max_events - used)
def allow(self, key: str, cost: float = 1) -> bool:
"""Record an attempt costing ``cost`` for ``key`` and return whether it is within the limit.
+64
View File
@@ -4,6 +4,7 @@ and the comfort shelf on Home. Only this unit writes them; another unit reads th
edge, or asks this unit's service."""
from datetime import datetime
from enum import StrEnum
from sqlalchemy import (
Boolean,
@@ -84,6 +85,21 @@ class VideoState(Base, UpdatedAtMixin):
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class VideoUnmark(Base):
"""When the user last took a video back to "new" here — un-marked it, or reset it. That leaves
no `VideoState` (its absence is "new"), so without this row a later import of what they watched
on YouTube could not tell a video they un-marked from one never touched, and marked it watched
again. Kept by user and video; the video's own deletion takes it."""
__tablename__ = "video_unmarks"
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_video_unmark"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class Playlist(Base, TimestampMixin, UpdatedAtMixin):
"""A per-user named, ordered collection of videos from the shared catalog.
@@ -213,3 +229,51 @@ class ComfortPin(Base, TimestampMixin):
# 0-based shelf order. The shelf is short and hand-curated, so a dense integer the reorder
# endpoint rewrites wholesale beats a fractional index nobody would ever exercise here.
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
class TakeoutKind(StrEnum):
"""What a Google Takeout row says the user did with a video."""
WATCHED = "watched" # history/watch-history.json
WATCH_LATER = "watch_later" # playlists/Watch later-videos.csv
class TakeoutOutcome(StrEnum):
"""What the import did with a row; NULL = still waiting for its video to be known (and, for a
watched one, probed for Shorts)."""
WATCHED = "watched" # the video is marked watched at the Takeout time
KEPT = "kept" # the state the user set here (watched, hidden, or changed later) stays
SHORT = "short" # a Short: swiping one past is not watching it
ADDED = "added" # added to Watch later
PRESENT = "present" # already in Watch later
MISSING = "missing" # YouTube no longer has the video
class TakeoutItem(Base, TimestampMixin):
"""One video from a user's Google Takeout export: watched (with when) or in their YouTube Watch
later list (with when it was added). The rows are both the import job's input and its memory: a
video the catalog does not know yet waits here, with no outcome, until it enters the catalog.
The id is not a foreign key for that reason. The channel is kept for a later "channels you
watch but do not follow" list."""
__tablename__ = "takeout_items"
__table_args__ = (
UniqueConstraint("user_id", "kind", "video_id", name="uq_takeout_item"),
CheckConstraint("kind IN ('watched', 'watch_later')", name="ck_takeout_item_kind"),
# The waiting rows, by video: what a newly known video looks up.
Index(
"ix_takeout_items_waiting",
"video_id",
postgresql_where=text("outcome IS NULL"),
),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
kind: Mapped[str] = mapped_column(String(12))
video_id: Mapped[str] = mapped_column(String(16))
channel_id: Mapped[str | None] = mapped_column(String(32))
# Watched: the last time in the export; Watch later: when it was added.
at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
outcome: Mapped[str | None] = mapped_column(String(12))
+177 -3
View File
@@ -1,5 +1,5 @@
"""Reader's reads: a user's labels, saved views, watch state and playlists, for the HTTP layer and
for the other units that ask about them.
"""Reader's reads: a user's labels, saved views, watch state, playlists and Takeout import, for the HTTP
layer and for the other units that ask about them.
Every read of a user's row goes through `userscope`, so "someone else's row" is the same answer as
a missing one. Reader reads Catalog's videos along its declared edge (a playlist's cover and
@@ -7,6 +7,9 @@ length, whether a video exists); another unit asks the questions at the end of t
than joining Reader's tables itself.
"""
from collections.abc import Iterable
from datetime import datetime
from sqlalchemy import ColumnElement, and_, func, or_, select
from sqlalchemy.orm import InstrumentedAttribute, Session, aliased
@@ -14,7 +17,17 @@ from app.collation import collated
from app.feedquery import feed_columns, serialize
from app.models import User
from app.catalog.models import Channel, Subscription, Video
from app.reader.models import ChannelTag, Playlist, PlaylistItem, SavedView, Tag, VideoState
from app.reader.models import (
ChannelTag,
Playlist,
PlaylistItem,
SavedView,
Tag,
TakeoutItem,
TakeoutKind,
VideoState,
VideoUnmark,
)
from app.userscope import owned_by, owned_row
# --- catalog, read along the edge ------------------------------------------------------------
@@ -359,3 +372,164 @@ def user_tag_link(db: Session, user: User, channel_id: str, tag_id: int) -> Chan
owned_by(ChannelTag.user_id, user),
)
).scalar_one_or_none()
# --- Google Takeout --------------------------------------------------------------------------
def _settled_here() -> ColumnElement[bool]:
"""A video a waiting "watched" row may be marked on: one the catalog keeps of its own, not a
discovery video only a search or an explore brought in. A mark is a `VideoState`, which the
cleanup jobs read as a reader keeping it (`kept_by_a_reader`): on one of those it would keep the
video, and its explored channel, for good. Such a row waits until the video is the catalog's own
(its channel subscribed to) — or is gone."""
subscribed = select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
return and_(Video.via_explore.is_(False), or_(Video.via_search.is_(False), subscribed))
def takeout_watched_ready(db: Session, user: User) -> list[tuple[int, str, datetime, bool]]:
"""The user's waiting "watched" rows whose video the catalog knows and has probed for Shorts:
(row id, video id, Takeout time, is Short)."""
return list(
db.execute(
select(TakeoutItem.id, TakeoutItem.video_id, TakeoutItem.at, Video.is_short)
.join(Video, Video.id == TakeoutItem.video_id)
.where(
owned_by(TakeoutItem.user_id, user),
TakeoutItem.kind == TakeoutKind.WATCHED,
TakeoutItem.outcome.is_(None),
Video.shorts_probed,
_settled_here(),
)
).tuples()
)
def takeout_waiting(db: Session, user: User, kind: TakeoutKind) -> list[tuple[int, str]]:
"""The user's rows of `kind` the import has not settled, oldest first: (row id, video id)."""
return list(
db.execute(
select(TakeoutItem.id, TakeoutItem.video_id)
.where(
owned_by(TakeoutItem.user_id, user),
TakeoutItem.kind == kind,
TakeoutItem.outcome.is_(None),
)
.order_by(TakeoutItem.at, TakeoutItem.id)
).tuples()
)
# Ids per IN list: a list is bound one parameter an id, and a statement takes at most 65,535.
_IN_CHUNK = 5000
def known_video_ids(db: Session, ids: list[str]) -> set[str]:
"""Which of `ids` the catalog holds."""
known: set[str] = set()
for start in range(0, len(ids), _IN_CHUNK):
chunk = ids[start : start + _IN_CHUNK]
known.update(db.execute(select(Video.id).where(Video.id.in_(chunk))).scalars())
return known
def unmarked_at(db: Session, user: User, ids: list[str]) -> dict[str, datetime]:
"""When the user last took each of `ids` back to "new" here, for those they ever did."""
out: dict[str, datetime] = {}
for start in range(0, len(ids), _IN_CHUNK):
rows = db.execute(
select(VideoUnmark.video_id, VideoUnmark.at).where(
owned_by(VideoUnmark.user_id, user),
VideoUnmark.video_id.in_(ids[start : start + _IN_CHUNK]),
)
).tuples()
out.update((vid, at) for vid, at in rows)
return out
def in_playlist(db: Session, playlist_id: int, ids: list[str]) -> set[str]:
"""Which of `ids` the playlist already holds."""
held: set[str] = set()
for start in range(0, len(ids), _IN_CHUNK):
chunk = ids[start : start + _IN_CHUNK]
held.update(
db.execute(
select(PlaylistItem.video_id).where(
PlaylistItem.playlist_id == playlist_id, PlaylistItem.video_id.in_(chunk)
)
).scalars()
)
return held
def takeout_rows_besides(
db: Session, user: User, kind: TakeoutKind, video_ids: Iterable[str]
) -> int:
"""How many Takeout rows the user keeps other than `video_ids` of `kind`: what an upload of
those videos would be added to."""
total = (
db.scalar(select(func.count(TakeoutItem.id)).where(owned_by(TakeoutItem.user_id, user)))
or 0
)
ids = list(video_ids)
for start in range(0, len(ids), _IN_CHUNK):
total -= (
db.scalar(
select(func.count(TakeoutItem.id)).where(
owned_by(TakeoutItem.user_id, user),
TakeoutItem.kind == kind,
TakeoutItem.video_id.in_(ids[start : start + _IN_CHUNK]),
)
)
or 0
)
return total
def takeout_outcomes(db: Session, user: User) -> dict[tuple[str, str | None], int]:
"""How many of the user's rows each kind and outcome holds (None = waiting)."""
rows = db.execute(
select(TakeoutItem.kind, TakeoutItem.outcome, func.count())
.where(owned_by(TakeoutItem.user_id, user))
.group_by(TakeoutItem.kind, TakeoutItem.outcome)
).all()
return {(kind, outcome): n for kind, outcome, n in rows}
def takeout_summary(db: Session, user: User) -> dict[str, dict[str, int]]:
"""The user's kept Takeout videos by kind and outcome, for their settings page: `{"watched":
{"watched": n, "short": n, "waiting": n, …}, "watch_later": {…}}` — the import's outcome words,
"waiting" for a video not known yet."""
out: dict[str, dict[str, int]] = {kind.value: {} for kind in TakeoutKind}
for (kind, outcome), n in takeout_outcomes(db, user).items():
out[kind][outcome or "waiting"] = n
return out
def takeout_users_ready(db: Session) -> list[int]:
"""Every user with a waiting "watched" row whose video is now known and probed for Shorts: the
rows a later run of the Shorts probe made ready."""
return list(
db.execute(
select(TakeoutItem.user_id)
.join(Video, Video.id == TakeoutItem.video_id)
.where(
TakeoutItem.kind == TakeoutKind.WATCHED,
TakeoutItem.outcome.is_(None),
Video.shorts_probed,
_settled_here(),
)
.distinct()
).scalars()
)
def takeout_users_watch_later_waiting(db: Session) -> list[int]:
"""Every user with a Watch later row a run could not put in yet."""
return list(
db.execute(
select(TakeoutItem.user_id)
.where(TakeoutItem.kind == TakeoutKind.WATCH_LATER, TakeoutItem.outcome.is_(None))
.distinct()
).scalars()
)
+168 -1
View File
@@ -15,7 +15,18 @@ from sqlalchemy.orm import Session
from app.models import User
from app.reader import queries
from app.reader.models import ChannelTag, Playlist, PlaylistItem, SavedView, Tag, VideoState
from app.reader.models import (
ChannelTag,
Playlist,
PlaylistItem,
SavedView,
Tag,
TakeoutItem,
TakeoutKind,
TakeoutOutcome,
VideoState,
VideoUnmark,
)
from app.sync.playlists import lock_playlists, recompute_dirty
from app.userscope import owned_by
@@ -196,6 +207,7 @@ def unmark(db: Session, user: User, video_id: str) -> None:
the in-progress state, not where the user left off); otherwise the row goes. The player fires
this beside a checkpoint that may delete the same row: the caller's commit then raises
`StaleDataError`, which is that lost race and nothing worse."""
lock_takeout(db, user) # an import settling meanwhile reads the un-mark below, or comes first
row = queries.video_state(db, user, video_id)
if row is None:
return
@@ -204,9 +216,21 @@ def unmark(db: Session, user: User, video_id: str) -> None:
row.watched_at = None
else:
db.delete(row)
_remember_unmark(db, user, video_id)
db.flush()
def _remember_unmark(db: Session, user: User, video_id: str) -> None:
"""Note that the user took the video back to "new" now: the state they set leaves no row, and
an import of their YouTube history must not mark it watched again (`apply_takeout_watched`)."""
now = datetime.now(timezone.utc)
db.execute(
pg_insert(VideoUnmark)
.values(user_id=user.id, video_id=video_id, at=now)
.on_conflict_do_update(constraint="uq_video_unmark", set_={"at": now})
)
def mark(db: Session, user: User, video_id: str, status: str) -> None:
""" "watched" or "hidden", as one atomic upsert on (user, video). The player marks "watched" at
the end of playback at the instant a checkpoint may delete the in-progress row; a SELECT then
@@ -231,9 +255,11 @@ def mark(db: Session, user: User, video_id: str, status: str) -> None:
def clear_state(db: Session, user: User, video_id: str) -> None:
"""The video back to pristine for this user: status, resume point and `watched_at` all go.
Playlist membership lives elsewhere and stays."""
lock_takeout(db, user) # as `unmark`
row = queries.video_state(db, user, video_id)
if row is not None:
db.delete(row)
_remember_unmark(db, user, video_id)
db.flush()
@@ -495,3 +521,144 @@ def detach_user_tag(db: Session, user: User, channel_id: str, tag_id: int) -> No
if row is not None:
db.delete(row)
db.flush()
# --- Google Takeout --------------------------------------------------------------------------
_TAKEOUT_BATCH = 1000
# The advisory-lock namespace of one user's Takeout rows ("TO"), so a key cannot meet another lock's.
_TAKEOUT_LOCK_NS = 0x544F
def lock_takeout(db: Session, user: User) -> None:
"""Hold the user's Takeout rows for the rest of the transaction. An upload's staging and an
import's settling each lock many of the same rows, one statement after another and in no common
order, so without one lock over both the two can deadlock. An un-mark takes it too: the import
reads the un-marks before it marks, and one landing between the two would be overwritten."""
db.execute(select(func.pg_advisory_xact_lock(_TAKEOUT_LOCK_NS, user.id)))
def stage_takeout(
db: Session, user: User, kind: TakeoutKind, rows: dict[str, tuple[datetime, str | None]]
) -> None:
"""Keep the videos of one Takeout file, `{video id: (time, channel id)}`, for the import job.
A second upload of the same export, or a newer one, adds what is new: a video already kept keeps
the later time, and a row the import has settled keeps its outcome."""
lock_takeout(db, user)
values = [
{"user_id": user.id, "kind": kind, "video_id": vid, "at": at, "channel_id": channel}
for vid, (at, channel) in rows.items()
]
for start in range(0, len(values), _TAKEOUT_BATCH):
stmt = pg_insert(TakeoutItem).values(values[start : start + _TAKEOUT_BATCH])
db.execute(
stmt.on_conflict_do_update(
constraint="uq_takeout_item",
set_={
"at": func.greatest(TakeoutItem.at, stmt.excluded.at),
"channel_id": func.coalesce(stmt.excluded.channel_id, TakeoutItem.channel_id),
},
)
)
db.flush()
def _settle(db: Session, ids: list[int], outcome: TakeoutOutcome) -> None:
for start in range(0, len(ids), _TAKEOUT_BATCH):
db.execute(
update(TakeoutItem)
.where(TakeoutItem.id.in_(ids[start : start + _TAKEOUT_BATCH]))
.values(outcome=outcome)
)
def apply_takeout_watched(db: Session, user: User) -> dict[str, int]:
"""Mark watched what the user watched on YouTube, for the videos the catalog knows and has probed
for Shorts; the rest wait. `watched_at` is the Takeout time. A Short is not marked (swiping one
past is not watching it), and a state the user set here wins: watched or hidden, anything
changed after the Takeout time, or an un-mark since then (`VideoUnmark`). The upsert's own
condition is that rule, so a state set while the import runs wins too."""
lock_takeout(db, user)
short: list[int] = []
todo: dict[str, tuple[int, datetime]] = {}
ready = queries.takeout_watched_ready(db, user)
unmarked = queries.unmarked_at(db, user, [video_id for _, video_id, _, _ in ready])
kept: list[int] = []
for row_id, video_id, at, is_short in ready:
if is_short:
short.append(row_id)
elif video_id in unmarked and unmarked[video_id] >= at:
# Un-marked here since they watched it there: theirs, as a state they set is.
kept.append(row_id)
else:
todo[video_id] = (row_id, at)
marked: set[str] = set()
items = list(todo.items())
now = datetime.now(timezone.utc)
for start in range(0, len(items), _TAKEOUT_BATCH):
stmt = pg_insert(VideoState).values(
[
{"user_id": user.id, "video_id": vid, "status": "watched", "watched_at": at}
for vid, (_, at) in items[start : start + _TAKEOUT_BATCH]
]
)
marked.update(
db.execute(
stmt.on_conflict_do_update(
constraint="uq_user_video",
set_={
"status": "watched",
"watched_at": stmt.excluded.watched_at,
"updated_at": now,
},
where=(VideoState.status == "new")
& (VideoState.updated_at < stmt.excluded.watched_at),
).returning(VideoState.video_id)
).scalars()
)
kept += [row_id for vid, (row_id, _) in items if vid not in marked]
_settle(db, [todo[vid][0] for vid in marked], TakeoutOutcome.WATCHED)
_settle(db, kept, TakeoutOutcome.KEPT)
_settle(db, short, TakeoutOutcome.SHORT)
db.flush()
return {"watched": len(marked), "kept": len(kept), "shorts": len(short)}
def apply_takeout_watch_later(
db: Session, user: User, rows: list[tuple[int, str]], asked: set[str]
) -> dict[str, int]:
"""Put the videos of the user's waiting Watch later `rows`, (row id, video id) oldest first, into
their list here, after what it already holds. `asked` is the videos YouTube was just asked for: one
of those the catalog still lacks is gone from YouTube; any other unknown one keeps waiting."""
if not rows:
return {}
lock_takeout(db, user)
ids = [vid for _, vid in rows]
known = queries.known_video_ids(db, ids)
pl = watch_later(db, user)
# What add_item does a video at a time, for the whole list at once: one presence read and one
# insert, not four statements a video (a full list is thousands). Locked before the read, as there.
next_position = next_item_position(db, pl.id)
held = queries.in_playlist(db, pl.id, [vid for vid in ids if vid in known])
done: dict[TakeoutOutcome, list[int]] = {o: [] for o in TakeoutOutcome}
for row_id, vid in rows:
if vid not in known:
if vid in asked:
done[TakeoutOutcome.MISSING].append(row_id)
elif vid in held:
done[TakeoutOutcome.PRESENT].append(row_id)
else:
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=next_position))
next_position += 1
held.add(vid)
done[TakeoutOutcome.ADDED].append(row_id)
if done[TakeoutOutcome.ADDED]:
touch(pl)
for outcome, ids in done.items():
_settle(db, ids, outcome)
db.flush()
return {
"watch_later_added": len(done[TakeoutOutcome.ADDED]),
"watch_later_present": len(done[TakeoutOutcome.PRESENT]),
"watch_later_missing": len(done[TakeoutOutcome.MISSING]),
}
+411
View File
@@ -0,0 +1,411 @@
"""A Google Takeout export, imported: what the user watched on YouTube becomes watched here, and their
YouTube Watch later list joins theirs. The YouTube API reads neither of them, so the export is the
only way in.
Two files of the export are read, each uploaded on its own: `history/watch-history.json` and
`playlists/Watch later-videos.csv`. Their videos are kept (`TakeoutItem`) and the import job applies
what it can; a video the catalog does not know yet waits for it.
The format, measured on a real export (2026-10): the history is a JSON list, one entry per view,
newest first. A view of a video has a `titleUrl` with `watch?v=<id>` (on www.youtube.com or
music.youtube.com) and a `time`, with or without milliseconds. The channel is the first `subtitles`
entry's url, absent for a video since removed. An ad is an entry whose `details` say "From Google
Ads". The title is in the account's language, so nothing here reads it. A Short is a plain
`watch?v=` view, told apart only once the catalog has probed the video. The Watch later csv is a
header row (in the account's language) and `<id>,<ISO time added>` rows.
"""
import codecs
import csv
import io
import json
import re
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from itertools import chain
from sqlalchemy.orm import Session
from app import logs, quota
from app.bodylimit import TAKEOUT_MAX_BYTES
from app.db import SessionLocal
from app.models import User
from app.ratelimit import RateLimiter
from app.reader import queries, service
from app.reader.models import TakeoutKind
from app.sync.runner import get_service_user
from app.sync.videos import ensure_videos
from app.youtube.client import YouTubeClient
log = logs.get_logger(__name__)
# The largest file the upload takes (its edge cap and the route's own are one number).
MAX_BYTES = TAKEOUT_MAX_BYTES
# The most videos a Watch later file may hold: YouTube's own cap on one playlist. Each unknown one is
# looked up on the shared API budget, so a longer list is not one YouTube made.
WATCH_LATER_MAX = 5000
# The most Takeout videos one user keeps: twenty times the real export measured here (~9,000 videos
# across both files), so it only stops uploads of made-up ones piling up for good.
ROWS_MAX = 200_000
_VIDEO_ID = re.compile(r"^[A-Za-z0-9_-]{11}$")
_WATCH_URL = re.compile(
r"^https?://(?:www\.|music\.|m\.)?youtube\.com/watch\?v=([A-Za-z0-9_-]{11})"
)
_CHANNEL_URL = re.compile(r"/channel/(UC[A-Za-z0-9_-]{22})")
class TakeoutError(ValueError):
"""The file is not one the import reads; `reason` is the client's word for why."""
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
@dataclass
class Parsed:
"""One file's videos, `{video id: (time, channel id)}`, and what it left out, counted."""
kind: TakeoutKind
videos: dict[str, tuple[datetime, str | None]] = field(default_factory=dict)
entries: int = 0
ads: int = 0
other: int = 0 # not a view of a video (a post, a story) or a video with no link left
malformed: int = 0
def _time(raw: object) -> datetime | None:
if not isinstance(raw, str):
return None
try:
at = datetime.fromisoformat(raw.replace("Z", "+00:00"))
# silent-ok: the row is counted as malformed, and the count is in the upload's answer.
except ValueError:
return None
return at if at.tzinfo is not None else None
def _keep(parsed: Parsed, vid: str, at: datetime, channel: str | None) -> None:
old = parsed.videos.get(vid)
if old is None or at > old[0]:
parsed.videos[vid] = (at, channel or (old[1] if old else None))
def _history(data: Iterator[object]) -> Parsed:
parsed = Parsed(TakeoutKind.WATCHED)
for entry in data:
parsed.entries += 1
if not isinstance(entry, dict):
parsed.malformed += 1
continue
details = entry.get("details")
if isinstance(details, list) and any(
isinstance(d, dict) and d.get("name") == "From Google Ads" for d in details
):
parsed.ads += 1
continue
url = entry.get("titleUrl")
match = _WATCH_URL.match(url) if isinstance(url, str) else None
if match is None:
parsed.other += 1
continue
at = _time(entry.get("time"))
if at is None:
parsed.malformed += 1
continue
channel = None
subtitles = entry.get("subtitles")
if isinstance(subtitles, list) and subtitles and isinstance(subtitles[0], dict):
found = _CHANNEL_URL.search(str(subtitles[0].get("url", "")))
channel = found.group(1) if found else None
_keep(parsed, match.group(1), at, channel)
return parsed
def _watch_later(text: str) -> Parsed:
parsed = Parsed(TakeoutKind.WATCH_LATER)
rows = (row for row in csv.reader(io.StringIO(text)) if any(cell.strip() for cell in row))
first = next(rows, None)
if first is None or len(first) < 2:
raise TakeoutError("not_takeout")
# The first row is the header, in the account's language, unless it is already a video.
for row in chain([first], rows) if _VIDEO_ID.match(first[0].strip()) else rows:
parsed.entries += 1
vid = row[0].strip()
at = _time(row[1].strip()) if len(row) > 1 else None
if not _VIDEO_ID.match(vid) or at is None:
parsed.malformed += 1
continue
_keep(parsed, vid, at, None)
if len(parsed.videos) > WATCH_LATER_MAX:
raise TakeoutError("too_many")
return parsed
def _skip(text: str, at: int) -> int:
"""The first index at or after `at` that is not JSON whitespace."""
while at < len(text) and text[at] in " \t\n\r":
at += 1
return at
# How much of the file is decoded at a time. Decoded whole, the text costs up to four bytes a
# character (one emoji in a title is enough), several times the file on top of its bytes.
_WINDOW = 1024 * 1024
class _Window:
"""The file's text, decoded a window at a time: `text` holds what is not read yet from `at` on.
The bytes are the caller's, so only the window is this reader's memory."""
def __init__(self, body: bytes, start: int):
self._body, self._next = body, start
self._decoder = codecs.getincrementaldecoder("utf-8")() # strict: bad UTF-8 raises
self.text, self.at = "", 0
@property
def done(self) -> bool:
return self._next >= len(self._body)
def more(self, at_least: int = 0) -> None:
"""Decode the next window (at least `at_least` bytes), dropping what is read already."""
self.text, self.at = self.text[self.at :], 0
size = max(_WINDOW, at_least)
chunk = self._body[self._next : self._next + size]
self._next += len(chunk)
self.text += self._decoder.decode(chunk, final=self.done)
def skip(self) -> None:
"""Past the whitespace, reading on as far as it goes."""
while True:
self.at = _skip(self.text, self.at)
if self.at < len(self.text) or self.done:
return
self.more()
def startswith(self, char: str) -> bool:
self.skip()
return self.text.startswith(char, self.at)
def _decode(decoder: json.JSONDecoder, text: str, at: int) -> tuple[object, int] | None:
"""The JSON value at `at` and where it ends, or None where none reads (yet)."""
try:
return decoder.raw_decode(text, at)
# silent-ok: not a failure here — the caller reads on, or refuses the file once it has all.
except json.JSONDecodeError:
return None
def _json_list(body: bytes, start: int) -> Iterator[object]:
"""The entries of the JSON list at byte `start` (its "["), one at a time: the whole tree is never
built, nor the whole text, so a file of a million tiny entries costs a window and one entry, not
a million dicts at once."""
decoder = json.JSONDecoder()
window = _Window(body, start + 1) # past the "[" the caller saw
if window.startswith("]"):
window.at += 1
else:
while True:
window.skip()
read = _decode(decoder, window.text, window.at)
# An entry cut by the window's edge fails, or (a number) reads short: read on and try
# again, with a window twice what is left, so one entry larger than any window is read
# in a few tries, not one a window.
if read is None or (read[1] == len(window.text) and not window.done):
if window.done:
raise ValueError("not a JSON list")
window.more(2 * (len(window.text) - window.at))
continue
entry, window.at = read
yield entry
if window.startswith(","):
window.at += 1
elif window.startswith("]"):
window.at += 1
break
else:
raise ValueError("not a JSON list")
window.skip()
if window.at != len(window.text):
raise ValueError("text after the JSON list")
# The largest Watch later file read: YouTube's longest list is some 200 KB of rows, so a file ten
# times that is not one — and, unlike the history, it is decoded whole.
CSV_MAX_BYTES = 2 * 1024 * 1024
_BOM = codecs.BOM_UTF8
def parse(body: bytes) -> Parsed:
"""Read an uploaded Takeout file: the watch history (JSON) or the Watch later list (csv), told
apart by their content, not their name. Raises `TakeoutError` for anything else, and for a file
with nothing to import in it."""
start = len(_BOM) if body.startswith(_BOM) else 0
while start < len(body) and body[start] in b" \t\n\r\f\v":
start += 1
first = body[start : start + 1]
if first in (b"{", b"<"):
# Another JSON file of the export, or the history's HTML variant.
raise TakeoutError("not_takeout")
if first != b"[" and len(body) - start > CSV_MAX_BYTES:
raise TakeoutError("too_many")
try:
# A bad UTF-8 sequence is a ValueError too (UnicodeDecodeError), read below as not a file.
parsed = (
_history(_json_list(body, start))
if first == b"["
else _watch_later(body[start:].decode("utf-8"))
)
# A file the import cannot read, however it fails: bad JSON (a ValueError, as is a number too
# long to convert), nesting deeper than the decoder recurses, a csv field past csv's limit.
except (ValueError, RecursionError, csv.Error) as exc:
if isinstance(exc, TakeoutError):
raise
raise TakeoutError("not_takeout") from None
if not parsed.videos:
raise TakeoutError("no_videos")
return parsed
# One import per user at a time: a second file uploaded while the first one's run is under way runs
# after it (the scheduler's ad-hoc queue), but the Shorts job settles the same users' rows on a
# thread of its own, and the two would settle the same rows. One API process runs the scheduler, so a
# lock in this module holds it (as the channel backfill's does).
_user_locks: dict[int, threading.Lock] = {}
_user_locks_guard = threading.Lock()
@contextmanager
def _one_import(user_id: int) -> Iterator[None]:
with _user_locks_guard:
lock = _user_locks.setdefault(user_id, threading.Lock())
with lock:
yield
def run_target(user_id: int) -> str:
"""The import run's target, `job_runs.target`: one user's import."""
return f"user:{user_id}"
def target_title(db: Session, target: str) -> str | None:
"""Who an import run's target is, for the admin's run lists: the user's name, else their email."""
_, _, raw = target.partition("user:")
user = db.get(User, int(raw)) if raw.isdigit() else None
return (user.display_name or user.email) if user is not None else None
class TooMany(Exception):
"""The upload would take the user past `ROWS_MAX` kept videos."""
def stage(db: Session, user: User, parsed: Parsed) -> None:
"""Keep an uploaded file's videos for the import, unless that would take the user past
`ROWS_MAX` (`TooMany`). The caller commits."""
service.lock_takeout(db, user) # the count and the insert are one user's, not two uploads' each
if (
queries.takeout_rows_besides(db, user, parsed.kind, parsed.videos) + len(parsed.videos)
> ROWS_MAX
):
raise TooMany
service.stage_takeout(db, user, parsed.kind, parsed.videos)
# How many unknown Watch later videos one user may have looked up on the shared API budget in a day:
# two full Watch later lists (two units a hundred ids). The rest wait for the next day's runs.
_lookups = RateLimiter(max_events=2 * WATCH_LATER_MAX, window_seconds=24 * 3600)
def _put_in_watch_later(db: Session, user: User) -> dict:
"""Put the user's waiting Watch later videos into theirs here: the ones the catalog knows at once,
the others once YouTube was asked for them — when a YouTube-connected account can ask, today's
budget is above the backfill reserve, and the user's own daily share of lookups allows it. A video
not asked about yet waits for a later run. The lookup commits what it fetched; the caller commits
the rest."""
waiting = queries.takeout_waiting(db, user, TakeoutKind.WATCH_LATER)
if not waiting:
return {}
ids = [vid for _, vid in waiting]
known = queries.known_video_ids(db, ids)
ask = [vid for vid in ids if vid not in known][:WATCH_LATER_MAX]
asked: set[str] = set()
fetcher = get_service_user(db) if ask else None
if (
fetcher is not None
and not quota.backfill_reserve_reached()
and _lookups.room(str(user.id)) >= len(ask)
):
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP):
with YouTubeClient(db, fetcher) as yt:
ensure_videos(db, yt, ask)
# Counted once done: a lookup that failed spent none of the share (one run per user at a
# time, so the room asked above is still there).
_lookups.allow(str(user.id), cost=len(ask))
asked = set(ask)
# Only the rows read above: one a second upload staged meanwhile was never asked about.
return service.apply_takeout_watch_later(db, user, waiting, asked)
def run_import(user_id: int) -> dict:
"""The import job: apply what the user's kept Takeout rows can say now, as one recorded run.
The watched part commits before the Watch later part asks YouTube for the videos the catalog
lacks, so a YouTube failure there loses nothing already marked. Returns the counts."""
with _one_import(user_id), SessionLocal() as db:
user = db.get(User, user_id)
if user is None:
return {"skipped": "no user"}
result = service.apply_takeout_watched(db, user)
db.commit()
result |= _put_in_watch_later(db, user)
db.commit()
outcomes = queries.takeout_outcomes(db, user)
result["waiting"] = outcomes.get((TakeoutKind.WATCHED, None), 0)
if outcomes.get((TakeoutKind.WATCH_LATER, None)):
result["watch_later_waiting"] = outcomes[(TakeoutKind.WATCH_LATER, None)]
return result
def apply_waiting(db: Session) -> dict:
"""Settle what every user's import left waiting and can be settled now, one commit each: a
watched video the catalog has come to know and the Shorts probe has cleared (by its job or a
search's), and the Watch later videos a run could not put in yet. Run after each run of the
`shorts` job, which is what clears the most of them. Returns its counts, or an empty dict when
nothing was waiting that could move."""
marked = added = failed = 0
ready = set(queries.takeout_users_ready(db))
later = set(queries.takeout_users_watch_later_waiting(db))
for user_id in sorted(ready | later):
user = db.get(User, user_id)
if user is None:
continue
# One user's failure (a lookup YouTube refused) is theirs: it neither fails the job that
# runs this nor keeps the users after them waiting. What it did not settle waits.
try:
with _one_import(user_id):
if user_id in ready:
marked += service.apply_takeout_watched(db, user)["watched"]
db.commit()
if user_id in later:
added += _put_in_watch_later(db, user).get("watch_later_added", 0)
db.commit()
except Exception:
db.rollback()
failed += 1
log.exception(
"Takeout settling failed for user %s",
user_id,
extra=logs.event("takeout.settle.failed", user_id=user_id),
)
out = {}
if ready:
out["takeout_watched"] = marked
if added:
out["takeout_watch_later_added"] = added
if failed:
out["takeout_failed"] = failed
return out
+8
View File
@@ -124,6 +124,14 @@ async def capped_body(request: Request, limit: int) -> bytes:
return bytes(body)
async def drain_body(request: Request) -> None:
"""Read the rest of the body to nowhere, before a refusal is raised: answered mid-upload, the
client reports a network failure, not the refusal (`capped_stream_to` says why). `bodylimit`
bounds how much there can be."""
async for _ in request.stream():
pass
class ByteSink(Protocol):
"""Where `capped_stream_to` writes: a file, or anything that takes the bytes on their way to one."""
+129 -5
View File
@@ -1,18 +1,29 @@
import threading
from collections.abc import AsyncIterator
from datetime import date
from functools import partial
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy.orm.exc import StaleDataError
from app import quota
from app import jobruns, quota, scheduler
from app.auth import current_user
from app.db import get_db
from app.db import get_db, release_read_connection
from app.mailtext import lang_for_user
from app.ratelimit import RateLimiter
from app.feedquery import BadCursor, facet_counts, feed_count, feed_page, feed_summary, feed_years
from app.models import User
from app.reader import queries, service
from app.routes._common import ProgressIn, kept_position, not_found, youtube_refusal
from app.reader import queries, service, takeout
from app.routes._common import (
ProgressIn,
capped_body,
drain_body,
kept_position,
not_found,
youtube_refusal,
)
from app.sync.videos import parse_iso8601_duration
from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError
@@ -293,3 +304,116 @@ def get_video_detail(
items[0].get("contentDetails", {}).get("duration")
),
}
# --- Google Takeout ------------------------------------------------------------------------------
# One upload at a time per user, ten an hour, and two at once across every user: a file's bytes are
# held whole while it is read (its text a window at a time), so these bound what the accounts can
# make the process hold. The section uploads its files one after another.
_takeout_limiter = RateLimiter(max_events=10, window_seconds=3600)
_takeout_uploading: set[int] = set()
_takeout_uploading_guard = threading.Lock()
_TAKEOUT_AT_ONCE = 2
async def _takeout_body(
request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> AsyncIterator[bytes]:
# The sign-in check has run, and it was a SELECT: hand the connection back before a file of
# megabytes arrives, so a slow upload does not hold one `idle in transaction`. The user's id is
# taken first: the release expires the user, and reading it after would reload it, taking a
# connection straight back out.
uid = user.id
release_read_connection(db)
with _takeout_uploading_guard:
busy = uid in _takeout_uploading
full = not busy and len(_takeout_uploading) >= _TAKEOUT_AT_ONCE
taken = not busy and not full
if taken:
_takeout_uploading.add(uid)
if not taken or not _takeout_limiter.allow(str(uid)):
if taken:
with _takeout_uploading_guard:
_takeout_uploading.discard(uid)
# Read to the end before answering: refused mid-upload, the browser reports a network
# failure instead of the refusal (capped_stream_to says why).
await drain_body(request)
if busy:
raise refused(429, "takeout_busy")
if full:
raise refused(429, "takeout_server_busy")
raise refused(429, "takeout_cooldown")
try:
yield await capped_body(request, takeout.MAX_BYTES)
finally:
with _takeout_uploading_guard:
_takeout_uploading.discard(uid)
@router.post("/takeout")
def upload_takeout(
# The sign-in check FIRST: the body's own dependency needs the signed-in user, so a stranger's
# file is refused before a byte of it is read.
user: User = Depends(current_user),
body: bytes = Depends(_takeout_body),
db: Session = Depends(get_db),
):
"""One file of the user's Google Takeout export — the watch history or the Watch later list.
Its videos are kept and the import runs in the background; its outcome reaches the user's inbox."""
try:
parsed = takeout.parse(body)
except takeout.TakeoutError as exc:
if exc.reason == "no_videos":
raise refused(422, "takeout_no_videos") from None
if exc.reason == "too_many":
raise refused(422, "takeout_list_too_long", limit=takeout.WATCH_LATER_MAX) from None
raise refused(422, "takeout_unreadable") from None
try:
takeout.stage(db, user, parsed)
except takeout.TooMany:
db.rollback()
raise refused(422, "takeout_too_many", limit=takeout.ROWS_MAX) from None
db.commit()
scheduler.run_ad_hoc(
scheduler.TAKEOUT_IMPORT,
partial(takeout.run_import, user.id),
actor_id=user.id,
target=takeout.run_target(user.id),
notify=True,
)
return {
"ok": True,
"kind": parsed.kind,
"videos": len(parsed.videos),
"entries": parsed.entries,
"ads": parsed.ads,
"other": parsed.other,
"malformed": parsed.malformed,
}
@router.get("/takeout")
def takeout_status(user: User = Depends(current_user), db: Session = Depends(get_db)):
"""What the user's Takeout import has done: its last run (none before the first upload), and
every video kept from the export, by kind and outcome — a video the catalog did not know yet
counts as "waiting" until it does, and is marked then."""
target = takeout.run_target(user.id)
run = jobruns.last_run(db, scheduler.TAKEOUT_IMPORT, target)
return {
# Queued and not started: its run row is not written yet, and the last run is still the
# previous one, so the page waits for it rather than taking that one for this upload's.
"queued": scheduler.ad_hoc_queued(scheduler.TAKEOUT_IMPORT, target),
"max_bytes": takeout.MAX_BYTES,
"run": None
if run is None
else {
"status": run.status,
"started_at": iso(run.started_at),
"finished_at": iso(run.finished_at),
"result": run.result,
"failure": run.failure,
},
**queries.takeout_summary(db, user),
}
+6 -2
View File
@@ -3,7 +3,7 @@
Admin-only and read-only — rows leave the table through the retention job
(`job_run_retention_days`). Filtered on the server and paged by `_keyset`, like the server log and
the audit log beside it on the activity page. A row names what a reader needs without a second
request: the channel a user's ad-hoc run worked on, and how many log lines the run left (only
request: what a user's ad-hoc run worked on (the channel, the importing user), and how many log lines the run left (only
WARNING and up are stored, so most runs have none — the count says so before the click).
"""
@@ -116,8 +116,12 @@ def list_runs(
limit,
lambda r: r.started_at,
)
channels = {r.target for r in runs if r.job in scheduler.AD_HOC_JOBS and r.target}
channels = {r.target for r in runs if r.job == scheduler.CHANNEL_BACKFILL and r.target}
titles = catalog_queries.channel_titles(db, channels)
for target in {r.target for r in runs if r.job == scheduler.TAKEOUT_IMPORT and r.target}:
title = scheduler.ad_hoc_target_title(db, scheduler.TAKEOUT_IMPORT, target)
if title is not None:
titles[target] = title
lines: dict[int, int] = {}
if runs:
counted = db.execute(
+160 -41
View File
@@ -22,6 +22,7 @@ from app.plex.tmdb import run_people_enrich
from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync
from app.notifications import create_notification
from app.runstatus import RunStatus, RunTrigger
from app.reader import takeout
from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all
from app.sync.explore import purge_ephemeral
@@ -118,8 +119,9 @@ def _notify_done(
summary: str | None,
result: dict | None = None,
) -> None:
"""Post a completion notice to the triggering admin's inbox (manual runs only). Best
effort — a notification failure must never mask the job's own outcome."""
"""Post a completion notice to the inbox of who started the run: an admin's manual run, or a
user's ad-hoc run that asks for one. Best effort — a notification failure must never mask the
job's own outcome."""
try:
create_notification(
db,
@@ -272,7 +274,6 @@ def scheduler_snapshot(db) -> dict:
open_runs = _open_run_ids()
recent = jobruns.recent_runs(db, [*JOB_INTERVALS, *AD_HOC_JOBS], RECENT_RUNS)
open_runs |= _open_run_ids()
going_id = max((r for r, j in open_runs.items() if j == CHANNEL_BACKFILL), default=None)
jobs = []
for job_id, default_interval in JOB_INTERVALS.items():
a = acts.get(job_id, {})
@@ -288,30 +289,11 @@ def scheduler_snapshot(db) -> dict:
)
# Work a user starts on one target: no interval, no "run now" (it needs its target), but its
# runs are shown beside the jobs' — it spends the same quota. Its one in-memory guard is the
# target's own (sync/runner), so "running" is the runs this process has open: two channels can
# backfill at once, so a run still going counts even when a newer one already ended. That run then
# leads the row, so its channel, its "no outcome yet" and its dot are one run's, never the
# newer run's outcome under the older one's channel.
backfills = recent.get(CHANNEL_BACKFILL, [])
going = db.get(JobRun, going_id) if going_id is not None else None
if going is not None and going.status != RunStatus.RUNNING:
going = None # its outcome is written; only the bookkeeping has not caught up
if going is not None:
backfills = [going, *(r for r in backfills if r.id != going.id)][:RECENT_RUNS]
shown = backfills[0] if backfills else None
ad_hoc = [
{
"id": CHANNEL_BACKFILL,
"running": going is not None,
"target": shown.target if shown else None,
"target_title": (
catalog_queries.channel_title(db, shown.target)
if shown is not None and shown.target
else None
),
**_last_runs(backfills, open_runs),
}
]
# target's own (sync/runner, the import's per-user lock), so "running" is the runs this process
# has open: two targets can run at once, so a run still going counts even when a newer one already
# ended. That run then leads the row, so its target, its "no outcome yet" and its dot are one
# run's, never the newer run's outcome under the older one's target.
ad_hoc = [_ad_hoc_row(db, job, recent.get(job, []), open_runs) for job in AD_HOC_JOBS]
return {
"running_here": running_here,
"enabled": settings.scheduler_enabled,
@@ -333,6 +315,27 @@ def _seen(run, open_runs: dict[int, str]) -> RunStatus:
return RunStatus(run.status)
def _ad_hoc_row(db, job: str, runs: list[JobRun], open_runs: dict[int, str]) -> dict:
going_id = max((r for r, j in open_runs.items() if j == job), default=None)
going = db.get(JobRun, going_id) if going_id is not None else None
if going is not None and going.status != RunStatus.RUNNING:
going = None # its outcome is written; only the bookkeeping has not caught up
if going is not None:
runs = [going, *(r for r in runs if r.id != going.id)][:RECENT_RUNS]
shown = runs[0] if runs else None
return {
"id": job,
"running": going is not None,
"target": shown.target if shown else None,
"target_title": (
ad_hoc_target_title(db, job, shown.target)
if shown is not None and shown.target
else None
),
**_last_runs(runs, open_runs),
}
def _last_runs(runs: list, open_runs: dict[int, str]) -> dict:
"""A row's run fields from its newest runs (newest first): how the last one went, and the few
before it for the history dots."""
@@ -402,7 +405,12 @@ def _autotag_job() -> None:
def _shorts_job() -> None:
_job("shorts", run_shorts)
def work(db):
# A video waiting in someone's Takeout import becomes markable the moment the probe has
# said it is not a Short, so the import's waiting rows are settled right after it.
return run_shorts(db) | takeout.apply_waiting(db)
_job("shorts", work)
def _subscriptions_job() -> None:
@@ -608,48 +616,140 @@ def trigger_all(actor_id: int | None = None) -> list[str]:
# one channel. It runs on the same scheduler as a recorded run (trigger `ad_hoc`), so what it did and
# spent is in `job_runs` like any job's, not a background task only the log ever heard of.
CHANNEL_BACKFILL = "channel_backfill"
# A user's Google Takeout import (`reader.takeout`): its target is the user.
TAKEOUT_IMPORT = "takeout_import"
# Every ad-hoc job id: the page names each one (a frontend contract test reads this tuple).
AD_HOC_JOBS = (CHANNEL_BACKFILL,)
AD_HOC_JOBS = (CHANNEL_BACKFILL, TAKEOUT_IMPORT)
# What each one's target is called on the admin's pages: a channel's title, an importing user's name.
_TARGET_TITLES: dict[str, Callable[..., str | None]] = {
CHANNEL_BACKFILL: catalog_queries.channel_title,
TAKEOUT_IMPORT: takeout.target_title,
}
def run_ad_hoc(job: str, fn: Callable[[], dict], *, actor_id: int, target: str) -> None:
def ad_hoc_target_title(db, job: str, target: str) -> str | None:
"""The name of an ad-hoc run's target (None for a job with none, or a target since gone)."""
title = _TARGET_TITLES.get(job)
return title(db, target) if title is not None else None
# The ad-hoc runs added and not yet recorded, `job@target`: between the two the target's newest
# run row is still the previous run's, so a page watching it asks `ad_hoc_queued` too.
_queued: set[str] = set()
_queued_guard = threading.Lock()
# What the scheduler is told to run is only the key: the request itself waits here, the newest one
# per key, until a fire of that key takes it. A request made while its key's run is under way is so
# kept for the run after it, which that run asks for as it ends (`_running`): the scheduler would
# drop a second fire of an id whose run has not returned (one instance per job id).
_pending: dict[str, tuple[Callable[[], dict], int, bool]] = {}
_running: set[str] = set()
# Two at once, for the moment between a run letting go of its key and its thread returning: a fire
# then is what takes the next request, and is not dropped.
_AD_HOC_INSTANCES = 2
def ad_hoc_queued(job: str, target: str) -> bool:
"""Whether a run of `job` on `target` is queued and its run row not written yet."""
with _queued_guard:
return f"{job}@{target}" in _queued
def run_ad_hoc(
job: str, fn: Callable[[], dict], *, actor_id: int, target: str, notify: bool = False
) -> None:
"""Run `fn` now as `actor_id`'s run of `job` on `target`, a one-shot job on the scheduler. Its
own exclusion (if any) lives in `fn`, which says so in its result; one id per job and target, so
a second request before the first has even started is that same run."""
a second request before the first has even started is that same run, and one made while it runs
is the run after it. `notify` posts its outcome to the user's inbox, for work whose page does not
watch it run."""
if job not in AD_HOC_JOBS:
raise KeyError(job) # an unnamed job would show on the page as its bare id
sched = _scheduler
if sched is None:
if _scheduler is None:
# Only between shutdown and exit: start_scheduler runs at every API startup.
raise RuntimeError("the scheduler is not running")
key = f"{job}@{target}"
with _queued_guard:
_pending[key] = (fn, actor_id, notify)
_queued.add(key)
running = key in _running
if running:
log.info("ad-hoc %s for %s runs again once the run under way ends", job, target)
return
_fire(job, target)
def _fire(job: str, target: str) -> None:
"""Add the one-shot that takes `job@target`'s pending request. Raises (the request dropped) only
when the scheduler cannot take it."""
key = f"{job}@{target}"
sched = _scheduler
try:
if sched is None:
raise RuntimeError("the scheduler is not running")
sched.add_job(
_ad_hoc,
_ad_hoc_fired,
"date",
args=(job, fn, actor_id, target),
id=f"{job}@{target}",
args=(job, target),
id=key,
misfire_grace_time=None,
max_instances=_AD_HOC_INSTANCES,
)
# silent-ok: not a failure — the run for this target is already queued, and it does this work.
# silent-ok: not a failure — a fire of this key is already queued, and it takes this request.
except ConflictingIdError:
log.info("ad-hoc %s for %s already queued", job, target)
except Exception:
with _queued_guard:
_pending.pop(key, None)
_queued.discard(key)
raise
def _ad_hoc(job: str, fn: Callable[[], dict], actor_id: int, target: str) -> None:
def _ad_hoc_fired(job: str, target: str) -> None:
key = f"{job}@{target}"
with _queued_guard:
request = _pending.pop(key, None)
if request is None:
return # an earlier fire took it
_running.add(key)
fn, actor_id, notify = request
try:
_ad_hoc(job, fn, actor_id, target, notify)
finally:
with _queued_guard:
_running.discard(key)
again = key in _pending
if again:
try:
_fire(job, target)
except Exception:
# The request made meanwhile is lost with the scheduler: shut down, nothing runs.
log.exception("ad-hoc %s for %s not run again", job, target)
def _ad_hoc(
job: str, fn: Callable[[], dict], actor_id: int, target: str, notify: bool = False
) -> None:
key = f"{job}@{target}"
try:
run_id = jobruns.start(job, RunTrigger.AD_HOC, actor_id, target)
except Exception:
# No row to tell it in: the database is what failed. The log is the record.
log.exception("ad-hoc %s for %s not started: its run could not be recorded", job, target)
return
finally:
# Recorded (or never will be): the run row now speaks for it — unless another request came
# meanwhile, which is still queued.
with _queued_guard:
if key not in _pending:
_queued.discard(key)
_opened(run_id, job)
try:
_ad_hoc_work(job, fn, run_id)
_ad_hoc_work(job, fn, run_id, actor_id if notify else None)
finally:
_closed(run_id)
def _ad_hoc_work(job: str, fn: Callable[[], dict], run_id: int) -> None:
def _ad_hoc_work(job: str, fn: Callable[[], dict], run_id: int, notify_id: int | None) -> None:
with logs.bind(run_id=run_id):
# As in _run: only the work is inside the try.
try:
@@ -658,8 +758,23 @@ def _ad_hoc_work(job: str, fn: Callable[[], dict], run_id: int) -> None:
log.exception("job %s failed", job, extra=logs.event("scheduler.job.failed", job=job))
err, failure = _failure(exc)
_close(run_id, RunStatus.ERROR, error=err, failure=failure)
# The user's notice names the failure, never the upstream's own text: that is an English
# diagnostic, for the admin's run row and the log, not a user's inbox.
_notify_ad_hoc(
notify_id, job, RunStatus.ERROR, None, failure or {"failure": "unexpected"}
)
return
_finish_ok(job, run_id, result)
summary, data = _finish_ok(job, run_id, result)
_notify_ad_hoc(notify_id, job, RunStatus.OK, summary, data)
def _notify_ad_hoc(
user_id: int | None, job: str, status: RunStatus, summary: str | None, result: dict | None
) -> None:
if user_id is None:
return
with SessionLocal() as db:
_notify_done(db, user_id, job, status, summary, result)
# Room for every job at once (a "run all") beside a few ad-hoc runs: APScheduler's default pool of
@@ -700,3 +815,7 @@ def shutdown_scheduler() -> None:
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
with _queued_guard:
# What it held will not run.
_queued.clear()
_pending.clear()
+2 -2
View File
@@ -6,7 +6,7 @@
# 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);
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2).
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2); brotli 1.2.0 -> 1.2.1 (PYSEC-2026-4322).
APScheduler==3.11.3
Authlib==1.7.2
Mako==1.4.1
@@ -21,7 +21,7 @@ anyio==4.14.2
argon2-cffi-bindings==25.1.0
argon2-cffi==25.1.0
bgutil-ytdlp-pot-provider==1.3.1
brotli==1.2.0
brotli==1.2.1
certifi==2026.10.19
cffi==2.1.1
charset-normalizer==3.5.1
+12
View File
@@ -327,3 +327,15 @@ def _reset_log_erasures():
from app import logs
logs._erased.clear()
@pytest.fixture(autouse=True)
def _reset_ad_hoc_queue():
"""The scheduler's ad-hoc queue is module-level too: a request a test left pending would read as
a later test's user's import still queued (ids restart from 1)."""
from app import scheduler
with scheduler._queued_guard:
scheduler._queued.clear()
scheduler._pending.clear()
scheduler._running.clear()
+9
View File
@@ -159,6 +159,15 @@ def test_the_run_list_filters_pages_and_names_the_channel_and_the_log_lines(db,
assert facets["total"] == 4
def test_the_run_list_names_the_user_a_takeout_import_ran_for(db, make_user, as_user):
admin = make_user(role="admin")
importer = make_user("importer@example.com")
imported = _run(db, "takeout_import", "ok", importer.id, T0, f"user:{importer.id}", "ad_hoc")
db.commit()
(row,) = as_user(admin).get("/api/admin/runs").json()["items"]
assert (row["id"], row["target_title"]) == (imported, "importer@example.com")
def test_the_quota_breakdown_covers_its_range_and_one_account(db, make_user, as_user):
admin = make_user(role="admin")
now = datetime.now(timezone.utc)
+5
View File
@@ -23,6 +23,7 @@ from app import bodylimit, debugsession, sysconfig
from app.config import settings
from app.models import SystemConfig
from app.bodylimit import DEFAULT_MAX_BODY_BYTES, BodyLimitMiddleware
from app.reader import takeout
from app.routes import diagnostics as diagnostics_routes
SMALL = b"x" * 16
@@ -221,6 +222,7 @@ def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes():
CAPPED_BODY_SITES = {
("diagnostics.py", "_parsed_events", "capped_body", "BODY_MAX_BYTES"),
("debug_sessions.py", "_image_body", "capped_body", "debugsession.SCREENSHOT_MAX_BYTES"),
("feed.py", "_takeout_body", "capped_body", "takeout.MAX_BYTES"),
("files.py", "spool_upload", "capped_stream_to", "cap"),
}
@@ -368,6 +370,9 @@ def test_every_body_reading_site_is_known_and_fits_under_its_ceiling():
# The upload site's limit is the sysconfig knob, which is bounded BY the edge ceiling rather
# than being a constant to compare against — that pairing is its own test below.
assert ("POST", "/api/files/upload") in bodylimit.ROUTE_MAX_BODY_BYTES
# A Takeout file is larger than the default: without its ceiling every real history is a 413.
assert takeout.MAX_BYTES > DEFAULT_MAX_BODY_BYTES
assert bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/takeout")] == takeout.MAX_BYTES
def test_release_read_connection_ends_the_transaction_it_was_called_for(db):
+66 -5
View File
@@ -6,6 +6,7 @@ through (at the top of _job): holding _activity_lock across the check-and-set me
caller can hold a job's "running" claim at a time, and it's claimable again once released."""
import logging
import threading
import time
from datetime import datetime, timedelta, timezone
@@ -342,6 +343,29 @@ def test_an_ad_hoc_run_is_the_users_run_of_its_target_with_its_spend_and_failure
assert (failed.status, failed.error) == ("error", "quota exceeded")
def _ad_hoc_row(db, job: str) -> dict:
rows = scheduler.scheduler_snapshot(db)["ad_hoc"]
assert [r["id"] for r in rows] == list(scheduler.AD_HOC_JOBS) # every one has its row
return next(r for r in rows if r["id"] == job)
def test_a_takeout_import_has_its_row_named_by_the_importing_user(db, make_user):
user = make_user("importer@example.com")
user.display_name = "Ida Importer"
db.commit()
assert _ad_hoc_row(db, "takeout_import")["recent"] == []
scheduler._ad_hoc("takeout_import", lambda: {"watched": 2}, user.id, f"user:{user.id}")
row = _ad_hoc_row(db, "takeout_import")
assert (row["running"], row["target"], row["target_title"], row["status"]) == (
False,
f"user:{user.id}",
"Ida Importer",
"ok",
)
assert _ad_hoc_row(db, "channel_backfill")["recent"] == []
def test_the_page_shows_the_ad_hoc_runs_beside_the_jobs_with_their_channel(db, make_user):
user = make_user("shown@example.com")
db.add(Channel(id="UC_shown", title="BBC Earth"))
@@ -349,7 +373,7 @@ def test_the_page_shows_the_ad_hoc_runs_beside_the_jobs_with_their_channel(db, m
scheduler._ad_hoc("channel_backfill", lambda: {"skipped": "at capacity"}, user.id, "UC_none")
scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 69}, user.id, "UC_shown")
(row,) = scheduler.scheduler_snapshot(db)["ad_hoc"]
row = _ad_hoc_row(db, "channel_backfill")
assert (row["id"], row["running"], row["target"], row["target_title"]) == (
"channel_backfill",
False,
@@ -367,12 +391,12 @@ def test_the_page_shows_the_ad_hoc_runs_beside_the_jobs_with_their_channel(db, m
seen = []
scheduler._ad_hoc(
"channel_backfill",
lambda: seen.append(scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"]) or {},
lambda: seen.append(_ad_hoc_row(db, "channel_backfill")["running"]) or {},
user.id,
"UC_shown",
)
assert seen == [True]
assert scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"] is False
assert _ad_hoc_row(db, "channel_backfill")["running"] is False
def _finished(db, job) -> list[JobRun]:
@@ -397,9 +421,46 @@ def test_a_second_ad_hoc_request_before_the_first_starts_is_that_same_run(
"channel_backfill@UC_x",
"channel_backfill@UC_y",
]
# Queued, so its target's newest run row is not this run's yet: a page watching it can tell.
assert scheduler.ad_hoc_queued(scheduler.CHANNEL_BACKFILL, "UC_x")
assert not scheduler.ad_hoc_queued(scheduler.CHANNEL_BACKFILL, "UC_z")
live_scheduler.remove_all_jobs() # never run: a run finishing after the test would land in the next
def test_a_request_made_while_its_run_is_under_way_is_the_run_after_it(
db, make_user, live_scheduler
):
"""The scheduler drops a second fire of an id whose run has not returned: the second Takeout file
of a pair, uploaded while the first one's import ran, was never imported, and its key stayed
queued until a restart (the page spun on "Processing…" for good)."""
user = make_user("again@example.com")
first_runs, release = threading.Event(), threading.Event()
calls = []
def first():
first_runs.set()
assert release.wait(10)
calls.append("first")
return {"n": 1}
def second():
calls.append("second")
return {"n": 2}
scheduler.run_ad_hoc(scheduler.CHANNEL_BACKFILL, first, actor_id=user.id, target="UC_again")
assert first_runs.wait(10)
scheduler.run_ad_hoc(scheduler.CHANNEL_BACKFILL, second, actor_id=user.id, target="UC_again")
assert scheduler.ad_hoc_queued(scheduler.CHANNEL_BACKFILL, "UC_again")
release.set()
deadline = time.monotonic() + 10
while time.monotonic() < deadline and len(_finished(db, "channel_backfill")) < 2:
time.sleep(0.05)
assert calls == ["first", "second"]
assert sorted(r.result["n"] for r in _finished(db, "channel_backfill")) == [1, 2]
assert not scheduler.ad_hoc_queued(scheduler.CHANNEL_BACKFILL, "UC_again")
def test_a_manual_run_leaves_no_actor_behind_on_its_thread(db, make_user):
"""The scheduler's pool threads are reused: an actor a manual run left set made the next
scheduled run on that thread an admin's "manual" run, with a notice in their inbox."""
@@ -475,7 +536,7 @@ def test_a_run_whose_outcome_cannot_be_written_is_not_called_failed(db, make_use
scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 1}, user.id, "UC_blip")
assert calls == ["ok"]
# Its row is still `running`, but nothing here is: the page does not call it going.
(row,) = scheduler.scheduler_snapshot(db)["ad_hoc"]
row = _ad_hoc_row(db, "channel_backfill")
assert (row["running"], row["status"], row["recent"][0]["status"]) == (
False,
"interrupted",
@@ -529,7 +590,7 @@ def test_an_ad_hoc_run_still_going_shows_although_a_newer_one_ended(db, make_use
return {}
scheduler._ad_hoc("channel_backfill", long_one, user.id, "UC_long")
(row,) = rows
(row,) = [r for r in rows if r["id"] == "channel_backfill"]
assert (row["running"], row["target"], row["target_title"]) == (True, "UC_long", "Long one")
# The outcome beside it is the long one's (none yet), never the short one's skip.
assert (row["status"], row["result"], row["last_error"]) == (None, None, None)
File diff suppressed because it is too large Load Diff
+7
View File
@@ -44,6 +44,8 @@ UNIT_MODELS = {
"PlaylistItem",
"SavedView",
"ComfortPin",
"TakeoutItem",
"VideoUnmark",
"Tag",
"ChannelTag",
},
@@ -360,6 +362,11 @@ UNIT_COMMITS = {
"sync/playlists.py:pull_user_playlists",
"sync/playlists.py:merge_playlist",
"sync/playlists.py:repull_playlist",
# The Takeout import is a recorded run with its own session: the watched part lands before
# the Watch later part asks YouTube (which commits the videos it fetched).
"reader/takeout.py:run_import",
# Run inside the Shorts job, on its session: each user's waiting rows land on their own.
"reader/takeout.py:apply_waiting",
},
# The catalog's ingest jobs commit per page or batch, a YouTube call between each: a page
# fetched and stored is not lost when the next call fails, and an enrich that fails rolls back
+3 -1
View File
@@ -275,7 +275,9 @@ Also in force:
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller. The band
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; the floating
header over it is its header slot (`PageHeader`: the page's own pills, the palette's Jump-to pill where a
page has none, `PageHeaderDefault`). The band
holds controls only: an intro paragraph scrolls with the content (in the band it ate three lines of a
short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
+27 -1
View File
@@ -36,6 +36,32 @@ The dev server also serves the **design lab** at `http://localhost:5173/design-l
decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
(see [`DESIGN.md`](DESIGN.md)).
## Adding a page
A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
[`CLAUDE.md`](../CLAUDE.md) § Architecture shape):
1. The page itself, default-exported, and anything only it uses. Header content (a search box, a toggle) the
page renders into the floating header itself through `PageHeader`; the shell knows no page.
2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a
`lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily.
3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
hooks). Another folder reaches the module through this file or not at all.
4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
nav-rail order. The rail, the palette, the `g` chords, the tab title and the root's stores all read from there.
5. Its strings in both locales (`src/i18n/locales`).
6. A page with a side rail also names its panel below the module, in `lib/` (rails share one layout
and collapse machinery, which lib may not learn from a module): the `PanelId` and its default group
order, storage and pref keys in `lib/panelLayout.ts`, its collapse flag in `lib/useRail.ts`
(`COLLAPSE`), and those keys in `lib/storage.ts`. The rail calls `useRail("<id>")` and hands what it
returns to `ui/FilterRail`; tsc names each table still missing the new id.
What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily),
`moduleStores.contract.test.tsx` (no store reads another module), the `local/layers` lint rule (imports only go
down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
i18n parity test.
## The gate
A change is done when this is green, from the repo root:
@@ -105,7 +131,7 @@ npm run lint # eslint (typed rules included; warnings fail)
npm run format:check # prettier
npm run knip # unused files/exports/deps
npm run test # vitest (incl. the compiler and gate-register contracts)
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code)
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot)
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
```
+3 -2
View File
@@ -61,7 +61,8 @@ them itself (`no-undef`, `no-redeclare`, `no-dupe-keys`, …), nor the formattin
| src tests · ui tests · lib tests | `local/literal-a11y-text`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw input as a focus target, an untranslated label) that is not product UI. `local/button-primitive` stays on: in a test file (and in a control primitive) it only refuses a raw `<button disabled>`, which drops focus there as anywhere. | — |
| design-lab | `local/literal-a11y-text` — off | The dev-only design lab (never in the bundle — devOnlyGuard fails the build if it is) holds the owner's working copy, not product copy. | — |
| e2e | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | Playwright specs drive a browser from Node: no React, no product UI, and they print to the console on purpose. They get the base + typed rules + the playwright rules. | — |
| ts configs | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | The build configs (vite, vitest, playwright, reactPlugin, devOnlyGuard) run in Node at build time. | — |
| ts configs · tool tests | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | The build configs (vite, vitest, playwright, reactPlugin, devOnlyGuard, chunkGuard) run in Node at build time. Their unit tests beside them (`tool tests`: `chunkGuard.test.ts`) are linted the same way, plus `vitest/no-focused-tests` and `vitest/no-disabled-tests`, since vitest runs them. | — |
| src root tests · design-lab | `local/layers` — not applied (inside the rule: its `free` check, which the effective config cannot show) | The app-wide tests at `src/` root exercise a behaviour across modules (a write failure reported the same way in Plex and Files, the playlist editors' failures), so they render module-private components directly; the dev-only design lab shows every component. Neither ships. Every other layer, test or not, is held. | 3 imports of module-private files (`PlaylistEditFailures.dom.test.tsx` → `modules/plex/PlexPlaylistView`; `WriteFailureReporting.dom.test.tsx` → `modules/plex/PlexCollectionEditor`, `modules/files/UploadDialog`); design-lab 0 |
| js configs | `@typescript-eslint/*`, `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax`, `no-var`, `prefer-const`, `prefer-rest-params`, `prefer-spread`, `no-array-constructor`, `no-unused-expressions` — not applied | `eslint.config.js` and `postcss.config.js`: plain-JS build config, linted with `js.configs.recommended` only. | — |
| repo scripts | `scripts/*.mjs` — not in the eslint or prettier lane; `js.configs.recommended` + Prettier via `src/lib/repoScripts.contract.test.ts` | They sit at the repo root, and an ESLint config cannot reach past its own directory. The contract test lints and format-checks them from the repo root, to the same bar as the `js configs` row; no typed rules, as they are in no tsc project. | 0 lint hits; 2 files reformatted (E32 review) |
| everywhere | ignored paths: `dist`, `node_modules`, `playwright-report`, `test-results`, `e2e/.auth` | Build output, installed packages, test artefacts, a live Playwright session. Generated, not written. | — |
@@ -114,7 +115,7 @@ project checks needs a row.
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
| npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
| knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry |
| React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
| React Compiler | the functions it skips, pinned | `src/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
## Accepted findings
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { bootLeaks, type ChunkLike } from "./chunkGuard";
const APP = "/r/src/shell/App.tsx";
const chunk = (fileName: string, moduleIds: string[], imports: string[] = [], isEntry = false) =>
({ fileName, moduleIds, imports, isEntry }) satisfies ChunkLike;
describe("a lazily imported module", () => {
const lazy = new Set([APP, "/r/src/modules/plex/PlexBrowse.tsx"]);
it("is a leak in the entry chunk, in a chunk the entry imports, or in the App chunk", () => {
const P = "/r/src/modules/plex/PlexBrowse.tsx";
for (const chunks of [
[chunk("index.js", ["/r/src/main.tsx", P], [], true)],
[chunk("index.js", ["/r/src/main.tsx"], ["shared.js"], true), chunk("shared.js", [P])],
[chunk("index.js", ["/r/src/main.tsx"], [], true), chunk("App.js", [APP, P])],
[
chunk("index.js", ["/r/src/main.tsx"], [], true),
chunk("App.js", [APP], ["s.js"]),
chunk("s.js", [P]),
],
])
expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([
`${P} (${chunks[chunks.length - 1]!.fileName})`,
]);
});
it("is not a leak in its own chunk, or in one only another lazy chunk loads — and App itself is lazy by design", () => {
const chunks = [
chunk("index.js", ["/r/src/main.tsx"], ["react.js"], true),
chunk("react.js", ["/r/node_modules/react/index.js"]),
chunk("App.js", [APP], ["react.js"]),
chunk("PlexBrowse.js", ["/r/src/modules/plex/PlexBrowse.tsx"], ["react.js"]),
];
expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([]);
});
it("is checked on Windows paths too", () => {
const win = "D:\\r\\src\\modules\\plex\\PlexBrowse.tsx";
const chunks = [chunk("index.js", ["D:\\r\\src\\main.tsx", win], [], true)];
expect(bootLeaks(chunks, new Set([win]), "src/shell/App.tsx")).toHaveLength(1);
});
});
+73
View File
@@ -0,0 +1,73 @@
import type { Plugin } from "vite";
// A lazily imported page, rail, dialog or player must stay out of the chunks loaded at boot. A page
// is imported with import()/lazy() so it is fetched only when it is opened, and Rollup honours that
// only while nothing on the boot path imports the same module statically: one static import — a
// module's index.ts re-exporting it is the easy way — and the module joins a boot chunk, the import()
// becomes a no-op, and nothing says so. That rule used to be a hope; this makes it a build failure,
// naming each module and the boot chunk it landed in. The boot path is the entry chunks and what they
// import, plus the shell (App, itself loaded lazily but fetched at boot alongside the identity probe)
// and what it imports.
const SHELL = "src/shell/App.tsx";
export interface ChunkLike {
fileName: string;
isEntry: boolean;
/** The chunks this one imports statically. */
imports: string[];
moduleIds: string[];
}
const posix = (id: string) => id.split("\\").join("/");
/** Every lazily imported module (`lazy`: module ids) that sits in a boot chunk, as "id (chunk)". The
* shell's own module (`shell`, a path under the project) is exempt: it is lazy so that the public
* pages never load it, and its chunk is the shell. */
export function bootLeaks(chunks: ChunkLike[], lazy: Set<string>, shell: string): string[] {
const byName = new Map(chunks.map((c) => [c.fileName, c]));
const isShell = (id: string) => posix(id).endsWith(`/${shell}`);
const boot = new Set<ChunkLike>();
const visit = (c: ChunkLike | undefined) => {
if (!c || boot.has(c)) return;
boot.add(c);
c.imports.forEach((name) => visit(byName.get(name)));
};
chunks.filter((c) => c.isEntry || c.moduleIds.some(isShell)).forEach(visit);
const leaks: string[] = [];
for (const c of boot)
for (const id of c.moduleIds)
if (lazy.has(id) && !isShell(id)) leaks.push(`${posix(id)} (${c.fileName})`);
return leaks.sort();
}
export function chunkGuard(): Plugin {
return {
name: "siftlode-chunk-guard",
apply: "build",
generateBundle(_options, bundle) {
const lazy = new Set(
[...this.getModuleIds()].filter(
(id) => (this.getModuleInfo(id)?.dynamicImporters.length ?? 0) > 0,
),
);
const chunks: ChunkLike[] = Object.values(bundle).flatMap((o) =>
o.type === "chunk"
? [
{
fileName: o.fileName,
isEntry: o.isEntry,
imports: o.imports,
moduleIds: o.moduleIds,
},
]
: [],
);
const leaks = bootLeaks(chunks, lazy, SHELL);
if (leaks.length)
this.error(
`lazily imported, yet loaded at boot: ${leaks.join(", ")}. ` +
"Something on the boot path imports it statically (an index.ts re-export?): import it lazily there too, or not at all.",
);
},
};
}
+4 -2
View File
@@ -1,9 +1,9 @@
import { test as setup, expect, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { MEMBER_STORAGE } from "./helpers";
import { MEMBER_STORAGE, SEED_FILE } from "./helpers";
import { retryConnect } from "./retryConnect";
/**
@@ -99,6 +99,8 @@ setup("seed + authenticate", async ({ page, browser }) => {
{ input: readFileSync(SEED_SCRIPT) },
);
console.log(`[e2e seed] ${out.toString().trim()}`);
// What the seed chose, for the specs that need one of its videos (helpers.seeded).
writeFileSync(SEED_FILE, out);
} catch (err: unknown) {
const e = err as { stderr?: Buffer; message?: string };
throw new Error(`E2E seed failed: ${e.stderr?.toString() || e.message}`);
+10
View File
@@ -1,8 +1,18 @@
import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and
* `devices.spec.ts` run from it. */
export const MEMBER_STORAGE = "e2e/.auth/member.json";
// The seed's own answer (scripts/e2e_seed.py), written by global.setup: the videos it chose.
export const SEED_FILE = "e2e/.auth/seed.json";
/** The videos the seed chose for the main account under `key` in its answer. */
export function seeded(key: string): string[] {
const ids = (JSON.parse(readFileSync(SEED_FILE, "utf-8")) as Record<string, unknown>)[key];
if (!Array.isArray(ids) || !ids.length) throw new Error(`the seed chose no ${key} videos`);
return ids as string[];
}
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
export async function openApp(page: Page, path = "/"): Promise<void> {
+59
View File
@@ -0,0 +1,59 @@
import { test, expect } from "@playwright/test";
import { openApp, gotoPage, seeded } from "./helpers";
// Settings › Account › YouTube history: a Google Takeout watch history, uploaded through the real
// picker, is read, imported in the background, and the section says what it did — a video the
// catalog knows is marked watched, one it does not know is remembered for later. The seed clears
// the account's kept Takeout videos each run, but the projects share one account: a later one's
// import of the same video adds nothing new, so what each adds is counted against what was there
// before it.
test("a Takeout watch history uploads, imports, and the section says what it did", async ({
page,
}) => {
await openApp(page);
await gotoPage(page, "settings");
await page.getByRole("tab", { name: "Account" }).click();
await expect(page.getByRole("heading", { name: "YouTube history" })).toBeVisible();
// The first project's upload is an import already made: its "New import" opens the drop zone.
const zone = page.getByRole("button", { name: "Drop the files here, or choose them" });
const again = page.getByRole("button", { name: "New import" });
await expect(zone.or(again)).toBeVisible();
if (await again.isVisible()) await again.click();
await expect(zone).toBeVisible();
const before = (await (await page.request.get("/api/takeout")).json()) as {
watched?: Record<string, number>;
};
const watchedBefore = before.watched?.watched ?? 0;
// A video the catalog knows and the Shorts probe has cleared, chosen by the seed (one not probed
// yet would wait for the probe): the next one no earlier project of this run has imported.
const known = seeded("takeout_known")[watchedBefore];
expect(known, "the seed chose too few videos for every project").toBeTruthy();
// Watched now: later than any state the seed set, which the import would otherwise leave alone.
const now = new Date().toISOString();
const history = [
{ titleUrl: `https://www.youtube.com/watch?v=${known}`, time: now },
{ titleUrl: "https://www.youtube.com/watch?v=e2eUnknown0", time: now },
{
titleUrl: "https://www.youtube.com/watch?v=e2eAdvert00",
time: now,
details: [{ name: "From Google Ads" }],
},
];
const uploaded = page.waitForResponse(
(r) => r.url().endsWith("/api/takeout") && r.request().method() === "POST",
);
await page.locator('input[type="file"][accept=".json,.csv"]').setInputFiles({
name: "watch-history.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(history)),
});
expect((await uploaded).ok()).toBeTruthy();
await expect(page.getByText("watch-history.json: 2 videos read")).toBeVisible();
const line = page.getByTestId("takeout-last");
await expect(line).toContainText("Last import:");
await expect(line).toContainText(`${watchedBefore + 1} watched`);
await expect(line).toContainText("1 remembered");
await expect(page.getByRole("button", { name: "New import" })).toBeVisible();
});
+121 -3
View File
@@ -7,6 +7,8 @@ import globals from "globals";
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
import vitest from "@vitest/eslint-plugin";
import playwright from "eslint-plugin-playwright";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the
// hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this
@@ -99,7 +101,8 @@ const localeFormat = {
// is a clock face with its own 12/24h switch, not a date.
const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path);
const collates = /\/src\/lib\/collate\.ts$/.test(path);
const component = /\/src\/components\//.test(path);
// Every folder that holds components (CLAUDE.md "Architecture shape"), not lib/ below them.
const component = /\/src\/(components|shell|modules|features|watch)\//.test(path);
const importedFromI18n = (node) => {
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
const v = scope.set.get(node.name);
@@ -418,8 +421,105 @@ const glyphLink = {
};
},
};
// The app's layers (CLAUDE.md "Architecture shape"), bottom up: lib/ → components/ui/ → features/ →
// modules/ → shell/. An import may go down, never up — with one door: a module's index.ts, its public
// face, which features/ may read too (the module stores are mounted at the root and read app-wide).
// Anything else of ANOTHER module is reached through that face, or lazily by import(), which is a
// chunk boundary and so the way a page is opened. Measured on the import, resolved to its file, so
// every spelling of a path is the same path. lib/ and ui/ keep their own `no-restricted-imports`
// blocks below; this rule adds the folders above them.
const SRC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "src");
const RANK = { lib: 0, ui: 1, features: 2, modules: 3, shell: 4 };
function placeOf(abs) {
const rel = path.relative(SRC_DIR, abs).split(path.sep).join("/");
if (rel.startsWith("..")) return null;
const parts = rel.replace(/\.(tsx?|jsx?)$/, "").split("/");
const [top, unit] = parts;
if (top === "lib") return { layer: "lib" };
if (top === "components") return unit === "ui" ? { layer: "ui" } : { layer: "other" };
if (top === "features" || top === "modules") {
// `modules/plex` and `modules/plex/index` are the unit's public face.
const face = parts.length === 2 || (parts.length === 3 && parts[2] === "index");
return { layer: top, unit, face };
}
if (top === "shell") return { layer: "shell" };
// main.tsx, watch/, i18n/, content/, design-lab/, the app-wide tests at the root. The two entries —
// the app's main.tsx and the shared /watch page (watch/ + watchMain.tsx) — sit above every layer.
const root = parts.length === 1;
const entry = top === "watch" || (root && (top === "main" || top === "watchMain"));
return { layer: "other", top: root ? "root" : top, entry };
}
const layers = {
meta: {
type: "problem",
messages: {
up: "{{from}} sits below {{to}}: it may not import from it (`{{spec}}`). Move the shared piece down, or take it as a prop.",
face: "`{{spec}}` is one of {{unit}}'s own files: reach another unit through its index.ts (its public face), or lazily with import().",
entry:
"`{{spec}}` is an entry (the app's main.tsx or the shared /watch page): no layer imports one, or the app and the /watch page stop being separable.",
},
},
create(context) {
const file = context.filename;
const from = placeOf(file);
// The dev-only lab and the app-wide tests at the root look at everything on purpose.
const free =
!from ||
(from.layer === "other" && from.top === "design-lab") ||
(from.layer === "other" && from.top === "root" && /\.test(kit)?\.|\.testkit\./.test(file));
const check = (node, spec, lazy) => {
if (free || typeof spec !== "string" || !spec.startsWith(".")) return;
const to = placeOf(path.resolve(path.dirname(file), spec));
if (!to) return;
if (to.layer === "other") {
if (to.entry && from.layer !== "other")
context.report({ node, messageId: "entry", data: { spec } });
return;
}
const fromRank = from.layer === "other" ? 5 : RANK[from.layer];
if (
to.layer === "shell" &&
from.layer !== "shell" &&
!(from.layer === "other" && from.top === "root")
)
return context.report({
node,
messageId: "up",
data: { from: from.layer, to: "shell", spec },
});
const door = to.layer === "modules" && (to.face || lazy) && from.layer === "features";
if (RANK[to.layer] > fromRank && !door)
return context.report({
node,
messageId: "up",
data: { from: from.layer, to: to.layer, spec },
});
const sameUnit = to.layer === from.layer && to.unit === from.unit;
if (to.layer === "modules" && !sameUnit && !to.face && !lazy)
context.report({ node, messageId: "face", data: { spec, unit: `modules/${to.unit}` } });
};
return {
ImportDeclaration: (n) => check(n.source, n.source.value, false),
ExportNamedDeclaration: (n) => n.source && check(n.source, n.source.value, false),
ExportAllDeclaration: (n) => check(n.source, n.source.value, false),
ImportExpression: (n) => {
// A template literal with nothing substituted is a plain path to TypeScript and Rollup too.
const s = n.source;
if (s.type === "Literal") check(s, s.value, true);
else if (s.type === "TemplateLiteral" && s.expressions.length === 0)
check(s, s.quasis[0].value.cooked, true);
},
TSImportType: (n) => {
const lit = n.argument?.literal ?? n.argument;
if (lit?.type === "Literal") check(lit, lit.value, false);
},
};
},
};
const localRules = {
rules: {
layers,
"glyph-link": glyphLink,
"sortable-a11y": sortableA11y,
"jsx-comment-text": jsxCommentText,
@@ -536,7 +636,7 @@ export default tseslint.config(
// function the compiler skips over a suppression is caught by the coverage contract below.
// `todo` does NOT see every such shape — a conditional, a loop
// or an optional call inside a try skips the function with no lint hit at all — so the full
// per-function picture is components/compilerCoverage.contract.test.ts.
// per-function picture is src/compilerCoverage.contract.test.ts.
"react-hooks/todo": "error",
"react-hooks/memo-dependencies": "error",
"react-hooks/invariant": "error",
@@ -566,6 +666,7 @@ export default tseslint.config(
"local/literal-a11y-text": "error",
"local/glyph-link": "error",
"local/sortable-a11y": "error",
"local/layers": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
@@ -760,7 +861,14 @@ export default tseslint.config(
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
// typed rules as the app; Node globals, since none of it runs in the browser.
files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"],
files: [
"e2e/**/*.ts",
"*.config.ts",
"reactPlugin.ts",
"devOnlyGuard.ts",
"chunkGuard.ts",
"chunkGuard.test.ts",
],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
@@ -775,6 +883,16 @@ export default tseslint.config(
"no-undef": "off",
},
},
{
// The unit tests of the build tools beside them (chunkGuard's): vitest runs them, so a stray
// `.only` / `.skip` there is the same silent skip as in src.
files: ["*.test.ts"],
plugins: { vitest },
rules: {
"vitest/no-focused-tests": "error",
"vitest/no-disabled-tests": "error",
},
},
{
files: ["e2e/**/*.ts"],
plugins: { playwright },
+1 -1
View File
@@ -15,7 +15,7 @@ import reactCompiler from "babel-plugin-react-compiler";
// there is no logger here, so a skipped component still works and simply re-renders everything under
// it. This comment used to say "its rules are also linted"; it was false while `react-hooks/refs` was
// off, and 95 functions sat uncompiled behind it. What actually holds the line now is
// src/components/compilerCoverage.contract.test.ts, which runs THIS compiler over the shipped source
// src/compilerCoverage.contract.test.ts, which runs THIS compiler over the shipped source
// and fails on any skip not on its list; the react-hooks lint rules are the early warning. None of
// them is off (three run at the preset's warn, which `--max-warnings 0` fails — docs/gates.md), but
// three of the compiler's diagnostics cannot report under the installed plugin (eslint.config.js
-499
View File
@@ -1,499 +0,0 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { qk } from "./lib/queryKeys";
import { saveLanguage } from "./lib/saveLanguage";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, setUnauthorizedHandler, type Me } from "./lib/api";
import { notifyActionError } from "./lib/actionErrors";
import { setLanguage, type LangCode } from "./i18n";
import { clearParams } from "./lib/urlState";
import { reloadAfterRefusal } from "./lib/entryUrl";
import { useAccountSwitchNotice } from "./lib/useAccountSwitchNotice";
import {
PREF_KEY,
saveLayoutLocal,
seedLayout,
type PanelId,
type PanelLayout,
} from "./lib/panelLayout";
import { notify, removeByMetaKind } from "./lib/notifications";
import { LS } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useVerifyEntry } from "./lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "./components/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext";
import { useFeedView, useFeedViewActions } from "./components/feedViewContext";
import { moduleOrder } from "./lib/modules";
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
import { lazyWithPreload } from "./lib/lazyWithPreload";
import { LoadingState } from "./components/ui/QueryState";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import BackToTop from "./components/ui/BackToTop";
import PageShell from "./components/ui/PageShell";
import { SkipLink } from "./components/ui/SkipLink";
import { pageTitleKey } from "./lib/pageMeta";
import DebugRecorder from "./components/DebugRecorder";
import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette";
import DownloadIntake from "./components/DownloadIntake";
import DownloadTray from "./components/DownloadTray";
import { useGlobalKeymap } from "./lib/keymap";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import VerifyBanner from "./components/VerifyBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
import { focusSettingsTab } from "./lib/settingsTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
import { FRONTEND_VERSION } from "./lib/version";
import { applyDiagnostics } from "./lib/diagnostics";
// The page modules (and their side rails) are code-split per route via the module registry
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
// two modals, which sit outside the registry-driven content column.
const ChannelPage = lazyWithPreload(() => import("./components/ChannelPage"));
const About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
export default function App() {
const { t, i18n } = useTranslation();
const { page, channelView, ytSearch } = useNavigation();
const { setPage, enterYtSearch } = useNavigationActions();
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
// that branch never renders when signed in — the token was silently dropped. Signed out, the
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
const verifyEntry = useVerifyEntry();
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
const view = useFeedView();
const { setView } = useFeedViewActions();
const queryClient = useQueryClient();
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
// persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from
// the account's server preferences — BootGate mounts App only once `me` is cached — so a layout
// changed on another device is right on the first paint; the local copy covers a key the server
// has never stored.
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => {
const prefs = queryClient.getQueryData<Me | null>(qk.me())?.preferences;
return {
feed: seedLayout("feed", prefs),
plex: seedLayout("plex", prefs),
playlists: seedLayout("playlists", prefs),
files: seedLayout("files", prefs),
};
});
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
function openReleaseNotes(highlight?: string) {
setNotesHighlight(highlight);
setNotesOpen(true);
}
function setPanelLayout(panel: PanelId, next: PanelLayout) {
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
saveLayoutLocal(panel, next);
api
.savePrefs({ [PREF_KEY[panel]]: next })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
}
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
// user's preferences (via usePref) so it follows the account across devices, seeded from the `me`
// cache at mount; the localStorage mirror covers an account whose server prefs never stored it.
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
localKey: LS.filterCollapsed,
parse: Boolean,
});
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
localKey: LS.playlistsCollapsed,
parse: Boolean,
});
// The Files rail is the library's folder list — how you move around it, not a filter you set once —
// so it starts pinned open (the owner's mockup), and has its own flag.
const [filesCollapsed, setFilesCollapsed] = usePref<boolean>("filesCollapsed", false, {
localKey: LS.filesCollapsed,
parse: Boolean,
});
const railCollapse = {
filter: [filterCollapsed, setFilterCollapsed],
playlists: [playlistsCollapsed, setPlaylistsCollapsed],
files: [filesCollapsed, setFilesCollapsed],
} as const;
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
// Passive observers (enabled:false) of the setup-status and `me` queries BootGate fetches, polls
// and waits for: App mounts only once both have answered, so their caches are always populated here.
const setupQuery = useQuery({
queryKey: qk.setupStatus(),
queryFn: api.setupStatus,
enabled: false,
});
const needsSetup = setupQuery.data?.configured === false;
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// The signed-in account, or null/undefined while signed out. Hoisted here because half a dozen
// effects below depend on it, and a dep list is only honest if it names the value the body reads.
const me = meQuery.data;
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
// above the signed-out branch is worse than not binding it.
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
// The account's consented diagnostics level, applied to this tab's log: raised, the log is
// kept in more detail and streamed to the server until the level lapses. A debug session that is
// recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
// applyDiagnostics computes an effective-level key and returns early when it has not moved
// (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback
// before that early return, which is the half we WANT re-run. The old dep list picked out four
// primitive fields to dodge the fresh object identity a refetch produces; that is the checker's
// job to verify, not a comment's to assert, and it silently missed any other field the module
// may come to read.
const diagnostics = me?.diagnostics;
const debugSession = me?.debug_session;
useEffect(() => {
applyDiagnostics(
diagnostics,
debugSession,
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
);
}, [diagnostics, debugSession, queryClient]);
// Fetch the code of every module this account can open — and the channel page — once the first page
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
// requestIdleCallback, hence the timer).
// Depending on the whole `me` rather than its id is both honest and MORE correct: the module set
// is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission
// change — an admin grant, a Plex server appearing — alters it without the id moving, and the old
// `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent
// (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches
// nothing; only the idle callback is re-scheduled.
useEffect(() => {
if (!me) return;
const run = () => {
preloadPages([...moduleOrder(me), "settings"]);
ChannelPage.preload().catch(() => {});
};
const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined;
if (idle) {
const id = idle(run, { timeout: 3000 });
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(run, 1000);
return () => window.clearTimeout(id);
}, [me]);
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
useEffect(() => {
const probe = document.createElement("div");
probe.style.cssText =
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
document.body.appendChild(probe);
const w = probe.offsetWidth - probe.clientWidth;
document.body.removeChild(probe);
document.documentElement.style.setProperty("--sbw", `${w}px`);
}, []);
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
// Keyed on "is there a session at all", which is the whole condition the body tests. Re-running
// on an account switch was never the point — by then the params are already gone and a second
// clearParams is a no-op.
const signedIn = !!me;
useEffect(() => {
if (signedIn) clearParams("access", "verify", "reset");
}, [signedIn]);
// A sign-in as someone else that the server refused, or a reload as another account: say so, once.
useAccountSwitchNotice(signedIn);
// Any request that 401s means the session ended server-side. If we were signed in (cached
// `me` is a user object), reload to the login page at once (also covers any click that hits
// the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
// can't loop the public landing. A tab showing another account than the one signed in now
// reloads as that one instead, and says why (`reloadAfterRefusal`).
useEffect(() => {
setUnauthorizedHandler((err) => {
if (!queryClient.getQueryData(qk.me())) return;
queryClient.clear();
reloadAfterRefusal(err);
});
return () => setUnauthorizedHandler(null);
}, [queryClient]);
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
// Settings → Account so the new state is visible, then strip the param for a clean URL.
// "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list:
// `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the
// toast when the user changes language. (The old `[]` got away with it only because clearParams
// had already removed the param — an invariant held by a side effect two lines further down.)
const linkAnswered = useRef(false);
const { refetch: refetchMe } = meQuery;
useEffect(() => {
if (linkAnswered.current) return;
const result = new URLSearchParams(window.location.search).get("link");
if (!result) return;
linkAnswered.current = true;
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void refetchMe();
focusSettingsTab("account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
}
clearParams("link");
}, [t, setPage, refetchMe]);
// A Google OAuth flow started while already signed in (signing in as someone else, or a Google
// connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a
// neutral toast is the right feedback — the pre-auth login banner would never render here.
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
const oauthAnswered = useRef(false);
useEffect(() => {
if (oauthAnswered.current) return;
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
oauthAnswered.current = true;
notify({ level: "info", message: t("settings.account.googleCancelled") });
clearParams("oauth");
}, [t]);
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
// `me` is known, and only for an admin (others just land on their default page).
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
// Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the
// checker: the snapshot lives for the whole session, so with honest deps a later role change —
// or any `me` refetch, under the old id-only key — would yank the user to Users long after the
// deep link was answered.
const adminDeepLinkAnswered = useRef(false);
useEffect(() => {
if (adminDeepLinkAnswered.current) return;
if (adminDeepLink !== "access-requests" || !me) return;
adminDeepLinkAnswered.current = true;
if (me.role === "admin") {
focusAccessRequestsTab();
setPage("users");
}
clearParams("admin");
}, [adminDeepLink, me, setPage]);
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
// fresh load (which is why the notice used to linger until F5 after an approval).
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
// before — the notice stayed in the old language until the count changed.
const role = me?.role;
const pendingInvites = me?.pending_invites ?? 0;
const pendingActivations = me?.pending_activations ?? 0;
useEffect(() => {
if (role !== "admin") return;
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
removeByMetaKind("access-requests");
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
// address was approved before they registered. The second used to produce NO signal at all —
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
const pending = pendingInvites + pendingActivations;
if (pending > 0) {
notify({
level: "warning",
title: t("common.accessRequestsTitle"),
message: t("common.accessRequestsMessage", { count: pending }),
// Durable inbox link (survives reload, unlike a live action callback); the toast also
// gets a click via the action.
meta: { kind: "access-requests" },
action: {
label: t("common.accessRequestsReview"),
onClick: () => {
focusAccessRequestsTab();
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
setPage("users");
},
},
});
}
}, [role, pendingInvites, pendingActivations, t, setPage]);
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
setLanguage(code);
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
// the reporter stays quiet there.
saveLanguage(queryClient, code).catch((e: unknown) =>
notifyActionError(e, t("errors.writeFailed.prefs")),
);
}
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
if (needsSetup) return <SetupWizard />;
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
// probe — so no data here means "not signed in" → the public landing.
if (!me) return <Welcome verify={verifyEntry} />;
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
const pageFallback = <LoadingState className="flex-1 justify-center" />;
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
// page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
// the fresh result set starts at the top (bug 3). `q` is normalised out so typing in the search
// box doesn't reset the scroll on every keystroke; the live-YouTube sub-view gets its own key.
const normalScrollKey =
page !== "feed"
? `page:${page}`
: ytSearch != null
? `yt:${ytSearch}`
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A page
// the account can't reach never gets here: the navigation state already moved it to the feed
// (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it.
const { Component: PageContent, maxWidth } = PAGE_CONTENT[page];
const railDef = channelView ? undefined : PAGE_RAIL[page];
return (
<div className="h-dvh flex text-fg">
<SkipLink label={t("nav.skipToContent")} />
<NavSidebar
me={me!}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
language={i18n.language as LangCode}
/>
{/* The active page's side rail (feed filters / plex filters / playlists list), if it has one.
A floating collapsible column beside the nav rail; hidden while a channel page overlays the
content. feed + plex share the filter-rail collapse flag, playlists and files have their own. */}
{railDef && (
<Suspense fallback={null}>
<railDef.Rail
layout={panelLayouts[railDef.panelId]}
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
collapsed={railCollapse[railDef.collapse][0]}
onToggleCollapse={() => {
const [collapsed, setCollapsed] = railCollapse[railDef.collapse];
setCollapsed(!collapsed);
}}
/>
</Suspense>
)}
{/* `@container/appmain`: the floating header + its search box adapt to THIS column's width
(which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint
can't see the sidebar-driven narrowing. NB: `container-type: inline-size` implies
`contain: layout`, making this column a containing block for `position: fixed` descendants
too — safe today because floating-ui menus render via FloatingPortal (to document.body) and
the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */}
<div className="@container/appmain relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<PageShell scrollKey={`channel:${channelView.id}`}>
<Suspense fallback={pageFallback}>
<ChannelPage
key={channelView.id}
channelId={channelView.id}
initialName={channelView.name}
me={me!}
view={view}
setView={setView}
onShowInFeed={() => {
// Keep the rest of the feed's filters — the point is "this channel, the way I
// normally read": unwatched, my tags, my date window.
// ADD to the picked set, don't replace it: channels OR together now, so arriving
// from a second channel page should widen the feed, not silently drop the first.
// Deduped — clicking it again for a channel already picked is a no-op.
setFilters({
...filters,
channelIds: [...new Set([...filters.channelIds, channelView.id])],
});
// setPage already leaves an open channel page and pushes a clean history entry.
// Calling closeChannel() first ran history.back(), whose popstate landed AFTER this
// and restored whatever page the channel had been opened from.
setPage("feed");
}}
/>
</Suspense>
</PageShell>
) : (
<PageShell
title={t(pageTitleKey(page))}
scrollKey={normalScrollKey}
maxWidth={maxWidth}
header={<Header me={me!} onYtSearch={enterYtSearch} />}
banners={
<>
<VerifyBanner entry={verifyEntry} />
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
</>
}
>
<Suspense fallback={pageFallback}>
<PageContent />
</Suspense>
</PageShell>
)}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
Anchored inside the content column so they clear the sidebar automatically
(collapsed or expanded) without tracking its width. */}
<Toaster />
{/* The running downloads, bottom-right in the same column (the toasts keep the left). */}
<DownloadTray />
</div>
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
Mounted once, outside the page area, so every surface answers "what can I press here" and
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
<KeyboardHelp />
<AppCommandPalette />
{/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */}
<DownloadIntake />
{/* The debug recorder: its start dialog, and its pill docked under the toasts. */}
{me && <DebugRecorder />}
<Suspense fallback={null}>
{aboutOpen && (
<About
onClose={() => setAboutOpen(false)}
onOpenReleaseNotes={() => {
setAboutOpen(false);
openReleaseNotes();
}}
/>
)}
{notesOpen && (
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)}
</Suspense>
<ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
</div>
);
}
@@ -20,20 +20,20 @@ vi.mock("react-i18next", async (importOriginal) => {
}),
};
});
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()),
notify,
}));
import PlexPlaylistView from "./PlexPlaylistView";
import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import TagManager from "./TagManager";
import { api, HttpError, type PlexCard } from "../lib/api";
import PlexPlaylistView from "./modules/plex/PlexPlaylistView";
import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist";
import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
import TagManager from "./features/channel/TagManager";
import { api, HttpError, type PlexCard } from "./lib/api";
beforeAll(() => {
class Stub {
@@ -17,19 +17,19 @@ vi.mock("react-i18next", async (importOriginal) => {
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()),
notify,
}));
import PlexCollectionEditor from "./PlexCollectionEditor";
import UploadDialog from "./UploadDialog";
import DownloadDialog from "./DownloadDialog";
import { api, HttpError } from "../lib/api";
import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
import UploadDialog from "./modules/files/UploadDialog";
import DownloadDialog from "./features/downloads/DownloadDialog";
import { api, HttpError } from "./lib/api";
beforeAll(() => {
class Stub {
@@ -26,15 +26,15 @@ const KNOWN_SKIPS: CompileReport["skipped"] = {
// The files a regression hurt most: compiled, and they must stay so (a positive check too, so a
// harness that compiled nothing could not pass as "no skips").
const MUST_COMPILE: Record<string, string[]> = {
"components/Channels.tsx": ["Channels"],
"modules/channels/Channels.tsx": ["Channels"],
"components/ui/DataTable.tsx": ["DataTable"],
// Under every button and every popover in the app.
"components/ui/FocusSafeButton.tsx": ["FocusSafeButton"],
"components/ui/anchoredPopoverHooks.ts": ["useAnchoredPopover"],
"components/ui/AnchoredPopover.tsx": ["PopoverSurfaceInner"],
// The two players — the largest components here, and the last to compile.
"components/PlexPlayer.tsx": ["PlexPlayer"],
"components/PlayerModal.tsx": ["PlayerModal"],
"modules/plex/PlexPlayer.tsx": ["PlexPlayer"],
"features/player/PlayerModal.tsx": ["PlayerModal"],
"lib/playerModalHooks.ts": ["useYtProgress"],
};
@@ -7,7 +7,7 @@ import reactCompiler from "babel-plugin-react-compiler";
// word (`panicThreshold` is "none" and the build has no logger), so this is the only place a skip is
// visible at all. Used by compilerCoverage.contract.test.ts.
const SRC = new URL("../", import.meta.url);
const SRC = new URL("./", import.meta.url);
/** Every file the production build compiles: src, minus tests and test kits. */
export function shippedSources(): string[] {
-137
View File
@@ -1,137 +0,0 @@
import { forwardRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { YouTubeIcon } from "../lib/brandIcons";
import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import { useFiles, useFilesActions } from "./filesContext";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
import PlexViewToggle from "./PlexViewToggle";
import FeedScopeToggle from "./FeedScopeToggle";
import SyncStatus from "./SyncStatus";
import { useNavigation } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
import { useChannels, useChannelsActions } from "./channelsContext";
import { PaletteLauncher } from "./PaletteLauncher";
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
// has one, and the per-user sync chip at the right end.
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
// owns both the header slot and the padded content wrapper.
const Header = forwardRef<
HTMLDivElement,
{
me: Me;
// Trigger a live YouTube search for the current term (feed only).
onYtSearch: (q: string) => void;
}
>(function Header({ me, onYtSearch }, ref) {
const { t } = useTranslation();
const { page } = useNavigation();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// Each module's search term lives in its own provider (shared with that module's rail/content):
// playlists name filter, Plex search term, channels name filter. The channel sync chip's
// "go to full history" jump is a ChannelsProvider action.
const { search: playlistsSearch } = usePlaylists();
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { search: channelsSearch } = useChannels();
const { search: filesSearch } = useFiles();
const { setSearch: setFilesSearch } = useFilesActions();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
// Per-page SearchBar wiring — null for modules without a search.
// A page without a search field still gets the palette's entry (PaletteLauncher).
let search: ReactNode = <PaletteLauncher />;
if (page === "feed") {
const trimmed = filters.q.trim();
search = (
<SearchBar
value={filters.q}
placeholder={t("header.searchPlaceholder")}
onChange={(q) => {
// When a search first appears, rank the feed by relevance — set atomically with the
// query (race-free vs. per-keystroke updates). Only overrides the default "newest"
// sort; a custom sort the user chose is left alone. Cleared in Feed.
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}}
onGo={() => onYtSearch(trimmed)}
goLabel={t("header.searchYoutube")}
goTitle={t(
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
)}
goIcon={<YouTubeIcon className="w-4 h-4" />}
goDisabled={!trimmed}
/>
);
} else if (page === "plex" && me.plex_enabled) {
search = <PlexSearchBox />;
} else if (page === "channels") {
search = (
<SearchBar
value={channelsSearch}
placeholder={t("channels.searchPlaceholder")}
onChange={setChannelsSearch}
onGo={blur}
/>
);
} else if (page === "files") {
search = (
<SearchBar
value={filesSearch}
placeholder={t("files.searchPlaceholder")}
onChange={setFilesSearch}
onGo={blur}
/>
);
} else if (page === "playlists") {
search = (
<SearchBar
value={playlistsSearch}
placeholder={t("playlists.searchPlaceholder")}
onChange={setPlaylistsSearch}
onGo={blur}
/>
);
}
return (
// Absolutely positioned within the (relative) content column, so its left edge tracks the
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
// slim and/or no filter sidebar is present — no hard-coded offset.
<div
ref={ref}
// left-4 / right = 1rem + the scrollbar gutter defines the full content column; max-w-[--content-mw]
// + mx-auto then centres the header band on the SAME width the page's content uses, so on a max-width
// page (Plex/Channels/Playlists) the band lines up with the centred grid, not the whole column. `none`
// (feed) = full width. Matches the toolbar band below it. `justify-center` then centres the search-box
// group (search + scope toggle / sync chip) within that band, so the box sits centred over the cards
// once the column is wider than the box's max-w — not hugged to the left edge.
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
>
{search}
{/* Mine/Library scope sits right of the feed search box — relocated here from the filter sidebar
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
{page === "feed" && <FeedScopeToggle />}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
the filter sidebar so the primary "what am I browsing" control is always in reach. The
Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
pills rather than pinned to the far right: on a wide window that just strands it. Only on
the two modules it says anything about: the feed those subscriptions fill, and the manager
where you curate them. It's noise on Plex (a different source entirely), playlists, etc. */}
{(page === "feed" || page === "channels") && (
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
)}
</div>
);
});
export default Header;
@@ -1,28 +0,0 @@
import { useState, type ReactNode } from "react";
import {
PlexViewPublishContext,
PlexViewReadContext,
type PlexViewControls,
} from "./plexViewContext";
// A one-way bridge so the always-mounted Header can host the Plex Discover ⟷ Browse toggle even though
// the sub-view state lives in (and is owned by) the plex-only PlexBrowse. PlexBrowse PUBLISHES its
// current top-level view + opener here (on mount and on view change) and clears it on unmount; the
// Header READS it to render the toggle beside the library-scope pill.
//
// Why a bridge and not a global sub-view provider: PlexBrowse's `useHistorySubview` REMOUNTS every time
// you enter the plex page, re-seeding the view from history (a page switch clears ?v + drops _sub, so
// each plex entry starts at the Discover root). Lifting that hook to an always-mounted provider would
// lose the remount and let the view go stale across navigation. Keeping it in PlexBrowse preserves the
// reseed; the bridge only mirrors the small slice the header needs.
//
// The contexts + hooks live in plexViewContext.ts.
export function PlexViewBridge({ children }: { children: ReactNode }) {
const [controls, setControls] = useState<PlexViewControls | null>(null);
return (
<PlexViewPublishContext.Provider value={setControls}>
<PlexViewReadContext.Provider value={controls}>{children}</PlexViewReadContext.Provider>
</PlexViewPublishContext.Provider>
);
}
@@ -1,264 +0,0 @@
import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
api,
coercePlexFilters,
plexFiltersSignature,
type FeedFilters,
type PlexFilters,
type SavedView,
} from "../lib/api";
import { filtersToParams, hasFilterParams } from "../lib/urlState";
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
import { notifyActionError } from "../lib/actionErrors";
import { LS, ownKey } from "../lib/storage";
import { useConfirm } from "./confirmContext";
import { useNavigation } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { usePlex, usePlexActions } from "./plexContext";
import {
asFilters,
SavedViewsCtx,
type SavedViewsContext,
type ViewSource,
} from "./savedViewsContext";
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
// switches which module it operates on by the active page (both filter providers are global, so it can
// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and
// on the plex page against plex views without a second provider or an App-layout change.
//
// The context, its value type and the hook live in savedViewsContext.ts.
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
interface ViewConfig {
source: ViewSource;
filters: Record<string, unknown>;
setFilters: (f: Record<string, unknown>) => void;
keyOf: (f: Record<string, unknown>) => string;
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
* stored filters for the active account, and — feed only — no URL filter override. */
canSeedDefault: () => boolean;
}
const feedKeyOf = (f: Record<string, unknown>) =>
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
// True once this account has its own persisted filters for `lsKey` — the signal that the last
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
// report "stored" so a default never seeds blind over an indeterminate state.
function storedFiltersPresent(lsKey: string): boolean {
const key = ownKey(lsKey);
if (!key) return true;
try {
return localStorage.getItem(key) != null;
} catch {
return true;
}
}
// Feed also yields to a URL that carries filter params (a shared/bookmarked view).
function feedCanSeedDefault(): boolean {
if (hasFilterParams(new URLSearchParams(window.location.search))) return false;
return !storedFiltersPresent(LS.filters);
}
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
function plexCanSeedDefault(): boolean {
return !storedFiltersPresent(LS.plexFilters);
}
export function SavedViewsProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const { page } = useNavigation();
// Both filter providers are global — read both, then use the one the active page owns.
const feedFilters = useFeedFilters();
const { setFilters: setFeedFilters } = useFeedFiltersActions();
const { filters: plexFilters } = usePlex();
const { setFilters: setPlexFilters } = usePlexActions();
const cfg: ViewConfig = useMemo(() => {
if (page === "plex") {
return {
source: "plex",
filters: plexFilters as unknown as Record<string, unknown>,
setFilters: (f) => setPlexFilters(f as unknown as PlexFilters),
keyOf: plexKeyOf,
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
stripForSave: (f) => f, // Plex has no transient field to drop
canSeedDefault: plexCanSeedDefault,
};
}
return {
source: "feed",
filters: feedFilters as unknown as Record<string, unknown>,
setFilters: (f) => setFeedFilters(f as unknown as FeedFilters),
keyOf: feedKeyOf,
asFilters: (raw) =>
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
stripForSave: (f) => {
const { seed: _seed, ...rest } = f;
return rest;
},
canSeedDefault: feedCanSeedDefault,
};
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
const source = cfg.source;
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
// (the public landing), and saved views are per-account, so asking for "nobody's" views only logs
// a 401 on the landing page. Enabled once a signed-in account is cached.
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const viewsQuery = useQuery({
queryKey: qk.savedViews(source),
queryFn: () => api.savedViews(source),
enabled: !!me,
});
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
const [stickyId, setStickyId] = useState<number | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
// and a feed modal shouldn't linger open on plex. Adjusted DURING RENDER against the previous
// source rather than from an effect, so the first plex render never commits carrying the feed's
// sticky id — which is exactly long enough for a stale view chip to paint.
const [prevSource, setPrevSource] = useState(source);
if (prevSource !== source) {
setPrevSource(source);
setStickyId(null);
setModalOpen(false);
}
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
// stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists
// it, so it happens once and the normal last-view-wins path takes over, exactly as the storage
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
// state to the account (claimLocalData) before this provider mounts.
const seededRef = useRef<Set<ViewSource>>(new Set());
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
// fire on the views query's first resolve for a source and never again, which `seededRef`
// enforces. Listing cfg would re-arm nothing (the guard still holds) but would re-run the effect
// on every keystroke in the filter bar.
const latestCfg = useEffectEvent(() => cfg);
useEffect(() => {
const data = viewsQuery.data;
if (!data || seededRef.current.has(source)) return;
seededRef.current.add(source);
const c = latestCfg();
const def = pickDefaultToSeed(data, c.canSeedDefault());
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
if (def) c.setFilters(c.asFilters(def.filters));
}, [viewsQuery.data, source]);
const currentKey = cfg.keyOf(cfg.filters);
const activeView = useMemo(
() =>
preferSticky(
views.filter((v) => cfg.keyOf(v.filters) === currentKey),
stickyId,
),
[views, currentKey, stickyId, cfg],
);
// The sticky id follows whichever view is active — also adjusted during render. It converges by
// construction: preferSticky returns the sticky match when there is one, so the very write below
// makes its own condition false on the next pass.
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
const stickyView = useMemo(
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
[views, stickyId],
);
const displayView = activeView ?? stickyView;
const isDirty = displayView != null && activeView == null;
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
let ok = true;
try {
await fn();
} catch (e) {
notifyActionError(e, failMsg);
ok = false;
}
refresh();
return ok;
}
const value: SavedViewsContext = {
source,
views,
loading: viewsQuery.isLoading,
activeViewId: activeView?.id ?? null,
displayView,
isDirty,
modalOpen,
openModal: () => setModalOpen(true),
closeModal: () => setModalOpen(false),
apply: (v) => {
cfg.setFilters(cfg.asFilters(v.filters));
setStickyId(v.id);
},
clearActive: () => setStickyId(null),
create: async (name) => {
const clean = name.trim();
if (!clean) return false;
return runViewMutation(
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
t("views.actionFailed"),
);
},
rename: async (id, name) => {
const next = name.trim();
if (!next) return false;
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
},
updateFilters: (id, filters) =>
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
saveCurrent: async (id) => {
const filters = cfg.stripForSave(cfg.filters);
const ok = await runViewMutation(
() => api.updateView(id, { filters }),
t("views.actionFailed"),
);
return ok ? filters : null;
},
toggleDefault: (v) =>
runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"),
).then(() => undefined),
remove: async (v) => {
const ok = await confirm({
message: t("views.deleteConfirm", { name: v.name }),
confirmLabel: t("views.delete"),
danger: true,
});
if (!ok) return false;
if (stickyId === v.id) setStickyId(null);
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
},
reorder: async (nextIds) => {
qc.setQueryData<SavedView[]>(
qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
);
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
},
};
return <SavedViewsCtx.Provider value={value}>{children}</SavedViewsCtx.Provider>;
}
@@ -12,7 +12,7 @@ import { useTranslation } from "react-i18next";
import { setLanguage, type LangCode } from "../../i18n";
import { formatDate } from "../../lib/format";
import { send } from "../../lib/http";
import LanguageSwitcher from "../LanguageSwitcher";
import LanguageSwitcher from "../ui/LanguageSwitcher";
import { SURFACE } from "../ui/surfaces";
// The day the text last changed materially — bump it with every edit of either body.
-102
View File
@@ -1,102 +0,0 @@
import type { ComponentType } from "react";
import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload";
import type { Page } from "../lib/urlState";
import type { PanelId, PanelLayout } from "../lib/panelLayout";
import Sidebar from "./Sidebar";
import PlaylistsRail from "./PlaylistsRail";
import { Feed, FeedPage } from "./modulePages";
// The RENDER half of the module registry: which component (and side rail) each page renders. The
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
// Header / App's gate check). Together they let App drive the whole shell from `page` alone, with no
// `page === X` chrome conditionals.
//
// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state
// from the scoped providers, so App renders `<Content />` uniformly. The two that need per-render
// wiring get a thin wrapper in modulePages (FeedPage supplies the GLOBAL feed scope; the channel page
// threads its own isolated scope to <Feed> directly — see the scope note in FeedFiltersProvider).
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>. Each
// one is preloadable: App fetches the chunks of every page the account can reach once the first page
// is on screen (preloadPages below), so switching modules never shows the chunk fallback.
const Home = lazyWithPreload(() => import("./Home"));
const Channels = lazyWithPreload(() => import("./Channels"));
const Playlists = lazyWithPreload(() => import("./Playlists"));
const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse"));
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
const Files = lazyWithPreload(() => import("./Files"));
const FilesRail = lazyWithPreload(() => import("./FilesRail"));
const Stats = lazyWithPreload(() => import("./Stats"));
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
const Activity = lazyWithPreload(() => import("./Activity"));
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
export type PageDef = {
// Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so
// a new page cannot silently opt out of preloadPages.
Component: PreloadableComponent<object>;
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
// so the floating chrome lines up with the centred content instead of spanning the whole column.
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
maxWidth?: string;
};
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
home: { Component: Home, maxWidth: "80rem" },
// The two wrappers preload the lazy chunk they render.
feed: { Component: Object.assign(FeedPage, { preload: Feed.preload }) },
channels: { Component: Channels, maxWidth: "96rem" },
playlists: { Component: Playlists, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "100rem" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
files: { Component: Files, maxWidth: "80rem" },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
config: { Component: ConfigPanel },
users: { Component: AdminUsers, maxWidth: "48rem" },
activity: { Component: Activity },
settings: { Component: SettingsPanel },
};
// The uniform side-rail contract — shell layout/collapse only; the rail reads its module state from
// its provider and `me` from useMe().
type RailProps = {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
};
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists and files
// have their own).
type RailCollapseKey = "filter" | "playlists" | "files";
export type RailDef = {
Rail: ComponentType<RailProps>;
// The persisted panel-layout record this rail edits (App owns the panelLayouts map).
panelId: PanelId;
collapse: RailCollapseKey;
};
// page id → its side rail (only the pages that have one). App renders it in the rail slot.
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
files: { Rail: FilesRail, panelId: "files", collapse: "files" },
};
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
* swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */
export function preloadPages(pages: Page[]): void {
for (const p of pages) {
PAGE_CONTENT[p].Component.preload().catch(() => {});
const rail = PAGE_RAIL[p]?.Rail as Partial<PreloadableComponent<RailProps>> | undefined;
rail?.preload?.().catch(() => {});
}
}
@@ -1,15 +0,0 @@
import { createContext, useContext } from "react";
import { type Sub } from "./plexSubUrl";
// The read + publish sides of the Plex view bridge (see PlexViewBridge for why it is a bridge), split
// out of that file so it exports only components (Fast Refresh).
export type PlexViewControls = {
kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it
open: (v: Sub) => void;
};
export const PlexViewReadContext = createContext<PlexViewControls | null>(null);
export const PlexViewPublishContext = createContext<(c: PlexViewControls | null) => void>(() => {});
export const usePlexViewControls = () => useContext(PlexViewReadContext);
export const usePublishPlexView = () => useContext(PlexViewPublishContext);
-25
View File
@@ -1,25 +0,0 @@
import { createContext, useContext } from "react";
import { DEFAULT_THEME } from "../lib/theme";
import { getNotifSettings } from "../lib/notifications";
import type { PrefsController } from "./SettingsPanel";
// The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only
// components (Fast Refresh).
export const PrefsContext = createContext<PrefsController>({
theme: DEFAULT_THEME,
setTheme: () => {},
perf: false,
setPerf: () => {},
hints: true,
setHints: () => {},
railLabels: false,
setRailLabels: () => {},
notif: getNotifSettings(),
setNotif: () => {},
saveState: "idle",
});
export function usePrefs(): PrefsController {
return useContext(PrefsContext);
}
@@ -1,7 +1,7 @@
import { useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
import Modal, { ModalFooter } from "./Modal";
import { Button } from "./Button";
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
// The dialog behind useConfirm()/useChoice() (see confirmContext.ts for the hooks and their contract).
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
//
// ui/DropZone: a drop or a pick hands over the files (only the first unless `multiple`), the drag
// highlight survives the cursor crossing the zone's own children, and a disabled zone takes nothing.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { DropZone } from "./DropZone";
afterEach(cleanup);
const files = (...names: string[]) => names.map((n) => new File(["x"], n));
function mount(props: Partial<Parameters<typeof DropZone>[0]> = {}) {
const onFiles = vi.fn();
render(
<DropZone onFiles={onFiles} {...props}>
{(over) => <span data-testid="inner">{over ? "over" : "idle"}</span>}
</DropZone>,
);
return { onFiles, zone: screen.getByRole("button"), inner: screen.getByTestId("inner") };
}
describe("DropZone", () => {
it("a drop hands over the first file, or all of them when multiple", () => {
const one = mount();
fireEvent.drop(one.zone, { dataTransfer: { files: files("a", "b") } });
expect(one.onFiles.mock.calls[0]![0].map((f: File) => f.name)).toEqual(["a"]);
cleanup();
const many = mount({ multiple: true });
fireEvent.drop(many.zone, { dataTransfer: { files: files("a", "b") } });
expect(many.onFiles.mock.calls[0]![0].map((f: File) => f.name)).toEqual(["a", "b"]);
});
it("a pick hands over the picked files; an empty pick hands over nothing", () => {
const { onFiles } = mount({ multiple: true });
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
expect(input.multiple).toBe(true);
fireEvent.change(input, { target: { files: [] } });
expect(onFiles).not.toHaveBeenCalled();
fireEvent.change(input, { target: { files: files("c") } });
expect(onFiles.mock.calls[0]![0].map((f: File) => f.name)).toEqual(["c"]);
});
it("stays highlighted while the cursor crosses its own children, and clears on the last leave", () => {
const { zone, inner } = mount();
fireEvent.dragEnter(zone);
fireEvent.dragEnter(inner); // into a child: the zone gets its leave after this enter
fireEvent.dragLeave(zone);
expect(inner.textContent).toBe("over");
fireEvent.dragLeave(inner);
expect(inner.textContent).toBe("idle");
});
it("a disabled zone takes no drop", () => {
const { zone, onFiles } = mount({ disabled: true });
fireEvent.drop(zone, { dataTransfer: { files: files("a") } });
expect(onFiles).not.toHaveBeenCalled();
});
it("an invalid zone reads as invalid and points at the reason", () => {
const { zone } = mount({ invalid: true, describedBy: "why" });
expect(zone.getAttribute("aria-invalid")).toBe("true");
expect(zone.getAttribute("aria-describedby")).toBe("why");
});
});
+101
View File
@@ -0,0 +1,101 @@
import { useRef, useState, type ReactNode, type Ref } from "react";
import clsx from "clsx";
import { Pressable } from "./Pressable";
// A place to drop files onto, which is also the button that opens the native file dialog: the
// dashed surface an upload starts from. It owns what each such surface needed and the first one
// got right only after a flicker: dragenter/dragleave fire as the cursor crosses the zone's OWN
// children (an icon, a line of text), so a plain boolean flickers — enter and leave are counted
// and the highlight clears at depth 0. The caller draws the inside, told whether a drag is over it.
export function DropZone({
ref,
onFiles,
multiple = false,
accept,
disabled = false,
invalid = false,
describedBy,
className,
children,
}: {
/** The zone's button, for a caller that moves the focus to it. */
ref?: Ref<HTMLButtonElement>;
/** The files dropped or picked, never empty. One unless `multiple`. */
onFiles: (files: [File, ...File[]]) => void;
multiple?: boolean;
/** The picker's filter (`<input accept>`); a drop is not filtered by it. */
accept?: string;
disabled?: boolean;
/** What the zone holds is refused: the border turns danger and `describedBy` says why. */
invalid?: boolean;
describedBy?: string;
/** Lays the zone out (its padding); the border and the drag paint are the zone's. */
className?: string;
children: (dragOver: boolean) => ReactNode;
}) {
const [dragOver, setDragOver] = useState(false);
const depth = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const take = (list: FileList | null | undefined) => {
const [first, ...rest] = [...(list ?? [])];
if (first) onFiles(multiple ? [first, ...rest] : [first]);
};
return (
<>
<Pressable
ref={ref}
align="center"
onClick={() => inputRef.current?.click()}
onDragEnter={(e) => {
e.preventDefault();
depth.current += 1;
setDragOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragOver(false);
}}
onDrop={(e) => {
e.preventDefault();
depth.current = 0;
setDragOver(false);
if (!disabled) take(e.dataTransfer.files);
}}
disabled={disabled}
// The refusal is tied to the control it invalidates, not left to a one-shot announcement:
// the zone reads as invalid and points at the sentence, so a reader who tabs back to it
// hears WHY. (Measured, because `aria-invalid` is not in `button`'s ARIA-supported list:
// Chromium's accessibility node for a native button does carry `invalid` from it.) The
// signal that is NOT colour is the caller's sentence — the border only reinforces it.
aria-invalid={invalid}
aria-describedby={describedBy}
// `dragOver` outranks `invalid`: a drag is transient and the refusal repaints the instant
// the drop lands, whereas a zone that stays red under the pointer tells the user their drop
// target is dead — in the one state where they are trying to replace the file.
className={clsx(
"w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed transition disabled:opacity-60",
dragOver
? "border-accent bg-accent/5"
: invalid
? "border-danger"
: "border-border hover:border-fg/40",
className,
)}
>
{children(dragOver)}
</Pressable>
<input
ref={inputRef}
type="file"
className="hidden"
multiple={multiple}
accept={accept}
onChange={(e) => {
take(e.target.files);
e.target.value = ""; // let re-picking the same file fire onChange again
}}
/>
</>
);
}
@@ -1,18 +1,18 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
import SidePanel from "./ui/SidePanel";
import PanelGroups, { type PanelItem } from "./ui/PanelGroups";
import { type PanelLayout } from "../../lib/panelLayout";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
// the layout default, edit hint); each rail supplies only what actually differs — its identity
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content below the
// groups (`children`, e.g. the feed's tag manager). `kind` is the PanelId (also the layout key), so
// `children` defaults to nothing for the rails that need none.
// groups (`children`, e.g. the feed's tag manager; nothing for the rails that need none). What Reset
// goes back to is the rail's (`onResetLayout`, from lib/useRail), so this shell names no page.
export default function FilterRail({
kind,
onResetLayout,
title,
icon,
count,
@@ -28,7 +28,8 @@ export default function FilterRail({
titleActionLabel,
children,
}: {
kind: PanelId;
/** Puts the groups back in the rail's default order (lib/useRail's `onResetLayout`). */
onResetLayout: () => void;
title: string;
icon: ReactNode;
count?: number;
@@ -56,7 +57,7 @@ export default function FilterRail({
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout(kind))}
onReset={onResetLayout}
editHint={t("sidebar.editHint")}
actions={actions}
iconExpanded={iconExpanded}
@@ -1,12 +1,12 @@
import { useState } from "react";
import { Globe } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
import { RAIL_GLYPH, RAIL_ROW } from "./ui/NavRail";
import { SURFACE } from "./ui/surfaces";
import { MenuCheck } from "./MenuCheck";
import { LANGUAGES, type LangCode } from "../../i18n";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { Pressable } from "./Pressable";
import { RAIL_GLYPH, RAIL_ROW } from "./NavRail";
import { SURFACE } from "./surfaces";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
@@ -119,7 +119,7 @@ describe("NavRailAction", () => {
});
it("the nav rail holds no toolbar icon button", () => {
const src = readFileSync(resolve(__dirname, "../NavSidebar.tsx"), "utf8");
const src = readFileSync(resolve(__dirname, "../../shell/NavSidebar.tsx"), "utf8");
expect(src).not.toMatch(/<IconButton[\s>]/);
});
});
@@ -0,0 +1,176 @@
// @vitest-environment jsdom
//
// The floating header knows no page: a page fills it through PageHeader (a portal into the header's
// root box), and the shell's PageHeaderDefault shows only while no page does. These pin the slot's
// three promises: the page's pills land IN the header, the default steps aside for them and comes
// back when the next page has none, and a shell without a header (the channel page) renders the
// page's pills nowhere.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, cleanup, screen } from "@testing-library/react";
import PageShell, { PageHeader, PageHeaderDefault } from "./PageShell";
vi.mock("./PageScroller", () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const hdr = <div data-testid="hdr" />;
const fallback = (
<PageHeaderDefault>
<span>default</span>
</PageHeaderDefault>
);
describe("the header slot", () => {
it("puts a page's pills inside the header box", () => {
render(
<PageShell header={hdr}>
<PageHeader>
<span>search</span>
</PageHeader>
</PageShell>,
);
expect(screen.getByTestId("hdr").textContent).toBe("search");
});
it("shows the default on a page with no header of its own", () => {
render(<PageShell header={hdr}>{fallback}</PageShell>);
expect(screen.getByTestId("hdr").textContent).toBe("default");
});
it("hides the default while a page fills the header, and brings it back on the next page", () => {
const page = (own: boolean) => (
<PageShell header={hdr}>
{own && (
<PageHeader>
<span>search</span>
</PageHeader>
)}
{fallback}
</PageShell>
);
const { rerender } = render(page(true));
expect(screen.getByTestId("hdr").textContent).toBe("search");
rerender(page(false));
expect(screen.getByTestId("hdr").textContent).toBe("default");
rerender(page(true));
expect(screen.getByTestId("hdr").textContent).toBe("search");
});
it("keeps the default hidden while one of two PageHeaders on a page remains", () => {
const page = (both: boolean) => (
<PageShell header={hdr}>
<PageHeader>
<span>search</span>
</PageHeader>
{both && (
<PageHeader>
<span>toggle</span>
</PageHeader>
)}
{fallback}
</PageShell>
);
const { rerender } = render(page(true));
rerender(page(false));
expect(screen.getByTestId("hdr").textContent).toBe("search");
});
it("pads the content by the filled header before paint, not when the observer catches up", () => {
// jsdom does no layout: the header's height here is one 40px row per pill in it. The observer
// never fires in this test, so only a measurement made as the pills arrive can see them.
const height = (el: HTMLElement) => 40 * el.childElementCount;
const page = (own: boolean) => (
<PageShell header={hdr}>
{own && (
<PageHeader>
<span>search</span>
<span>scope</span>
</PageHeader>
)}
{fallback}
</PageShell>
);
const { rerender, container } = render(<PageShell header={hdr}>{null}</PageShell>);
Object.defineProperty(screen.getByTestId("hdr"), "offsetHeight", {
get() {
return height(this as HTMLElement);
},
configurable: true,
});
const pad = () =>
(container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement).style.getPropertyValue(
"--hdr-h",
);
rerender(page(false));
expect(pad()).toBe("44px"); // the default alone: one row
rerender(page(true));
expect(pad()).toBe("84px"); // the page's two pills, the default gone
});
it("re-measures when one page's pills replace another's in the same commit", () => {
// Feed → Playlists: the leaving page's claim and the arriving page's land in one commit, so the
// number of claims never changes — the pad must follow the new pills all the same.
const height = (el: HTMLElement) => 40 * el.childElementCount;
const Feed = () => (
<PageHeader>
<span>search</span>
<span>scope</span>
</PageHeader>
);
const Playlists = () => (
<PageHeader>
<span>search</span>
</PageHeader>
);
const page = (which: "feed" | "playlists") => (
<PageShell header={hdr}>
{which === "feed" ? <Feed /> : <Playlists />}
{fallback}
</PageShell>
);
const { rerender, container } = render(<PageShell header={hdr}>{null}</PageShell>);
Object.defineProperty(screen.getByTestId("hdr"), "offsetHeight", {
get() {
return height(this as HTMLElement);
},
configurable: true,
});
const pad = () =>
(container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement).style.getPropertyValue(
"--hdr-h",
);
rerender(page("feed"));
expect(pad()).toBe("84px");
rerender(page("playlists"));
expect(pad()).toBe("44px");
rerender(page("feed"));
expect(pad()).toBe("84px");
});
it("renders a page's pills nowhere in a shell without a header", () => {
render(
<PageShell>
<PageHeader>
<span>search</span>
</PageHeader>
{fallback}
</PageShell>,
);
expect(screen.queryByText("search")).toBeNull();
expect(screen.queryByText("default")).toBeNull();
});
});
+78 -20
View File
@@ -13,6 +13,7 @@ import {
import { createPortal } from "react-dom";
import PageScroller from "./PageScroller";
import { PageToolbarSlot } from "./pageToolbarSlot";
import { PageHeaderSlot } from "./pageHeaderSlot";
import { PageHeadingProvider } from "./pageHeading";
import { PageMenuSlot, type PageMenuOpener } from "./pageMenuSlot";
import { isBackgroundPoint } from "../../lib/backgroundTarget";
@@ -29,6 +30,31 @@ export function PageToolbar({ children }: { children: ReactNode }) {
return slot ? createPortal(children, slot) : null;
}
// A page renders its controls in the floating header (its search box, scope toggles, sync chip)
// through this, the same way as PageToolbar: a portal, so the controls stay in the page's own React
// subtree and reach its state directly (Plex's toggles drive the sub-view that lives in PlexBrowse).
// The header itself knows no page. Claiming the header hides the shell's default (PageHeaderDefault).
export function PageHeader({ children }: { children: ReactNode }) {
const slot = useContext(PageHeaderSlot);
const claim = slot?.claim;
useLayoutEffect(() => claim?.(), [claim]);
return slot?.target ? createPortal(children, slot.target) : null;
}
// What the header shows on a page that renders no PageHeader of its own (the palette's entry). App
// renders it beside the page, inside the page's Suspense, so a page still loading shows no default
// that its own controls would replace a moment later.
export function PageHeaderDefault({ children }: { children: ReactNode }) {
const slot = useContext(PageHeaderSlot);
const shown = !!slot?.target && !slot.claimed;
const showDefault = slot?.showDefault;
useLayoutEffect(() => {
showDefault?.(shown);
return () => showDefault?.(false);
}, [shown, showDefault]);
return shown && slot?.target ? createPortal(children, slot.target) : null;
}
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page
// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate
@@ -38,6 +64,7 @@ export function PageToolbar({ children }: { children: ReactNode }) {
// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads
// its top by that height so nothing hides under it. Omit it (channel page) → no pad
// and no fixed top band, so the module's own banner can scroll from the very top.
// The header is an empty band: the page fills it through PageHeader.
// - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are
// the visible identity, and a screen reader still lands on a named page. A view that
// shows its own title renders it through `PageHeading`, and this one steps aside.
@@ -123,6 +150,29 @@ export default function PageShell({
// the measurement to the live node re-runs it every time the header mounts/unmounts, so it stays correct.
const [headerEl, setHeaderEl] = useState<HTMLDivElement | null>(null);
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
// How many PageHeaders fill the header right now: a count, not a flag, so of two on one page the one
// that leaves releases only its own claim.
const [headerClaims, setHeaderClaims] = useState(0);
// Bumped by every claim and every release. The count alone cannot say the pills changed: when one
// page's PageHeader replaces another's in one commit (feed → playlists), the release and the claim
// net to the same count, and the measurement below would not re-run.
const [headerGeneration, setHeaderGeneration] = useState(0);
const claimHeader = useCallback(() => {
setHeaderClaims((n) => n + 1);
setHeaderGeneration((g) => g + 1);
return () => {
setHeaderClaims((n) => n - 1);
setHeaderGeneration((g) => g + 1);
};
}, []);
// Whether the shell's default is in the header (PageHeaderDefault reports it).
const [defaultShown, setDefaultShown] = useState(false);
// The pills arrive by portal AFTER this shell mounted (with the page's chunk), so the first measurement
// saw an empty header; the ResizeObserver catches up only a frame or more later, and until then the
// content sat under the pills (the feed toolbar jumped 55px on a tablet). So the measurement also re-runs
// here, before paint, whenever what fills the header changes (a claim made or released, the default
// shown or gone); the observer still covers a change within a page's own pills (Plex's view switch
// appearing).
useLayoutEffect(() => {
if (!headerEl || !contentEl) {
// No header (channel page) → drop any stale pad so a leftover value can't linger.
@@ -136,7 +186,13 @@ export default function PageShell({
const ro = new ResizeObserver(apply);
ro.observe(headerEl);
return () => ro.disconnect();
}, [headerEl, contentEl]);
}, [headerEl, contentEl, headerGeneration, defaultShown]);
const headerSlot = {
target: headerEl,
claimed: headerClaims > 0,
claim: claimHeader,
showDefault: setDefaultShown,
};
// How many views below render their own page `h1` right now (see ./pageHeading).
const [ownHeadings, setOwnHeadings] = useState(0);
const registerOwnHeading = useCallback(() => {
@@ -173,26 +229,28 @@ export default function PageShell({
: header;
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
<PageMenuSlot.Provider value={registerPageMenu}>
<PageHeadingProvider value={registerOwnHeading}>
{measuredHeader}
<div
ref={setContentEl}
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
onPointerDown={(e) => {
lastPointer.current = e.pointerType;
}}
onContextMenu={onContextMenu}
>
{title && ownHeadings === 0 && <h1 className="sr-only">{title}</h1>}
{banners}
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
<PageHeaderSlot.Provider value={headerSlot}>
<PageMenuSlot.Provider value={registerPageMenu}>
<PageHeadingProvider value={registerOwnHeading}>
{measuredHeader}
<div
ref={setContentEl}
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
onPointerDown={(e) => {
lastPointer.current = e.pointerType;
}}
onContextMenu={onContextMenu}
>
{title && ownHeadings === 0 && <h1 className="sr-only">{title}</h1>}
{banners}
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */}
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
</div>
</PageHeadingProvider>
</PageMenuSlot.Provider>
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
</div>
</PageHeadingProvider>
</PageMenuSlot.Provider>
</PageHeaderSlot.Provider>
</PageToolbarSlot.Provider>
);
}
+11 -11
View File
@@ -1,7 +1,7 @@
# `components/ui/` — the platform layer
Everything in this directory is a **platform component**: generic, reusable, and **domain-free**.
Everything in `components/` above it is a **module component**: it knows about videos, channels,
Everything above it — `features/`, `modules/` and `shell/` under `src/` — knows about videos, channels,
playlists, Plex or downloads.
That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among
@@ -18,9 +18,9 @@ A component belongs here **iff its props and imports name no domain concept**. I
- generic helpers from `lib/` (`layerStack`, `history`, `columnSort`, `useScrollFade`, …),
- i18n, React, and third-party UI libraries.
It may **not** import a feature component (`../Feed`) or a domain API slice (`lib/api/*`). This is
enforced by `no-restricted-imports` in `eslint.config.js` — not by this README, which is only the
explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
It may **not** import anything from `features/`, `modules/` or `shell/`, or a domain API slice
(`lib/api/*`). This is enforced in `eslint.config.js` (`no-restricted-imports` and `local/layers`) — not
by this README, which is only the explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
supply it. The same goes for its words: a primitive translates only from the platform i18n
namespaces (`common`, `errors`, `datatable`, `keys`, `palette`, `sidebar`, `notifications`, `time` —
`uiI18n.contract.test.ts`); what a value means where it is used arrives as a prop (`PriorityStepper`'s
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| area | components |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| area | components |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c)
@@ -18,12 +18,12 @@ import { readSources } from "../../lib/rawSources.testkit";
// BOTH patterns: `../**/*.tsx` from this directory does not match the siblings in `ui/` itself, so
// ui/Modal.tsx — the overlay behind every dialog in the app — was never checked by this guard at all
// (found by a later review).
const sources = readSources(__dirname, ["../**/*.tsx", "./*.tsx"]);
const sources = readSources(__dirname, ["../../**/*.tsx", "!../../design-lab/**"]);
// Overlays with nothing a toast could be about: a lazy chunk's black placeholder while the player
// loads, and the signed-out landing page's screenshot zoom (the app's Toaster is not even mounted
// there — App renders Welcome instead of the shell).
const EXEMPT = new Set(["../PlexBrowse.tsx", "../Welcome.tsx"]);
const EXEMPT = new Set(["../../modules/plex/PlexBrowse.tsx", "../../shell/Welcome.tsx"]);
/** Every class string in the file (quoted or templated), including multi-line ones. */
const classStrings = (src: string): string[] =>
@@ -67,9 +67,9 @@ describe("full-screen overlays host their own toasts", () => {
expect(paths).toEqual(
expect.arrayContaining([
"./Modal.tsx",
"../PlayerModal.tsx",
"../PlexPlayer.tsx",
"../OnboardingWizard.tsx",
"../../features/player/PlayerModal.tsx",
"../../modules/plex/PlexPlayer.tsx",
"../../features/wizard/OnboardingWizard.tsx",
]),
);
});
@@ -1,11 +1,11 @@
import { useState } from "react";
import { type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { MenuCheck } from "./ui/MenuCheck";
import { SelectTrigger } from "./ui/SelectTrigger";
import { SURFACE } from "./ui/surfaces";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { MenuCheck } from "./MenuCheck";
import { SelectTrigger } from "./SelectTrigger";
import { SURFACE } from "./surfaces";
export interface ViewOption<T extends string> {
id: T;
@@ -1,16 +1,17 @@
import { useTranslation } from "react-i18next";
import { Copy, Link2 } from "lucide-react";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications";
import { type ActionMenuItem } from "./ActionMenu";
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
// (DESIGN.md C1, "Text stays selectable"): on touch the long-press belongs to the context menu, so the text a
// finger cannot select is copied from here. One builder, so every object says it the same way and
// the clipboard write always runs inside the gesture (`inGesture`, which Safari insists on).
//
// A feature file, not `ui/`: it speaks the app's notifications.
// Domain-free: it speaks through lib/notifications, the platform's toast service, so any module's menu
// can end with it.
type Named = "title" | "name";
@@ -6,9 +6,9 @@ import { describe, expect, it } from "vitest";
// or have no header at all. Each renders the hidden-filter cue, so no filter narrows its rows unseen.
describe("every view that filters rows by column", () => {
it("renders the hidden-filter cue", () => {
const root = new URL("../", import.meta.url);
const root = new URL("../../", import.meta.url);
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
.filter((f) => f.endsWith(".tsx") && !f.includes(".test.") && !f.startsWith("design-lab"))
.map((f) => [f, readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8")] as const)
.filter(([, src]) => /filterRows\(/.test(src));
expect(sites.length).toBeGreaterThanOrEqual(3);
@@ -1,7 +1,8 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const root = new URL("../", import.meta.url);
// The whole app source (shell, modules, features, ui): the checkable menus live in all of them.
const root = new URL("../../", import.meta.url);
const sources = () =>
readdirSync(root, { recursive: true, encoding: "utf8" })
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
@@ -0,0 +1,17 @@
import { createContext } from "react";
// The floating header's slot (see PageHeader in PageShell): the node a page portals its header
// controls into, whether a page has claimed it, and how a page claims it. The value is null with no
// shell and the target null in a shell without a header (the channel page): a page renders its header
// nowhere there. Its own
// module so PageShell exports only components (Fast Refresh).
export type PageHeaderSlotValue = {
target: HTMLElement | null;
claimed: boolean;
/** Claim the header for this page's controls; the returned function releases it. */
claim: () => () => void;
/** The default (PageHeaderDefault) reports whether it is in the header, so PageShell re-measures. */
showDefault: (shown: boolean) => void;
};
export const PageHeaderSlot = createContext<PageHeaderSlotValue | null>(null);
@@ -7,9 +7,9 @@ import { readSources } from "../../lib/rawSources.testkit";
// stats, users, downloads) keep the browser's menu. A page that starts rendering one fails here until
// that decision is taken again.
const files = readSources(__dirname, [
"../**/*.tsx",
"!../**/*.test.*",
"!../design-lab/**",
"../../**/*.tsx",
"!../../**/*.test.*",
"!../../design-lab/**",
"!./**",
]) as Record<string, string>;
@@ -17,14 +17,14 @@ describe("the pages with a background menu", () => {
it("are the five the owner chose", () => {
const users = Object.entries(files)
.filter(([, code]) => /<PageMenu\b|\busePageMenu\(/.test(code))
.map(([path]) => path.replace(/^\.\.\//, ""))
.map(([path]) => path.replace(/^(\.\.\/)+/, ""))
.sort();
expect(users).toEqual([
"Channels.tsx",
"Feed.tsx",
"NotificationsPanel.tsx",
"Playlists.tsx",
"PlexBrowse.tsx",
"modules/channels/Channels.tsx",
"modules/feed/Feed.tsx",
"modules/notifications/NotificationsPanel.tsx",
"modules/playlists/Playlists.tsx",
"modules/plex/PlexBrowse.tsx",
]);
});
});
@@ -10,66 +10,74 @@ import { readSources } from "../../lib/rawSources.testkit";
// someone decides.
const files = readSources(__dirname, [
"../**/*.tsx",
"!../**/*.test.*",
"!../design-lab/**",
"../../**/*.tsx",
"!../../**/*.test.*",
"!../../design-lab/**",
]) as Record<string, string>;
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = {
"ApiTokens.tsx": [
"modules/settings/ApiTokens.tsx": [
1,
"a token kind's tile in the new-token dialog (an option: its text is its label)",
],
"DownloadTray.tsx": [
"features/downloads/DownloadTray.tsx": [
2,
"the folded pill (its summary is its label); a failure's reason, which opens its details",
],
"FileCard.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
"FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
"Home.tsx": [
"modules/files/FileCard.tsx": [
1,
"a card's picture (named by aria-label; its title is the caption)",
],
"modules/files/FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
"modules/home/Home.tsx": [
2,
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
],
"LanguageSwitcher.tsx": [1, "an option row"],
"NavSidebar.tsx": [3, "the account trigger and its menu rows"],
"PaletteLauncher.tsx": [2, "launcher controls"],
"PlaylistsRail.tsx": [1, "the load-error retry"],
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"PlexInfo.tsx": [1, "the rating filter (a number)"],
"PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
"PlexSearchBox.tsx": [1, "a listbox option"],
"plexCards.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
"Scheduler.tsx": [1, "the interval editor trigger"],
"SettingsPanel.tsx": [1, "a colour swatch"],
"SyncStatus.tsx": [1, "the status pill trigger"],
"TagManager.tsx": [1, "a suggestion in a picker"],
"UploadDialog.tsx": [1, "the drop zone"],
"VideoEditor.tsx": [2, "timeline glyphs"],
"Welcome.tsx": [1, "a screenshot (zoom)"],
"playlistColumns.tsx": [1, "the thumbnail"],
"plexDetailUi.tsx": [2, "a switch row and the poster"],
"plexViews.tsx": [1, "the rating filter (a number)"],
"ui/ActionMenu.tsx": [1, "a menu row"],
"ui/BackToTop.tsx": [1, "a control"],
"ui/CommandPalette.tsx": [1, "a command row"],
"ui/NavRail.tsx": [
"components/ui/LanguageSwitcher.tsx": [1, "an option row"],
"shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"],
"modules/plex/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
"features/plexCards/PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
"modules/plex/PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
"modules/plex/PlexSearchBox.tsx": [1, "a listbox option"],
"features/plexCards/plexCards.tsx": [
1,
"a card's picture (named by aria-label; its title is the caption)",
],
"modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"],
"modules/settings/SettingsPanel.tsx": [1, "a colour swatch"],
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
"features/channel/TagManager.tsx": [1, "a suggestion in a picker"],
"components/ui/DropZone.tsx": [1, "the drop zone (its text is its own prompt)"],
"modules/files/VideoEditor.tsx": [2, "timeline glyphs"],
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
"modules/playlists/playlistColumns.tsx": [1, "the thumbnail"],
"modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"],
"modules/plex/plexViews.tsx": [1, "the rating filter (a number)"],
"components/ui/ActionMenu.tsx": [1, "a menu row"],
"components/ui/BackToTop.tsx": [1, "a control"],
"components/ui/CommandPalette.tsx": [1, "a command row"],
"components/ui/NavRail.tsx": [
2,
"the action rows (About, Sign out) and the group row that opens its panel; a page row is a link",
],
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
"ui/SidePanel.tsx": [2, "panel controls"],
"ui/tableHeader.tsx": [1, "a column's sort header"],
"components/ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
"components/ui/SidePanel.tsx": [2, "panel controls"],
"components/ui/tableHeader.tsx": [1, "a column's sort header"],
};
function scan(): { plain: Record<string, number>; selectable: number } {
const plain: Record<string, number> = {};
let selectable = 0;
for (const [path, code] of Object.entries(files)) {
// Glob keys are relative to this folder: `./x.tsx` is ui/, `../x.tsx` is components/.
const rel = path.startsWith("./") ? `ui/${path.slice(2)}` : path.replace(/^\.\.\//, "");
// Glob keys are relative to this folder (`./x.tsx` is ui/); the table keys them from src/.
const rel = path.startsWith("./")
? `components/ui/${path.slice(2)}`
: path.replace(/^(\.\.\/)+/, "");
for (const m of code.matchAll(/<Pressable\b/g)) {
if (/\sselectable\b(?!=)/.test(openingTag(code, m.index))) selectable++;
else plain[rel] = (plain[rel] ?? 0) + 1;
@@ -10,8 +10,8 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../../lib/notifications", () => ({ notify: h.notify }));
import { useCopyFeedback } from "./useCopyFeedback";
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications";
// How long a Copy button reads "Copied" after it worked.
const COPIED_MS = 2000;
@@ -1,4 +1,33 @@
[
{
"version": "0.110.0",
"date": "2026-10-23",
"new": [
"YouTube history: upload your Google Takeout export in Settings › Account. What you watched on YouTube is marked watched here (never a Short, never over a state you set), and your YouTube Watch later list joins yours.",
"A video the catalog does not know yet is remembered and marked once it arrives. The import's outcome reaches your inbox, and admins see each run on the Scheduler page."
],
"fixed": [
"Scheduler: a channel's full-history backfill asked for again while one is running now runs after it, instead of being silently dropped."
]
},
{
"version": "0.109.2",
"date": "2026-10-23",
"new": [],
"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."
]
},
{
"version": "0.109.1",
"date": "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."
]
},
{
"version": "0.109.0",
"date": "2026-10-21",
@@ -1,4 +1,33 @@
[
{
"version": "0.110.0",
"date": "2026-10-23",
"new": [
"YouTube-előzmények: a Google Takeout exportod a Beállítások › Fiók oldalon tölthető fel. Amit a YouTube-on megnéztél, itt megnézettnek jelölődik (Short soha, és amit itt beállítottál, azt nem írja felül), a „Megnézem később” listád pedig az ittenihez adódik.",
"Egy videót, amit a katalógus még nem ismer, megjegyez, és megjelöli, amint bekerül. Az import eredménye a postafiókodba érkezik, az adminok pedig minden futását látják az Ütemező oldalon."
],
"fixed": [
"Ütemező: ha egy csatorna teljes előzményének letöltését futás közben újra kérik, az most utána lefut, nem vész el csendben."
]
},
{
"version": "0.109.2",
"date": "2026-10-23",
"new": [],
"fixed": [
"Plex-lejátszó: ha a szerver epizód közben újraindul (frissítés), a lejátszás a pufferből megy tovább, és ahol tartasz, ott folytatódik, amint a szerver visszajött – nem írja ki, hogy „Nem sikerült elindítani a lejátszást”, és nem áll meg a puffer végén."
]
},
{
"version": "0.109.1",
"date": "2026-10-23",
"new": [
"Fejlesztés: a frontend oldalanként egy mappa (az oldal, a sávja, a store-ja és a definíciója), a rétegeket lint őrzi, a megosztott /watch oldalt pedig contract teszt tartja külön; a fejléc nem ismer oldalt, a feednek és a Plexnek saját mentett nézetei vannak."
],
"fixed": [
"Mentett nézetek: egy alkalmazott, majd módosított nézet egy másik oldal meglátogatása után is aktív marad, a feedben és a Plexben is."
]
},
{
"version": "0.109.0",
"date": "2026-10-21",
@@ -1,9 +1,9 @@
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import type { ChannelDetail } from "../lib/api";
import { linkify } from "../lib/linkify";
import { Badge } from "./ui/Badge";
import { languageName, regionName, vocab } from "../lib/vocab";
import type { ChannelDetail } from "../../lib/api";
import { linkify } from "../../lib/linkify";
import { Badge } from "../../components/ui/Badge";
import { languageName, regionName, vocab } from "../../lib/vocab";
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
@@ -16,7 +16,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { ChannelTagsControl } from "./ChannelTags";
import { api } from "../lib/api";
import { api } from "../../lib/api";
beforeAll(() => {
class Stub {
@@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import type { Tag } from "../lib/api";
import type { Tag } from "../../lib/api";
beforeAll(() => {
class Stub {
@@ -1,16 +1,16 @@
import { useRef, useState } from "react";
import { Plus, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { Chip, DismissibleChip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { MembershipMenu } from "./ui/MembershipMenu";
import { useTags } from "../lib/useTags";
import { SURFACE } from "./ui/surfaces";
import { type Tag } from "../../lib/api";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
import { Chip, DismissibleChip } from "../../components/ui/Chip";
import { chipClass } from "../../components/ui/chipStyles";
import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { useTags } from "../../lib/useTags";
import { SURFACE } from "../../components/ui/surfaces";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -1,22 +1,22 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { qk } from "../../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { useTags } from "../lib/useTags";
import { api, type Tag } from "../../lib/api";
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { IconButton } from "../../components/ui/IconButton";
import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
import { useTags } from "../../lib/useTags";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
@@ -8,7 +8,7 @@
// disabled state shows an enabled row that no-ops.
import { describe, it, expect } from "vitest";
import { pinTargetForCard, shelfMoveTarget } from "./comfortPin";
import type { DiscoverCard } from "../lib/api";
import type { DiscoverCard } from "../../lib/api";
const card = (over: Partial<DiscoverCard>): DiscoverCard =>
({
@@ -8,8 +8,8 @@ import { describe, it, expect, afterEach, vi } from "vitest";
import type { ReactNode } from "react";
import { renderHook, act, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { setLanguage } from "../i18n";
import { api } from "../lib/api";
import { setLanguage } from "../../i18n";
import { api } from "../../lib/api";
import { useComfortPin } from "./comfortPin";
// Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded
@@ -3,12 +3,12 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pin, PinOff } from "lucide-react";
import { plexLang } from "../i18n";
import { api, type DiscoverCard, type PinKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { plexLang } from "../../i18n";
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
// "put this on my comfort shelf", from wherever you happen to be looking at the thing.
//
@@ -10,8 +10,8 @@ import { useState } from "react";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DebugSession, Me } from "../lib/api";
import { qk } from "../lib/queryKeys";
import type { DebugSession, Me } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -58,8 +58,8 @@ const { order, session, startDebugSession, stopDebugSession, hold, past } = vi.h
});
return { order, session, startDebugSession, stopDebugSession, hold, past };
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/api")>();
return {
...actual,
api: {
@@ -70,8 +70,8 @@ vi.mock("../lib/api", async (importOriginal) => {
},
};
});
vi.mock("../lib/diagnostics", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/diagnostics")>();
vi.mock("../../lib/diagnostics", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/diagnostics")>();
return {
...actual,
flushNow: () => {
@@ -82,16 +82,21 @@ vi.mock("../lib/diagnostics", async (importOriginal) => {
});
import DebugRecorder, { RecorderSettings } from "./DebugRecorder";
import Modal from "./ui/Modal";
import Toaster from "./ui/Toaster";
import Modal from "../../components/ui/Modal";
import Toaster from "../../components/ui/Toaster";
import {
closeComposer,
openRecorderStart,
setStepPrompt,
useComposerSession,
} from "../lib/debugRecorder";
import { anyBlockingLayer, anyBlockingLayerAbove, pushLayer, removeLayer } from "../lib/layerStack";
import { events } from "../lib/log";
} from "../../lib/debugRecorder";
import {
anyBlockingLayer,
anyBlockingLayerAbove,
pushLayer,
removeLayer,
} from "../../lib/layerStack";
import { events } from "../../lib/log";
beforeEach(() => {
order.length = 0;
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, Flag, Square } from "lucide-react";
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
import { canCaptureTab } from "../lib/captureTab";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { canCaptureTab } from "../../lib/captureTab";
import {
closeRecorderStart,
onSessionChangeElsewhere,
@@ -15,21 +15,21 @@ import {
useRecorderStartOpen,
useStepCount,
useStepPrompt,
} from "../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../lib/format";
import { useLayer } from "../lib/layerStack";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { dockUnderToasts } from "../lib/toastDock";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import Modal, { ModalFooter } from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
} from "../../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../../lib/format";
import { useLayer } from "../../lib/layerStack";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { dockUnderToasts } from "../../lib/toastDock";
import { useMe } from "../../lib/useMe";
import { Button } from "../../components/ui/Button";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
// The debug recorder: Start → reproduce → Stop.
//
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { navItemAttrs } from "../../lib/listNav";
import { Pressable } from "../../components/ui/Pressable";
// One dense row of a history list — a background run, an admin action, a log line (./LogLine draws
// its lines through this): its cells on one line, and the row opens in place to its details. One frame
@@ -8,8 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DebugDraft, DebugSession, ServerLogRow } from "../lib/api";
import { qk } from "../lib/queryKeys";
import type { DebugDraft, DebugSession, ServerLogRow } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -38,8 +38,8 @@ const api = vi.hoisted(() => ({
// The server's half-failure: the issue is committed, then an upload dies and the call 422s.
failSendAfterCreate: false,
}));
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/api")>();
return {
...actual,
api: {
@@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
import { closeComposer } from "../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../lib/format";
import { notify } from "../lib/notifications";
import { useMe } from "../lib/useMe";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
import { closeComposer } from "../../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import {
draftSteps,
environment,
@@ -15,21 +15,21 @@ import {
type DraftForm,
type DraftStep,
type IssueText,
} from "../lib/issueDraft";
import { moduleDef } from "../lib/modules";
import type { Page } from "../lib/urlState";
import { qk } from "../lib/queryKeys";
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
} from "../../lib/issueDraft";
import { moduleDef } from "../navigation/moduleRegistry";
import type { Page } from "../../lib/urlState";
import { qk } from "../../lib/queryKeys";
import { focusIsUnclaimed } from "../../lib/useFocusKeeper";
import { LEVEL_TAG, logClock } from "./logLineParts";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import Modal, { ModalFooter } from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Textarea, Input } from "./ui/Input";
import { Checkbox } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces";
import RemoteImage from "./ui/RemoteImage";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { Textarea, Input } from "../../components/ui/Input";
import { Checkbox } from "../../components/ui/NativeControls";
import { SURFACE } from "../../components/ui/surfaces";
import RemoteImage from "../../components/ui/RemoteImage";
// The issue composer: one dialog — the draft on the left (title, description, the
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import type { Level } from "../lib/log";
import type { Level } from "../../lib/log";
import HistoryRow from "./HistoryRow";
import { LEVEL_TAG, logClock } from "./logLineParts";
@@ -1,4 +1,4 @@
import type { Level } from "../lib/log";
import type { Level } from "../../lib/log";
// What a log line is drawn with — the level tags and the clock — shared by LogLine (both log
// viewers) and the issue composer's review list, so a level reads the same everywhere. Apart from
@@ -2,21 +2,21 @@ import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { captureTab } from "../lib/captureTab";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { captureTab } from "../../lib/captureTab";
import {
announceSessionChange,
closeRecorderStart,
nextStep,
openComposer,
setStepPrompt,
} from "../lib/debugRecorder";
import { flushNow, liveSession } from "../lib/diagnostics";
import { log } from "../lib/log";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { updateMeCache, useMe } from "../lib/useMe";
} from "../../lib/debugRecorder";
import { flushNow, liveSession } from "../../lib/diagnostics";
import { log } from "../../lib/log";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { updateMeCache, useMe } from "../../lib/useMe";
/** The debug recording's actions, shared by everything that drives it: the pill, Settings →
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
@@ -1,5 +1,5 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Download } from "lucide-react";
@@ -7,8 +7,8 @@ import clsx from "clsx";
// Lazy: the download dialog (+ its profile editor) loads only when a card's download button is
// clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api } from "../lib/api";
import { IconButton } from "./ui/IconButton";
import { api } from "../../lib/api";
import { IconButton } from "../../components/ui/IconButton";
// Self-contained download affordance for a video card / the player. Reflects the video's current state from
// the shared per-user download index (one polled query shared across every card).
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type DownloadProfile } from "../lib/api";
import { api, type DownloadProfile } from "../../lib/api";
const h = vi.hoisted(() => ({
prefs: new Map<string, unknown>(),
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../lib/usePref", () => ({
vi.mock("../../lib/usePref", () => ({
usePref: <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() =>
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
@@ -33,11 +33,11 @@ vi.mock("../lib/usePref", () => ({
];
},
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
vi.mock("../../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
import DownloadDialog from "./DownloadDialog";
@@ -1,22 +1,22 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { Button } from "../../components/ui/Button";
import { api } from "../../lib/api";
import { invalidateDownloads } from "../../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { showInTray } from "../lib/downloadQueue";
import { profileName } from "../lib/vocab";
import { LS } from "../lib/storage";
import { usePref } from "../lib/usePref";
import FilterSelect from "./ui/FilterSelect";
import { Input } from "./ui/Input";
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { profileName } from "../../lib/vocab";
import { LS } from "../../lib/storage";
import { usePref } from "../../lib/usePref";
import FilterSelect from "../../components/ui/FilterSelect";
import { Input } from "../../components/ui/Input";
// Preset picker shown when the user hits Download on a card / in the player, or brings a link (the
// Files link box, a paste anywhere). Enqueues via the API; its notice's "View" opens the tray on it.
@@ -5,7 +5,7 @@ import {
pasteIsForThePage,
requestDownload,
usePendingDownload,
} from "../lib/downloadIntake";
} from "../../lib/downloadIntake";
const DownloadDialog = lazy(() => import("./DownloadDialog"));
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type QueueJob } from "../lib/api";
import { api, type QueueJob } from "../../lib/api";
const h = vi.hoisted(() => ({
jobs: [] as unknown[],
@@ -28,7 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
`${Array.isArray(key) ? key[0] : key}${o?.count !== undefined ? `:${o.count}` : ""}${o?.title ? `:${o.title}` : ""}`,
}),
}));
vi.mock("../lib/useLiveQuery", () => ({
vi.mock("../../lib/useLiveQuery", () => ({
useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => (
(h.intervalMs = opts.intervalMs),
{
@@ -42,7 +42,7 @@ vi.mock("../lib/useLiveQuery", () => ({
}));
// The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How
// usePref stores them is its own business (usePref.dom.test).
vi.mock("../lib/usePref", () => ({
vi.mock("../../lib/usePref", () => ({
usePref: <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() =>
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
@@ -56,21 +56,23 @@ vi.mock("../lib/usePref", () => ({
];
},
}));
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("../lib/downloads", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/downloads")>()),
vi.mock("../../modules/files/filesContext", () => ({
useFilesActions: () => ({ openFile: h.openFile }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("../../lib/downloads", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/downloads")>()),
quickShareLink: h.share,
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
import DownloadTray from "./DownloadTray";
import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue";
import { pushLayer, removeLayer } from "../lib/layerStack";
import { qk } from "../lib/queryKeys";
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys";
beforeEach(() => {
resetQueueWatch();
@@ -10,8 +10,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type DownloadJob } from "../lib/api";
import { menuGroups } from "./ui/menuGroups";
import { api, type DownloadJob } from "../../lib/api";
import { menuGroups } from "../../components/ui/menuGroups";
const mocks = vi.hoisted(() => ({
jobs: [] as unknown[],
@@ -28,8 +28,8 @@ vi.mock("react-i18next", async (importOriginal) => {
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => ({
data: key[0] === "downloads" ? mocks.jobs : undefined,
isError: false,
@@ -37,10 +37,12 @@ vi.mock("../lib/useLiveQuery", () => ({
refetch: vi.fn(),
}),
}));
vi.mock("../lib/usePref", () => ({
vi.mock("../../lib/usePref", () => ({
usePref: <T,>(_key: string, initial: T) => useState<T>(initial),
}));
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }) }));
vi.mock("../../modules/files/filesContext", () => ({
useFilesActions: () => ({ openFile: vi.fn() }),
}));
globalThis.ResizeObserver ??= class {
observe() {}
@@ -13,9 +13,9 @@ import {
RotateCw,
X,
} from "lucide-react";
import type { QueueJob } from "../lib/api";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import type { QueueJob } from "../../lib/api";
import { invalidateDownloads, quickShareLink } from "../../lib/downloads";
import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure";
import {
IN_FLIGHT,
DOWNLOAD_PHASES,
@@ -27,31 +27,31 @@ import {
useFinished,
useRevealing,
useUnannounced,
} from "../lib/downloadQueue";
import { anyBlockingLayer } from "../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../lib/listNav";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { LS } from "../lib/storage";
import { useLiveQuery } from "../lib/useLiveQuery";
import { usePref } from "../lib/usePref";
} from "../../lib/downloadQueue";
import { anyBlockingLayer } from "../../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { LS } from "../../lib/storage";
import { useLiveQuery } from "../../lib/useLiveQuery";
import { usePref } from "../../lib/usePref";
import { ErrorModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { sourceLabel } from "./fileFolders";
import { useFilesActions } from "./filesContext";
import { useFilesActions } from "../../modules/files";
import { sourceLabel } from "../folders/fileFolders";
import { useDownloadActions } from "./useDownloadActions";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { setCornerLift } from "./ui/backToTopState";
import { Button } from "./ui/Button";
import { ContextMenu } from "./ui/ContextMenu";
import { IconButton } from "./ui/IconButton";
import { menuGroups } from "./ui/menuGroups";
import { MoreButton } from "./ui/MoreButton";
import NavGrid from "./ui/NavGrid";
import { Pressable } from "./ui/Pressable";
import { CONTENT_ID } from "./ui/landmarks";
import { SURFACE } from "./ui/surfaces";
import { useContextMenu } from "./ui/useContextMenu";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { setCornerLift } from "../../components/ui/backToTopState";
import { Button } from "../../components/ui/Button";
import { ContextMenu } from "../../components/ui/ContextMenu";
import { IconButton } from "../../components/ui/IconButton";
import { menuGroups } from "../../components/ui/menuGroups";
import { MoreButton } from "../../components/ui/MoreButton";
import NavGrid from "../../components/ui/NavGrid";
import { Pressable } from "../../components/ui/Pressable";
import { CONTENT_ID } from "../../components/ui/landmarks";
import { SURFACE } from "../../components/ui/surfaces";
import { useContextMenu } from "../../components/ui/useContextMenu";
// The running downloads, app-wide (DESIGN.md "Running downloads"): a tray in the bottom-right corner,
// up only while something waits, runs or failed — and what finished in this tab, "Done · Open",
@@ -4,7 +4,7 @@
// downloader did not understand (an update on the server cures it). Never for an unclassified one.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { DownloadJob } from "../lib/api";
import type { DownloadJob } from "../../lib/api";
import { ErrorModal } from "./downloadModals";
vi.mock("react-i18next", async (importOriginal) => ({
@@ -1,14 +1,14 @@
import { useState } from "react";
import { invalidateDownloads } from "../lib/downloads";
import { invalidateDownloads } from "../../lib/downloads";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
import Modal, { ModalFooter } from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { Button } from "./ui/Button";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { api, type DownloadJob } from "../../lib/api";
import { Button } from "../../components/ui/Button";
import { IconBtn } from "./downloadRow";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { Input } from "./ui/Input";
import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure";
import { Input } from "../../components/ui/Input";
// Per-item user modals (self-contained, prop-driven): a library file's details (Files) and a failed
// download's reasons (the tray).
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { JobThumb } from "./downloadRow";
import type { DownloadJob } from "../lib/api";
import type { DownloadJob } from "../../lib/api";
const baseJob: DownloadJob = {
id: 1,
@@ -2,11 +2,11 @@ import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { File, Film, Music } from "lucide-react";
import clsx from "clsx";
import { DOWNLOAD_PHASES } from "../lib/downloadQueue";
import { api, type DownloadJob } from "../lib/api";
import { IconButton } from "./ui/IconButton";
import { MediaProgress } from "./ui/MediaCard";
import RemoteImage from "./ui/RemoteImage";
import { DOWNLOAD_PHASES } from "../../lib/downloadQueue";
import { api, type DownloadJob } from "../../lib/api";
import { IconButton } from "../../components/ui/IconButton";
import { MediaProgress } from "../../components/ui/MediaCard";
import RemoteImage from "../../components/ui/RemoteImage";
// The small shared primitives of a download (status pill, icon button, the job's picture), all purely
// prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the
@@ -1,4 +1,4 @@
import type { DownloadJob } from "../lib/api";
import type { DownloadJob } from "../../lib/api";
// A download job's display title (a user rename wins over the extracted asset title), shared by the
// tray, the Files list, the admin Storage tab and the library player.
@@ -1,14 +1,14 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Ban, Pause, Play, RefreshCw } from "lucide-react";
import { api, type DownloadJob, type DownloadJobKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads";
import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems";
import { api, type DownloadJob, type DownloadJobKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { invalidateDownloads } from "../../lib/downloads";
import { useConfirm } from "../../components/ui/confirmContext";
import { useCopyItems } from "../../components/ui/copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups";
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
@@ -1,16 +1,16 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
import { decideChipLayout } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { ChipLabelPending } from "./ui/Skeleton";
import { SURFACE } from "./ui/surfaces";
import type { ActiveFilter } from "../../lib/useActiveFilters";
import { decideChipLayout } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { Button } from "../../components/ui/Button";
import { Chip } from "../../components/ui/Chip";
import { chipClass } from "../../components/ui/chipStyles";
import { ChipLabelPending } from "../../components/ui/Skeleton";
import { SURFACE } from "../../components/ui/surfaces";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
@@ -63,7 +63,8 @@ const CTX = {
toggleDefault: vi.fn(async () => {}),
};
vi.mock("./savedViewsContext", () => ({
vi.mock("./savedViewsContext", async (importOriginal) => ({
...(await importOriginal<typeof import("./savedViewsContext")>()),
useSavedViews: () => ({
...CTX,
activeViewId: h.activeViewId,
@@ -80,8 +81,8 @@ vi.mock("./savedViewsContext", () => ({
}));
import PersonalViewsModal from "./PersonalViewsModal";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -17,28 +17,33 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import {
coercePlexFilters,
type FeedFilters,
type PlexFilters,
type SavedView,
} from "../../lib/api";
import Modal from "../../components/ui/Modal";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { useSavedViews, asFilters } from "./savedViewsContext";
import { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
import { useUndoable } from "../lib/useUndoable";
import UndoToolbar from "./ui/UndoToolbar";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
import { OrderButtons } from "./ui/OrderButtons";
import { Pressable } from "./ui/Pressable";
import { DismissibleChip } from "./ui/Chip";
import { Input } from "./ui/Input";
import { useActiveFilters } from "../../lib/useActiveFilters";
import { buildPlexActiveFilters } from "../../lib/plexActiveFilters";
import { useUndoable } from "../../lib/useUndoable";
import UndoToolbar from "../../components/ui/UndoToolbar";
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
import { DragHandle } from "../../components/ui/DragHandle";
import { OrderButtons } from "../../components/ui/OrderButtons";
import { Pressable } from "../../components/ui/Pressable";
import { DismissibleChip } from "../../components/ui/Chip";
import { Input } from "../../components/ui/Input";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder, or the selected one's order buttons on the right) plus a "save the current filters" input;
// the right column shows the selected view's name (renamable), its captured filters as REMOVABLE
// chips (pruning writes back to the stored view, undoable), its default flag, its place in the order,
// apply, save-current, and delete. Opened from the filter panel's title or the saved-views popover;
// all mutations flow through SavedViewsProvider.
// all mutations flow through the module's saved-views store (useSavedViewsStore).
function ViewRow({
view,
selected,

Some files were not shown because too many files have changed in this diff Show More