Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90523284ab | ||
|
|
17bfea8754 | ||
|
|
2a9ddb78ac | ||
|
|
e887233210 | ||
|
|
b17d92d7c2 | ||
|
|
90ee34a1b4 | ||
|
|
118fc70e08 | ||
|
|
636372511b | ||
|
|
1354f5b503 | ||
|
|
98ec1ffcab | ||
|
|
18c11ca437 | ||
|
|
ca5e446fbd | ||
|
|
4c7ab12a3e | ||
|
|
c6b552e75f | ||
|
|
3cd0c525e2 | ||
|
|
f130a1c05a | ||
|
|
bbd31c7143 | ||
|
|
8731508645 | ||
|
|
b5d37ca142 | ||
|
|
eb0507f942 | ||
|
|
6d09b94bbb | ||
|
|
5274216981 | ||
|
|
75d89613d0 | ||
|
|
8fb276aa90 | ||
|
|
78a9324381 | ||
|
|
8a50538275 | ||
|
|
b4aea7fa5b | ||
|
|
92c305bab9 |
@@ -7,6 +7,54 @@ edit the release notes and re-run the generator.
|
|||||||
|
|
||||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||||
|
|
||||||
|
## 0.86.0 — 2026-09-14
|
||||||
|
|
||||||
|
A failed download now tells you why it failed and what you can do about it, instead of repeating yt-dlp's raw error. Facebook comes first: downloads that used to die on “Cannot parse data” go through, and a Facebook group post downloads its own video instead of a different one from the page.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- A failure says why, in your language: the row names the cause (“Facebook needs you to be signed in”, “Facebook did not accept your cookies”, “No video found in this post”) and its details window says what to do next. The raw error text is still there below it.
|
||||||
|
- A Facebook download with your cookies that stops at “Cannot parse data” is tried again as Chrome, which gets past it.
|
||||||
|
- A Facebook group post link downloads the post's own video. Before, yt-dlp either found nothing or quietly downloaded another video from the page; a post without a video now says so.
|
||||||
|
- Retry and Re-download use a cookie you added after the failure, so the Retry the message asks for actually goes out signed in.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- A Re-download of a download that needed your cookies used to rebuild without them.
|
||||||
|
- A job queued behind a download that already failed shows that failure's explanation instead of a bare “Download failed”.
|
||||||
|
- A download yt-dlp reports no length for takes its length from the file, so its Edit / trim action appears.
|
||||||
|
- Pressing Pause, Stop or Remove twice, or in two tabs, can no longer free a file another download still uses.
|
||||||
|
- The confirm buttons in Downloads name the action (Stop download, Re-download, Remove) instead of repeating the question.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- Four sprints, each Facebook behaviour measured on the development server with a real cookie before it was built. Two epic-end review rounds (20 → 17 fixed, then a targeted 4 → 4), every regression test mutation-checked.
|
||||||
|
|
||||||
|
## 0.85.0 — 2026-09-13
|
||||||
|
|
||||||
|
Siftlode can now be driven from the keyboard, and Back, dialogs and menus stop losing your place. j/k and the arrow keys walk every list and grid, Enter opens what you are on, and Back puts you on the item you opened a view from instead of at the top of the page.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- j/k and the arrow keys walk the feed, the library, a channel page, the Home rails, every Plex grid and rail, the Channels, Playlists and Downloads tables and the conversation list; Enter opens the item you are on. After you hide or delete an item, the next j goes on from where it was.
|
||||||
|
- Back returns you to the item a view was opened from — the Plex title you opened from a Home rail, the channel you opened from its row — with the keyboard still on it.
|
||||||
|
- Shortcuts to get around: s jumps to the search field, g-chords jump to a module, and the cheat sheet (h) names every key in the language you are reading. PageUp/PageDown move a page at a time in the command palette and in every dropdown list.
|
||||||
|
- The command palette ranks what you typed first: “theme” now puts the Theme rows above a channel whose handle happens to contain it.
|
||||||
|
- A card reached with Tab or j shows one ring around the whole card, and a screen reader calls it by its title.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Back and overlays keep their books straight: Back after navigating over About also closes About, a late clean-up no longer undoes the navigation you just made, and Shift+W on Home closes the player.
|
||||||
|
- Dialogs and menus hand the keyboard back where it came from. Escape from a card’s context menu returns to the card, not its thumbnail link; closing the player under another dialog no longer leaves the page unscrollable; a button that is busy with a request keeps the focus instead of dropping it to the page.
|
||||||
|
- A write that fails says so, with the reason — several saves, share links and admin actions used to fail silently or in English under the Hungarian interface.
|
||||||
|
- A split video export that fails part-way (the job limit) tells you how many clips were added and leaves only the rest selected, so trying again does not queue the first ones twice.
|
||||||
|
- “Feed” is called Feed everywhere, in both languages.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- Five sprints: history integrity, one dialog/focus contract, palette ranking and accessibility, the keyboard layer, and a Hungarian/English keyboard-only pass. Focus and event-order questions were measured in a real browser before each fix, not argued.
|
||||||
|
- Three epic-end review rounds — 19 → 14, 18 → 12 and a targeted 14 → 14 — each regression test mutation-checked. The last round's findings all sat inside the previous round's own fixes, which is where the loop was closed.
|
||||||
|
|
||||||
## 0.84.0 — 2026-09-12
|
## 0.84.0 — 2026-09-12
|
||||||
|
|
||||||
A bug-fix release, from a list you wrote while using Siftlode. Two of these were destroying your own data: a YouTube video rewound itself when you reopened it, and clearing a resume point on a half-watched Plex item told Plex nothing. Subtitles now appear in seconds instead of minutes, the Plex player's bitrate is a real measurement rather than a guess, and a channel you turn full history on for fills in front of you instead of looking frozen.
|
A bug-fix release, from a list you wrote while using Siftlode. Two of these were destroying your own data: a YouTube video rewound itself when you reopened it, and clearing a resume point on a half-watched Plex item told Plex nothing. Subtitles now appear in seconds instead of minutes, the Plex player's bitrate is a real measurement rather than a guess, and a channel you turn full history on for fills in front of you instead of looking frozen.
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` rout
|
|||||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
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
|
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
||||||
feature component or a domain API slice (`no-restricted-imports` in `eslint.config.js`, with its own
|
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
|
||||||
test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
|
||||||
|
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||||
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
|
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
|
||||||
`lib/api/*` slices over `core.req<T>`.
|
`lib/api/*` slices over `core.req<T>`.
|
||||||
|
|
||||||
@@ -89,8 +90,11 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||||
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
||||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||||
|
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||||
|
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||||
|
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||||
|
|
||||||
## House rules that bite (learned from real bugs)
|
## House rules that bite (learned from real bugs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install -r requirements.txt
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser /app
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
CMD ["sh", "entrypoint.sh"]
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""add download_jobs.error_detail — the domain-specific entry of the failure catalog (E11.5 S1)
|
||||||
|
|
||||||
|
Revision ID: 0084_dl_job_error_detail
|
||||||
|
Revises: 0083_comfort_pins
|
||||||
|
Create Date: 2026-09-13
|
||||||
|
|
||||||
|
`error_code` is the generic category (it decides whether a Retry is offered). `error_detail` names
|
||||||
|
the host-specific catalog entry `downloads.errors.classify` matched on top of it — `facebook.login_wall`
|
||||||
|
and its siblings — so the UI can say what went wrong ON THAT SITE and what to do about it, instead of
|
||||||
|
the category's generic sentence. Nullable: most failures have no host-specific entry.
|
||||||
|
|
||||||
|
Backfill: a job's code is written once, at failure time, so a new rule never reaches the rows that
|
||||||
|
failed before it. The one shape measured on prod (2026-09-13, jobs 9/66/75/145/156/157/182/256/257) is
|
||||||
|
yt-dlp's `[facebook] <id>: Cannot parse data`, stored as `unknown`. Without a cookie that is Facebook's
|
||||||
|
login wall. Whether a cookie was attached at the time is not recorded anywhere, so every old row gets
|
||||||
|
the no-cookie entry — on prod no account had a Facebook cookie, so that is what they all were.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0084_dl_job_error_detail"
|
||||||
|
down_revision: str | None = "0083_comfort_pins"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("download_jobs", sa.Column("error_detail", sa.String(length=48), nullable=True))
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE download_jobs SET error_code = 'login_required', "
|
||||||
|
"error_detail = 'facebook.login_wall' "
|
||||||
|
"WHERE (error_code IS NULL OR error_code = 'unknown') "
|
||||||
|
"AND error ILIKE '%[facebook]%cannot parse data%'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("download_jobs", "error_detail")
|
||||||
@@ -11,10 +11,22 @@ Resolution works both before extraction (from source_kind / URL host) and after
|
|||||||
`extractor_key` in the info dict), so a non-YouTube "url" job still lands on the right adapter.
|
`extractor_key` in the info dict), so a non-YouTube "url" job still lands on the right adapter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import yt_dlp
|
||||||
|
from yt_dlp.networking.impersonate import ImpersonateTarget
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
# Facebook's "Cannot parse data": the page yt-dlp got was a wall, not the content. Both the Chrome retry
|
||||||
|
# below and the failure catalog (errors.py) decide on it, so the wording exists once.
|
||||||
|
FACEBOOK_PARSE_WALL = "cannot parse data"
|
||||||
|
# A source's own verdicts on a link, raised from `resolve_url`. errors.py classifies them; it imports
|
||||||
|
# them from here because this module must not import errors (errors → credentials → adapters).
|
||||||
|
POST_WITHOUT_VIDEO_MESSAGE = "No single video was found in this post."
|
||||||
|
POST_NOT_SHOWN_MESSAGE = "Facebook did not show this post to the signed-in session."
|
||||||
|
|
||||||
|
|
||||||
class SourceAdapter:
|
class SourceAdapter:
|
||||||
"""Generic source: yt-dlp handles everything and the ref is already a fetchable URL."""
|
"""Generic source: yt-dlp handles everything and the ref is already a fetchable URL."""
|
||||||
@@ -22,9 +34,21 @@ class SourceAdapter:
|
|||||||
key = "generic"
|
key = "generic"
|
||||||
|
|
||||||
# --- extraction (worker._extract_with_fallback / backfill) ---------------------------------
|
# --- extraction (worker._extract_with_fallback / backfill) ---------------------------------
|
||||||
def client_sets(self) -> list:
|
def resolve_url(self, url: str, ydl_opts: dict) -> str:
|
||||||
"""Player-client sets to attempt, in order. A generic source needs no client override, so a
|
"""The link to hand yt-dlp for `url`, resolved BEFORE the first attempt. `ydl_opts` are the
|
||||||
single default attempt (`[None]`) — the per-client fallback is a YouTube-only concept."""
|
download's own options (cookie, timeouts, address family) for any page this needs to read.
|
||||||
|
|
||||||
|
Raises a RuntimeError carrying one of this module's verdicts when the page read shows the
|
||||||
|
download cannot go ahead: the content is there but holds no single video
|
||||||
|
(`POST_WITHOUT_VIDEO_MESSAGE`), or the page is not the content at all — the login page a
|
||||||
|
refused cookie gets (`POST_NOT_SHOWN_MESSAGE`), never the no-video verdict. A failed page read
|
||||||
|
is raised as itself, so it is classified like any other download error. Generic: `url`."""
|
||||||
|
return url
|
||||||
|
|
||||||
|
def client_sets(self, authed: bool = False) -> list:
|
||||||
|
"""Attempts to make, in order — each entry is handed back to `ydl_opts_extra`, and the next one
|
||||||
|
is tried when `retriable` says so. A generic source makes a single default attempt (`[None]`).
|
||||||
|
`authed` is whether the acting user's cookie file goes with the download."""
|
||||||
return [None]
|
return [None]
|
||||||
|
|
||||||
def ydl_opts_extra(self, clients) -> dict:
|
def ydl_opts_extra(self, clients) -> dict:
|
||||||
@@ -67,7 +91,7 @@ class YouTubeAdapter(SourceAdapter):
|
|||||||
"Sign in to confirm",
|
"Sign in to confirm",
|
||||||
)
|
)
|
||||||
|
|
||||||
def client_sets(self) -> list:
|
def client_sets(self, authed: bool = False) -> list:
|
||||||
return [
|
return [
|
||||||
c for c in (settings.player_client_list, settings.player_client_fallback_list) if c
|
c for c in (settings.player_client_list, settings.player_client_fallback_list) if c
|
||||||
] or [None]
|
] or [None]
|
||||||
@@ -93,6 +117,46 @@ class YouTubeAdapter(SourceAdapter):
|
|||||||
|
|
||||||
class FacebookAdapter(SourceAdapter):
|
class FacebookAdapter(SourceAdapter):
|
||||||
key = "facebook"
|
key = "facebook"
|
||||||
|
# Measured (E11.5, yt-dlp 2026.07.04): with the user's cookie, some reels answer a plain request
|
||||||
|
# with "Cannot parse data" and parse only when the request looks like Chrome. Impersonation is a
|
||||||
|
# SECOND attempt behind that one error, not the default: the links that already download with the
|
||||||
|
# cookie keep their proven path, and only the ones stuck at the wall go through curl_cffi. Without
|
||||||
|
# a cookie the same error is the login wall, which no disguise gets past.
|
||||||
|
_IMPERSONATE = "impersonate"
|
||||||
|
# A post page is ~4–5 MB of inline JSON; anything far past that is not a post page.
|
||||||
|
_POST_PAGE_MAX_BYTES = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
def resolve_url(self, url: str, ydl_opts: dict) -> str:
|
||||||
|
# Measured (E11.5 S4): given a group post's link, yt-dlp either finds no formats or — worse —
|
||||||
|
# downloads ANOTHER video from the page's feed data. The post's own video is the one Video node
|
||||||
|
# in the page scripts that carry the post id; its watch link downloads (plain first, then the
|
||||||
|
# Chrome retry above). Needs the cookie: signed out, the page is the login wall.
|
||||||
|
post_id = facebook_group_post_id(url)
|
||||||
|
if post_id is None or not ydl_opts.get("cookiefile"):
|
||||||
|
return url
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
raw = ydl.urlopen(url).read(self._POST_PAGE_MAX_BYTES)
|
||||||
|
page = raw.decode("utf-8", "replace")
|
||||||
|
# Measured (E11.5 review): a refused or expired cookie gets the login page (~450 KB, a login
|
||||||
|
# form, no post id at all), which is not a post without a video — the user's cookie is the cause.
|
||||||
|
if _quoted(post_id) not in page:
|
||||||
|
raise RuntimeError(POST_NOT_SHOWN_MESSAGE)
|
||||||
|
video_id = facebook_post_video_id(page, post_id)
|
||||||
|
if video_id is None:
|
||||||
|
# Never fall back to the post link: that is exactly where the wrong video comes from.
|
||||||
|
raise RuntimeError(POST_WITHOUT_VIDEO_MESSAGE)
|
||||||
|
return f"https://www.facebook.com/watch/?v={video_id}"
|
||||||
|
|
||||||
|
def client_sets(self, authed: bool = False) -> list:
|
||||||
|
return [None, self._IMPERSONATE] if authed else [None]
|
||||||
|
|
||||||
|
def ydl_opts_extra(self, clients) -> dict:
|
||||||
|
if clients == self._IMPERSONATE:
|
||||||
|
return {"impersonate": ImpersonateTarget.from_str("chrome")}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def retriable(self, error_text: str) -> bool:
|
||||||
|
return FACEBOOK_PARSE_WALL in error_text.lower()
|
||||||
|
|
||||||
def uploader_url(self, info: dict) -> str | None:
|
def uploader_url(self, info: dict) -> str | None:
|
||||||
# Facebook gives neither channel_url nor uploader_url, but exposes a numeric `uploader_id`
|
# Facebook gives neither channel_url nor uploader_url, but exposes a numeric `uploader_id`
|
||||||
@@ -107,6 +171,39 @@ class FacebookAdapter(SourceAdapter):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_FB_GROUP_POST_PATH = re.compile(r"^/groups/[^/]+/(?:permalink|posts)/(\d+)/?$")
|
||||||
|
_SCRIPT_BLOCK = re.compile(r"<script[^>]*>(.*?)</script>", re.S)
|
||||||
|
_VIDEO_NODE = re.compile(r'"__typename":"Video","id":"(\d+)"')
|
||||||
|
|
||||||
|
|
||||||
|
def _quoted(post_id: str) -> str:
|
||||||
|
# How the page JSON carries the id: quoted, so a longer id that merely starts with it never counts.
|
||||||
|
return f'"{post_id}"'
|
||||||
|
|
||||||
|
|
||||||
|
def facebook_group_post_id(url: str) -> str | None:
|
||||||
|
"""The post id of a Facebook group post link (`/groups/<group>/permalink|posts/<id>/`), else None."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not _host_is(parsed.netloc.lower(), "facebook.com"):
|
||||||
|
return None
|
||||||
|
match = _FB_GROUP_POST_PATH.match(parsed.path)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def facebook_post_video_id(page: str, post_id: str) -> str | None:
|
||||||
|
"""The id of the one video attached to `post_id`, read from its page: the Video nodes of the script
|
||||||
|
blocks that carry the post id (the page's other ~10 videos — feed, reels — sit in other blocks).
|
||||||
|
None for no video AND for more than one: guessing between two would be the wrong-video bug again."""
|
||||||
|
quoted = _quoted(post_id)
|
||||||
|
ids = {
|
||||||
|
vid
|
||||||
|
for block in _SCRIPT_BLOCK.findall(page)
|
||||||
|
if quoted in block
|
||||||
|
for vid in _VIDEO_NODE.findall(block)
|
||||||
|
}
|
||||||
|
return ids.pop() if len(ids) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
class RedditAdapter(SourceAdapter):
|
class RedditAdapter(SourceAdapter):
|
||||||
key = "reddit"
|
key = "reddit"
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Per-user, per-host download cookies — the single source of truth for "does the acting user's
|
"""Per-user, per-host download cookies — the single source of truth for "does the acting user's
|
||||||
cookie apply to this download, and how does that keep the result private".
|
cookie apply to this download, and how does that keep the result private".
|
||||||
|
|
||||||
Two call sites depend on ONE consistent answer (house rule: an invariant lives in shared code, not
|
Two sides depend on ONE consistent answer (house rule: an invariant lives in shared code, not
|
||||||
in prose):
|
in prose):
|
||||||
* `enqueue` salts the MediaAsset cache signature with the user id WHEN a cookie applies, so the
|
* `enqueue` salts the MediaAsset cache signature with the user id WHEN a cookie applies, so the
|
||||||
authed download becomes a PRIVATE asset that is never cache-served to another user.
|
authed download becomes a PRIVATE asset that is never cache-served to another user. Retry
|
||||||
|
(`service.resume`) and Re-download (`service.redownload`) ask the same question again, so a
|
||||||
|
cookie the user added after a failure goes with the next attempt.
|
||||||
* the worker attaches the cookie file to yt-dlp ONLY when a cookie still applies AND the asset it
|
* the worker attaches the cookie file to yt-dlp ONLY when a cookie still applies AND the asset it
|
||||||
is downloading into is that private, salted identity — so a cookie can never poison a shared
|
is downloading into is that private, salted identity — so a cookie can never poison a shared
|
||||||
asset (the load-bearing cross-user privacy fix).
|
asset (the load-bearing cross-user privacy fix).
|
||||||
@@ -30,7 +32,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
from app.downloads.adapters import _host_is
|
from app.downloads.adapters import _host_is
|
||||||
from app.models import DownloadCredential
|
from app.models import DownloadCredential, MediaAsset
|
||||||
from app.security import decrypt_optional, encrypt
|
from app.security import decrypt_optional, encrypt
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.downloads.credentials")
|
log = logging.getLogger("siftlode.downloads.credentials")
|
||||||
@@ -104,6 +106,29 @@ def authed_format_sig(spec: dict, user_id: int, host_key: str) -> str:
|
|||||||
return formats.format_sig(spec, salt=cookie_salt(user_id, host_key))
|
return formats.format_sig(spec, salt=cookie_salt(user_id, host_key))
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_salt(asset_id: int, cookie: str | None) -> str:
|
||||||
|
"""The salt of a Re-download's fresh asset: its OWN row id (so every rebuild is a new identity no
|
||||||
|
enqueue can ever compute), prefixed with the user's `cookie_salt` when a cookie applies. The id is
|
||||||
|
what makes it recognisable — the worker has the asset row, so `is_authed_asset` can recompute this
|
||||||
|
one, where a salt built from the job's previous asset id could not be."""
|
||||||
|
return f"{cookie}:rebuild:{asset_id}" if cookie else f"rebuild:{asset_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_authed_asset(asset: MediaAsset, spec: dict, user_id: int, url: str | None) -> bool:
|
||||||
|
"""Whether an asset is `user_id`'s private, cookie-salted identity for `url`'s host — the one
|
||||||
|
kind of asset a cookie is ever attached to: the one enqueue creates (`authed_format_sig`), or a
|
||||||
|
Re-download's rebuild of it (`rebuild_salt`). The worker's cookie gate asks it before a download,
|
||||||
|
and the failure diagnosis asks it after one (a failure on this asset happened WITH the cookie)."""
|
||||||
|
host_key = host_key_for_url(url)
|
||||||
|
if host_key is None:
|
||||||
|
return False
|
||||||
|
if asset.format_sig == authed_format_sig(spec, user_id, host_key):
|
||||||
|
return True
|
||||||
|
return asset.id is not None and asset.format_sig == formats.format_sig(
|
||||||
|
spec, salt=rebuild_salt(asset.id, cookie_salt(user_id, host_key))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def salt_for_enqueue(db: Session, user_id: int, url: str | None) -> str | None:
|
def salt_for_enqueue(db: Session, user_id: int, url: str | None) -> str | None:
|
||||||
"""At enqueue: the cache salt to apply if this user has a cookie for `url`'s host, else None (a
|
"""At enqueue: the cache salt to apply if this user has a cookie for `url`'s host, else None (a
|
||||||
normal shared download). Returns the salt string so the caller passes it straight to
|
normal shared download). Returns the salt string so the caller passes it straight to
|
||||||
@@ -199,20 +224,19 @@ def status_list(db: Session, user_id: int) -> list[dict]:
|
|||||||
# --- worker: resolve + write the cookie file --------------------------------------------------
|
# --- worker: resolve + write the cookie file --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def resolve_cookiefile(
|
def usable_cookie(
|
||||||
db: Session,
|
db: Session, user_id: int, url: str | None, asset: MediaAsset, spec: dict
|
||||||
user_id: int,
|
) -> str | None:
|
||||||
url: str | None,
|
"""The user's cookie text a download of `url` into `asset` goes with, or None — the ONE decision
|
||||||
asset_format_sig: str,
|
whether a download is signed in. The worker child writes it out for yt-dlp (`resolve_cookiefile`);
|
||||||
spec: dict,
|
the worker asks it again for a job that inherits a failed asset. That second ask sees the
|
||||||
staging: Path,
|
credential as it is when the job is claimed: a cookie the user removed or added since the failed
|
||||||
) -> Path | None:
|
download changes this job's explanation to fit the cookie its Retry would go with.
|
||||||
"""In the worker child: the cookies.txt to hand yt-dlp for this download, or None.
|
|
||||||
|
|
||||||
The double gate that prevents cross-user leakage:
|
The double gate that prevents cross-user leakage:
|
||||||
1. a credential must still exist for `url`'s host (host-scoped via `host_key_for_url`), AND
|
1. a credential must still exist for `url`'s host (host-scoped via `host_key_for_url`), AND
|
||||||
2. the asset being downloaded into must BE this user's private, salted identity
|
2. the asset being downloaded into must BE this user's private, salted identity
|
||||||
(`asset_format_sig == authed_format_sig(...)`).
|
(`is_authed_asset`).
|
||||||
|
|
||||||
If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the
|
If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the
|
||||||
global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key →
|
global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key →
|
||||||
@@ -224,18 +248,31 @@ def resolve_cookiefile(
|
|||||||
cred = get_credential(db, user_id, host_key)
|
cred = get_credential(db, user_id, host_key)
|
||||||
if cred is None:
|
if cred is None:
|
||||||
return None
|
return None
|
||||||
if asset_format_sig != authed_format_sig(spec, user_id, host_key):
|
if not is_authed_asset(asset, spec, user_id, url):
|
||||||
return None # shared asset — never attach a cookie to a non-private identity
|
return None # shared asset — never attach a cookie to a non-private identity
|
||||||
raw = decrypt_optional(cred.cookies_enc)
|
raw = decrypt_optional(cred.cookies_enc)
|
||||||
if not raw:
|
if not raw:
|
||||||
log.warning(
|
log.warning(
|
||||||
"download cookie for user %s host %s could not be decrypted (rotated key?) — "
|
"download cookie for user %s host %s could not be decrypted (rotated key?) — "
|
||||||
"proceeding without it",
|
"treated as no cookie",
|
||||||
user_id,
|
user_id,
|
||||||
host_key,
|
host_key,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
return _write_cookiefile(raw, staging)
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_cookiefile(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
url: str | None,
|
||||||
|
asset: MediaAsset,
|
||||||
|
spec: dict,
|
||||||
|
staging: Path,
|
||||||
|
) -> Path | None:
|
||||||
|
"""In the worker child: the cookies.txt to hand yt-dlp for this download (`usable_cookie`), or None."""
|
||||||
|
raw = usable_cookie(db, user_id, url, asset, spec)
|
||||||
|
return _write_cookiefile(raw, staging) if raw else None
|
||||||
|
|
||||||
|
|
||||||
def _write_cookiefile(raw: str, staging: Path) -> Path:
|
def _write_cookiefile(raw: str, staging: Path) -> Path:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Classify a raw yt-dlp / ffmpeg failure string into a stable category code.
|
"""Classify a raw yt-dlp / ffmpeg failure string into a stable category code — and, where a site's
|
||||||
|
failure has been measured, into that site's entry of the failure catalog.
|
||||||
|
|
||||||
The worker stores the raw exception text (truncated) for technical detail, but a raw
|
The worker stores the raw exception text (truncated) for technical detail, but a raw
|
||||||
`ERROR: [youtube] <id>: Private video. Sign in…` is noise to a user. `classify()` maps that text
|
`ERROR: [youtube] <id>: Private video. Sign in…` is noise to a user. `classify()` maps that text
|
||||||
@@ -6,21 +7,51 @@ to one of a small set of category codes; the frontend turns the code into a loca
|
|||||||
explanation (and decides whether a retry makes sense). The raw text stays available behind a
|
explanation (and decides whether a retry makes sense). The raw text stays available behind a
|
||||||
"details" disclosure. This is the same string-matching approach the worker already trusts for
|
"details" disclosure. This is the same string-matching approach the worker already trusts for
|
||||||
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
|
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
|
||||||
|
|
||||||
|
**The failure catalog** (`_CATALOG`, E11.5). A category is generic: "Sign-in required" cannot tell a
|
||||||
|
Facebook user that the cookie they already gave was refused, and yt-dlp words several different
|
||||||
|
Facebook walls identically (`Cannot parse data`). A catalog entry is scoped to ONE host and may also
|
||||||
|
depend on whether a cookie was attached and on the kind of link, and names a `detail` code the UI has
|
||||||
|
its own sentence for — why it failed on that site, and what the user can do. `diagnose()` tries the
|
||||||
|
catalog first and falls back to the category. How an entry is added, every time a failure is reported:
|
||||||
|
1. reproduce it on dev (with and without the user's cookie) and keep the verbatim yt-dlp text;
|
||||||
|
2. add the entry below, scoped as narrowly as the measurement allows (a marker that is yt-dlp's
|
||||||
|
generic wording for every site must never match without its host; a marker another module also
|
||||||
|
decides on — the Facebook Chrome retry's FACEBOOK_PARSE_WALL — is imported, never retyped);
|
||||||
|
3. pin the real text in `tests/test_download_errors.py`;
|
||||||
|
4. add `downloads.errors.fail.catalog.<detail>.{short,body}` in EN and HU (a contract test checks it;
|
||||||
|
the group is not called `detail`, which `actionErrors.contract` reads as a raw `HttpError.detail`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Two worker-authored strings that this module owns because both are coupled to `_RULES`.
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from app.downloads.adapters import (
|
||||||
|
FACEBOOK_PARSE_WALL,
|
||||||
|
POST_NOT_SHOWN_MESSAGE,
|
||||||
|
POST_WITHOUT_VIDEO_MESSAGE,
|
||||||
|
facebook_group_post_id,
|
||||||
|
)
|
||||||
|
from app.downloads.credentials import host_key_for_url
|
||||||
|
|
||||||
|
# Worker-authored strings that this module owns because each is coupled to `_RULES`.
|
||||||
#
|
#
|
||||||
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
|
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
|
||||||
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
||||||
# rule drifting apart is exactly how a category silently becomes unreachable.
|
# rule drifting apart is exactly how a category silently becomes unreachable.
|
||||||
#
|
#
|
||||||
# NETWORK_REDACTED_MESSAGE is the opposite direction: it REPLACES a message after classification, so
|
# NETWORK_REDACTED_MESSAGE REPLACES a message after classification (`user_error_text`). It is written
|
||||||
# it deliberately has no rule. Nothing re-classifies a stored `job.error` today; if anything ever
|
# to the shared asset too, and a second job that inherits that failed asset re-diagnoses the stored
|
||||||
# does, it must carry the original code rather than re-deriving one from this text.
|
# text (`worker._process_job`), so it has a rule of its own that gives the category back.
|
||||||
#
|
#
|
||||||
# Other worker-raised texts are still inline literals at their raise sites. The ffmpeg ones and
|
# Other worker-raised texts are still inline literals at their raise sites. The ffmpeg ones and
|
||||||
# "produced no output file" have markers here; the urlguard refusal deliberately has none and
|
# "produced no output file" have markers here; the urlguard refusal deliberately has none and
|
||||||
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
|
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
|
||||||
|
#
|
||||||
|
# A source adapter's verdicts on a link (`SourceAdapter.resolve_url`, E11.5 S4) are adapters.py's
|
||||||
|
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
|
||||||
|
# that gives the site's sentence.
|
||||||
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
||||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||||
|
|
||||||
@@ -30,6 +61,12 @@ _RULES: tuple[tuple[str, str], ...] = (
|
|||||||
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
|
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
|
||||||
# first — it is our wording, so it can never be a false match on an upstream message.
|
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||||
("download storage is full", "quota_exceeded"),
|
("download storage is full", "quota_exceeded"),
|
||||||
|
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
|
||||||
|
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
||||||
|
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
||||||
|
# or the site answered the signed-in read with something that is not the post at all.
|
||||||
|
(POST_WITHOUT_VIDEO_MESSAGE.lower(), "format_unavailable"),
|
||||||
|
(POST_NOT_SHOWN_MESSAGE.lower(), "login_required"),
|
||||||
# Login / age / members / bot-check — the user must be signed in (we deliberately are not).
|
# Login / age / members / bot-check — the user must be signed in (we deliberately are not).
|
||||||
("sign in to confirm your age", "login_required"),
|
("sign in to confirm your age", "login_required"),
|
||||||
("confirm your age", "login_required"),
|
("confirm your age", "login_required"),
|
||||||
@@ -101,6 +138,83 @@ _RULES: tuple[tuple[str, str], ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Diagnosis(NamedTuple):
|
||||||
|
"""What a failure was: the generic `code` (category) and, when a catalog entry matched, its
|
||||||
|
host-specific `detail`."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Entry:
|
||||||
|
"""One measured failure of one site. It matches when the download URL belongs to `host`
|
||||||
|
(`credentials.SUPPORTED_HOSTS`), every one of `markers` is in the message, the cookie state is
|
||||||
|
`cookie` (None: either), and — when `link` is given — it recognises the URL (a truthy result). A
|
||||||
|
link predicate is the adapter's own, so the catalog and the download agree on what a link is."""
|
||||||
|
|
||||||
|
host: str
|
||||||
|
markers: tuple[str, ...]
|
||||||
|
code: str
|
||||||
|
detail: str
|
||||||
|
cookie: bool | None = None
|
||||||
|
link: Callable[[str], object] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_CATALOG: tuple[_Entry, ...] = (
|
||||||
|
# Facebook answers a signed-out client with a page yt-dlp cannot parse — an 18+ reel, a closed
|
||||||
|
# group's post (measured 2026-09-13: prod jobs 145, 156, 256 download once the owner's cookie is
|
||||||
|
# attached). "Cannot parse data" is yt-dlp's generic wording for a broken extractor on EVERY site,
|
||||||
|
# which is why the entry is scoped to Facebook and never becomes a general rule.
|
||||||
|
_Entry(
|
||||||
|
host="facebook",
|
||||||
|
markers=("[facebook]", FACEBOOK_PARSE_WALL),
|
||||||
|
code="login_required",
|
||||||
|
detail="facebook.login_wall",
|
||||||
|
cookie=False,
|
||||||
|
),
|
||||||
|
# The same text WITH a cookie: an expired / signed-out cookie, or a page that also needs
|
||||||
|
# impersonation (prod job 257). Telling this user to add cookies would be wrong — they have.
|
||||||
|
_Entry(
|
||||||
|
host="facebook",
|
||||||
|
markers=("[facebook]", FACEBOOK_PARSE_WALL),
|
||||||
|
code="login_required",
|
||||||
|
detail="facebook.cookie_rejected",
|
||||||
|
cookie=True,
|
||||||
|
),
|
||||||
|
# A group post read with a cookie that Facebook answered with its login page (measured with a
|
||||||
|
# refused cookie: no post id anywhere on the page) — the same refused cookie, found by the adapter.
|
||||||
|
_Entry(
|
||||||
|
host="facebook",
|
||||||
|
markers=(POST_NOT_SHOWN_MESSAGE.lower(),),
|
||||||
|
code="login_required",
|
||||||
|
detail="facebook.cookie_rejected",
|
||||||
|
cookie=True,
|
||||||
|
),
|
||||||
|
# A group post with no single video of its own. With a cookie the adapter reads the post's page
|
||||||
|
# and says so itself (POST_WITHOUT_VIDEO_MESSAGE); without one yt-dlp is given the post link and
|
||||||
|
# answers "No video formats found" (measured on prod jobs 66 and 145, whose videos are reshared
|
||||||
|
# clips). With a cookie yt-dlp only ever gets the post's VIDEO link, so the same text there is about
|
||||||
|
# that video, not the post — the category says it. Only for group post links (the adapter's own
|
||||||
|
# test of one): a reel with no formats is a different failure.
|
||||||
|
_Entry(
|
||||||
|
host="facebook",
|
||||||
|
markers=(POST_WITHOUT_VIDEO_MESSAGE.lower(),),
|
||||||
|
code="format_unavailable",
|
||||||
|
detail="facebook.group_post_video",
|
||||||
|
link=facebook_group_post_id,
|
||||||
|
),
|
||||||
|
_Entry(
|
||||||
|
host="facebook",
|
||||||
|
markers=("[facebook]", "no video formats found"),
|
||||||
|
code="format_unavailable",
|
||||||
|
detail="facebook.group_post_video",
|
||||||
|
cookie=False,
|
||||||
|
link=facebook_group_post_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def classify(msg: str | None) -> str:
|
def classify(msg: str | None) -> str:
|
||||||
"""Return a category code for a raw failure message (``"unknown"`` if nothing matches)."""
|
"""Return a category code for a raw failure message (``"unknown"`` if nothing matches)."""
|
||||||
if not msg:
|
if not msg:
|
||||||
@@ -112,6 +226,27 @@ def classify(msg: str | None) -> str:
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose(
|
||||||
|
msg: str | None, *, url: str | None = None, cookie_attached: bool = False
|
||||||
|
) -> Diagnosis:
|
||||||
|
"""The catalog entry for this failure of this link, else just its category (see the module doc).
|
||||||
|
|
||||||
|
`url` is the address yt-dlp was given; `cookie_attached` whether the acting user's cookie went
|
||||||
|
with it. Without a URL only the category can be known."""
|
||||||
|
host = host_key_for_url(url) if msg and url else None
|
||||||
|
if host is not None:
|
||||||
|
low = msg.lower() # type: ignore[union-attr] — `host` is only set when `msg` is truthy
|
||||||
|
for e in _CATALOG:
|
||||||
|
if (
|
||||||
|
e.host == host
|
||||||
|
and all(m in low for m in e.markers)
|
||||||
|
and (e.cookie is None or e.cookie == cookie_attached)
|
||||||
|
and (e.link is None or e.link(url)) # type: ignore[arg-type] — as `msg` above
|
||||||
|
):
|
||||||
|
return Diagnosis(e.code, e.detail)
|
||||||
|
return Diagnosis(classify(msg))
|
||||||
|
|
||||||
|
|
||||||
def user_error_text(source_kind: str, msg: str, code: str) -> str:
|
def user_error_text(source_kind: str, msg: str, code: str) -> str:
|
||||||
"""The failure text to STORE on the job, given the raw message and its category.
|
"""The failure text to STORE on the job, given the raw message and its category.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -323,14 +322,33 @@ def _extract_with_fallback(
|
|||||||
):
|
):
|
||||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||||
error); other sources make a single attempt. Returns the info dict of the successful attempt.
|
error); a Facebook download WITH a cookie is retried as Chrome behind "Cannot parse data"; other
|
||||||
|
sources make a single attempt. Returns the info dict of the successful attempt.
|
||||||
|
|
||||||
|
Before the first attempt the adapter may resolve `url` to the link that really holds the video (a
|
||||||
|
Facebook group post → its video's watch link); when the page shows no single video there, or is not
|
||||||
|
the post at all (a refused cookie's login page), the adapter raises its own verdict
|
||||||
|
(adapters.POST_*_MESSAGE) instead of letting yt-dlp guess, and a failed page read fails the
|
||||||
|
download as itself.
|
||||||
|
|
||||||
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
|
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
|
||||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
|
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
|
||||||
|
|
||||||
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
|
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
|
||||||
client_sets = adapter.client_sets()
|
client_sets = adapter.client_sets(authed=cookiefile is not None)
|
||||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||||
|
# Resolving may read a page first — network time the stall watchdog must not see as a stall.
|
||||||
|
_touch_job(job_id)
|
||||||
|
target = adapter.resolve_url(
|
||||||
|
url,
|
||||||
|
formats.build_ydl_opts(
|
||||||
|
spec,
|
||||||
|
outtmpl,
|
||||||
|
_make_progress_hook(job_id, asset_id),
|
||||||
|
cookiefile=cookiefile,
|
||||||
|
max_bytes=max_bytes,
|
||||||
|
),
|
||||||
|
)
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for i, clients in enumerate(client_sets):
|
for i, clients in enumerate(client_sets):
|
||||||
opts = formats.build_ydl_opts(
|
opts = formats.build_ydl_opts(
|
||||||
@@ -348,7 +366,7 @@ def _extract_with_fallback(
|
|||||||
# killed — acceptable, as socket_timeout + extractor_retries bound one attempt well under it.)
|
# killed — acceptable, as socket_timeout + extractor_retries bound one attempt well under it.)
|
||||||
try:
|
try:
|
||||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
return ydl.extract_info(url, download=True)
|
return ydl.extract_info(target, download=True)
|
||||||
except yt_dlp.utils.DownloadError as exc:
|
except yt_dlp.utils.DownloadError as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
if i < len(client_sets) - 1 and adapter.retriable(str(exc)):
|
if i < len(client_sets) - 1 and adapter.retriable(str(exc)):
|
||||||
@@ -425,6 +443,7 @@ def _download(
|
|||||||
) -> None:
|
) -> None:
|
||||||
staging = _staging_dir(asset_id)
|
staging = _staging_dir(asset_id)
|
||||||
cookiefile: Path | None = None
|
cookiefile: Path | None = None
|
||||||
|
url: str | None = None # read by the failure branch, which can run before it is resolved
|
||||||
try:
|
try:
|
||||||
if staging.exists():
|
if staging.exists():
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
@@ -445,12 +464,10 @@ def _download(
|
|||||||
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
||||||
remaining_bytes: int | None = None
|
remaining_bytes: int | None = None
|
||||||
with SessionLocal() as cdb:
|
with SessionLocal() as cdb:
|
||||||
asset_sig = cdb.execute(
|
target = cdb.get(MediaAsset, asset_id)
|
||||||
select(MediaAsset.format_sig).where(MediaAsset.id == asset_id)
|
if target is not None:
|
||||||
).scalar_one_or_none()
|
|
||||||
if asset_sig is not None:
|
|
||||||
cookiefile = credentials.resolve_cookiefile(
|
cookiefile = credentials.resolve_cookiefile(
|
||||||
cdb, user_id, url, asset_sig, spec, staging
|
cdb, user_id, url, target, spec, staging
|
||||||
)
|
)
|
||||||
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
||||||
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
||||||
@@ -484,13 +501,19 @@ def _download(
|
|||||||
produced = _produced_file(info, staging)
|
produced = _produced_file(info, staging)
|
||||||
produced = _ensure_browser_playable(job_id, produced, spec, info)
|
produced = _ensure_browser_playable(job_id, produced, spec, info)
|
||||||
ext = produced.suffix.lstrip(".").lower()
|
ext = produced.suffix.lstrip(".").lower()
|
||||||
|
# Codecs/dims/duration come from a PROBE of the produced file — the ground truth — NOT yt-dlp's
|
||||||
|
# `info` dict, whose fields are absent for some sources (Facebook among them): deriving
|
||||||
|
# media_kind from those nulls misclassified a real H.264 video as a download-only 'file', and a
|
||||||
|
# missing duration hid the trim action. Probed before the sidecars so the .nfo runtime has it
|
||||||
|
# too. Same probe the upload ingest uses.
|
||||||
|
vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info)
|
||||||
meta = storage.MediaMeta(
|
meta = storage.MediaMeta(
|
||||||
video_id=info.get("id") or source_ref,
|
video_id=info.get("id") or source_ref,
|
||||||
title=normalize_title(info.get("title")) or source_ref,
|
title=normalize_title(info.get("title")) or source_ref,
|
||||||
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
|
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
|
||||||
upload_date=_parse_upload_date(info.get("upload_date")),
|
upload_date=_parse_upload_date(info.get("upload_date")),
|
||||||
uniqueid_type=adapter.nfo_uniqueid_type(),
|
uniqueid_type=adapter.nfo_uniqueid_type(),
|
||||||
duration_s=int(info["duration"]) if info.get("duration") else None,
|
duration_s=duration_s,
|
||||||
description=info.get("description"),
|
description=info.get("description"),
|
||||||
thumbnail_url=info.get("thumbnail"),
|
thumbnail_url=info.get("thumbnail"),
|
||||||
)
|
)
|
||||||
@@ -516,12 +539,11 @@ def _download(
|
|||||||
asset.rel_path = rel
|
asset.rel_path = rel
|
||||||
asset.size_bytes = size
|
asset.size_bytes = size
|
||||||
asset.duration_s = meta.duration_s
|
asset.duration_s = meta.duration_s
|
||||||
# Codecs/dims come from a PROBE of the finished file — the ground truth — NOT yt-dlp's
|
asset.vcodec, asset.acodec, asset.width, asset.height = (
|
||||||
# `info` dict, whose codec fields are absent for some sources (Facebook among them):
|
vcodec,
|
||||||
# deriving media_kind from those nulls misclassified a real H.264 video as a
|
acodec,
|
||||||
# download-only 'file' with no in-app play/edit. Same probe the upload ingest uses.
|
width,
|
||||||
asset.vcodec, asset.acodec, asset.width, asset.height = probe.resolve_media_facts(
|
height,
|
||||||
final, info
|
|
||||||
)
|
)
|
||||||
asset.container = ext
|
asset.container = ext
|
||||||
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
|
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
|
||||||
@@ -578,6 +600,7 @@ def _download(
|
|||||||
status=JobStatus.ERROR,
|
status=JobStatus.ERROR,
|
||||||
error=msg,
|
error=msg,
|
||||||
error_code=dlerrors.classify(msg),
|
error_code=dlerrors.classify(msg),
|
||||||
|
error_detail=None,
|
||||||
phase=None,
|
phase=None,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -588,9 +611,11 @@ def _download(
|
|||||||
log.info("job %s refused for its owner: %s", job_id, msg)
|
log.info("job %s refused for its owner: %s", job_id, msg)
|
||||||
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||||
msg = str(exc)[:500]
|
msg = str(exc)[:500]
|
||||||
code = dlerrors.classify(msg)
|
# The catalog needs the link and whether the user's cookie went with it: the same yt-dlp
|
||||||
|
# text is Facebook's login wall without one and a refused cookie with one.
|
||||||
|
diagnosis = dlerrors.diagnose(msg, url=url, cookie_attached=cookiefile is not None)
|
||||||
# SEC-12: redact a transport-level readout for user-supplied URLs (see user_error_text).
|
# SEC-12: redact a transport-level readout for user-supplied URLs (see user_error_text).
|
||||||
msg = dlerrors.user_error_text(source_kind, msg, code)
|
msg = dlerrors.user_error_text(source_kind, msg, diagnosis.code)
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
asset = db.get(MediaAsset, asset_id)
|
asset = db.get(MediaAsset, asset_id)
|
||||||
if asset:
|
if asset:
|
||||||
@@ -603,7 +628,8 @@ def _download(
|
|||||||
job_id,
|
job_id,
|
||||||
status=JobStatus.ERROR,
|
status=JobStatus.ERROR,
|
||||||
error=msg,
|
error=msg,
|
||||||
error_code=code,
|
error_code=diagnosis.code,
|
||||||
|
error_detail=diagnosis.detail,
|
||||||
phase=None,
|
phase=None,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -81,10 +81,12 @@ def probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | N
|
|||||||
|
|
||||||
def resolve_media_facts(
|
def resolve_media_facts(
|
||||||
path: Path, info: dict
|
path: Path, info: dict
|
||||||
) -> tuple[str | None, str | None, int | None, int | None]:
|
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||||
"""(vcodec, acodec, width, height) for the stored download asset: PROBE the finished file on disk
|
"""(vcodec, acodec, width, height, duration_s) for the stored download asset: PROBE the finished
|
||||||
(the ground truth). yt-dlp's `info` omits codec/dimension fields for some sources (Facebook among
|
file on disk (the ground truth). yt-dlp's `info` omits codec/dimension fields for some sources
|
||||||
them), so deriving media_kind from it there labelled a real H.264 video a download-only 'file'.
|
(Facebook among them), so deriving media_kind from it there labelled a real H.264 video a
|
||||||
|
download-only 'file'. It omits the duration too (a Facebook reel, an Instagram reel), and a NULL
|
||||||
|
duration hides the Library row's trim action although the file plays for minutes.
|
||||||
|
|
||||||
Precedence is by whether the probe RAN, not per-field: if ffprobe read the file, its codec verdict
|
Precedence is by whether the probe RAN, not per-field: if ffprobe read the file, its codec verdict
|
||||||
is authoritative — INCLUDING a deliberate None (cover-art-only audio, a still image). A per-field
|
is authoritative — INCLUDING a deliberate None (cover-art-only audio, a still image). A per-field
|
||||||
@@ -92,16 +94,23 @@ def resolve_media_facts(
|
|||||||
have (yt-dlp reports a progressive `vcodec` for an audio-mode download whose video it then strips),
|
have (yt-dlp reports a progressive `vcodec` for an audio-mode download whose video it then strips),
|
||||||
mislabelling an audio download as a broken in-app 'video'. Only when ffprobe could NOT read the
|
mislabelling an audio download as a broken in-app 'video'. Only when ffprobe could NOT read the
|
||||||
file at all do we fall back to `info` (the pre-probe behaviour, a safety net); the cosmetic
|
file at all do we fall back to `info` (the pre-probe behaviour, a safety net); the cosmetic
|
||||||
dimensions still gap-fill from `info` in either case."""
|
dimensions and the duration still gap-fill from `info` in either case."""
|
||||||
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||||
info_v = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
info_v = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
||||||
info_a = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
info_a = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||||
info_w = int(info["width"]) if info.get("width") else None
|
info_w = int(info["width"]) if info.get("width") else None
|
||||||
info_h = int(info["height"]) if info.get("height") else None
|
info_h = int(info["height"]) if info.get("height") else None
|
||||||
|
info_d = int(info["duration"]) if info.get("duration") else None
|
||||||
if data is None:
|
if data is None:
|
||||||
# ffprobe could not read the file — fall back to the extractor's info entirely.
|
# ffprobe could not read the file — fall back to the extractor's info entirely.
|
||||||
return info_v, info_a, info_w, info_h
|
return info_v, info_a, info_w, info_h, info_d
|
||||||
pv, pa, pw, ph, _dur = _facts_from_probe(data)
|
pv, pa, pw, ph, pd = _facts_from_probe(data)
|
||||||
# Probe ran → its codecs win outright (a None means "no such track", not "unknown"); info only
|
# Probe ran → its codecs win outright (a None means "no such track", not "unknown"); info only
|
||||||
# gap-fills the cosmetic width/height the probe couldn't read.
|
# gap-fills the width/height/duration the probe couldn't read.
|
||||||
return pv, pa, pw if pw is not None else info_w, ph if ph is not None else info_h
|
return (
|
||||||
|
pv,
|
||||||
|
pa,
|
||||||
|
pw if pw is not None else info_w,
|
||||||
|
ph if ph is not None else info_h,
|
||||||
|
pd if pd is not None else info_d,
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ handles everything else. State transitions used by the routes (pause/resume/canc
|
|||||||
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
|
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
@@ -80,11 +81,13 @@ def get_or_create_asset(
|
|||||||
status="pending",
|
status="pending",
|
||||||
ref_count=0,
|
ref_count=0,
|
||||||
)
|
)
|
||||||
db.add(asset)
|
|
||||||
try:
|
try:
|
||||||
db.flush()
|
# A SAVEPOINT: losing the race undoes only this insert, never the caller's own pending writes
|
||||||
|
# (Retry clears the job's error before it gets here; a whole-session rollback threw that away).
|
||||||
|
with db.begin_nested():
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
|
||||||
asset = db.execute(stmt).scalar_one()
|
asset = db.execute(stmt).scalar_one()
|
||||||
return asset
|
return asset
|
||||||
|
|
||||||
@@ -341,13 +344,20 @@ def pause(db: Session, job: DownloadJob) -> None:
|
|||||||
def resume(db: Session, job: DownloadJob) -> None:
|
def resume(db: Session, job: DownloadJob) -> None:
|
||||||
"""Requeue a paused/errored job for a fresh attempt, clearing its error. If the SHARED asset itself
|
"""Requeue a paused/errored job for a fresh attempt, clearing its error. If the SHARED asset itself
|
||||||
failed, reset it to pending so the worker actually re-downloads — otherwise the requeued job would
|
failed, reset it to pending so the worker actually re-downloads — otherwise the requeued job would
|
||||||
instantly re-inherit the asset's stale error (the worker short-circuits an errored asset)."""
|
instantly re-inherit the asset's stale error (the worker short-circuits an errored asset).
|
||||||
|
|
||||||
|
A cookie the user added AFTER the job was queued moves the job onto their private asset first
|
||||||
|
(`_follow_the_cookie`): the worker only ever attaches a cookie to that identity, so without the move
|
||||||
|
the Retry a login-wall message asks for would fail on the same wall."""
|
||||||
if job.status in (JobStatus.PAUSED, JobStatus.ERROR):
|
if job.status in (JobStatus.PAUSED, JobStatus.ERROR):
|
||||||
job.progress = 0
|
job.progress = 0
|
||||||
job.error = None
|
job.error = None
|
||||||
job.error_code = None
|
job.error_code = None
|
||||||
|
job.error_detail = None
|
||||||
if job.asset_id:
|
if job.asset_id:
|
||||||
asset = db.get(MediaAsset, job.asset_id)
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
if asset is not None:
|
||||||
|
asset = _follow_the_cookie(db, job, asset)
|
||||||
if asset and asset.status == "error": # MediaAsset.status (no enum), not a JobStatus
|
if asset and asset.status == "error": # MediaAsset.status (no enum), not a JobStatus
|
||||||
asset.status = "pending"
|
asset.status = "pending"
|
||||||
asset.error = None
|
asset.error = None
|
||||||
@@ -355,6 +365,32 @@ def resume(db: Session, job: DownloadJob) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _follow_the_cookie(db: Session, job: DownloadJob, asset: MediaAsset) -> MediaAsset:
|
||||||
|
"""Re-point a requeued job at the asset enqueue would pick NOW, when the user has a cookie for the
|
||||||
|
link's host but the job still holds an asset the worker won't attach it to (queued before the
|
||||||
|
cookie existed). Returns the asset the job holds afterwards.
|
||||||
|
|
||||||
|
Only a `pending` / `error` asset moves — neither has a file nor a download in flight. A `ready` one
|
||||||
|
already has the file, and a `downloading` one may be this job's own child still being stopped by
|
||||||
|
the pause; the next Retry after it settles moves the job then. Removing a cookie never moves a job
|
||||||
|
back: the worker attaches nothing without a credential, so the private asset is as good as any."""
|
||||||
|
if asset.status not in ("pending", "error"):
|
||||||
|
return asset
|
||||||
|
url = source_url(job.source_kind, job.source_ref)
|
||||||
|
salt = credentials.salt_for_enqueue(db, job.user_id, url)
|
||||||
|
spec = formats.normalize(job.profile_snapshot)
|
||||||
|
if salt is None or credentials.is_authed_asset(asset, spec, job.user_id, url):
|
||||||
|
return asset
|
||||||
|
target = get_or_create_asset(
|
||||||
|
db, job.source_kind, job.source_ref, formats.format_sig(spec, salt=salt)
|
||||||
|
)
|
||||||
|
populate_from_catalog(db, target)
|
||||||
|
release_asset(db, job) # drop the old hold BEFORE re-pointing — it keys off job.asset_id
|
||||||
|
incr_ref_count(db, target.id)
|
||||||
|
job.asset_id = target.id
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
def cancel(db: Session, job: DownloadJob) -> None:
|
def cancel(db: Session, job: DownloadJob) -> None:
|
||||||
"""Cancel a job unless it's already done/canceled, releasing its asset hold WHILE it still counts
|
"""Cancel a job unless it's already done/canceled, releasing its asset hold WHILE it still counts
|
||||||
as holding — `canceled` ∉ HOLDING, so a later delete won't double-release."""
|
as holding — `canceled` ∉ HOLDING, so a later delete won't double-release."""
|
||||||
@@ -373,13 +409,31 @@ def delete(db: Session, job: DownloadJob) -> None:
|
|||||||
|
|
||||||
def redownload(db: Session, job: DownloadJob) -> None:
|
def redownload(db: Session, job: DownloadJob) -> None:
|
||||||
"""Rebuild a finished/failed job's file into a FRESH, private asset (the cache sig is salted with
|
"""Rebuild a finished/failed job's file into a FRESH, private asset (the cache sig is salted with
|
||||||
the job id + current asset id, so it never reuses the dedup pool) and requeue the SAME job row —
|
the new asset's own id, so it never reuses the dedup pool) and requeue the SAME job row —
|
||||||
so any public share link (DownloadLink -> job_id) keeps resolving to the newly-downloaded file.
|
so any public share link (DownloadLink -> job_id) keeps resolving to the newly-downloaded file.
|
||||||
The old hold is dropped first (freeing the old file iff this job was its last holder). The caller
|
The old hold is dropped first (freeing the old file iff this job was its last holder). The caller
|
||||||
has already rejected edit clips + still-in-progress jobs (HTTP 409)."""
|
has already rejected edit clips + still-in-progress jobs (HTTP 409).
|
||||||
|
|
||||||
|
When the user has a cookie for the link's host, the salt carries it (`credentials.rebuild_salt`),
|
||||||
|
so the worker recognises the rebuild as their private identity and attaches the cookie — a cookie
|
||||||
|
download, or one the user added after a failure, rebuilds with the cookie."""
|
||||||
spec = formats.normalize(job.profile_snapshot)
|
spec = formats.normalize(job.profile_snapshot)
|
||||||
sig = formats.format_sig(spec, salt=f"{job.id}:{job.asset_id}")
|
cookie = credentials.salt_for_enqueue(
|
||||||
asset = get_or_create_asset(db, job.source_kind, job.source_ref, sig)
|
db, job.user_id, source_url(job.source_kind, job.source_ref)
|
||||||
|
)
|
||||||
|
# The salt names the row's own id, which exists only once the row does: insert it under a unique
|
||||||
|
# placeholder, then write the real signature in the same transaction (nobody sees the placeholder).
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind=job.source_kind,
|
||||||
|
source_ref=job.source_ref,
|
||||||
|
format_sig=f"rebuild:{uuid.uuid4().hex}",
|
||||||
|
status="pending",
|
||||||
|
ref_count=0,
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
|
asset.format_sig = formats.format_sig(spec, salt=credentials.rebuild_salt(asset.id, cookie))
|
||||||
|
db.flush()
|
||||||
populate_from_catalog(db, asset)
|
populate_from_catalog(db, asset)
|
||||||
# Drop the old hold BEFORE re-pointing the job — release_asset keys off the job's current asset.
|
# Drop the old hold BEFORE re-pointing the job — release_asset keys off the job's current asset.
|
||||||
release_asset(db, job)
|
release_asset(db, job)
|
||||||
@@ -389,6 +443,8 @@ def redownload(db: Session, job: DownloadJob) -> None:
|
|||||||
job.progress = 0
|
job.progress = 0
|
||||||
job.phase = None
|
job.phase = None
|
||||||
job.error = None
|
job.error = None
|
||||||
|
job.error_code = None # a rebuild that fails again gets its own diagnosis, never the old one
|
||||||
|
job.error_detail = None
|
||||||
job.speed_bps = None
|
job.speed_bps = None
|
||||||
job.eta_s = None
|
job.eta_s = None
|
||||||
# Re-derive the display name from the freshly cleaned title on completion (else an older, un-cleaned
|
# Re-derive the display name from the freshly cleaned title on completion (else an older, un-cleaned
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ MAIL_TEXT: dict[str, dict[str, str]] = {
|
|||||||
"en": (
|
"en": (
|
||||||
"Hi,\n\n"
|
"Hi,\n\n"
|
||||||
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
|
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
|
||||||
"subscriptions. Every upload from the channels you follow lands in one feed you can "
|
"subscriptions. Every upload from the channels you follow lands in one Feed you can "
|
||||||
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
|
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
|
||||||
"livestreams unless you want them.\n\n"
|
"livestreams unless you want them.\n\n"
|
||||||
"{next_step}\n\n"
|
"{next_step}\n\n"
|
||||||
@@ -186,7 +186,7 @@ MAIL_TEXT: dict[str, dict[str, str]] = {
|
|||||||
"Szia,\n\n"
|
"Szia,\n\n"
|
||||||
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
|
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
|
||||||
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
|
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
|
||||||
"hírfolyamba érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
|
"Feedbe érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
|
||||||
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
|
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
|
||||||
"{next_step}\n\n"
|
"{next_step}\n\n"
|
||||||
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
|
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
|
||||||
|
|||||||
@@ -871,6 +871,8 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
error_code: Mapped[str | None] = mapped_column(
|
error_code: Mapped[str | None] = mapped_column(
|
||||||
String(24)
|
String(24)
|
||||||
) # category, see downloads.errors.classify
|
) # category, see downloads.errors.classify
|
||||||
|
# The host-specific failure-catalog entry on top of the category (`facebook.login_wall`), or NULL.
|
||||||
|
error_detail: Mapped[str | None] = mapped_column(String(48)) # see downloads.errors.diagnose
|
||||||
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
|||||||
"phase": job.phase,
|
"phase": job.phase,
|
||||||
"error": job.error,
|
"error": job.error,
|
||||||
"error_code": job.error_code,
|
"error_code": job.error_code,
|
||||||
|
"error_detail": job.error_detail,
|
||||||
"queue_pos": job.queue_pos,
|
"queue_pos": job.queue_pos,
|
||||||
"created_at": iso(job.created_at),
|
"created_at": iso(job.created_at),
|
||||||
"source_kind": job.source_kind,
|
"source_kind": job.source_kind,
|
||||||
@@ -172,6 +173,32 @@ def _own_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||||
|
"""`_own_job` for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
|
||||||
|
or writes the status those decide on (pause). The job is re-read under a row lock, so two
|
||||||
|
overlapping requests for one job run one after the other and the second decides on the first's
|
||||||
|
committed result — never both releasing the same hold, and never a pause turning a job a cancel
|
||||||
|
just released back into a holder; either can free a file another job still holds.
|
||||||
|
|
||||||
|
The asset is locked BEFORE the job: the order a finishing worker child takes them in
|
||||||
|
(`service.finalize_if_running`), so the two can never deadlock."""
|
||||||
|
while True:
|
||||||
|
asset_id = _own_job(db, user, job_id).asset_id
|
||||||
|
if asset_id is not None:
|
||||||
|
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
||||||
|
job = db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(DownloadJob.id == job_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if job is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown download")
|
||||||
|
if job.asset_id == asset_id:
|
||||||
|
return job
|
||||||
|
db.rollback() # another request moved the job to another asset meanwhile: lock that one
|
||||||
|
|
||||||
|
|
||||||
# --- profiles ------------------------------------------------------------------------------
|
# --- profiles ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -605,7 +632,7 @@ def update_download_meta(
|
|||||||
def pause_download(
|
def pause_download(
|
||||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job_for_update(db, user, job_id)
|
||||||
service.pause(db, job)
|
service.pause(db, job)
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
@@ -614,7 +641,7 @@ def pause_download(
|
|||||||
def resume_download(
|
def resume_download(
|
||||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job_for_update(db, user, job_id)
|
||||||
service.resume(db, job)
|
service.resume(db, job)
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
@@ -629,12 +656,13 @@ def redownload_download(
|
|||||||
resolves to the newly-downloaded file — a re-download never breaks a link you already sent out
|
resolves to the newly-downloaded file — a re-download never breaks a link you already sent out
|
||||||
(only a *failed* re-download leaves the job in `error`, which is the intended broken-link case).
|
(only a *failed* re-download leaves the job in `error`, which is the intended broken-link case).
|
||||||
|
|
||||||
The rebuild always re-fetches into a FRESH, private asset (the cache sig is salted with the job
|
The rebuild always re-fetches into a FRESH, private asset (the cache sig is salted with the new
|
||||||
id + current asset id) rather than the dedup pool. That means it (a) always genuinely rebuilds
|
asset's own id, and the user's cookie salt when one applies — `credentials.rebuild_salt`) rather
|
||||||
|
than the dedup pool. That means it (a) always genuinely rebuilds
|
||||||
under the current format/naming rules — never silently reuses a shared cached file — and (b)
|
under the current format/naming rules — never silently reuses a shared cached file — and (b)
|
||||||
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
||||||
was its last holder."""
|
was its last holder."""
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job_for_update(db, user, job_id)
|
||||||
if job.job_kind in ("edit", "upload"):
|
if job.job_kind in ("edit", "upload"):
|
||||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||||
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
||||||
@@ -649,7 +677,7 @@ def redownload_download(
|
|||||||
def cancel_download(
|
def cancel_download(
|
||||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job_for_update(db, user, job_id)
|
||||||
service.cancel(db, job)
|
service.cancel(db, job)
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
@@ -658,7 +686,7 @@ def cancel_download(
|
|||||||
def delete_download(
|
def delete_download(
|
||||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job_for_update(db, user, job_id)
|
||||||
service.delete(db, job)
|
service.delete(db, job)
|
||||||
return {"deleted": job_id}
|
return {"deleted": job_id}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ WELCOME = {
|
|||||||
"This is your private message inbox. Conversations with other members are end-to-end "
|
"This is your private message inbox. Conversations with other members are end-to-end "
|
||||||
"encrypted — only you and the person you're chatting with can read them, not even an "
|
"encrypted — only you and the person you're chatting with can read them, not even an "
|
||||||
"admin. To start messaging someone, set up secure messaging with a passphrase first.\n\n"
|
"admin. To start messaging someone, set up secure messaging with a passphrase first.\n\n"
|
||||||
"Enjoy your feed!"
|
"Enjoy your Feed!"
|
||||||
),
|
),
|
||||||
"hu": (
|
"hu": (
|
||||||
"Üdv a Siftlode-on! 👋\n\n"
|
"Üdv a Siftlode-on! 👋\n\n"
|
||||||
|
|||||||
+44
-10
@@ -32,12 +32,12 @@ from app import sysconfig
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import quota, service
|
from app.downloads import credentials, formats, quota, service
|
||||||
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads.jobdb import (
|
from app.downloads.jobdb import (
|
||||||
_edit_staging_dir,
|
_edit_staging_dir,
|
||||||
_requeue_if_running,
|
_requeue_if_running,
|
||||||
_reset_asset_pending,
|
_reset_asset_pending,
|
||||||
_set_job,
|
|
||||||
_staging_dir,
|
_staging_dir,
|
||||||
)
|
)
|
||||||
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
||||||
@@ -253,7 +253,27 @@ def _fail_after_kill(
|
|||||||
"""`_stop_and_free` + mark the job errored — for the watchdog's stall/timeout kills and the
|
"""`_stop_and_free` + mark the job errored — for the watchdog's stall/timeout kills and the
|
||||||
supervisor-error fallback."""
|
supervisor-error fallback."""
|
||||||
_stop_and_free(job_id, asset_id, proc, grace_s)
|
_stop_and_free(job_id, asset_id, proc, grace_s)
|
||||||
_set_job(job_id, status="error", error=error, error_code=error_code, phase=None)
|
_fail_running(job_id, error=error, error_code=error_code)
|
||||||
|
|
||||||
|
|
||||||
|
def _fail_running(
|
||||||
|
job_id: int, *, error: str, error_code: str | None, error_detail: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Fail a claimed job — only while it is still `running` (`service.finalize_if_running`), so a
|
||||||
|
cancel/pause a route landed first is never turned back into `error`, which holds the asset the
|
||||||
|
route already released. `error_detail` is written every time: a failure never shows an earlier
|
||||||
|
failure's explanation."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
service.finalize_if_running(
|
||||||
|
db,
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.ERROR,
|
||||||
|
error=error,
|
||||||
|
error_code=error_code,
|
||||||
|
error_detail=error_detail,
|
||||||
|
phase=None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def _run_supervised(job_id: int, asset_id: int) -> None:
|
def _run_supervised(job_id: int, asset_id: int) -> None:
|
||||||
@@ -329,12 +349,10 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
|||||||
status, _ = _job_liveness(job_id)
|
status, _ = _job_liveness(job_id)
|
||||||
if status == "running":
|
if status == "running":
|
||||||
_cleanup_staging(asset_id)
|
_cleanup_staging(asset_id)
|
||||||
_set_job(
|
_fail_running(
|
||||||
job_id,
|
job_id,
|
||||||
status="error",
|
|
||||||
error_code="unknown",
|
|
||||||
phase=None,
|
|
||||||
error=f"The download process exited unexpectedly (code {proc.returncode}).",
|
error=f"The download process exited unexpectedly (code {proc.returncode}).",
|
||||||
|
error_code="unknown",
|
||||||
)
|
)
|
||||||
log.warning(
|
log.warning(
|
||||||
"job %s: child exited %s without finishing", job_id, proc.returncode
|
"job %s: child exited %s without finishing", job_id, proc.returncode
|
||||||
@@ -399,16 +417,32 @@ def _process_job(job_id: int) -> None:
|
|||||||
asset_id = asset.id if asset else None
|
asset_id = asset.id if asset else None
|
||||||
asset_status = asset.status if asset else None
|
asset_status = asset.status if asset else None
|
||||||
asset_error = asset.error if asset else None
|
asset_error = asset.error if asset else None
|
||||||
|
if asset_status == "error":
|
||||||
|
# A job that meets an already-failed asset gets that failure's explanation — not a bare
|
||||||
|
# "Download failed". Only the text is on the asset, so it is diagnosed again, for this
|
||||||
|
# link, with whether a cookie goes with it asked of the same gate the download child
|
||||||
|
# used (`credentials.usable_cookie`) — as it stands now, so the advice fits this job's Retry.
|
||||||
|
url = service.source_url(job.source_kind, job.source_ref)
|
||||||
|
cookie = credentials.usable_cookie(
|
||||||
|
db, job.user_id, url, asset, formats.normalize(job.profile_snapshot)
|
||||||
|
)
|
||||||
|
diagnosis = dlerrors.diagnose(asset_error, url=url, cookie_attached=cookie is not None)
|
||||||
|
del cookie # only whether there is one matters here
|
||||||
|
|
||||||
if asset is None:
|
if asset is None:
|
||||||
_set_job(job_id, status="error", error="No media asset", phase=None)
|
_fail_running(job_id, error="No media asset", error_code=None)
|
||||||
return
|
return
|
||||||
|
|
||||||
if asset_status == "ready":
|
if asset_status == "ready":
|
||||||
_finish_from_cache(job_id, asset_id)
|
_finish_from_cache(job_id, asset_id)
|
||||||
return
|
return
|
||||||
if asset_status == "error":
|
if asset_status == "error":
|
||||||
_set_job(job_id, status="error", error=asset_error or "Download failed", phase=None)
|
_fail_running(
|
||||||
|
job_id,
|
||||||
|
error=asset_error or "Download failed",
|
||||||
|
error_code=diagnosis.code,
|
||||||
|
error_detail=diagnosis.detail,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
if asset_status == "downloading":
|
if asset_status == "downloading":
|
||||||
# Another worker won this asset between our claim and here (a narrow race — the claim SQL
|
# Another worker won this asset between our claim and here (a narrow race — the claim SQL
|
||||||
@@ -461,7 +495,7 @@ def _worker_loop(idx: int) -> None:
|
|||||||
_process_job(job_id)
|
_process_job(job_id)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
log.exception("processing job %s crashed", job_id)
|
log.exception("processing job %s crashed", job_id)
|
||||||
_set_job(job_id, status="error", error="Worker error", phase=None)
|
_fail_running(job_id, error="Worker error", error_code=None)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Authlib==1.7.2
|
|||||||
Mako==1.4.1
|
Mako==1.4.1
|
||||||
MarkupSafe==3.0.3
|
MarkupSafe==3.0.3
|
||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
|
Pygments==2.21.0
|
||||||
SQLAlchemy==2.0.52
|
SQLAlchemy==2.0.52
|
||||||
alembic==1.19.1
|
alembic==1.19.1
|
||||||
annotated-doc==0.0.5
|
annotated-doc==0.0.5
|
||||||
@@ -21,6 +22,7 @@ cffi==2.1.1
|
|||||||
charset-normalizer==3.5.1
|
charset-normalizer==3.5.1
|
||||||
click==8.4.2
|
click==8.4.2
|
||||||
cryptography==50.0.0
|
cryptography==50.0.0
|
||||||
|
curl_cffi==0.15.0
|
||||||
fastapi==0.141.1
|
fastapi==0.141.1
|
||||||
feedparser-sgmllib==2.1.0
|
feedparser-sgmllib==2.1.0
|
||||||
feedparser==6.0.14
|
feedparser==6.0.14
|
||||||
@@ -32,6 +34,8 @@ httpx==0.28.1
|
|||||||
idna==3.18
|
idna==3.18
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
joserfc==1.7.4
|
joserfc==1.7.4
|
||||||
|
markdown-it-py==4.2.0
|
||||||
|
mdurl==0.1.2
|
||||||
mutagen==1.48.1
|
mutagen==1.48.1
|
||||||
numpy==2.5.2
|
numpy==2.5.2
|
||||||
psycopg-binary==3.3.4
|
psycopg-binary==3.3.4
|
||||||
@@ -45,6 +49,7 @@ pydantic_core==2.46.4
|
|||||||
python-dotenv==1.2.3
|
python-dotenv==1.2.3
|
||||||
python-multipart==0.0.32
|
python-multipart==0.0.32
|
||||||
requests==2.34.2
|
requests==2.34.2
|
||||||
|
rich==15.0.0
|
||||||
starlette==1.6.0
|
starlette==1.6.0
|
||||||
typing-inspection==0.4.4
|
typing-inspection==0.4.4
|
||||||
typing_extensions==4.16.0
|
typing_extensions==4.16.0
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ argon2-cffi>=23.1,<26
|
|||||||
# scripts Deno runs for YouTube's n-signature). yt-dlp is release-often (YouTube changes), so
|
# scripts Deno runs for YouTube's n-signature). yt-dlp is release-often (YouTube changes), so
|
||||||
# keep the floor loose and bump it when extraction breaks.
|
# keep the floor loose and bump it when extraction breaks.
|
||||||
yt-dlp[default]>=2025.5.22
|
yt-dlp[default]>=2025.5.22
|
||||||
|
# Browser impersonation (`impersonate`) for Facebook downloads made with the user's cookie: some
|
||||||
|
# links answer a signed-in plain request with "Cannot parse data" and only parse as Chrome (E11.5,
|
||||||
|
# measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imports curl_cffi only in
|
||||||
|
# 0.10.x–0.15.x and silently reports every target "unavailable" outside it. Move it when bumping
|
||||||
|
# yt-dlp; `tests/test_facebook_impersonation.py::test_the_locked_curl_cffi_can_impersonate_chrome`
|
||||||
|
# fails the gate if the locked pair can no longer impersonate.
|
||||||
|
curl_cffi>=0.10,<0.16
|
||||||
# PO Token provider plugin: talks to the bgutil sidecar (brainicism/bgutil-ytdlp-pot-provider)
|
# PO Token provider plugin: talks to the bgutil sidecar (brainicism/bgutil-ytdlp-pot-provider)
|
||||||
# to mint YouTube Proof-of-Origin tokens on demand, bypassing "confirm you're not a bot" and
|
# to mint YouTube Proof-of-Origin tokens on demand, bypassing "confirm you're not a bot" and
|
||||||
# unlocking high-quality formats. Pin to match the sidecar image tag (versions must align).
|
# unlocking high-quality formats. Pin to match the sidecar image tag (versions must align).
|
||||||
|
|||||||
@@ -103,12 +103,21 @@ def test_generic_source_url_is_passthrough_youtube_reconstructs():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_client_sets_and_retriable_are_youtube_only():
|
def test_client_sets_and_retriable_are_per_source():
|
||||||
# Generic sources make a single attempt and never retry on the YouTube per-client markers.
|
# Generic sources make a single attempt and never retry on the YouTube per-client markers.
|
||||||
assert SourceAdapter().client_sets() == [None]
|
assert SourceAdapter().client_sets() == [None]
|
||||||
|
assert SourceAdapter().client_sets(authed=True) == [None]
|
||||||
assert SourceAdapter().retriable("Sign in to confirm you're not a bot") is False
|
assert SourceAdapter().retriable("Sign in to confirm you're not a bot") is False
|
||||||
assert YouTubeAdapter().retriable("Sign in to confirm you're not a bot") is True
|
assert YouTubeAdapter().retriable("Sign in to confirm you're not a bot") is True
|
||||||
assert YouTubeAdapter().retriable("some unrelated network blip") is False
|
assert YouTubeAdapter().retriable("some unrelated network blip") is False
|
||||||
|
# Facebook: a second, Chrome-impersonating attempt only WITH the user's cookie, and only behind
|
||||||
|
# the parse wall (without a cookie that wall is the login wall — no disguise gets past it).
|
||||||
|
fb = FacebookAdapter()
|
||||||
|
assert fb.client_sets() == [None]
|
||||||
|
assert len(fb.client_sets(authed=True)) == 2
|
||||||
|
assert fb.ydl_opts_extra(fb.client_sets(authed=True)[0]) == {}
|
||||||
|
assert fb.retriable("ERROR: [facebook] 1: Cannot parse data; please report this issue") is True
|
||||||
|
assert fb.retriable("ERROR: [facebook] 1: No video formats found!") is False
|
||||||
|
|
||||||
|
|
||||||
def test_youtube_ydl_opts_extra_carries_player_client_and_pot():
|
def test_youtube_ydl_opts_extra_carries_player_client_and_pot():
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from cryptography.fernet import Fernet
|
|||||||
|
|
||||||
from app import security
|
from app import security
|
||||||
from app.downloads import credentials, formats, service
|
from app.downloads import credentials, formats, service
|
||||||
from app.models import DownloadCredential, MediaAsset, User
|
from app.models import DownloadCredential, DownloadJob, MediaAsset, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -189,7 +189,9 @@ class TestResolveCookiefile:
|
|||||||
db.commit()
|
db.commit()
|
||||||
spec = formats.normalize({})
|
spec = formats.normalize({})
|
||||||
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
path = credentials.resolve_cookiefile(db, u.id, FB_URL, salted, spec, tmp_path)
|
path = credentials.resolve_cookiefile(
|
||||||
|
db, u.id, FB_URL, self._asset_sig(salted), spec, tmp_path
|
||||||
|
)
|
||||||
assert path is not None and Path(path).read_text() == COOKIES
|
assert path is not None and Path(path).read_text() == COOKIES
|
||||||
|
|
||||||
def test_never_attaches_to_a_shared_asset(self, db, tmp_path):
|
def test_never_attaches_to_a_shared_asset(self, db, tmp_path):
|
||||||
@@ -200,7 +202,12 @@ class TestResolveCookiefile:
|
|||||||
db.commit()
|
db.commit()
|
||||||
spec = formats.normalize({})
|
spec = formats.normalize({})
|
||||||
shared = formats.format_sig(spec)
|
shared = formats.format_sig(spec)
|
||||||
assert credentials.resolve_cookiefile(db, u.id, FB_URL, shared, spec, tmp_path) is None
|
assert (
|
||||||
|
credentials.resolve_cookiefile(
|
||||||
|
db, u.id, FB_URL, self._asset_sig(shared), spec, tmp_path
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
def test_non_matching_host_gets_no_cookie(self, db, tmp_path):
|
def test_non_matching_host_gets_no_cookie(self, db, tmp_path):
|
||||||
u = _user(db)
|
u = _user(db)
|
||||||
@@ -210,13 +217,154 @@ class TestResolveCookiefile:
|
|||||||
# A YouTube URL has no supported host_key -> no cookie regardless of what's stored.
|
# A YouTube URL has no supported host_key -> no cookie regardless of what's stored.
|
||||||
yt = "https://www.youtube.com/watch?v=abc"
|
yt = "https://www.youtube.com/watch?v=abc"
|
||||||
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
assert credentials.resolve_cookiefile(db, u.id, yt, salted, spec, tmp_path) is None
|
assert (
|
||||||
|
credentials.resolve_cookiefile(db, u.id, yt, self._asset_sig(salted), spec, tmp_path)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
def test_no_credential_gets_no_cookie(self, db, tmp_path):
|
def test_no_credential_gets_no_cookie(self, db, tmp_path):
|
||||||
u = _user(db)
|
u = _user(db)
|
||||||
spec = formats.normalize({})
|
spec = formats.normalize({})
|
||||||
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
assert credentials.resolve_cookiefile(db, u.id, FB_URL, salted, spec, tmp_path) is None
|
assert (
|
||||||
|
credentials.resolve_cookiefile(
|
||||||
|
db, u.id, FB_URL, self._asset_sig(salted), spec, tmp_path
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Retry / Re-download take a cookie added later (DB) -----------------------------------------
|
||||||
|
# Measured on dev (E11.5): a Facebook reel failed on the login wall, the user added their cookie as
|
||||||
|
# the failure copy tells them to, pressed Retry — and it failed again, because the job still held the
|
||||||
|
# shared asset the worker never attaches a cookie to. Re-download forked a salt the gate could not
|
||||||
|
# recognise, so even a job downloaded WITH a cookie rebuilt without it.
|
||||||
|
|
||||||
|
|
||||||
|
def _held_job(db, u, *, sig, asset_status="error", job_status="error"):
|
||||||
|
a = MediaAsset(
|
||||||
|
source_kind="url", source_ref=FB_URL, format_sig=sig, status=asset_status, ref_count=1
|
||||||
|
)
|
||||||
|
db.add(a)
|
||||||
|
db.flush()
|
||||||
|
j = DownloadJob(
|
||||||
|
user_id=u.id,
|
||||||
|
asset_id=a.id,
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=FB_URL,
|
||||||
|
status=job_status,
|
||||||
|
queue_pos=0,
|
||||||
|
profile_snapshot={},
|
||||||
|
)
|
||||||
|
db.add(j)
|
||||||
|
db.commit()
|
||||||
|
return a, j
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequeueTakesTheCookie:
|
||||||
|
spec = formats.normalize({})
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("asset_status", "job_status"),
|
||||||
|
# A failed download; and a job paused before it ever started (its asset never downloaded).
|
||||||
|
[("error", "error"), ("pending", "paused")],
|
||||||
|
)
|
||||||
|
def test_retry_after_adding_a_cookie_moves_the_job_to_the_private_asset(
|
||||||
|
self, db, tmp_path, asset_status, job_status
|
||||||
|
):
|
||||||
|
u = _user(db)
|
||||||
|
shared, j = _held_job(
|
||||||
|
db,
|
||||||
|
u,
|
||||||
|
sig=formats.format_sig(self.spec),
|
||||||
|
asset_status=asset_status,
|
||||||
|
job_status=job_status,
|
||||||
|
)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service.resume(db, j)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, j.id)
|
||||||
|
target = db.get(MediaAsset, job.asset_id)
|
||||||
|
assert job.status == "queued" and target.id != shared.id
|
||||||
|
assert target.format_sig == credentials.authed_format_sig(self.spec, u.id, "facebook")
|
||||||
|
assert (target.status, target.ref_count) == ("pending", 1)
|
||||||
|
assert db.get(MediaAsset, shared.id).ref_count == 0 # the old hold went with the move
|
||||||
|
assert credentials.resolve_cookiefile(db, u.id, FB_URL, target, self.spec, tmp_path)
|
||||||
|
|
||||||
|
def test_retry_without_a_cookie_keeps_the_shared_asset(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
shared, j = _held_job(db, u, sig=formats.format_sig(self.spec))
|
||||||
|
|
||||||
|
service.resume(db, j)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, j.id).asset_id == shared.id
|
||||||
|
assert db.get(MediaAsset, shared.id).ref_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("asset_status", "job_status"), [("ready", "error"), ("downloading", "paused")]
|
||||||
|
)
|
||||||
|
def test_retry_never_moves_off_a_file_or_a_download_in_flight(
|
||||||
|
self, db, asset_status, job_status
|
||||||
|
):
|
||||||
|
u = _user(db)
|
||||||
|
shared, j = _held_job(
|
||||||
|
db,
|
||||||
|
u,
|
||||||
|
sig=formats.format_sig(self.spec),
|
||||||
|
asset_status=asset_status,
|
||||||
|
job_status=job_status,
|
||||||
|
)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service.resume(db, j)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, j.id).asset_id == shared.id
|
||||||
|
|
||||||
|
def test_redownload_with_a_cookie_rebuilds_into_an_asset_the_cookie_goes_with(
|
||||||
|
self, db, tmp_path
|
||||||
|
):
|
||||||
|
u = _user(db)
|
||||||
|
other = _user(db, "other@example.com")
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
credentials.upsert_credential(db, other.id, "facebook", COOKIES)
|
||||||
|
authed = credentials.authed_format_sig(self.spec, u.id, "facebook")
|
||||||
|
first, j = _held_job(db, u, sig=authed, asset_status="ready", job_status="done")
|
||||||
|
|
||||||
|
seen = {first.id}
|
||||||
|
for _ in range(2): # a rebuild of a rebuild is still recognised
|
||||||
|
service.redownload(db, j)
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, j.id)
|
||||||
|
target = db.get(MediaAsset, job.asset_id)
|
||||||
|
assert target.id not in seen and target.format_sig != authed
|
||||||
|
seen.add(target.id)
|
||||||
|
assert credentials.resolve_cookiefile(db, u.id, FB_URL, target, self.spec, tmp_path)
|
||||||
|
# Private: another user's cookie is never attached to it.
|
||||||
|
assert not credentials.is_authed_asset(target, self.spec, other.id, FB_URL)
|
||||||
|
|
||||||
|
# A Retry of a failed rebuild keeps the rebuild — it is already the cookie's identity.
|
||||||
|
target.status, job.status = "error", "error"
|
||||||
|
db.commit()
|
||||||
|
service.resume(db, job)
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, j.id).asset_id == target.id
|
||||||
|
|
||||||
|
def test_a_rebuild_made_without_a_cookie_is_no_cookie_identity(self, db, tmp_path):
|
||||||
|
u = _user(db)
|
||||||
|
_, j = _held_job(db, u, sig=formats.format_sig(self.spec))
|
||||||
|
service.redownload(db, j)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
target = db.get(MediaAsset, db.get(DownloadJob, j.id).asset_id)
|
||||||
|
assert not credentials.is_authed_asset(target, self.spec, u.id, FB_URL)
|
||||||
|
|
||||||
|
|
||||||
# --- API: write-only, never echoes the blob (DB) ----------------------------------------------
|
# --- API: write-only, never echoes the blob (DB) ----------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
|
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
|
||||||
|
|
||||||
|
from app.downloads import adapters
|
||||||
from app.downloads import errors as dlerrors
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads.errors import classify
|
from app.downloads.errors import classify
|
||||||
|
|
||||||
@@ -64,6 +65,98 @@ def test_our_own_worker_refusals_classify_as_themselves():
|
|||||||
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
|
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_redacted_network_text_classifies_back_to_network():
|
||||||
|
# The redacted text is also what a failed asset keeps, and a second job that meets that asset is
|
||||||
|
# diagnosed from it — without its own rule it would read "Download failed".
|
||||||
|
assert classify(dlerrors.NETWORK_REDACTED_MESSAGE) == "network"
|
||||||
|
|
||||||
|
|
||||||
|
# Verbatim yt-dlp 2026.07.04 output from prod jobs, measured on dev 2026-09-13 (E11.5).
|
||||||
|
_FB_PARSE = (
|
||||||
|
"ERROR: [facebook] 1736360027626346: Cannot parse data; please report this issue on "
|
||||||
|
"https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template."
|
||||||
|
)
|
||||||
|
_FB_NO_FORMATS = (
|
||||||
|
"ERROR: [facebook] 1755988225525547: No video formats found!; please report this issue on "
|
||||||
|
"https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template."
|
||||||
|
)
|
||||||
|
_REEL = "https://www.facebook.com/reel/1736360027626346/?fs=e&fs=e"
|
||||||
|
_GROUP_POST = "https://www.facebook.com/groups/983554479435596/permalink/1755988225525547/?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_facebook_cannot_parse_data_is_the_login_wall_without_a_cookie():
|
||||||
|
for url in (
|
||||||
|
_REEL,
|
||||||
|
_GROUP_POST,
|
||||||
|
"https://fb.watch/Ia_iJYI2Vw/?fs=e",
|
||||||
|
"https://m.facebook.com/x",
|
||||||
|
):
|
||||||
|
assert dlerrors.diagnose(_FB_PARSE, url=url) == ("login_required", "facebook.login_wall")
|
||||||
|
|
||||||
|
|
||||||
|
def test_facebook_cannot_parse_data_with_a_cookie_is_the_cookie_refused():
|
||||||
|
# Telling a user who already gave a cookie to add one is the failure this entry exists to avoid.
|
||||||
|
assert dlerrors.diagnose(_FB_PARSE, url=_REEL, cookie_attached=True) == (
|
||||||
|
"login_required",
|
||||||
|
"facebook.cookie_rejected",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_facebook_group_post_without_formats_names_the_link():
|
||||||
|
# Without a cookie yt-dlp is given the post link itself (measured, prod jobs 66 and 145).
|
||||||
|
assert dlerrors.diagnose(_FB_NO_FORMATS, url=_GROUP_POST) == (
|
||||||
|
"format_unavailable",
|
||||||
|
"facebook.group_post_video",
|
||||||
|
)
|
||||||
|
# With one it is only ever given the post's VIDEO link, so the text is about that video: the adapter
|
||||||
|
# found it, and "this post has no video" would be false.
|
||||||
|
assert dlerrors.diagnose(_FB_NO_FORMATS, url=_GROUP_POST, cookie_attached=True) == (
|
||||||
|
"format_unavailable",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
# A reel, or a group link that is not a post, is another failure: only the category.
|
||||||
|
for url in (_REEL, "https://www.facebook.com/groups/983554479435596/"):
|
||||||
|
assert dlerrors.diagnose(_FB_NO_FORMATS, url=url) == ("format_unavailable", None), url
|
||||||
|
|
||||||
|
|
||||||
|
def test_our_post_not_shown_verdict_is_the_refused_cookie():
|
||||||
|
# The adapter's text when a signed-in read of the post got a page without the post (the login page).
|
||||||
|
msg = adapters.POST_NOT_SHOWN_MESSAGE
|
||||||
|
assert classify(msg) == "login_required"
|
||||||
|
assert dlerrors.diagnose(msg, url=_GROUP_POST, cookie_attached=True) == (
|
||||||
|
"login_required",
|
||||||
|
"facebook.cookie_rejected",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_our_post_without_a_video_verdict_names_the_group_link():
|
||||||
|
# The adapter's own text when the post page holds no single video (S4): the message and its rule and
|
||||||
|
# catalog entry are pinned together, or an edit to the wording orphans the copy.
|
||||||
|
msg = adapters.POST_WITHOUT_VIDEO_MESSAGE
|
||||||
|
assert classify(msg) == "format_unavailable"
|
||||||
|
assert dlerrors.diagnose(msg, url=_GROUP_POST, cookie_attached=True) == (
|
||||||
|
"format_unavailable",
|
||||||
|
"facebook.group_post_video",
|
||||||
|
)
|
||||||
|
assert dlerrors.diagnose(msg, url=_REEL, cookie_attached=True) == ("format_unavailable", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_catalog_entry_never_leaves_its_host():
|
||||||
|
# "Cannot parse data" is yt-dlp's wording for a broken extractor on every site.
|
||||||
|
other = _FB_PARSE.replace("[facebook]", "[vimeo]")
|
||||||
|
assert dlerrors.diagnose(other, url="https://vimeo.com/1") == ("unknown", None)
|
||||||
|
# The Facebook text for a link that is not Facebook's (a look-alike host) is not the Facebook wall.
|
||||||
|
assert dlerrors.diagnose(_FB_PARSE, url="https://facebook.com.evil/reel/1") == ("unknown", None)
|
||||||
|
# No link known: the category only.
|
||||||
|
assert dlerrors.diagnose(_FB_PARSE) == ("unknown", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnose_falls_back_to_the_category():
|
||||||
|
msg = "ERROR: [facebook] 123: You must be logged in to see this post"
|
||||||
|
assert dlerrors.diagnose(msg, url=_REEL) == ("login_required", None)
|
||||||
|
assert dlerrors.diagnose(None, url=_REEL) == ("unknown", None)
|
||||||
|
|
||||||
|
|
||||||
def test_a_url_jobs_network_failure_is_redacted_but_youtube_keeps_its_text():
|
def test_a_url_jobs_network_failure_is_redacted_but_youtube_keeps_its_text():
|
||||||
# SEC-12: the raw transport text for a user-chosen host:port is a port-scanner readout, so it is
|
# SEC-12: the raw transport text for a user-chosen host:port is a port-scanner readout, so it is
|
||||||
# replaced for `url` jobs. Everything else must survive verbatim, or a real diagnosis is lost.
|
# replaced for `url` jobs. Everything else must survive verbatim, or a real diagnosis is lost.
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""DB-backed tests for the download failure catalog (E11.5 S1) at the places a failure is WRITTEN.
|
||||||
|
|
||||||
|
`test_download_errors` pins which entry a message matches; these pin that every writer hands the
|
||||||
|
matcher what it needs — the link and whether the user's cookie went with it — and that a stored entry
|
||||||
|
never outlives the failure it describes:
|
||||||
|
- the download child's failure branch (`pipeline._download`);
|
||||||
|
- a second job meeting an already-failed asset (`worker._process_job`), which only has the text;
|
||||||
|
- Retry / Re-download, which requeue the job;
|
||||||
|
- the `0084` backfill of the rows that failed before the catalog existed;
|
||||||
|
- the downloads list, which is how the entry reaches the UI.
|
||||||
|
It also holds the one success-path check of the same child: the duration it stores (E11.5 S3).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import engine
|
||||||
|
from app.downloads import credentials, formats, pipeline, service
|
||||||
|
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||||
|
from app.worker import _process_job
|
||||||
|
|
||||||
|
_REEL = "https://www.facebook.com/reel/1736360027626346/?fs=e&fs=e"
|
||||||
|
# Verbatim yt-dlp 2026.07.04, prod job 256.
|
||||||
|
_FB_PARSE = (
|
||||||
|
"ERROR: [facebook] 1736360027626346: Cannot parse data; please report this issue on "
|
||||||
|
"https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template."
|
||||||
|
)
|
||||||
|
_COOKIES = "# Netscape HTTP Cookie File\n.facebook.com\tTRUE\t/\tTRUE\t0\tc_user\t123\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _fernet(monkeypatch):
|
||||||
|
# A throwaway key, so a stored credential encrypts and decrypts (mirrors test_download_credentials).
|
||||||
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||||
|
|
||||||
|
|
||||||
|
def _user(db, email):
|
||||||
|
u = User(email=email)
|
||||||
|
db.add(u)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
DownloadQuota(
|
||||||
|
user_id=u.id, max_bytes=10**12, max_concurrent=5, max_jobs=10**6, unlimited=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_and_job(db, user, *, sig, asset_status, job_status, error=None):
|
||||||
|
a = MediaAsset(
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_REEL,
|
||||||
|
format_sig=sig,
|
||||||
|
status=asset_status,
|
||||||
|
error=error,
|
||||||
|
ref_count=1,
|
||||||
|
)
|
||||||
|
db.add(a)
|
||||||
|
db.flush()
|
||||||
|
j = DownloadJob(
|
||||||
|
user_id=user.id,
|
||||||
|
asset_id=a.id,
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_REEL,
|
||||||
|
status=job_status,
|
||||||
|
queue_pos=0,
|
||||||
|
profile_snapshot={},
|
||||||
|
)
|
||||||
|
db.add(j)
|
||||||
|
db.commit()
|
||||||
|
return a, j
|
||||||
|
|
||||||
|
|
||||||
|
def _fail_the_child(monkeypatch, tmp_path, *, cookie: bool):
|
||||||
|
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||||
|
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||||
|
cookiefile = tmp_path / "cookies.txt"
|
||||||
|
cookiefile.write_text("# Netscape HTTP Cookie File\n")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pipeline.credentials,
|
||||||
|
"resolve_cookiefile",
|
||||||
|
lambda *a, **k: cookiefile if cookie else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def extract(*a, **k):
|
||||||
|
raise RuntimeError(_FB_PARSE)
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_download_child_diagnoses_with_the_link_and_no_cookie(db, monkeypatch, tmp_path):
|
||||||
|
u = _user(db, "fb-nocookie@example.com")
|
||||||
|
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
||||||
|
_fail_the_child(monkeypatch, tmp_path, cookie=False)
|
||||||
|
|
||||||
|
pipeline._download(j.id, a.id, "url", _REEL, {}, u.id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, j.id)
|
||||||
|
assert (job.status, job.error_code, job.error_detail) == (
|
||||||
|
"error",
|
||||||
|
"login_required",
|
||||||
|
"facebook.login_wall",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_download_child_knows_the_cookie_went_with_it(db, monkeypatch, tmp_path):
|
||||||
|
# The same text with the user's cookie attached: telling them to add cookies would be wrong.
|
||||||
|
u = _user(db, "fb-cookie@example.com")
|
||||||
|
a, j = _asset_and_job(db, u, sig="private", asset_status="downloading", job_status="running")
|
||||||
|
_fail_the_child(monkeypatch, tmp_path, cookie=True)
|
||||||
|
|
||||||
|
pipeline._download(j.id, a.id, "url", _REEL, {}, u.id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, j.id)
|
||||||
|
assert (job.error_code, job.error_detail) == ("login_required", "facebook.cookie_rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_job_meeting_a_failed_shared_asset_gets_the_same_explanation(db):
|
||||||
|
# Before the catalog this path wrote the text and NO code: "Download failed" for the second
|
||||||
|
# job, "Facebook sign-in required" for the first.
|
||||||
|
u = _user(db, "fb-second@example.com")
|
||||||
|
_, j = _asset_and_job(
|
||||||
|
db, u, sig="shared", asset_status="error", job_status="running", error=_FB_PARSE
|
||||||
|
)
|
||||||
|
|
||||||
|
_process_job(j.id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, j.id)
|
||||||
|
assert (job.status, job.error_code, job.error_detail) == (
|
||||||
|
"error",
|
||||||
|
"login_required",
|
||||||
|
"facebook.login_wall",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_job_meeting_its_users_failed_private_asset_knows_the_cookie_was_used(db, _fernet):
|
||||||
|
u = _user(db, "fb-private@example.com")
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", _COOKIES)
|
||||||
|
sig = credentials.authed_format_sig(formats.normalize({}), u.id, "facebook")
|
||||||
|
_, j = _asset_and_job(
|
||||||
|
db, u, sig=sig, asset_status="error", job_status="running", error=_FB_PARSE
|
||||||
|
)
|
||||||
|
|
||||||
|
_process_job(j.id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, j.id)
|
||||||
|
assert (job.error_code, job.error_detail) == ("login_required", "facebook.cookie_rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_private_asset_the_cookie_could_not_go_with_is_explained_as_the_download_had_it(db):
|
||||||
|
# The cookie was deleted (or can no longer be decrypted) after the jobs were queued: the download
|
||||||
|
# child had nothing to attach and said "add your cookies". The job meeting that failed asset must say
|
||||||
|
# the same — the asset's salt alone would have told it "your cookies were refused".
|
||||||
|
u = _user(db, "fb-gone@example.com")
|
||||||
|
sig = credentials.authed_format_sig(formats.normalize({}), u.id, "facebook")
|
||||||
|
_, j = _asset_and_job(
|
||||||
|
db, u, sig=sig, asset_status="error", job_status="running", error=_FB_PARSE
|
||||||
|
)
|
||||||
|
|
||||||
|
_process_job(j.id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, j.id)
|
||||||
|
assert (job.error_code, job.error_detail) == ("login_required", "facebook.login_wall")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_job_canceled_before_it_meets_a_failed_asset_stays_canceled(db):
|
||||||
|
# The cancel landed between the claim and this write. `error` would hold the asset again after the
|
||||||
|
# route already released it — a later delete releases twice.
|
||||||
|
u = _user(db, "fb-canceled@example.com")
|
||||||
|
_, j = _asset_and_job(
|
||||||
|
db, u, sig="shared", asset_status="error", job_status="canceled", error=_FB_PARSE
|
||||||
|
)
|
||||||
|
|
||||||
|
_process_job(j.id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, j.id)
|
||||||
|
assert (job.status, job.error_code, job.error_detail) == ("canceled", None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_downloads_list_carries_the_catalog_entry(db, as_user):
|
||||||
|
# The whole explanation rides on this one field: without it every row falls back to the category.
|
||||||
|
u = _user(db, "fb-list@example.com")
|
||||||
|
_, j = _asset_and_job(
|
||||||
|
db, u, sig="shared", asset_status="error", job_status="error", error=_FB_PARSE
|
||||||
|
)
|
||||||
|
j.error_code, j.error_detail = "login_required", "facebook.login_wall"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
[row] = as_user(u).get("/api/downloads").json()
|
||||||
|
|
||||||
|
assert (row["error_code"], row["error_detail"]) == ("login_required", "facebook.login_wall")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||||
|
def test_a_download_yt_dlp_gave_no_duration_stores_the_files_duration(db, monkeypatch, tmp_path):
|
||||||
|
# Facebook reel 257 (E11.5): yt-dlp's info had no duration, the asset stored NULL, and the Library
|
||||||
|
# row lost its trim action. Through the real finalize, with a real 3-second file.
|
||||||
|
u = _user(db, "fb-duration@example.com")
|
||||||
|
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
||||||
|
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||||
|
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||||
|
|
||||||
|
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
||||||
|
media = staging / "1736360027626346.mp4"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"testsrc=size=64x64:rate=10",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"sine=frequency=440",
|
||||||
|
"-t",
|
||||||
|
"3",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
str(media),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return {"id": "1736360027626346", "ext": "mp4", "title": "Reel", "filepath": str(media)}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||||
|
|
||||||
|
pipeline._download(j.id, a.id, "url", _REEL, {}, u.id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, j.id).status == "done"
|
||||||
|
assert db.get(MediaAsset, a.id).duration_s == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_and_redownload_drop_the_old_explanation(db):
|
||||||
|
u = _user(db, "fb-requeue@example.com")
|
||||||
|
for requeue in (service.resume, service.redownload):
|
||||||
|
_, j = _asset_and_job(
|
||||||
|
db, u, sig=f"s-{requeue.__name__}", asset_status="error", job_status="error"
|
||||||
|
)
|
||||||
|
j.error, j.error_code, j.error_detail = _FB_PARSE, "login_required", "facebook.login_wall"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
requeue(db, j)
|
||||||
|
|
||||||
|
db.refresh(j)
|
||||||
|
assert (j.status, j.error_code, j.error_detail) == ("queued", None, None), requeue.__name__
|
||||||
|
|
||||||
|
|
||||||
|
def test_0084_backfills_only_the_facebook_parse_failures_it_measured(db):
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "0083_comfort_pins")
|
||||||
|
upgraded = False
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
uid = conn.execute(
|
||||||
|
text("INSERT INTO users (email) VALUES ('backfill@example.com') RETURNING id")
|
||||||
|
).scalar_one()
|
||||||
|
rows = {
|
||||||
|
"fb_unknown": ("error", "unknown", _FB_PARSE),
|
||||||
|
"fb_null": ("canceled", None, _FB_PARSE),
|
||||||
|
"other_site": ("error", "unknown", _FB_PARSE.replace("[facebook]", "[vimeo]")),
|
||||||
|
"fb_other_code": ("error", "network", _FB_PARSE),
|
||||||
|
}
|
||||||
|
ids = {}
|
||||||
|
for name, (status, code, error) in rows.items():
|
||||||
|
ids[name] = conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO download_jobs (user_id, source_kind, source_ref, "
|
||||||
|
"profile_snapshot, status, error_code, error) "
|
||||||
|
"VALUES (:u, 'url', :ref, '{}', :s, :c, :e) RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": uid, "ref": _REEL, "s": status, "c": code, "e": error},
|
||||||
|
).scalar_one()
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
upgraded = True
|
||||||
|
with engine.connect() as conn:
|
||||||
|
got = {
|
||||||
|
name: tuple(
|
||||||
|
conn.execute(
|
||||||
|
text("SELECT error_code, error_detail FROM download_jobs WHERE id = :i"),
|
||||||
|
{"i": i},
|
||||||
|
).one()
|
||||||
|
)
|
||||||
|
for name, i in ids.items()
|
||||||
|
}
|
||||||
|
assert got == {
|
||||||
|
"fb_unknown": ("login_required", "facebook.login_wall"),
|
||||||
|
"fb_null": ("login_required", "facebook.login_wall"),
|
||||||
|
"other_site": ("unknown", None),
|
||||||
|
"fb_other_code": ("network", None),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if not upgraded:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Overlapping requests on ONE job (E11.5 review) — DB-backed, two real sessions.
|
||||||
|
|
||||||
|
The routes that move a job's asset ref count (resume, cancel, delete, redownload) used to decide on an
|
||||||
|
unlocked read: two overlapping requests for one job (a double-click, a second tab, a replay after a
|
||||||
|
gateway timeout) both saw it holding its asset and both released the hold. The count then reaches 0
|
||||||
|
while another job still holds the asset, and GC or the ready-file delete removes it under that job.
|
||||||
|
A pause racing a cancel did the same one step later: its unlocked write turned the released job back
|
||||||
|
into a holder, which a later delete releases again. `_own_job_for_update` makes the second request
|
||||||
|
wait and decide on the first one's committed result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.downloads import service
|
||||||
|
from app.models import DownloadJob, MediaAsset, User
|
||||||
|
from app.routes.downloads import (
|
||||||
|
_own_job_for_update,
|
||||||
|
cancel_download,
|
||||||
|
delete_download,
|
||||||
|
pause_download,
|
||||||
|
)
|
||||||
|
|
||||||
|
_REF = "https://example.com/video"
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_held_by_two_jobs(db, status="error"):
|
||||||
|
mine, theirs = User(email="lock-mine@example.com"), User(email="lock-theirs@example.com")
|
||||||
|
db.add_all([mine, theirs])
|
||||||
|
db.flush()
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind="url", source_ref=_REF, format_sig="shared", status="error", ref_count=2
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
|
jobs = [
|
||||||
|
DownloadJob(
|
||||||
|
user_id=owner.id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_REF,
|
||||||
|
status=status if owner is mine else "error",
|
||||||
|
queue_pos=0,
|
||||||
|
profile_snapshot={},
|
||||||
|
)
|
||||||
|
for owner in (mine, theirs)
|
||||||
|
]
|
||||||
|
db.add_all(jobs)
|
||||||
|
db.commit()
|
||||||
|
return mine, asset, jobs[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status", "transition", "route", "left"),
|
||||||
|
[
|
||||||
|
("error", service.cancel, cancel_download, "canceled"),
|
||||||
|
("error", service.delete, delete_download, None),
|
||||||
|
("queued", service.cancel, pause_download, "canceled"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_overlapping_requests_for_one_job_release_its_hold_once(
|
||||||
|
db, status, transition, route, left
|
||||||
|
):
|
||||||
|
user, asset, job = _asset_held_by_two_jobs(db, status)
|
||||||
|
job_id = job.id # read before the delete case removes the row under the expired instance
|
||||||
|
first, second = SessionLocal(), SessionLocal()
|
||||||
|
finished = threading.Event()
|
||||||
|
|
||||||
|
def the_other_request():
|
||||||
|
try:
|
||||||
|
route(job_id, user=user, db=second)
|
||||||
|
except HTTPException:
|
||||||
|
pass # a request after the delete: the job is gone, a 404
|
||||||
|
finally:
|
||||||
|
second.close()
|
||||||
|
finished.set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
locked = _own_job_for_update(first, user, job_id)
|
||||||
|
other = threading.Thread(target=the_other_request)
|
||||||
|
other.start()
|
||||||
|
# The second request waits for the first one's locks instead of deciding on the same row.
|
||||||
|
assert not finished.wait(0.5), "the second request did not wait for the first"
|
||||||
|
transition(first, locked)
|
||||||
|
other.join(10)
|
||||||
|
assert finished.is_set()
|
||||||
|
finally:
|
||||||
|
first.close()
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
mine = select(DownloadJob.status).where(DownloadJob.id == job_id)
|
||||||
|
assert db.execute(mine).scalar_one_or_none() == left
|
||||||
|
assert db.get(MediaAsset, asset.id).ref_count == 1 # the other user's job still holds it
|
||||||
|
|
||||||
|
|
||||||
|
def test_losing_the_asset_insert_race_keeps_the_callers_own_writes(db, monkeypatch):
|
||||||
|
# Retry clears the job's error, THEN may create the cookie asset. When another request inserted that
|
||||||
|
# asset first, the loser used to roll the whole session back — and requeue the job with its stale
|
||||||
|
# error, which a stall or crash later leaves showing under the new failure.
|
||||||
|
user = User(email="race@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=user.id,
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_REF,
|
||||||
|
status="error",
|
||||||
|
error="the old failure",
|
||||||
|
queue_pos=0,
|
||||||
|
profile_snapshot={},
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
job.error = None # the caller's pending write
|
||||||
|
|
||||||
|
real_flush, raced = db.flush, []
|
||||||
|
|
||||||
|
def flush_after_the_other_request_inserted(*args, **kwargs):
|
||||||
|
if not raced: # the other request commits the same asset between our lookup and our insert
|
||||||
|
raced.append(True)
|
||||||
|
with SessionLocal() as other:
|
||||||
|
other.add(
|
||||||
|
MediaAsset(source_kind="url", source_ref=_REF, format_sig="raced", ref_count=0)
|
||||||
|
)
|
||||||
|
other.commit()
|
||||||
|
return real_flush(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(db, "flush", flush_after_the_other_request_inserted)
|
||||||
|
|
||||||
|
asset = service.get_or_create_asset(db, "url", _REF, "raced")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert raced and asset.format_sig == "raced"
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, job.id).error is None
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""E11.5 S3 + S4 — the Facebook download path with the user's cookie.
|
||||||
|
|
||||||
|
S3, measured on dev (yt-dlp 2026.07.04, the owner's cookie): reel 806379462330494 fails with the cookie
|
||||||
|
alone and extracts 2560p with `--impersonate chrome`; the reels that already download with the cookie
|
||||||
|
keep their plain first attempt. These pin the attempt order through the real download loop
|
||||||
|
(`pipeline._extract_with_fallback`, with yt-dlp faked at the `YoutubeDL` boundary), and that the
|
||||||
|
LOCKED curl_cffi can actually impersonate — the pair only works inside yt-dlp's own version range, and
|
||||||
|
outside it every target silently reports "unavailable".
|
||||||
|
|
||||||
|
S4, measured on dev and in the owner's Chrome: given a group post's link, yt-dlp finds no formats or
|
||||||
|
downloads another video from the page's feed. The post's own video is the single Video node in the
|
||||||
|
page scripts that carry the post id (prod posts 66, 145, 140, 143, 221; text posts have none while the
|
||||||
|
page holds ~10 other videos), and its watch link downloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yt_dlp
|
||||||
|
from yt_dlp.networking.exceptions import TransportError
|
||||||
|
from yt_dlp.networking.impersonate import ImpersonateTarget
|
||||||
|
|
||||||
|
from app.downloads import adapters, pipeline
|
||||||
|
from app.downloads import errors as dlerrors
|
||||||
|
|
||||||
|
_URL = "https://www.facebook.com/reel/806379462330494/?fs=e&fs=e"
|
||||||
|
_POST = "1755988225525547"
|
||||||
|
_GROUP_LINK = f"https://www.facebook.com/groups/983554479435596/permalink/{_POST}/?"
|
||||||
|
_POST_VIDEO = "1408015447857753"
|
||||||
|
_FEED_VIDEO = "1056963063816966" # what yt-dlp downloaded from this post's link: a feed video
|
||||||
|
_CHROME = ImpersonateTarget.from_str("chrome")
|
||||||
|
|
||||||
|
|
||||||
|
def _video_node(video_id: str) -> str:
|
||||||
|
# The measured shape of a post's attachment in the page JSON.
|
||||||
|
return '{"media":{"__typename":"Video","id":"%s","__isNode":"Video"},"styles":{}}' % video_id
|
||||||
|
|
||||||
|
|
||||||
|
def _page(*post_videos: str, post: str = _POST) -> str:
|
||||||
|
attachments = ",".join(_video_node(v) for v in post_videos)
|
||||||
|
return (
|
||||||
|
'<html><script type="application/json" data-sjs>'
|
||||||
|
f'{{"post_id":"{post}","creation_time":1785084554,"attachments":[{attachments}]}}</script>'
|
||||||
|
'<script type="application/json" data-sjs>'
|
||||||
|
f'{{"feed":[{_video_node(_FEED_VIDEO)}]}}</script></html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_WALL = (
|
||||||
|
"ERROR: [facebook] 806379462330494: Cannot parse data; please report this issue on "
|
||||||
|
"https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template."
|
||||||
|
)
|
||||||
|
_NO_FORMATS = "ERROR: [facebook] 2541212983053999: No video formats found!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_locked_curl_cffi_can_impersonate_chrome():
|
||||||
|
# YoutubeDL refuses to start when the target is unavailable — the same check a download makes.
|
||||||
|
with yt_dlp.YoutubeDL({"impersonate": ImpersonateTarget.from_str("chrome"), "quiet": True}):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ydl(monkeypatch):
|
||||||
|
"""Fake `yt_dlp.YoutubeDL`: records each attempt's options and plays back `outcomes` in order;
|
||||||
|
`web["page"]` is what a page read returns (`web["fail"]`, when set, is raised instead), and `web`
|
||||||
|
records the pages opened, the byte cap each read asked for, and the links extracted."""
|
||||||
|
seen: list[dict] = []
|
||||||
|
outcomes: list = []
|
||||||
|
web: dict = {"page": "", "fail": None, "opened": [], "reads": [], "extracted": []}
|
||||||
|
|
||||||
|
class FakeYDL:
|
||||||
|
def __init__(self, opts):
|
||||||
|
seen.append(opts)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def urlopen(self, url):
|
||||||
|
web["opened"].append(url)
|
||||||
|
if web["fail"] is not None:
|
||||||
|
raise web["fail"]
|
||||||
|
body = web["page"].encode()
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def read(self, amt=None):
|
||||||
|
web["reads"].append(amt)
|
||||||
|
return body
|
||||||
|
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
def extract_info(self, url, download):
|
||||||
|
web["extracted"].append(url)
|
||||||
|
outcome = outcomes.pop(0)
|
||||||
|
if isinstance(outcome, str):
|
||||||
|
raise yt_dlp.utils.DownloadError(outcome)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline.yt_dlp, "YoutubeDL", FakeYDL)
|
||||||
|
monkeypatch.setattr(pipeline, "_touch_job", lambda job_id: None)
|
||||||
|
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **fields: None)
|
||||||
|
return seen, outcomes, web
|
||||||
|
|
||||||
|
|
||||||
|
def _run(tmp_path, cookiefile, url=_URL):
|
||||||
|
return pipeline._extract_with_fallback(
|
||||||
|
1, 1, url, {}, tmp_path, adapters.FacebookAdapter(), cookiefile=cookiefile
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cookie_download_at_the_wall_is_retried_as_chrome(ydl, tmp_path):
|
||||||
|
seen, outcomes, web = ydl
|
||||||
|
outcomes += [_WALL, {"id": "806379462330494"}]
|
||||||
|
|
||||||
|
assert _run(tmp_path, "/cookies.txt") == {"id": "806379462330494"}
|
||||||
|
|
||||||
|
assert [o.get("impersonate") for o in seen] == [None, _CHROME]
|
||||||
|
assert all(o["cookiefile"] == "/cookies.txt" for o in seen) # the retry stays signed in
|
||||||
|
assert web["opened"] == [] # a reel is not a group post: no page read
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_a_cookie_the_wall_is_the_login_wall_and_final(ydl, tmp_path):
|
||||||
|
seen, outcomes, _ = ydl
|
||||||
|
outcomes += [_WALL]
|
||||||
|
|
||||||
|
with pytest.raises(yt_dlp.utils.DownloadError):
|
||||||
|
_run(tmp_path, None)
|
||||||
|
|
||||||
|
assert len(seen) == 1 and "impersonate" not in seen[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_facebook_failures_are_not_retried_as_chrome(ydl, tmp_path):
|
||||||
|
seen, outcomes, _ = ydl
|
||||||
|
outcomes += [_NO_FORMATS]
|
||||||
|
|
||||||
|
with pytest.raises(yt_dlp.utils.DownloadError):
|
||||||
|
_run(tmp_path, "/cookies.txt")
|
||||||
|
|
||||||
|
assert len(seen) == 1 and "impersonate" not in seen[0]
|
||||||
|
|
||||||
|
|
||||||
|
# --- S4: a group post downloads ITS video, or fails saying it has none ----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cookie_group_post_downloads_the_posts_own_video(ydl, tmp_path):
|
||||||
|
seen, outcomes, web = ydl
|
||||||
|
web["page"] = _page(_POST_VIDEO)
|
||||||
|
outcomes += [_WALL, {"id": _POST_VIDEO}] # the watch link needs Chrome too (measured)
|
||||||
|
|
||||||
|
assert _run(tmp_path, "/cookies.txt", _GROUP_LINK) == {"id": _POST_VIDEO}
|
||||||
|
|
||||||
|
assert web["opened"] == [_GROUP_LINK]
|
||||||
|
# Never an unbounded read.
|
||||||
|
assert web["reads"] == [adapters.FacebookAdapter._POST_PAGE_MAX_BYTES]
|
||||||
|
assert seen[0]["cookiefile"] == "/cookies.txt" # the post page is read signed in
|
||||||
|
watch = f"https://www.facebook.com/watch/?v={_POST_VIDEO}"
|
||||||
|
# Never the post link: that is where the feed video comes from.
|
||||||
|
assert web["extracted"] == [watch, watch]
|
||||||
|
assert [o.get("impersonate") for o in seen[1:]] == [None, _CHROME]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_group_post_without_a_single_video_fails_saying_so(ydl, tmp_path):
|
||||||
|
_, _, web = ydl
|
||||||
|
web["page"] = _page() # a text post: the only video on the page is the feed's
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as exc:
|
||||||
|
_run(tmp_path, "/cookies.txt", _GROUP_LINK)
|
||||||
|
|
||||||
|
assert web["extracted"] == [] # yt-dlp is not left to guess from the post link
|
||||||
|
assert dlerrors.diagnose(str(exc.value), url=_GROUP_LINK, cookie_attached=True) == (
|
||||||
|
"format_unavailable",
|
||||||
|
"facebook.group_post_video",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_group_post_page_without_the_post_is_the_refused_cookie(ydl, tmp_path):
|
||||||
|
# Measured with a refused cookie (E11.5 review): Facebook answers the post link with its login page —
|
||||||
|
# a login form, no post id anywhere. Saying "this post has no video" would blame the post.
|
||||||
|
_, _, web = ydl
|
||||||
|
web["page"] = '<html><title>ChatGPT magyarul</title><form id="login_form"></form></html>'
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as exc:
|
||||||
|
_run(tmp_path, "/cookies.txt", _GROUP_LINK)
|
||||||
|
|
||||||
|
assert web["extracted"] == []
|
||||||
|
assert dlerrors.diagnose(str(exc.value), url=_GROUP_LINK, cookie_attached=True) == (
|
||||||
|
"login_required",
|
||||||
|
"facebook.cookie_rejected",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_post_page_read_fails_the_download_as_itself(ydl, tmp_path):
|
||||||
|
# A read that failed says nothing about the post: it must not turn into "no video in this post".
|
||||||
|
_, _, web = ydl
|
||||||
|
web["fail"] = TransportError("Connection reset by peer")
|
||||||
|
|
||||||
|
with pytest.raises(TransportError) as exc:
|
||||||
|
_run(tmp_path, "/cookies.txt", _GROUP_LINK)
|
||||||
|
|
||||||
|
assert web["extracted"] == []
|
||||||
|
assert dlerrors.diagnose(str(exc.value), url=_GROUP_LINK, cookie_attached=True) == (
|
||||||
|
"network",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_a_cookie_the_group_post_link_goes_to_ytdlp_unchanged(ydl, tmp_path):
|
||||||
|
_, outcomes, web = ydl
|
||||||
|
outcomes += [_NO_FORMATS]
|
||||||
|
|
||||||
|
with pytest.raises(yt_dlp.utils.DownloadError):
|
||||||
|
_run(tmp_path, None, _GROUP_LINK)
|
||||||
|
|
||||||
|
assert web["opened"] == [] # signed out the page is the login wall: nothing to read
|
||||||
|
assert web["extracted"] == [_GROUP_LINK]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_group_post_link_forms_are_recognised():
|
||||||
|
for url in (
|
||||||
|
_GROUP_LINK,
|
||||||
|
f"https://www.facebook.com/groups/983554479435596/posts/{_POST}/",
|
||||||
|
f"https://m.facebook.com/groups/983554479435596/permalink/{_POST}/?",
|
||||||
|
f"https://www.facebook.com/groups/chatgptmagyarul/posts/{_POST}",
|
||||||
|
):
|
||||||
|
assert adapters.facebook_group_post_id(url) == _POST, url
|
||||||
|
for url in (
|
||||||
|
_URL,
|
||||||
|
f"https://www.facebook.com/watch/?v={_POST_VIDEO}",
|
||||||
|
f"https://facebook.com.evil/groups/x/permalink/{_POST}/",
|
||||||
|
"https://www.facebook.com/groups/983554479435596/",
|
||||||
|
):
|
||||||
|
assert adapters.facebook_group_post_id(url) is None, url
|
||||||
|
assert adapters.SourceAdapter().resolve_url(_GROUP_LINK, {"cookiefile": "/c"}) == _GROUP_LINK
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_single_video_of_the_posts_own_blocks_counts():
|
||||||
|
assert adapters.facebook_post_video_id(_page(_POST_VIDEO), _POST) == _POST_VIDEO
|
||||||
|
# No video in the post, although the page has one elsewhere.
|
||||||
|
assert adapters.facebook_post_video_id(_page(), _POST) is None
|
||||||
|
# Two videos: guessing between them would be the wrong-video bug again.
|
||||||
|
assert adapters.facebook_post_video_id(_page(_POST_VIDEO, "2063302677911049"), _POST) is None
|
||||||
|
# A longer number that merely STARTS with the post id is another object.
|
||||||
|
assert adapters.facebook_post_video_id(_page(_POST_VIDEO, post=_POST + "0"), _POST) is None
|
||||||
@@ -424,10 +424,36 @@ class TestResolveMediaFacts:
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"width": None, "height": None})
|
v, a, w, h, _d = probemod.resolve_media_facts(Path("x"), {"width": None, "height": None})
|
||||||
assert (v, a, w, h) == ("h264", "aac", 720, 1280)
|
assert (v, a, w, h) == ("h264", "aac", 720, 1280)
|
||||||
assert mediakind.derive(v, a) == mediakind.VIDEO # NOT 'file'
|
assert mediakind.derive(v, a) == mediakind.VIDEO # NOT 'file'
|
||||||
|
|
||||||
|
def test_the_duration_comes_from_the_file_when_info_has_none(self, monkeypatch):
|
||||||
|
# Facebook reel 257 (E11.5): yt-dlp gave no duration, the file is 128.4 s. A NULL duration hid
|
||||||
|
# the Library row's trim action.
|
||||||
|
self._ffprobe(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"format": {"format_name": "mov,mp4,m4a", "duration": "128.400000"},
|
||||||
|
"streams": [{"codec_type": "video", "codec_name": "h264"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert probemod.resolve_media_facts(Path("x"), {"duration": None})[4] == 128
|
||||||
|
# The file wins over a differing info duration, like its codecs do.
|
||||||
|
assert probemod.resolve_media_facts(Path("x"), {"duration": 300})[4] == 128
|
||||||
|
|
||||||
|
def test_info_fills_the_duration_the_probe_could_not_read(self, monkeypatch):
|
||||||
|
self._ffprobe(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"format": {"format_name": "mov,mp4,m4a"},
|
||||||
|
"streams": [{"codec_type": "video", "codec_name": "h264"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert probemod.resolve_media_facts(Path("x"), {"duration": 61.7})[4] == 61
|
||||||
|
self._ffprobe(monkeypatch, None)
|
||||||
|
assert probemod.resolve_media_facts(Path("x"), {"duration": 61.7})[4] == 61
|
||||||
|
|
||||||
def test_probe_wins_over_a_DIFFERING_populated_info(self, monkeypatch):
|
def test_probe_wins_over_a_DIFFERING_populated_info(self, monkeypatch):
|
||||||
# Both the probe AND info are populated but DISAGREE (info carries a pre-remux/pre-merge codec).
|
# Both the probe AND info are populated but DISAGREE (info carries a pre-remux/pre-merge codec).
|
||||||
# The finished file on disk is ground truth, so the probe wins outright — pins the precedence a
|
# The finished file on disk is ground truth, so the probe wins outright — pins the precedence a
|
||||||
@@ -442,7 +468,7 @@ class TestResolveMediaFacts:
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
v, a, w, h = probemod.resolve_media_facts(
|
v, a, w, h, _d = probemod.resolve_media_facts(
|
||||||
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 640, "height": 480}
|
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 640, "height": 480}
|
||||||
)
|
)
|
||||||
assert (v, a, w, h) == ("h264", "aac", 1920, 1080) # probe, not info
|
assert (v, a, w, h) == ("h264", "aac", 1920, 1080) # probe, not info
|
||||||
@@ -466,7 +492,7 @@ class TestResolveMediaFacts:
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
v, a, w, h = probemod.resolve_media_facts(
|
v, a, w, h, _d = probemod.resolve_media_facts(
|
||||||
Path("x"), {"vcodec": "avc1", "acodec": "mp4a", "width": 1280, "height": 720}
|
Path("x"), {"vcodec": "avc1", "acodec": "mp4a", "width": 1280, "height": 720}
|
||||||
)
|
)
|
||||||
assert v is None and a == "mp3" # info's avc1 did NOT win
|
assert v is None and a == "mp3" # info's avc1 did NOT win
|
||||||
@@ -482,7 +508,7 @@ class TestResolveMediaFacts:
|
|||||||
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
|
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
v, a, w, h = probemod.resolve_media_facts(
|
v, a, w, h, _d = probemod.resolve_media_facts(
|
||||||
Path("x"), {"vcodec": "png", "width": 8, "height": 8}
|
Path("x"), {"vcodec": "png", "width": 8, "height": 8}
|
||||||
)
|
)
|
||||||
assert v is None and mediakind.derive(v, a) == mediakind.FILE
|
assert v is None and mediakind.derive(v, a) == mediakind.FILE
|
||||||
@@ -490,7 +516,7 @@ class TestResolveMediaFacts:
|
|||||||
def test_info_fills_in_only_when_ffprobe_could_not_read_the_file(self, monkeypatch):
|
def test_info_fills_in_only_when_ffprobe_could_not_read_the_file(self, monkeypatch):
|
||||||
# ffprobe failed entirely (None) → fall back to info so we never regress the working path.
|
# ffprobe failed entirely (None) → fall back to info so we never regress the working path.
|
||||||
self._ffprobe(monkeypatch, None)
|
self._ffprobe(monkeypatch, None)
|
||||||
v, a, w, h = probemod.resolve_media_facts(
|
v, a, w, h, _d = probemod.resolve_media_facts(
|
||||||
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 1920, "height": 1080}
|
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 1920, "height": 1080}
|
||||||
)
|
)
|
||||||
assert (v, a, w, h) == ("vp9", "opus", 1920, 1080)
|
assert (v, a, w, h) == ("vp9", "opus", 1920, 1080)
|
||||||
@@ -498,6 +524,8 @@ class TestResolveMediaFacts:
|
|||||||
def test_info_none_codec_string_is_treated_as_absent(self, monkeypatch):
|
def test_info_none_codec_string_is_treated_as_absent(self, monkeypatch):
|
||||||
# yt-dlp writes the literal "none" for a missing stream — it must not become a codec name.
|
# yt-dlp writes the literal "none" for a missing stream — it must not become a codec name.
|
||||||
self._ffprobe(monkeypatch, None) # probe failed → info path, where the "none" scrub applies
|
self._ffprobe(monkeypatch, None) # probe failed → info path, where the "none" scrub applies
|
||||||
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"vcodec": "none", "acodec": "aac"})
|
v, a, w, h, _d = probemod.resolve_media_facts(
|
||||||
|
Path("x"), {"vcodec": "none", "acodec": "aac"}
|
||||||
|
)
|
||||||
assert (v, a) == (None, "aac")
|
assert (v, a) == (None, "aac")
|
||||||
assert mediakind.derive(v, a) == mediakind.AUDIO
|
assert mediakind.derive(v, a) == mediakind.AUDIO
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ def rec(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(worker, "_reset_asset_pending", record("reset_asset"))
|
monkeypatch.setattr(worker, "_reset_asset_pending", record("reset_asset"))
|
||||||
monkeypatch.setattr(worker, "_requeue_if_running", record("requeue"))
|
monkeypatch.setattr(worker, "_requeue_if_running", record("requeue"))
|
||||||
monkeypatch.setattr(worker, "_set_job", record("set_job"))
|
monkeypatch.setattr(worker, "_fail_running", record("fail"))
|
||||||
monkeypatch.setattr(worker, "_cleanup_staging", record("cleanup"))
|
monkeypatch.setattr(worker, "_cleanup_staging", record("cleanup"))
|
||||||
monkeypatch.setattr(worker, "_stop_child", record("stop_child"))
|
monkeypatch.setattr(worker, "_stop_child", record("stop_child"))
|
||||||
monkeypatch.setattr(worker._stop, "is_set", lambda: False)
|
monkeypatch.setattr(worker._stop, "is_set", lambda: False)
|
||||||
@@ -84,9 +84,9 @@ def test_no_progress_stall_kills_and_fails_the_job(monkeypatch, rec):
|
|||||||
worker._run_supervised(1, 99)
|
worker._run_supervised(1, 99)
|
||||||
names = _names(rec)
|
names = _names(rec)
|
||||||
assert "stop_child" in names and "reset_asset" in names
|
assert "stop_child" in names and "reset_asset" in names
|
||||||
set_job = next(c for c in rec if c[0] == "set_job")
|
fail = next(c for c in rec if c[0] == "fail")
|
||||||
assert set_job[2]["status"] == "error" and set_job[2]["error_code"] == "network"
|
assert fail[2]["error_code"] == "network"
|
||||||
assert "stalled" in set_job[2]["error"].lower()
|
assert "stalled" in fail[2]["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
def test_route_pause_kills_but_keeps_the_route_status(monkeypatch, rec):
|
def test_route_pause_kills_but_keeps_the_route_status(monkeypatch, rec):
|
||||||
@@ -97,7 +97,7 @@ def test_route_pause_kills_but_keeps_the_route_status(monkeypatch, rec):
|
|||||||
worker._run_supervised(1, 99)
|
worker._run_supervised(1, 99)
|
||||||
names = _names(rec)
|
names = _names(rec)
|
||||||
assert "stop_child" in names and "reset_asset" in names
|
assert "stop_child" in names and "reset_asset" in names
|
||||||
assert "set_job" not in names and "requeue" not in names
|
assert "fail" not in names and "requeue" not in names
|
||||||
|
|
||||||
|
|
||||||
def test_route_cancel_kills_but_keeps_the_route_status(monkeypatch, rec):
|
def test_route_cancel_kills_but_keeps_the_route_status(monkeypatch, rec):
|
||||||
@@ -110,7 +110,7 @@ def test_route_cancel_kills_but_keeps_the_route_status(monkeypatch, rec):
|
|||||||
worker._run_supervised(1, 99)
|
worker._run_supervised(1, 99)
|
||||||
names = _names(rec)
|
names = _names(rec)
|
||||||
assert "stop_child" in names and "reset_asset" in names
|
assert "stop_child" in names and "reset_asset" in names
|
||||||
assert "set_job" not in names and "requeue" not in names
|
assert "fail" not in names and "requeue" not in names
|
||||||
|
|
||||||
|
|
||||||
def test_wall_clock_cap_kills_even_while_progressing(monkeypatch, rec):
|
def test_wall_clock_cap_kills_even_while_progressing(monkeypatch, rec):
|
||||||
@@ -118,9 +118,8 @@ def test_wall_clock_cap_kills_even_while_progressing(monkeypatch, rec):
|
|||||||
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
||||||
_liveness(monkeypatch, ("running", 1.0)) # fresh progress, but over the absolute cap
|
_liveness(monkeypatch, ("running", 1.0)) # fresh progress, but over the absolute cap
|
||||||
worker._run_supervised(1, 99)
|
worker._run_supervised(1, 99)
|
||||||
set_job = next(c for c in rec if c[0] == "set_job")
|
fail = next(c for c in rec if c[0] == "fail")
|
||||||
assert set_job[2]["status"] == "error"
|
assert "time limit" in fail[2]["error"].lower()
|
||||||
assert "time limit" in set_job[2]["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_child_crash_without_finishing_is_failed(monkeypatch, rec):
|
def test_child_crash_without_finishing_is_failed(monkeypatch, rec):
|
||||||
@@ -130,8 +129,8 @@ def test_child_crash_without_finishing_is_failed(monkeypatch, rec):
|
|||||||
worker._run_supervised(1, 99)
|
worker._run_supervised(1, 99)
|
||||||
names = _names(rec)
|
names = _names(rec)
|
||||||
assert "reset_asset" in names
|
assert "reset_asset" in names
|
||||||
set_job = next(c for c in rec if c[0] == "set_job")
|
fail = next(c for c in rec if c[0] == "fail")
|
||||||
assert set_job[2]["status"] == "error" and "unexpectedly" in set_job[2]["error"].lower()
|
assert "unexpectedly" in fail[2]["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
def test_child_nonzero_exit_that_already_set_state_is_left_alone(monkeypatch, rec):
|
def test_child_nonzero_exit_that_already_set_state_is_left_alone(monkeypatch, rec):
|
||||||
@@ -139,7 +138,7 @@ def test_child_nonzero_exit_that_already_set_state_is_left_alone(monkeypatch, re
|
|||||||
_spawn(monkeypatch, _FakeProc([("exit", 1)]))
|
_spawn(monkeypatch, _FakeProc([("exit", 1)]))
|
||||||
_liveness(monkeypatch, ("error", None))
|
_liveness(monkeypatch, ("error", None))
|
||||||
worker._run_supervised(1, 99)
|
worker._run_supervised(1, 99)
|
||||||
assert "set_job" not in _names(rec)
|
assert "fail" not in _names(rec)
|
||||||
|
|
||||||
|
|
||||||
def test_spawn_failure_frees_the_asset_and_requeues(monkeypatch, rec):
|
def test_spawn_failure_frees_the_asset_and_requeues(monkeypatch, rec):
|
||||||
@@ -158,7 +157,7 @@ def test_shutdown_kills_and_requeues_for_next_boot(monkeypatch, rec):
|
|||||||
_liveness(monkeypatch, ("running", 1.0))
|
_liveness(monkeypatch, ("running", 1.0))
|
||||||
worker._run_supervised(1, 99)
|
worker._run_supervised(1, 99)
|
||||||
names = _names(rec)
|
names = _names(rec)
|
||||||
assert "stop_child" in names and "requeue" in names and "set_job" not in names
|
assert "stop_child" in names and "requeue" in names and "fail" not in names
|
||||||
|
|
||||||
|
|
||||||
def test_a_long_download_survives_while_progress_stays_fresh(monkeypatch, rec):
|
def test_a_long_download_survives_while_progress_stays_fresh(monkeypatch, rec):
|
||||||
@@ -191,7 +190,7 @@ def test_a_wedged_finished_child_is_killed_by_the_wall_clock(monkeypatch, rec):
|
|||||||
_liveness(monkeypatch, ("done", 1.0)) # finished, but the process lingers
|
_liveness(monkeypatch, ("done", 1.0)) # finished, but the process lingers
|
||||||
worker._run_supervised(1, 99)
|
worker._run_supervised(1, 99)
|
||||||
names = _names(rec)
|
names = _names(rec)
|
||||||
assert "stop_child" in names and "set_job" not in names # killed to free the slot, status kept
|
assert "stop_child" in names and "fail" not in names # killed to free the slot, status kept
|
||||||
|
|
||||||
|
|
||||||
def test_a_watchdog_error_still_stops_the_child(monkeypatch, rec):
|
def test_a_watchdog_error_still_stops_the_child(monkeypatch, rec):
|
||||||
@@ -206,7 +205,7 @@ def test_a_watchdog_error_still_stops_the_child(monkeypatch, rec):
|
|||||||
worker._run_supervised(1, 99)
|
worker._run_supervised(1, 99)
|
||||||
names = _names(rec)
|
names = _names(rec)
|
||||||
assert "stop_child" in names and "reset_asset" in names
|
assert "stop_child" in names and "reset_asset" in names
|
||||||
assert next(c for c in rec if c[0] == "set_job")[2]["status"] == "error"
|
assert "fail" in _names(rec)
|
||||||
|
|
||||||
|
|
||||||
# --- _stop_child escalation -----------------------------------------------------------------
|
# --- _stop_child escalation -----------------------------------------------------------------
|
||||||
|
|||||||
@@ -109,5 +109,27 @@ export default tseslint.config(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// E11 S3: the same one-way rule one layer down. `lib/` is what components are built ON (hooks,
|
||||||
|
// the API slices, pure helpers), so it may not reach back UP into `components/` — not even for a
|
||||||
|
// type. That is how the `Column` contract came to be owned by a ui component that three lib
|
||||||
|
// helpers had to import from. A piece both layers need moves down into `lib/`.
|
||||||
|
files: ["src/lib/**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
// Any depth (`lib/api/*` is one level deeper), and `(/|$)` so a bare barrel counts too.
|
||||||
|
regex: String.raw`^(\.\./)+components(/|$)`,
|
||||||
|
message:
|
||||||
|
"lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
prettier,
|
prettier,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
// The global Shift+W wiring, pinned at its call site.
|
|
||||||
//
|
|
||||||
// What this is: a source contract, the same idiom as uiBoundary.contract.test.ts — a structural rule
|
|
||||||
// asserted directly rather than left to prose. What it is NOT: a behaviour test. The behaviour lives
|
|
||||||
// in history.dom.test.ts and history.burial.dom.test.ts, which drive `navigateOverOverlays` against
|
|
||||||
// real overlay entries; the end-to-end gesture (Shift+W over an open player, then Back) is a browser
|
|
||||||
// check, because jsdom reproduces history ordering only as bookkeeping.
|
|
||||||
//
|
|
||||||
// It exists because the composition line itself was unpinned: App.tsx is the one module no test
|
|
||||||
// renders (main.tsx lazy-imports it and nothing else does), globalKeymap.dom.test.tsx drives the
|
|
||||||
// hook with its OWN handler, and history.dom.test.ts drives the helper with its own navigate — so
|
|
||||||
// dropping `navigateOverOverlays` from App left all 818 tests green while the user-visible bug came
|
|
||||||
// straight back (round 2, verified by running that revert).
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const APP = readFileSync(fileURLToPath(new URL("./App.tsx", import.meta.url)), "utf8");
|
|
||||||
|
|
||||||
describe("App · global keymap wiring", () => {
|
|
||||||
it("routes the global home shortcut through navigateOverOverlays", () => {
|
|
||||||
const call = /useGlobalKeymap\(([\s\S]*?)\);/.exec(APP)?.[1];
|
|
||||||
// A rename or a move is a legitimate change — but then this test is the thing that has to be
|
|
||||||
// updated deliberately, which is the whole point of pinning it.
|
|
||||||
expect(
|
|
||||||
call,
|
|
||||||
"App.tsx no longer calls useGlobalKeymap in a shape this test can read",
|
|
||||||
).toBeTypeOf("string");
|
|
||||||
expect(call).toContain("navigateOverOverlays");
|
|
||||||
expect(call).toContain('setPage("home")');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+22
-13
@@ -3,6 +3,7 @@ import { qk } from "./lib/queryKeys";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
||||||
|
import { notifyActionError } from "./lib/actionErrors";
|
||||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||||
import { clearParams } from "./lib/urlState";
|
import { clearParams } from "./lib/urlState";
|
||||||
import type { Page } from "./lib/urlState";
|
import type { Page } from "./lib/urlState";
|
||||||
@@ -22,7 +23,7 @@ import { useNavigation, useNavigationActions } from "./components/NavigationProv
|
|||||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||||
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
||||||
import { moduleDef } from "./lib/modules";
|
import { moduleDef, moduleOrder } from "./lib/modules";
|
||||||
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
|
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
|
||||||
// Persistent shell — always mounted, so eagerly imported.
|
// Persistent shell — always mounted, so eagerly imported.
|
||||||
import Welcome from "./components/Welcome";
|
import Welcome from "./components/Welcome";
|
||||||
@@ -37,7 +38,6 @@ import Toaster from "./components/ui/Toaster";
|
|||||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||||
import AppCommandPalette from "./components/AppCommandPalette";
|
import AppCommandPalette from "./components/AppCommandPalette";
|
||||||
import { useGlobalKeymap } from "./lib/keymap";
|
import { useGlobalKeymap } from "./lib/keymap";
|
||||||
import { navigateOverOverlays } from "./lib/history";
|
|
||||||
import { StateMessage } from "./components/ui/QueryState";
|
import { StateMessage } from "./components/ui/QueryState";
|
||||||
import ErrorDialog from "./components/ErrorDialog";
|
import ErrorDialog from "./components/ErrorDialog";
|
||||||
import VersionBanner from "./components/VersionBanner";
|
import VersionBanner from "./components/VersionBanner";
|
||||||
@@ -98,7 +98,9 @@ export default function App() {
|
|||||||
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
||||||
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
||||||
saveLayoutLocal(panel, next);
|
saveLayoutLocal(panel, next);
|
||||||
api.savePrefs({ [PREF_KEY[panel]]: next }).catch(() => {});
|
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
|
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||||
@@ -142,13 +144,16 @@ export default function App() {
|
|||||||
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
// 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
|
// (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.
|
// above the signed-out branch is worse than not binding it.
|
||||||
// Through `navigateOverOverlays`, because a global key can fire while an overlay is open and does
|
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
|
||||||
// not know it: the page change unmounts the player (it lives inside the module), whose reconcile
|
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
|
||||||
// then reclaimed the entry this push had just made — Shift+W over a playing video closed the
|
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
||||||
// player and snapped straight back to the feed. `setPage`'s boolean is load-bearing here, not
|
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
|
||||||
// decoration: pressed while ALREADY on Home (the default landing page) it navigates nowhere, and
|
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
||||||
// burying the open player's entry under a push that never happened orphaned it — see `buryUnder`.
|
useGlobalKeymap(
|
||||||
useGlobalKeymap(!!meQuery.data, () => navigateOverOverlays(() => setPage("home")));
|
!!meQuery.data,
|
||||||
|
setPage,
|
||||||
|
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
|
||||||
|
);
|
||||||
|
|
||||||
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
|
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
|
||||||
// an account (it was riding the session's default) would silently swap identity when ANOTHER
|
// an account (it was riding the session's default) would silently swap identity when ANOTHER
|
||||||
@@ -279,8 +284,8 @@ export default function App() {
|
|||||||
label: t("common.accessRequestsReview"),
|
label: t("common.accessRequestsReview"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
focusAccessRequestsTab();
|
focusAccessRequestsTab();
|
||||||
// Over whatever overlay the toast was clicked in — as navigateTo does (see lib/nav).
|
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
|
||||||
navigateOverOverlays(() => setPage("users"));
|
setPage("users");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -291,7 +296,11 @@ export default function App() {
|
|||||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||||
function changeLanguage(code: LangCode) {
|
function changeLanguage(code: LangCode) {
|
||||||
setLanguage(code);
|
setLanguage(code);
|
||||||
api.savePrefs({ language: code }).catch(() => {});
|
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
|
||||||
|
// the reporter stays quiet there.
|
||||||
|
api
|
||||||
|
.savePrefs({ language: 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.
|
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
|||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60 focus-visible:outline-offset-2"
|
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||||
>
|
>
|
||||||
{preview}
|
{preview}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||||
|
|
||||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||||
@@ -66,25 +67,19 @@ export default function AccountPanel({
|
|||||||
const issue = useMutation({
|
const issue = useMutation({
|
||||||
mutationFn: () => api.createSetPasswordLink(user.id),
|
mutationFn: () => api.createSetPasswordLink(user.id),
|
||||||
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
|
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
|
||||||
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
|
onError: (err) => notifyActionError(err, t("users.account.linkFailed")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resend = useMutation({
|
const resend = useMutation({
|
||||||
mutationFn: () => api.resendUserVerification(user.id),
|
mutationFn: () => api.resendUserVerification(user.id),
|
||||||
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
||||||
onError: (err) =>
|
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
|
||||||
notify({
|
|
||||||
level: "error",
|
|
||||||
message: isEmailOff(err)
|
|
||||||
? t("users.pendingAccess.resendEmailOff")
|
|
||||||
: t("users.pendingAccess.resendFailed"),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const activate = useMutation({
|
const activate = useMutation({
|
||||||
mutationFn: () => api.activateUser(user.id),
|
mutationFn: () => api.activateUser(user.id),
|
||||||
onSuccess: () => invalidateUserAccess(qc, user.id),
|
onSuccess: () => invalidateUserAccess(qc, user.id),
|
||||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
@@ -201,7 +196,9 @@ export default function AccountPanel({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => resend.mutate()}
|
onClick={() => resend.mutate()}
|
||||||
disabled={!a.email_configured || a.email_verified || resend.isPending}
|
// The demo account's address is never confirmed, and the server refuses to mail it — as
|
||||||
|
// it refuses it a set-password link below (deep-review E11 round 3).
|
||||||
|
disabled={user.is_demo || !a.email_configured || a.email_verified || resend.isPending}
|
||||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4 shrink-0" />
|
<Send className="w-4 h-4 shrink-0" />
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ describe("a user action that fails", () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(notify).toHaveBeenCalledWith({
|
expect(notify).toHaveBeenCalledWith({
|
||||||
level: "error",
|
level: "error",
|
||||||
message: i18n.t("errors.notFound.user"), // the request layer localizes a structured detail
|
title: "users.roles.failed", // what failed…
|
||||||
|
message: i18n.t("errors.notFound.user"), // …and why: the request layer localizes a structured detail
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
type PendingAccessRow,
|
type PendingAccessRow,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
@@ -38,7 +38,7 @@ import { useConfirm } from "./ConfirmProvider";
|
|||||||
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||||
import { PageToolbar } from "./ui/PageShell";
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
||||||
|
|
||||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||||
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
||||||
@@ -319,12 +319,6 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Did the invitation fail because this instance has no working SMTP? */
|
|
||||||
function inviteEmailOff(err: HttpError): boolean {
|
|
||||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
|
||||||
return d?.code === "invite_send" && d.reason === "email_off";
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
|
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
|
||||||
//
|
//
|
||||||
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
|
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
|
||||||
@@ -351,7 +345,7 @@ function PendingAccess() {
|
|||||||
refresh();
|
refresh();
|
||||||
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
|
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
|
||||||
},
|
},
|
||||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resend = useMutation({
|
const resend = useMutation({
|
||||||
@@ -360,15 +354,9 @@ function PendingAccess() {
|
|||||||
refresh();
|
refresh();
|
||||||
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
|
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
|
||||||
},
|
},
|
||||||
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
|
// The public resend is deliberately silent; this one is not, and the server's reason says WHY
|
||||||
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
|
// (no working SMTP, a refused send) — a success toast for a mail never sent would be the lie.
|
||||||
onError: (err) =>
|
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
|
||||||
notify({
|
|
||||||
level: "error",
|
|
||||||
message: isEmailOff(err)
|
|
||||||
? t("users.pendingAccess.resendEmailOff")
|
|
||||||
: t("users.pendingAccess.resendFailed"),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = rows.data ?? [];
|
const list = rows.data ?? [];
|
||||||
@@ -563,12 +551,8 @@ function AdminInvites() {
|
|||||||
// A 409 means the list was stale — someone registered on that address since it loaded. Say
|
// A 409 means the list was stale — someone registered on that address since it loaded. Say
|
||||||
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
|
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
|
||||||
const conflict = e instanceof HttpError && e.status === 409;
|
const conflict = e instanceof HttpError && e.status === 409;
|
||||||
notify({
|
if (conflict) notifyKnownFailure(e, t("settings.invites.deleteHasAccount"));
|
||||||
level: "error",
|
else notifyActionError(e, t("settings.invites.deleteFailed"));
|
||||||
message: t(
|
|
||||||
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (conflict) refresh();
|
if (conflict) refresh();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -598,14 +582,7 @@ function AdminInvites() {
|
|||||||
refresh();
|
refresh();
|
||||||
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
|
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) => notifyActionError(err, t("settings.invites.inviteFailed")),
|
||||||
notify({
|
|
||||||
level: "error",
|
|
||||||
message:
|
|
||||||
err instanceof HttpError && inviteEmailOff(err)
|
|
||||||
? t("settings.invites.inviteEmailOff")
|
|
||||||
: t("settings.invites.inviteFailed"),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
// The one recipient the app has no language signal about: the admin typed this address in, so
|
// The one recipient the app has no language signal about: the admin typed this address in, so
|
||||||
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
|
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
|
||||||
|
|||||||
@@ -108,9 +108,13 @@ export default function ChannelDiscovery({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSubscribe(c)}
|
// Busy blocks the click, not the focus: a focused button that turns natively `disabled`
|
||||||
disabled={!canWrite || subscribe.isPending}
|
// drops the keyboard to <body> (the rule and its measurement: ui/Button).
|
||||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
|
onClick={() => !subscribe.isPending && onSubscribe(c)}
|
||||||
|
disabled={!canWrite}
|
||||||
|
aria-disabled={subscribe.isPending || undefined}
|
||||||
|
aria-busy={subscribe.isPending || undefined}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
|
||||||
>
|
>
|
||||||
<UserPlus className="w-3.5 h-3.5" />
|
<UserPlus className="w-3.5 h-3.5" />
|
||||||
{t("channels.discovery.subscribe")}
|
{t("channels.discovery.subscribe")}
|
||||||
@@ -238,6 +242,7 @@ export default function ChannelDiscovery({
|
|||||||
sort={sort}
|
sort={sort}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
emptyText={t("channels.discovery.empty")}
|
emptyText={t("channels.discovery.empty")}
|
||||||
|
keyboardNav
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChannelLayoutGrid
|
<ChannelLayoutGrid
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The channel manager's card layout as a keyboard list (E11 S4c): each card is an item, and Enter opens
|
||||||
|
// the channel through the name ChannelLink renders. VirtualGrid is replaced by a stand-in that mounts
|
||||||
|
// every item — jsdom has no layout for the virtualizer to measure — and registers with listNav exactly
|
||||||
|
// when the real one would: when it is asked to (`keyboardNav`).
|
||||||
|
import { Fragment, useRef } from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render } from "@testing-library/react";
|
||||||
|
import type { VirtualGridProps } from "./ui/VirtualGrid";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
vi.mock("./ui/VirtualGrid", async () => {
|
||||||
|
const { useListNav } = await import("../lib/listNav");
|
||||||
|
return {
|
||||||
|
default: function GridStandIn({
|
||||||
|
items,
|
||||||
|
getKey,
|
||||||
|
renderItem,
|
||||||
|
keyboardNav,
|
||||||
|
}: VirtualGridProps<unknown>) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useListNav(ref, { enabled: !!keyboardNav });
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<Fragment key={getKey(item)}>{renderItem(item, 0, i)}</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||||
|
import ChannelLink from "./ChannelLink";
|
||||||
|
import { type Column } from "./ui/DataTable";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type Ch = { id: string; title: string };
|
||||||
|
const rows: Ch[] = [
|
||||||
|
{ id: "UCa", title: "Alpha" },
|
||||||
|
{ id: "UCb", title: "Beta" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function press(key: string) {
|
||||||
|
act(() => {
|
||||||
|
(document.activeElement ?? document.body).dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusedTitle = () =>
|
||||||
|
(document.activeElement as HTMLElement).querySelector("[data-nav-open]")?.textContent;
|
||||||
|
|
||||||
|
describe("ChannelLayoutGrid · keyboard", () => {
|
||||||
|
it("walks the cards with j/k and opens the channel on Enter", () => {
|
||||||
|
const opened: string[] = [];
|
||||||
|
const columns: Column<Ch>[] = [
|
||||||
|
{
|
||||||
|
key: "channel",
|
||||||
|
header: "Channel",
|
||||||
|
cardPrimary: true,
|
||||||
|
render: (c) => (
|
||||||
|
<ChannelLink
|
||||||
|
id={c.id}
|
||||||
|
title={c.title}
|
||||||
|
thumbnailUrl={null}
|
||||||
|
onView={() => opened.push(c.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} />);
|
||||||
|
press("j");
|
||||||
|
expect(focusedTitle()).toBe("Alpha");
|
||||||
|
press("j");
|
||||||
|
expect(focusedTitle()).toBe("Beta");
|
||||||
|
press("Enter");
|
||||||
|
expect(opened).toEqual(["UCb"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { type Column } from "./ui/DataTable";
|
import { type Column } from "./ui/DataTable";
|
||||||
import VirtualGrid from "./ui/VirtualGrid";
|
import VirtualGrid from "./ui/VirtualGrid";
|
||||||
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||||
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
|
||||||
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
||||||
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
||||||
@@ -12,6 +13,9 @@ import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
|||||||
// Named slots place the columns that need bespoke positions; everything else flows in the meta line.
|
// Named slots place the columns that need bespoke positions; everything else flows in the meta line.
|
||||||
// Unlike DataTable's cramped narrow-screen card fallback, the desktop card is roomy, so it shows the
|
// Unlike DataTable's cramped narrow-screen card fallback, the desktop card is roomy, so it shows the
|
||||||
// `hideInCard` columns too (About, priority) — the user wants those affordances in every layout.
|
// `hideInCard` columns too (About, priority) — the user wants those affordances in every layout.
|
||||||
|
//
|
||||||
|
// Each card is a keyboard item (lib/listNav): `j`/`k` and the arrows walk the cards, and Enter opens
|
||||||
|
// the channel — the name ChannelLink renders is the card's open element.
|
||||||
|
|
||||||
interface ChannelLayoutGridProps<T> {
|
interface ChannelLayoutGridProps<T> {
|
||||||
rows: T[];
|
rows: T[];
|
||||||
@@ -52,9 +56,10 @@ export default function ChannelLayoutGrid<T>({
|
|||||||
// Everything else flows in the meta line: not the heading, not a slotted column.
|
// Everything else flows in the meta line: not the heading, not a slotted column.
|
||||||
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
||||||
|
|
||||||
const renderCard = (row: T) => (
|
const renderCard = (row: T, index: number) => (
|
||||||
<div
|
<div
|
||||||
{...rowProps?.(row)}
|
{...rowProps?.(row)}
|
||||||
|
{...navItemAttrs(index)}
|
||||||
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -91,7 +96,8 @@ export default function ChannelLayoutGrid<T>({
|
|||||||
minCol={spec.minCol}
|
minCol={spec.minCol}
|
||||||
colMaxWidth={null}
|
colMaxWidth={null}
|
||||||
rowEst={spec.rowEst}
|
rowEst={spec.rowEst}
|
||||||
renderItem={renderCard}
|
keyboardNav
|
||||||
|
renderItem={(row, _itemWidth, index) => renderCard(row, index)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export default function ChannelLink({
|
|||||||
{onView ? (
|
{onView ? (
|
||||||
<button
|
<button
|
||||||
onClick={onView}
|
onClick={onView}
|
||||||
|
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
|
||||||
|
// card clicks this.
|
||||||
|
data-nav-open=""
|
||||||
onAuxClick={(e) => {
|
onAuxClick={(e) => {
|
||||||
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps
|
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps
|
||||||
// opening the in-app channel detail.
|
// opening the in-app channel detail.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { urlWithParams } from "../lib/urlState";
|
|||||||
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
|
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
|
||||||
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
|
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
|
||||||
// the SPA path does more than navigate (the player has to close first and reclaim its own
|
// the SPA path does more than navigate (the player has to close first and reclaim its own
|
||||||
// Back-to-close history entry; see closeThenNavigate).
|
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
|
||||||
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
|
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
title: string | null | undefined;
|
title: string | null | undefined;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The channel page's Block and Hide toggles (deep-review E11 round 2) and its Subscribe / Unsubscribe
|
||||||
|
// button (round 3: the same row, missed) while their request runs. A
|
||||||
|
// focused button that turns natively `disabled` drops the keyboard to <body> in Chrome (measured for
|
||||||
|
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
|
||||||
|
// still pressed only once.
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
vi.mock("./NavigationProvider", () => ({
|
||||||
|
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||||
|
vi.mock("./FeedFiltersProvider", () => ({
|
||||||
|
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("./Feed", () => ({ default: () => null }));
|
||||||
|
vi.mock("./ChannelAbout", () => ({ default: () => null }));
|
||||||
|
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
||||||
|
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
||||||
|
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ChannelPage from "./ChannelPage";
|
||||||
|
import { api, type ChannelDetail, type Me } from "../lib/api";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
||||||
|
|
||||||
|
describe("ChannelPage — a busy toggle keeps the keyboard", () => {
|
||||||
|
it.each([
|
||||||
|
["channel.block", "blockChannel", true],
|
||||||
|
["channel.hide", "updateChannel", true],
|
||||||
|
["channel.subscribe", "subscribeChannel", false],
|
||||||
|
["channel.subscribedState", "unsubscribeChannel", true],
|
||||||
|
] as const)(
|
||||||
|
"%s stays focusable and fires once while its request runs",
|
||||||
|
async (label, call, subscribed) => {
|
||||||
|
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||||
|
id: CH,
|
||||||
|
title: "LEMMiNO",
|
||||||
|
subscribed,
|
||||||
|
blocked: false,
|
||||||
|
hidden: false,
|
||||||
|
deep_requested: false,
|
||||||
|
backfill_done: true,
|
||||||
|
stored_videos: 10,
|
||||||
|
} as unknown as ChannelDetail);
|
||||||
|
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||||
|
const pending = vi.spyOn(api, call).mockReturnValue(new Promise(() => {}) as never);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider
|
||||||
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
|
>
|
||||||
|
<ChannelPage
|
||||||
|
channelId={CH}
|
||||||
|
initialName="LEMMiNO"
|
||||||
|
me={{ id: 1, can_write: true } as unknown as Me}
|
||||||
|
view="cards"
|
||||||
|
setView={() => {}}
|
||||||
|
onShowInFeed={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
const button = await screen.findByRole("button", { name: label });
|
||||||
|
act(() => button.focus());
|
||||||
|
fireEvent.click(button);
|
||||||
|
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
|
||||||
|
expect(button.hasAttribute("disabled")).toBe(false);
|
||||||
|
expect(document.activeElement).toBe(button);
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(pending).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
|
|||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
||||||
import type { FeedView } from "../lib/feedView";
|
import type { FeedView } from "../lib/feedView";
|
||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||||
@@ -106,18 +107,23 @@ export default function ChannelPage({
|
|||||||
const runExplore = useCallback(
|
const runExplore = useCallback(
|
||||||
(token?: string | null) => {
|
(token?: string | null) => {
|
||||||
setExploring(true);
|
setExploring(true);
|
||||||
return api
|
return (
|
||||||
.exploreChannel(channelId, token ?? undefined)
|
api
|
||||||
.then((r) => {
|
.exploreChannel(channelId, token ?? undefined)
|
||||||
setNextToken(r.next_page_token);
|
.then((r) => {
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
setNextToken(r.next_page_token);
|
||||||
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
// first visit (the GET that drove this render predated the explore that just ran).
|
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
|
||||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
// first visit (the GET that drove this render predated the explore that just ran).
|
||||||
})
|
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||||
.finally(() => setExploring(false));
|
})
|
||||||
|
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
|
||||||
|
// channel page that looked like a channel with no videos (E11 S2b).
|
||||||
|
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
|
||||||
|
.finally(() => setExploring(false))
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[channelId, qc],
|
[channelId, qc, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
|
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
|
||||||
@@ -129,7 +135,7 @@ export default function ChannelPage({
|
|||||||
if (ch.subscribed || ch.blocked) return;
|
if (ch.subscribed || ch.blocked) return;
|
||||||
if (ch.explored && ch.stored_videos > 0) return;
|
if (ch.explored && ch.stored_videos > 0) return;
|
||||||
autoTried.current = channelId;
|
autoTried.current = channelId;
|
||||||
runExplore().catch(() => {});
|
void runExplore();
|
||||||
}, [ch, channelId, me.is_demo, runExplore]);
|
}, [ch, channelId, me.is_demo, runExplore]);
|
||||||
|
|
||||||
const block = useMutation({
|
const block = useMutation({
|
||||||
@@ -139,15 +145,16 @@ export default function ChannelPage({
|
|||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
||||||
},
|
},
|
||||||
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case),
|
// No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide
|
||||||
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) —
|
// reporter names the failure the error dialog does not cover (a 404 for a channel gone).
|
||||||
// a caller toast here would just double it.
|
meta: { failTitle: "errors.writeFailed.blockChannel" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}).
|
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}).
|
||||||
// Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
|
// Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
|
||||||
const toggleHidden = useMutation({
|
const toggleHidden = useMutation({
|
||||||
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
|
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
|
||||||
|
meta: { failTitle: "errors.writeFailed.hideChannel" },
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
@@ -472,14 +479,18 @@ export default function ChannelPage({
|
|||||||
)}
|
)}
|
||||||
{!me.is_demo && (
|
{!me.is_demo && (
|
||||||
<button
|
<button
|
||||||
onClick={() => block.mutate()}
|
// Busy blocks the click, not the focus: a focused button that turns natively
|
||||||
disabled={block.isPending}
|
// `disabled` drops the keyboard to <body> (the rule and its measurement: ui/Button).
|
||||||
|
// Nor does a busy toggle light up on hover, as if a click would do something.
|
||||||
|
onClick={() => !block.isPending && block.mutate()}
|
||||||
|
aria-disabled={block.isPending || undefined}
|
||||||
|
aria-busy={block.isPending || undefined}
|
||||||
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||||
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||||
ch?.blocked
|
ch?.blocked
|
||||||
? "border-danger text-danger"
|
? "border-danger text-danger"
|
||||||
: "border-border text-muted hover:text-danger hover:border-danger"
|
: "border-border text-muted not-aria-disabled:hover:text-danger not-aria-disabled:hover:border-danger"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Ban className="w-4 h-4" />
|
<Ban className="w-4 h-4" />
|
||||||
@@ -490,14 +501,15 @@ export default function ChannelPage({
|
|||||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||||
{ch?.subscribed && !me.is_demo && (
|
{ch?.subscribed && !me.is_demo && (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleHidden.mutate()}
|
onClick={() => !toggleHidden.isPending && toggleHidden.mutate()}
|
||||||
disabled={toggleHidden.isPending}
|
aria-disabled={toggleHidden.isPending || undefined}
|
||||||
|
aria-busy={toggleHidden.isPending || undefined}
|
||||||
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
||||||
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||||
ch.hidden
|
ch.hidden
|
||||||
? "border-accent text-accent"
|
? "border-accent text-accent"
|
||||||
: "border-border text-muted hover:text-fg hover:border-fg/40"
|
: "border-border text-muted not-aria-disabled:hover:text-fg not-aria-disabled:hover:border-fg/40"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
@@ -509,7 +521,7 @@ export default function ChannelPage({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onUnsubscribe}
|
onClick={onUnsubscribe}
|
||||||
disabled={unsubscribe.isPending}
|
loading={unsubscribe.isPending}
|
||||||
title={t("channel.unsubscribe")}
|
title={t("channel.unsubscribe")}
|
||||||
icon={<Check className="w-4 h-4" />}
|
icon={<Check className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
@@ -519,7 +531,7 @@ export default function ChannelPage({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => subscribe.mutate()}
|
onClick={() => subscribe.mutate()}
|
||||||
disabled={subscribe.isPending}
|
loading={subscribe.isPending}
|
||||||
icon={<Plus className="w-4 h-4" />}
|
icon={<Plus className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
{t("channel.subscribe")}
|
{t("channel.subscribe")}
|
||||||
@@ -566,9 +578,11 @@ export default function ChannelPage({
|
|||||||
{!ch?.subscribed && nextToken && (
|
{!ch?.subscribed && nextToken && (
|
||||||
<div className="flex justify-center pb-6">
|
<div className="flex justify-center pb-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => runExplore(nextToken)}
|
// Busy blocks the click, not the focus (see Block above).
|
||||||
disabled={exploring}
|
onClick={() => !exploring && runExplore(nextToken)}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
|
aria-disabled={exploring || undefined}
|
||||||
|
aria-busy={exploring || undefined}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border not-aria-disabled:hover:bg-surface aria-disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{exploring ? (
|
{exploring ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } fr
|
|||||||
import ExcludedChannelList from "./ExcludedChannelList";
|
import ExcludedChannelList from "./ExcludedChannelList";
|
||||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||||
@@ -32,7 +31,7 @@ import ChannelStatsPopover from "./ChannelStatsPopover";
|
|||||||
import Pager from "./ui/Pager";
|
import Pager from "./ui/Pager";
|
||||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||||
import { sortRows } from "../lib/columnSort";
|
import { sortRows } from "../lib/columnSort";
|
||||||
import { filterRows } from "./ui/tableHeader";
|
import { filterRows } from "../lib/tableColumns";
|
||||||
import { useCardPager } from "../lib/useCardPager";
|
import { useCardPager } from "../lib/useCardPager";
|
||||||
import { PageToolbar } from "./ui/PageShell";
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
import ChannelDiscovery from "./ChannelDiscovery";
|
import ChannelDiscovery from "./ChannelDiscovery";
|
||||||
@@ -110,6 +109,7 @@ export default function Channels() {
|
|||||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||||
const unblock = useMutation({
|
const unblock = useMutation({
|
||||||
mutationFn: (id: string) => api.unblockChannel(id),
|
mutationFn: (id: string) => api.unblockChannel(id),
|
||||||
|
meta: { failTitle: "errors.writeFailed.unblockChannel" },
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
@@ -133,6 +133,7 @@ export default function Channels() {
|
|||||||
id: string;
|
id: string;
|
||||||
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
|
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
|
||||||
}) => api.updateChannel(v.id, v.body),
|
}) => api.updateChannel(v.id, v.body),
|
||||||
|
meta: { failTitle: "errors.writeFailed.channelSettings" },
|
||||||
// Requesting full history may have just pulled in a channel's recent uploads, so
|
// Requesting full history may have just pulled in a channel's recent uploads, so
|
||||||
// refresh the per-user status and feed too — not only the channel list.
|
// refresh the per-user status and feed too — not only the channel list.
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -182,11 +183,10 @@ export default function Channels() {
|
|||||||
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
|
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
|
||||||
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
|
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
|
||||||
// failure the tag never actually attached — so the server still suggests it and the ghost chips
|
// failure the tag never actually attached — so the server still suggests it and the ghost chips
|
||||||
// must come back rather than leaving an empty cell (honest states). Surface the write failure too.
|
// must come back rather than leaving an empty cell (honest states). The hook reports the failure.
|
||||||
onError: (e) => {
|
onError: () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||||
notifyActionError(e, t("channels.tags.updateFailed"));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
|
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||||
@@ -623,6 +623,7 @@ export default function Channels() {
|
|||||||
fixedLayout
|
fixedLayout
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
rowProps={pinMenu.triggerProps}
|
rowProps={pinMenu.triggerProps}
|
||||||
|
keyboardNav
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChannelLayoutGrid
|
<ChannelLayoutGrid
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useSortState } from "../lib/useSortState";
|
|||||||
import { useFilterState } from "../lib/useFilterState";
|
import { useFilterState } from "../lib/useFilterState";
|
||||||
import { useActiveAccountId } from "../lib/activeAccount";
|
import { useActiveAccountId } from "../lib/activeAccount";
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
|
import { rememberNavPosition } from "../lib/history";
|
||||||
import {
|
import {
|
||||||
channelsParamsToState,
|
channelsParamsToState,
|
||||||
channelsParamUpdates,
|
channelsParamUpdates,
|
||||||
@@ -23,7 +24,7 @@ import {
|
|||||||
type ChannelsUrlState,
|
type ChannelsUrlState,
|
||||||
} from "../lib/channelsUrlState";
|
} from "../lib/channelsUrlState";
|
||||||
import { usePersistedTab } from "./ui/Tabs";
|
import { usePersistedTab } from "./ui/Tabs";
|
||||||
import type { FilterMap } from "./ui/tableHeader";
|
import type { FilterMap } from "../lib/tableColumns";
|
||||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
import type { ChannelsView } from "./Channels";
|
import type { ChannelsView } from "./Channels";
|
||||||
|
|
||||||
@@ -243,6 +244,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
if (window.history.state?.sfPage === "channels") {
|
if (window.history.state?.sfPage === "channels") {
|
||||||
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
|
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
|
||||||
// Back re-open the channel overlay or a spent search on top of the tab.
|
// Back re-open the channel overlay or a spent search on top of the tab.
|
||||||
|
rememberNavPosition();
|
||||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ const GROUP_ORDER = [
|
|||||||
"backfill",
|
"backfill",
|
||||||
"shorts",
|
"shorts",
|
||||||
"batch",
|
"batch",
|
||||||
|
"maintenance",
|
||||||
"downloads",
|
"downloads",
|
||||||
"audit",
|
"audit",
|
||||||
"plex",
|
"plex",
|
||||||
|
"tmdb",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ConfigPanel() {
|
export default function ConfigPanel() {
|
||||||
@@ -111,9 +113,9 @@ export default function ConfigPanel() {
|
|||||||
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
|
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
|
||||||
setSaveState("saved");
|
setSaveState("saved");
|
||||||
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
||||||
} catch {
|
} catch (e) {
|
||||||
setSaveState("error");
|
setSaveState("error");
|
||||||
notify({ level: "error", message: t("config.saveFailed") });
|
notifyActionError(e, t("config.saveFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The Download Center's confirm dialogs name their ACTION on the confirm button (E11.5): the shared
|
||||||
|
// `confirmThen` once labelled it with the dialog's question — "Re-download this file?" on the button,
|
||||||
|
// and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys.
|
||||||
|
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 type { DownloadJob } from "../lib/api";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
jobs: [] as unknown[],
|
||||||
|
confirm: vi.fn(async (_opts: { title: string; confirmLabel?: string }) => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => mocks.confirm }));
|
||||||
|
vi.mock("../lib/useLiveQuery", () => ({
|
||||||
|
useLiveQuery: (key: readonly unknown[]) => ({
|
||||||
|
data: key[0] === "downloads" ? mocks.jobs : undefined,
|
||||||
|
isError: false,
|
||||||
|
isLoading: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) }));
|
||||||
|
// The page toolbar (title + tabs) renders into the app shell's slot, which a bare render has none of.
|
||||||
|
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
||||||
|
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import DownloadCenter from "./DownloadCenter";
|
||||||
|
|
||||||
|
const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
progress: 0,
|
||||||
|
speed_bps: null,
|
||||||
|
eta_s: null,
|
||||||
|
phase: null,
|
||||||
|
error: null,
|
||||||
|
error_code: null,
|
||||||
|
error_detail: null,
|
||||||
|
queue_pos: 0,
|
||||||
|
created_at: null,
|
||||||
|
source_kind: "youtube",
|
||||||
|
source_ref: `v${id}`,
|
||||||
|
source_url: null,
|
||||||
|
poster_url: null,
|
||||||
|
profile_id: null,
|
||||||
|
spec: {
|
||||||
|
mode: "av",
|
||||||
|
max_height: null,
|
||||||
|
container: null,
|
||||||
|
audio_format: null,
|
||||||
|
vcodec: null,
|
||||||
|
embed_subs: false,
|
||||||
|
embed_chapters: false,
|
||||||
|
embed_thumbnail: false,
|
||||||
|
sponsorblock: false,
|
||||||
|
},
|
||||||
|
display_name: `Clip ${id}`,
|
||||||
|
display_uploader: null,
|
||||||
|
display_uploader_url: null,
|
||||||
|
extra_links: [],
|
||||||
|
can_download: status === "done",
|
||||||
|
expired: false,
|
||||||
|
asset: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mocks.confirm.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DownloadCenter confirm dialogs", () => {
|
||||||
|
it("label the confirm button with the action, not the dialog's question", async () => {
|
||||||
|
mocks.jobs = [job(1, "running"), job(2, "done")];
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<DownloadCenter />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle("downloads.actions.cancel"));
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||||
|
// The tab's label shares its button with a count badge.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /downloads\.tabs\.done/ }));
|
||||||
|
fireEvent.click(await screen.findByTitle("downloads.actions.redownload"));
|
||||||
|
fireEvent.click(screen.getByTitle("downloads.actions.delete"));
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
|
||||||
|
|
||||||
|
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
|
||||||
|
["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"],
|
||||||
|
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
|
||||||
|
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,6 +34,7 @@ import { useConfirm } from "./ConfirmProvider";
|
|||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
||||||
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
@@ -41,6 +42,7 @@ import { Button } from "./ui/Button";
|
|||||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||||
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||||
import { AdminSystem } from "./downloadAdmin";
|
import { AdminSystem } from "./downloadAdmin";
|
||||||
|
import NavGrid from "./ui/NavGrid";
|
||||||
|
|
||||||
export default function DownloadCenter() {
|
export default function DownloadCenter() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
@@ -95,18 +97,22 @@ export default function DownloadCenter() {
|
|||||||
? api.redownloadDownload(id)
|
? api.redownloadDownload(id)
|
||||||
: api.deleteDownload(id),
|
: api.deleteDownload(id),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global
|
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
|
||||||
// error modal (api.ts req) with the backend's specific message — a caller toast would double it.
|
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
|
||||||
|
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
||||||
const removeShared = useMutation({
|
const removeShared = useMutation({
|
||||||
mutationFn: (id: number) => api.removeSharedDownload(id),
|
mutationFn: (id: number) => api.removeSharedDownload(id),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
|
||||||
|
meta: { failTitle: "errors.writeFailed.removeShared" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmThen = async (title: string, body: string, fn: () => void) => {
|
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
|
||||||
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
|
||||||
|
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
|
||||||
|
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
|
||||||
};
|
};
|
||||||
|
|
||||||
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
|
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
|
||||||
@@ -222,9 +228,9 @@ export default function DownloadCenter() {
|
|||||||
{t("downloads.page.upload")}
|
{t("downloads.page.upload")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<NavGrid className="space-y-2">
|
||||||
{queue.map((job) => (
|
{queue.map((job, i) => (
|
||||||
<DownloadRow key={job.id} job={job} onShowError={setErrorJob}>
|
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
|
||||||
{(job.status === "queued" || job.status === "running") && (
|
{(job.status === "queued" || job.status === "running") && (
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() => act.mutate({ id: job.id, op: "pause" })}
|
onClick={() => act.mutate({ id: job.id, op: "pause" })}
|
||||||
@@ -250,6 +256,7 @@ export default function DownloadCenter() {
|
|||||||
confirmThen(
|
confirmThen(
|
||||||
t("downloads.confirm.cancelTitle"),
|
t("downloads.confirm.cancelTitle"),
|
||||||
t("downloads.confirm.cancelBody"),
|
t("downloads.confirm.cancelBody"),
|
||||||
|
t("downloads.confirm.cancelAction"),
|
||||||
() => act.mutate({ id: job.id, op: "cancel" }),
|
() => act.mutate({ id: job.id, op: "cancel" }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -272,21 +279,25 @@ export default function DownloadCenter() {
|
|||||||
{t("downloads.empty.queue")}
|
{t("downloads.empty.queue")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "done" && (
|
{tab === "done" && (
|
||||||
<>
|
<>
|
||||||
<UsageBar />
|
<UsageBar />
|
||||||
<div className="space-y-2">
|
<NavGrid className="space-y-2">
|
||||||
{library.map((job) => {
|
{library.map((job, i) => {
|
||||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||||
const a = libraryRowActions(job);
|
const a = libraryRowActions(job);
|
||||||
return (
|
return (
|
||||||
<DownloadRow key={job.id} job={job}>
|
<DownloadRow key={job.id} job={job} navIndex={i}>
|
||||||
{a.play && (
|
{a.play && (
|
||||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
<IconBtn
|
||||||
|
open
|
||||||
|
onClick={() => setPlayJob(job)}
|
||||||
|
title={t("downloads.actions.play")}
|
||||||
|
>
|
||||||
<PlayCircle className="w-4 h-4" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
@@ -319,6 +330,7 @@ export default function DownloadCenter() {
|
|||||||
confirmThen(
|
confirmThen(
|
||||||
t("downloads.confirm.redownloadTitle"),
|
t("downloads.confirm.redownloadTitle"),
|
||||||
t("downloads.confirm.redownloadBody"),
|
t("downloads.confirm.redownloadBody"),
|
||||||
|
t("downloads.confirm.redownloadAction"),
|
||||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -332,6 +344,7 @@ export default function DownloadCenter() {
|
|||||||
confirmThen(
|
confirmThen(
|
||||||
t("downloads.confirm.deleteTitle"),
|
t("downloads.confirm.deleteTitle"),
|
||||||
t("downloads.confirm.deleteBody"),
|
t("downloads.confirm.deleteBody"),
|
||||||
|
t("downloads.actions.delete"),
|
||||||
() => act.mutate({ id: job.id, op: "delete" }),
|
() => act.mutate({ id: job.id, op: "delete" }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -355,19 +368,23 @@ export default function DownloadCenter() {
|
|||||||
{t("downloads.empty.done")}
|
{t("downloads.empty.done")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "shared" && (
|
{tab === "shared" && (
|
||||||
<div className="space-y-2">
|
<NavGrid className="space-y-2">
|
||||||
{(sharedQ.data ?? []).map((job) => {
|
{(sharedQ.data ?? []).map((job, i) => {
|
||||||
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||||
const a = sharedRowActions(job);
|
const a = sharedRowActions(job);
|
||||||
return (
|
return (
|
||||||
<DownloadRow key={job.id} job={job}>
|
<DownloadRow key={job.id} job={job} navIndex={i}>
|
||||||
{a.play && (
|
{a.play && (
|
||||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
<IconBtn
|
||||||
|
open
|
||||||
|
onClick={() => setPlayJob(job)}
|
||||||
|
title={t("downloads.actions.play")}
|
||||||
|
>
|
||||||
<PlayCircle className="w-4 h-4" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
@@ -382,6 +399,7 @@ export default function DownloadCenter() {
|
|||||||
confirmThen(
|
confirmThen(
|
||||||
t("downloads.confirm.removeSharedTitle"),
|
t("downloads.confirm.removeSharedTitle"),
|
||||||
t("downloads.confirm.removeSharedBody"),
|
t("downloads.confirm.removeSharedBody"),
|
||||||
|
t("downloads.actions.removeShared"),
|
||||||
() => removeShared.mutate(job.id),
|
() => removeShared.mutate(job.id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -405,7 +423,7 @@ export default function DownloadCenter() {
|
|||||||
{t("downloads.empty.shared")}
|
{t("downloads.empty.shared")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { invalidateDownloads } from "../lib/downloads";
|
|||||||
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
|
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
|
||||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { navigateTo } from "../lib/nav";
|
import { navigateTo } from "../lib/nav";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
|
||||||
@@ -51,8 +52,11 @@ export default function DownloadDialog({
|
|||||||
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Non-quiet request: the global error dialog already surfaced the reason (e.g. quota).
|
// A quota or validation refusal (422/409) is already in the global error dialog, which the
|
||||||
|
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
|
||||||
|
// open with nothing said (E11 S2b).
|
||||||
|
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The Hidden / Blocked channel cards as a keyboard list (E11 S4c): `j`/`k` walk the cards, Enter opens
|
||||||
|
// the channel — the ChannelLink name is the card's open element — and the card's own action button
|
||||||
|
// keeps its Enter.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import ExcludedChannelList from "./ExcludedChannelList";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function press(key: string) {
|
||||||
|
act(() => {
|
||||||
|
(document.activeElement ?? document.body).dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const channels = [
|
||||||
|
{ id: "UCa", title: "Alpha", thumbnail_url: null },
|
||||||
|
{ id: "UCb", title: "Beta", thumbnail_url: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("ExcludedChannelList · keyboard", () => {
|
||||||
|
it("walks the cards with j/k and opens the channel on Enter", () => {
|
||||||
|
const onView = vi.fn();
|
||||||
|
render(
|
||||||
|
<ExcludedChannelList
|
||||||
|
channels={channels}
|
||||||
|
icon={null}
|
||||||
|
title="Hidden"
|
||||||
|
hint=""
|
||||||
|
emptyText=""
|
||||||
|
actionLabel="Unhide"
|
||||||
|
actionIcon={null}
|
||||||
|
onAction={() => {}}
|
||||||
|
onView={onView}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
press("j");
|
||||||
|
press("j");
|
||||||
|
expect(document.activeElement?.textContent).toContain("Beta");
|
||||||
|
press("k");
|
||||||
|
expect(document.activeElement?.textContent).toContain("Alpha");
|
||||||
|
press("Enter");
|
||||||
|
expect(onView).toHaveBeenCalledWith(channels[0]);
|
||||||
|
expect(screen.getAllByRole("button", { name: "Unhide" })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import ChannelLink from "./ChannelLink";
|
import ChannelLink from "./ChannelLink";
|
||||||
|
import NavGrid from "./ui/NavGrid";
|
||||||
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
|
||||||
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
|
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
|
||||||
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
|
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
|
||||||
@@ -40,9 +42,13 @@ export default function ExcludedChannelList({
|
|||||||
{icon}
|
{icon}
|
||||||
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
|
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
<NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{channels.map((c) => (
|
{channels.map((c, i) => (
|
||||||
<div key={c.id} className="glass-card flex items-center gap-2 rounded-xl px-3 py-2">
|
<div
|
||||||
|
key={c.id}
|
||||||
|
{...navItemAttrs(i)}
|
||||||
|
className="glass-card flex items-center gap-2 rounded-xl px-3 py-2"
|
||||||
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<ChannelLink
|
<ChannelLink
|
||||||
id={c.id}
|
id={c.id}
|
||||||
@@ -63,7 +69,7 @@ export default function ExcludedChannelList({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
<p className="text-2xs text-muted mt-3">{hint}</p>
|
<p className="text-2xs text-muted mt-3">{hint}</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
|
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { notify, resolveVideo } from "../lib/notifications";
|
import { notify, resolveVideo } from "../lib/notifications";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import {
|
import {
|
||||||
@@ -145,7 +146,7 @@ export default function Feed({
|
|||||||
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
|
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown/scroll-lock effect
|
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
|
||||||
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
|
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
|
||||||
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
|
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
|
||||||
// debounced volume write on each pass.
|
// debounced volume write on each pass.
|
||||||
@@ -308,15 +309,17 @@ export default function Feed({
|
|||||||
resolveVideo(id);
|
resolveVideo(id);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch((e: unknown) => {
|
||||||
// Server rejected the change — drop the optimistic override so the card
|
// Server rejected the change — drop the optimistic override so the card
|
||||||
// reverts to the real (unchanged) state instead of staying phantom-hidden.
|
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
|
||||||
|
// a card that silently snaps back reads as a misclick.
|
||||||
|
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
|
||||||
setOverrides((o) => {
|
setOverrides((o) => {
|
||||||
const next = { ...o };
|
const next = { ...o };
|
||||||
delete next[id];
|
delete next[id];
|
||||||
return next;
|
return next;
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
},
|
},
|
||||||
[qc],
|
[qc],
|
||||||
);
|
);
|
||||||
@@ -330,13 +333,14 @@ export default function Feed({
|
|||||||
.clearState(id)
|
.clearState(id)
|
||||||
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
||||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
|
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
|
||||||
.catch(() =>
|
.catch((e: unknown) => {
|
||||||
|
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
|
||||||
setOverrides((o) => {
|
setOverrides((o) => {
|
||||||
const next = { ...o };
|
const next = { ...o };
|
||||||
delete next[id];
|
delete next[id];
|
||||||
return next;
|
return next;
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
},
|
},
|
||||||
[qc],
|
[qc],
|
||||||
);
|
);
|
||||||
@@ -349,7 +353,15 @@ export default function Feed({
|
|||||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||||
})
|
})
|
||||||
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
|
.catch((e: unknown) => {
|
||||||
|
notifyActionError(
|
||||||
|
e,
|
||||||
|
i18n.t(
|
||||||
|
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[qc],
|
[qc],
|
||||||
);
|
);
|
||||||
@@ -466,7 +478,9 @@ export default function Feed({
|
|||||||
.then((r) =>
|
.then((r) =>
|
||||||
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
|
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
|
||||||
)
|
)
|
||||||
.catch(() => {});
|
.catch((e: unknown) =>
|
||||||
|
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
|
||||||
|
);
|
||||||
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
||||||
onExitYtSearch();
|
onExitYtSearch();
|
||||||
dropFeedCaches();
|
dropFeedCaches();
|
||||||
|
|||||||
@@ -372,8 +372,8 @@ describe("Home", () => {
|
|||||||
it("an unrelated mutation does not put the reroll button into a spinner", async () => {
|
it("an unrelated mutation does not put the reroll button into a spinner", async () => {
|
||||||
// The other edge of the test above. Because the pick key sits under the `["discover"]` prefix,
|
// The other edge of the test above. Because the pick key sits under the `["discover"]` prefix,
|
||||||
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
|
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
|
||||||
// so binding the button's `loading` to raw `isFetching` made it spin and, since `ui/Button` turns
|
// so binding the button's `loading` to raw `isFetching` made it spin and, since a `loading`
|
||||||
// `loading` into `disabled`, swallow clicks after actions the user never associated with a
|
// `ui/Button` swallows its clicks, ignore presses after actions the user never associated with a
|
||||||
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
|
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
|
||||||
const rerollBtn = () =>
|
const rerollBtn = () =>
|
||||||
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
||||||
@@ -391,7 +391,6 @@ describe("Home", () => {
|
|||||||
// The positive control, and it is the half most easily broken while fixing the other: this is the
|
// The positive control, and it is the half most easily broken while fixing the other: this is the
|
||||||
// FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting
|
// FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting
|
||||||
// on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it.
|
// on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it.
|
||||||
expect(rerollBtn().disabled).toBe(true);
|
|
||||||
expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
|
expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -409,7 +408,6 @@ describe("Home", () => {
|
|||||||
});
|
});
|
||||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
expect(rerollBtn().disabled).toBe(false);
|
|
||||||
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
|
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,7 +432,7 @@ describe("Home", () => {
|
|||||||
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
|
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
|
||||||
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
|
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
|
||||||
failPick(new Error("boom"));
|
failPick(new Error("boom"));
|
||||||
await waitFor(() => expect(rerollBtn().disabled).toBe(false)); // the failure releases the button
|
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull()); // the failure releases the button
|
||||||
|
|
||||||
// Now an unrelated action refetches that errored key in the background.
|
// Now an unrelated action refetches that errored key in the background.
|
||||||
discoverPick.mockReturnValue(new Promise(() => {}));
|
discoverPick.mockReturnValue(new Promise(() => {}));
|
||||||
@@ -442,7 +440,6 @@ describe("Home", () => {
|
|||||||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||||
});
|
});
|
||||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||||
expect(rerollBtn().disabled).toBe(false);
|
|
||||||
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
|
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -479,14 +476,14 @@ describe("Home", () => {
|
|||||||
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
||||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||||
expect(rerollBtn().disabled).toBe(true); // the press is outstanding
|
expect(rerollBtn().getAttribute("aria-busy")).toBe("true"); // the press is outstanding
|
||||||
|
|
||||||
failPick(new Error("boom"));
|
failPick(new Error("boom"));
|
||||||
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies
|
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies
|
||||||
// the observer through its own `setTimeout(0)`, and the empty state is already on screen while
|
// the observer through its own `setTimeout(0)`, and the empty state is already on screen while
|
||||||
// the press is in flight — so asserting it straight away would pass with or without the fix.
|
// the press is in flight — so asserting it straight away would pass with or without the fix.
|
||||||
// The button releasing is that signal (`isFetched` flips once the key settles, error included).
|
// The button releasing is that signal (`isFetched` flips once the key settles, error included).
|
||||||
await waitFor(() => expect(rerollBtn().disabled).toBe(false));
|
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull());
|
||||||
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
|
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
|
||||||
expect(screen.queryByText("Hero v1")).toBeNull();
|
expect(screen.queryByText("Hero v1")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -674,3 +671,47 @@ describe("Home", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E11 S4b, owner decision D4: the rails are one keyboard page — `j` walks a rail's cards and goes on
|
||||||
|
// into the next rail, whichever order the page stacks them in.
|
||||||
|
describe("Home · keyboard", () => {
|
||||||
|
it("walks the rails with j, from one rail on into the next", async () => {
|
||||||
|
discover.mockResolvedValue({
|
||||||
|
...payload("v1"),
|
||||||
|
continue_watching: [
|
||||||
|
ytCard("v1", "Hero v1"),
|
||||||
|
ytCard("v2", "Second thing"),
|
||||||
|
ytCard("v3", "Third thing"),
|
||||||
|
],
|
||||||
|
// A pinned channel has no card of its own: the shelf draws it as a ShelfTile.
|
||||||
|
comfort: [
|
||||||
|
ytCard("v4", "Comfort thing"),
|
||||||
|
{
|
||||||
|
...ytCard("UC9", "Pinned channel"),
|
||||||
|
kind: "channel",
|
||||||
|
video: null,
|
||||||
|
next: null,
|
||||||
|
unwatched_count: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Comfort thing");
|
||||||
|
const titles = ["Comfort thing", "Pinned channel", "Second thing", "Third thing"];
|
||||||
|
const seen: (string | undefined)[] = [];
|
||||||
|
for (let i = 0; i < titles.length; i++) {
|
||||||
|
act(() => {
|
||||||
|
(document.activeElement ?? document.body).dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const item = (document.activeElement as HTMLElement).closest("[data-nav-index]");
|
||||||
|
seen.push(titles.find((title) => item?.textContent?.includes(title)));
|
||||||
|
}
|
||||||
|
// Every card once, across both rails: the shelf tile, and on from one rail into the next.
|
||||||
|
expect(new Set(seen)).toEqual(new Set(titles));
|
||||||
|
// Every card of every rail is an item of it — the shelf tile too, not only the native cards.
|
||||||
|
const rails = [...document.querySelectorAll("[data-nav-list]")];
|
||||||
|
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import VideoCard from "./VideoCard";
|
|||||||
import { PlexPosterCard } from "./plexCards";
|
import { PlexPosterCard } from "./plexCards";
|
||||||
import { useNavigationActions } from "./NavigationProvider";
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import { usePlexActions } from "./PlexProvider";
|
import { usePlexActions } from "./PlexProvider";
|
||||||
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
|
||||||
// The Home surface (E24) — "Mit nézzek most?".
|
// The Home surface (E24) — "Mit nézzek most?".
|
||||||
//
|
//
|
||||||
@@ -172,8 +173,9 @@ function Rail({
|
|||||||
<Carousel
|
<Carousel
|
||||||
title={<span className="text-sm font-semibold">{title}</span>}
|
title={<span className="text-sm font-semibold">{title}</span>}
|
||||||
ariaLabel={ariaLabel}
|
ariaLabel={ariaLabel}
|
||||||
|
keyboardNav
|
||||||
>
|
>
|
||||||
{cards.map((c) => (
|
{cards.map((c, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${c.source}:${c.kind}:${c.id}`}
|
key={`${c.source}:${c.kind}:${c.id}`}
|
||||||
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
|
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
|
||||||
@@ -184,6 +186,7 @@ function Rail({
|
|||||||
video={c.video}
|
video={c.video}
|
||||||
view="cardsSmall"
|
view="cardsSmall"
|
||||||
width={YT_SLOT}
|
width={YT_SLOT}
|
||||||
|
navIndex={i}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
||||||
@@ -195,13 +198,14 @@ function Rail({
|
|||||||
) : c.plex ? (
|
) : c.plex ? (
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
card={c.plex}
|
card={c.plex}
|
||||||
|
navIndex={i}
|
||||||
onOpen={() => onPlay(c)}
|
onOpen={() => onPlay(c)}
|
||||||
onInfo={() => onInfo(c)}
|
onInfo={() => onInfo(c)}
|
||||||
onToggleWatched={onToggleWatched}
|
onToggleWatched={onToggleWatched}
|
||||||
menuProps={() => menu.triggerProps(c)}
|
menuProps={() => menu.triggerProps(c)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} />
|
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -218,10 +222,12 @@ function ShelfTile({
|
|||||||
card,
|
card,
|
||||||
onPlay,
|
onPlay,
|
||||||
menuProps,
|
menuProps,
|
||||||
|
navIndex,
|
||||||
}: {
|
}: {
|
||||||
card: DiscoverCard;
|
card: DiscoverCard;
|
||||||
onPlay: (card: DiscoverCard) => void;
|
onPlay: (card: DiscoverCard) => void;
|
||||||
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
|
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
|
||||||
|
navIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
||||||
@@ -234,6 +240,7 @@ function ShelfTile({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => card.next && onPlay(card)}
|
onClick={() => card.next && onPlay(card)}
|
||||||
{...menuProps?.(card)}
|
{...menuProps?.(card)}
|
||||||
|
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||||
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
|
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
|
||||||
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
|
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
|
||||||
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
|
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const baseJob: DownloadJob = {
|
|||||||
phase: null,
|
phase: null,
|
||||||
error: null,
|
error: null,
|
||||||
error_code: null,
|
error_code: null,
|
||||||
|
error_detail: null,
|
||||||
queue_pos: 0,
|
queue_pos: 0,
|
||||||
created_at: null,
|
created_at: null,
|
||||||
source_kind: "youtube",
|
source_kind: "youtube",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The conversation list as a keyboard list (E11 S5): `j`/`k` walk the conversations and Enter opens
|
||||||
|
// the one the row's name button opens — not the pop-out button beside it. The live query, the key
|
||||||
|
// state and the thread are stubbed: the list itself is what is under test.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversation = (id: number, name: string) => ({
|
||||||
|
partner: { id, name, avatar_url: null },
|
||||||
|
last_message: { kind: "system", body: `hello from ${name}`, created_at: "2026-09-13T00:00:00Z" },
|
||||||
|
unread: 0,
|
||||||
|
});
|
||||||
|
const items = [conversation(0, "Siftlode"), conversation(7, "Anna"), conversation(9, "Bence")];
|
||||||
|
|
||||||
|
vi.mock("../lib/useLiveQuery", () => ({
|
||||||
|
useLiveQuery: () => ({ data: { items }, isError: false, isLoading: false, refetch: () => {} }),
|
||||||
|
}));
|
||||||
|
vi.mock("../lib/messaging", () => ({
|
||||||
|
SYSTEM_ID: 0,
|
||||||
|
POLL_MS: 20000,
|
||||||
|
partnerPub: async () => "",
|
||||||
|
useKeyState: () => "ready",
|
||||||
|
}));
|
||||||
|
const openChat = vi.fn();
|
||||||
|
vi.mock("../lib/chatDock", () => ({ openChat: (...a: unknown[]) => openChat(...a) }));
|
||||||
|
vi.mock("./ChatThread", () => ({
|
||||||
|
default: ({ partnerId }: { partnerId: number }) => <div data-testid="thread">{partnerId}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import Messages from "./Messages";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
history.replaceState(null, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
function press(key: string) {
|
||||||
|
act(() => {
|
||||||
|
(document.activeElement ?? document.body).dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Messages · the conversation list is a keyboard list", () => {
|
||||||
|
it("walks the conversations with j/k and opens the focused one on Enter", () => {
|
||||||
|
render(<Messages meId={1} />);
|
||||||
|
const focusedRow = () => document.activeElement?.getAttribute("data-nav-index");
|
||||||
|
press("j");
|
||||||
|
press("j");
|
||||||
|
press("j");
|
||||||
|
expect(focusedRow()).toBe("2");
|
||||||
|
expect(document.activeElement?.textContent).toContain("Bence");
|
||||||
|
press("k");
|
||||||
|
expect(focusedRow()).toBe("1");
|
||||||
|
expect(document.activeElement?.textContent).toContain("Anna");
|
||||||
|
press("Enter");
|
||||||
|
expect(screen.getByTestId("thread").textContent).toBe("7");
|
||||||
|
expect(openChat).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,8 @@ import Avatar from "./ui/Avatar";
|
|||||||
import KeyGate from "./KeyGate";
|
import KeyGate from "./KeyGate";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
import ChatThread from "./ChatThread";
|
import ChatThread from "./ChatThread";
|
||||||
|
import NavGrid from "./ui/NavGrid";
|
||||||
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| "list"
|
| "list"
|
||||||
@@ -140,15 +142,19 @@ function ConversationList({
|
|||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1.5">
|
// A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
|
||||||
{items.map((c) => {
|
// opens; the pop-out button beside it keeps its own Enter.
|
||||||
|
<NavGrid className="flex flex-col gap-1.5">
|
||||||
|
{items.map((c, i) => {
|
||||||
const isSystem = c.partner.id === SYSTEM_ID;
|
const isSystem = c.partner.id === SYSTEM_ID;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={c.partner.id}
|
key={c.partner.id}
|
||||||
|
{...navItemAttrs(i, c.partner.name)}
|
||||||
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
|
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
data-nav-open=""
|
||||||
onClick={() => onOpen(c)}
|
onClick={() => onOpen(c)}
|
||||||
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
|
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
|
||||||
>
|
>
|
||||||
@@ -195,7 +201,7 @@ function ConversationList({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</NavGrid>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// `setPage`'s no-op contract — the PRODUCER side of the history-burial fix, which nothing rendered
|
// The navigation actions against the overlay history stack: `setPage` and `openChannel` never push
|
||||||
// before this. `setPage` early-returns when the requested page is already current with no channel
|
// under an open overlay — they close it and push once its Back-to-close entry is reclaimed — and
|
||||||
// page over it, and reports that by returning `false`; `lib/history.buryUnder` reads the boolean to
|
// `setPage`'s same-page guard, which decides whether anything is pushed at all.
|
||||||
// decide whether the overlay entries beneath a navigation are still reclaimable. Get it wrong and
|
|
||||||
// the round-2 bug is back: Shift+W over the player on Home buries an entry nothing pushed past, and
|
|
||||||
// the user's next browser Back does nothing.
|
|
||||||
//
|
//
|
||||||
// The type does NOT fence this. `NavigationActions` pins the SHAPE (`=> boolean`), so deleting one
|
// The overlay half is what used to live at each call site (Shift+W in App, a toast's "View", the
|
||||||
// `return` fails tsc — but round 3 measured that swapping the two values (early return `true`, tail
|
// palette, the player's channel link) and could be forgotten at any of them; it now lives in the two
|
||||||
// `false`) passes typecheck and all 822 tests. Hence assertions on the value AND on the effects the
|
// actions, so this file is where it is pinned. The end-to-end gesture — Shift+W over the player,
|
||||||
// early return skips, which is what the boolean actually stands for.
|
// then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as
|
||||||
|
// bookkeeping.
|
||||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||||
import { renderHook, act, cleanup } from "@testing-library/react";
|
import { renderHook, act, cleanup } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -27,6 +25,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider";
|
import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
|
import { useBackToClose } from "../lib/history";
|
||||||
|
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
<NavigationProvider>{children}</NavigationProvider>
|
<NavigationProvider>{children}</NavigationProvider>
|
||||||
@@ -34,6 +33,25 @@ const wrapper = ({ children }: { children: ReactNode }) => (
|
|||||||
const render = () =>
|
const render = () =>
|
||||||
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
|
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
|
||||||
|
|
||||||
|
const settle = () =>
|
||||||
|
act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** An open overlay (the player) whose close really unmounts it; its reclaiming back() is stubbed to
|
||||||
|
* land its popstate at once. */
|
||||||
|
async function openOverlay() {
|
||||||
|
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
const holder: { unmount?: () => void } = {};
|
||||||
|
const onClose = vi.fn(() => holder.unmount?.());
|
||||||
|
const layer = renderHook(() => useBackToClose(onClose));
|
||||||
|
holder.unmount = layer.unmount;
|
||||||
|
await settle();
|
||||||
|
return { back, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
|
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
|
||||||
@@ -41,46 +59,29 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
|
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
|
||||||
// provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does.
|
// provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does.
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe("NavigationProvider · setPage reports whether it navigated", () => {
|
describe("NavigationProvider · setPage's same-page guard", () => {
|
||||||
it("returns true and pushes an entry for a real page change", () => {
|
it("pushes an entry for a real page change", () => {
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
const push = vi.spyOn(window.history, "pushState");
|
const push = vi.spyOn(window.history, "pushState");
|
||||||
try {
|
act(() => result.current.setPage("plex"));
|
||||||
let reported: boolean | undefined;
|
expect(push).toHaveBeenCalledTimes(1);
|
||||||
act(() => {
|
expect(result.current.page).toBe("plex");
|
||||||
reported = result.current.setPage("plex");
|
|
||||||
});
|
|
||||||
expect(reported).toBe(true);
|
|
||||||
expect(push).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result.current.page).toBe("plex");
|
|
||||||
} finally {
|
|
||||||
push.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false and pushes nothing when it is already that page", () => {
|
it("pushes nothing when it is already that page", () => {
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
act(() => {
|
act(() => result.current.setPage("plex"));
|
||||||
result.current.setPage("plex");
|
|
||||||
});
|
|
||||||
const push = vi.spyOn(window.history, "pushState");
|
const push = vi.spyOn(window.history, "pushState");
|
||||||
try {
|
act(() => result.current.setPage("plex"));
|
||||||
let reported: boolean | undefined;
|
expect(push).not.toHaveBeenCalled();
|
||||||
act(() => {
|
|
||||||
reported = result.current.setPage("plex");
|
|
||||||
});
|
|
||||||
// Both halves matter: `false` is what `buryUnder` reads, and "pushed nothing" is what makes
|
|
||||||
// that answer true. A value-only assertion would pass against an inverted pair.
|
|
||||||
expect(reported).toBe(false);
|
|
||||||
expect(push).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
push.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true even for the current page when a channel page is over it", () => {
|
it("still navigates to the current page when a channel page is over it", () => {
|
||||||
// The nav rail's own module while a channel page is open over it is a real navigation: it closes
|
// The nav rail's own module while a channel page is open over it is a real navigation: it closes
|
||||||
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
|
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
@@ -90,22 +91,64 @@ describe("NavigationProvider · setPage reports whether it navigated", () => {
|
|||||||
// asking for "feed" was just an ordinary page change and deleting the clause left it green.
|
// asking for "feed" was just an ordinary page change and deleting the clause left it green.
|
||||||
const current = result.current.page;
|
const current = result.current.page;
|
||||||
expect(current).toBe("home");
|
expect(current).toBe("home");
|
||||||
act(() => {
|
act(() => result.current.openChannel("UC1", "A channel"));
|
||||||
result.current.openChannel("UC1", "A channel");
|
|
||||||
});
|
|
||||||
expect(result.current.channelView).not.toBeNull();
|
expect(result.current.channelView).not.toBeNull();
|
||||||
|
|
||||||
const push = vi.spyOn(window.history, "pushState");
|
const push = vi.spyOn(window.history, "pushState");
|
||||||
try {
|
act(() => result.current.setPage(current));
|
||||||
let reported: boolean | undefined;
|
expect(push).toHaveBeenCalledTimes(1);
|
||||||
act(() => {
|
expect(result.current.channelView).toBeNull();
|
||||||
reported = result.current.setPage(current);
|
});
|
||||||
});
|
});
|
||||||
expect(reported).toBe(true);
|
|
||||||
expect(push).toHaveBeenCalledTimes(1);
|
describe("NavigationProvider · never under an open overlay", () => {
|
||||||
expect(result.current.channelView).toBeNull();
|
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
|
||||||
} finally {
|
const { result } = render();
|
||||||
push.mockRestore();
|
const { back, onClose } = await openOverlay();
|
||||||
}
|
const push = vi.spyOn(window.history, "pushState");
|
||||||
|
|
||||||
|
act(() => result.current.setPage("plex"));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(push).not.toHaveBeenCalled(); // not while the player's entry is still current
|
||||||
|
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
expect(back).toHaveBeenCalledTimes(1);
|
||||||
|
expect(push).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.page).toBe("plex");
|
||||||
|
expect(window.history.state).toMatchObject({ sfPage: "plex" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player
|
||||||
|
// closes, and its entry is reclaimed rather than orphaned under a push that never happened.
|
||||||
|
it("setPage to the current page closes the overlay and pushes nothing", async () => {
|
||||||
|
const { result } = render();
|
||||||
|
const { back, onClose } = await openOverlay();
|
||||||
|
const push = vi.spyOn(window.history, "pushState");
|
||||||
|
|
||||||
|
act(() => result.current.setPage(result.current.page));
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(back).toHaveBeenCalledTimes(1);
|
||||||
|
expect(push).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The player's channel link and the palette's channel rows.
|
||||||
|
it("openChannel closes the overlay first and pushes the channel once its entry is reclaimed", async () => {
|
||||||
|
const { result } = render();
|
||||||
|
const { back, onClose } = await openOverlay();
|
||||||
|
const push = vi.spyOn(window.history, "pushState");
|
||||||
|
|
||||||
|
act(() => result.current.openChannel("UC1", "A channel"));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(push).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
expect(back).toHaveBeenCalledTimes(1);
|
||||||
|
expect(push).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.channelView).toEqual({ id: "UC1", name: "A channel" });
|
||||||
|
expect(window.history.state).toMatchObject({ _chan: "UC1" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import { pageTitleKey } from "../lib/pageMeta";
|
|||||||
import { setAccountRaw } from "../lib/storage";
|
import { setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||||
import { setNavigator } from "../lib/nav";
|
import { setNavigator } from "../lib/nav";
|
||||||
import { nextNavId, notifyNav } from "../lib/history";
|
import {
|
||||||
|
closeOverlaysThenNavigate,
|
||||||
|
nextNavId,
|
||||||
|
notifyNav,
|
||||||
|
rememberNavPosition,
|
||||||
|
} from "../lib/history";
|
||||||
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
|
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
|
||||||
|
|
||||||
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
|
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
|
||||||
@@ -37,10 +42,11 @@ export interface NavigationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigationActions {
|
export interface NavigationActions {
|
||||||
/** Switch modules. Returns whether it actually navigated — it is a no-op when the requested page
|
/** Switch modules. Closes every open overlay first and pushes only once their history entries are
|
||||||
* is already current with no channel page over it, and a caller that buries overlay history
|
* back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment
|
||||||
* entries under the push has to know that (see `lib/history.navigateOverOverlays`). */
|
* after the call; with nothing open it is synchronous. The requested page being already current
|
||||||
setPage: (next: Page) => boolean;
|
* (with no channel page over it) pushes nothing — but the overlays still close. */
|
||||||
|
setPage: (next: Page) => void;
|
||||||
openChannel: (id: string, name?: string) => void;
|
openChannel: (id: string, name?: string) => void;
|
||||||
closeChannel: () => void;
|
closeChannel: () => void;
|
||||||
enterYtSearch: (q: string) => void;
|
enterYtSearch: (q: string) => void;
|
||||||
@@ -53,7 +59,7 @@ const StateContext = createContext<NavigationState>({
|
|||||||
ytSearch: null,
|
ytSearch: null,
|
||||||
});
|
});
|
||||||
const ActionsContext = createContext<NavigationActions>({
|
const ActionsContext = createContext<NavigationActions>({
|
||||||
setPage: () => false,
|
setPage: () => {},
|
||||||
openChannel: () => {},
|
openChannel: () => {},
|
||||||
closeChannel: () => {},
|
closeChannel: () => {},
|
||||||
enterYtSearch: () => {},
|
enterYtSearch: () => {},
|
||||||
@@ -118,53 +124,66 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const setPage = useCallback(
|
const setPage = useCallback(
|
||||||
(next: Page) => {
|
(next: Page) =>
|
||||||
// A channel page overlays the content column, so a nav click must close it even when the
|
// Never under an open overlay — see the interface doc. The refs are read when the push
|
||||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
// actually happens, which over an open player is a moment after this call.
|
||||||
if (next === pageRef.current && !channelViewRef.current) return false;
|
closeOverlaysThenNavigate(() => {
|
||||||
markDirty();
|
// A channel page overlays the content column, so a nav click must close it even when the
|
||||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||||
setPageState(next);
|
if (next === pageRef.current && !channelViewRef.current) return;
|
||||||
setAccountRaw(PAGE_KEY, next);
|
markDirty();
|
||||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||||
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
|
setPageState(next);
|
||||||
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
|
setAccountRaw(PAGE_KEY, next);
|
||||||
// (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that
|
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
|
||||||
// module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex/channels
|
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
|
||||||
// keys re-mirror from PlexProvider / ChannelsProvider on return to their page.
|
// root, and the address bar reflects the page (feed is implicit) while clearing the
|
||||||
window.history.pushState(
|
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
|
||||||
{ sfPage: next, sfScroll: nextNavId() },
|
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
|
||||||
"",
|
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
|
||||||
urlWithParams({
|
// their page. The item it was opened from is still focused: the state setters above have not
|
||||||
page: next === "feed" ? null : next,
|
// re-rendered anything yet.
|
||||||
chan: null,
|
rememberNavPosition();
|
||||||
v: null,
|
window.history.pushState(
|
||||||
pl: null,
|
{ sfPage: next, sfScroll: nextNavId() },
|
||||||
pscope: null,
|
"",
|
||||||
psort: null,
|
urlWithParams({
|
||||||
pshow: null,
|
page: next === "feed" ? null : next,
|
||||||
pf: null,
|
chan: null,
|
||||||
ctab: null,
|
v: null,
|
||||||
csort: null,
|
pl: null,
|
||||||
cf: null,
|
pscope: null,
|
||||||
}),
|
psort: null,
|
||||||
);
|
pshow: null,
|
||||||
notifyNav();
|
pf: null,
|
||||||
return true;
|
ctab: null,
|
||||||
},
|
csort: null,
|
||||||
|
cf: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
notifyNav();
|
||||||
|
}),
|
||||||
[markDirty],
|
[markDirty],
|
||||||
);
|
);
|
||||||
|
|
||||||
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
|
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
|
||||||
// pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
|
// pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
|
||||||
// replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
|
// replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
|
||||||
|
//
|
||||||
|
// Like setPage it never lands under an open overlay: the player's channel link and the palette's
|
||||||
|
// channel rows call it from inside one.
|
||||||
const openChannel = useCallback((id: string, name?: string) => {
|
const openChannel = useCallback((id: string, name?: string) => {
|
||||||
setChannelView({ id, name });
|
closeOverlaysThenNavigate(() => {
|
||||||
const st = window.history.state || {};
|
const url = urlWithParams({ chan: id });
|
||||||
const url = urlWithParams({ chan: id });
|
if (window.history.state?._chan) {
|
||||||
if (st._chan)
|
const st = window.history.state;
|
||||||
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||||
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
} else {
|
||||||
|
const st = rememberNavPosition();
|
||||||
|
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||||
|
}
|
||||||
|
setChannelView({ id, name });
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeChannel = useCallback(() => {
|
const closeChannel = useCallback(() => {
|
||||||
@@ -185,7 +204,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
const { _ytScrape: _drop, ...st } = window.history.state || {};
|
const { _ytScrape: _drop, ...st } = window.history.state || {};
|
||||||
if (st._yt)
|
if (st._yt)
|
||||||
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
|
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
|
||||||
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
|
else {
|
||||||
|
// New sub-view entry.
|
||||||
|
const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
|
||||||
|
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const exitYtSearch = useCallback(() => {
|
const exitYtSearch = useCallback(() => {
|
||||||
|
|||||||
@@ -79,10 +79,26 @@ export default function NotificationsPanel() {
|
|||||||
qc.invalidateQueries({ queryKey: qk.notifications() });
|
qc.invalidateQueries({ queryKey: qk.notifications() });
|
||||||
qc.invalidateQueries({ queryKey: qk.notifUnread() });
|
qc.invalidateQueries({ queryKey: qk.notifUnread() });
|
||||||
};
|
};
|
||||||
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
|
const readMut = useMutation({
|
||||||
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
|
mutationFn: api.markNotificationRead,
|
||||||
const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh });
|
onSuccess: refresh,
|
||||||
const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh });
|
meta: { failTitle: "errors.writeFailed.notifRead" },
|
||||||
|
});
|
||||||
|
const readAllMut = useMutation({
|
||||||
|
mutationFn: api.markAllNotificationsRead,
|
||||||
|
onSuccess: refresh,
|
||||||
|
meta: { failTitle: "errors.writeFailed.notifReadAll" },
|
||||||
|
});
|
||||||
|
const dismissMut = useMutation({
|
||||||
|
mutationFn: api.dismissNotification,
|
||||||
|
onSuccess: refresh,
|
||||||
|
meta: { failTitle: "errors.writeFailed.notifDismiss" },
|
||||||
|
});
|
||||||
|
const clearMut = useMutation({
|
||||||
|
mutationFn: api.clearNotifications,
|
||||||
|
onSuccess: refresh,
|
||||||
|
meta: { failTitle: "errors.writeFailed.notifClear" },
|
||||||
|
});
|
||||||
|
|
||||||
const serverUnread = serverItems.some((n) => !n.read);
|
const serverUnread = serverItems.some((n) => !n.read);
|
||||||
const hasAny = serverItems.length > 0 || clientItems.length > 0;
|
const hasAny = serverItems.length > 0 || clientItems.length > 0;
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||||
},
|
},
|
||||||
|
// The step itself says the import failed and offers a retry button — no toast on top.
|
||||||
|
meta: { errorShownInline: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// First time we see read access with zero imported channels, pull the user's
|
// First time we see read access with zero imported channels, pull the user's
|
||||||
@@ -123,8 +125,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
useDialogChrome(panelRef, rootRef);
|
useDialogChrome(panelRef, rootRef);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
// Only while nothing is above us: an error dialog opened from inside the wizard owns the Tab.
|
// Only while nothing that owns the keyboard is above us: an error dialog opened from inside the
|
||||||
if (e.key !== "Tab" || !layer.isTopmost()) return;
|
// wizard owns the Tab, a hover card under the pointer does not.
|
||||||
|
if (e.key !== "Tab" || layer.blockingAbove()) return;
|
||||||
const panel = panelRef.current;
|
const panel = panelRef.current;
|
||||||
// The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had
|
// The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had
|
||||||
// neither of them (round 6, on the commit that extracted the helper so the two shells could
|
// neither of them (round 6, on the commit that extracted the helper so the two shells could
|
||||||
|
|||||||
@@ -290,3 +290,63 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- the dialog contract it now shares (ui/dialogFocus) -----------------------------------------
|
||||||
|
// Until E11 this was the one `aria-modal` shell with no accessible name, no Tab trap and no focus
|
||||||
|
// restore: Tab walked out of an open player into the page behind the scrim, and closing it with
|
||||||
|
// Escape left a keyboard user on <body>, their place in a long feed lost (deep-review round 7).
|
||||||
|
describe("PlayerModal — the dialog contract", () => {
|
||||||
|
const open = (onClose = () => {}) => {
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
const A = vid("A", 0);
|
||||||
|
return renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("is named by the video's title", () => {
|
||||||
|
open();
|
||||||
|
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens focused on its card, not on a control, so its shortcuts keep working", () => {
|
||||||
|
open();
|
||||||
|
const active = document.activeElement as HTMLElement;
|
||||||
|
expect(active.tagName).not.toBe("BUTTON");
|
||||||
|
expect(screen.getByTestId("player-modal").contains(active)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the focus back to what opened it when it closes", () => {
|
||||||
|
const opener = document.createElement("button");
|
||||||
|
document.body.appendChild(opener);
|
||||||
|
opener.focus();
|
||||||
|
try {
|
||||||
|
const view = open();
|
||||||
|
expect(document.activeElement).not.toBe(opener);
|
||||||
|
view.unmount();
|
||||||
|
expect(document.activeElement).toBe(opener);
|
||||||
|
} finally {
|
||||||
|
opener.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Tab inside the player", () => {
|
||||||
|
open();
|
||||||
|
const dialog = screen.getByTestId("player-modal");
|
||||||
|
const tabbables = [...dialog.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]")];
|
||||||
|
const last = tabbables[tabbables.length - 1]!;
|
||||||
|
last.focus();
|
||||||
|
// jsdom has no layout, so the trap's visibility filter would see nothing tabbable; give every
|
||||||
|
// element an offsetParent for the duration of this check.
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
||||||
|
.mockReturnValue(document.body);
|
||||||
|
try {
|
||||||
|
fireEvent.keyDown(window, { key: "Tab" });
|
||||||
|
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||||
|
expect(document.activeElement).not.toBe(last);
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigationActions } from "./NavigationProvider";
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { usePref } from "../lib/usePref";
|
import { usePref } from "../lib/usePref";
|
||||||
import {
|
import { PlayerTransportControls } from "./playerModalControls";
|
||||||
AUTO_MODES,
|
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes";
|
||||||
LOOP_MODES,
|
|
||||||
PlayerTransportControls,
|
|
||||||
type AutoMode,
|
|
||||||
type LoopMode,
|
|
||||||
} from "./playerModalControls";
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -32,7 +27,14 @@ import {
|
|||||||
import Avatar from "./ui/Avatar";
|
import Avatar from "./ui/Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
import {
|
||||||
|
pushLayer,
|
||||||
|
removeLayer,
|
||||||
|
anyBlockingLayerAbove,
|
||||||
|
anyLayerAbove,
|
||||||
|
escapeHandled,
|
||||||
|
} from "../lib/layerStack";
|
||||||
|
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||||
import type { YTPlayer } from "../lib/youtube";
|
import type { YTPlayer } from "../lib/youtube";
|
||||||
@@ -46,7 +48,7 @@ import {
|
|||||||
import { renderDescription } from "../lib/descriptionLinks";
|
import { renderDescription } from "../lib/descriptionLinks";
|
||||||
import { fullscreenElement } from "../lib/fullscreen";
|
import { fullscreenElement } from "../lib/fullscreen";
|
||||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||||
import { closeThenNavigate, useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
import ChannelNameLink from "./ChannelNameLink";
|
import ChannelNameLink from "./ChannelNameLink";
|
||||||
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
||||||
import { afterRemoval } from "../lib/playerQueue";
|
import { afterRemoval } from "../lib/playerQueue";
|
||||||
@@ -166,6 +168,12 @@ export default function PlayerModal({
|
|||||||
// modal adjusts volume (not just the small player area).
|
// modal adjusts volume (not just the small player area).
|
||||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
const focusModal = () => cardRef.current?.focus();
|
const focusModal = () => cardRef.current?.focus();
|
||||||
|
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
|
||||||
|
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
|
||||||
|
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
|
||||||
|
// hand-rolled shell — no accessible name, no Tab trap, no restore (deep-review round 7).
|
||||||
|
useDialogChrome(cardRef, dialogRef, { focusScope: true });
|
||||||
|
const titleId = useId();
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
if (!p || typeof p.getPlayerState !== "function") return;
|
if (!p || typeof p.getPlayerState !== "function") return;
|
||||||
@@ -397,7 +405,7 @@ export default function PlayerModal({
|
|||||||
|
|
||||||
useKeyScope("ytPlayer");
|
useKeyScope("ytPlayer");
|
||||||
|
|
||||||
// Keyboard shortcuts + background scroll lock. The chords live in the shared keymap (`ytPlayer`
|
// Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer`
|
||||||
// scope) so the help sheet lists them; only the behaviour behind each id is here.
|
// scope) so the help sheet lists them; only the behaviour behind each id is here.
|
||||||
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
|
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
|
||||||
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
|
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
|
||||||
@@ -422,6 +430,14 @@ export default function PlayerModal({
|
|||||||
nudgeSeek(secs);
|
nudgeSeek(secs);
|
||||||
};
|
};
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
// The shared dialog Tab trap — only while nothing that owns the keyboard is layered above the
|
||||||
|
// player: a Modal opened over it (the download dialog) traps for itself. The title's hover card
|
||||||
|
// does not, so it must not switch the trap off.
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
|
||||||
|
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
dispatch(e, "ytPlayer", {
|
dispatch(e, "ytPlayer", {
|
||||||
close: (ev) => {
|
close: (ev) => {
|
||||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||||
@@ -468,11 +484,9 @@ export default function PlayerModal({
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
focusModal();
|
focusModal();
|
||||||
const prevOverflow = document.body.style.overflow;
|
// (The scroll lock is the shared, counted one in useDialogChrome.)
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
document.body.style.overflow = prevOverflow;
|
|
||||||
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
|
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
@@ -511,6 +525,7 @@ export default function PlayerModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
>
|
>
|
||||||
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
|
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
|
||||||
playlist). Each strip sits just outside the card, spans its full height, and fades out at
|
playlist). Each strip sits just outside the card, spans its full height, and fades out at
|
||||||
@@ -735,7 +750,7 @@ export default function PlayerModal({
|
|||||||
<div className="p-4 sm:p-5">
|
<div className="p-4 sm:p-5">
|
||||||
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||||
{/* Hover target is the text itself (inline), not the whole row. */}
|
{/* Hover target is the text itself (inline), not the whole row. */}
|
||||||
<span
|
<span
|
||||||
ref={descPop.refs.setReference}
|
ref={descPop.refs.setReference}
|
||||||
@@ -822,13 +837,11 @@ export default function PlayerModal({
|
|||||||
channelId={active.channel_id}
|
channelId={active.channel_id}
|
||||||
title={active.channel_title}
|
title={active.channel_title}
|
||||||
onOpenInApp={() => {
|
onOpenInApp={() => {
|
||||||
const cid = active.channel_id;
|
// openChannel closes this player first and pushes only once its
|
||||||
const cname = active.channel_title ?? undefined;
|
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
|
||||||
// Close first, open the channel only once this player's own Back-to-close
|
// and clicking the name merely closes the player
|
||||||
// entry has been reclaimed — otherwise that reclaim undoes the push and
|
// (lib/history.closeOverlaysThenNavigate).
|
||||||
// clicking the name merely closes the player. The rule lives in history.ts
|
openChannel(active.channel_id, active.channel_title ?? undefined);
|
||||||
// now; the command palette needs exactly the same one.
|
|
||||||
closeThenNavigate(onClose, () => openChannel(cid, cname));
|
|
||||||
}}
|
}}
|
||||||
className="font-medium hover:text-accent truncate text-left"
|
className="font-medium hover:text-accent truncate text-left"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -380,7 +380,26 @@ describe("Playlists — a change the server refused is taken back on screen", ()
|
|||||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||||
expect(h.notify).toHaveBeenCalledWith(
|
expect(h.notify).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ level: "error", message: "playlists.removeFailed" }),
|
expect.objectContaining({ level: "error", title: "playlists.removeFailed" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Playlists — the rows are a keyboard list", () => {
|
||||||
|
it("walks the rows with j and plays the focused one on Enter", async () => {
|
||||||
|
mount({ 1: [vid("A", 0), vid("B", 0)] });
|
||||||
|
await screen.findByRole("button", { name: "card.play — Video B" });
|
||||||
|
const press = (key: string) =>
|
||||||
|
act(() => {
|
||||||
|
(document.activeElement ?? document.body).dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
press("j");
|
||||||
|
press("j");
|
||||||
|
expect(document.activeElement?.tagName).toBe("TR");
|
||||||
|
expect(document.activeElement?.textContent).toContain("Video B");
|
||||||
|
press("Enter");
|
||||||
|
await waitFor(() => expect(h.player?.video.id).toBe("B"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,20 +56,16 @@ import { useComfortPin, pinVideo } from "./comfortPin";
|
|||||||
import { ContextMenu } from "./ui/ContextMenu";
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import UndoToolbar from "./ui/UndoToolbar";
|
import UndoToolbar from "./ui/UndoToolbar";
|
||||||
|
import NavGrid from "./ui/NavGrid";
|
||||||
import { PageToolbar } from "./ui/PageShell";
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { usePlaylistActions } from "./usePlaylistActions";
|
import { usePlaylistActions } from "./usePlaylistActions";
|
||||||
import { usePlaylists } from "./PlaylistsProvider";
|
import { usePlaylists } from "./PlaylistsProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { usePref } from "../lib/usePref";
|
import { usePref } from "../lib/usePref";
|
||||||
import {
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
ColumnHead,
|
import { ColumnHead, alignClass } from "./ui/tableHeader";
|
||||||
alignClass,
|
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
|
||||||
filterRows,
|
|
||||||
isActive,
|
|
||||||
type Column,
|
|
||||||
type FilterMap,
|
|
||||||
} from "./ui/tableHeader";
|
|
||||||
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
||||||
|
|
||||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||||
@@ -99,12 +95,15 @@ const idsKey = (items: Video[]) =>
|
|||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
video,
|
video,
|
||||||
|
index,
|
||||||
columns,
|
columns,
|
||||||
dragDisabled,
|
dragDisabled,
|
||||||
dragHint,
|
dragHint,
|
||||||
menuProps,
|
menuProps,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
|
/** Its position among the rows shown — the row is a keyboard item (lib/listNav). */
|
||||||
|
index: number;
|
||||||
columns: Column<Video>[];
|
columns: Column<Video>[];
|
||||||
dragDisabled: boolean;
|
dragDisabled: boolean;
|
||||||
dragHint: string;
|
dragHint: string;
|
||||||
@@ -131,6 +130,7 @@ function Row({
|
|||||||
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
||||||
// the same lag by another route (150ms behind the pointer, on every row).
|
// the same lag by another route (150ms behind the pointer, on every row).
|
||||||
className="border-b border-border/50 hover:bg-card/40 transition-colors"
|
className="border-b border-border/50 hover:bg-card/40 transition-colors"
|
||||||
|
{...navItemAttrs(index)}
|
||||||
{...menuProps}
|
{...menuProps}
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
@@ -676,16 +676,20 @@ export default function Playlists() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function playerState(id: string, status: string) {
|
function playerState(id: string, status: string) {
|
||||||
api.setState(id, status).then(() => {
|
api
|
||||||
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
|
.setState(id, status)
|
||||||
// list that is no longer the selected one (see `playing`), and that list holds the row whose
|
.then(() => {
|
||||||
// status just changed. Only lists actually on screen refetch.
|
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
|
||||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
// list that is no longer the selected one (see `playing`), and that list holds the row whose
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
// status just changed. Only lists actually on screen refetch.
|
||||||
});
|
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
|
})
|
||||||
|
// No catch used to mean an unhandled rejection and nothing said (E11 S2b).
|
||||||
|
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
|
// Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure
|
||||||
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
|
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
|
||||||
const closePlayer = useCallback(() => setPlaying(null), []);
|
const closePlayer = useCallback(() => setPlaying(null), []);
|
||||||
|
|
||||||
@@ -1089,10 +1093,14 @@ export default function Playlists() {
|
|||||||
items={shown.map((v) => v.id)}
|
items={shown.map((v) => v.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<tbody>
|
{/* The rows are a keyboard list: `j`/`k` and the arrows walk them, Enter plays. The grip
|
||||||
{shown.map((v) => (
|
keeps Space/Enter, and during a keyboard drag the arrows too (listNav leaves a key
|
||||||
|
dnd-kit has handled). */}
|
||||||
|
<NavGrid as="tbody">
|
||||||
|
{shown.map((v, i) => (
|
||||||
<Row
|
<Row
|
||||||
key={v.id}
|
key={v.id}
|
||||||
|
index={i}
|
||||||
video={v}
|
video={v}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dragDisabled={!canEdit || viewActive}
|
dragDisabled={!canEdit || viewActive}
|
||||||
@@ -1100,7 +1108,7 @@ export default function Playlists() {
|
|||||||
menuProps={rowMenu.triggerProps(v)}
|
menuProps={rowMenu.triggerProps(v)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</NavGrid>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</table>
|
</table>
|
||||||
{shown.length === 0 && (
|
{shown.length === 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The rail's in-row rename, from the keyboard. Enter and Escape unmount the focused field and render
|
||||||
|
// the row as a NEW button; without a refocus the keyboard user was dropped on <body> and lost their
|
||||||
|
// place (E10 review round 1, measured again in Chrome in E11 S2). Everything around the rail that is
|
||||||
|
// not the rename path is stubbed.
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||||
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||||
|
});
|
||||||
|
vi.mock("./PlaylistsProvider", () => ({
|
||||||
|
usePlaylists: () => ({ search: "", selectedId: 1 }),
|
||||||
|
usePlaylistsActions: () => ({ setSelectedId: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("../lib/useMe", () => ({ useMe: () => ({ can_write: true }) }));
|
||||||
|
vi.mock("./comfortPin", () => ({
|
||||||
|
useComfortPin: () => ({ item: () => [] }),
|
||||||
|
pinPlaylist: (id: number) => id,
|
||||||
|
}));
|
||||||
|
const rename = vi.fn(async () => {});
|
||||||
|
vi.mock("./usePlaylistActions", () => ({
|
||||||
|
usePlaylistActions: () => ({ rename, remove: vi.fn() }),
|
||||||
|
}));
|
||||||
|
// The SidePanel/PanelGroups chrome is not what is under test: render the rail's bodies directly.
|
||||||
|
vi.mock("./FilterRail", () => ({
|
||||||
|
default: ({ items }: { items: { id: string; body: ReactNode }[] }) => (
|
||||||
|
<>
|
||||||
|
{items.map((i) => (
|
||||||
|
<div key={i.id}>{i.body}</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import PlaylistsRail from "./PlaylistsRail";
|
||||||
|
import { api, type Playlist } from "../lib/api";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
Element.prototype.scrollIntoView ??= () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
rename.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const alpha = {
|
||||||
|
id: 1,
|
||||||
|
name: "alpha",
|
||||||
|
kind: "custom",
|
||||||
|
source: "local",
|
||||||
|
item_count: 3,
|
||||||
|
total_duration_seconds: 0,
|
||||||
|
cover_thumbnail: null,
|
||||||
|
dirty: false,
|
||||||
|
} as unknown as Playlist;
|
||||||
|
|
||||||
|
async function openRename() {
|
||||||
|
vi.spyOn(api, "playlists").mockResolvedValue([alpha]);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<FloatingTree>
|
||||||
|
<PlaylistsRail
|
||||||
|
layout={{} as never}
|
||||||
|
setLayout={() => {}}
|
||||||
|
collapsed={false}
|
||||||
|
onToggleCollapse={() => {}}
|
||||||
|
/>
|
||||||
|
</FloatingTree>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
const row = (await screen.findByText("alpha")).closest("button")!;
|
||||||
|
act(() => row.focus());
|
||||||
|
fireEvent.keyDown(row, { key: "F10", shiftKey: true });
|
||||||
|
fireEvent.click(await screen.findByText("playlists.rename"));
|
||||||
|
// The menu runs its action a task after it closes (see ui/ActionMenu).
|
||||||
|
const field = await screen.findByRole("textbox", { name: "playlists.rename" });
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(field));
|
||||||
|
return field as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowButton = () => screen.getByText("alpha").closest("button");
|
||||||
|
|
||||||
|
describe("PlaylistsRail · rename from the keyboard", () => {
|
||||||
|
it("Escape cancels and puts the focus back on the row", async () => {
|
||||||
|
const field = await openRename();
|
||||||
|
fireEvent.change(field, { target: { value: "alpha2" } });
|
||||||
|
fireEvent.keyDown(field, { key: "Escape" });
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
|
||||||
|
expect(rename).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter commits and puts the focus back on the row", async () => {
|
||||||
|
const field = await openRename();
|
||||||
|
fireEvent.change(field, { target: { value: "beta" } });
|
||||||
|
fireEvent.keyDown(field, { key: "Enter" });
|
||||||
|
expect(rename).toHaveBeenCalledWith(1, "beta");
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -160,6 +160,7 @@ export default function PlaylistsRail({
|
|||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (name: string) => api.createPlaylist(name),
|
mutationFn: (name: string) => api.createPlaylist(name),
|
||||||
|
meta: { failTitle: "errors.writeFailed.createPlaylist" },
|
||||||
onSuccess: (pl: Playlist) => {
|
onSuccess: (pl: Playlist) => {
|
||||||
setNewName("");
|
setNewName("");
|
||||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||||
@@ -167,35 +168,20 @@ export default function PlaylistsRail({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// KNOWN GAP: Enter and Escape unmount the focused <input> and re-render the row as a <button> that
|
// Enter and Escape unmount the focused <input> and re-render the row as a NEW <button>, so without
|
||||||
// nothing focuses, so a keyboard user is dropped on <body> and loses their place in the rail (E10
|
// this a keyboard user was dropped on <body> and lost their place in the rail (E10 review round 1).
|
||||||
// review round 1). A refocus-the-row fix was written in round 1 and REMOVED in round 3 because a
|
// The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome (E11
|
||||||
// real-browser check showed it never worked: Escape's handler does run (typing then Escape leaves
|
// S2), a focus issued after the commit sticks and nothing takes it back — the round-1 attempt that
|
||||||
// the name unchanged, i.e. it cancels rather than blur-commits) and the row button is focusable
|
// "never worked" focused before the replacement button existed. Set by Enter/Escape only: a
|
||||||
// when focused from outside — yet activeElement is still BODY afterwards. Three explanations were
|
// blur-commit means the user put the focus somewhere else on purpose.
|
||||||
// tried and none survived contact, so the machinery went rather than a fourth guess. Whoever picks
|
const refocusRowRef = useRef<number | null>(null);
|
||||||
// this up: instrument the effect in a real browser first, do not reason about the commit order.
|
|
||||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// UNRESOLVED — do not "simplify" this back to `autoFocus` without reading the whole story.
|
// Opening this field from the context menu used to race the menu's own focus return (floating-ui
|
||||||
//
|
// hands the focus back to the trigger from a microtask queued as the popover unmounts, and the field
|
||||||
// Opening this field from the context menu is a focus race: `MenuItemList` runs `onSelect()` and
|
// mounted in that same commit). `MenuItemList` now runs the item's action on a fresh task AFTER that
|
||||||
// `onClose()` in one batch, so the field mounts in the same commit that unmounts the popover, and
|
// return, so the field simply mounts and is focused here; measured in Chrome (E11 S2), the field is
|
||||||
// floating-ui's FloatingFocusManager hands focus back from a `queueMicrotask` queued in its
|
// focused and survives.
|
||||||
// layout-effect cleanup. If that microtask wins, the field is blurred straight back out and its
|
|
||||||
// own onBlur commits and closes it — "the rename field flashes and vanishes" (E10 review round 1).
|
|
||||||
//
|
|
||||||
// Three answers have been tried and none is VERIFIED. Round 1 turned floating-ui's returnFocus off
|
|
||||||
// platform-wide and broke two other surfaces (a non-focusable <tr> trigger; a scroll-dismiss that
|
|
||||||
// scrolled the list back). Round 2 replaced it with this effect, on the reasoning that a passive
|
|
||||||
// effect is scheduled after the microtask. Round 3 disputed that (React flushes passive effects
|
|
||||||
// synchronously at the end of a SyncLane commit, which would put the microtask LAST) — and a jsdom
|
|
||||||
// harness built to settle it reproduced no failure for ANY of autoFocus / this effect / a
|
|
||||||
// microtask, i.e. jsdom does not exercise the race, because focus never enters the menu there.
|
|
||||||
//
|
|
||||||
// So: this is the shape that is deliberate rather than accidental, and the question is open. It
|
|
||||||
// needs ONE real-browser check (open the rail's context menu, pick Rename, see whether the field
|
|
||||||
// survives) — not a fourth guess. Logged in the E10 plan.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (renamingId !== null) renameInputRef.current?.focus();
|
if (renamingId !== null) renameInputRef.current?.focus();
|
||||||
}, [renamingId]);
|
}, [renamingId]);
|
||||||
@@ -338,9 +324,15 @@ export default function PlaylistsRail({
|
|||||||
onChange={(e) => setRenameValue(e.target.value)}
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
onBlur={() => void commitRename(pl)}
|
onBlur={() => void commitRename(pl)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") void commitRename(pl);
|
if (e.key === "Enter") {
|
||||||
|
refocusRowRef.current = pl.id;
|
||||||
|
void commitRename(pl);
|
||||||
|
}
|
||||||
// Unmounting the input fires no blur in React, so Escape really does cancel.
|
// Unmounting the input fires no blur in React, so Escape really does cancel.
|
||||||
if (e.key === "Escape") setRenamingId(null);
|
if (e.key === "Escape") {
|
||||||
|
refocusRowRef.current = pl.id;
|
||||||
|
setRenamingId(null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={t("playlists.rename")}
|
aria-label={t("playlists.rename")}
|
||||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||||
@@ -349,7 +341,14 @@ export default function PlaylistsRail({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
key={pl.id}
|
key={pl.id}
|
||||||
ref={pl.id === selectedId ? selectedRef : null}
|
ref={(el) => {
|
||||||
|
if (pl.id === selectedId) selectedRef.current = el;
|
||||||
|
// Back from the rename field by Enter/Escape — see refocusRowRef.
|
||||||
|
if (el && refocusRowRef.current === pl.id) {
|
||||||
|
refocusRowRef.current = null;
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onClick={() => setSelectedId(pl.id)}
|
onClick={() => setSelectedId(pl.id)}
|
||||||
{...railMenu.triggerProps(pl)}
|
{...railMenu.triggerProps(pl)}
|
||||||
className={`${ROW_BASE} text-left ${
|
className={`${ROW_BASE} text-left ${
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { PlexPinMenu } from "./ComfortPinMenu";
|
|||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
||||||
import { dispatch, useKeyScope } from "../lib/keymap";
|
import { dispatch, useKeyScope } from "../lib/keymap";
|
||||||
|
import NavGrid from "./ui/NavGrid";
|
||||||
|
|
||||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||||
@@ -607,10 +608,11 @@ export default function PlexBrowse() {
|
|||||||
{episodes.length > 0 && (
|
{episodes.length > 0 && (
|
||||||
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
|
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
{items.map((c) => (
|
{items.map((c, i) => (
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
navIndex={i}
|
||||||
card={c}
|
card={c}
|
||||||
onOpen={() => onCard(c)}
|
onOpen={() => onCard(c)}
|
||||||
onInfo={() => onInfo(c)}
|
onInfo={() => onInfo(c)}
|
||||||
@@ -618,24 +620,25 @@ export default function PlexBrowse() {
|
|||||||
menuProps={pinMenu.triggerProps}
|
menuProps={pinMenu.triggerProps}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
|
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
|
||||||
{episodes.length > 0 && (
|
{episodes.length > 0 && (
|
||||||
<section className="mt-8">
|
<section className="mt-8">
|
||||||
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
|
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||||
{episodes.map((ep) => (
|
{episodes.map((ep, i) => (
|
||||||
<EpisodeCard
|
<EpisodeCard
|
||||||
key={ep.id}
|
key={ep.id}
|
||||||
|
navIndex={i}
|
||||||
ep={ep}
|
ep={ep}
|
||||||
withShowTitle
|
withShowTitle
|
||||||
onPlay={() => onCard(ep)}
|
onPlay={() => onCard(ep)}
|
||||||
onToggleWatched={() => toggleWatched(ep)}
|
onToggleWatched={() => toggleWatched(ep)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useContextMenu } from "./ui/useContextMenu";
|
|||||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
|
import NavGrid from "./ui/NavGrid";
|
||||||
|
|
||||||
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
|
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
|
||||||
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
|
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
|
||||||
@@ -398,11 +399,12 @@ export default function PlexCollectionView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
<NavGrid className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||||
{items.map((c) => (
|
{items.map((c, i) => (
|
||||||
<div key={c.id} className="relative">
|
<div key={c.id} className="relative">
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
card={c}
|
card={c}
|
||||||
|
navIndex={i}
|
||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
onInfo={() => onInfoCard(c)}
|
onInfo={() => onInfoCard(c)}
|
||||||
onToggleWatched={toggleWatched}
|
onToggleWatched={toggleWatched}
|
||||||
@@ -421,7 +423,7 @@ export default function PlexCollectionView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve
|
// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve
|
||||||
// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
||||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -263,3 +263,40 @@ describe("PlexDiscover", () => {
|
|||||||
expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull();
|
expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E11 S4b: every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on
|
||||||
|
// from one to the next in page order (owner decision D4).
|
||||||
|
describe("PlexDiscover · keyboard", () => {
|
||||||
|
it("registers every rail and the genre grid, and j walks on from rail to rail", async () => {
|
||||||
|
plexDiscover.mockResolvedValue(fullPayload);
|
||||||
|
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
|
||||||
|
wrap(
|
||||||
|
<PlexDiscover
|
||||||
|
scope="both"
|
||||||
|
onOpenCard={noop}
|
||||||
|
onInfoCard={noop}
|
||||||
|
onGenre={noop}
|
||||||
|
onSeeAllRecent={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await screen.findAllByText("Random Pick");
|
||||||
|
// Five rails (continue, recently watched, recently added, because-you-watched, surprise) + genres.
|
||||||
|
const lists = [...document.querySelectorAll("[data-nav-list]")];
|
||||||
|
expect(lists.length).toBe(6);
|
||||||
|
// …and each holds its cards as items: the genre tiles too, not only the posters.
|
||||||
|
expect(lists.every((list) => list.querySelector("[data-nav-index]"))).toBe(true);
|
||||||
|
const step = () => {
|
||||||
|
act(() => {
|
||||||
|
(document.activeElement ?? document.body).dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return (document.activeElement as HTMLElement).closest("[data-nav-index]")?.textContent;
|
||||||
|
};
|
||||||
|
const first = step();
|
||||||
|
const second = step();
|
||||||
|
expect(first).toBeTruthy();
|
||||||
|
expect(second).toBeTruthy();
|
||||||
|
expect(second).not.toBe(first); // one card per rail: the second press crossed into the next rail
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PlexPosterCard } from "./plexCards";
|
|||||||
import { PlexPinMenu } from "./ComfortPinMenu";
|
import { PlexPinMenu } from "./ComfortPinMenu";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
|
import NavGrid from "./ui/NavGrid";
|
||||||
|
|
||||||
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
|
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
|
||||||
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
|
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
|
||||||
@@ -55,11 +56,18 @@ function CardRail({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section className="mb-5">
|
<section className="mb-5">
|
||||||
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
|
<Carousel
|
||||||
{cards.map((c) => (
|
title={heading}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
onSeeAll={onSeeAll}
|
||||||
|
seeAllLabel={seeAllLabel}
|
||||||
|
keyboardNav
|
||||||
|
>
|
||||||
|
{cards.map((c, i) => (
|
||||||
<div key={c.id} className="w-[150px] shrink-0">
|
<div key={c.id} className="w-[150px] shrink-0">
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
card={c}
|
card={c}
|
||||||
|
navIndex={i}
|
||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
onInfo={() => onInfoCard(c)}
|
onInfo={() => onInfoCard(c)}
|
||||||
onToggleWatched={onToggleWatched}
|
onToggleWatched={onToggleWatched}
|
||||||
@@ -222,17 +230,18 @@ export default function PlexDiscover({
|
|||||||
{d.genres.length > 0 && (
|
{d.genres.length > 0 && (
|
||||||
<section className="mb-5">
|
<section className="mb-5">
|
||||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||||
{d.genres.map((g) => (
|
{d.genres.map((g, i) => (
|
||||||
<PosterTile
|
<PosterTile
|
||||||
key={g.value}
|
key={g.value}
|
||||||
|
navIndex={i}
|
||||||
title={g.value}
|
title={g.value}
|
||||||
art={g.art}
|
art={g.art}
|
||||||
onClick={() => onGenre(g.value)}
|
onClick={() => onGenre(g.value)}
|
||||||
className="h-24"
|
className="h-24"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NavGrid>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Carousel } from "./ui/Carousel";
|
|||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
import { PLAYABLE_TINT, playableClass } from "./plexCards";
|
import { PLAYABLE_TINT, playableClass } from "./plexCards";
|
||||||
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
|
||||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||||
@@ -649,7 +650,11 @@ export default function PlexInfo({
|
|||||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||||
{t("plex.info.cast")}
|
{t("plex.info.cast")}
|
||||||
</h2>
|
</h2>
|
||||||
<Carousel ariaLabel={t("plex.info.cast")} gap="gap-3">
|
<Carousel
|
||||||
|
ariaLabel={t("plex.info.cast")}
|
||||||
|
gap="gap-3"
|
||||||
|
keyboardNav={!!(onOpenPerson || onFilter)}
|
||||||
|
>
|
||||||
{cast.map((c, i) => {
|
{cast.map((c, i) => {
|
||||||
const clickable = !!(onOpenPerson || onFilter);
|
const clickable = !!(onOpenPerson || onFilter);
|
||||||
const inner = (
|
const inner = (
|
||||||
@@ -686,6 +691,7 @@ export default function PlexInfo({
|
|||||||
return clickable ? (
|
return clickable ? (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
|
{...navItemAttrs(i)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
||||||
}
|
}
|
||||||
@@ -734,10 +740,11 @@ export default function PlexInfo({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Carousel ariaLabel={col.title} gap="gap-3">
|
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
|
||||||
{col.items.map((m) => (
|
{col.items.map((m, i) => (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
|
{...navItemAttrs(i)}
|
||||||
onClick={() => onPlayItem?.(m.id)}
|
onClick={() => onPlayItem?.(m.id)}
|
||||||
disabled={!onPlayItem}
|
disabled={!onPlayItem}
|
||||||
className="group/col w-28 shrink-0 text-left"
|
className="group/col w-28 shrink-0 text-left"
|
||||||
|
|||||||
@@ -915,7 +915,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
info: () => setInfoOpen((v) => !v),
|
info: () => setInfoOpen((v) => !v),
|
||||||
stats: () => setStatsOpen((v) => !v),
|
stats: () => setStatsOpen((v) => !v),
|
||||||
back: (ev) => {
|
back: (ev) => {
|
||||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
|
||||||
|
// Back button (shared cascade).
|
||||||
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
|
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
|
||||||
// and its focused close button is not a typing target, so the key reaches us regardless.
|
// and its focused close button is not a typing target, so the key reaches us regardless.
|
||||||
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
|
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
|
||||||
|
|||||||
@@ -180,6 +180,25 @@ describe("PlexSearchBox — typeahead", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E11 S4: SearchBar's Escape hands the keyboard back to the page (blur). With the panel open, the
|
||||||
|
// first Escape belongs to the panel: it must close it and leave the caret in the field; only the
|
||||||
|
// next Escape leaves.
|
||||||
|
it("spends the first Escape on the panel and only the second on leaving the field", async () => {
|
||||||
|
plexState.q = "matrix";
|
||||||
|
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||||
|
renderBox();
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
input.focus();
|
||||||
|
await screen.findByRole("listbox");
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: "Escape" });
|
||||||
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: "Escape" });
|
||||||
|
expect(document.activeElement).not.toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
|
it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
|
||||||
plexState.q = "matrix";
|
plexState.q = "matrix";
|
||||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export default function PlexSearchBox() {
|
|||||||
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
|
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
|
||||||
pick(hi);
|
pick(hi);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault(); // close the panel only; SearchBar's Escape-to-blur waits for the next press
|
||||||
setDismissed(true);
|
setDismissed(true);
|
||||||
setHi(-1); // don't leave a stale highlight to flash on the next reopen
|
setHi(-1); // don't leave a stale highlight to flash on the next reopen
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { plexLang } from "../i18n";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { type PanelLayout } from "../lib/panelLayout";
|
import { type PanelLayout } from "../lib/panelLayout";
|
||||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
@@ -377,9 +378,14 @@ function PlaylistPicker() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function createPlaylist(title: string) {
|
async function createPlaylist(title: string) {
|
||||||
const pl = await api.plexCreatePlaylist(title);
|
try {
|
||||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
const pl = await api.plexCreatePlaylist(title);
|
||||||
onOpenPlaylist(pl.id);
|
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||||
|
onOpenPlaylist(pl.id);
|
||||||
|
} catch (e) {
|
||||||
|
// Called without an await from the picker, so a refusal was an unhandled rejection (E11 S2b).
|
||||||
|
notifyActionError(e, t("plex.playlistAdd.createFailed"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
||||||
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
|
||||||
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
||||||
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
||||||
@@ -12,6 +13,7 @@ export default function PosterTile({
|
|||||||
art,
|
art,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
|
navIndex,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m")
|
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m")
|
||||||
@@ -19,6 +21,7 @@ export default function PosterTile({
|
|||||||
art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash
|
art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
|
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
|
||||||
|
navIndex?: number; // position in a keyboard-walkable grid (lib/listNav); Enter is the button's own
|
||||||
}) {
|
}) {
|
||||||
const posters = (art ?? []).slice(0, 2);
|
const posters = (art ?? []).slice(0, 2);
|
||||||
const band = hueTileGradient(hueOf(title));
|
const band = hueTileGradient(hueOf(title));
|
||||||
@@ -27,6 +30,7 @@ export default function PosterTile({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={title}
|
title={title}
|
||||||
|
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||||
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
|
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
|
||||||
>
|
>
|
||||||
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
||||||
|
|||||||
@@ -77,10 +77,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
invalidate();
|
invalidate();
|
||||||
setFormOpen(false);
|
setFormOpen(false);
|
||||||
},
|
},
|
||||||
|
meta: { failTitle: "errors.writeFailed.downloadProfileSave" },
|
||||||
});
|
});
|
||||||
const del = useMutation({
|
const del = useMutation({
|
||||||
mutationFn: (id: number) => api.deleteDownloadProfile(id),
|
mutationFn: (id: number) => api.deleteDownloadProfile(id),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
|
meta: { failTitle: "errors.writeFailed.downloadProfileDelete" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDelete = async (p: DownloadProfile) => {
|
const onDelete = async (p: DownloadProfile) => {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// "Manage views…" opens a dialog from a menu row, so it must close the menu FIRST and open the dialog
|
||||||
|
// on a fresh task (`closeThenAct`): the menu's focus return, queued as it unmounts, otherwise takes the
|
||||||
|
// dialog's focus back to the trigger behind its scrim — measured in Chrome on ActionMenu (E11 S2), and
|
||||||
|
// this row was the sibling that fix missed (deep-review E11 round 1). jsdom does not run that focus
|
||||||
|
// race, so the ORDER is what is pinned: the menu is gone before the dialog is asked to open.
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
|
|
||||||
|
const openModal = vi.fn();
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
vi.mock("./SavedViewsProvider", () => ({
|
||||||
|
useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import SavedViewsPopover from "./SavedViewsPopover";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SavedViewsPopover · Manage views…", () => {
|
||||||
|
it("closes the menu first, then opens the dialog once", async () => {
|
||||||
|
render(
|
||||||
|
<FloatingTree>
|
||||||
|
<SavedViewsPopover trigger="views" ariaLabel="Saved views" />
|
||||||
|
</FloatingTree>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Saved views" }));
|
||||||
|
// floating-ui never positions in jsdom, so the surface stays visibility:hidden and the row has no
|
||||||
|
// accessible name to query by: find it by its text.
|
||||||
|
fireEvent.click(screen.getByText("views.manage"));
|
||||||
|
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
|
||||||
|
expect(openModal).not.toHaveBeenCalled();
|
||||||
|
await vi.waitFor(() => expect(openModal).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Bookmark, Check, Settings2 } from "lucide-react";
|
import { Bookmark, Check, Settings2 } from "lucide-react";
|
||||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
import {
|
||||||
|
PopoverItem,
|
||||||
|
PopoverSurface,
|
||||||
|
closeThenAct,
|
||||||
|
useAnchoredPopover,
|
||||||
|
} from "./ui/AnchoredPopover";
|
||||||
import { useSavedViews } from "./SavedViewsProvider";
|
import { useSavedViews } from "./SavedViewsProvider";
|
||||||
|
|
||||||
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
|
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
|
||||||
@@ -67,10 +72,8 @@ export default function SavedViewsPopover({
|
|||||||
<div className="mt-1 border-t border-border pt-1">
|
<div className="mt-1 border-t border-border pt-1">
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onSelect={() => {
|
// Opens a dialog: close first, or the menu's focus return steals its focus (closeThenAct).
|
||||||
openModal();
|
onSelect={() => closeThenAct(() => setOpen(false), openModal)}
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
label={t("views.manage")}
|
label={t("views.manage")}
|
||||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ export default function Scheduler() {
|
|||||||
// live poll surfaces the "running" state within a tick, so we just nudge a refetch.
|
// live poll surfaces the "running" state within a tick, so we just nudge a refetch.
|
||||||
const runMut = useMutation({
|
const runMut = useMutation({
|
||||||
mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
|
mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
|
||||||
|
meta: { failTitle: "errors.writeFailed.schedulerRun" },
|
||||||
onSuccess: (_d, jobId) => {
|
onSuccess: (_d, jobId) => {
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
@@ -400,6 +401,7 @@ export default function Scheduler() {
|
|||||||
});
|
});
|
||||||
const runAllMut = useMutation({
|
const runAllMut = useMutation({
|
||||||
mutationFn: () => api.runAllSchedulerJobs(),
|
mutationFn: () => api.runAllSchedulerJobs(),
|
||||||
|
meta: { failTitle: "errors.writeFailed.schedulerRunAll" },
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
@@ -410,10 +412,12 @@ export default function Scheduler() {
|
|||||||
});
|
});
|
||||||
const batchMut = useMutation({
|
const batchMut = useMutation({
|
||||||
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
||||||
|
meta: { failTitle: "errors.writeFailed.maintenanceBatch" },
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
|
||||||
});
|
});
|
||||||
const purgeMut = useMutation({
|
const purgeMut = useMutation({
|
||||||
mutationFn: () => api.purgeDiscovery(),
|
mutationFn: () => api.purgeDiscovery(),
|
||||||
|
meta: { failTitle: "errors.writeFailed.purgeDiscovery" },
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
const removed =
|
const removed =
|
||||||
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
|
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E11 S4: `s` puts the caret in the module's search box from anywhere on the page, and Escape hands
|
||||||
|
// the keyboard back to the page — the way in needs a way out, or `j`/`k` end up typed into the query.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import SearchBar from "./SearchBar";
|
||||||
|
import KeyboardHelp from "./ui/KeyboardHelp";
|
||||||
|
import { closeKeyboardHelp, toggleKeyboardHelp } from "../lib/keymap";
|
||||||
|
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
closeKeyboardHelp();
|
||||||
|
});
|
||||||
|
|
||||||
|
function press(init: KeyboardEventInit, on?: Element | null): KeyboardEvent {
|
||||||
|
const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
|
||||||
|
act(() => {
|
||||||
|
(on ?? document.activeElement ?? document.body).dispatchEvent(e);
|
||||||
|
});
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = () => screen.getByPlaceholderText("Search…") as HTMLInputElement;
|
||||||
|
|
||||||
|
describe("SearchBar keys", () => {
|
||||||
|
it("focuses the box on s, selects the old query, and keeps the s out of it", () => {
|
||||||
|
render(<SearchBar value="old query" onChange={() => {}} placeholder="Search…" />);
|
||||||
|
const e = press({ key: "s" });
|
||||||
|
expect(document.activeElement).toBe(field());
|
||||||
|
expect(e.defaultPrevented).toBe(true); // the `s` is not typed into the box it just focused
|
||||||
|
expect([field().selectionStart, field().selectionEnd]).toEqual([0, "old query".length]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an s typed into another field alone", () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<input data-testid="other" />
|
||||||
|
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
const other = screen.getByTestId("other");
|
||||||
|
act(() => other.focus());
|
||||||
|
expect(press({ key: "s" }).defaultPrevented).toBe(false);
|
||||||
|
expect(document.activeElement).toBe(other);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet under a dialog", () => {
|
||||||
|
render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
||||||
|
const id = pushLayer();
|
||||||
|
press({ key: "s" });
|
||||||
|
removeLayer(id);
|
||||||
|
expect(document.activeElement).not.toBe(field());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the keyboard back to the page on Escape", () => {
|
||||||
|
render(<SearchBar value="abc" onChange={() => {}} placeholder="Search…" />);
|
||||||
|
press({ key: "s" });
|
||||||
|
press({ key: "Escape" });
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plex's typeahead uses its first Escape to close the result panel; the field keeps focus for that.
|
||||||
|
it("lets a caller keep its own Escape", () => {
|
||||||
|
render(
|
||||||
|
<SearchBar
|
||||||
|
value="abc"
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Search…"
|
||||||
|
onKeyDown={(e) => e.key === "Escape" && e.preventDefault()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
press({ key: "s" });
|
||||||
|
press({ key: "Escape" });
|
||||||
|
expect(document.activeElement).toBe(field());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Through the real hook: the sheet offers `s` only where a search box is mounted.
|
||||||
|
it("is listed in the help sheet while a search box is mounted", () => {
|
||||||
|
const { unmount } = render(
|
||||||
|
<>
|
||||||
|
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
|
||||||
|
<KeyboardHelp />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
act(() => toggleKeyboardHelp());
|
||||||
|
expect(screen.getAllByRole("definition").map((d) => d.textContent)).toContain("keys.search");
|
||||||
|
unmount();
|
||||||
|
closeKeyboardHelp();
|
||||||
|
|
||||||
|
render(<KeyboardHelp />);
|
||||||
|
act(() => toggleKeyboardHelp());
|
||||||
|
expect(screen.getAllByRole("definition").map((d) => d.textContent)).not.toContain(
|
||||||
|
"keys.search",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opened over a player, the page keys are quiet — the sheet must not list them there.
|
||||||
|
it("is left off a sheet opened over a keyboard-owning layer", () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
|
||||||
|
<KeyboardHelp />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
const id = pushLayer();
|
||||||
|
act(() => toggleKeyboardHelp());
|
||||||
|
expect(screen.queryAllByRole("definition").map((d) => d.textContent)).not.toContain(
|
||||||
|
"keys.search",
|
||||||
|
);
|
||||||
|
removeLayer(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
useRef,
|
||||||
type FocusEventHandler,
|
type FocusEventHandler,
|
||||||
type InputHTMLAttributes,
|
type InputHTMLAttributes,
|
||||||
type KeyboardEventHandler,
|
type KeyboardEventHandler,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
|
import { useSearchFocusKey } from "../lib/keymap";
|
||||||
|
|
||||||
// The floating search pill in the top header. One component for every module that searches
|
// The floating search pill in the top header. One component for every module that searches
|
||||||
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
|
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
|
||||||
@@ -52,6 +54,9 @@ export default function SearchBar({
|
|||||||
inputProps?: InputHTMLAttributes<HTMLInputElement>;
|
inputProps?: InputHTMLAttributes<HTMLInputElement>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// `s` puts the caret here from anywhere on the page (E11 S4) — the header mounts exactly one box.
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useSearchFocusKey(inputRef);
|
||||||
const fire = () => {
|
const fire = () => {
|
||||||
if (onGo && !goDisabled) onGo();
|
if (onGo && !goDisabled) onGo();
|
||||||
};
|
};
|
||||||
@@ -67,13 +72,19 @@ export default function SearchBar({
|
|||||||
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
||||||
<input
|
<input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
|
ref={inputRef}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
onKeyDown?.(e);
|
onKeyDown?.(e);
|
||||||
if (e.key === "Enter" && !e.defaultPrevented) fire();
|
if (e.defaultPrevented) return;
|
||||||
|
if (e.key === "Enter") fire();
|
||||||
|
// Escape hands the keyboard back to the page, where `j`/`k` and the `g` chords live — `s`
|
||||||
|
// got you in, so there has to be a key that gets you out. The query stays as typed. A caller
|
||||||
|
// that uses Escape itself (Plex's typeahead closing its panel) preventDefaults first.
|
||||||
|
else if (e.key === "Escape") e.currentTarget.blur();
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, HttpError, type Me } from "../lib/api";
|
import { api, HttpError, type Me } from "../lib/api";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||||
import Avatar from "./ui/Avatar";
|
import Avatar from "./ui/Avatar";
|
||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
@@ -383,8 +384,8 @@ function SignInMethods({ me }: { me: Me }) {
|
|||||||
try {
|
try {
|
||||||
await api.logoutOthers();
|
await api.logoutOthers();
|
||||||
notify({ level: "success", message: t("settings.account.sessions.done") });
|
notify({ level: "success", message: t("settings.account.sessions.done") });
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify({ level: "error", message: t("settings.account.sessions.failed") });
|
notifyActionError(e, t("settings.account.sessions.failed"));
|
||||||
} finally {
|
} finally {
|
||||||
setSessBusy(false);
|
setSessBusy(false);
|
||||||
}
|
}
|
||||||
@@ -553,10 +554,11 @@ function PlexWatchSync() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
|
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
|
||||||
// "connect YouTube"), and their 409/500 are already surfaced by the global error modal — a caller
|
// "connect YouTube"). Their 409/500 open the global error dialog and the rest go to the app-wide
|
||||||
// toast would double it. Actual import failures come back as 200 {error}, handled by announce.
|
// reporter (lib/queryClient). Actual import failures come back as 200 {error}, handled by announce.
|
||||||
const toggle = useMutation({
|
const toggle = useMutation({
|
||||||
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
||||||
|
meta: { failTitle: "errors.writeFailed.plexWatchLink" },
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
qc.setQueryData(qk.plexWatchLink(), res);
|
qc.setQueryData(qk.plexWatchLink(), res);
|
||||||
announce(res.import);
|
announce(res.import);
|
||||||
@@ -564,6 +566,7 @@ function PlexWatchSync() {
|
|||||||
});
|
});
|
||||||
const reimport = useMutation({
|
const reimport = useMutation({
|
||||||
mutationFn: () => api.plexWatchImport(),
|
mutationFn: () => api.plexWatchImport(),
|
||||||
|
meta: { failTitle: "errors.writeFailed.plexWatchImport" },
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
qc.setQueryData(qk.plexWatchLink(), res);
|
qc.setQueryData(qk.plexWatchLink(), res);
|
||||||
announce(res.import);
|
announce(res.import);
|
||||||
@@ -637,10 +640,13 @@ export function DownloadAuth() {
|
|||||||
notify({ level: "success", message: t("settings.downloadAuth.saved") });
|
notify({ level: "success", message: t("settings.downloadAuth.saved") });
|
||||||
},
|
},
|
||||||
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
|
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
|
||||||
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail).
|
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the
|
||||||
|
// statuses it does not cover go to the app-wide reporter.
|
||||||
|
meta: { failTitle: "settings.downloadAuth.failed" },
|
||||||
});
|
});
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (host: string) => api.deleteDownloadCredential(host),
|
mutationFn: (host: string) => api.deleteDownloadCredential(host),
|
||||||
|
meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" },
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
qc.setQueryData(qk.downloadCredentials(), res);
|
qc.setQueryData(qk.downloadCredentials(), res);
|
||||||
notify({ level: "success", message: t("settings.downloadAuth.removed") });
|
notify({ level: "success", message: t("settings.downloadAuth.removed") });
|
||||||
@@ -781,8 +787,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||||||
await api.deleteAccount();
|
await api.deleteAccount();
|
||||||
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
|
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
|
||||||
window.location.href = "/?deleted=1";
|
window.location.href = "/?deleted=1";
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* the global error dialog surfaces the reason (e.g. last admin) */
|
// A refusal with a reason (the last admin, a 409) is already in the global error dialog, which
|
||||||
|
// the reporter skips; anything else used to end the confirmed delete with nothing said.
|
||||||
|
notifyActionError(e, t("errors.writeFailed.deleteAccount"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -106,15 +106,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
const toggle = useMutation({
|
const toggle = useMutation({
|
||||||
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
||||||
onSuccess: onChanged,
|
onSuccess: onChanged,
|
||||||
|
meta: { failTitle: "errors.writeFailed.shareLinkDownload" },
|
||||||
});
|
});
|
||||||
const revoke = useMutation({
|
const revoke = useMutation({
|
||||||
mutationFn: () => api.revokeDownloadLink(link.id),
|
mutationFn: () => api.revokeDownloadLink(link.id),
|
||||||
onSuccess: onChanged,
|
onSuccess: onChanged,
|
||||||
|
meta: { failTitle: "errors.writeFailed.shareLinkRevoke" },
|
||||||
});
|
});
|
||||||
// Set / change / clear the link password. Rotating (or clearing) it bumps the link's
|
// Set / change / clear the link password. Rotating (or clearing) it bumps the link's
|
||||||
// password_version server-side, which invalidates any watch grant issued under the old password.
|
// password_version server-side, which invalidates any watch grant issued under the old password.
|
||||||
const savePass = useMutation({
|
const savePass = useMutation({
|
||||||
mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }),
|
mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }),
|
||||||
|
meta: { failTitle: "errors.writeFailed.shareLinkPassword" },
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setEditPass(false);
|
setEditPass(false);
|
||||||
setNewPass("");
|
setNewPass("");
|
||||||
@@ -282,6 +285,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
setExpiryDays(null);
|
setExpiryDays(null);
|
||||||
setPassword("");
|
setPassword("");
|
||||||
},
|
},
|
||||||
|
meta: { failTitle: "downloads.share.linkFailed" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ function SystemStats() {
|
|||||||
const pauseResume = useMutation({
|
const pauseResume = useMutation({
|
||||||
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
||||||
|
onError: (e, paused) =>
|
||||||
|
notifyActionError(
|
||||||
|
e,
|
||||||
|
t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const data = q.data;
|
const data = q.data;
|
||||||
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
|
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||||
import { api, type MyStatus } from "../lib/api";
|
import { api, type MyStatus } from "../lib/api";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { formatViews } from "../lib/format";
|
import { formatViews } from "../lib/format";
|
||||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||||
|
|
||||||
@@ -53,6 +54,11 @@ export default function SyncStatus({
|
|||||||
const toggle = useMutation({
|
const toggle = useMutation({
|
||||||
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
|
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
||||||
|
onError: (e) =>
|
||||||
|
notifyActionError(
|
||||||
|
e,
|
||||||
|
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export default function TagManager({
|
|||||||
};
|
};
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
|
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
|
||||||
|
meta: { failTitle: "channels.tags.createFailed" },
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNewName("");
|
setNewName("");
|
||||||
invalidate();
|
invalidate();
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E11 S4: the video card's half of keyboard navigation (`lib/listNav`). Every layout's ROOT carries the
|
||||||
|
// list index — that is the element `j`/`k` focus and ring — and the title link is the card's `open`
|
||||||
|
// element, so Enter on a selected card runs the very click a mouse would: the in-app player.
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
vi.mock("./NavigationProvider", () => ({
|
||||||
|
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import VideoCard from "./VideoCard";
|
||||||
|
import type { Video } from "../lib/api";
|
||||||
|
import type { FeedView } from "../lib/feedView";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
const video = {
|
||||||
|
id: "abc123",
|
||||||
|
title: "Cicada 3301",
|
||||||
|
channel_id: "UCsXVk37bltHxD1rDPwtNM8Q",
|
||||||
|
channel_title: "LEMMiNO",
|
||||||
|
status: "new",
|
||||||
|
saved: false,
|
||||||
|
position_seconds: 0,
|
||||||
|
duration_seconds: 1200,
|
||||||
|
published_at: "2026-01-01T00:00:00Z",
|
||||||
|
watch_url: "https://www.youtube.com/watch?v=abc123",
|
||||||
|
thumbnail_url: "",
|
||||||
|
} as unknown as Video;
|
||||||
|
|
||||||
|
beforeEach(cleanup);
|
||||||
|
|
||||||
|
function renderCard(view: FeedView, navIndex?: number, onOpen = vi.fn()) {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<VideoCard
|
||||||
|
video={video}
|
||||||
|
view={view}
|
||||||
|
width={0}
|
||||||
|
navIndex={navIndex}
|
||||||
|
onState={() => {}}
|
||||||
|
onToggleSave={() => {}}
|
||||||
|
onOpen={onOpen}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return onOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One per layout FAMILY: the stacked card, the thumbnailed row, the one-line row — three roots.
|
||||||
|
const VIEWS: FeedView[] = ["cards", "rows", "rowsCompact"];
|
||||||
|
|
||||||
|
describe("VideoCard — keyboard navigation hooks", () => {
|
||||||
|
for (const view of VIEWS) {
|
||||||
|
it(`puts the list index on the ${view} root and opens in-app from the title`, () => {
|
||||||
|
const onOpen = renderCard(view, 7);
|
||||||
|
const root = screen.getByTestId("video-card");
|
||||||
|
expect(root.getAttribute("data-nav-index")).toBe("7");
|
||||||
|
const open = root.querySelectorAll("[data-nav-open]");
|
||||||
|
expect(open).toHaveLength(1);
|
||||||
|
expect(open[0]!.textContent).toBe("Cicada 3301");
|
||||||
|
(open[0] as HTMLElement).click();
|
||||||
|
expect(onOpen).toHaveBeenCalledWith(video);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("carries no index outside a navigable list (Home's rails)", () => {
|
||||||
|
renderCard("cards");
|
||||||
|
expect(screen.getByTestId("video-card").hasAttribute("data-nav-index")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import Avatar from "./ui/Avatar";
|
|||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import VideoInfoCard from "./VideoInfoCard";
|
import VideoInfoCard from "./VideoInfoCard";
|
||||||
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { Video, VideoStatus } from "../lib/api";
|
import type { Video, VideoStatus } from "../lib/api";
|
||||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||||
@@ -388,6 +389,7 @@ function VideoCard({
|
|||||||
onToggleSave,
|
onToggleSave,
|
||||||
onOpen,
|
onOpen,
|
||||||
menuProps,
|
menuProps,
|
||||||
|
navIndex,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
view: FeedView;
|
view: FeedView;
|
||||||
@@ -403,6 +405,8 @@ function VideoCard({
|
|||||||
* blow past this component's memo for every card in the virtualized feed on every parent
|
* blow past this component's memo for every card in the virtualized feed on every parent
|
||||||
* render. The card calls it with the video it already has. */
|
* render. The card calls it with the video it already has. */
|
||||||
menuProps?: (v: Video) => Record<string, unknown>;
|
menuProps?: (v: Video) => Record<string, unknown>;
|
||||||
|
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
|
||||||
|
navIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { openChannel } = useNavigationActions();
|
const { openChannel } = useNavigationActions();
|
||||||
@@ -465,6 +469,9 @@ function VideoCard({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
onClick={(e) => openInApp(e, video, onOpen)}
|
onClick={(e) => openInApp(e, video, onOpen)}
|
||||||
|
// Enter on a keyboard-selected card clicks THIS link (lib/listNav), so it opens exactly like a
|
||||||
|
// click: the in-app player, resuming where the reader left off.
|
||||||
|
data-nav-open=""
|
||||||
title={titleClamped ? (video.title ?? undefined) : undefined}
|
title={titleClamped ? (video.title ?? undefined) : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-medium leading-snug hover:text-accent",
|
"font-medium leading-snug hover:text-accent",
|
||||||
@@ -534,6 +541,7 @@ function VideoCard({
|
|||||||
data-testid="video-card"
|
data-testid="video-card"
|
||||||
data-video-id={video.id}
|
data-video-id={video.id}
|
||||||
{...menuProps?.(video)}
|
{...menuProps?.(video)}
|
||||||
|
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||||
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
|
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
|
||||||
>
|
>
|
||||||
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
|
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
|
||||||
@@ -597,6 +605,7 @@ function VideoCard({
|
|||||||
data-testid="video-card"
|
data-testid="video-card"
|
||||||
data-video-id={video.id}
|
data-video-id={video.id}
|
||||||
{...menuProps?.(video)}
|
{...menuProps?.(video)}
|
||||||
|
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||||
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
|
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
|
||||||
>
|
>
|
||||||
<Thumb
|
<Thumb
|
||||||
@@ -631,6 +640,7 @@ function VideoCard({
|
|||||||
data-testid="video-card"
|
data-testid="video-card"
|
||||||
data-video-id={video.id}
|
data-video-id={video.id}
|
||||||
{...menuProps?.(video)}
|
{...menuProps?.(video)}
|
||||||
|
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
|
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
|
||||||
"flex flex-col",
|
"flex flex-col",
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A split export that fails part-way (deep-review E11 rounds 2 and 3). Every clip is its own job,
|
||||||
|
// committed as it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor
|
||||||
|
// reported a plain failure and kept both selected — and trying again queued clip 1 a second time. It
|
||||||
|
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
|
||||||
|
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
|
||||||
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
|
||||||
|
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||||
|
notify,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import VideoEditor from "./VideoEditor";
|
||||||
|
import { api, HttpError, type DownloadJob } from "../lib/api";
|
||||||
|
import { markReported } from "../lib/api/core";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
notify.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = {
|
||||||
|
id: 7,
|
||||||
|
display_name: "Talk",
|
||||||
|
asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" },
|
||||||
|
} as unknown as DownloadJob;
|
||||||
|
|
||||||
|
describe("VideoEditor — a split export that fails part-way", () => {
|
||||||
|
it("says how many clips made it, and a retry queues only the rest", async () => {
|
||||||
|
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
|
||||||
|
let refuse: (e: unknown) => void = () => {};
|
||||||
|
const enqueue = vi
|
||||||
|
.spyOn(api, "enqueueEdit")
|
||||||
|
.mockResolvedValueOnce({} as never)
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
refuse = reject;
|
||||||
|
}) as never,
|
||||||
|
)
|
||||||
|
.mockResolvedValue({} as never);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<VideoEditor job={job} onClose={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The playhead is where the video element says it is.
|
||||||
|
const video = document.querySelector("video")!;
|
||||||
|
const playhead = (s: number) => {
|
||||||
|
Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true });
|
||||||
|
fireEvent.timeUpdate(video);
|
||||||
|
};
|
||||||
|
playhead(50);
|
||||||
|
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("editor.createN"));
|
||||||
|
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
|
||||||
|
// While the clips are queued the cut list holds still: a split now would be a range never queued.
|
||||||
|
playhead(75);
|
||||||
|
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||||
|
|
||||||
|
// The job cap, as `req` delivers it: already shown in the error dialog, and marked reported.
|
||||||
|
const refused = new HttpError(422, "errors.generic", { code: "quota" });
|
||||||
|
markReported(refused);
|
||||||
|
await act(async () => refuse(refused));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(notify).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)");
|
||||||
|
|
||||||
|
// The queued first clip is deselected: one clip left to create, and only it is sent again — as
|
||||||
|
// the second of the same two.
|
||||||
|
fireEvent.click(screen.getByText("editor.create"));
|
||||||
|
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(3));
|
||||||
|
expect(enqueue.mock.calls[2]![0].edit_spec.trim).toEqual({ start_s: 50, end_s: 100 });
|
||||||
|
expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,8 @@ import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
|||||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||||
import { inputCls, btnCls } from "./ui/form";
|
import { inputCls, btnCls } from "./ui/form";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { failureReason, notifyActionError } from "../lib/actionErrors";
|
||||||
|
import { markReported } from "../lib/api/core";
|
||||||
|
|
||||||
// mm:ss.d timecode <-> seconds.
|
// mm:ss.d timecode <-> seconds.
|
||||||
function tc(s: number): string {
|
function tc(s: number): string {
|
||||||
@@ -60,6 +61,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
|
|
||||||
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
||||||
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
||||||
|
// Set while Create queues its clips. A part-way failure deselects the clips it queued by the ids
|
||||||
|
// Create started with, so the cut list holds still until it is done: a split or a merge in between
|
||||||
|
// would deselect a range that was never queued, or keep one that was (deep-review E11 round 3).
|
||||||
|
const creating = useRef(false);
|
||||||
|
// The "(i/N)" each clip of the last Create was named with, by segment id: a retry after a part-way
|
||||||
|
// failure goes on with that series instead of renumbering what is left ("(1/2)" beside "(1/4)").
|
||||||
|
const series = useRef<Map<number, string> | null>(null);
|
||||||
const [selId, setSelId] = useState(0);
|
const [selId, setSelId] = useState(0);
|
||||||
const [output, setOutput] = useState<"separate" | "join">("separate");
|
const [output, setOutput] = useState<"separate" | "join">("separate");
|
||||||
const [cropOn, setCropOn] = useState(false);
|
const [cropOn, setCropOn] = useState(false);
|
||||||
@@ -135,6 +143,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
// --- cut-list mutations ---
|
// --- cut-list mutations ---
|
||||||
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
|
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
|
||||||
const splitAtPlayhead = () => {
|
const splitAtPlayhead = () => {
|
||||||
|
if (creating.current) return;
|
||||||
const time = current;
|
const time = current;
|
||||||
setSegments((segs) => {
|
setSegments((segs) => {
|
||||||
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
|
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
|
||||||
@@ -147,7 +156,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
};
|
};
|
||||||
const moveBoundary = (bi: number, time: number) =>
|
const moveBoundary = (bi: number, time: number) =>
|
||||||
setSegments((segs) => {
|
setSegments((segs) => {
|
||||||
if (bi < 0 || bi >= segs.length - 1) return segs;
|
if (creating.current || bi < 0 || bi >= segs.length - 1) return segs;
|
||||||
// bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
|
// bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
|
||||||
const lo = segs[bi]!.start + MIN_SEG;
|
const lo = segs[bi]!.start + MIN_SEG;
|
||||||
const hi = segs[bi + 1]!.end - MIN_SEG;
|
const hi = segs[bi + 1]!.end - MIN_SEG;
|
||||||
@@ -159,12 +168,14 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
});
|
});
|
||||||
const deleteBoundary = (bi: number) =>
|
const deleteBoundary = (bi: number) =>
|
||||||
setSegments((segs) => {
|
setSegments((segs) => {
|
||||||
if (segs.length < 2) return segs;
|
if (creating.current || segs.length < 2) return segs;
|
||||||
const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
|
const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
|
||||||
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
|
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
|
||||||
});
|
});
|
||||||
const toggleKeep = (id: number) =>
|
const toggleKeep = (id: number) =>
|
||||||
setSegments((segs) => segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)));
|
setSegments((segs) =>
|
||||||
|
creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)),
|
||||||
|
);
|
||||||
|
|
||||||
// --- timeline drag (seek / boundary) + hover preview ---
|
// --- timeline drag (seek / boundary) + hover preview ---
|
||||||
const [drag, setDrag] = useState<Drag | null>(null);
|
const [drag, setDrag] = useState<Drag | null>(null);
|
||||||
@@ -291,6 +302,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
creating.current = true;
|
||||||
const cp = cropPx();
|
const cp = cropPx();
|
||||||
const base = name.trim() || defaultName;
|
const base = name.trim() || defaultName;
|
||||||
if (willJoin) {
|
if (willJoin) {
|
||||||
@@ -307,13 +319,45 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
return { files: 1 };
|
return { files: 1 };
|
||||||
}
|
}
|
||||||
const N = kept.length;
|
const N = kept.length;
|
||||||
for (let i = 0; i < N; i++) {
|
const prev = series.current;
|
||||||
const s = kept[i]!; // i < N === kept.length, so kept[i] exists
|
const suffix =
|
||||||
const spec: EditSpec = { trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) } };
|
prev && kept.every((s) => prev.has(s.id))
|
||||||
if (cp) spec.crop = cp;
|
? prev
|
||||||
else spec.accurate = accurate;
|
: new Map(kept.map((s, i) => [s.id, N > 1 ? ` (${i + 1}/${N})` : ""]));
|
||||||
const dn = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined;
|
series.current = suffix;
|
||||||
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
|
const done: number[] = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const s = kept[i]!; // i < N === kept.length, so kept[i] exists
|
||||||
|
const spec: EditSpec = {
|
||||||
|
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
|
||||||
|
};
|
||||||
|
if (cp) spec.crop = cp;
|
||||||
|
else spec.accurate = accurate;
|
||||||
|
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
|
||||||
|
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
|
||||||
|
done.push(s.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Every clip is its own job, committed as it goes: a failure at clip 3 of 3 (the job cap) left
|
||||||
|
// clips 1 and 2 queued while the editor reported a plain failure and kept all three selected,
|
||||||
|
// so trying again queued the first two a second time. Say how many made it, and deselect them
|
||||||
|
// so another try adds only the rest.
|
||||||
|
if (done.length) {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
||||||
|
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
|
||||||
|
// Its own toast, not notifyActionError: a quota or edit refusal has already opened req's
|
||||||
|
// error dialog and marked the error, so that helper would say nothing — and the count is
|
||||||
|
// what the dialog does not tell (deep-review E11 round 3).
|
||||||
|
notify({
|
||||||
|
level: "error",
|
||||||
|
title: t("editor.partlyCreated", { done: done.length, total: N }),
|
||||||
|
message: failureReason(e),
|
||||||
|
});
|
||||||
|
markReported(e);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
return { files: N };
|
return { files: N };
|
||||||
},
|
},
|
||||||
@@ -324,6 +368,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (e) => notifyActionError(e, t("editor.failed")),
|
onError: (e) => notifyActionError(e, t("editor.failed")),
|
||||||
|
onSettled: () => {
|
||||||
|
creating.current = false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
||||||
|
|||||||
@@ -64,10 +64,12 @@ export default function VirtualFeed({
|
|||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
fetchNextPage={fetchNextPage}
|
fetchNextPage={fetchNextPage}
|
||||||
renderItem={(v, itemWidth) => (
|
keyboardNav
|
||||||
|
renderItem={(v, itemWidth, index) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
video={v}
|
video={v}
|
||||||
view={view}
|
view={view}
|
||||||
|
navIndex={index}
|
||||||
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||||
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
||||||
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { setLanguage, type LangCode } from "../i18n";
|
|||||||
import LanguageSwitcher from "./LanguageSwitcher";
|
import LanguageSwitcher from "./LanguageSwitcher";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||||
import { isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
|
import { anyBlockingLayerAbove, isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
import { entryHash, entrySearch } from "../lib/entryUrl";
|
import { entryHash, entrySearch } from "../lib/entryUrl";
|
||||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||||
@@ -275,11 +275,9 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
|||||||
// and the wizard get theirs; the body-scroll lock moved with it, and round 7 added the layer
|
// and the wizard get theirs; the body-scroll lock moved with it, and round 7 added the layer
|
||||||
// membership below.
|
// membership below.
|
||||||
//
|
//
|
||||||
// It is the THIRD of four to get the contract, not the last: `PlayerModal` is also
|
// It was the third of four `aria-modal` shells to get the contract; `PlayerModal`, the fourth,
|
||||||
// `role="dialog" aria-modal="true"` and still hand-rolls its own scroll lock with no accessible
|
// joined in E11 S2, so all four (`ui/Modal`, the wizard, this, the player) now share
|
||||||
// name, no Tab trap and no focus restore. Saying "the fourth shell" here read as though the sweep
|
// `useDialogChrome` and its one counted scroll lock.
|
||||||
// were finished, which is the false-completeness that let a demo refusal sit unconverted for a
|
|
||||||
// whole round. It is on ROADMAP.md instead (round 7).
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
useDialogChrome(panelRef);
|
useDialogChrome(panelRef);
|
||||||
// Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal).
|
// Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal).
|
||||||
@@ -295,14 +293,14 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
|||||||
// landing page grows (deep-review round 7).
|
// landing page grows (deep-review round 7).
|
||||||
const id = pushLayer();
|
const id = pushLayer();
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (!isTopmost(id)) return;
|
if (e.key === "Escape" && isTopmost(id)) {
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onCloseRef.current();
|
onCloseRef.current();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The trap defers only to a layer that owns the keyboard, not to a hover card under the pointer.
|
||||||
const panel = panelRef.current;
|
const panel = panelRef.current;
|
||||||
if (e.key === "Tab" && panel) trapTab(e, panel, panel);
|
if (e.key === "Tab" && panel && !anyBlockingLayerAbove(id)) trapTab(e, panel, panel);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||||
const { notify } = vi.hoisted(() => ({
|
const { notify } = vi.hoisted(() => ({
|
||||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||||
@@ -28,6 +28,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
|||||||
|
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import UploadDialog from "./UploadDialog";
|
import UploadDialog from "./UploadDialog";
|
||||||
|
import DownloadDialog from "./DownloadDialog";
|
||||||
import { api, HttpError } from "../lib/api";
|
import { api, HttpError } from "../lib/api";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -48,7 +49,9 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const errors = () => notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.message);
|
// What failed, as the toast names it: the title (E11 S2b — the title says WHAT, the line under it WHY).
|
||||||
|
const errors = () =>
|
||||||
|
notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.title ?? n.message);
|
||||||
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
describe("the Plex collection editor", () => {
|
describe("the Plex collection editor", () => {
|
||||||
@@ -144,3 +147,20 @@ describe("the upload dialog", () => {
|
|||||||
expect(errors()).toEqual([]);
|
expect(errors()).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the download dialog", () => {
|
||||||
|
it("names a queueing the server refused with no dialog of its own", async () => {
|
||||||
|
// Its catch said "the global error dialog already surfaced the reason" — true for a quota 422,
|
||||||
|
// not for a 403/404/429 or a lost connection, where the dialog just stayed open (E11 S2b).
|
||||||
|
vi.spyOn(api, "downloadProfiles").mockResolvedValue([{ id: 1, name: "Best" }] as never);
|
||||||
|
vi.spyOn(api, "enqueueDownload").mockRejectedValue(new HttpError(403, "nope"));
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client()}>
|
||||||
|
<DownloadDialog source="https://example.com/v" onClose={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByText("Best");
|
||||||
|
fireEvent.click(screen.getByText("downloads.dialog.submit"));
|
||||||
|
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ const user = {
|
|||||||
is_suspended: false,
|
is_suspended: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function mount() {
|
function mount(u: typeof user = user) {
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<AccountPanel user={user as any} onClose={() => {}} />
|
<AccountPanel user={u as any} onClose={() => {}} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,11 +87,13 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
// What each error toast names: its title when it has one (a failure reported with its reason under
|
||||||
|
// it — E11 S2b), else the specific sentence (the no-mail case).
|
||||||
const errorMessages = () =>
|
const errorMessages = () =>
|
||||||
notifyMock.mock.calls
|
notifyMock.mock.calls
|
||||||
.map(([a]) => a as { level?: string; message?: string })
|
.map(([a]) => a as { level?: string; title?: string; message?: string })
|
||||||
.filter((a) => a.level === "error")
|
.filter((a) => a.level === "error")
|
||||||
.map((a) => a.message);
|
.map((a) => a.title ?? a.message);
|
||||||
|
|
||||||
describe("the account panel reports a refused recovery action", () => {
|
describe("the account panel reports a refused recovery action", () => {
|
||||||
it("says the link could not be minted instead of leaving the button inert", async () => {
|
it("says the link could not be minted instead of leaving the button inert", async () => {
|
||||||
@@ -105,13 +107,21 @@ describe("the account panel reports a refused recovery action", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
|
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
|
||||||
|
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||||
apiMock.resendUserVerification.mockRejectedValue(
|
apiMock.resendUserVerification.mockRejectedValue(
|
||||||
new HttpError(409, "email off", { code: "resend", reason: "email_off" }),
|
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await clickAction("users.account.resend");
|
await clickAction("users.account.resend");
|
||||||
|
|
||||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendEmailOff"));
|
await waitFor(() =>
|
||||||
|
expect(notifyMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "users.pendingAccess.resendFailed",
|
||||||
|
message: "errors.admin.email_off",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
|
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
|
||||||
@@ -138,3 +148,15 @@ describe("the account panel reports a refused recovery action", () => {
|
|||||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
|
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the account panel on the demo account", () => {
|
||||||
|
// The demo account's address is never confirmed, so "send the confirmation link again" looked
|
||||||
|
// available — and the server refused it as "nothing left to confirm", beside a row reading
|
||||||
|
// "unconfirmed" (deep-review E11 round 3).
|
||||||
|
it("does not offer it a confirmation mail, which the server refuses", async () => {
|
||||||
|
mount({ ...user, is_demo: true });
|
||||||
|
await screen.findByText("users.account.recovery");
|
||||||
|
const resend = screen.getByText("users.account.resend").closest("button")!;
|
||||||
|
expect(resend.hasAttribute("disabled")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -331,16 +331,20 @@ describe("inviting someone the admin whitelisted", () => {
|
|||||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||||
inviteMock.mockRejectedValue(
|
inviteMock.mockRejectedValue(
|
||||||
new HttpError(409, "ignored", { code: "invite_send", reason: "email_off" }),
|
new HttpError(409, "errors.admin.email_off", { code: "invite_send", reason: "email_off" }),
|
||||||
);
|
);
|
||||||
await open();
|
await open();
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||||
|
|
||||||
// A button the admin pressed must not answer with a success toast for a mail nobody received.
|
// A button the admin pressed must not answer with a success toast for a mail nobody received.
|
||||||
|
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(notifyMock).toHaveBeenCalledWith(
|
expect(notifyMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ message: "settings.invites.inviteEmailOff" }),
|
expect.objectContaining({
|
||||||
|
title: "settings.invites.inviteFailed",
|
||||||
|
message: "errors.admin.email_off",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -159,17 +159,21 @@ describe("the stuck sign-ups section", () => {
|
|||||||
const { HttpError } = await import("../lib/api");
|
const { HttpError } = await import("../lib/api");
|
||||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||||
resendMock.mockRejectedValue(
|
resendMock.mockRejectedValue(
|
||||||
new HttpError(409, "ignored", { code: "resend", reason: "email_off" }),
|
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
|
||||||
);
|
);
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||||
|
|
||||||
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
|
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
|
||||||
// back on. A button the admin pressed must not answer with a success toast.
|
// back on. A button the admin pressed must not answer with a success toast. `req` has already
|
||||||
|
// localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(notifyMock).toHaveBeenCalledWith(
|
expect(notifyMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ message: "users.pendingAccess.resendEmailOff" }),
|
expect.objectContaining({
|
||||||
|
title: "users.pendingAccess.resendFailed",
|
||||||
|
message: "errors.admin.email_off",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,10 +62,12 @@ function QuotaModal({
|
|||||||
unlimited: state!.unlimited,
|
unlimited: state!.unlimited,
|
||||||
}),
|
}),
|
||||||
onSuccess: done,
|
onSuccess: done,
|
||||||
|
meta: { failTitle: "errors.writeFailed.downloadQuota" },
|
||||||
});
|
});
|
||||||
const reset = useMutation({
|
const reset = useMutation({
|
||||||
mutationFn: () => api.adminResetDownloadQuota(userId),
|
mutationFn: () => api.adminResetDownloadQuota(userId),
|
||||||
onSuccess: done,
|
onSuccess: done,
|
||||||
|
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
|
||||||
});
|
});
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
|
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { api, type DownloadJob } from "../lib/api";
|
|||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconBtn } from "./downloadRow";
|
import { IconBtn } from "./downloadRow";
|
||||||
|
import { failCopyKeys } from "../lib/downloadFailure";
|
||||||
|
|
||||||
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
||||||
// editing and the failure-detail window.
|
// editing and the failure-detail window.
|
||||||
@@ -41,6 +42,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
|
|||||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
meta: { failTitle: "errors.writeFailed.downloadDetails" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,8 +119,9 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
|
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
|
||||||
// sign-in/unsupported-host/…), where a retry can't help — the queue row still keeps its own retry
|
// unsupported-host/…), where a retry can't help, or need a fix outside this window first (sign-in: a
|
||||||
// control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
|
// cookie in Settings — its copy then sends the user to the row) — the queue row still keeps its own
|
||||||
|
// retry control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
|
||||||
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
|
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
|
||||||
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
|
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
|
||||||
const RETRYABLE_ERRORS = ["postprocess", "network"];
|
const RETRYABLE_ERRORS = ["postprocess", "network"];
|
||||||
@@ -141,8 +144,8 @@ export function ErrorModal({
|
|||||||
return (
|
return (
|
||||||
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
|
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
|
<div className="font-medium">{t(failCopyKeys(job, "short"))}</div>
|
||||||
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
|
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
|
||||||
{job.error && (
|
{job.error && (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { DownloadRow } from "./downloadRow";
|
import { DownloadRow, IconBtn } from "./downloadRow";
|
||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../lib/api";
|
||||||
|
|
||||||
const baseJob: DownloadJob = {
|
const baseJob: DownloadJob = {
|
||||||
@@ -24,6 +24,7 @@ const baseJob: DownloadJob = {
|
|||||||
phase: null,
|
phase: null,
|
||||||
error: null,
|
error: null,
|
||||||
error_code: null,
|
error_code: null,
|
||||||
|
error_detail: null,
|
||||||
queue_pos: 0,
|
queue_pos: 0,
|
||||||
created_at: null,
|
created_at: null,
|
||||||
source_kind: "youtube",
|
source_kind: "youtube",
|
||||||
@@ -116,3 +117,27 @@ describe("DownloadRow file kind", () => {
|
|||||||
expect(container.querySelector("img")).not.toBeNull();
|
expect(container.querySelector("img")).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DownloadRow · keyboard", () => {
|
||||||
|
it("is a list item only when it is given a position", () => {
|
||||||
|
const { container, rerender } = render(<DownloadRow job={baseJob} navIndex={3} />);
|
||||||
|
expect(container.firstElementChild?.getAttribute("data-nav-index")).toBe("3");
|
||||||
|
rerender(<DownloadRow job={baseJob} />);
|
||||||
|
expect(container.firstElementChild?.hasAttribute("data-nav-index")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the action that opens the row, and only that one", () => {
|
||||||
|
render(
|
||||||
|
<DownloadRow job={baseJob} navIndex={0}>
|
||||||
|
<IconBtn open onClick={() => {}} title="play">
|
||||||
|
p
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn onClick={() => {}} title="delete">
|
||||||
|
d
|
||||||
|
</IconBtn>
|
||||||
|
</DownloadRow>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTitle("play").hasAttribute("data-nav-open")).toBe(true);
|
||||||
|
expect(screen.getByTitle("delete").hasAttribute("data-nav-open")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { api, type DownloadJob } from "../lib/api";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
|
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
|
||||||
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
import { failCopyKeys } from "../lib/downloadFailure";
|
||||||
|
|
||||||
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
|
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
|
||||||
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
|
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
|
||||||
@@ -112,16 +114,20 @@ export function IconBtn({
|
|||||||
onClick,
|
onClick,
|
||||||
title,
|
title,
|
||||||
danger,
|
danger,
|
||||||
|
open,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
/** The row's "open" action (play): Enter on a keyboard-walked row (lib/listNav) clicks it. */
|
||||||
|
open?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
data-nav-open={open ? "" : undefined}
|
||||||
title={title}
|
title={title}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
||||||
@@ -201,16 +207,22 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
|||||||
export function DownloadRow({
|
export function DownloadRow({
|
||||||
job,
|
job,
|
||||||
onShowError,
|
onShowError,
|
||||||
|
navIndex,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
job: DownloadJob;
|
job: DownloadJob;
|
||||||
onShowError?: (job: DownloadJob) => void;
|
onShowError?: (job: DownloadJob) => void;
|
||||||
|
/** Its position in a keyboard-walkable list (lib/listNav). */
|
||||||
|
navIndex?: number;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const running = job.status === "running";
|
const running = job.status === "running";
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
|
<div
|
||||||
|
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
|
||||||
|
className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition"
|
||||||
|
>
|
||||||
<Thumb job={job} />
|
<Thumb job={job} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||||
@@ -249,7 +261,7 @@ export function DownloadRow({
|
|||||||
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
|
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
|
||||||
title={t("downloads.errors.fail.title")}
|
title={t("downloads.errors.fail.title")}
|
||||||
>
|
>
|
||||||
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
|
· {t(failCopyKeys(job, "short"))}
|
||||||
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Every rail and every card of a card list is walkable by the keyboard (E11 S4, lib/listNav). Two
|
||||||
|
// opt-in halves, both checked here, because either one missing silently leaves the keyboard out —
|
||||||
|
// and a page where `j` walks two rails and skips the third reads as broken, not as unfinished:
|
||||||
|
// - a `Carousel`, a `VirtualGrid` and a `DataTable` say `keyboardNav`, and a card says `navIndex`;
|
||||||
|
// - a card that says `navIndex` sits in a list that registers with listNav — a `NavGrid`, a
|
||||||
|
// `Carousel`, or a `VirtualGrid` (its `renderItem`). In a plain `<div className="grid">` the card
|
||||||
|
// is marked as an item of a list nobody walks.
|
||||||
|
// Each exception is listed with its reason.
|
||||||
|
//
|
||||||
|
// The assertion pins the SET of offending tags, never a count, and checks the glob matched at all: a
|
||||||
|
// guard whose glob stops matching is green over nothing (E10 review round 4, round 7).
|
||||||
|
const WALKABLE = ["Carousel", "VirtualGrid", "DataTable"];
|
||||||
|
const CARDS = [
|
||||||
|
"VideoCard",
|
||||||
|
"PlexPosterCard",
|
||||||
|
"SeasonCard",
|
||||||
|
"EpisodeCard",
|
||||||
|
"PosterTile",
|
||||||
|
"ShelfTile",
|
||||||
|
"DownloadRow",
|
||||||
|
];
|
||||||
|
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
|
||||||
|
|
||||||
|
/** `path#tag#n` → why it is not a keyboard list. An entry that no longer matches a tag fails the test. */
|
||||||
|
const EXEMPT: Record<string, string> = {
|
||||||
|
"components/AuditLog.tsx#DataTable#1":
|
||||||
|
"an admin log read in place: its rows open nothing, so stepping onto them would only move a ring",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Tag {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripComments = (source: string) =>
|
||||||
|
source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
|
||||||
|
|
||||||
|
/** Each opening `<Name …>` tag, found by brace depth — a JSX attribute holds its own `>`
|
||||||
|
* (`title={<span>…</span>}`, an arrow `=>`), so the first `>` is not the end of the tag. Takes
|
||||||
|
* comment-free code, so the positions line up with `inList`. */
|
||||||
|
export function openingTags(code: string, name: string): Tag[] {
|
||||||
|
const tags: Tag[] = [];
|
||||||
|
const start = new RegExp(`<${name}(?=[\\s/>])`, "g");
|
||||||
|
for (let m = start.exec(code); m; m = start.exec(code)) {
|
||||||
|
let depth = 0;
|
||||||
|
let quote: string | null = null;
|
||||||
|
let i = m.index + name.length + 1;
|
||||||
|
for (; i < code.length; i++) {
|
||||||
|
const c = code[i];
|
||||||
|
if (quote) {
|
||||||
|
if (c === quote) quote = null;
|
||||||
|
} else if (depth === 0 && (c === '"' || c === "'")) quote = c;
|
||||||
|
else if (c === "{") depth++;
|
||||||
|
else if (c === "}") depth--;
|
||||||
|
else if (c === ">" && depth === 0) break;
|
||||||
|
}
|
||||||
|
tags.push({ start: m.index, end: i + 1, text: code.slice(m.index, i + 1) });
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `pos` lies inside an open list element: between `<List …>` and `</List>`, or inside a
|
||||||
|
* list's own tag (a `renderItem={…}` attribute). */
|
||||||
|
export function inList(code: string, pos: number): boolean {
|
||||||
|
return LISTS.some((name) => {
|
||||||
|
let open = 0;
|
||||||
|
for (const tag of openingTags(code, name)) {
|
||||||
|
if (tag.start > pos) break;
|
||||||
|
if (tag.end > pos) return true;
|
||||||
|
if (!tag.text.endsWith("/>")) open++;
|
||||||
|
}
|
||||||
|
return open > code.slice(0, pos).split(`</${name}>`).length - 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const has = (tag: Tag, prop: string) => new RegExp(`\\s${prop}\\b`).test(tag.text);
|
||||||
|
|
||||||
|
function offenders(): { hits: string[]; tags: number; exempted: Set<string> } {
|
||||||
|
const files = import.meta.glob("./**/*.tsx", { query: "?raw", import: "default", eager: true });
|
||||||
|
const hits: string[] = [];
|
||||||
|
const exempted = new Set<string>();
|
||||||
|
let tags = 0;
|
||||||
|
for (const [rel, source] of Object.entries(files)) {
|
||||||
|
const path = rel.replace(/^\.\//, "components/");
|
||||||
|
if (/\.test\.tsx$/.test(path)) continue;
|
||||||
|
const code = stripComments(String(source));
|
||||||
|
const report = (id: string, why: string) => {
|
||||||
|
if (id in EXEMPT) exempted.add(id);
|
||||||
|
else hits.push(`${id} ${why}`);
|
||||||
|
};
|
||||||
|
for (const name of WALKABLE) {
|
||||||
|
openingTags(code, name).forEach((tag, n) => {
|
||||||
|
tags++;
|
||||||
|
if (!has(tag, "keyboardNav")) report(`${path}#${name}#${n + 1}`, "no keyboardNav");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const name of CARDS) {
|
||||||
|
openingTags(code, name).forEach((tag, n) => {
|
||||||
|
tags++;
|
||||||
|
const id = `${path}#${name}#${n + 1}`;
|
||||||
|
if (!has(tag, "navIndex")) report(id, "no navIndex");
|
||||||
|
else if (!inList(code, tag.start)) report(id, "not inside a NavGrid/Carousel/VirtualGrid");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { hits: hits.sort(), tags, exempted };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the keyboard-list wiring contract", () => {
|
||||||
|
it("has no rail or card list the keyboard cannot walk", () => {
|
||||||
|
const { hits, tags, exempted } = offenders();
|
||||||
|
expect(tags).toBeGreaterThan(15);
|
||||||
|
expect(hits).toEqual([]);
|
||||||
|
expect(Object.keys(EXEMPT).filter((id) => !exempted.has(id))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a tag to its real end, past the > inside its attributes", () => {
|
||||||
|
const code = stripComments(`
|
||||||
|
<Carousel
|
||||||
|
title={<span className="x">{title}</span>}
|
||||||
|
onSeeAll={() => go(1 > 0)}
|
||||||
|
keyboardNav
|
||||||
|
>
|
||||||
|
// <Carousel keyboardNav> in a comment is no tag
|
||||||
|
<Carousel ariaLabel="a > b">
|
||||||
|
`);
|
||||||
|
const tags = openingTags(code, "Carousel");
|
||||||
|
expect(tags).toHaveLength(2);
|
||||||
|
expect(has(tags[0]!, "keyboardNav")).toBe(true);
|
||||||
|
expect(has(tags[1]!, "keyboardNav")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells a card inside a list from one in a plain grid", () => {
|
||||||
|
const code = `
|
||||||
|
<NavGrid className="grid">{a.map((c, i) => <PosterTile navIndex={i} />)}</NavGrid>
|
||||||
|
<div className="grid">{a.map((c, i) => <PosterTile navIndex={i} />)}</div>
|
||||||
|
<VirtualGrid renderItem={(v, w, i) => <VideoCard navIndex={i} />} />
|
||||||
|
`;
|
||||||
|
const at = (n: number) => openingTags(code, "PosterTile")[n]!.start;
|
||||||
|
expect(inList(code, at(0))).toBe(true);
|
||||||
|
expect(inList(code, at(1))).toBe(false);
|
||||||
|
expect(inList(code, openingTags(code, "VideoCard")[0]!.start)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user