Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4695e1a62f | ||
|
|
1adf59ea1b | ||
|
|
bc8fe2cc6c | ||
|
|
33dd9ff054 | ||
|
|
b5f9cf14db | ||
|
|
bd2340c204 | ||
|
|
84af92aa29 | ||
|
|
420baff463 | ||
|
|
9c5f81fd94 | ||
|
|
9f580fd8c8 | ||
|
|
e0b02a0c14 | ||
|
|
3553eac8c4 | ||
|
|
5d9952ab1e | ||
|
|
f486a69ef2 | ||
|
|
7b8505af95 | ||
|
|
bf5ceec8a1 | ||
|
|
e788326247 | ||
|
|
38c71b7b4f | ||
|
|
cd5b26efa7 | ||
|
|
0892a1a400 | ||
|
|
b443ac0c84 | ||
|
|
ad05932217 | ||
|
|
6ae7825a14 | ||
|
|
cde5a98fd2 | ||
|
|
e83c5e2d97 | ||
|
|
6a0c1d377f | ||
|
|
4ef963b005 | ||
|
|
0b24f2d8fa | ||
|
|
d5867bacca | ||
|
|
63a0fecc8b |
@@ -7,6 +7,44 @@ 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.111.0 — 2026-10-24
|
||||
|
||||
### New
|
||||
|
||||
- Channels › Discover from your history: the channels your uploaded YouTube history shows you watching often (five videos of five minutes or more) but not following.
|
||||
- Follow one, put it out of the list (with Undo), or open the videos you watched there and play them one by one or all in a row.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A channel someone explored and then followed on YouTube is now part of the shared Library like any followed channel, and so are its videos; before, they could stay hidden and their watched marks never applied.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""A watched Takeout video's length, and the channels a user put out of their list
|
||||
|
||||
Revision ID: 0115_takeout_channels
|
||||
Revises: 0114_video_unmarks
|
||||
Create Date: 2026-10-24
|
||||
|
||||
The "channels you watch but do not follow" list counts a user's watched videos over five minutes
|
||||
by channel. The length is asked of YouTube for that list and kept on the Takeout row; a channel the
|
||||
user does not want on the list is kept apart.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0115_takeout_channels"
|
||||
down_revision: str | None = "0114_video_unmarks"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"takeout_items", sa.Column("looked_up_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
op.add_column("takeout_items", sa.Column("duration_seconds", sa.Integer(), nullable=True))
|
||||
op.create_table(
|
||||
"takeout_channel_dismissals",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("channel_id", sa.String(32), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "channel_id", name="uq_takeout_channel_dismissal"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("takeout_channel_dismissals")
|
||||
op.drop_column("takeout_items", "duration_seconds")
|
||||
op.drop_column("takeout_items", "looked_up_at")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""An explored channel someone follows is the catalog's own, and so are its videos
|
||||
|
||||
Revision ID: 0116_followed_explored_channels
|
||||
Revises: 0115_takeout_channels
|
||||
Create Date: 2026-10-24
|
||||
|
||||
A follow pulled in from YouTube (the subscription import) left an explored channel flagged as an
|
||||
exploration's, and a subscribe landing while an exploration's videos came in left those videos
|
||||
flagged on a channel that no longer was one. Both are held in the code now; this clears what they
|
||||
left: the channel of a subscription is not an exploration's, and no video is an exploration's on a
|
||||
channel that is not one. Data only, so the downgrade has nothing to undo.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0116_followed_explored_channels"
|
||||
down_revision: str | None = "0115_takeout_channels"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"UPDATE channels SET from_explore = false WHERE from_explore"
|
||||
" AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.channel_id = channels.id)"
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE videos SET via_explore = false WHERE via_explore"
|
||||
" AND channel_id IN (SELECT id FROM channels WHERE NOT from_explore)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -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
|
||||
|
||||
@@ -71,10 +71,12 @@ class Channel(Base, TimestampMixin):
|
||||
Boolean, default=False, server_default="false", index=True
|
||||
)
|
||||
# True when this channel exists only because a user EXPLORED it (opened its page without
|
||||
# subscribing) — an ephemeral, per-explorer addition. The cleanup job hard-deletes such a
|
||||
# channel (and its via_explore videos) once nobody keeps it (no subscription, no live
|
||||
# ExploredChannel row, no per-user state) after a grace period. Cleared when someone
|
||||
# subscribes (the "keep" signal promotes it to organic).
|
||||
# subscribing) — an ephemeral, per-explorer addition. After a grace period the cleanup job
|
||||
# hard-deletes its via_explore videos nobody keeps (no subscription, no live ExploredChannel
|
||||
# row, no per-user state), and then the channel row itself once it holds no videos — unless a
|
||||
# Takeout watch history shows it watched: the "watched but not followed" list names it from
|
||||
# that row. Cleared when someone subscribes, here or on YouTube (the "keep" signal promotes it
|
||||
# to organic).
|
||||
from_explore: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", index=True
|
||||
)
|
||||
@@ -235,8 +237,9 @@ class ExploredChannel(Base, TimestampMixin):
|
||||
Exploring an un-subscribed channel ingests its recent uploads (flagged via_explore) so the
|
||||
user can browse them, but keeps them out of everyone else's catalog. This row (a) gates the
|
||||
explorer's access to those ephemeral videos in the feed query and (b) drives the grace-period
|
||||
purge: a from_explore channel with no live ExploredChannel row (and no subscription / no
|
||||
per-user state on its videos) is hard-deleted by the cleanup job. Subscribing is the "keep"
|
||||
purge: of a from_explore channel with no live ExploredChannel row (and no subscription) the
|
||||
cleanup job hard-deletes the videos without per-user state, then the channel once it holds none
|
||||
and no Takeout watch history shows it watched. Subscribing is the "keep"
|
||||
signal — it promotes the channel to organic and these rows become irrelevant. `created_at`
|
||||
(from TimestampMixin) is the grace clock; a re-visit refreshes it."""
|
||||
|
||||
|
||||
@@ -96,13 +96,24 @@ def record_subscription(db: Session, user: User, channel: Channel, yt_subscripti
|
||||
subscribed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
if channel.from_explore:
|
||||
channel.from_explore = False
|
||||
db.execute(
|
||||
update(Video)
|
||||
.where(Video.channel_id == channel.id, Video.via_explore.is_(True))
|
||||
.values(via_explore=False)
|
||||
)
|
||||
db.flush()
|
||||
settle_explored(db, channel)
|
||||
|
||||
|
||||
def settle_explored(db: Session, channel: Channel) -> None:
|
||||
"""Hold the rule that a channel's videos are an exploration's only while nobody follows it,
|
||||
decided by who follows it now, not by the flag held in memory: a subscribe in another request
|
||||
may have cleared that while YouTube answered this one, and the videos that came in meanwhile
|
||||
would stay flagged. A channel someone follows, however the follow came (here or pulled in from
|
||||
YouTube), is the catalog's own, and so are its videos."""
|
||||
if channel.from_explore and not queries.followed(db, channel.id):
|
||||
return
|
||||
channel.from_explore = False
|
||||
db.execute(
|
||||
update(Video)
|
||||
.where(Video.channel_id == channel.id, Video.via_explore.is_(True))
|
||||
.values(via_explore=False)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,70 @@ 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. A watched row's channel and length feed the
|
||||
"channels you watch but do not follow" list: the length is asked of YouTube for that list alone
|
||||
and kept here, so its videos need not enter the shared catalog to be counted."""
|
||||
|
||||
__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))
|
||||
# When YouTube was asked for the video's length (NULL = not yet), and the length it gave; a
|
||||
# video it no longer has keeps a NULL length.
|
||||
looked_up_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
|
||||
class TakeoutChannelDismissal(Base, TimestampMixin):
|
||||
"""A channel the user put out of their "watched but not followed" list. The id is not a foreign
|
||||
key: the channel may never have entered the catalog, or may leave it, and the choice stays."""
|
||||
|
||||
__tablename__ = "takeout_channel_dismissals"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "channel_id", name="uq_takeout_channel_dismissal"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
channel_id: Mapped[str] = mapped_column(String(32))
|
||||
|
||||
@@ -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,14 +7,28 @@ 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
|
||||
|
||||
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.catalog.models import BlockedChannel, Channel, Subscription, Video
|
||||
from app.reader.models import (
|
||||
ChannelTag,
|
||||
Playlist,
|
||||
PlaylistItem,
|
||||
SavedView,
|
||||
Tag,
|
||||
TakeoutChannelDismissal,
|
||||
TakeoutItem,
|
||||
TakeoutKind,
|
||||
VideoState,
|
||||
VideoUnmark,
|
||||
)
|
||||
from app.userscope import owned_by, owned_row
|
||||
|
||||
# --- catalog, read along the edge ------------------------------------------------------------
|
||||
@@ -230,6 +244,18 @@ def kept_by_a_reader(video_id: InstrumentedAttribute[str]) -> ColumnElement[bool
|
||||
)
|
||||
|
||||
|
||||
def in_a_watch_history(channel_id: InstrumentedAttribute[str]) -> ColumnElement[bool]:
|
||||
"""A channel someone's uploaded Takeout history shows watched. The "watched but not followed"
|
||||
list names a channel from its catalog row, so the explore cleanup keeps the row of one (its
|
||||
videos it may still reclaim: opening the list's videos brings them back). As a clause, so the
|
||||
cleanup's DELETE stays one statement."""
|
||||
return (
|
||||
select(TakeoutItem.id)
|
||||
.where(TakeoutItem.channel_id == channel_id, TakeoutItem.kind == TakeoutKind.WATCHED)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def playlisted_by(db: Session, video_ids: list[str]) -> list[tuple[int, str]]:
|
||||
"""(user id, video id) for every user who has one of these videos in a playlist."""
|
||||
return list(
|
||||
@@ -359,3 +385,355 @@ 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()
|
||||
)
|
||||
|
||||
|
||||
# --- channels watched but not followed ---------------------------------------------------------
|
||||
|
||||
# A channel joins the list with this many watched videos of at least this length. A Short is three
|
||||
# minutes at most, so the length alone keeps them out, and a video under five minutes says too
|
||||
# little of what a channel is about to count towards it.
|
||||
WATCHED_CHANNEL_MIN_VIDEOS = 5
|
||||
WATCHED_CHANNEL_MIN_SECONDS = 300
|
||||
|
||||
|
||||
def _unfollowed(user: User) -> ColumnElement[bool]:
|
||||
"""A watched row whose channel the user neither follows, blocked, put out of the list, nor is."""
|
||||
subscribed = select(Subscription.channel_id).where(owned_by(Subscription.user_id, user))
|
||||
blocked = select(BlockedChannel.channel_id).where(owned_by(BlockedChannel.user_id, user))
|
||||
dismissed = select(TakeoutChannelDismissal.channel_id).where(
|
||||
owned_by(TakeoutChannelDismissal.user_id, user)
|
||||
)
|
||||
clauses = [
|
||||
owned_by(TakeoutItem.user_id, user),
|
||||
TakeoutItem.kind == TakeoutKind.WATCHED,
|
||||
TakeoutItem.channel_id.is_not(None),
|
||||
TakeoutItem.channel_id.not_in(subscribed),
|
||||
TakeoutItem.channel_id.not_in(blocked),
|
||||
TakeoutItem.channel_id.not_in(dismissed),
|
||||
]
|
||||
if user.yt_channel_id:
|
||||
clauses.append(TakeoutItem.channel_id != user.yt_channel_id)
|
||||
return and_(*clauses)
|
||||
|
||||
|
||||
def _candidate_channels(user: User):
|
||||
"""The unfollowed channels with enough watched videos to make the list once their lengths are
|
||||
known: (channel id, how many watched)."""
|
||||
return (
|
||||
select(TakeoutItem.channel_id, func.count().label("watched"))
|
||||
.where(_unfollowed(user))
|
||||
.group_by(TakeoutItem.channel_id)
|
||||
.having(func.count() >= WATCHED_CHANNEL_MIN_VIDEOS)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def takeout_lengths_to_ask(db: Session, user: User) -> list[str]:
|
||||
"""The watched videos whose length the list still needs: those of the candidate channels not
|
||||
asked about yet, the most watched channels first, so a lookup cut short fills the top."""
|
||||
cand = _candidate_channels(user)
|
||||
return list(
|
||||
db.execute(
|
||||
select(TakeoutItem.video_id)
|
||||
.join(cand, cand.c.channel_id == TakeoutItem.channel_id)
|
||||
.where(_unfollowed(user), TakeoutItem.looked_up_at.is_(None))
|
||||
.order_by(cand.c.watched.desc(), TakeoutItem.channel_id, TakeoutItem.at.desc())
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def watched_channels_pending(db: Session, user: User) -> int:
|
||||
"""How many channels a later run may still add to the list: the candidates with a watched video
|
||||
whose length is not known, and the ones that made it but have no catalog row to be listed from
|
||||
yet. One the catalog holds without details is listed already (by the name it came in with), so
|
||||
it is not one to come."""
|
||||
cand = _candidate_channels(user)
|
||||
unasked = db.execute(
|
||||
select(TakeoutItem.channel_id)
|
||||
.join(cand, cand.c.channel_id == TakeoutItem.channel_id)
|
||||
.where(_unfollowed(user), TakeoutItem.looked_up_at.is_(None))
|
||||
.distinct()
|
||||
).scalars()
|
||||
counted = _counted_channels(user)
|
||||
unlisted = db.execute(
|
||||
select(counted.c.channel_id)
|
||||
.outerjoin(Channel, Channel.id == counted.c.channel_id)
|
||||
.where(Channel.id.is_(None))
|
||||
).scalars()
|
||||
return len(set(unasked) | set(unlisted))
|
||||
|
||||
|
||||
def _counted_channels(user: User):
|
||||
"""The channels that make the list by their counted videos, whatever the catalog holds of them."""
|
||||
return (
|
||||
select(TakeoutItem.channel_id)
|
||||
.where(_unfollowed(user), TakeoutItem.duration_seconds >= WATCHED_CHANNEL_MIN_SECONDS)
|
||||
.group_by(TakeoutItem.channel_id)
|
||||
.having(func.count() >= WATCHED_CHANNEL_MIN_VIDEOS)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def watched_channels_to_describe(db: Session, user: User) -> list[str]:
|
||||
"""The channels on the list (by their counted videos) that the catalog lacks or has no details
|
||||
of yet: what the list cannot name, or names with no more than a video brought."""
|
||||
counted = _counted_channels(user)
|
||||
return list(
|
||||
db.execute(
|
||||
select(counted.c.channel_id)
|
||||
.outerjoin(Channel, Channel.id == counted.c.channel_id)
|
||||
.where(Channel.details_synced_at.is_(None))
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def watched_channels(db: Session, user: User) -> list[tuple[Channel, int, datetime]]:
|
||||
"""The channels the user watched often but does not follow: (channel, how many of its videos
|
||||
over five minutes they watched, when last). Most watched first; on a tie the one watched more
|
||||
lately, which is the likelier interest."""
|
||||
n = func.count(TakeoutItem.id)
|
||||
last = func.max(TakeoutItem.at)
|
||||
return list(
|
||||
db.execute(
|
||||
select(Channel, n, last)
|
||||
.join(Channel, Channel.id == TakeoutItem.channel_id)
|
||||
.where(_unfollowed(user), TakeoutItem.duration_seconds >= WATCHED_CHANNEL_MIN_SECONDS)
|
||||
.group_by(Channel.id)
|
||||
.having(n >= WATCHED_CHANNEL_MIN_VIDEOS)
|
||||
.order_by(n.desc(), last.desc(), Channel.id)
|
||||
).tuples()
|
||||
)
|
||||
|
||||
|
||||
def has_watch_history(db: Session, user: User) -> bool:
|
||||
"""Whether the user has uploaded a Takeout watch history at all."""
|
||||
return bool(
|
||||
db.scalar(
|
||||
select(
|
||||
select(TakeoutItem.id)
|
||||
.where(owned_by(TakeoutItem.user_id, user), TakeoutItem.kind == TakeoutKind.WATCHED)
|
||||
.exists()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def watched_in_history(db: Session, user: User, channel_id: str) -> bool:
|
||||
"""Whether the user's Takeout history shows them watching this channel at all."""
|
||||
return bool(
|
||||
db.scalar(
|
||||
select(
|
||||
select(TakeoutItem.id)
|
||||
.where(
|
||||
owned_by(TakeoutItem.user_id, user),
|
||||
TakeoutItem.kind == TakeoutKind.WATCHED,
|
||||
TakeoutItem.channel_id == channel_id,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def watched_channel_videos(
|
||||
db: Session, user: User, channel_id: str
|
||||
) -> tuple[list[tuple[str, datetime]], int]:
|
||||
"""The user's watched videos of one channel that the list counts, last watched first: (video
|
||||
id, when). And how many YouTube no longer has."""
|
||||
rows = db.execute(
|
||||
select(TakeoutItem.video_id, TakeoutItem.at, TakeoutItem.duration_seconds)
|
||||
.where(
|
||||
owned_by(TakeoutItem.user_id, user),
|
||||
TakeoutItem.kind == TakeoutKind.WATCHED,
|
||||
TakeoutItem.channel_id == channel_id,
|
||||
TakeoutItem.looked_up_at.is_not(None),
|
||||
)
|
||||
.order_by(TakeoutItem.at.desc(), TakeoutItem.video_id)
|
||||
).tuples()
|
||||
counted: list[tuple[str, datetime]] = []
|
||||
gone = 0
|
||||
for vid, at, seconds in rows:
|
||||
if seconds is None:
|
||||
gone += 1
|
||||
elif seconds >= WATCHED_CHANNEL_MIN_SECONDS:
|
||||
counted.append((vid, at))
|
||||
return counted, gone
|
||||
|
||||
|
||||
def takeout_users_with_list_work(db: Session) -> list[int]:
|
||||
"""Every user whose "watched but not followed" list a run can still add to: lengths to ask, or
|
||||
channels to name. Asked of each user whose history holds a channel of enough watched videos,
|
||||
since what is off a list (followed, blocked, put out, their own) is that user's own — a row of
|
||||
one is never asked about, and would otherwise bring the user back on every run."""
|
||||
per_channel = (
|
||||
select(TakeoutItem.user_id)
|
||||
.where(TakeoutItem.kind == TakeoutKind.WATCHED, TakeoutItem.channel_id.is_not(None))
|
||||
.group_by(TakeoutItem.user_id, TakeoutItem.channel_id)
|
||||
.having(func.count() >= WATCHED_CHANNEL_MIN_VIDEOS)
|
||||
.subquery()
|
||||
)
|
||||
users = db.execute(
|
||||
select(User).where(User.id.in_(select(per_channel.c.user_id))).order_by(User.id)
|
||||
).scalars()
|
||||
return [user.id for user in users if watched_channels_pending(db, user)]
|
||||
|
||||
@@ -9,13 +9,27 @@ unique key, a row lock taken before a presence check. The comfort shelf's writes
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import exists, func, select, text, update
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
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,
|
||||
TakeoutChannelDismissal,
|
||||
TakeoutItem,
|
||||
TakeoutKind,
|
||||
TakeoutOutcome,
|
||||
VideoState,
|
||||
VideoUnmark,
|
||||
)
|
||||
from app.sync.playlists import lock_playlists, recompute_dirty
|
||||
from app.userscope import owned_by
|
||||
|
||||
@@ -196,6 +210,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 +219,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 +258,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 +524,192 @@ 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]),
|
||||
}
|
||||
|
||||
|
||||
# --- channels watched but not followed ---------------------------------------------------------
|
||||
|
||||
|
||||
def record_takeout_lengths(
|
||||
db: Session, user: User, asked: list[str], lengths: dict[str, int | None]
|
||||
) -> None:
|
||||
"""Keep what YouTube said of the user's watched videos `asked` about: each one's length, or none
|
||||
for a video it no longer has (`lengths` leaves it out). Every asked row is marked asked, so a
|
||||
video YouTube lost is not asked about again."""
|
||||
if not asked:
|
||||
return
|
||||
lock_takeout(db, user) # these rows are an upload's to update too
|
||||
now = datetime.now(timezone.utc)
|
||||
table = cast(Table, TakeoutItem.__table__)
|
||||
db.execute(
|
||||
table.update()
|
||||
.where(
|
||||
owned_by(TakeoutItem.user_id, user),
|
||||
table.c.kind == TakeoutKind.WATCHED,
|
||||
table.c.video_id == bindparam("vid"),
|
||||
)
|
||||
.values(looked_up_at=now, duration_seconds=bindparam("seconds")),
|
||||
[{"vid": vid, "seconds": lengths.get(vid)} for vid in asked],
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
def dismiss_watched_channel(db: Session, user: User, channel_id: str) -> None:
|
||||
"""Put a channel out of the user's "watched but not followed" list (again is a no-op)."""
|
||||
db.execute(
|
||||
pg_insert(TakeoutChannelDismissal)
|
||||
.values(user_id=user.id, channel_id=channel_id)
|
||||
.on_conflict_do_nothing(constraint="uq_takeout_channel_dismissal")
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
def restore_watched_channel(db: Session, user: User, channel_id: str) -> None:
|
||||
"""Let a channel the user put out of the list back on it."""
|
||||
db.execute(
|
||||
delete(TakeoutChannelDismissal).where(
|
||||
owned_by(TakeoutChannelDismissal.user_id, user),
|
||||
TakeoutChannelDismissal.channel_id == channel_id,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
"""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 watched videos' channels also
|
||||
make the list of channels the user watches but does not follow (`queries.watched_channels`): the
|
||||
job asks YouTube how long those videos are, and what the channels are, 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.subscriptions import describe_channels
|
||||
from app.sync.videos import ensure_videos, parse_iso8601_duration
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
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. Both
|
||||
# shares pace the work, they do not bound its cost: they are held in memory, so a restart starts
|
||||
# them afresh, which only brings the rest forward — every video is asked about once (a looked-up
|
||||
# row is marked), and `quota.backfill_reserve_reached` still guards the shared budget.
|
||||
_lookups = RateLimiter(max_events=2 * WATCH_LATER_MAX, window_seconds=24 * 3600)
|
||||
# How many watched videos' lengths one user may ask about in a day for the "watched but not followed"
|
||||
# list (a unit a fifty). A share of its own: a large history asks for thousands, and on the Watch
|
||||
# later share it would leave the export's Watch later file waiting a day behind it.
|
||||
_lengths = 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 _ask_lengths(db: Session, user: User) -> dict:
|
||||
"""Ask YouTube how long the user's watched videos are that the "watched but not followed" list
|
||||
still needs, and keep the answer — on the same terms as the Watch later lookups, on a daily
|
||||
share of its own. What is not asked yet (no YouTube-connected account, the reserve reached, the
|
||||
share spent) waits for a later run, and the list says it is not complete. A YouTube failure is
|
||||
logged and left for a later run too: it is no reason to fail the import it follows. The caller
|
||||
commits."""
|
||||
ask = queries.takeout_lengths_to_ask(db, user)
|
||||
fetcher = get_service_user(db) if ask else None
|
||||
if fetcher is None or quota.backfill_reserve_reached():
|
||||
return {}
|
||||
ask = ask[: int(_lengths.room(str(user.id)))]
|
||||
if not ask:
|
||||
return {}
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP):
|
||||
with YouTubeClient(db, fetcher) as yt:
|
||||
items = yt.get_videos(ask)
|
||||
except YouTubeError as exc:
|
||||
log.warning(
|
||||
"Takeout: asking the watched videos' lengths failed: %s",
|
||||
exc,
|
||||
extra=logs.event("takeout.lengths.failed", user_id=user.id, reason=exc.reason),
|
||||
)
|
||||
return {}
|
||||
_lengths.allow(str(user.id), cost=len(ask))
|
||||
lengths = {
|
||||
it["id"]: parse_iso8601_duration(it.get("contentDetails", {}).get("duration"))
|
||||
for it in items
|
||||
if it.get("id")
|
||||
}
|
||||
service.record_takeout_lengths(db, user, ask, lengths)
|
||||
return {"lengths_asked": len(ask)}
|
||||
|
||||
|
||||
def _describe_channels(db: Session, user: User) -> dict:
|
||||
"""Bring the channels the user's list holds into the catalog with their details (name, picture,
|
||||
description), the ones it lacks: without them the list cannot show a channel. A YouTube failure
|
||||
is logged and left for a later run. The caller commits."""
|
||||
need = queries.watched_channels_to_describe(db, user)
|
||||
fetcher = get_service_user(db) if need else None
|
||||
if fetcher is None or quota.backfill_reserve_reached():
|
||||
return {}
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
|
||||
with YouTubeClient(db, fetcher) as yt:
|
||||
describe_channels(db, yt, need)
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
log.warning(
|
||||
"Takeout: describing the watched channels failed: %s",
|
||||
exc,
|
||||
extra=logs.event("takeout.channels.failed", user_id=user.id, reason=exc.reason),
|
||||
)
|
||||
return {}
|
||||
return {"channels_described": len(need)}
|
||||
|
||||
|
||||
def _channel_list(db: Session, user: User) -> dict:
|
||||
"""The "watched but not followed" list's part of a run: the lengths, landed, then the channels.
|
||||
Commits each."""
|
||||
result = _ask_lengths(db, user)
|
||||
db.commit()
|
||||
result |= _describe_channels(db, user)
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
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; the "watched but not followed"
|
||||
list's lookups come last. 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()
|
||||
result |= _channel_list(db, user)
|
||||
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), the Watch later videos a run could not put in yet, and what the "watched but not
|
||||
followed" list still needs asked of YouTube. 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 = asked = failed = 0
|
||||
ready = set(queries.takeout_users_ready(db))
|
||||
later = set(queries.takeout_users_watch_later_waiting(db))
|
||||
lengths = set(queries.takeout_users_with_list_work(db))
|
||||
for user_id in sorted(ready | later | lengths):
|
||||
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()
|
||||
if user_id in lengths:
|
||||
asked += _channel_list(db, user).get("lengths_asked", 0)
|
||||
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 asked:
|
||||
out["takeout_lengths_asked"] = asked
|
||||
if failed:
|
||||
out["takeout_failed"] = failed
|
||||
return out
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -307,9 +307,9 @@ def explore_channel(
|
||||
"""Open an un-subscribed channel for browsing. Records the exploration (a per-user,
|
||||
grace-clocked row that gates this user's access to the channel's ephemeral videos and
|
||||
drives the later cleanup), flags the channel ephemeral unless someone organically follows
|
||||
it, then ingests one page of its uploads (newest-first) marked via_explore + enriched.
|
||||
Returns the next page token so the page can "load more". Guarded against the shared
|
||||
backfill quota reserve."""
|
||||
it, then ingests one page of its uploads (newest-first), enriched — marked via_explore while
|
||||
nobody follows it. Returns the next page token so the page can "load more". Guarded against
|
||||
the shared backfill quota reserve."""
|
||||
channel = _channel_or_404(db, channel_id)
|
||||
if queries.is_blocked(db, user, channel_id):
|
||||
raise refused(403, "channel_blocked")
|
||||
|
||||
+229
-6
@@ -1,19 +1,31 @@
|
||||
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, Path, 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.catalog import queries as catalog_queries, service as catalog_service
|
||||
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.sync.videos import parse_iso8601_duration
|
||||
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 ensure_videos, parse_iso8601_duration
|
||||
from app.utils import iso
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
from app.refusal import refused
|
||||
@@ -293,3 +305,214 @@ 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),
|
||||
}
|
||||
|
||||
|
||||
# --- channels watched but not followed -----------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/takeout/channels")
|
||||
def watched_channels(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""The channels the user's Takeout history shows them watching often but not following: each
|
||||
with how many of its videos over five minutes they watched, and when last. `pending` is how many
|
||||
more channels may join once the import has asked YouTube for their videos' lengths or their
|
||||
names, so a list still being made does not read as the whole of it; `imported` tells "no history
|
||||
uploaded" from "nothing to suggest"."""
|
||||
return {
|
||||
"imported": queries.has_watch_history(db, user),
|
||||
"pending": queries.watched_channels_pending(db, user),
|
||||
"channels": [
|
||||
{
|
||||
"id": ch.id,
|
||||
"title": ch.title,
|
||||
"handle": ch.handle,
|
||||
"thumbnail_url": ch.thumbnail_url,
|
||||
"subscriber_count": ch.subscriber_count,
|
||||
"video_count": ch.video_count,
|
||||
"description": ch.description,
|
||||
"watched": int(n),
|
||||
"last_watched_at": iso(last),
|
||||
}
|
||||
for ch, n, last in queries.watched_channels(db, user)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/takeout/channels/{channel_id}/videos")
|
||||
def watched_channel_videos(
|
||||
channel_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""The videos of a channel that the user's list counts, ready to play, last watched first. The
|
||||
channel is explored for the user (as its page does), and the videos the catalog lacks come in as
|
||||
the exploration's: the player needs them in the catalog, the shared Library does not get them,
|
||||
and the cleanup reclaims them once nobody keeps them. Of a channel someone follows they come in
|
||||
as the catalog's own, as its uploads do. `gone`: how many YouTube no longer has."""
|
||||
channel = catalog_queries.channel(db, channel_id)
|
||||
if channel is None:
|
||||
raise not_found("channel")
|
||||
if catalog_queries.is_blocked(db, user, channel_id):
|
||||
raise refused(403, "channel_blocked")
|
||||
counted, gone = queries.watched_channel_videos(db, user, channel_id)
|
||||
ids = [vid for vid, _ in counted]
|
||||
known = queries.known_video_ids(db, ids)
|
||||
missing = [vid for vid in ids if vid not in known]
|
||||
if missing and quota.backfill_reserve_reached():
|
||||
raise refused(429, "quota_reserve")
|
||||
catalog_service.explore(db, user, channel)
|
||||
db.commit()
|
||||
if missing:
|
||||
try:
|
||||
with (
|
||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_EXPLORE),
|
||||
YouTubeClient(db, user) as yt,
|
||||
):
|
||||
ensure_videos(db, yt, missing, explore=channel.from_explore)
|
||||
except YouTubeError as exc:
|
||||
raise youtube_refusal(exc) from exc
|
||||
catalog_service.settle_explored(db, channel)
|
||||
db.commit()
|
||||
items = queries.feed_rows_by_ids(db, user, ids)
|
||||
watched_at = dict(counted)
|
||||
for item in items:
|
||||
item["takeout_watched_at"] = iso(watched_at[item["id"]])
|
||||
# A video YouTube still had when its length was asked may have gone since.
|
||||
return {"ok": True, "items": items, "gone": gone + len(ids) - len(items)}
|
||||
|
||||
|
||||
@router.put("/takeout/channels/{channel_id}/dismissal")
|
||||
def dismiss_watched_channel(
|
||||
channel_id: str = Path(max_length=32),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Put a channel out of the user's list (it stays out until restored). Only one their own history
|
||||
shows them watching: a made-up id would only pile up rows every list read then has to skip."""
|
||||
if not queries.watched_in_history(db, user, channel_id):
|
||||
raise not_found("channel")
|
||||
service.dismiss_watched_channel(db, user, channel_id)
|
||||
db.commit()
|
||||
return {"ok": True, "channel_id": channel_id}
|
||||
|
||||
|
||||
@router.delete("/takeout/channels/{channel_id}/dismissal")
|
||||
def restore_watched_channel(
|
||||
channel_id: str = Path(max_length=32),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Let a channel the user put out of the list back on it."""
|
||||
service.restore_watched_channel(db, user, channel_id)
|
||||
db.commit()
|
||||
return {"ok": True, "channel_id": channel_id}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import delete, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app.catalog import service as catalog_service
|
||||
from app.catalog.models import Channel, ExploredChannel, SearchFind, Subscription, Video
|
||||
from app.reader import queries as reader_queries
|
||||
from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_details
|
||||
@@ -66,11 +67,13 @@ def _enrich_new(db: Session, yt: YouTubeClient, video_ids: list[str]) -> int:
|
||||
def explore_ingest_page(
|
||||
db: Session, yt: YouTubeClient, channel: Channel, page_token: str | None = None
|
||||
) -> dict:
|
||||
"""Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new
|
||||
videos via_explore, then enrich them. Returns the count ingested and the next page token
|
||||
(None when the back-catalogue is exhausted) so the caller can offer "load more". Stateless
|
||||
pagination: the caller round-trips the token, so the channel's own backfill cursor (used by
|
||||
the organic deep backfill) is never touched."""
|
||||
"""Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new videos
|
||||
via_explore while the channel is an exploration's (`from_explore`): of a channel someone follows
|
||||
they are its uploads like any other, and a flag there would never be cleared, since subscribing
|
||||
clears only an explored channel's. Then enrich them. Returns the count ingested and the next
|
||||
page token (None when the back-catalogue is exhausted) so the caller can offer "load more".
|
||||
Stateless pagination: the caller round-trips the token, so the channel's own backfill cursor
|
||||
(used by the organic deep backfill) is never touched."""
|
||||
if not channel.uploads_playlist_id:
|
||||
return {"ingested": 0, "next_page_token": None}
|
||||
try:
|
||||
@@ -86,9 +89,10 @@ def explore_ingest_page(
|
||||
for item in data.get("items", []):
|
||||
stub = stub_from_playlist_item(item, channel.id)
|
||||
if stub is not None:
|
||||
stub["via_explore"] = True
|
||||
stub["via_explore"] = channel.from_explore
|
||||
stubs.append(stub)
|
||||
inserted = insert_stubs(db, stubs)
|
||||
catalog_service.settle_explored(db, channel)
|
||||
self_enriched = _enrich_new(db, yt, [s["id"] for s in stubs])
|
||||
log.info(
|
||||
"explore ingest channel=%s ingested=%s enriched=%s", channel.id, inserted, self_enriched
|
||||
@@ -105,7 +109,9 @@ def purge_unkept_explored(db: Session) -> dict:
|
||||
- a live ExploredChannel row (someone explored it within the grace window — channel-level), or
|
||||
- a video has per-user state: watched/in-progress (VideoState), in a playlist (PlaylistItem),
|
||||
or surfaced by the user's own search (SearchFind) — these survive even if the channel goes.
|
||||
A channel row is dropped only once it holds no videos at all. 0 quota."""
|
||||
A channel row is dropped only once it holds no videos at all, and not while someone's Takeout
|
||||
history shows it watched (their "watched but not followed" list names it from that row).
|
||||
0 quota."""
|
||||
grace_days = sysconfig.get_int(db, "explore_grace_days")
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=grace_days)
|
||||
|
||||
@@ -135,7 +141,9 @@ def purge_unkept_explored(db: Session) -> dict:
|
||||
|
||||
has_videos = select(Video.id).where(Video.channel_id == Channel.id).exists()
|
||||
channels_deleted = db.execute(
|
||||
delete(Channel).where(Channel.id.in_(orphan_ids), ~has_videos)
|
||||
delete(Channel).where(
|
||||
Channel.id.in_(orphan_ids), ~has_videos, ~reader_queries.in_a_watch_history(Channel.id)
|
||||
)
|
||||
).rowcount
|
||||
db.commit()
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app.models import User
|
||||
from app.catalog import service as catalog_service
|
||||
from app.catalog.models import Channel, Subscription
|
||||
from app.utils import normalize_description
|
||||
from app.youtube.client import YouTubeClient, YouTubeError, best_thumbnail
|
||||
@@ -109,6 +110,21 @@ def apply_channel_details(db: Session, items: list[dict], asked: Iterable[str])
|
||||
channel.details_synced_at = now
|
||||
|
||||
|
||||
def describe_channels(db: Session, yt: YouTubeClient, channel_ids: list[str]) -> None:
|
||||
"""Make sure each given channel is in the catalog with its details: a channel it lacks is added
|
||||
as a stub, then all of them are asked for (`channels.list`, 1 unit a 50). Flushes; the caller
|
||||
commits."""
|
||||
if not channel_ids:
|
||||
return
|
||||
have = set(db.execute(select(Channel.id).where(Channel.id.in_(channel_ids))).scalars())
|
||||
for cid in dict.fromkeys(channel_ids):
|
||||
if cid not in have:
|
||||
db.add(Channel(id=cid))
|
||||
db.flush()
|
||||
apply_channel_details(db, yt.get_channels(channel_ids), channel_ids)
|
||||
db.flush()
|
||||
|
||||
|
||||
def should_prune(fetched_count: int, existing_count: int) -> bool:
|
||||
"""Whether an import may delete the local subscriptions missing from this fetch.
|
||||
|
||||
@@ -182,6 +198,10 @@ def import_subscriptions(db: Session, user: User) -> dict:
|
||||
db.add(sub_row)
|
||||
sub_row.yt_subscription_id = sub.get("yt_subscription_id")
|
||||
db.flush()
|
||||
# A follow made on youtube.com keeps an explored channel as a follow made here does.
|
||||
explored = select(Channel).where(Channel.id.in_(fetched), Channel.from_explore.is_(True))
|
||||
for channel in db.execute(explored).scalars():
|
||||
catalog_service.settle_explored(db, channel)
|
||||
|
||||
# Prune subscriptions removed on YouTube — but only when this fetch is trustworthy
|
||||
# (see `should_prune`).
|
||||
|
||||
@@ -420,11 +420,15 @@ def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
|
||||
return {"probed": probed, "shorts": shorts}
|
||||
|
||||
|
||||
def ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None:
|
||||
def ensure_videos(
|
||||
db: Session, yt: YouTubeClient, video_ids: list[str], *, explore: bool = False
|
||||
) -> None:
|
||||
"""Make sure every given video id exists in the catalog, fetching + ingesting the
|
||||
missing ones (and a stub channel for any unknown channel). Deleted/private videos that
|
||||
the API won't return are simply left out. The catalog's write for a playlist sync that names
|
||||
videos it has never seen; commits."""
|
||||
videos it has never seen; commits. `explore` adds the new ones as an exploration's
|
||||
(`via_explore`): visible to who explores or follows their channel, and reclaimed by the cleanup
|
||||
once nobody keeps them."""
|
||||
if not video_ids:
|
||||
return
|
||||
have = set(db.execute(select(Video.id).where(Video.id.in_(video_ids))).scalars().all())
|
||||
@@ -458,7 +462,12 @@ def ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None:
|
||||
seen.add(vid)
|
||||
# published_at is NOT NULL (0068); apply_video_details re-fills a missing date, but seed a now()
|
||||
# fallback here too so the constructor never carries an explicit NULL.
|
||||
v = Video(id=vid, channel_id=cid, published_at=parse_dt(sn.get("publishedAt")) or now)
|
||||
v = Video(
|
||||
id=vid,
|
||||
channel_id=cid,
|
||||
published_at=parse_dt(sn.get("publishedAt")) or now,
|
||||
via_explore=explore,
|
||||
)
|
||||
apply_video_details(v, it)
|
||||
v.enriched_at = now
|
||||
db.add(v)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.catalog import queries, search, service
|
||||
from app.db import SessionLocal
|
||||
from app.catalog.models import (
|
||||
BlockedChannel,
|
||||
Channel,
|
||||
@@ -22,6 +23,8 @@ from app.catalog.models import (
|
||||
)
|
||||
from app.reader import queries as reader_queries
|
||||
from app.reader import service as reader_service
|
||||
from app.sync import explore as sync_explore
|
||||
from app.sync import subscriptions as sync_subscriptions
|
||||
|
||||
|
||||
def _channel(db, cid="UCc", **kw):
|
||||
@@ -141,6 +144,89 @@ def test_subscribing_keeps_an_explored_channel_and_its_videos(db, make_user):
|
||||
assert queries.subscription(db, user, "UCc") is None
|
||||
|
||||
|
||||
def test_exploring_a_channel_someone_follows_brings_its_uploads_in_as_the_catalogs_own(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
follower, explorer = make_user(), make_user()
|
||||
ch = _channel(db, uploads_playlist_id="UUc")
|
||||
db.add(Subscription(user_id=follower.id, channel_id="UCc"))
|
||||
db.commit()
|
||||
service.explore(db, explorer, ch)
|
||||
db.commit()
|
||||
|
||||
class _Yt:
|
||||
def get_playlist_items(self, playlist_id, page_token):
|
||||
item = {"contentDetails": {"videoId": "v1", "videoPublishedAt": "2026-05-01T00:00:00Z"}}
|
||||
return {"items": [item]}
|
||||
|
||||
monkeypatch.setattr(sync_explore, "_enrich_new", lambda session, yt, ids: 0)
|
||||
sync_explore.explore_ingest_page(db, _Yt(), ch)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
# The channel is followed, so not the exploration's: a video flagged as one would stay flagged
|
||||
# (subscribing clears only an explored channel's) and its watched marks would wait for good.
|
||||
assert (ch.from_explore, db.get(Video, "v1").via_explore) == (False, False)
|
||||
|
||||
|
||||
def test_a_follow_pulled_in_from_youtube_makes_an_explored_channel_the_catalogs_own(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
explorer, follower = make_user(), make_user()
|
||||
ch = _channel(db, details_synced_at=datetime.now(timezone.utc))
|
||||
service.explore(db, explorer, ch)
|
||||
db.commit()
|
||||
_video(db, "v1", via_explore=True)
|
||||
|
||||
class _Yt:
|
||||
def __init__(self, session, user):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def iter_subscriptions(self):
|
||||
yield {"channel_id": "UCc", "title": "UCc", "yt_subscription_id": "sub-UCc"}
|
||||
|
||||
def get_channels(self, ids):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(sync_subscriptions, "YouTubeClient", _Yt)
|
||||
sync_subscriptions.import_subscriptions(db, follower)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
# Followed on youtube.com is followed all the same: left flagged, the channel's later videos
|
||||
# came in as an exploration's, hidden from the Library and their watched marks waiting for good.
|
||||
assert (db.get(Channel, "UCc").from_explore, db.get(Video, "v1").via_explore) == (False, False)
|
||||
|
||||
|
||||
def test_a_follow_landing_while_an_explored_page_comes_in_leaves_none_of_it_flagged(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
explorer, follower = make_user(), make_user()
|
||||
ch = _channel(db, uploads_playlist_id="UUc")
|
||||
service.explore(db, explorer, ch)
|
||||
db.commit()
|
||||
|
||||
class _Yt:
|
||||
def get_playlist_items(self, playlist_id, page_token):
|
||||
# Someone subscribes, in a request of their own, while YouTube answers this one.
|
||||
with SessionLocal() as other:
|
||||
service.record_subscription(other, follower, other.get(Channel, "UCc"), "sub-UCc")
|
||||
other.commit()
|
||||
item = {"contentDetails": {"videoId": "v1", "videoPublishedAt": "2026-05-01T00:00:00Z"}}
|
||||
return {"items": [item]}
|
||||
|
||||
monkeypatch.setattr(sync_explore, "_enrich_new", lambda session, yt, ids: 0)
|
||||
sync_explore.explore_ingest_page(db, _Yt(), ch)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
# The subscribe cleared what was flagged before it; a page that lands after it must not be.
|
||||
assert (db.get(Channel, "UCc").from_explore, db.get(Video, "v1").via_explore) == (False, False)
|
||||
|
||||
|
||||
def test_a_block_is_once_and_ends_the_exploration(db, make_user):
|
||||
user = make_user()
|
||||
ch = _channel(db)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Migration 0116's data step, DB-lane: an explored channel someone already follows becomes the
|
||||
catalog's own, and no video stays flagged as an exploration's on a channel that is not one.
|
||||
|
||||
The schema half is checked by test_migrations.py; a flag left on is invisible there.
|
||||
"""
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
|
||||
def test_followed_explored_channels_and_their_videos_are_the_catalogs_own(db, make_user):
|
||||
follower = make_user().id
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0115_takeout_channels")
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
|
||||
def channel(cid, explored):
|
||||
conn.execute(
|
||||
text("INSERT INTO channels (id, from_explore) VALUES (:c, :e)"),
|
||||
{"c": cid, "e": explored},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO videos (id, channel_id, published_at, via_explore)"
|
||||
" VALUES (:v, :c, now(), true)"
|
||||
),
|
||||
{"v": f"v-{cid}", "c": cid},
|
||||
)
|
||||
|
||||
channel("UCfollowed", True) # explored, then followed on youtube.com
|
||||
channel("UCexplored", True) # explored, nobody follows it: stays the exploration's
|
||||
channel("UCorganic", False) # a video flagged after a subscribe had cleared the channel
|
||||
conn.execute(
|
||||
text("INSERT INTO subscriptions (user_id, channel_id) VALUES (:u, 'UCfollowed')"),
|
||||
{"u": follower},
|
||||
)
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
with engine.connect() as conn:
|
||||
channels = dict(conn.execute(text("SELECT id, from_explore FROM channels")).tuples().all())
|
||||
videos = dict(
|
||||
conn.execute(text("SELECT channel_id, via_explore FROM videos")).tuples().all()
|
||||
)
|
||||
assert channels == {"UCfollowed": False, "UCexplored": True, "UCorganic": False}
|
||||
assert videos == {"UCfollowed": False, "UCexplored": True, "UCorganic": False}
|
||||
@@ -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
@@ -44,6 +44,9 @@ UNIT_MODELS = {
|
||||
"PlaylistItem",
|
||||
"SavedView",
|
||||
"ComfortPin",
|
||||
"TakeoutItem",
|
||||
"TakeoutChannelDismissal",
|
||||
"VideoUnmark",
|
||||
"Tag",
|
||||
"ChannelTag",
|
||||
},
|
||||
@@ -360,6 +363,13 @@ 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",
|
||||
# Inside either of those: the lengths YouTube gave land before the channels are asked for.
|
||||
"reader/takeout.py:_channel_list",
|
||||
},
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
"""The channels a user's Takeout history shows them watching but not following: which channels make
|
||||
the list (five videos of five minutes or more, none they follow, block, put out or own), what the
|
||||
import asks YouTube for it and when, and the channel's watched videos made ready to play."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app import quota
|
||||
from app.catalog import service as catalog_service
|
||||
from app.catalog.models import BlockedChannel, Channel, ExploredChannel, Subscription, Video
|
||||
from app.db import SessionLocal
|
||||
from app.reader import queries, service, takeout
|
||||
from app.reader.models import TakeoutChannelDismissal, TakeoutItem, TakeoutKind
|
||||
from app.routes import feed as feed_routes
|
||||
from app.sync import explore as sync_explore
|
||||
from app.sync import videos as sync_videos
|
||||
from app.youtube.client import YouTubeError
|
||||
|
||||
T0 = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
LONG = 600
|
||||
SHORT = 120
|
||||
|
||||
|
||||
def _ch(n: int) -> str:
|
||||
return f"UC{n:022d}"
|
||||
|
||||
|
||||
def _vid(channel: int, n: int) -> str:
|
||||
return f"c{channel:02d}v{n:07d}"
|
||||
|
||||
|
||||
def _watched(db, user, channel: int, lengths: list[int | None], *, at=T0, asked=True):
|
||||
"""Stage `len(lengths)` watched videos of a channel, the newest first, each with its length
|
||||
already asked (None: YouTube no longer has it) — or not asked yet."""
|
||||
service.stage_takeout(
|
||||
db,
|
||||
user,
|
||||
TakeoutKind.WATCHED,
|
||||
{_vid(channel, i): (at - timedelta(days=i), _ch(channel)) for i in range(len(lengths))},
|
||||
)
|
||||
db.flush()
|
||||
if asked:
|
||||
ids = [_vid(channel, i) for i in range(len(lengths))]
|
||||
service.record_takeout_lengths(
|
||||
db, user, ids, {v: s for v, s in zip(ids, lengths, strict=True) if s is not None}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _channel(db, n: int, title: str | None = None):
|
||||
db.add(Channel(id=_ch(n), title=title or f"Channel {n}", details_synced_at=T0))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _listed(db, user) -> list[tuple[str, int]]:
|
||||
return [(ch.id, n) for ch, n, _ in queries.watched_channels(db, user)]
|
||||
|
||||
|
||||
# --- which channels make the list --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_channel_makes_the_list_with_five_watched_videos_of_five_minutes_or_more(db, make_user):
|
||||
user = make_user("list@example.com")
|
||||
for n in (1, 2, 3):
|
||||
_channel(db, n)
|
||||
_watched(db, user, 1, [LONG] * 5 + [SHORT] * 3) # five long, three short: on it with 5
|
||||
_watched(db, user, 2, [LONG] * 4 + [SHORT] * 6) # ten watched, only four long: off it
|
||||
_watched(db, user, 3, [299, 300, 300, 300, 300, 300]) # five at exactly five minutes: on it
|
||||
# A tie: channel 1's counted videos were watched later (channel 3's newest is under five minutes).
|
||||
assert _listed(db, user) == [(_ch(1), 5), (_ch(3), 5)]
|
||||
|
||||
|
||||
def test_a_video_youtube_no_longer_has_does_not_count(db, make_user):
|
||||
user = make_user("gone@example.com")
|
||||
_channel(db, 1)
|
||||
_watched(db, user, 1, [LONG] * 4 + [None] * 3)
|
||||
assert _listed(db, user) == []
|
||||
|
||||
|
||||
def test_the_most_watched_lead_and_a_tie_goes_to_the_one_watched_more_lately(db, make_user):
|
||||
user = make_user("order@example.com")
|
||||
for n in (1, 2, 3):
|
||||
_channel(db, n)
|
||||
_watched(db, user, 1, [LONG] * 6, at=T0 - timedelta(days=100))
|
||||
_watched(db, user, 2, [LONG] * 9, at=T0 - timedelta(days=300))
|
||||
_watched(db, user, 3, [LONG] * 6, at=T0)
|
||||
assert _listed(db, user) == [(_ch(2), 9), (_ch(3), 6), (_ch(1), 6)]
|
||||
last = {ch.id: when for ch, _, when in queries.watched_channels(db, user)}
|
||||
assert last[_ch(3)] == T0 # the last watched of the counted ones
|
||||
|
||||
|
||||
def test_a_channel_the_user_follows_blocked_put_out_or_owns_is_not_on_the_list(db, make_user):
|
||||
user = make_user("off@example.com", yt_channel_id=_ch(4))
|
||||
for n in (1, 2, 3, 4, 5):
|
||||
_channel(db, n)
|
||||
_watched(db, user, n, [LONG] * 5)
|
||||
db.add(Subscription(user_id=user.id, channel_id=_ch(1)))
|
||||
db.add(BlockedChannel(user_id=user.id, channel_id=_ch(2)))
|
||||
db.commit()
|
||||
service.dismiss_watched_channel(db, user, _ch(3))
|
||||
db.commit()
|
||||
assert _listed(db, user) == [(_ch(5), 5)]
|
||||
|
||||
|
||||
def test_one_users_follows_and_dismissals_leave_anothers_list_alone(db, make_user):
|
||||
me, other = make_user("me@example.com"), make_user("other@example.com")
|
||||
_channel(db, 1)
|
||||
for user in (me, other):
|
||||
_watched(db, user, 1, [LONG] * 5)
|
||||
db.add(Subscription(user_id=other.id, channel_id=_ch(1)))
|
||||
db.commit()
|
||||
service.dismiss_watched_channel(db, other, _ch(1))
|
||||
db.commit()
|
||||
assert _listed(db, me) == [(_ch(1), 5)]
|
||||
assert _listed(db, other) == []
|
||||
|
||||
|
||||
def test_a_channel_whose_lengths_are_not_all_known_is_pending_until_they_are(db, make_user):
|
||||
user = make_user("pending@example.com")
|
||||
_channel(db, 1)
|
||||
_watched(db, user, 1, [LONG] * 6, asked=False)
|
||||
_watched(db, user, 2, [LONG] * 4, asked=False) # four watched: it can never make the list
|
||||
assert queries.watched_channels_pending(db, user) == 1
|
||||
assert _listed(db, user) == []
|
||||
ids = [_vid(1, i) for i in range(6)]
|
||||
service.record_takeout_lengths(db, user, ids, dict.fromkeys(ids, LONG))
|
||||
db.commit()
|
||||
assert queries.watched_channels_pending(db, user) == 0
|
||||
assert _listed(db, user) == [(_ch(1), 6)]
|
||||
|
||||
|
||||
def test_a_new_upload_of_the_history_keeps_the_lengths_already_asked(db, make_user):
|
||||
user = make_user("again@example.com")
|
||||
_channel(db, 1)
|
||||
_watched(db, user, 1, [LONG] * 5)
|
||||
_watched(db, user, 1, [LONG] * 5, at=T0 + timedelta(days=1), asked=False)
|
||||
assert _listed(db, user) == [(_ch(1), 5)]
|
||||
assert queries.takeout_lengths_to_ask(db, user) == []
|
||||
|
||||
|
||||
# --- what the import asks YouTube for ----------------------------------------------------------
|
||||
|
||||
|
||||
class FakeYouTube:
|
||||
"""Stands in for `YouTubeClient`: answers `videos.list` with the lengths it was given and
|
||||
`channels.list` with a title per channel, and records what it was asked."""
|
||||
|
||||
def __init__(self, lengths: dict[str, int] | None = None, *, fail: bool = False):
|
||||
self.lengths = lengths or {}
|
||||
self.fail = fail
|
||||
self.fail_channels = False
|
||||
self.videos_asked: list[list[str]] = []
|
||||
self.channels_asked: list[list[str]] = []
|
||||
self.actions: list[tuple[int | None, str]] = []
|
||||
|
||||
def __call__(self, db, user):
|
||||
return self
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def get_videos(self, ids):
|
||||
self.videos_asked.append(list(ids))
|
||||
self.actions.append((quota._actor_id.get(), quota._action.get()))
|
||||
if self.fail:
|
||||
raise YouTubeError("refused", reason="quotaExceeded", path="videos")
|
||||
return [
|
||||
{"id": v, "contentDetails": {"duration": f"PT{self.lengths[v]}S"}}
|
||||
for v in ids
|
||||
if v in self.lengths
|
||||
]
|
||||
|
||||
def get_channels(self, ids):
|
||||
self.channels_asked.append(list(ids))
|
||||
self.actions.append((quota._actor_id.get(), quota._action.get()))
|
||||
if self.fail_channels:
|
||||
raise YouTubeError("refused", reason="backendError", path="channels")
|
||||
return [{"id": c, "snippet": {"title": f"Named {c[-2:]}"}} for c in ids]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def youtube(monkeypatch, db, make_user):
|
||||
fetcher = make_user("fetcher@example.com")
|
||||
fake = FakeYouTube()
|
||||
monkeypatch.setattr(takeout, "YouTubeClient", fake)
|
||||
monkeypatch.setattr(takeout, "get_service_user", lambda session: fetcher)
|
||||
monkeypatch.setattr(takeout.quota, "backfill_reserve_reached", lambda: False)
|
||||
return fake
|
||||
|
||||
|
||||
def test_the_import_asks_the_lengths_the_list_needs_and_names_its_channels(db, make_user, youtube):
|
||||
user = make_user("import@example.com")
|
||||
_watched(db, user, 1, [None] * 7, asked=False) # the most watched channel
|
||||
_watched(db, user, 2, [None] * 5, asked=False)
|
||||
_watched(db, user, 3, [None] * 4, asked=False) # too few to ever make it: not asked about
|
||||
youtube.lengths = {_vid(1, i): LONG for i in range(6)} | {_vid(2, i): SHORT for i in range(5)}
|
||||
|
||||
result = takeout.run_import(user.id)
|
||||
|
||||
asked = [_vid(1, i) for i in range(7)] + [_vid(2, i) for i in range(5)]
|
||||
assert youtube.videos_asked == [asked] # the top channel first
|
||||
assert result["lengths_asked"] == 12
|
||||
# Only channel 1 made it (six long; its seventh is gone, channel 2's are short): it is named.
|
||||
assert youtube.channels_asked == [[_ch(1)]]
|
||||
assert result["channels_described"] == 1
|
||||
assert {
|
||||
(user.id, quota.QuotaAction.VIDEOS_LOOKUP),
|
||||
(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
||||
} == set(youtube.actions)
|
||||
db.expire_all()
|
||||
assert [(ch.title, n) for ch, n, _ in queries.watched_channels(db, user)] == [("Named 01", 6)]
|
||||
assert queries.watched_channels_pending(db, user) == 0
|
||||
# Asked once: a second run asks nothing again, the gone video included.
|
||||
youtube.videos_asked.clear()
|
||||
youtube.channels_asked.clear()
|
||||
assert "lengths_asked" not in takeout.run_import(user.id)
|
||||
assert youtube.videos_asked == [] and youtube.channels_asked == []
|
||||
|
||||
|
||||
def test_the_lookups_stop_at_the_users_daily_share_and_the_rest_wait(
|
||||
db, make_user, youtube, monkeypatch
|
||||
):
|
||||
user = make_user("share@example.com")
|
||||
_watched(db, user, 1, [None] * 8, asked=False)
|
||||
_watched(db, user, 2, [None] * 6, asked=False)
|
||||
monkeypatch.setattr(takeout, "_lengths", takeout.RateLimiter(10, 3600))
|
||||
takeout.run_import(user.id)
|
||||
assert youtube.videos_asked == [[_vid(1, i) for i in range(8)] + [_vid(2, 0), _vid(2, 1)]]
|
||||
db.expire_all()
|
||||
assert queries.watched_channels_pending(db, user) == 1 # channel 2 still has some to ask
|
||||
youtube.videos_asked.clear()
|
||||
takeout.run_import(user.id)
|
||||
assert youtube.videos_asked == [] # the share is spent for today
|
||||
|
||||
|
||||
@pytest.mark.parametrize("why", ["no_fetcher", "reserve"])
|
||||
def test_nothing_is_asked_without_a_connected_account_or_past_the_reserve(
|
||||
db, make_user, youtube, monkeypatch, why
|
||||
):
|
||||
user = make_user(f"{why}@example.com")
|
||||
_watched(db, user, 1, [None] * 5, asked=False)
|
||||
if why == "no_fetcher":
|
||||
monkeypatch.setattr(takeout, "get_service_user", lambda session: None)
|
||||
else:
|
||||
monkeypatch.setattr(takeout.quota, "backfill_reserve_reached", lambda: True)
|
||||
assert "lengths_asked" not in takeout.run_import(user.id)
|
||||
assert youtube.videos_asked == []
|
||||
assert queries.watched_channels_pending(db, user) == 1
|
||||
|
||||
|
||||
def test_a_youtube_failure_asking_lengths_fails_neither_the_import_nor_its_marks(
|
||||
db, make_user, youtube
|
||||
):
|
||||
user = make_user("fail@example.com")
|
||||
_watched(db, user, 1, [None] * 5, asked=False)
|
||||
youtube.fail = True
|
||||
result = takeout.run_import(user.id)
|
||||
assert "lengths_asked" not in result
|
||||
assert queries.takeout_lengths_to_ask(db, user) == [_vid(1, i) for i in range(5)]
|
||||
db.expire_all()
|
||||
rows = db.execute(select(TakeoutItem).where(TakeoutItem.user_id == user.id)).scalars()
|
||||
assert all(r.looked_up_at is None for r in rows) # left to ask again
|
||||
|
||||
|
||||
def test_the_shorts_job_asks_what_an_import_could_not(db, make_user, youtube):
|
||||
user = make_user("later@example.com")
|
||||
_watched(db, user, 1, [None] * 5, asked=False)
|
||||
youtube.lengths = {_vid(1, i): LONG for i in range(5)}
|
||||
assert takeout.apply_waiting(db)["takeout_lengths_asked"] == 5
|
||||
db.expire_all()
|
||||
assert [n for _, n, _ in queries.watched_channels(db, user)] == [5]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("why", ["follows", "blocked", "dismissed", "own"])
|
||||
def test_a_channel_off_the_list_is_not_asked_about_nor_looked_at_again(
|
||||
db, make_user, youtube, monkeypatch, why
|
||||
):
|
||||
user = make_user(f"off-{why}@example.com", yt_channel_id=_ch(1) if why == "own" else None)
|
||||
_channel(db, 1)
|
||||
_watched(db, user, 1, [None] * 5, asked=False)
|
||||
if why == "follows":
|
||||
db.add(Subscription(user_id=user.id, channel_id=_ch(1)))
|
||||
elif why == "blocked":
|
||||
db.add(BlockedChannel(user_id=user.id, channel_id=_ch(1)))
|
||||
elif why == "dismissed":
|
||||
service.dismiss_watched_channel(db, user, _ch(1))
|
||||
db.commit()
|
||||
runs = []
|
||||
monkeypatch.setattr(takeout, "_channel_list", lambda session, u: runs.append(u.id) or {})
|
||||
assert takeout.apply_waiting(db) == {}
|
||||
# Its rows are never asked about, so they would stay "to ask" for good: the Shorts job must not
|
||||
# take the user up again for them on every run.
|
||||
assert runs == []
|
||||
assert youtube.videos_asked == []
|
||||
|
||||
|
||||
def test_naming_the_channels_is_tried_again_and_meanwhile_the_list_says_it_is_not_complete(
|
||||
db, make_user, youtube
|
||||
):
|
||||
user = make_user("unnamed@example.com")
|
||||
_watched(db, user, 1, [None] * 5, asked=False)
|
||||
youtube.lengths = {_vid(1, i): LONG for i in range(5)}
|
||||
youtube.fail_channels = True
|
||||
takeout.run_import(user.id)
|
||||
db.expire_all()
|
||||
# Every length is known, the channel could not be named: not on the list, and not complete.
|
||||
assert queries.takeout_lengths_to_ask(db, user) == []
|
||||
assert _listed(db, user) == []
|
||||
assert queries.watched_channels_pending(db, user) == 1
|
||||
youtube.fail_channels = False
|
||||
takeout.apply_waiting(db)
|
||||
db.expire_all()
|
||||
assert [(ch.title, n) for ch, n, _ in queries.watched_channels(db, user)] == [("Named 01", 5)]
|
||||
assert queries.watched_channels_pending(db, user) == 0
|
||||
|
||||
|
||||
def test_a_listed_channel_still_to_be_described_is_not_counted_as_one_to_come(db, make_user):
|
||||
user = make_user("stub@example.com")
|
||||
# A stub the catalog made when one of its videos came in: a title, no details yet.
|
||||
db.add(Channel(id=_ch(1), title="From a video"))
|
||||
db.commit()
|
||||
_watched(db, user, 1, [LONG] * 5)
|
||||
assert queries.watched_channels_to_describe(db, user) == [_ch(1)]
|
||||
assert _listed(db, user) == [(_ch(1), 5)]
|
||||
# It is on the list already: "the list may grow" would promise one more that cannot come.
|
||||
assert queries.watched_channels_pending(db, user) == 0
|
||||
|
||||
|
||||
def test_the_lengths_leave_the_watch_later_lookups_their_own_share(
|
||||
db, make_user, youtube, monkeypatch
|
||||
):
|
||||
user = make_user("shares@example.com")
|
||||
monkeypatch.setattr(takeout, "_lookups", takeout.RateLimiter(10, 3600))
|
||||
monkeypatch.setattr(takeout, "_lengths", takeout.RateLimiter(10, 3600))
|
||||
_watched(db, user, 1, [None] * 15, asked=False)
|
||||
takeout.run_import(user.id) # the history: its lengths spend all they may today
|
||||
youtube.videos_asked.clear()
|
||||
service.stage_takeout(db, user, TakeoutKind.WATCH_LATER, {"later000001": (T0, None)})
|
||||
db.commit()
|
||||
takeout.run_import(user.id) # then the export's Watch later file
|
||||
assert ["later000001"] in youtube.videos_asked
|
||||
|
||||
|
||||
# --- the channel's watched videos, ready to play ------------------------------------------------
|
||||
|
||||
|
||||
def _fake_ensure(calls):
|
||||
def ensure(session, yt, ids, *, explore=False):
|
||||
calls.append((list(ids), explore))
|
||||
for vid in ids:
|
||||
if vid.endswith("4"):
|
||||
continue # gone from YouTube since its length was asked
|
||||
session.add(Video(id=vid, channel_id=_ch(1), published_at=T0, via_explore=explore))
|
||||
session.commit()
|
||||
|
||||
return ensure
|
||||
|
||||
|
||||
def test_a_channels_watched_videos_come_ready_to_play_and_explored(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
user = make_user("play@example.com")
|
||||
_channel(db, 1)
|
||||
_watched(db, user, 1, [LONG, SHORT, LONG, None, LONG, LONG, LONG])
|
||||
db.add(Video(id=_vid(1, 0), channel_id=_ch(1), published_at=T0)) # already in the catalog
|
||||
db.commit()
|
||||
calls = []
|
||||
monkeypatch.setattr(feed_routes, "ensure_videos", _fake_ensure(calls))
|
||||
monkeypatch.setattr(feed_routes.quota, "backfill_reserve_reached", lambda: False)
|
||||
|
||||
resp = as_user(user).post(f"/api/takeout/channels/{_ch(1)}/videos")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# Only the counted ones, newest first; the short one and the gone ones are left out.
|
||||
assert calls == [([_vid(1, 2), _vid(1, 4), _vid(1, 5), _vid(1, 6)], True)]
|
||||
assert [it["id"] for it in body["items"]] == [_vid(1, 0), _vid(1, 2), _vid(1, 5), _vid(1, 6)]
|
||||
assert body["items"][0]["takeout_watched_at"].startswith("2026-05-01")
|
||||
assert body["gone"] == 2 # one YouTube had lost before, one since
|
||||
assert body["ok"] is True
|
||||
db.expire_all()
|
||||
assert db.execute(
|
||||
select(ExploredChannel).where(
|
||||
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == _ch(1)
|
||||
)
|
||||
).scalar_one()
|
||||
assert (
|
||||
db.get(Channel, _ch(1)).from_explore is True
|
||||
) # nobody follows it: the cleanup's to reclaim
|
||||
|
||||
|
||||
def test_a_followed_channels_watched_videos_come_in_as_the_catalogs_own(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
user, follower = make_user("viewer@example.com"), make_user("follower@example.com")
|
||||
_channel(db, 1)
|
||||
_watched(db, user, 1, [LONG] * 5)
|
||||
db.add(Subscription(user_id=follower.id, channel_id=_ch(1)))
|
||||
db.commit()
|
||||
calls = []
|
||||
monkeypatch.setattr(feed_routes, "ensure_videos", _fake_ensure(calls))
|
||||
monkeypatch.setattr(feed_routes.quota, "backfill_reserve_reached", lambda: False)
|
||||
assert as_user(user).post(f"/api/takeout/channels/{_ch(1)}/videos").status_code == 200
|
||||
# Someone follows it, so it is no exploration's to reclaim: a video flagged as one would never
|
||||
# be cleared again (subscribing clears only an explored channel's) and never marked watched.
|
||||
assert calls == [([_vid(1, i) for i in range(5)], False)]
|
||||
db.expire_all()
|
||||
assert db.get(Channel, _ch(1)).from_explore is False
|
||||
|
||||
|
||||
def test_a_follow_landing_while_the_videos_come_in_leaves_none_of_them_flagged(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
user, follower = make_user("racer@example.com"), make_user("late@example.com")
|
||||
_channel(db, 1)
|
||||
_watched(db, user, 1, [LONG] * 5)
|
||||
fake = _fake_ensure(calls := [])
|
||||
|
||||
def ensure(session, yt, ids, *, explore=False):
|
||||
# Someone subscribes, in a request of their own, while YouTube answers this one.
|
||||
with SessionLocal() as other:
|
||||
ch = other.get(Channel, _ch(1))
|
||||
catalog_service.record_subscription(other, follower, ch, "sub-late")
|
||||
other.commit()
|
||||
fake(session, yt, ids, explore=explore)
|
||||
|
||||
monkeypatch.setattr(feed_routes, "ensure_videos", ensure)
|
||||
monkeypatch.setattr(feed_routes.quota, "backfill_reserve_reached", lambda: False)
|
||||
assert as_user(user).post(f"/api/takeout/channels/{_ch(1)}/videos").status_code == 200
|
||||
assert calls[0][1] is True # asked as the exploration's
|
||||
db.expire_all()
|
||||
flagged = db.scalars(select(Video.id).where(Video.via_explore.is_(True))).all()
|
||||
assert (db.get(Channel, _ch(1)).from_explore, flagged) == (False, [])
|
||||
|
||||
|
||||
def test_the_cleanup_keeps_a_channel_a_watch_history_lists_and_reclaims_its_videos(db, make_user):
|
||||
user = make_user("kept@example.com")
|
||||
_channel(db, 1)
|
||||
_watched(db, user, 1, [LONG] * 5)
|
||||
catalog_service.explore(db, user, db.get(Channel, _ch(1)))
|
||||
db.add(Video(id=_vid(1, 0), channel_id=_ch(1), published_at=T0, via_explore=True))
|
||||
db.add(Channel(id=_ch(2), from_explore=True)) # explored, in nobody's history
|
||||
db.commit()
|
||||
db.execute(update(ExploredChannel).values(created_at=T0 - timedelta(days=365)))
|
||||
db.commit()
|
||||
out = sync_explore.purge_unkept_explored(db)
|
||||
# The unplayed video goes (opening the list's videos brings it back); the channel stays listed.
|
||||
assert (out["videos_deleted"], out["channels_deleted"]) == (1, 1)
|
||||
db.expire_all()
|
||||
assert db.get(Channel, _ch(2)) is None
|
||||
assert _listed(db, user) == [(_ch(1), 5)]
|
||||
|
||||
|
||||
def test_a_blocked_or_unknown_channels_videos_are_refused(db, make_user, as_user):
|
||||
user = make_user("refused@example.com")
|
||||
_channel(db, 1)
|
||||
db.add(BlockedChannel(user_id=user.id, channel_id=_ch(1)))
|
||||
db.commit()
|
||||
client = as_user(user)
|
||||
assert client.post(f"/api/takeout/channels/{_ch(1)}/videos").status_code == 403
|
||||
assert client.post(f"/api/takeout/channels/{_ch(9)}/videos").status_code == 404
|
||||
|
||||
|
||||
def test_ensure_videos_adds_an_explorations_videos_as_its_own(db):
|
||||
db.add(Channel(id=_ch(1), title="C"))
|
||||
db.commit()
|
||||
|
||||
class YT:
|
||||
def get_videos(self, ids):
|
||||
return [{"id": v, "snippet": {"channelId": _ch(1)}} for v in ids]
|
||||
|
||||
sync_videos.ensure_videos(db, YT(), ["exploredvid"], explore=True)
|
||||
sync_videos.ensure_videos(db, YT(), ["organicvid0"])
|
||||
db.expire_all()
|
||||
assert db.get(Video, "exploredvid").via_explore is True
|
||||
assert db.get(Video, "organicvid0").via_explore is False
|
||||
|
||||
|
||||
# --- the routes -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_list_route_says_what_it_holds_and_whether_a_history_was_uploaded(
|
||||
db, make_user, as_user
|
||||
):
|
||||
user = make_user("route@example.com")
|
||||
client = as_user(user)
|
||||
assert client.get("/api/takeout/channels").json() == {
|
||||
"imported": False,
|
||||
"pending": 0,
|
||||
"channels": [],
|
||||
}
|
||||
_channel(db, 1, "Some channel")
|
||||
_watched(db, user, 1, [LONG] * 5)
|
||||
_watched(db, user, 2, [None] * 5, asked=False)
|
||||
body = client.get("/api/takeout/channels").json()
|
||||
assert body["imported"] is True and body["pending"] == 1
|
||||
assert [(c["id"], c["title"], c["watched"]) for c in body["channels"]] == [
|
||||
(_ch(1), "Some channel", 5)
|
||||
]
|
||||
assert body["channels"][0]["last_watched_at"].startswith("2026-05-01")
|
||||
|
||||
|
||||
def test_a_channel_put_out_of_the_list_comes_back_when_restored(db, make_user, as_user):
|
||||
user = make_user("dismiss@example.com")
|
||||
_channel(db, 1)
|
||||
_watched(db, user, 1, [LONG] * 5)
|
||||
client = as_user(user)
|
||||
url = f"/api/takeout/channels/{_ch(1)}/dismissal"
|
||||
assert client.put(url).json() == {"ok": True, "channel_id": _ch(1)}
|
||||
assert client.put(url).status_code == 200 # again: no error
|
||||
assert client.get("/api/takeout/channels").json()["channels"] == []
|
||||
assert client.delete(url).json() == {"ok": True, "channel_id": _ch(1)}
|
||||
assert [c["id"] for c in client.get("/api/takeout/channels").json()["channels"]] == [_ch(1)]
|
||||
db.expire_all()
|
||||
assert db.execute(select(TakeoutChannelDismissal)).scalars().all() == []
|
||||
|
||||
|
||||
def test_only_a_channel_of_the_users_own_history_can_be_put_out_of_the_list(db, make_user, as_user):
|
||||
user, other = make_user("mine@example.com"), make_user("theirs@example.com")
|
||||
_channel(db, 1)
|
||||
_watched(db, other, 1, [LONG] * 5)
|
||||
client = as_user(user)
|
||||
assert client.put(f"/api/takeout/channels/{_ch(1)}/dismissal").status_code == 404
|
||||
assert client.put(f"/api/takeout/channels/{_ch(9)}/dismissal").status_code == 404
|
||||
db.expire_all()
|
||||
assert db.execute(select(TakeoutChannelDismissal)).scalars().all() == []
|
||||
|
||||
|
||||
def test_a_dismissal_of_an_overlong_channel_id_is_refused_before_the_database(
|
||||
db, make_user, as_user
|
||||
):
|
||||
user = make_user("long@example.com")
|
||||
assert as_user(user).put(f"/api/takeout/channels/{'U' * 33}/dismissal").status_code == 422
|
||||
+3
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+8
-8
@@ -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 {
|
||||
+7
-7
@@ -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 {
|
||||
+3
-3
@@ -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"],
|
||||
};
|
||||
|
||||
+1
-1
@@ -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[] {
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
+2
-2
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
+7
-7
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
+5
-5
@@ -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,75 @@ 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/channels/WatchedVideosDialog.tsx": [1, "the thumbnail (its title sits beside it)"],
|
||||
"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;
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
+2
-2
@@ -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,44 @@
|
||||
[
|
||||
{
|
||||
"version": "0.111.0",
|
||||
"date": "2026-10-24",
|
||||
"new": [
|
||||
"Channels › Discover from your history: the channels your uploaded YouTube history shows you watching often (five videos of five minutes or more) but not following.",
|
||||
"Follow one, put it out of the list (with Undo), or open the videos you watched there and play them one by one or all in a row."
|
||||
],
|
||||
"fixed": [
|
||||
"A channel someone explored and then followed on YouTube is now part of the shared Library like any followed channel, and so are its videos; before, they could stay hidden and their watched marks never applied."
|
||||
]
|
||||
},
|
||||
{
|
||||
"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,44 @@
|
||||
[
|
||||
{
|
||||
"version": "0.111.0",
|
||||
"date": "2026-10-24",
|
||||
"new": [
|
||||
"Csatornák › Felfedezés az előzményekből: azok a csatornák, amelyeket a feltöltött YouTube-előzményeid szerint sokat nézel (öt legalább ötperces videó), de nem követsz.",
|
||||
"Követheted, kiveheted a listából (visszavonható), vagy megnyithatod az ott megnézett videóit, és egyenként vagy sorban lejátszhatod őket."
|
||||
],
|
||||
"fixed": [
|
||||
"Ha egy felfedezett csatornát valaki a YouTube-on kezd követni, az most a többi követett csatornához hasonlóan a közös Könyvtár része lesz a videóival együtt; eddig rejtve maradhattak, és a megnézett jelöléseik sem érvényesültek."
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
|
||||
+4
-4
@@ -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".
|
||||
+1
-1
@@ -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 {
|
||||
+1
-1
@@ -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 {
|
||||
+10
-10
@@ -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:
|
||||
+15
-15
@@ -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
|
||||
+1
-1
@@ -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 =>
|
||||
({
|
||||
+2
-2
@@ -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
|
||||
+6
-6
@@ -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.
|
||||
//
|
||||
+16
-11
@@ -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;
|
||||
+15
-15
@@ -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.
|
||||
//
|
||||
+2
-2
@@ -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
|
||||
+4
-4
@@ -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: {
|
||||
+19
-19
@@ -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
-1
@@ -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
|
||||
+9
-9
@@ -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
|
||||
+3
-3
@@ -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).
|
||||
+5
-5
@@ -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";
|
||||
|
||||
+14
-14
@@ -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.
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
pasteIsForThePage,
|
||||
requestDownload,
|
||||
usePendingDownload,
|
||||
} from "../lib/downloadIntake";
|
||||
} from "../../lib/downloadIntake";
|
||||
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
|
||||
+14
-12
@@ -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();
|
||||
+8
-6
@@ -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() {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user