Compare commits

...
21 Commits
Author SHA1 Message Date
peter f52bc5898b release: 0.74.0 (improvement/quickfix-batch)
gate / frontend (push) Failing after 1m28s
gate / backend (push) Successful in 2m15s
2026-08-28 00:05:21 +02:00
peter d0033617e2 chore(release): 0.74.0 — quickfix batch (Plex sync/browse + Channel Manager) 2026-08-28 00:03:13 +02:00
peter f80ed115ad fix(plex,channels): deep-review fixes — durable watch-sync + no shared-link clobber
Deep-review of the quickfix batch surfaced two data/preference-loss bugs plus
smaller items; all fixed with tests.

- watch_sync _pull_apply: don't let the Plex-authoritative pull overwrite a DIRTY
  local row (a change whose immediate push failed) — that stranded the value
  forever since _repush_dirty only retries dirty rows. Leave it dirty for re-push.
- PlexProvider / ChannelsProvider: a partial shared link opened in a fresh tab
  persisted mount-time DEFAULTS over the account's stored scope/sort/filters
  (base read before the account was pinned). Persist/keep only URL-present dims;
  re-hydrate the omitted ones from storage.
- playback resume write-back: guard against out-of-order delivery (a superseded
  older push now drops) and evict the throttle map so it can't grow unbounded.
- PlexPlayer: announce mid-playback buffering to assistive tech (role=status +
  sr-only label); new plex.player.buffering (HU+EN).
- columnSort: hoist the shared key:dir sort codec used by both URL-state modules.
- Tests: dirty-guard + flag-settle + per-key throttle scoping + superseded-push
  (backend); hold-repeat/suppress-click/unmount + partial-link no-clobber +
  refetch-doesn't-clobber-undo (frontend). Backend pytest 475, FE vitest 519.
2026-08-27 23:54:44 +02:00
peter 7fbb996297 feat(channels): shareable manager view in the URL — tab+sort+column-filters (S8)
The Channels-manager content state now round-trips through the address bar,
so a manager view is bookmarkable/shareable — the S4 pattern applied to the
last content page the audit flagged.

- New lib/channelsUrlState.ts (channelsParamUpdates / channelsParamsToState /
  hasChannelsParams) under NAMESPACED keys ctab/csort/cf — channels-unique
  like ?v=/?pl/pscope, so no other provider reads or clears them and the
  feed's URL-stripping can't delete them. The mixed scalar/array FilterMap
  (sync/priority scalars + tags multi) rides in `cf` as a JSON blob so both
  shapes round-trip exactly. Only non-default values emit.
- ChannelsProvider mirrors {view, sort, filters} via useUrlMirror, page-gated
  on channels (off-page the keys clear; localStorage still holds the state and
  it re-mirrors on return). A shared link wins over stored state: readChannels
  UrlSeed captured once, applied before paint through the slice setters (so the
  account-pin re-hydrate sees them dirty and won't reload storage over the
  shared view), then persisted to the account once pinned so it survives a
  later plain reload — mirrors PlexProvider's seed/rehydrate. The name search
  stays ephemeral (out of the shared contract).
- NavigationProvider's page-switch nulls ctab/csort/cf alongside the plex keys
  so switching modules resets to the manager root.

Tests: channelsUrlState.dom.test.ts (9 round-trip) + ChannelsProvider.dom
seed/mirror (2); the existing tab-history test's beforeEach now resets the
query string too (the mirror writes it). FE vitest 503→514. FE-only.
2026-08-27 04:37:53 +02:00
peter 9cd4ae09d4 feat(views): editable saved-view detail — removable filter chips + undo + save-current (S7)
The Personal-views manager's detail pane becomes editable instead of
read-only:

- Captured filters render as REMOVABLE chips. Removing one prunes the set
  and writes it straight back to the stored view (PATCH filters), via the
  same live-row builders (useActiveFilters / buildPlexActiveFilters) that
  already compute the pruned snapshot — so the modal and the live chip row
  can't drift. The prune is wrapped in useUndoable + the shared UndoToolbar,
  so undo/redo re-persist the same way. The undoable draft is the single
  source of truth for the chip row, so a chip-prune refetch never clobbers
  the history.
- A "Save current" button beside Apply stores the module's currently-applied
  filters INTO the view (the live→stored inverse of Apply). Disabled when the
  view already equals the live filters (it's the active one). On save the
  draft re-seeds to the saved snapshot and clears the stale undo history.
- Rename gains a visible commit affordance: a check button beside the name
  input that enables once the name changes (Enter and blur still commit).

Provider gains two methods mirroring `rename`: updateFilters(id, filters)
and saveCurrent(id). One shared provider serves feed + plex, so both get it.
PATCH filters was already supported end-to-end — FE-only.

New i18n: views.saveCurrent / saveCurrentHint / saveCurrentUpToDate (HU+EN).
Tests: 5 new in PersonalViewsModal.dom.test.tsx (chip-remove persists, undo
re-persists, save-current fires + disables when up-to-date, rename commit).
2026-08-27 04:22:07 +02:00
peter 00527d0cf3 feat(channels): leading '+ Tag' pill + detail-page Unhide affordance (S6)
Item 6 — the tag add-trigger now LEADS the chip row (a stable left anchor)
instead of trailing it, so on the detail page (bounded=false) adding chips
grows the row to its right and the '+'/its editor popover no longer drift.
Compact icon in the width-bounded manager cell; a labelled '+ Tag' pill
where there's free width (the detail header).

Item 7 — surface the per-subscription 'hidden' flag on the detail GET
(channel_detail was omitting it) and add an Eye/EyeOff hide/unhide toggle
next to Block on the channel page, mirroring the manager. FE ChannelDetail
gains 'hidden'; new channel.* i18n keys (HU+EN).

Tests: +3 backend (channel_detail surfaces hidden, both states + unsubscribed
default). Gate green: backend pytest 471, FE vitest 498.
2026-08-27 04:02:25 +02:00
peter 8dc4976c2c feat(channels): horizontal −/+ priority stepper with pop-free inline edit (S5)
Redesign the shared PriorityStepper (channel manager cell + detail header)
from a value-plus-stacked-arrows control into a horizontal [−] value [+]
segmented stepper. The value now sits centred between the buttons and opens
an inline editor in a FIXED-width slot, so click-to-type no longer "pops"
the control wider (the old absolutely-positioned overlay grew w-6 → w-14).

One `size` prop drives the two surfaces: `sm` draws its own bordered pill
for the standalone table cell; `md` drops the outer border and keeps only
the segment dividers, blending into the detail header's lozenge instead of
double-bordering it. Hold-to-repeat, negatives, and the debounced-flush
persistence hook are unchanged.

The manager priority column widens 3.5rem → 5.5rem to fit the horizontal
control under fixed table layout.

Tests: 7 new DOM tests for the stepper interaction contract.
2026-08-27 03:29:42 +02:00
peter 56a2ecdec3 feat(plex): shareable Plex-browse state in the URL — scope+filters+sort+show (S4)
Mirror the Plex-browse content state into the address bar so a browse view is
bookmarkable/shareable, the Plex analogue of the feed's urlState. Keys are
NAMESPACED (pscope/psort/pshow/pf) rather than reusing the feed's bare
scope/sort/show: FeedFiltersProvider actively strips its own KEYS from the URL
whenever off the feed page, so a shared key would be deleted out from under the
Plex page. The namespaced keys are Plex-unique (like ?v=/?pl), so no other
provider ever reads or clears them.

- New lib/plexUrlState.ts: plexParamUpdates / plexParamsToState / hasPlexParams.
  The pf filter blob reuses the SAME appendPlexFilters wire serializer (new
  public plexFiltersToParams + inverse parsePlexFilters, kept next to each other
  so a new wire key can't be added on one side only).
- PlexProvider: URL-seed wins over localStorage at mount; the account-pin
  rehydrate persists the seed instead of clobbering it (mirrors feed's
  initialHadUrlFilters); useUrlMirror mirrors/clears the 4 keys, gated on
  page===plex. q/field stay out of the shareable contract.
- NavigationProvider: a page switch now also nulls the 4 plex keys.

Tests: 8 new round-trip cases in plexUrlState.dom.test.ts (FE vitest 483→491).
2026-08-27 03:14:54 +02:00
peter 8299ea72b7 feat(plex): 'last watched' browse sort + auto-apply in the in-progress view (S3)
Add a 'lastwatched' sort key to the unified Plex browse. Movies rank by
coalesce(progress_updated_at, watched_at); shows by max(coalesce(...)) across
their episodes (rollup.resumed_at, now broadened from progress-only so watched
episodes count too). Never-watched titles sink via nullslast.

Frontend auto-applies lastwatched:desc when entering the in-progress watch-state
view and restores the pre-entry sort on exit; a manual sort change while there
still wins. i18n HU+EN. Backend tests cover both union legs + mixed nullslast.
2026-08-27 02:50:41 +02:00
peter 6c918d1dff docs(plex): refresh watch-sync job docstrings for Plex-authoritative pull (S2)
The incremental/reconcile job docstrings still described last-write-wins and a
30-min cadence; update them to match the S2 behavior (Plex wins on divergence,
15-min incremental) so the "why" doesn't drift from the code.
2026-08-27 02:33:42 +02:00
peter 3f8257059d feat(plex): Plex-authoritative watch-sync — resume write-back + faster pull (S2)
Make Plex the source of truth for watch state:

- Write-back during playback: item_progress now pushes the resume position to
  Plex on the periodic checkpoint too (not only the final flush), throttled to
  one push per item every ~10s (in-memory, single-process rate limiter). The FE
  now also fires an immediate (debounced) push on seek.
- Faster incremental pull: 30 → 15 min (plex_watch_sync_interval_min).
- Plex-authoritative pull: _pull_apply resolves a genuine divergence to Plex's
  state instead of last-write-wins; the _same_state + synced_to_plex short-circuit
  still prevents a freshly-pushed row from bouncing. Removed the now-unused
  _LWW_TOLERANCE_S / _sift_ts.

Tests: 5 new in test_plex_watch_sync.py (Plex-wins resume/watched, no-bounce,
throttle final vs periodic).
2026-08-27 02:07:45 +02:00
peter 12d9561ba1 fix(plex): warm up HLS buffer on session start + player loading/buffering spinner
The Plex player stalled ~2s a few seconds into every auto-advanced next
episode. Root cause: each auto-advance spins up a cold ffmpeg HLS remux
session, and for a fresh start (start_s==0) start_session returned
immediately with zero buffer — hls.js drained the first segment, then
rebuffered waiting for the still-warming ffmpeg to write the next one.
Once ffmpeg gets ahead (stream-copy is faster than realtime) it never
stalls again.

- stream.py: before reporting a session ready, wait for the first
  _WARMUP_SEGMENTS (3) video segments to exist — the seg0 wait is now
  unconditional (was start_s>0 only) and warm-up runs for all starts.
  Best-effort and bounded: stop at the first segment not ready in time.
- PlexPlayer.tsx: a loading/buffering spinner. The existing overlay only
  covered the INITIAL load (!ready); add a `buffering` state driven by the
  video's waiting/stalled/seeking (set) and playing/canplay/seeked/pause
  (clear) events so a mid-playback stall or in-flight seek reads as
  "loading" instead of a frozen frame, and surface a fatal loadError even
  once playback has started. Spinner uses the shared Loader2.

Merges roadmap E6·item1 (spinner while loading/seeking) into this fix,
since the server-side warm-up deliberately holds ready a beat longer.

Tests: 3 new in test_plex_stream.py (warm-up waits for the first few
segments; stops at the first missing one; no warm-up when seg0 never
arrives). Gate: backend pytest 460 (Docker lane), FE tsc/lint/knip/
prettier + vitest 483.
2026-08-27 00:50:58 +02:00
peter fe09879edd release: 0.73.0 (feature/e8-feed-filtering)
gate / frontend (push) Failing after 1m22s
gate / backend (push) Successful in 2m4s
2026-08-26 05:28:11 +02:00
peter 7104767ff9 fix(e8): deep-review hardening + cut 0.73.0
Fixes from several multi-agent deep-review passes over the E8 feed-filtering &
channel-manager epic (S1-S4):
- feed_years buckets by UTC year to match the UTC-pinned year-filter bounds
- tag suggestions: examples ∩ subscribed (drop tagged-then-unsubscribed channels
  from the profile denominator); exclude hidden channels as suggestion targets
- channel manager: restore ghost chips + surface an error toast on a failed tag accept
- year filter: debounce the search term so the per-year facet doesn't refetch per keystroke
- channel tags: aria-label the "+N" overflow pills
- extract useTagSuggestions hook; add backend + hook tests (profile boundaries, cap,
  language exclusion, unsubscribed/hidden handling, UTC bucketing, bulk-accept)

Bump VERSION to 0.73.0 + release notes.
2026-08-26 05:25:47 +02:00
peter 17f8885fb1 fix(channels): collapse overflowing tag suggestions into a "+N" pill
With 2+ suggestions the narrow tags cell clipped the second ghost chip
mid-word right next to the edit "+", reading as the icon covering it. Route
the ghost chips through the same fit machinery the attached chips use: a
dedicated SuggestedChipsLane fits what the cell holds and collapses the rest
into a "+N" pill whose hover popover still one-tap accepts / dismisses each.

The lane is attached-XOR-ghost (suggestions are untagged-only, and accepting
one now drops the whole channel from the suggestion cache), so each lane owns
the full cell width instead of clipping the other.
2026-08-26 02:39:00 +02:00
peter 549aae67cc feat(channels): suggest your own tags for untagged channels from your tagging pattern (E8·S4)
Learn, per user tag, which auto TOPIC tags are characteristic of the channels
you've tagged it, then suggest that tag for still-untagged subscribed channels
whose topics cover enough of the profile. Language auto-tags are excluded from
the signal — near-universal within a language, they flooded every channel.

New GET /api/tags/suggestions (read-only, deterministic) backed by
app/sync/tagsuggest.py. Surfaced in the Channel Manager as dashed "ghost" chips
in the tags cell — one tap accepts (reuses the attach-tag endpoint), × dismisses
for the session — plus a header badge and a bulk "Accept N confident" (the
full-profile matches) behind a confirm. No silent writes (D4 = suggest-and-confirm).

Also reflow a pre-existing prettier drift in dateFilter.ts from E8·S2.
2026-08-26 02:18:34 +02:00
peter fbb961b5b7 feat(feed): hover infobox on feed cards — full title, description, tags (E8·S3) 2026-08-26 00:44:27 +02:00
peter edcea87ea5 feat(feed): filter by year — per-year count facet + year control (E8·S2)
Add a Year filter to the feed rail: quick chips for recent years plus a
'More…' popover listing all years with per-year video counts, sortable by
year or count. Selecting a year narrows the feed to that whole calendar
year (a published_after/before range applied in the api layer) and is
mutually exclusive with the Upload-date control.

Backend: new /api/feed/years endpoint + feed_years() — one grouped query
that honours every filter EXCEPT the publish-date narrowing (dropped), so
the year list stays a live drill-down and switching years never zeroes the
others. FE keys the list on the date-stripped filters, so picking a year
re-queries the feed but not the list.

Wires year through FeedFilters, the filterParams/URL (?year) round-trip,
the active-filter chip row, and the panel layout. i18n HU+EN.

Tests: feed_years count query (date narrowing ignored, other filters
honoured); dateFilter year-clearing; urlState year round-trip.
2026-08-25 02:55:12 +02:00
peter 2c843e549b fix(channels): tag-filter row folds overflow into "+N", keeps "…" pinned
The channel manager's tag-filter row wrapped the trailing "…" catalog-actions
menu onto a second line once the chips + Clear outgrew the width. Rework it as
one non-wrapping row: the TAGS editor trigger (left), a single-line chip lane
that collapses overflow into a "+N" pill whose popover holds the rest (still
toggleable), then Clear and the "…" menu pinned right — so "…" always stays on
the first line.

New TagFilterChips reuses the shared measure core (useChipOverflowFit +
fitChipsWithPill), the same compression the feed's applied-filter row uses.
2026-08-25 02:30:28 +02:00
peter 4ee0dab68f feat(feed,channels): "No tag" filter for untagged channels (E8·S1)
Filter to channels you haven't personally tagged in the channel manager
(no user-owned ChannelTag; auto language/topic tags don't count):

- Feed: a "No tag" sentinel in the "Your tags" sidebar combobox (exclusive
  with real tag picks) + a removable "Untagged" chip; new `untagged` bool
  feed param / `notag` URL key, persisted + coerced on every filter path.
- Channel manager: a virtual "No tag" chip in the tag-chip row filters the
  table to untagged channels (client-side, shared NO_TAG_SENTINEL).

Backend `untagged` is a plain channel-level narrowing, so feed_summary keeps
it applied in the base pass — the count/facet lockstep holds with no mirror.

Note: item E (per-tag counts) and item D (Any/All toggle) were already
shipped; S1 delivers only item B.
2026-08-25 01:44:34 +02:00
peter 5d7be687d3 test(views,layer): close the two polish-tail deep-review test gaps
UX-8 layerStack: add an integration test wired through the real useLayer
hook (popover document-listener + stamp) and a PlayerModal-shaped window
listener, driven by a real dispatched keydown. The popover close is wrapped
in flushSync so its layer is popped synchronously between the two listeners
-- the production mid-dispatch race -- making the escapeHandled stamp the
only thing that keeps the player from also closing. Mutation-checked:
removing the guard fails exactly this case.

SavedViews: cover feedCanSeedDefault's "yield to a bookmarked filter URL"
branch (a filter-bearing search string -> no seed) and the per-source
seededRef guard against the 60s refetch (a changed-list refetch re-runs the
seed effect; the guard blocks re-seed). The refetch test waits for the
changed list to reach the component so the effect has its chance to wrongly
re-fire. Both mutation-checked.

Frontend gate green: 464 vitest (+4). Backend untouched.
2026-08-24 23:41:49 +02:00
83 changed files with 4493 additions and 268 deletions
+1 -1
View File
@@ -1 +1 @@
0.72.1 0.74.0
+3 -1
View File
@@ -232,7 +232,9 @@ class Settings(BaseSettings):
plex_sync_interval_min: int = 360 plex_sync_interval_min: int = 360
# How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C): # How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C):
# a cheap history + on-deck pull plus a re-push of any states that failed their immediate push. # a cheap history + on-deck pull plus a re-push of any states that failed their immediate push.
plex_watch_sync_interval_min: int = 30 # 15 min (S2): with Plex now the source of truth (prompt write-back on the ~10s checkpoint +
# Plex-authoritative pull), a faster incremental pull keeps Siftlode close to Plex-side changes.
plex_watch_sync_interval_min: int = 15
# How often the FULL watch-state reconcile runs, in minutes (Phase C). A whole-section rescan # How often the FULL watch-state reconcile runs, in minutes (Phase C). A whole-section rescan
# that catches what the incremental history feed can't — notably an un-watch done on the Plex # that catches what the incremental history feed can't — notably an un-watch done on the Plex
# side — so it runs far less often (daily) than the incremental pass. # side — so it runs far less often (daily) than the incremental pass.
+48 -4
View File
@@ -100,6 +100,11 @@ def serialize(row) -> dict:
} }
def _tag_visible(user: User):
"""A ChannelTag row this user may see: a shared system tag (user_id NULL) or their own."""
return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]: def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]:
"""Group tag ids by their category (language/topic/other). The single source for the category split """Group tag ids by their category (language/topic/other). The single source for the category split
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
@@ -118,6 +123,7 @@ def filtered_query(
*, *,
tags: list[int], tags: list[int],
tag_modes: dict[str, str], tag_modes: dict[str, str],
untagged: bool = False,
channel_ids: list[str], channel_ids: list[str],
q: str | None, q: str | None,
min_duration: int | None, min_duration: int | None,
@@ -294,7 +300,7 @@ def filtered_query(
# selections but still honours the other categories' filters). # selections but still honours the other categories' filters).
if exclude_tag_category: if exclude_tag_category:
by_category.pop(exclude_tag_category, None) by_category.pop(exclude_tag_category, None)
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) visible = _tag_visible(user)
for category, ids in by_category.items(): for category, ids in by_category.items():
if tag_modes.get(category, "or") == "and" and len(ids) > 1: if tag_modes.get(category, "or") == "and" and len(ids) > 1:
sub = ( sub = (
@@ -307,6 +313,18 @@ def filtered_query(
sub = select(ChannelTag.channel_id).where(ChannelTag.tag_id.in_(ids), visible) sub = select(ChannelTag.channel_id).where(ChannelTag.tag_id.in_(ids), visible)
query = query.where(Video.channel_id.in_(sub)) query = query.where(Video.channel_id.in_(sub))
# "No tag" filter (item B): keep only channels the USER has not personally tagged in the channel
# manager — i.e. channels with no ChannelTag row owned by this user (user_id == me, source "user").
# System/auto tags (user_id NULL) don't count: this is "channels I haven't tagged yet". It's a plain
# channel-level narrowing (like channel_ids/date), NOT a tag-category selection, so feed_summary
# leaves it applied in the base pass and needs no mirror in passes().
if untagged:
query = query.where(
Video.channel_id.notin_(
select(ChannelTag.channel_id).where(ChannelTag.user_id == user.id)
)
)
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types. # Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing. # Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
explicit_view = show in ("watched", "hidden") explicit_view = show in ("watched", "hidden")
@@ -355,8 +373,9 @@ def feed_summary(db: Session, user: User, **params) -> dict:
within, a category in "and" mode requires ALL its selected tags).""" within, a category in "and" mode requires ALL its selected tags)."""
tag_modes = params.get("tag_modes", {}) tag_modes = params.get("tag_modes", {})
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel # ONE pass: per-channel video counts for the base filter, the positive tag filter stripped so every
# is present to facet over. # candidate channel is present to facet over. The "No tag" (untagged) filter is a plain channel-level
# narrowing, so it STAYS applied here (not stripped) and the facets naturally drill down within it.
base, _rank = filtered_query(db, user, **{**params, "tags": []}) base, _rank = filtered_query(db, user, **{**params, "tags": []})
ch_col = base.with_only_columns(Video.channel_id).subquery() ch_col = base.with_only_columns(Video.channel_id).subquery()
channel_counts: dict[str, int] = dict( channel_counts: dict[str, int] = dict(
@@ -386,7 +405,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
def passes(ch_tags: set[int], exclude_cat: str | None) -> bool: def passes(ch_tags: set[int], exclude_cat: str | None) -> bool:
# The Python mirror of filtered_query's SQL tag block (AND across categories, OR within, # The Python mirror of filtered_query's SQL tag block (AND across categories, OR within,
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there. # topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there. (The "No tag"
# filter is applied in the base query above, so it needs no mirror here.)
for cat, sel in selected_by_cat.items(): for cat, sel in selected_by_cat.items():
if cat == exclude_cat: if cat == exclude_cat:
continue continue
@@ -417,6 +437,30 @@ def feed_summary(db: Session, user: User, **params) -> dict:
return {"count": count, "facets": facets} return {"count": count, "facets": facets}
def feed_years(db: Session, user: User, **params) -> list[dict]:
"""Per-year video counts for the current filter context (item 8 / E8·S2): powers the year control's
quick chips + the "more…" per-year-counts dialog. Every OTHER filter (tags, channels, content type,
search, watch state, scope) still applies, but the publish-date narrowing itself is DROPPED —
published_after/before and max_age_days — so the year list is the full set of years available and
switching years stays a live drill-down (the same principle as the tag facets dropping their own
category). One grouped query, newest year first."""
base, _rank = filtered_query(
db,
user,
**{**params, "published_after": None, "published_before": None, "max_age_days": None},
)
pub = base.with_only_columns(Video.published_at.label("pub")).subquery()
# Bucket by UTC year to stay in lockstep with the year filter's boundaries (published_after/before
# are pinned to UTC in filtered_query). published_at is timestamptz, so a bare extract('year', …)
# would use the DB session timezone and, on a non-UTC session, bucket a video near a year boundary
# into a different year than the filter includes — the count and the drill-down feed would disagree.
year = func.extract("year", func.timezone("UTC", pub.c.pub))
rows = db.execute(
select(year.label("y"), func.count()).group_by(year).order_by(year.desc())
).all()
return [{"year": int(y), "count": n} for y, n in rows if y is not None]
# --- Keyset (cursor) pagination --------------------------------------------- # --- Keyset (cursor) pagination ---------------------------------------------
# #
# Each sort is described as an ordered list of key columns (expression + direction # Each sort is described as an ordered list of key columns (expression + direction
+7 -3
View File
@@ -43,8 +43,10 @@ def rollup(cards: list[dict]) -> dict:
def agg_subquery(db: Session, user_id: int, include_resumed: bool = False): def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show """Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
grid by aggregate watch-state, (b) compute each card's badge, and (c) drive the Discover grid by aggregate watch-state, (b) compute each card's badge, and (c) drive the Discover
continue-watching rail. `include_resumed` adds `resumed_at` = max(progress_updated_at) across the continue-watching rail, and (d) the browse "last watched" sort. `include_resumed` adds `resumed_at`
show's episodes, so the rail can order shows by how recently they were last progressed.""" = max(coalesce(progress_updated_at, watched_at)) across the show's episodes — i.e. how recently ANY
episode was last touched (resumed OR marked watched) — so callers can order shows by recency. A
fully-watched show (no resume progress) still gets a timestamp from its episodes' watched_at."""
ps = aliased(PlexState) ps = aliased(PlexState)
cols = [ cols = [
PlexItem.show_id.label("show_id"), PlexItem.show_id.label("show_id"),
@@ -63,7 +65,9 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
).label("inprog"), ).label("inprog"),
] ]
if include_resumed: if include_resumed:
cols.append(func.max(ps.progress_updated_at).label("resumed_at")) cols.append(
func.max(func.coalesce(ps.progress_updated_at, ps.watched_at)).label("resumed_at")
)
return ( return (
select(*cols) select(*cols)
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id)) .outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
+30 -8
View File
@@ -38,6 +38,17 @@ _HLS_ROOT = (
else Path(settings.download_root) / ".plex-hls" else Path(settings.download_root) / ".plex-hls"
) )
_SEG_SECONDS = 6 _SEG_SECONDS = 6
# Cold-start warm-up. A freshly-spawned ffmpeg has produced only the first segment (or two) by the
# time we could report the session ready; hls.js parses the EVENT playlist, drains that tiny buffer,
# then STALLS a few seconds into playback waiting for the next segment the still-warming ffmpeg
# hasn't written yet. Very visible on auto-advance to the next episode (start_s==0, which previously
# returned with ZERO buffer). So before reporting ready we wait for the first few segments to exist:
# the client then starts with ~_WARMUP_SEGMENTS×_SEG_SECONDS of buffer and, since stream-copy runs
# faster than realtime, ffmpeg stays ahead from there and never stalls again. Best-effort and bounded
# — we stop at the first segment that isn't ready within the per-segment budget (a slow miss is no
# worse than the old zero-buffer behaviour).
_WARMUP_SEGMENTS = 3
_WARMUP_SEG_TIMEOUT_S = 8.0
_SESSION_IDLE_S = 600 # reap a session with no access for this long _SESSION_IDLE_S = 600 # reap a session with no access for this long
# A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session); # A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session);
# before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names # before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names
@@ -296,16 +307,27 @@ def start_session(
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes); # Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
# at least 1 so playback can't be capped to zero. # at least 1 so playback can't be capped to zero.
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes"))) _enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
# Learn the REAL start offset K from the first VIDEO segment (outside the lock — this blocks on
# ffmpeg producing it, and we must not hold up other sessions). The client reads `media_start_s`, so # Wait for the first video segment, then warm up a few more — all outside the lock (this blocks on
# its absolute clock + subtitle shift key off the true keyframe, not the requested offset. On # ffmpeg producing them, and we must not hold up other sessions).
# timeout/probe failure we keep the provisional requested offset (old behaviour, small drift). def _seg(i: int) -> Path:
if start_s > 0: # multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts
seg0 = directory / ("seg_0_0.ts" if is_multi else "seg_0.ts") # multi: video variant = v0 return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts")
if wait_for(seg0, timeout=25.0):
k = _probe_first_pts(seg0) if wait_for(_seg(0), timeout=25.0):
# For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS,
# so the client's absolute clock + subtitle shift key off the true keyframe, not the requested
# offset. On probe failure we keep the provisional requested offset (old behaviour, small drift).
if start_s > 0:
k = _probe_first_pts(_seg(0))
if k is not None and k >= 0: if k is not None and k >= 0:
s.media_start_s = k s.media_start_s = k
# Warm up the buffer (see _WARMUP_SEGMENTS) so the client doesn't outrun the cold ffmpeg. Stop
# at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once
# warm, so a miss means something slow and we proceed rather than block the request further.
for i in range(1, _WARMUP_SEGMENTS):
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
break
log.info( log.info(
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s", "plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s",
key, key,
+25 -21
View File
@@ -11,11 +11,14 @@ already pages) and upsert them into the owner's `plex_states`. Plex wins on over
exist only in Siftlode are left untouched (union, Plex-authoritative on the intersection). exist only in Siftlode are left untouched (union, Plex-authoritative on the intersection).
Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→Siftlode via the watch Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→Siftlode via the watch
history + onDeck, last-write-wins) build on this and land in later ships. history + onDeck) build on this and land in later ships. As of S2 Plex is the source of truth: the
player pushes the resume position back to Plex on the periodic checkpoint (not just the final flush),
and the incremental pull resolves a genuine divergence to Plex's state (see `_pull_apply`) rather than
running a last-write-wins comparison.
""" """
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -293,12 +296,6 @@ def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -
# --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------ # --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------
# Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just
# pushed (Plex stamps viewedAt a beat after our watched_at) must not read as "newer on Plex" and get
# pulled back with a different shape. Genuine Plex-side edits are minutes from our writes — far
# outside this — so the window only absorbs skew, never real changes.
_LWW_TOLERANCE_S = 90
def _active_sync_links(db: Session) -> list[PlexLink]: def _active_sync_links(db: Session) -> list[PlexLink]:
"""Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so """Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so
@@ -338,11 +335,6 @@ def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int:
return acct_id return acct_id
def _sift_ts(st: PlexState) -> datetime | None:
"""The freshest Siftlode-side timestamp on a row, for last-write-wins."""
return max([t for t in (st.watched_at, st.progress_updated_at) if t], default=None)
def _same_state(st: PlexState | None, status: str, pos: int) -> bool: def _same_state(st: PlexState | None, status: str, pos: int) -> bool:
"""Whether a row already matches a target (status, pos) — the core ping-pong guard: when nothing """Whether a row already matches a target (status, pos) — the core ping-pong guard: when nothing
would change we skip the write entirely (no row churn, no timestamp bump, no re-pull).""" would change we skip the write entirely (no row churn, no timestamp bump, no re-pull)."""
@@ -370,9 +362,20 @@ def _pull_apply(
if st is not None and not st.synced_to_plex: if st is not None and not st.synced_to_plex:
st.synced_to_plex = True # already matches Plex → just settle the flag, no churn st.synced_to_plex = True # already matches Plex → just settle the flag, no churn
return None return None
# Conflict: keep Siftlode when it's newer than Plex (plus skew slack). # Plex-authoritative (S2): on a genuine divergence for an item Plex knows about, Plex wins — no
sts = _sift_ts(st) if st else None # last-write-wins tug-of-war. This is safe because prompt write-back (playback.py pushes the resume
if sts and plex_ts and plex_ts <= sts + timedelta(seconds=_LWW_TOLERANCE_S): # position to Plex on the ~10s checkpoint, not just on the final flush) keeps Plex current, so a
# local edit that's "newer" than Plex has almost always already been pushed and matched above by
# `_same_state`; a row that reaches here really is out of step with the source of truth. The
# `_same_state` + `synced_to_plex` short-circuit above still absorbs the freshly-pushed case (Plex
# echoes the same shape a beat later) so a just-pushed row never bounces.
if st is not None and not st.synced_to_plex:
# Durability guard: this row holds a local change that never reached Plex — its immediate push
# failed, so it's still dirty. Do NOT let the Plex-authoritative pull overwrite it and clear the
# flag: that would strand the local value forever, since `_repush_dirty` only retries dirty rows.
# Leave it dirty; `_repush_dirty` (later in this same run) pushes the local value up to Plex. This
# closes the "Plex flapped mid-session" lost-update — the one case the Plex-wins comment's
# "almost always already pushed" assumption above excludes.
return None return None
if st is None: if st is None:
st = PlexState(user_id=user_id, item_id=item_id) st = PlexState(user_id=user_id, item_id=item_id)
@@ -437,10 +440,11 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
def run_plex_watch_sync(db: Session) -> dict: def run_plex_watch_sync(db: Session) -> dict:
"""Scheduler job (default 30 min) — the cheap incremental pass. Pulls Plex-side changes since the """Scheduler job (default 15 min) — the cheap incremental pass. Pulls Plex-side changes since the
high-water mark (watch history + on-deck, filtered to the owner account) into Siftlode under high-water mark (watch history + on-deck, filtered to the owner account) into Siftlode, resolving a
last-write-wins, then re-pushes any still-unsynced local states. Does NOT propagate un-watches divergence to Plex (the source of truth — see `_pull_apply`), then re-pushes any still-unsynced
(the history feed only carries positive views) — that's `run_plex_watch_reconcile`'s job.""" local states. Does NOT propagate un-watches (the history feed only carries positive views) — that's
`run_plex_watch_reconcile`'s job."""
if not sysconfig.get_bool(db, "plex_enabled"): if not sysconfig.get_bool(db, "plex_enabled"):
return {"skipped": "disabled"} return {"skipped": "disabled"}
total = {"links": 0, "watched": 0, "in_progress": 0, "repushed": 0} total = {"links": 0, "watched": 0, "in_progress": 0, "repushed": 0}
@@ -491,7 +495,7 @@ def run_plex_watch_sync(db: Session) -> dict:
def run_plex_watch_reconcile(db: Session) -> dict: def run_plex_watch_reconcile(db: Session) -> dict:
"""Scheduler job (default daily) — the authoritative full reconcile. Rescans every section and, """Scheduler job (default daily) — the authoritative full reconcile. Rescans every section and,
per item, uses `synced_to_plex` to settle what the incremental feed can't: per item, uses `synced_to_plex` to settle what the incremental feed can't:
- Plex has a state, Siftlode differs → last-write-wins (Plex usually the newer here). - Plex has a state, Siftlode differs → Plex wins (the source of truth — see `_pull_apply`).
- Siftlode has a state, Plex has NO record: - Siftlode has a state, Plex has NO record:
· synced_to_plex=True → Plex un-watched it since our last sync → clear it (un-watch). · synced_to_plex=True → Plex un-watched it since our last sync → clear it (un-watch).
· synced_to_plex=False → a local change that never reached Plex → re-push it (via · synced_to_plex=False → a local change that never reached Plex → re-push it (via
+5
View File
@@ -229,6 +229,7 @@ def _channel_detail_dict(
count_live: int, count_live: int,
deep_requested: bool, deep_requested: bool,
priority: int, priority: int,
hidden: bool,
tag_ids: list[int], tag_ids: list[int],
) -> dict: ) -> dict:
return { return {
@@ -262,6 +263,9 @@ def _channel_detail_dict(
# This user's per-subscription feed priority (0 when not subscribed) so the channel page can # This user's per-subscription feed priority (0 when not subscribed) so the channel page can
# offer the same priority stepper as the manager. Editable only while subscribed. # offer the same priority stepper as the manager. Editable only while subscribed.
"priority": priority, "priority": priority,
# This user's per-subscription "hide from feed" flag (False when not subscribed), so the
# channel page can offer the same Unhide toggle the manager has. Editable only while subscribed.
"hidden": hidden,
"backfill_done": channel.backfill_done, "backfill_done": channel.backfill_done,
"from_explore": channel.from_explore, "from_explore": channel.from_explore,
"details_synced": channel.details_synced_at is not None, "details_synced": channel.details_synced_at is not None,
@@ -344,6 +348,7 @@ def channel_detail(
count_live=count_live, count_live=count_live,
deep_requested=bool(sub.deep_requested) if sub else False, deep_requested=bool(sub.deep_requested) if sub else False,
priority=int(sub.priority) if sub else 0, priority=int(sub.priority) if sub else 0,
hidden=bool(sub.hidden) if sub else False,
tag_ids=tag_ids, tag_ids=tag_ids,
) )
+17
View File
@@ -14,6 +14,7 @@ from app.feedquery import (
decode_cursor, decode_cursor,
encode_cursor, encode_cursor,
feed_summary, feed_summary,
feed_years,
filtered_query, filtered_query,
keyset_predicate, keyset_predicate,
order_by, order_by,
@@ -53,6 +54,8 @@ def _feed_params(
tag_mode_language: str = "or", tag_mode_language: str = "or",
tag_mode_topic: str = "or", tag_mode_topic: str = "or",
tag_mode_other: str = "or", tag_mode_other: str = "or",
# "No tag" filter: keep only channels the user has not personally tagged (no user-owned ChannelTag).
untagged: bool = False,
channel_ids: list[str] = Query(default=[]), channel_ids: list[str] = Query(default=[]),
q: str | None = None, q: str | None = None,
min_duration: int | None = None, min_duration: int | None = None,
@@ -74,6 +77,7 @@ def _feed_params(
"topic": tag_mode_topic, "topic": tag_mode_topic,
"other": tag_mode_other, "other": tag_mode_other,
}, },
"untagged": untagged,
"channel_ids": channel_ids, "channel_ids": channel_ids,
"q": q, "q": q,
"min_duration": min_duration, "min_duration": min_duration,
@@ -203,6 +207,19 @@ def get_feed_summary(
return feed_summary(db, user, **params) return feed_summary(db, user, **params)
@router.get("/feed/years")
def get_feed_years(
params: dict = Depends(_feed_params),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Per-year video counts for the year control (item 8 / E8·S2): the quick recent-year chips and the
"more…" per-year-counts dialog. Every filter EXCEPT the publish-date narrowing applies, so picking a
year re-queries the feed but not this list. Response: {"years": [{"year": <int>, "count": <int>}]},
newest year first."""
return {"years": feed_years(db, user, **params)}
class VideoStateIn(BaseModel): class VideoStateIn(BaseModel):
status: str status: str
+8 -1
View File
@@ -138,6 +138,9 @@ def unified_library(
cast(null(), Integer).label("season_count"), cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"), func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"), m_status.label("status"),
# "last watched" sort key: when the movie was last resumed, else when it was marked
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
( (
func.ts_rank(vector_col(PlexItem, lang), tsq) func.ts_rank(vector_col(PlexItem, lang), tsq)
if tsq is not None if tsq is not None
@@ -159,7 +162,7 @@ def unified_library(
mq = mq.where(recheck(PlexItem, field, tsq, lang)) mq = mq.where(recheck(PlexItem, field, tsq, lang))
selects.append(mq) selects.append(mq)
if want_shows and grid_field: if want_shows and grid_field:
agg = rollup.agg_subquery(db, user.id).subquery() agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
tot = func.coalesce(agg.c.total, 0) tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0) wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0) inp = func.coalesce(agg.c.inprog, 0)
@@ -179,6 +182,9 @@ def unified_library(
PlexShow.child_count.label("season_count"), PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"), literal(0).label("position_seconds"),
s_status.label("status"), s_status.label("status"),
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
agg.c.resumed_at.label("last_watched"),
( (
func.ts_rank(vector_col(PlexShow, lang), tsq) func.ts_rank(vector_col(PlexShow, lang), tsq)
if tsq is not None if tsq is not None
@@ -212,6 +218,7 @@ def unified_library(
"rating": u.c.rating, "rating": u.c.rating,
"duration": u.c.duration_s, "duration": u.c.duration_s,
"release": u.c.rel, "release": u.c.rel,
"lastwatched": u.c.last_watched,
}.get(sort, u.c.added_at) }.get(sort, u.c.added_at)
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
order.append(u.c.rk.desc()) order.append(u.c.rk.desc())
+89 -8
View File
@@ -3,7 +3,8 @@
import hashlib import hashlib
import os import os
import tempfile import tempfile
from datetime import datetime, timezone import threading
from datetime import datetime, timedelta, timezone
import httpx import httpx
from fastapi import ( from fastapi import (
@@ -155,6 +156,47 @@ _PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30 _FINISH_MARGIN_S = 30
# S2 write-back throttle: the resume position is now pushed to Plex on the periodic (non-final)
# checkpoint too — not only on the final pause/unmount flush — so Plex (the source of truth) stays
# current in near-real-time. To keep a single watch from spraying `/:/timeline` calls, a push is
# rate-limited to at most one per item per user every _PUSH_THROTTLE_S seconds; a `final` checkpoint
# (pause / seek / stop) always pushes immediately and resets the window. This is an in-memory, single-
# process rate-limiter (the deployment is one FastAPI process): after a restart the map is empty, so at
# most one extra push happens on the first checkpoint — harmless. It is NOT durable domain state.
_PUSH_THROTTLE_S = 10
_last_push_at: dict[tuple[int, int], datetime] = {}
_push_throttle_lock = threading.Lock()
def _should_push_resume(user_id: int, item_id: int, now: datetime, final: bool) -> bool:
"""Whether to mirror this resume checkpoint to Plex now. `final` always pushes; a periodic tick
pushes only when ≥_PUSH_THROTTLE_S has elapsed since the last push for this (user, item). Records
`now` as the last-push time whenever it returns True so the window is measured from the real push
(and so `_resume_push_superseded` can drop an older, still-pending push — see there)."""
with _push_throttle_lock:
last = _last_push_at.get((user_id, item_id))
if final or last is None or (now - last).total_seconds() >= _PUSH_THROTTLE_S:
_last_push_at[(user_id, item_id)] = now
# Opportunistic eviction so the map can't grow unbounded over a long-lived process (one
# entry per item ever checkpointed). Anything older than a few windows is dead — a push
# completes in seconds, so a stale entry protects nothing.
if len(_last_push_at) > 2048:
cutoff = now - timedelta(seconds=_PUSH_THROTTLE_S * 6)
for k in [k for k, t in _last_push_at.items() if t < cutoff]:
del _last_push_at[k]
return True
return False
def _resume_push_superseded(user_id: int, item_id: int, intent: datetime) -> bool:
"""True if a newer resume push for this (user, item) was scheduled after `intent`. Called at the
HTTP boundary of a background resume push: because periodic and seek/pause pushes are independent
background tasks with no ordering guard, an older still-pending periodic push could otherwise land
at Plex AFTER a newer backward-seek push and — with the Plex-authoritative pull — durably revive the
stale position. Skipping a superseded push keeps the last-scheduled position the one Plex settles on."""
with _push_throttle_lock:
return _last_push_at.get((user_id, item_id), intent) > intent
@router.get("/person-image") @router.get("/person-image")
def person_image(u: str, request: Request) -> Response: def person_image(u: str, request: Request) -> Response:
@@ -178,6 +220,26 @@ def person_image(u: str, request: Request) -> Response:
return imgcache.response(r.content, ct) return imgcache.response(r.content, ct)
def _push_resume_guarded(
user_id: int,
item_id: int,
rating_key: str,
time_ms: int,
duration_ms: int,
expect_ts: datetime | None,
intent: datetime,
) -> None:
"""Background entrypoint for a resume push that drops itself if a newer resume push for the same
(user, item) was scheduled after it (see `_resume_push_superseded`), then delegates to the real
push. This narrows the periodic-vs-seek write-ordering race to the (rare) case where the older push
has already left the process before the newer one is scheduled."""
if _resume_push_superseded(user_id, item_id, intent):
return
plex_watch.push_state_to_plex(
user_id, item_id, rating_key, "resume", time_ms, duration_ms, expect_ts
)
def _push_watch( def _push_watch(
background: BackgroundTasks, background: BackgroundTasks,
db: Session, db: Session,
@@ -187,10 +249,14 @@ def _push_watch(
action: str, action: str,
time_ms: int = 0, time_ms: int = 0,
duration_ms: int = 0, duration_ms: int = 0,
intent: datetime | None = None,
) -> None: ) -> None:
"""Schedule a best-effort Siftlode→Plex watch push, but only for a user whose owner link has """Schedule a best-effort Siftlode→Plex watch push, but only for a user whose owner link has
push enabled — non-owner users never touch Plex. Guarding here (with the request session) avoids push enabled — non-owner users never touch Plex. Guarding here (with the request session) avoids
spinning up a throwaway background session on every state change for the common no-link case.""" spinning up a throwaway background session on every state change for the common no-link case.
A `resume` push with an `intent` timestamp is routed through `_push_resume_guarded` so a superseded
(older) push can't land out of order; `watched`/`unwatched` scrobbles go straight through."""
if plex_watch.link_for_push(db, user_id) is None: if plex_watch.link_for_push(db, user_id) is None:
return return
# Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit # Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit
@@ -202,6 +268,12 @@ def _push_watch(
.scalars() .scalars()
.first() .first()
) )
marker = plex_watch._state_marker(st)
if action == "resume" and intent is not None:
background.add_task(
_push_resume_guarded, user_id, item_id, rating_key, time_ms, duration_ms, marker, intent
)
return
background.add_task( background.add_task(
plex_watch.push_state_to_plex, plex_watch.push_state_to_plex,
user_id, user_id,
@@ -210,7 +282,7 @@ def _push_watch(
action, action,
time_ms, time_ms,
duration_ms, duration_ms,
plex_watch._state_marker(st), marker,
) )
@@ -232,9 +304,10 @@ def item_progress(
) -> dict: ) -> dict:
"""Checkpoint the resume position while playing (near the end → mark watched + clear it). """Checkpoint the resume position while playing (near the end → mark watched + clear it).
`final` (sent on the pause/pagehide/unmount flush, not the periodic 10s checkpoint) is what S2 write-back: the resume position is mirrored to Plex (the source of truth) on the periodic
debounces the Plex resume push: we mirror the settled position to Plex only on a final flush, so checkpoint too, not only on the final flush — throttled to ≥_PUSH_THROTTLE_S per item (see
a single watch doesn't spray `/:/timeline` calls at the server every 10 seconds.""" `_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and
resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks."""
it = item_or_404(db, rating_key) it = item_or_404(db, rating_key)
position = max(0, int(body.position_seconds)) position = max(0, int(body.position_seconds))
duration = int(body.duration_seconds) duration = int(body.duration_seconds)
@@ -259,6 +332,10 @@ def item_progress(
st.position_seconds = 0 st.position_seconds = 0
st.progress_updated_at = now st.progress_updated_at = now
st.synced_to_plex = False st.synced_to_plex = False
# Terminal state for resume write-back: drop the throttle/ordering entry so the map doesn't
# retain a dead key for every finished item.
with _push_throttle_lock:
_last_push_at.pop((user.id, it.id), None)
db.commit() db.commit()
# Immediate on the watched transition; skip a repeat scrobble if it was already watched # Immediate on the watched transition; skip a repeat scrobble if it was already watched
# (a later checkpoint of the same finished item would otherwise bump Plex's viewCount again). # (a later checkpoint of the same finished item would otherwise bump Plex's viewCount again).
@@ -280,10 +357,13 @@ def item_progress(
db.add(st) db.add(st)
st.position_seconds = position st.position_seconds = position
st.progress_updated_at = now st.progress_updated_at = now
if final: push_now = _should_push_resume(user.id, it.id, now, final)
if push_now:
# Mark dirty before the (background) push so the reconcile knows to re-push if it fails, and so
# `push_state_to_plex` can settle synced_to_plex once Plex confirms.
st.synced_to_plex = False st.synced_to_plex = False
db.commit() db.commit()
if final: if push_now:
_push_watch( _push_watch(
background, background,
db, db,
@@ -293,6 +373,7 @@ def item_progress(
"resume", "resume",
position * 1000, position * 1000,
duration * 1000, duration * 1000,
intent=now,
) )
return {"position_seconds": position} return {"position_seconds": position}
+12
View File
@@ -8,10 +8,22 @@ from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import ChannelTag, Tag, User from app.models import ChannelTag, Tag, User
from app.routes._common import owned_or_404 from app.routes._common import owned_or_404
from app.sync.tagsuggest import compute_tag_suggestions
router = APIRouter(prefix="/api/tags", tags=["tags"]) router = APIRouter(prefix="/api/tags", tags=["tags"])
@router.get("/suggestions")
def tag_suggestions(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict[str, list[dict]]:
"""Suggested user tags for this user's still-untagged subscribed channels, learned from the
auto-tag pattern of the channels they HAVE tagged (see app.sync.tagsuggest). Read-only — the
Channel Manager shows these as one-tap-accept chips; accepting posts to the normal attach-tag
endpoint. Keyed by channel id; channels with no suggestion are omitted."""
return compute_tag_suggestions(db, user)
@router.get("") @router.get("")
def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]: def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute( rows = db.execute(
+131
View File
@@ -0,0 +1,131 @@
"""Suggest a user's own manual tags for channels they haven't tagged yet.
The signal is the auto-tagger's *topic* output: each channel carries ``source='auto'`` topic
ChannelTags (see ``autotag.py``). For every user tag T the user has applied to enough channels,
we learn which topics are *characteristic* of T (present in a high fraction of the channels the
user tagged T), then suggest T for any still-untagged channel whose own topics cover enough of
that profile.
Language auto-tags are deliberately EXCLUDED from the signal: they are near-universal within a
language ("English" is on most channels), so they don't discriminate — including them floods the
result (measured on real data: every untagged channel got a suggestion, most on a shared language
alone). Topic overlap is what makes "you tagged similar channels this way" meaningful.
Purely read-only and deterministic — the Channel Manager surfaces the result as one-tap-accept
"ghost" chips; nothing is written here (D4 = suggest-and-confirm, no silent auto-apply). The
thresholds are tuning constants rather than admin knobs on purpose: they mirror how ``autotag.py``
keeps its mappings inline, and sysconfig only stores int/str/bool (no float), so a knob would mean
storing percentages — not worth the surface for values an operator has no reason to tune per-install.
"""
from collections import Counter
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import ChannelTag, Subscription, Tag, User
# A user tag needs at least this many manually-tagged example channels before we trust its
# profile — fewer is noise (one gaming-cooking channel shouldn't make "cooking" mean "gaming").
MIN_EXAMPLES = 3
# A topic counts as *characteristic* of a user tag when it appears in ≥60% of that tag's example
# channels. High enough that an incidental shared topic (one channel's stray category) doesn't
# enter the profile, low enough to survive a couple of outliers in a small example set.
CHAR_SUPPORT = 0.6
# Suggest tag T for channel C when C's topics cover ≥60% of T's characteristic topics. The
# intersection-over-profile shape means a partial match scores proportionally: 1 topic of a
# 2-topic profile = 0.5 (dropped), 2 of 3 = 0.67 (kept). Tuned against real data to surface
# strong topical matches without flooding — 0.5 let a single shared topic carry a suggestion.
SUGGEST_THRESHOLD = 0.6
# A suggestion at/above this score is "high confidence" — a full-profile topic match — and is the
# only kind the UI's "Accept all confident" action touches. Never applied silently (see docstring).
AUTOAPPLY_THRESHOLD = 0.9
# Cap suggestions per channel so a busy channel's chip row stays legible.
MAX_PER_CHANNEL = 3
def compute_tag_suggestions(db: Session, user: User) -> dict[str, list[dict]]:
"""Map each of the user's still-untagged subscribed channels to suggested user tags.
Returns ``{channel_id: [{"tag_id": int, "confidence": float}, ...]}`` — ordered best-first,
capped at ``MAX_PER_CHANNEL``. Channels with no suggestion are omitted (empty dict when the
user has no tag with a usable profile). Deterministic for a fixed DB state.
"""
# Candidate universe: the channels this user is subscribed to. `subscribed` (incl. hidden) is the
# SIGNAL set — a hidden channel is still a valid example / topic-feature source, since hiding only
# affects the feed, not the tagging pattern. `hidden` is excluded as a suggestion TARGET below (the
# Channel Manager shows ghost chips only on non-hidden rows, so suggesting for hidden channels would
# inflate the header count past what's shown and let bulk-accept tag channels the user can't see).
subs = db.execute(
select(Subscription.channel_id, Subscription.hidden).where(Subscription.user_id == user.id)
).all()
subscribed: set[str] = {cid for cid, _ in subs}
hidden: set[str] = {cid for cid, h in subs if h}
if not subscribed:
return {}
# The user's manual tag links: tag_id -> example channels, and each channel's user-tag set (so
# a channel with ANY user tag is excluded from "untagged"). Intersect with `subscribed`: unsubscribe
# deletes the Subscription but NOT the source='user' ChannelTag, so a tagged-then-unsubscribed
# channel would otherwise stay an "example" — inflating a profile's CHAR_SUPPORT denominator while
# contributing zero features (features are scanned over `subscribed` only), dropping characteristic
# topics. Keeping examples ⊆ subscribed makes the support fraction honest.
examples: dict[int, set[str]] = {}
user_tagged: set[str] = set()
for tag_id, cid in db.execute(
select(ChannelTag.tag_id, ChannelTag.channel_id).where(
ChannelTag.user_id == user.id, ChannelTag.source == "user"
)
).all():
if cid not in subscribed:
continue
examples.setdefault(tag_id, set()).add(cid)
user_tagged.add(cid)
# Topic features per channel: the auto TOPIC tags on the candidate channels. Language auto-tags
# are joined out (Tag.category) — they don't discriminate (see the module docstring).
features: dict[str, set[int]] = {}
for cid, tid in db.execute(
select(ChannelTag.channel_id, ChannelTag.tag_id)
.join(Tag, Tag.id == ChannelTag.tag_id)
.where(
ChannelTag.user_id.is_(None),
ChannelTag.source == "auto",
ChannelTag.channel_id.in_(subscribed),
Tag.category == "topic",
)
).all():
features.setdefault(cid, set()).add(tid)
# Characteristic-feature profile per user tag with enough examples.
profiles: dict[int, set[int]] = {}
for tag_id, ex in examples.items():
if len(ex) < MIN_EXAMPLES:
continue
counts: Counter[int] = Counter()
for cid in ex:
counts.update(features.get(cid, ()))
threshold = CHAR_SUPPORT * len(ex)
chars = {f for f, c in counts.items() if c >= threshold}
if chars:
profiles[tag_id] = chars
if not profiles:
return {}
suggestions: dict[str, list[dict]] = {}
for cid in subscribed:
if cid in hidden or cid in user_tagged: # hidden → no chips shown; tagged → not "untagged"
continue
cfeat = features.get(cid)
if not cfeat:
continue
scored = [(tag_id, len(cfeat & chars) / len(chars)) for tag_id, chars in profiles.items()]
hits = [(tid, s) for tid, s in scored if s >= SUGGEST_THRESHOLD]
if not hits:
continue
# Best score first; tag_id as a stable tie-break so the order is deterministic.
hits.sort(key=lambda x: (-x[1], x[0]))
suggestions[cid] = [
{"tag_id": tid, "confidence": round(s, 3)} for tid, s in hits[:MAX_PER_CHANNEL]
]
return suggestions
+43
View File
@@ -55,6 +55,49 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
assert body["count_normal"] == 2 assert body["count_normal"] == 2
@pytest.mark.parametrize("hidden", [True, False])
def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden):
# S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer
# the same Unhide toggle the manager has (the flag was previously omitted from the detail dict).
ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB)
db.add(ch)
me = User(email="hid@example.com")
db.add(me)
db.flush()
db.add(Subscription(user_id=me.id, channel_id=ch.id, hidden=hidden))
db.commit()
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: me
try:
resp = TestClient(app).get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
assert resp.json()["hidden"] is hidden
def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatch):
# No subscription row → the flag can't be True; it must serialize as False, not raise.
ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB)
me = User(email="hidns@example.com")
db.add_all([ch, me])
db.commit()
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: me
try:
resp = TestClient(app).get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
assert resp.json()["hidden"] is False
def _setup_channel_and_sub(db, *, email, is_demo=False): def _setup_channel_and_sub(db, *, email, is_demo=False):
ch = Channel( ch = Channel(
id="UC_wire", id="UC_wire",
+143
View File
@@ -162,11 +162,154 @@ _SUMMARY_CASES = [
"show=all&tags={O1}&tags={O2}&tag_mode_other=and", "show=all&tags={O1}&tags={O2}&tag_mode_other=and",
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch) "show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category "show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
# "No tag" filter (item B): channels with no USER tag. It stays applied in the base pass, so the
# facet/count lockstep must still hold with it on (alone, and combined with a positive tag filter).
"show=all&untagged=true",
"show=all&untagged=true&tags={L1}",
"show=all&q=clip", "show=all&q=clip",
"show=unwatched", "show=unwatched",
] ]
def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user, make_user):
"""The "No tag" filter (item B): `untagged=true` keeps only channels with NO tag the USER personally
attached (no user-owned ChannelTag). System/auto tags (user_id NULL) don't count, and another user's
tag on the same channel doesn't either."""
user = make_user(email="untagged@example.com")
other = make_user(email="someone-else@example.com")
mine = Tag(user_id=user.id, name="Fav", category="other")
sys_topic = Tag(user_id=None, name="Tech", category="topic")
theirs = Tag(user_id=other.id, name="TheirTag", category="other")
db.add_all([mine, sys_topic, theirs])
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chMine", "chSys", "chNone")}
db.add_all(chans.values())
db.flush()
# chMine: the user's own tag → tagged. chSys: only a system tag + another user's tag → still
# "untagged" for THIS user. chNone: nothing at all → untagged.
db.add(ChannelTag(channel_id="chMine", tag_id=mine.id, user_id=user.id, source="user"))
db.add(ChannelTag(channel_id="chSys", tag_id=sys_topic.id, user_id=None, source="auto"))
db.add(ChannelTag(channel_id="chSys", tag_id=theirs.id, user_id=other.id, source="user"))
for cid in chans:
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
db.add(_video(f"{cid}v", cid, f"{cid} clip"))
db.commit()
client = as_user(user)
# Off: every subscribed channel shows.
assert _feed_ids(client, "show=all") == {"chMinev", "chSysv", "chNonev"}
# On: only channels the user has NOT personally tagged (a system tag / another user's tag don't count).
assert _feed_ids(client, "show=all&untagged=true") == {"chSysv", "chNonev"}
def test_feed_years_counts_ignore_the_date_narrowing_but_honour_other_filters(
db, as_user, make_user
):
"""The year facet (item 8 / E8·S2): `/api/feed/years` returns a per-year video count, newest first,
with the publish-date narrowing DROPPED (so switching years stays a drill-down) but every other
filter still applied (here: a channel pick)."""
user = make_user(email="years@example.com")
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chX", "chY")}
db.add_all(chans.values())
db.flush()
# chX: 2 videos in 2024, 1 in 2022; chY: 1 in 2023 (should drop once we scope to chX).
spread = {
"chX": [(2024, 1), (2024, 2), (2022, 5)],
"chY": [(2023, 3)],
}
for cid, dates in spread.items():
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
for i, (yr, day) in enumerate(dates):
db.add(
Video(
id=f"{cid}v{i}",
channel_id=cid,
published_at=datetime(yr, 1, day, tzinfo=timezone.utc),
title=f"{cid} clip {i}",
is_short=False,
live_status="none",
via_search=False,
via_explore=False,
)
)
db.commit()
client = as_user(user)
# All channels: every year present, counted, newest first.
resp = client.get("/api/feed/years?show=all")
assert resp.status_code == 200, resp.text
assert resp.json()["years"] == [
{"year": 2024, "count": 2},
{"year": 2023, "count": 1},
{"year": 2022, "count": 1},
]
# A channel filter narrows the year list; a date narrowing is ignored (the point of the facet — so
# a selected year never zeroes out the others). Scoping to chX drops 2023, keeps 2024 (×2) + 2022.
resp = client.get("/api/feed/years?show=all&channel_ids=chX&published_after=2024-01-01")
assert resp.status_code == 200, resp.text
assert resp.json()["years"] == [
{"year": 2024, "count": 2},
{"year": 2022, "count": 1},
]
def test_feed_years_buckets_by_utc_year_under_a_non_utc_session(db, make_user):
"""feed_years must bucket by UTC year to stay in lockstep with the year filter's UTC-pinned
published_after/before bounds. published_at is timestamptz; a bare extract('year', …) would use the
DB session timezone, so a video just before midnight UTC on New Year's Eve would bucket into the
wrong (local) year on a non-UTC session and disagree with the feed the year chip drills into. Called
directly on this session (the HTTP client uses a separate connection SET TIME ZONE wouldn't reach)."""
from sqlalchemy import text
from app.feedquery import feed_years
user = make_user(email="tz-years@example.com")
db.add(Channel(id="chTZ", title="chTZ", subscriber_count=1))
db.add(Subscription(user_id=user.id, channel_id="chTZ", priority=0, hidden=False))
# 2023-12-31 23:30 UTC — a positive-offset session tz local-shifts this into 2024.
db.add(
Video(
id="tzv0",
channel_id="chTZ",
published_at=datetime(2023, 12, 31, 23, 30, tzinfo=timezone.utc),
title="NYE clip",
is_short=False,
live_status="none",
via_search=False,
via_explore=False,
)
)
db.commit()
params: dict = {
"tags": [],
"tag_modes": {"language": "or", "topic": "or", "other": "or"},
"untagged": False,
"channel_ids": [],
"q": None,
"min_duration": None,
"max_duration": None,
"max_age_days": None,
"published_after": None,
"published_before": None,
"show_normal": True,
"include_shorts": False,
"include_live": False,
"show": "all",
"scope": "my",
"library_source": "organic",
}
db.execute(
text("SET TIME ZONE 'Asia/Tokyo'")
) # +9: pushes the video's local wall-clock into 2024
try:
years = feed_years(db, user, **params)
finally:
db.execute(text("SET TIME ZONE 'UTC'")) # SET (not SET LOCAL) survives rollback — reset it
# UTC bucketing keeps the boundary video in 2023; a bare session-tz extract would report 2024.
assert years == [{"year": 2023, "count": 1}]
@pytest.mark.parametrize("case", _SUMMARY_CASES) @pytest.mark.parametrize("case", _SUMMARY_CASES)
def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case): def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case):
# /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces, # /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces,
@@ -0,0 +1,158 @@
"""S3: the "last watched" browse sort (`sort=lastwatched`). Movies rank by
coalesce(progress_updated_at, watched_at); shows by max(coalesce(...)) across their episodes
(rollup.resumed_at). Never-watched titles sink to the bottom (nullslast). DB lane (`db` fixture) —
skipped without Postgres.
"""
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
def _seed(db):
lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([lib_m, lib_s])
db.flush()
# Movies: newest resume, a watched-only (coalesce falls back to watched_at), an older resume, and a
# never-touched one.
mo_new = PlexItem(rating_key="mo_new", library_id=lib_m.id, kind="movie", title="Newest Resume")
mo_watched = PlexItem(
rating_key="mo_watched", library_id=lib_m.id, kind="movie", title="Watched Only"
)
mo_old = PlexItem(rating_key="mo_old", library_id=lib_m.id, kind="movie", title="Older Resume")
mo_never = PlexItem(rating_key="mo_never", library_id=lib_m.id, kind="movie", title="Never")
# Shows: a recently-resumed episode, a watched-only episode, and a never-watched show.
sh_recent = PlexShow(
rating_key="sh_recent", library_id=lib_s.id, title="Recent Show", child_count=1
)
sh_watched = PlexShow(
rating_key="sh_watched", library_id=lib_s.id, title="Watched Show", child_count=1
)
sh_never = PlexShow(
rating_key="sh_never", library_id=lib_s.id, title="Never Show", child_count=1
)
db.add_all([mo_new, mo_watched, mo_old, mo_never, sh_recent, sh_watched, sh_never])
db.flush()
ep_recent = PlexItem(
rating_key="sh_recent_e1",
library_id=lib_s.id,
kind="episode",
title="E1",
show_id=sh_recent.id,
season_number=1,
episode_number=1,
)
ep_watched = PlexItem(
rating_key="sh_watched_e1",
library_id=lib_s.id,
kind="episode",
title="E1",
show_id=sh_watched.id,
season_number=1,
episode_number=1,
)
db.add_all([ep_recent, ep_watched])
user = User(email="lastwatched@example.com")
db.add(user)
db.flush()
db.add_all(
[
PlexState(
user_id=user.id,
item_id=mo_new.id,
status="new",
position_seconds=100,
progress_updated_at=NOW,
),
PlexState(
user_id=user.id,
item_id=mo_watched.id,
status="watched",
watched_at=NOW - timedelta(hours=1),
),
PlexState(
user_id=user.id,
item_id=mo_old.id,
status="new",
position_seconds=100,
progress_updated_at=NOW - timedelta(hours=2),
),
# Show episodes.
PlexState(
user_id=user.id,
item_id=ep_recent.id,
status="new",
position_seconds=50,
progress_updated_at=NOW - timedelta(minutes=30),
),
PlexState(
user_id=user.id,
item_id=ep_watched.id,
status="watched",
watched_at=NOW - timedelta(minutes=90),
),
]
)
db.commit()
return user
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _ids(resp):
return [i["id"] for i in resp.json()["items"]]
def test_movies_ordered_by_last_watched_desc(db, monkeypatch):
user = _seed(db)
resp = _get(
db, user, monkeypatch, "/api/plex/library?scope=movie&sort=lastwatched&sort_dir=desc"
)
assert resp.status_code == 200, resp.text
# Newest resume, then the watched-only (coalesce → watched_at), then the older resume, then never.
assert _ids(resp) == ["mo_new", "mo_watched", "mo_old", "mo_never"]
def test_shows_ordered_by_last_watched_desc(db, monkeypatch):
user = _seed(db)
resp = _get(
db, user, monkeypatch, "/api/plex/library?scope=show&sort=lastwatched&sort_dir=desc"
)
assert resp.status_code == 200, resp.text
# Recent resume first, then the watched-only show, then the never-watched one (nullslast).
assert _ids(resp) == ["sh_recent", "sh_watched", "sh_never"]
def test_last_watched_mixed_scope_nullslast(db, monkeypatch):
user = _seed(db)
resp = _get(
db, user, monkeypatch, "/api/plex/library?scope=both&sort=lastwatched&sort_dir=desc"
)
assert resp.status_code == 200, resp.text
ids = _ids(resp)
# Global recency order across both legs: mo_new (NOW) > sh_recent (−30m) > mo_watched (−1h) >
# sh_watched (−90m) > mo_old (−2h); the two never-watched titles sink to the bottom.
assert ids[:5] == ["mo_new", "sh_recent", "mo_watched", "sh_watched", "mo_old"]
assert set(ids[5:]) == {"mo_never", "sh_never"}
+71
View File
@@ -154,3 +154,74 @@ class TestSweepOrphans:
os.utime(f, (f.stat().st_mtime - 3600,) * 2) # old enough that only is_dir() saves it os.utime(f, (f.stat().st_mtime - 3600,) * 2) # old enough that only is_dir() saves it
assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0 assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0
assert f.exists() assert f.exists()
class _FakeProc:
"""Stand-in for the ffmpeg Popen — start_session only stores it (and _kill/reap poll it later)."""
def poll(self):
return None
class _FakeItem:
def __init__(self, rating_key="12345", file_path="/media/movie.mkv"):
self.rating_key = rating_key
self.file_path = file_path
self.playable = "remux"
class TestColdStartWarmup:
"""The cold-start buffer warm-up: before reporting a session ready, start_session waits for the
first _WARMUP_SEGMENTS video segments so hls.js doesn't drain a one-segment buffer and stall a
few seconds in (the auto-advance-to-next-episode stall). We spy on wait_for instead of running
ffmpeg."""
@pytest.fixture
def fake_env(self, tmp_path, monkeypatch):
monkeypatch.setattr(stream, "_HLS_ROOT", tmp_path)
monkeypatch.setattr(stream, "_sessions", {})
src = tmp_path / "movie.mkv"
src.write_bytes(b"x")
monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src)
monkeypatch.setattr(stream.sysconfig, "get_int", lambda db, k: 2)
monkeypatch.setattr(stream.subprocess, "Popen", lambda *a, **k: _FakeProc())
return tmp_path
def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch):
waited = []
def spy(path, timeout=20.0):
waited.append(path.name)
return True
monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
assert s is not None
# seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS.
assert waited == [f"seg_{i}.ts" for i in range(stream._WARMUP_SEGMENTS)]
def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch):
waited = []
def spy(path, timeout=20.0):
waited.append(path.name)
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
assert s is not None
assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2
def test_no_warmup_wait_when_the_first_segment_never_arrives(self, fake_env, monkeypatch):
# If seg_0 itself times out we don't warm up at all (nothing to build on) — and the session is
# still returned so the client's own segment requests can drive it, as before.
waited = []
def spy(path, timeout=20.0):
waited.append(path.name)
return False
monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
assert s is not None
assert waited == ["seg_0.ts"]
+251
View File
@@ -0,0 +1,251 @@
"""S2 watch-sync semantics: Plex is the source of truth.
Two independent pieces:
- `_pull_apply` (DB lane): on a genuine divergence for an item Plex knows about, Plex now WINS —
the old "keep Siftlode when it's newer within a tolerance" tug-of-war is gone. The anti-bounce
short-circuit (`_same_state` + `synced_to_plex`) must still keep a freshly-pushed row from bouncing.
- `_should_push_resume` (pure): the in-memory write-back throttle — a `final` checkpoint always pushes,
a periodic one pushes at most once per _PUSH_THROTTLE_S seconds per (user, item).
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.models import PlexItem, PlexLibrary, PlexState, User
from app.plex.watch_sync import _pull_apply
from app.routes.plex.playback import (
_PUSH_THROTTLE_S,
_resume_push_superseded,
_should_push_resume,
)
def _seed_item_and_user(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
item = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A Movie")
db.add(item)
user = User(email="watch@example.com")
db.add(user)
db.flush()
return item.id, user.id
def test_pull_plex_wins_over_diverging_synced_local_resume(db):
"""The core S2 change: a SYNCED local row (its state already reached Plex) that later diverges from
Plex = a real external Plex-side change, so the incremental pull resolves to PLEX's position — Plex
is authoritative, no LWW. (A DIRTY local row is protected instead — see the dirty-guard test.)"""
item_id, user_id = _seed_item_and_user(db)
now = datetime.now(timezone.utc)
db.add(
PlexState(
user_id=user_id,
item_id=item_id,
status="new",
position_seconds=300,
progress_updated_at=now, # local touched just now — "newer" than Plex
synced_to_plex=True, # but already pushed → a later Plex divergence is an external change
)
)
db.flush()
# Plex reports a DIFFERENT (older) resume position for the same item.
changed = _pull_apply(db, user_id, item_id, "new", 120, plex_ts=now - timedelta(seconds=60))
db.flush()
assert changed == "in_progress"
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
)
.scalars()
.one()
)
assert st.position_seconds == 120, "Plex's position must win, even though local was newer"
assert st.synced_to_plex is True
def test_pull_plex_watched_wins_over_synced_local_in_progress(db):
"""Plex marking the item watched propagates over a SYNCED local in-progress row (external change)."""
item_id, user_id = _seed_item_and_user(db)
now = datetime.now(timezone.utc)
db.add(
PlexState(
user_id=user_id,
item_id=item_id,
status="new",
position_seconds=300,
progress_updated_at=now,
synced_to_plex=True,
)
)
db.flush()
changed = _pull_apply(db, user_id, item_id, "watched", 0, plex_ts=now - timedelta(seconds=60))
db.flush()
assert changed == "watched"
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
)
.scalars()
.one()
)
assert st.status == "watched"
assert st.position_seconds == 0
def test_pull_no_bounce_on_matching_row(db):
"""Anti-bounce short-circuit: when the row already matches what Plex reports (a just-pushed
resume), the pull makes NO change and only settles the synced flag — no churn, no re-pull."""
item_id, user_id = _seed_item_and_user(db)
now = datetime.now(timezone.utc)
db.add(
PlexState(
user_id=user_id,
item_id=item_id,
status="new",
position_seconds=100,
progress_updated_at=now,
synced_to_plex=True, # freshly pushed
)
)
db.flush()
# Plex echoes the same position a beat later (well within the _PROGRESS_MIN_S fuzz).
changed = _pull_apply(db, user_id, item_id, "new", 102, plex_ts=now + timedelta(seconds=2))
db.flush()
assert changed is None, "a matching row must not bounce"
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
)
.scalars()
.one()
)
assert st.position_seconds == 100, "no churn to the stored position"
def test_pull_keeps_dirty_local_on_divergence(db):
"""Durability guard: a DIRTY row (a local change whose immediate push failed → synced_to_plex=False)
must NOT be overwritten by a diverging Plex signal, and must stay dirty so `_repush_dirty` can push
the local value up. Overwriting-and-clearing here would strand the local change forever."""
item_id, user_id = _seed_item_and_user(db)
now = datetime.now(timezone.utc)
db.add(
PlexState(
user_id=user_id,
item_id=item_id,
status="watched", # user just marked watched locally; the scrobble to Plex failed
position_seconds=0,
watched_at=now,
progress_updated_at=now,
synced_to_plex=False, # dirty — never reached Plex
)
)
db.flush()
# Plex still lists it in on_deck at an old in-progress offset (it never got the watched scrobble).
changed = _pull_apply(db, user_id, item_id, "new", 240, plex_ts=now - timedelta(seconds=60))
db.flush()
assert changed is None, "a dirty local row must not be clobbered by the pull"
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
)
.scalars()
.one()
)
assert st.status == "watched", "local watched preserved"
assert st.position_seconds == 0
assert st.synced_to_plex is False, "stays dirty so _repush_dirty retries the push"
def test_pull_settles_flag_on_matching_dirty_row(db):
"""Anti-bounce, dirty branch: a row that already MATCHES Plex but is still dirty gets its
synced_to_plex flag settled to True (no churn), so `_repush_dirty` stops re-pushing it forever."""
item_id, user_id = _seed_item_and_user(db)
now = datetime.now(timezone.utc)
db.add(
PlexState(
user_id=user_id,
item_id=item_id,
status="new",
position_seconds=100,
progress_updated_at=now,
synced_to_plex=False, # dirty, but already agrees with Plex
)
)
db.flush()
changed = _pull_apply(db, user_id, item_id, "new", 102, plex_ts=now + timedelta(seconds=2))
db.flush()
assert changed is None, "a matching row makes no change"
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
)
.scalars()
.one()
)
assert st.position_seconds == 100, "no churn to the stored position"
assert st.synced_to_plex is True, "the dirty flag is settled so it isn't re-pushed endlessly"
def test_should_push_resume_scopes_by_user_and_item():
"""The throttle window is keyed by the FULL (user, item) tuple: pushing for one key must not
suppress a distinct user or a distinct item within the same window."""
now = datetime.now(timezone.utc)
assert _should_push_resume(9101, 9201, now, final=False) is True # (u1, i1) first tick
# Same window, different item for the same user → independent, still pushes.
assert _should_push_resume(9101, 9202, now, final=False) is True
# Same window, different user, same item → independent, still pushes.
assert _should_push_resume(9102, 9201, now, final=False) is True
# And the original key is still throttled within its window (proves the above weren't just "always").
assert _should_push_resume(9101, 9201, now + timedelta(seconds=1), final=False) is False
def test_resume_push_superseded_drops_older_pending_push():
"""The write-ordering guard: an older still-pending resume push is dropped once a newer push for the
same (user, item) has been scheduled, so a reordered stale position can't land at Plex last."""
key = (9301, 9301)
t0 = datetime.now(timezone.utc)
# Schedule an initial push at t0 (records t0 as the last-push time for this key).
assert _should_push_resume(*key, t0, final=False) is True
# Nothing newer yet → the t0 push is current, not superseded.
assert _resume_push_superseded(*key, t0) is False
# A newer push (e.g. a backward seek) is scheduled at t1 > t0.
t1 = t0 + timedelta(seconds=1)
assert _should_push_resume(*key, t1, final=True) is True
# Now the still-pending t0 push is superseded and must drop; the t1 push is current.
assert _resume_push_superseded(*key, t0) is True
assert _resume_push_superseded(*key, t1) is False
def test_should_push_resume_final_always_pushes():
"""A final checkpoint (pause / seek / stop) always pushes, bypassing the throttle."""
now = datetime.now(timezone.utc)
key = (9001, 9001)
assert _should_push_resume(*key, now, final=True) is True
# A second final a fraction of a second later still pushes.
assert _should_push_resume(*key, now + timedelta(seconds=1), final=True) is True
def test_should_push_resume_periodic_is_throttled():
"""Periodic (non-final) checkpoints push at most once per _PUSH_THROTTLE_S per (user, item)."""
now = datetime.now(timezone.utc)
key = (9002, 9002)
assert _should_push_resume(*key, now, final=False) is True # first tick pushes
# A tick within the window is suppressed.
assert (
_should_push_resume(*key, now + timedelta(seconds=_PUSH_THROTTLE_S - 1), final=False)
is False
)
# Once the window elapses, the next tick pushes again.
assert _should_push_resume(*key, now + timedelta(seconds=_PUSH_THROTTLE_S), final=False) is True
+286
View File
@@ -0,0 +1,286 @@
"""DB-backed regression (E8·S4): the manual-tag-pattern suggester.
compute_tag_suggestions learns which auto TOPIC tags are *characteristic* of each user tag (from the
channels the user tagged), then proposes that tag for still-untagged subscribed channels whose own
topics cover ≥60% of the profile. The cases below pin the behaviours that would silently ship a
useless suggester if they broke: the min-examples floor, the intersection-over-profile fraction that
keeps a weak partial match out, the "language is not a signal" exclusion (its inclusion once flooded
every channel with a suggestion), and the "already tagged → excluded" scope. Deterministic — no
network, pure DB state.
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from app.models import Channel, ChannelTag, Subscription, Tag, User
from app.sync.tagsuggest import (
AUTOAPPLY_THRESHOLD,
CHAR_SUPPORT,
MAX_PER_CHANNEL,
SUGGEST_THRESHOLD,
compute_tag_suggestions,
)
def _auto(db, name, category="topic"):
tag = Tag(user_id=None, name=name, category=category)
db.add(tag)
db.flush()
return tag
def _subscribe(db, user, ch_id, *, auto_tag_ids=(), title=None, hidden=False):
"""A subscribed channel carrying the given auto-tags (source='auto', user_id NULL)."""
db.add(Channel(id=ch_id, title=title or ch_id))
db.add(Subscription(user_id=user.id, channel_id=ch_id, hidden=hidden))
for tid in auto_tag_ids:
db.add(ChannelTag(channel_id=ch_id, tag_id=tid, user_id=None, source="auto"))
def test_suggests_a_user_tag_for_a_channel_matching_its_topic_profile(db):
me = User(email="me@example.com")
db.add(me)
db.flush()
edu = _auto(db, "Education")
film = _auto(db, "Film")
eng = _auto(db, "English", category="language") # a language auto-tag → NOT part of the signal
docu = Tag(user_id=me.id, name="docu", category="other")
db.add(docu)
db.flush()
# Three example channels the user tagged "docu", all Education+Film (+English). The topic profile
# is {Education, Film}; the English language tag is excluded from the profile.
for i in range(3):
cid = f"UC_ex{i}"
_subscribe(db, me, cid, auto_tag_ids=[edu.id, film.id, eng.id])
db.add(ChannelTag(channel_id=cid, tag_id=docu.id, user_id=me.id, source="user"))
# An untagged subscribed channel sharing the full topic profile → full-confidence suggestion.
_subscribe(db, me, "UC_match", auto_tag_ids=[edu.id, film.id, eng.id])
# An untagged channel sharing ONLY the language → no topic overlap at all, so no suggestion. This
# is the anti-flood guarantee: a shared language must never carry a suggestion on its own.
_subscribe(db, me, "UC_english_only", auto_tag_ids=[eng.id])
db.commit()
out = compute_tag_suggestions(db, me)
assert "UC_match" in out
assert out["UC_match"][0]["tag_id"] == docu.id
# Both profile topics present → full confidence, i.e. an "accept all confident" hit.
assert out["UC_match"][0]["confidence"] >= AUTOAPPLY_THRESHOLD
assert "UC_english_only" not in out
# Example channels are already tagged → excluded from suggestions.
assert "UC_ex0" not in out
def test_a_tag_below_the_example_floor_produces_no_profile(db):
me = User(email="me2@example.com")
db.add(me)
db.flush()
edu = _auto(db, "Education")
niche = Tag(user_id=me.id, name="niche", category="other")
db.add(niche)
db.flush()
# Only TWO example channels for "niche" (< MIN_EXAMPLES=3) → no profile, so no suggestion even
# for a perfectly-matching untagged channel.
for i in range(2):
cid = f"UC_niche{i}"
_subscribe(db, me, cid, auto_tag_ids=[edu.id])
db.add(ChannelTag(channel_id=cid, tag_id=niche.id, user_id=me.id, source="user"))
_subscribe(db, me, "UC_would_match", auto_tag_ids=[edu.id])
db.commit()
assert compute_tag_suggestions(db, me) == {}
def test_partial_topic_overlap_is_a_fraction_of_the_profile(db):
# Guards the intersection-over-profile shape at the SUGGEST_THRESHOLD boundary: a 3-topic profile
# matched by 1 topic scores 0.33 (dropped); matched by 2 scores 0.67 (kept). Pins the threshold
# so a change to it can't silently widen/narrow what "a suggestion" means.
assert SUGGEST_THRESHOLD == 0.6
me = User(email="me3@example.com")
db.add(me)
db.flush()
a, b, c = _auto(db, "A"), _auto(db, "B"), _auto(db, "C")
tag = Tag(user_id=me.id, name="triad", category="other")
db.add(tag)
db.flush()
for i in range(3):
cid = f"UC_triad{i}"
_subscribe(db, me, cid, auto_tag_ids=[a.id, b.id, c.id])
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=me.id, source="user"))
_subscribe(db, me, "UC_one_of_three", auto_tag_ids=[a.id]) # 1/3 = 0.33 < 0.6 → dropped
_subscribe(db, me, "UC_two_of_three", auto_tag_ids=[a.id, b.id]) # 2/3 = 0.67 ≥ 0.6 → kept
db.commit()
out = compute_tag_suggestions(db, me)
assert "UC_one_of_three" not in out
assert "UC_two_of_three" in out
assert out["UC_two_of_three"][0]["tag_id"] == tag.id
assert out["UC_two_of_three"][0]["confidence"] == round(2 / 3, 3)
def test_language_alone_is_never_a_profile_even_when_it_is_the_only_shared_tag(db):
# The anti-flood invariant WITH TEETH. Every example channel shares only a language; each carries a
# DIFFERENT topic, so no topic reaches CHAR_SUPPORT → the topic-only profile is empty → no
# suggestion. If language were ever admitted to the signal the profile would collapse to {language}
# and the language-only channel would score 1.0 and be suggested — the exact flood the exclusion
# prevents. (test_suggests_… can't catch a regression here: there the language merely enlarges an
# already topic-driven profile, so removing the exclusion flips no assertion.)
me = User(email="lang@example.com")
db.add(me)
db.flush()
eng = _auto(db, "English", category="language")
topics = [_auto(db, f"Topic{i}") for i in range(3)]
lang = Tag(user_id=me.id, name="english-stuff", category="other")
db.add(lang)
db.flush()
for i in range(3):
cid = f"UC_lang_ex{i}"
_subscribe(db, me, cid, auto_tag_ids=[eng.id, topics[i].id])
db.add(ChannelTag(channel_id=cid, tag_id=lang.id, user_id=me.id, source="user"))
_subscribe(db, me, "UC_lang_only", auto_tag_ids=[eng.id])
db.commit()
# No characteristic topic → no profile → nothing suggested for anyone (language never carries it).
assert compute_tag_suggestions(db, me) == {}
def test_char_support_admits_a_two_thirds_topic_and_excludes_a_one_third_topic(db):
# Pins the characteristic-profile threshold (CHAR_SUPPORT) at its PARTIAL-support boundary: across 3
# example channels a topic in 2/3 (0.67 ≥ 0.6) enters the profile, one in 1/3 (0.33) does not. The
# other fixtures give every topic full 3/3 support, so a regression demanding full support (or
# flipping ≥ to >) would ship green — this is the only case that exercises fractional support.
assert CHAR_SUPPORT == 0.6
me = User(email="chr@example.com")
db.add(me)
db.flush()
p, q, r = _auto(db, "P"), _auto(db, "Q"), _auto(db, "R")
mix = Tag(user_id=me.id, name="mix", category="other")
db.add(mix)
db.flush()
# p in 3/3 (kept), q in 2/3 (kept, 0.67), r in 1/3 (excluded, 0.33) → profile = {p, q}.
example_topics = [[p.id, q.id, r.id], [p.id, q.id], [p.id]]
for i, topics in enumerate(example_topics):
cid = f"UC_mix_ex{i}"
_subscribe(db, me, cid, auto_tag_ids=topics)
db.add(ChannelTag(channel_id=cid, tag_id=mix.id, user_id=me.id, source="user"))
_subscribe(db, me, "UC_pq", auto_tag_ids=[p.id, q.id]) # {p,q}/{p,q} = 1.0 → suggested
_subscribe(db, me, "UC_only_p", auto_tag_ids=[p.id]) # 1/2 = 0.5 < 0.6 → dropped
_subscribe(db, me, "UC_p_and_r", auto_tag_ids=[p.id, r.id]) # {p}/{p,q} = 0.5 → dropped
db.commit()
out = compute_tag_suggestions(db, me)
assert "UC_pq" in out and out["UC_pq"][0]["confidence"] == 1.0
# q (2/3) IS in the profile: p alone covers only half of it. Would be a full 1.0 match if the
# builder wrongly required 3/3 support (profile would collapse to {p}).
assert "UC_only_p" not in out
# r (1/3) is NOT in the profile: {p,r} covers only p. Would score 0.67 (suggested) if r wrongly
# entered the profile (a downward drift of CHAR_SUPPORT).
assert "UC_p_and_r" not in out
def test_suggestions_are_capped_and_ordered_best_first(db):
# A channel matching many user-tag profiles gets at most MAX_PER_CHANNEL suggestions, ordered best
# score first, with the weakest dropped by the cap. No other fixture matches >1 profile, so the sort
# and the cap are both no-ops there — dropping either ships green. Four DISTINCT confidences keep the
# order unambiguous. (The exact-tie tag_id tie-break in the sort key is a determinism detail, not
# asserted here: on a stable sort it isn't catchable without relying on unspecified DB row order.)
me = User(email="cap@example.com")
db.add(me)
db.flush()
def profile_tag(name, n_topics):
"""A user tag whose characteristic profile is exactly n_topics (3 identical example channels)."""
topics = [_auto(db, f"{name}_t{i}") for i in range(n_topics)]
tag = Tag(user_id=me.id, name=name, category="other")
db.add(tag)
db.flush()
for i in range(3):
cid = f"UC_{name}_ex{i}"
_subscribe(db, me, cid, auto_tag_ids=[t.id for t in topics])
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=me.id, source="user"))
return tag, topics
t0, top0 = profile_tag("p0", 1) # profile size 1
t1, top1 = profile_tag("p1", 5) # profile size 5
t2, top2 = profile_tag("p2", 10) # profile size 10
t3, top3 = profile_tag("p3", 10) # profile size 10
# One untagged channel matching all four at distinct confidences:
# t0: 1/1 = 1.00
# t1: 4/5 = 0.80
# t2: 7/10 = 0.70
# t3: 6/10 = 0.60 → a valid hit (≥ SUGGEST_THRESHOLD) but the weakest, dropped by the cap of 3
matched = (
[top0[0].id]
+ [t.id for t in top1[:4]]
+ [t.id for t in top2[:7]]
+ [t.id for t in top3[:6]]
)
_subscribe(db, me, "UC_many", auto_tag_ids=matched)
db.commit()
hits = compute_tag_suggestions(db, me)["UC_many"]
assert len(hits) == MAX_PER_CHANNEL # 4 matches, capped to 3
assert [h["tag_id"] for h in hits] == [t0.id, t1.id, t2.id] # best score first
assert [h["confidence"] for h in hits] == [1.0, 0.8, 0.7]
assert t3.id not in [
h["tag_id"] for h in hits
] # the weakest match (0.60) is the one the cap drops
def test_unsubscribed_example_channel_does_not_inflate_the_profile_denominator(db):
# Unsubscribe deletes the Subscription but NOT the source='user' ChannelTag, so a tagged-then-
# unsubscribed channel must NOT count as a profile example: it contributes no topic features (those
# are scanned over subscribed channels only) yet would inflate CHAR_SUPPORT's denominator and drop a
# genuinely characteristic topic. Here 'X' is characteristic at 2/3 of the SUBSCRIBED examples
# (threshold 1.8, count 2 → kept), but if the unsubscribed 4th example counted, threshold 2.4 would
# drop it and suppress the suggestion.
me = User(email="unsub@example.com")
db.add(me)
db.flush()
x, y = _auto(db, "X"), _auto(db, "Y")
wood = Tag(user_id=me.id, name="wood", category="other")
db.add(wood)
db.flush()
# 3 subscribed examples: 2 carry topic X, 1 carries only Y → X support 2/3 among subscribed.
for cid, topic in [("UC_w0", x), ("UC_w1", x), ("UC_w2", y)]:
_subscribe(db, me, cid, auto_tag_ids=[topic.id])
db.add(ChannelTag(channel_id=cid, tag_id=wood.id, user_id=me.id, source="user"))
# A 4th channel tagged "wood" but NO LONGER subscribed (channel + user tag, no Subscription row).
db.add(Channel(id="UC_gone", title="UC_gone"))
db.add(ChannelTag(channel_id="UC_gone", tag_id=wood.id, user_id=me.id, source="user"))
_subscribe(
db, me, "UC_cand", auto_tag_ids=[x.id]
) # untagged X match → should be suggested "wood"
db.commit()
out = compute_tag_suggestions(db, me)
assert "UC_cand" in out
assert out["UC_cand"][0]["tag_id"] == wood.id
def test_hidden_subscriptions_are_not_suggestion_targets(db):
# The Channel Manager renders suggestion ghost chips only on non-hidden rows, so a hidden channel
# must not be a suggestion target — otherwise the header count / bulk-accept would span channels with
# no visible chip. (Hidden channels still count as example/feature signal; only targeting is excluded.)
me = User(email="hid@example.com")
db.add(me)
db.flush()
z = _auto(db, "Z")
ztag = Tag(user_id=me.id, name="ztag", category="other")
db.add(ztag)
db.flush()
for i in range(3):
cid = f"UC_z{i}"
_subscribe(db, me, cid, auto_tag_ids=[z.id])
db.add(ChannelTag(channel_id=cid, tag_id=ztag.id, user_id=me.id, source="user"))
# A hidden, untagged, perfectly-matching subscription → NOT a target; a visible one → suggested.
_subscribe(db, me, "UC_hidden", auto_tag_ids=[z.id], hidden=True)
_subscribe(db, me, "UC_visible", auto_tag_ids=[z.id])
db.commit()
out = compute_tag_suggestions(db, me)
assert "UC_visible" in out
assert "UC_hidden" not in out
+32
View File
@@ -12,6 +12,8 @@ import {
Ban, Ban,
Check, Check,
ExternalLink, ExternalLink,
Eye,
EyeOff,
History, History,
Loader2, Loader2,
Plus, Plus,
@@ -137,6 +139,17 @@ export default function ChannelPage({
// a caller toast here would just double it. // a caller toast here would just double it.
}); });
// "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.
const toggleHidden = useMutation({
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
},
});
const subscribe = useMutation({ const subscribe = useMutation({
mutationFn: () => api.subscribeChannel(channelId), mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => { onSuccess: () => {
@@ -418,6 +431,7 @@ export default function ChannelPage({
priority={ch.priority} priority={ch.priority}
onPriority={(d) => bumpPriority(channelId, d)} onPriority={(d) => bumpPriority(channelId, d)}
onSet={(v) => setPriority(channelId, v)} onSet={(v) => setPriority(channelId, v)}
size="md"
/> />
</div> </div>
)} )}
@@ -469,6 +483,24 @@ export default function ChannelPage({
<Ban className="w-4 h-4" /> <Ban className="w-4 h-4" />
</button> </button>
)} )}
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && (
<button
onClick={() => toggleHidden.mutate()}
disabled={toggleHidden.isPending}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
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 ${
ch.hidden
? "border-accent text-accent"
: "border-border text-muted hover:text-fg hover:border-fg/40"
}`}
>
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
{!me.is_demo && {!me.is_demo &&
!ch?.blocked && !ch?.blocked &&
(ch?.subscribed ? ( (ch?.subscribed ? (
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
//
// Contract of the suggested-tag ghost chips (E8·S4): on an UNTAGGED channel, ChannelTagsControl renders
// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the
// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already
// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform
// and a ResizeObserver-driven fit measurer, so we stub the observer and wrap in a FloatingTree.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
// Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu").
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: { name?: string }) => (opts?.name ? `${key}:${opts.name}` : key),
i18n: { language: "en" },
}),
};
});
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import type { Tag } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(cleanup);
const tag = (id: number, name: string): Tag => ({
id,
name,
color: null,
category: "other",
system: false,
channel_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
const suggestions: SuggestedTag[] = [
{ tag: docu, confidence: 0.95 },
{ tag: history, confidence: 0.6 },
];
function renderControl(props: Partial<Parameters<typeof ChannelTagsControl>[0]> = {}) {
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
<FloatingTree>
<ChannelTagsControl
userTags={[docu, history]}
attachedIds={[]}
onToggle={() => {}}
onCreate={() => {}}
suggestions={suggestions}
onAcceptSuggestion={onAccept}
onDismissSuggestion={onDismiss}
{...props}
/>
</FloatingTree>,
);
return { onAccept, onDismiss };
}
describe("ChannelTagsControl suggested ghost chips", () => {
it("renders a ghost chip per suggestion and accepts on click", () => {
const { onAccept } = renderControl();
// Role query excludes the aria-hidden fit-measurer copy; the name carries the interpolated tag.
fireEvent.click(screen.getByRole("button", { name: "channels.tags.acceptSuggestion:docu" }));
expect(onAccept).toHaveBeenCalledWith(docu.id);
});
it("dismisses via the × without accepting", () => {
const { onAccept, onDismiss } = renderControl();
const dismissButtons = screen.getAllByRole("button", {
name: "channels.tags.dismissSuggestion",
});
// Two visible ghost chips (the measurer's copies are aria-hidden → excluded); order matches the
// suggestions array, so the second × is the "history" suggestion.
expect(dismissButtons).toHaveLength(2);
fireEvent.click(dismissButtons[1]!);
expect(onDismiss).toHaveBeenCalledWith(history.id);
expect(onAccept).not.toHaveBeenCalled();
});
it("shows the attached lane (no ghost chips) once the channel carries a user tag", () => {
renderControl({ attachedIds: [docu.id] });
// A tagged channel is not "untagged" → the ghost lane is not rendered at all (the server excludes
// tagged channels from suggestions; the control mirrors that with its attached-XOR-ghost branch).
expect(
screen.queryAllByRole("button", { name: "channels.tags.dismissSuggestion" }),
).toHaveLength(0);
});
});
+239 -52
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Check, Plus } from "lucide-react"; import { Check, Plus, Sparkles, X } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api"; import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover"; import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
@@ -9,8 +9,8 @@ import { useChipOverflowFit } from "../lib/useChipOverflowFit";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail // The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell: // page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
// 1. Stable edit anchor — the "+" edit trigger sits in a FIXED slot at the end, so toggling tags // 1. Stable edit anchor — the "+" edit trigger LEADS the row (fixed left slot), so toggling tags
// (which grows the chip row) never moves it, and the editor popover doesn't drift. // (which grows the chip row to its right) never moves it, and the editor popover doesn't drift.
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and // 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
// collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never // collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never
// wraps to a 2nd line, so it can't widen its table column and reflow every other row. // wraps to a 2nd line, so it can't widen its table column and reflow every other row.
@@ -40,6 +40,157 @@ function TagChip({ tag, onClick, title }: TagChipProps) {
); );
} }
// A suggested user tag (E8·S4): a dashed "ghost" chip the user hasn't applied yet, learned from
// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing
// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the
// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.)
export interface SuggestedTag {
tag: Tag;
confidence: number;
}
function SuggestedChip({
suggestion,
onAccept,
onDismiss,
}: {
suggestion: SuggestedTag;
onAccept: () => void;
onDismiss: () => void;
}) {
const { t } = useTranslation();
const pct = Math.round(suggestion.confidence * 100);
return (
<span className="shrink-0 inline-flex items-center rounded-full border border-dashed border-accent/50 text-accent/90 text-2xs font-medium pl-1 pr-0.5 py-0.5 max-w-36">
<button
type="button"
onClick={onAccept}
title={t("channels.tags.suggestedHint", { pct })}
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
>
<Sparkles className="w-2.5 h-2.5 shrink-0" />
<span className="truncate">{suggestion.tag.name}</span>
</button>
<button
type="button"
onClick={onDismiss}
title={t("channels.tags.dismissSuggestion")}
aria-label={t("channels.tags.dismissSuggestion")}
className="shrink-0 ml-0.5 rounded-full text-accent/60 hover:text-danger transition"
>
<X className="w-2.5 h-2.5" />
</button>
</span>
);
}
const GHOST_PILL =
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full border border-dashed border-accent/50 text-accent/90";
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
* stops a 2nd suggestion from being clipped mid-word right next to the edit "+". */
function SuggestedChipsLane({
suggestions,
onAccept,
onDismiss,
}: {
suggestions: SuggestedTag[];
onAccept: (tagId: number) => void;
onDismiss: (tagId: number) => void;
}) {
const { t } = useTranslation();
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(null);
const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|");
const visible = useChipOverflowFit<number>({
rowRef,
chipsRef: chipRefs,
pillRef,
deps: key,
initial: suggestions.length,
decide: (g) =>
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
const hidden = suggestions.length - visible;
const [overflowOpen, setOverflowOpen] = useState(false);
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
useEffect(() => {
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
}, [hidden, overflowOpen]);
return (
<div className="relative flex items-center gap-1 min-w-0 flex-1">
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{suggestions.map((s, i) => (
<span
key={s.tag.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
</span>
))}
<span ref={pillRef} className={GHOST_PILL}>
+{suggestions.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{suggestions.slice(0, visible).map((s) => (
<SuggestedChip
key={s.tag.id}
suggestion={s}
onAccept={() => onAccept(s.tag.id)}
onDismiss={() => onDismiss(s.tag.id)}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${GHOST_PILL} hover:text-accent transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
>
{suggestions.slice(visible).map((s) => (
<SuggestedChip
key={s.tag.id}
suggestion={s}
onAccept={() => onAccept(s.tag.id)}
onDismiss={() => onDismiss(s.tag.id)}
/>
))}
</PopoverSurface>
</div>
);
}
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline /** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel * "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
* detail page in Part 2; internal for now.) */ * detail page in Part 2; internal for now.) */
@@ -130,15 +281,18 @@ function TagEditorMenu({
); );
} }
/** The full tag control for a channel: a width-bounded chip row with "+N" overflow (hover reveals /** The full tag control for a channel: a stable leading "+" trigger (opens the shared editor),
* all), followed by a stable "+" trigger that opens the shared editor. Fills its container width, * followed by a width-bounded chip row with "+N" overflow (hover reveals all). Fills its container
* so a table cell bounds it via the column width and a card bounds it via the card. */ * width, so a table cell bounds it via the column width and a card bounds it via the card. */
export function ChannelTagsControl({ export function ChannelTagsControl({
userTags, userTags,
attachedIds, attachedIds,
onToggle, onToggle,
onCreate, onCreate,
onChipClick, onChipClick,
suggestions,
onAcceptSuggestion,
onDismissSuggestion,
bounded = true, bounded = true,
}: { }: {
userTags: Tag[]; userTags: Tag[];
@@ -147,6 +301,11 @@ export function ChannelTagsControl({
onCreate: (name: string) => void; onCreate: (name: string) => void;
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */ /** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
onChipClick?: (tagId: number, name: string) => void; onChipClick?: (tagId: number, name: string) => void;
/** Manager only (E8·S4): suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
* chips after the (empty) attached row. Omit on the detail page. */
suggestions?: SuggestedTag[];
onAcceptSuggestion?: (tagId: number) => void;
onDismissSuggestion?: (tagId: number) => void;
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the /** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
* table (the manager). Set false where there is free width (the channel detail header), so tags * table (the manager). Set false where there is free width (the channel detail header), so tags
* fit inline instead of needlessly collapsing into "+N". */ * fit inline instead of needlessly collapsing into "+N". */
@@ -154,6 +313,9 @@ export function ChannelTagsControl({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const attached = userTags.filter((tg) => attachedIds.includes(tg.id)); const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
// Suggestions only surface for channels with no user tag yet, but guard anyway: never suggest a tag
// the channel already carries (an optimistic accept adds it before the query refetches).
const shownSuggestions = (suggestions ?? []).filter((s) => !attachedIds.includes(s.tag.id));
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their // Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
// natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem // natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem
@@ -214,61 +376,86 @@ export function ChannelTagsControl({
<div <div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`} className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
> >
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. Each {/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its right edge; (they grow to its right), so its editor popover can't drift. Compact icon in the width-
the pill is the sample the hook reserves room for. */} bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{attached.map((tg, i) => (
<span
key={tg.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<TagChip tag={tg} />
</span>
))}
<span ref={pillRef} className={pillClass}>
+{attached.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{attached.slice(0, visible).map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
<button <button
ref={editPop.refs.setReference} ref={editPop.refs.setReference}
type="button" type="button"
title={t("channels.row.editTags")} title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")} aria-label={t("channels.row.editTags")}
className="shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition" className={
bounded
? "shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
: "shrink-0 inline-flex items-center gap-1 h-6 pl-1.5 pr-2 rounded-full border border-dashed border-border text-2xs font-medium text-muted hover:text-accent hover:border-accent transition"
}
{...editPop.getReferenceProps()} {...editPop.getReferenceProps()}
> >
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3 shrink-0" />
{!bounded && <span className="whitespace-nowrap">{t("channels.tags.add")}</span>}
</button> </button>
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
channels only, and accepting one removes the channel from the suggestion set — so a channel
never shows both, and each lane gets the full cell width instead of clipping the other. */}
{attached.length === 0 && shownSuggestions.length > 0 ? (
<SuggestedChipsLane
suggestions={shownSuggestions}
onAccept={(tagId) => onAcceptSuggestion?.(tagId)}
onDismiss={(tagId) => onDismissSuggestion?.(tagId)}
/>
) : (
<>
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths.
Each chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its
right edge; the pill is the sample the hook reserves room for. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{attached.map((tg, i) => (
<span
key={tg.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<TagChip tag={tg} />
</span>
))}
<span ref={pillRef} className={pillClass}>
+{attached.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{attached.slice(0, visible).map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={
onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined
}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
</>
)}
{/* Hover: reveal every attached tag when some are collapsed into "+N". */} {/* Hover: reveal every attached tag when some are collapsed into "+N". */}
<PopoverSurface <PopoverSurface
popover={overflowPop} popover={overflowPop}
+108 -41
View File
@@ -4,7 +4,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react"; import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
import ExcludedChannelList from "./ExcludedChannelList"; import ExcludedChannelList from "./ExcludedChannelList";
import { api, type ManagedChannel } from "../lib/api"; import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { Sparkles } from "lucide-react";
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";
@@ -15,10 +16,12 @@ import Tabs, { type TabDef } from "./Tabs";
import ChannelLayoutGrid from "./ChannelLayoutGrid"; import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState"; import { LoadingState, StateMessage } from "./QueryState";
import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useTagSuggestions } from "./useTagSuggestions";
import { useChannelPriority } from "../lib/useChannelPriority"; import { useChannelPriority } from "../lib/useChannelPriority";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl"; import ColumnSortControl from "./ColumnSortControl";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; import FilterChipGroup from "./FilterChipGroup";
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
import ActionMenu, { type ActionMenuItem } from "./ActionMenu"; import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover"; import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./Pager"; import Pager from "./Pager";
@@ -164,10 +167,37 @@ export default function Channels() {
), ),
), ),
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags // Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. // from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }), // recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }), // the learned pattern (E8·S4).
onSuccess: (id) => {
qc.invalidateQueries({ queryKey: qk.channel(id) });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
},
// 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
// 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.
onError: () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
notify({ level: "error", message: t("channels.tags.updateFailed") });
},
}); });
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The
// /tags/suggestions query, the session dismiss set, and the optimistic accept/dismiss/bulk logic all
// live in useTagSuggestions so they can be unit-tested apart from this component.
const {
suggestionsFor,
dismissSuggestion,
acceptSuggestion,
visibleSuggestions,
highConfidence,
acceptAllHighConfidence,
} = useTagSuggestions({ tags: tagsQuery.data ?? [], toggleTag });
const syncSubs = useMutation({ const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(), mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => { onSuccess: (r: { subscriptions?: number }) => {
@@ -217,11 +247,34 @@ export default function Channels() {
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`; // funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box). // filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
const tagFilter = Array.isArray(filters.tags) ? filters.tags : []; const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
const toggleTagFilter = (tagId: number) => { const toggleTagFilter = (tagId: number) => {
const id = String(tagId); const id = String(tagId);
const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id]; const base = tagFilter.filter((x) => x !== NO_TAG_SENTINEL);
const next = base.includes(id) ? base.filter((x) => x !== id) : [...base, id];
setFilters({ ...filters, tags: next }); setFilters({ ...filters, tags: next });
}; };
// The "No tag" chip: filter the table to channels you haven't personally tagged yet. Exclusive with
// real tag chips (clicking it drops them); the Tags column's test reads the sentinel (channelColumns).
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
const toggleNoTagFilter = () =>
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
// The tag-filter chips (user tags + the "No tag" sentinel), fed to TagFilterChips which folds any
// overflow into a "+N" pill so the row stays one line and the trailing "…" actions can't wrap.
const tagFilterChips: FilterChip[] = [
...userTags.map((tg) => ({
key: `t${tg.id}`,
label: tg.name,
active: tagFilter.includes(String(tg.id)),
onToggle: () => toggleTagFilter(tg.id),
})),
{
key: NO_TAG_SENTINEL,
label: t("channels.tags.noTag"),
active: noTagOn,
onToggle: toggleNoTagFilter,
},
];
// A focus-channel intent (header "without full history" / tag manager) seeds the search box. // A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => { useEffect(() => {
if (focusChannelName) setSearch(focusChannelName); if (focusChannelName) setSearch(focusChannelName);
@@ -295,6 +348,9 @@ export default function Channels() {
toggleTag, toggleTag,
createAndAttachTag, createAndAttachTag,
onFilterByTag, onFilterByTag,
suggestionsFor,
onAcceptSuggestion: acceptSuggestion,
onDismissSuggestion: dismissSuggestion,
}); });
// Status chips sit in the table's controls row (next to paging) — compact and close to // Status chips sit in the table's controls row (next to paging) — compact and close to
@@ -426,41 +482,52 @@ export default function Channels() {
{tabs} {tabs}
{view === "subscribed" && ( {view === "subscribed" && (
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto"> <div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name {/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds
search lives in the shared top SearchBar, tag create/delete in the manager. */} overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the
<div className="flex items-center justify-between gap-4 flex-wrap"> "…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */}
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex items-center gap-2 min-w-0">
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate {/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */} "Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}> <Tooltip hint={t("channels.tags.yourTagsHint")}>
<button <button
onClick={() => setTagManagerOpen(true)} onClick={() => setTagManagerOpen(true)}
className="mr-1 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent" className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
{t("channels.tags.label")} {t("channels.tags.label")}
</button> </button>
</Tooltip> </Tooltip>
{userTags.map((tg) => ( <TagFilterChips chips={tagFilterChips} className="flex-1" />
<button {tagFilter.length > 0 && (
key={tg.id} <button
onClick={() => toggleTagFilter(tg.id)} onClick={() => setFilters({ ...filters, tags: [] })}
aria-pressed={tagFilter.includes(String(tg.id))} className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
className={filterChipClass} >
> {t("datatable.clear")}
{tg.name} </button>
</button> )}
))} {/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
{tagFilter.length > 0 && ( one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
<button rest. Hidden when there's nothing to suggest. */}
onClick={() => setFilters({ ...filters, tags: [] })} {visibleSuggestions.length > 0 && (
className="text-xs px-2 py-1 text-muted hover:text-accent transition" <div className="shrink-0 flex items-center gap-1.5">
> <Tooltip hint={t("channels.tags.suggestionsHint")}>
{t("datatable.clear")} <span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-accent/50 px-2 py-1 text-xs text-accent/90">
</button> <Sparkles className="h-3 w-3" />
)} {t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
</div> </span>
{actionButtons} </Tooltip>
{highConfidence.length > 0 && (
<button
onClick={acceptAllHighConfidence}
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
>
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
</button>
)}
</div>
)}
<div className="shrink-0">{actionButtons}</div>
</div> </div>
</div> </div>
)} )}
@@ -17,6 +17,7 @@ vi.mock("./NavigationProvider", () => ({
})); }));
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider"; import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
import { setActiveAccount } from "../lib/api/core";
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper = ({ children }: { children: ReactNode }) => (
<ChannelsProvider>{children}</ChannelsProvider> <ChannelsProvider>{children}</ChannelsProvider>
@@ -27,7 +28,10 @@ const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffix
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
window.history.replaceState({ sfPage: "channels" }, ""); // setView only owns an entry on this page // Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
// and the S8 URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
// this provider's mount-time URL seed and change the starting tab.
window.history.replaceState({ sfPage: "channels" }, "", "/");
}); });
describe("ChannelsProvider tab history", () => { describe("ChannelsProvider tab history", () => {
@@ -71,3 +75,58 @@ describe("ChannelsProvider tab history", () => {
expect(result.current.view).toBe("hidden"); // unchanged expect(result.current.view).toBe("hidden"); // unchanged
}); });
}); });
describe("ChannelsProvider — S8 shareable URL", () => {
it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => {
// Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins.
localStorage.setItem(VIEW_KEY, "blocked");
const cf = encodeURIComponent(JSON.stringify({ sync: "needsFull" }));
window.history.replaceState(
{ sfPage: "channels" },
"",
`/?ctab=hidden&csort=priority:desc&cf=${cf}`,
);
const { result } = render();
expect(result.current.view).toBe("hidden");
expect(result.current.sort).toEqual({ key: "priority", dir: "desc" });
expect(result.current.filters).toEqual({ sync: "needsFull" });
});
it("mirrors the tab into the address bar and clears it back to the default", () => {
const { result } = render();
act(() => result.current.setView("blocked"));
expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked");
// Back to the default tab clears the key (only non-defaults are emitted).
act(() => result.current.setView("subscribed"));
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
});
it("a PARTIAL shared link (fresh tab) does not clobber the account's stored sort/filters", () => {
// Regression: on a fresh tab the account pins AFTER mount, so the seed's base for the OMITTED
// dimensions was read as defaults. Persisting the whole seed used to overwrite the account's real
// stored sort + filter map. The fix persists ONLY the URL-present dimensions.
localStorage.setItem(
"siftlode.channelSort.1",
JSON.stringify({ key: "subscribers", dir: "desc" }),
);
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
sessionStorage.removeItem("siftlode.activeAccount"); // account not pinned yet at mount
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
const { result } = render();
act(() => setActiveAccount(1)); // App pins the account after /api/me
// The present dim (tab) is adopted from the link...
expect(result.current.view).toBe("hidden");
// ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
expect(result.current.filters).toEqual({ sync: "needsFull" });
expect(JSON.parse(localStorage.getItem("siftlode.channelSort.1")!)).toEqual({
key: "subscribers",
dir: "desc",
});
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap.1")!)).toEqual({
sync: "needsFull",
});
});
});
+97 -2
View File
@@ -3,16 +3,25 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { getAccountRaw, LS } from "../lib/storage"; import { getAccountRaw, setAccountRaw, LS } from "../lib/storage";
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout"; import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
import { type SortState } from "../lib/columnSort"; import { hydrateSort, type SortState } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState"; import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState"; import { useFilterState } from "../lib/useFilterState";
import { useActiveAccountId } from "../lib/activeAccount";
import { useUrlMirror } from "../lib/useUrlMirror";
import {
channelsParamsToState,
channelsParamUpdates,
hasChannelsParams,
type ChannelsUrlState,
} from "../lib/channelsUrlState";
import { usePersistedTab } from "./Tabs"; import { usePersistedTab } from "./Tabs";
import type { FilterMap } from "./tableHeader"; import type { FilterMap } from "./tableHeader";
import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./NavigationProvider";
@@ -44,6 +53,47 @@ function migrateSyncChip(): FilterMap | null {
return null; return null;
} }
// The manager's stored column-filter map, healed to a plain string/string[] object (same discipline as
// useFilterState's hydrate). Used only to build the seed's base — the URL keeps whatever the stored map
// has for any dimension a partial shared link omits.
function readStoredFilters(): FilterMap {
const raw = getAccountRaw(LS.channelsFilterMap);
if (raw) {
try {
const v = JSON.parse(raw);
if (v && typeof v === "object" && !Array.isArray(v)) return v as FilterMap;
} catch {
/* fall through to the migrate seed */
}
}
return migrateSyncChip() ?? {};
}
// S8: a shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
// The seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` matters
// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null),
// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and
// persists only the PRESENT dimensions and lets the absent ones re-hydrate from storage normally —
// otherwise a partial shared link silently resets the omitted stored sort / filter map.
type ChannelsSeedPresent = { view: boolean; sort: boolean; filters: boolean };
type ChannelsUrlSeed = { state: ChannelsUrlState; present: ChannelsSeedPresent };
function readChannelsUrlSeed(): ChannelsUrlSeed | null {
const params = new URLSearchParams(window.location.search);
if (!hasChannelsParams(params)) return null;
const state = channelsParamsToState(params, {
view: clampView(getAccountRaw(LS.channelsView)),
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
filters: readStoredFilters(),
});
return {
state,
present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") },
};
}
type ChannelsState = { type ChannelsState = {
search: string; search: string;
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync // The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
@@ -132,6 +182,45 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
const [focusName, setFocusName] = useState<string | null>(null); const [focusName, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0); const [focusToken, setFocusToken] = useState(0);
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
const [urlSeed] = useState(readChannelsUrlSeed);
const accountId = useActiveAccountId();
const seededRef = useRef(false);
const persistedRef = useRef(false);
// Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters
// (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate
// (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view
// over the shared one. setViewRaw (not setView) restores the tab without pushing a phantom history
// entry. A normal load has urlSeed === null and this is a no-op.
useLayoutEffect(() => {
if (!urlSeed || seededRef.current) return;
seededRef.current = true;
// Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its
// setter (which would markDirty and block the account-pin re-hydrate from loading the stored value)
// — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters.
const { state, present } = urlSeed;
if (present.view && state.view !== view) setViewRaw(state.view);
if (present.filters) setFilters(state.filters);
if (present.sort) setSort(state.sort);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// On a fresh tab the account pins AFTER mount, so the seed's setter writes above no-op'd (no account
// yet). Persist the seed to the account once it's known, so the shared view also survives a later
// PLAIN reload (no URL) — mirrors PlexProvider's rehydrate-persist. The slice hooks' own re-hydrate
// already skips (marked dirty above), so this only touches storage, not state.
useEffect(() => {
if (!urlSeed || accountId == null || persistedRef.current) return;
persistedRef.current = true;
// Persist ONLY the URL-present dimensions — the omitted ones were seeded from mount-time defaults
// (no account yet), so writing them here would overwrite the account's real stored sort/filter map.
const { state, present } = urlSeed;
if (present.view) setAccountRaw(LS.channelsView, state.view);
if (present.filters) setAccountRaw(LS.channelsFilterMap, JSON.stringify(state.filters));
if (present.sort) setAccountRaw(LS.channelSort, JSON.stringify(state.sort));
}, [urlSeed, accountId]);
// Latest-value ref so the STABLE setView can compare against the current tab without depending on // Latest-value ref so the STABLE setView can compare against the current tab without depending on
// it (a dep would recreate the action on every switch and re-render every actions consumer). // it (a dep would recreate the action on every switch and re-render every actions consumer).
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`. // Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
@@ -218,6 +307,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory], [setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
); );
// S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
// deliberately transient rail-search, like the feed's and plex's.
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
return ( return (
<ActionsContext.Provider value={actions}> <ActionsContext.Provider value={actions}>
<LayoutContext.Provider value={layout}> <LayoutContext.Provider value={layout}>
@@ -151,6 +151,7 @@ export default function DateFilterSelect({
...filters, ...filters,
dateFrom: e.target.value || undefined, dateFrom: e.target.value || undefined,
maxAgeDays: undefined, maxAgeDays: undefined,
year: undefined,
}) })
} }
className={dateInputCls} className={dateInputCls}
@@ -169,6 +170,7 @@ export default function DateFilterSelect({
...filters, ...filters,
dateTo: e.target.value || undefined, dateTo: e.target.value || undefined,
maxAgeDays: undefined, maxAgeDays: undefined,
year: undefined,
}) })
} }
className={dateInputCls} className={dateInputCls}
@@ -13,6 +13,7 @@ import {
api, api,
getActiveAccount, getActiveAccount,
coerceTagModes, coerceTagModes,
coerceUntagged,
DEFAULT_TAG_MODES, DEFAULT_TAG_MODES,
type FeedFilters, type FeedFilters,
} from "../lib/api"; } from "../lib/api";
@@ -39,6 +40,7 @@ import { useNavigation } from "./NavigationProvider";
const DEFAULT_FILTERS: FeedFilters = { const DEFAULT_FILTERS: FeedFilters = {
tags: [], tags: [],
tagModes: DEFAULT_TAG_MODES, tagModes: DEFAULT_TAG_MODES,
untagged: false,
channelIds: [], channelIds: [],
q: "", q: "",
sort: "newest", sort: "newest",
@@ -74,7 +76,12 @@ export function loadAccountFilters(id: number | null): FeedFilters {
// (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it. // (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
if (hasStored && fKey) { if (hasStored && fKey) {
const raw = readJSON<Partial<FeedFilters>>(fKey, {}); const raw = readJSON<Partial<FeedFilters>>(fKey, {});
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) }; return {
...DEFAULT_FILTERS,
...raw,
tagModes: coerceTagModes(raw),
untagged: coerceUntagged(raw),
};
} }
return { ...DEFAULT_FILTERS }; return { ...DEFAULT_FILTERS };
} }
+16 -3
View File
@@ -126,12 +126,25 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and // app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
// the address bar reflects the page (feed is implicit) while clearing the view-specific params // the address bar reflects the page (feed is implicit) while clearing the view-specific params
// (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by // (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that
// FeedFiltersProvider) are left intact. // module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex/channels
// keys re-mirror from PlexProvider / ChannelsProvider on return to their page.
window.history.pushState( window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() }, { sfPage: next, sfScroll: nextNavId() },
"", "",
urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }), urlWithParams({
page: next === "feed" ? null : next,
chan: null,
v: null,
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
}),
); );
notifyNav(); notifyNav();
}, },
@@ -0,0 +1,186 @@
// @vitest-environment jsdom
//
// S7 — the Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
// chips that write the pruned set back to the stored view (undoable), an explicit Save-current button
// stores the live filters into the view, and rename has a visible commit affordance. This drives the
// real modal against a mocked SavedViewsProvider so we can assert exactly which mutation fires; the
// captured-filter row uses the REAL useActiveFilters (fed a tag from a seeded react-query cache), so
// the prune snapshots are the genuine ones the live chip row computes.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
updateFilters: vi.fn(async (_id: number, _filters: Record<string, unknown>) => true),
saveCurrent: vi.fn(async (_id: number) => ({ saved: true }) as Record<string, unknown> | null),
rename: vi.fn(async (_id: number, _name: string) => true),
activeViewId: null as number | null,
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
// A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters
// surfaces exactly one chip (the tag) — a partial object would read missing show/sort/content as
// "active" and spray spurious chips.
const VIEW_FILTERS = {
tags: [1],
tagModes: { language: "or", topic: "or", other: "or" },
untagged: false,
channelIds: [],
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
scope: "my",
q: "",
};
const CTX = {
source: "feed" as const,
views: [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }],
loading: false,
displayView: null,
isDirty: false,
modalOpen: true,
openModal: vi.fn(),
closeModal: vi.fn(),
apply: vi.fn(),
clearActive: vi.fn(),
create: vi.fn(async () => true),
reorder: vi.fn(async () => {}),
remove: vi.fn(async () => true),
toggleDefault: vi.fn(async () => {}),
};
vi.mock("./SavedViewsProvider", () => ({
useSavedViews: () => ({
...CTX,
activeViewId: h.activeViewId,
updateFilters: h.updateFilters,
saveCurrent: h.saveCurrent,
rename: h.rename,
}),
// Passthrough coercion — the modal only needs tags/channelIds to be arrays before the chip builder.
asFilters: (raw: Record<string, unknown>) => ({
...raw,
tags: (raw.tags as unknown[]) ?? [],
channelIds: (raw.channelIds as unknown[]) ?? [],
}),
}));
import PersonalViewsModal from "./PersonalViewsModal";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.tags(), [{ id: 1, name: "Docu" }]);
vi.spyOn(api, "tags").mockResolvedValue([{ id: 1, name: "Docu" }] as never);
const utils = render(
<QueryClientProvider client={qc}>
<PersonalViewsModal />
</QueryClientProvider>,
);
return { ...utils, qc };
}
beforeEach(() => {
h.updateFilters.mockClear();
h.saveCurrent.mockClear();
h.rename.mockClear();
h.activeViewId = null;
// Fresh baseline each test — one test mutates CTX.views to simulate a refetch.
CTX.views = [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }];
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("PersonalViewsModal — S7 editable detail pane", () => {
it("removing a captured-filter chip writes the pruned filters back to the stored view", async () => {
mount();
const removeBtn = await screen.findByLabelText("feed.chips.remove");
fireEvent.click(removeBtn);
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
expect(h.updateFilters.mock.calls[0]![0]).toBe(5);
expect(h.updateFilters.mock.calls[0]![1]).toMatchObject({ tags: [] });
});
it("the prune is undoable — Undo re-persists the original filters", async () => {
mount();
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
// The undo control only appears once there is history.
const undo = await screen.findByLabelText("common.undo");
fireEvent.click(undo);
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
});
it("a same-view refetch (new-identity filters) does not wipe the undo history", async () => {
// Guards the [selectedId]-only re-seed effect (its exhaustive-deps disable): after a chip prune the
// real provider refetches and hands back the SAME view id with a fresh-identity filters object. The
// draft must NOT re-seed on that (it would destroy the undo history and the Undo control). A deps
// regression that added selected.filters would make this fail.
const { rerender, qc } = mount();
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
expect(await screen.findByLabelText("common.undo")).toBeTruthy();
// Simulate the post-write refetch: same id 5, new object identity, carrying the pruned value.
CTX.views = [
{
id: 5,
name: "Docs",
filters: { ...VIEW_FILTERS, tags: [] },
position: 0,
is_default: false,
},
];
rerender(
<QueryClientProvider client={qc}>
<PersonalViewsModal />
</QueryClientProvider>,
);
// Undo history survived the refetch — the control is still there and still re-persists on click.
const undo = await screen.findByLabelText("common.undo");
fireEvent.click(undo);
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
});
it("Save-current stores the live filters into the view when it isn't already the active one", async () => {
mount();
fireEvent.click(await screen.findByText("views.saveCurrent"));
await waitFor(() => expect(h.saveCurrent).toHaveBeenCalledWith(5));
});
it("Save-current is disabled when the selected view is already the active one", async () => {
h.activeViewId = 5;
mount();
const btn = (await screen.findByText("views.saveCurrent")).closest(
"button",
) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
fireEvent.click(btn);
expect(h.saveCurrent).not.toHaveBeenCalled();
});
it("rename commits via the visible check button once the name changes", async () => {
mount();
const input = await screen.findByDisplayValue("Docs");
// No change yet → the commit button is disabled.
const commit = screen.getByLabelText("views.rename").closest("button") as HTMLButtonElement;
expect(commit.disabled).toBe(true);
fireEvent.change(input, { target: { value: "Documentaries" } });
expect(commit.disabled).toBe(false);
fireEvent.click(commit);
await waitFor(() => expect(h.rename).toHaveBeenCalledWith(5, "Documentaries"));
});
});
+121 -30
View File
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BookmarkPlus, GripVertical, Play, Star, Trash2 } from "lucide-react"; import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react";
import { import {
closestCenter, closestCenter,
DndContext, DndContext,
@@ -16,15 +16,18 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { coercePlexFilters, type FeedFilters, type SavedView } from "../lib/api"; import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
import Modal from "./Modal"; import Modal from "./Modal";
import { useSavedViews, asFilters } from "./SavedViewsProvider"; import { useSavedViews, asFilters } from "./SavedViewsProvider";
import { useActiveFilters } from "../lib/useActiveFilters"; import { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters"; import { buildPlexActiveFilters } from "./usePlexActiveFilters";
import { useUndoable } from "../lib/useUndoable";
import UndoToolbar from "./UndoToolbar";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to // Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder) plus a "save the current filters" input; the right column shows the selected view's name, // reorder) plus a "save the current filters" input; the right column shows the selected view's name
// what filters it captures, its default flag, apply, and delete. Opened from the filter panel's title // (renamable), its captured filters as REMOVABLE chips (pruning writes back to the stored view,
// undoable), its default flag, apply, save-current, and delete. Opened from the filter panel's title
// or the saved-views popover; all mutations flow through SavedViewsProvider. // or the saved-views popover; all mutations flow through SavedViewsProvider.
function ViewRow({ function ViewRow({
view, view,
@@ -80,6 +83,8 @@ export default function PersonalViewsModal() {
apply, apply,
create, create,
rename, rename,
updateFilters,
saveCurrent,
toggleDefault, toggleDefault,
remove, remove,
reorder, reorder,
@@ -89,21 +94,55 @@ export default function PersonalViewsModal() {
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null); const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
const selected = views.find((v) => v.id === selectedId) ?? null; const selected = views.find((v) => v.id === selectedId) ?? null;
const [draftName, setDraftName] = useState(""); // Two independent name fields: the left one names a NEW view; the right one renames the selected one.
const [createName, setCreateName] = useState("");
const [nameDraft, setNameDraft] = useState("");
// The selected view's captured filters, as the same human labels its module's chip row shows. The // A local, undoable draft of the SELECTED view's captured filters. Removing a chip prunes the draft
// feed hook runs unconditionally (fed empty filters off-feed); Plex uses the pure builder (no-op // and — via onApply — writes the pruned set straight back to the stored view; Undo/Redo re-persist
// removes — this is display-only). A plex view captures its rail filters, not the watch-state. // the same way. The draft is the single source of truth for the chip row, so a chip-prune refetch
// (which returns the value we just wrote) never clobbers the undo history. We only re-seed it on an
// external change of the visible filters: switching the selection, or Save-current below.
const filtersDraft = useUndoable<Record<string, unknown>>(selected?.filters ?? {}, {
onApply: (f) => {
if (selected) void updateFilters(selected.id, f);
},
});
const resetDraft = filtersDraft.reset;
useEffect(() => {
resetDraft(selected?.filters ?? {});
setNameDraft(selected?.name ?? "");
// Re-seed only when the selection changes (not on every same-view refetch), so pruning keeps its
// undo history. Save-current re-seeds explicitly in its own handler.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId]);
// The selected view's captured filters, as the same human labels its module's chip row shows, read
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
// uses the pure builder. A plex view captures its rail filters, not the watch-state (show="all").
const noop = () => {}; const noop = () => {};
const setDraftFeed = (f: FeedFilters) =>
filtersDraft.set(f as unknown as Record<string, unknown>);
const feedCaptured = useActiveFilters( const feedCaptured = useActiveFilters(
source === "feed" && selected source === "feed" && selected
? asFilters(selected.filters as unknown as FeedFilters) ? asFilters(filtersDraft.value as unknown as FeedFilters)
: ({ tags: [], channelIds: [] } as never), : ({ tags: [], channelIds: [] } as never),
noop, source === "feed" && selected ? setDraftFeed : noop,
); );
const patchDraftPlex = (p: Partial<PlexFilters>) =>
filtersDraft.set({
...coercePlexFilters(filtersDraft.value),
...p,
} as unknown as Record<string, unknown>);
const captured = const captured =
source === "plex" && selected source === "plex" && selected
? buildPlexActiveFilters(coercePlexFilters(selected.filters), "all", noop, noop, t) ? buildPlexActiveFilters(
coercePlexFilters(filtersDraft.value),
"all",
patchDraftPlex,
noop,
t,
)
: feedCaptured; : feedCaptured;
async function onDragEnd(e: DragEndEvent) { async function onDragEnd(e: DragEndEvent) {
@@ -115,11 +154,20 @@ export default function PersonalViewsModal() {
} }
async function onCreate() { async function onCreate() {
const name = draftName.trim(); const name = createName.trim();
if (!name) return; if (!name) return;
if (await create(name)) setDraftName(""); if (await create(name)) setCreateName("");
} }
const nameChanged = !!selected && nameDraft.trim() !== "" && nameDraft.trim() !== selected.name;
async function onRename() {
if (!selected || !nameChanged) return;
await rename(selected.id, nameDraft.trim());
}
// Save-current is a no-op when the live filters already equal this exact view (it's the active one).
const saveCurrentDisabled = selected != null && selected.id === activeViewId;
return ( return (
<Modal title={t("views.manageTitle")} onClose={closeModal} maxWidth="max-w-2xl"> <Modal title={t("views.manageTitle")} onClose={closeModal} maxWidth="max-w-2xl">
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4"> <div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
@@ -148,15 +196,15 @@ export default function PersonalViewsModal() {
</div> </div>
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2"> <div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
<input <input
value={draftName} value={createName}
onChange={(e) => setDraftName(e.target.value)} onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onCreate()} onKeyDown={(e) => e.key === "Enter" && onCreate()}
placeholder={t("views.namePlaceholder")} placeholder={t("views.namePlaceholder")}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30" className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/> />
<button <button
onClick={onCreate} onClick={onCreate}
disabled={!draftName.trim()} disabled={!createName.trim()}
title={t("views.save")} title={t("views.save")}
className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40" className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40"
> >
@@ -168,18 +216,37 @@ export default function PersonalViewsModal() {
{/* Right: the selected view's detail */} {/* Right: the selected view's detail */}
{selected ? ( {selected ? (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<input <div className="flex items-center gap-1.5">
key={selected.id} <input
defaultValue={selected.name} value={nameDraft}
onBlur={(e) => { onChange={(e) => setNameDraft(e.target.value)}
const next = e.target.value.trim(); onKeyDown={(e) => e.key === "Enter" && void onRename()}
if (next && next !== selected.name) void rename(selected.id, next); onBlur={() => void onRename()}
}} className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30" />
/> <button
onClick={() => void onRename()}
disabled={!nameChanged}
title={t("views.rename")}
aria-label={t("views.rename")}
className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-30"
>
<Check className="h-4 w-4" />
</button>
</div>
<div> <div>
<div className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted"> <div className="mb-1.5 flex items-center justify-between gap-2">
{t("views.capturedFilters")} <span className="text-2xs font-semibold uppercase tracking-wider text-muted">
{t("views.capturedFilters")}
</span>
{(filtersDraft.canUndo || filtersDraft.canRedo) && (
<UndoToolbar
canUndo={filtersDraft.canUndo}
canRedo={filtersDraft.canRedo}
onUndo={filtersDraft.undo}
onRedo={filtersDraft.redo}
/>
)}
</div> </div>
{captured.length === 0 ? ( {captured.length === 0 ? (
<p className="text-xs text-muted">{t("views.capturedNone")}</p> <p className="text-xs text-muted">{t("views.capturedNone")}</p>
@@ -188,15 +255,23 @@ export default function PersonalViewsModal() {
{captured.map((f) => ( {captured.map((f) => (
<span <span
key={f.key} key={f.key}
className="rounded-full bg-accent/10 px-2 py-0.5 text-2xs text-accent" className="group inline-flex items-center gap-1 rounded-full bg-accent/10 py-0.5 pl-2 pr-1 text-2xs text-accent"
> >
{f.label} {f.label}
<button
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className="rounded-full p-0.5 opacity-60 transition hover:bg-accent/20 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</span> </span>
))} ))}
</div> </div>
)} )}
</div> </div>
<div className="mt-auto flex items-center gap-2 border-t border-border/60 pt-3"> <div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
<button <button
onClick={() => apply(selected)} onClick={() => apply(selected)}
className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold" className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
@@ -204,6 +279,22 @@ export default function PersonalViewsModal() {
<Play className="h-3.5 w-3.5" /> <Play className="h-3.5 w-3.5" />
{t("views.apply")} {t("views.apply")}
</button> </button>
<button
onClick={async () => {
const saved = await saveCurrent(selected.id);
// Re-seed the draft to what we just stored (the live filters) so the chip row
// reflects the save; clears the now-stale undo history.
if (saved) resetDraft(saved);
}}
disabled={saveCurrentDisabled}
title={
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
}
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-40"
>
<Save className="h-3.5 w-3.5" />
{t("views.saveCurrent")}
</button>
<button <button
onClick={() => void toggleDefault(selected)} onClick={() => void toggleDefault(selected)}
title={t("views.default")} title={t("views.default")}
+27
View File
@@ -11,6 +11,7 @@ import { User, X } from "lucide-react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { healPlexField } from "../lib/plexSearch"; import { healPlexField } from "../lib/plexSearch";
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
import type { SortState } from "../lib/columnSort";
import { import {
api, api,
EMPTY_PLEX_FILTERS, EMPTY_PLEX_FILTERS,
@@ -52,6 +53,13 @@ const PlexCollectionView = lazy(() => import("./PlexCollectionView"));
const PAGE = 40; const PAGE = 40;
// D3: the "last watched" sort auto-applied when you enter the in-progress watch-state view (most
// recently watched first). Frozen for the same reason as PLEX_SORT_FALLBACK (handed out by reference).
const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
key: "lastwatched",
dir: "desc" as const,
});
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am // Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
// I looking at" control now, like the feed's Watch-state pill, not a rail widget). // I looking at" control now, like the feed's Watch-state pill, not a rail widget).
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
@@ -185,6 +193,25 @@ export default function PlexBrowse() {
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [sub]); }, [sub]);
// D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
// forces the sort, so a manual sort change while in the in-progress view still wins — it's just not
// carried back out (exit returns to the pre-entry preference). We stash the raw provider `sort`, not
// the scope-healed one, so the user's real preference round-trips.
const prevShow = useRef(show);
const preInProgressSort = useRef<SortState | null>(null);
useEffect(() => {
const was = prevShow.current;
if (was !== "in_progress" && show === "in_progress") {
preInProgressSort.current = sort;
setSort(LASTWATCHED_SORT);
} else if (was === "in_progress" && show !== "in_progress") {
if (preInProgressSort.current) setSort(preInProgressSort.current);
preInProgressSort.current = null;
}
prevShow.current = show;
}, [show, sort, setSort]);
const browseQ = useInfiniteQuery({ const browseQ = useInfiniteQuery({
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang), queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
// Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect // Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect
+46 -3
View File
@@ -12,6 +12,7 @@ import {
Info, Info,
Keyboard, Keyboard,
Languages, Languages,
Loader2,
Maximize, Maximize,
Minimize, Minimize,
Pause, Pause,
@@ -108,6 +109,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
const { fs, toggleFs } = useFullscreen(wrapRef); const { fs, toggleFs } = useFullscreen(wrapRef);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
// True while playback is stalled waiting for data (mid-stream rebuffer) or a seek is in flight — drives
// a spinner so a stall reads as "loading, hang on" instead of a frozen frame. Distinct from `ready`,
// which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
const [buffering, setBuffering] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are // The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off // absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
@@ -178,6 +184,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
setReady(false); setReady(false);
setBuffering(false);
setLoadError(null); setLoadError(null);
let sess; let sess;
try { try {
@@ -362,12 +369,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events // button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events
// so the icon can never contradict the video. // so the icon can never contradict the video.
const syncPlaying = () => setPlaying(!video.paused); const syncPlaying = () => setPlaying(!video.paused);
// Buffering spinner: `waiting`/`stalled` = playback stopped for lack of data; `seeking` = a seek is
// in flight. Cleared once data flows again (`playing`/`canplay`/`seeked`) or the user pauses (a
// deliberate pause isn't a stall, and would otherwise leave the spinner stuck).
const onWaiting = () => setBuffering(true);
const onResumed = () => setBuffering(false);
video.addEventListener("timeupdate", onTime); video.addEventListener("timeupdate", onTime);
video.addEventListener("play", onPlay); video.addEventListener("play", onPlay);
video.addEventListener("pause", onPause); video.addEventListener("pause", onPause);
video.addEventListener("emptied", syncPlaying); video.addEventListener("emptied", syncPlaying);
video.addEventListener("canplay", syncPlaying); video.addEventListener("canplay", syncPlaying);
video.addEventListener("seeked", syncPlaying); video.addEventListener("seeked", syncPlaying);
video.addEventListener("waiting", onWaiting);
video.addEventListener("stalled", onWaiting);
video.addEventListener("seeking", onWaiting);
video.addEventListener("playing", onResumed);
video.addEventListener("canplay", onResumed);
video.addEventListener("seeked", onResumed);
video.addEventListener("pause", onResumed);
return () => { return () => {
video.removeEventListener("timeupdate", onTime); video.removeEventListener("timeupdate", onTime);
video.removeEventListener("play", onPlay); video.removeEventListener("play", onPlay);
@@ -375,6 +394,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
video.removeEventListener("emptied", syncPlaying); video.removeEventListener("emptied", syncPlaying);
video.removeEventListener("canplay", syncPlaying); video.removeEventListener("canplay", syncPlaying);
video.removeEventListener("seeked", syncPlaying); video.removeEventListener("seeked", syncPlaying);
video.removeEventListener("waiting", onWaiting);
video.removeEventListener("stalled", onWaiting);
video.removeEventListener("seeking", onWaiting);
video.removeEventListener("playing", onResumed);
video.removeEventListener("canplay", onResumed);
video.removeEventListener("seeked", onResumed);
video.removeEventListener("pause", onResumed);
}; };
}, [id]); }, [id]);
@@ -730,12 +756,29 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
)} )}
</video> </video>
{!ready && ( {/* Loading / buffering / error overlay. `!ready` = initial session load; `buffering` = a
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none"> mid-playback stall or in-flight seek (see the state); `loadError` surfaces a fatal failure so
it can't hide behind a running video. Spinner on load/stall, error text otherwise. */}
{(!ready || buffering || loadError) && (
<div
role="status"
aria-live="polite"
className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none"
>
{loadError ? ( {loadError ? (
<span className="max-w-md text-danger">{loadError}</span> <span className="max-w-md text-danger">{loadError}</span>
) : ( ) : (
<span className="text-white/70">{t("plex.player.loading")}</span> <div className="flex flex-col items-center gap-3 text-white/80">
<Loader2 className="w-10 h-10 animate-spin" aria-hidden />
{/* Announce the stall to assistive tech. Initial load shows visible text; a mid-playback
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
{!ready ? (
<span>{t("plex.player.loading")}</span>
) : buffering ? (
<span className="sr-only">{t("plex.player.buffering")}</span>
) : null}
</div>
)} )}
</div> </div>
)} )}
+84 -4
View File
@@ -3,8 +3,16 @@ import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/
import { parseSortState, type SortState } from "../lib/columnSort"; import { parseSortState, type SortState } from "../lib/columnSort";
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch"; import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
import {
hasPlexParams,
plexParamsToState,
plexParamUpdates,
type PlexUrlState,
} from "../lib/plexUrlState";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate"; import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort / // Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of // expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of
@@ -112,14 +120,57 @@ function readPlexSort(): SortState {
return { key: raw, dir }; return { key: raw, dir };
} }
// S4: a shared Plex-browse link carries the content state in namespaced URL keys (pscope/psort/pshow/
// pf). When present, the URL wins over this account's stored view (like the feed's URL-over-localStorage
// seed) — read once at mount. The keys are Plex-unique, so their mere presence identifies a shared Plex
// view; no ?page check is needed (and the mirror below is page-gated so it never pollutes other pages).
function readPlexFilters(): PlexFilters {
try {
return coercePlexFilters(JSON.parse(readPlexFiltersRaw()) as Record<string, unknown>);
} catch {
return EMPTY_PLEX_FILTERS;
}
}
// A shared-link seed = the URL-merged state PLUS which dimensions the URL actually carried. `present`
// matters because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns
// null then), so the base for an OMITTED dimension is a hard default, NOT this account's stored value.
// The provider must therefore persist/keep only the PRESENT dimensions on account-pin and re-read the
// account's real stored value for the absent ones — otherwise a partial link silently resets them.
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
type PlexUrlSeed = { state: PlexUrlState; present: PlexSeedPresent };
function readPlexUrlSeed(): PlexUrlSeed | null {
const params = new URLSearchParams(window.location.search);
if (!hasPlexParams(params)) return null;
const state = plexParamsToState(params, {
scope: readPlexScope(),
sort: readPlexSort(),
show: readPlexShow(),
filters: readPlexFilters(),
});
return {
state,
present: {
scope: params.has("pscope"),
sort: params.has("psort"),
show: params.has("pshow"),
filters: params.has("pf"),
},
};
}
export function PlexProvider({ children }: { children: ReactNode }) { export function PlexProvider({ children }: { children: ReactNode }) {
// Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin,
// is persisted to the account instead of being clobbered by the stored view (see the rehydrate).
const [urlSeed] = useState(readPlexUrlSeed);
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library). // The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".) // (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [scope, setScopeState] = useState<string>(readPlexScope); const [scope, setScopeState] = useState<string>(() => urlSeed?.state.scope ?? readPlexScope());
const [show, setShowState] = useState<string>(readPlexShow); const [show, setShowState] = useState<string>(() => urlSeed?.state.show ?? readPlexShow());
const [sort, setSortState] = useState<SortState>(readPlexSort); const [sort, setSortState] = useState<SortState>(() => urlSeed?.state.sort ?? readPlexSort());
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it). // The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw); const [filtersRaw, setFiltersRaw] = useState<string>(() =>
urlSeed ? JSON.stringify(urlSeed.state.filters) : readPlexFiltersRaw(),
);
const filters = useMemo<PlexFilters>(() => { const filters = useMemo<PlexFilters>(() => {
try { try {
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort // coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
@@ -140,7 +191,29 @@ export function PlexProvider({ children }: { children: ReactNode }) {
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null); const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab). // Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
// A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL
// state to this account (so the shared view wins and survives a later plain reload), and adopt only the
// non-shared slices (q/field) from storage. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
const markDirty = useAccountRehydrate(() => { const markDirty = useAccountRehydrate(() => {
if (urlSeed) {
// Per dimension: a dimension the URL carried (present) is the shared view — adopt it and persist
// it to this now-pinned account (so it wins and survives a later plain reload). A dimension the
// URL OMITTED was seeded from a mount-time default (no account yet), so do NOT persist that
// default over the account's real stored value — re-read the stored value instead, exactly like a
// normal load. This is the fix for a partial shared link silently resetting the omitted slices.
const { state, present } = urlSeed;
if (present.scope) setAccountRaw(LS.plexLibrary, state.scope);
else setScopeState(readPlexScope());
if (present.show) setAccountRaw(LS.plexShow, state.show);
else setShowState(readPlexShow());
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(state.sort));
else setSortState(readPlexSort());
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(state.filters));
else setFiltersRaw(readPlexFiltersRaw());
setQState(readPlexQ());
setFieldState(readPlexField());
return;
}
setScopeState(readPlexScope()); setScopeState(readPlexScope());
setShowState(readPlexShow()); setShowState(readPlexShow());
setSortState(readPlexSort()); setSortState(readPlexSort());
@@ -208,6 +281,13 @@ export function PlexProvider({ children }: { children: ReactNode }) {
[setScope, setShow, setSort, setFilters, setQ, setField], [setScope, setShow, setSort, setFilters, setQ, setField],
); );
// S4: mirror the content state (scope + sort + watch-state + filters) into the address bar under the
// namespaced keys so a Plex-browse view is shareable. Gated on page===plex: off the Plex page the keys
// are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the
// search term is deliberately transient-per-view and not part of the shared four-key contract.
const { page } = useNavigation();
useUrlMirror(plexParamUpdates(page === "plex" ? { scope, sort, show, filters } : null));
return ( return (
<ActionsContext.Provider value={actions}> <ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider> <StateContext.Provider value={state}>{children}</StateContext.Provider>
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
//
// Interaction contract of the shared PriorityStepper (channel manager cell + detail header). i18n is
// mocked to a key-passthrough so aria-label lookups read stable keys, not translations. The visual
// size/border split (`sm` vs `md`) is CSS-only, so we assert behaviour, not styling.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { PriorityStepper } from "./PriorityStepper";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(() => {
cleanup();
vi.useRealTimers(); // any fake-timer test resets here so it can't leak into the next
});
const raise = () => screen.getByRole("button", { name: "channels.row.raisePriority" });
const lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" });
const valueBtn = () => screen.getByRole("button", { name: "channels.row.editPriority" });
describe("PriorityStepper", () => {
it("shows the current priority and both step buttons", () => {
render(<PriorityStepper priority={12} onPriority={vi.fn()} onSet={vi.fn()} />);
expect(valueBtn().textContent).toBe("12");
expect(raise()).toBeTruthy();
expect(lower()).toBeTruthy();
});
it("tapping + / − nudges by ±1", () => {
const onPriority = vi.fn();
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
fireEvent.click(raise());
fireEvent.click(lower());
expect(onPriority.mock.calls).toEqual([[1], [-1]]);
});
it("clicking the value opens an inline editor seeded with the current value", () => {
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={vi.fn()} />);
fireEvent.click(valueBtn());
const input = screen.getByRole("spinbutton") as HTMLInputElement;
expect(input.value).toBe("7");
});
it("typing a new value and pressing Enter commits it via onSet (negatives allowed)", () => {
const onSet = vi.fn();
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
fireEvent.click(valueBtn());
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "-3" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSet).toHaveBeenCalledWith(-3);
});
it("Escape cancels the edit without committing", () => {
const onSet = vi.fn();
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
fireEvent.click(valueBtn());
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "99" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onSet).not.toHaveBeenCalled();
// editor closed → the value button is back
expect(valueBtn().textContent).toBe("7");
});
it("committing an unchanged value does not call onSet", () => {
const onSet = vi.fn();
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
fireEvent.click(valueBtn());
fireEvent.keyDown(screen.getByRole("spinbutton"), { key: "Enter" });
expect(onSet).not.toHaveBeenCalled();
});
it("renders the same interactive contract at size=md", () => {
const onPriority = vi.fn();
render(<PriorityStepper priority={3} onPriority={onPriority} onSet={vi.fn()} size="md" />);
expect(valueBtn().textContent).toBe("3");
fireEvent.click(raise());
expect(onPriority).toHaveBeenCalledWith(1);
});
it("holding a step button auto-repeats (accelerating) and suppresses the trailing click", () => {
vi.useFakeTimers();
const onPriority = vi.fn();
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
fireEvent.pointerDown(raise());
vi.advanceTimersByTime(349);
expect(onPriority).not.toHaveBeenCalled(); // a tap below the 350ms threshold doesn't repeat
vi.advanceTimersByTime(1); // cross the threshold → first auto-repeat tick
expect(onPriority).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1000); // several accelerating repeats while held
const heldCount = onPriority.mock.calls.length;
expect(heldCount).toBeGreaterThan(3);
expect(onPriority.mock.calls.every(([d]) => d === 1)).toBe(true);
fireEvent.pointerUp(raise());
vi.advanceTimersByTime(1000);
expect(onPriority.mock.calls.length).toBe(heldCount); // stops repeating on release
fireEvent.click(raise()); // the click that trails a hold is swallowed (no double-count)
expect(onPriority.mock.calls.length).toBe(heldCount);
});
it("a quick tap (released before the hold threshold) still nudges exactly once", () => {
vi.useFakeTimers();
const onPriority = vi.fn();
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
fireEvent.pointerDown(lower());
vi.advanceTimersByTime(100); // below the threshold → no auto-repeat armed
fireEvent.pointerUp(lower());
fireEvent.click(lower()); // the tap's click is NOT suppressed
expect(onPriority.mock.calls).toEqual([[-1]]);
});
it("clears the auto-repeat timer on unmount (no fire after the row is gone)", () => {
vi.useFakeTimers();
const onPriority = vi.fn();
const { unmount } = render(
<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />,
);
fireEvent.pointerDown(raise());
vi.advanceTimersByTime(350); // repeat armed + first tick
const before = onPriority.mock.calls.length;
expect(before).toBeGreaterThan(0);
unmount();
vi.advanceTimersByTime(1000);
expect(onPriority.mock.calls.length).toBe(before); // no post-unmount timer fire
});
});
+63 -36
View File
@@ -1,19 +1,28 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown, ArrowUp } from "lucide-react"; import { Minus, Plus } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
type Size = "sm" | "md";
const SIZED = {
sm: { btn: "w-6 h-6", icon: "w-3.5 h-3.5", value: "w-8 h-6 text-sm" },
md: { btn: "w-7 h-7", icon: "w-4 h-4", value: "w-10 h-7 text-sm" },
} as const;
function PriorityStepButton({ function PriorityStepButton({
delta, delta,
Icon, Icon,
label, label,
sizeCls,
onHoldStart, onHoldStart,
onStop, onStop,
onTap, onTap,
}: { }: {
delta: number; delta: number;
Icon: typeof ArrowUp; Icon: typeof Plus;
label: string; label: string;
sizeCls: { btn: string; icon: string };
onHoldStart: (delta: number) => void; onHoldStart: (delta: number) => void;
onStop: () => void; onStop: () => void;
onTap: (delta: number) => void; onTap: (delta: number) => void;
@@ -32,28 +41,35 @@ function PriorityStepButton({
onPointerUp={onStop} onPointerUp={onStop}
onPointerCancel={onStop} onPointerCancel={onStop}
onClick={() => onTap(delta)} onClick={() => onTap(delta)}
className="text-muted hover:text-accent leading-none" className={`inline-flex items-center justify-center text-muted hover:text-accent ${sizeCls.btn}`}
aria-label={label} aria-label={label}
> >
<Icon className="w-3.5 h-3.5" /> <Icon className={sizeCls.icon} />
</button> </button>
); );
} }
/** /**
* A press-and-hold-repeat priority control with an inline-editable value (negatives allowed). * A horizontal −/+ priority stepper with the value centred between the buttons and click-to-type
* Shared by the channel manager table (a cell) and the channel detail page (a header control), so * inline editing (negatives allowed). Shared by the channel manager table (a cell, `size="sm"`) and
* the two never drift. The caller owns persistence: `onPriority(delta)` nudges by ±1 (with hold * the channel detail page (a header control, `size="md"`), so the two never drift. The caller owns
* auto-repeat), `onSet(value)` writes a typed-in absolute value. * persistence: `onPriority(delta)` nudges by ±1 (with hold auto-repeat), `onSet(value)` writes a
* typed-in absolute value.
*
* The `size="sm"` variant draws its own bordered pill (it stands alone in a table cell); `size="md"`
* drops the outer border and keeps only the segment dividers, so it blends into the detail header's
* surrounding lozenge instead of double-bordering it.
*/ */
export function PriorityStepper({ export function PriorityStepper({
priority, priority,
onPriority, onPriority,
onSet, onSet,
size = "sm",
}: { }: {
priority: number; priority: number;
onPriority: (delta: number) => void; onPriority: (delta: number) => void;
onSet: (value: number) => void; onSet: (value: number) => void;
size?: Size;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@@ -61,6 +77,7 @@ export function PriorityStepper({
const holdDelay = useRef<ReturnType<typeof setTimeout> | null>(null); const holdDelay = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdRepeat = useRef<ReturnType<typeof setTimeout> | null>(null); const holdRepeat = useRef<ReturnType<typeof setTimeout> | null>(null);
const suppressClick = useRef(false); const suppressClick = useRef(false);
const cls = SIZED[size];
const stop = useCallback(() => { const stop = useCallback(() => {
if (holdDelay.current) clearTimeout(holdDelay.current); if (holdDelay.current) clearTimeout(holdDelay.current);
@@ -103,21 +120,40 @@ export function PriorityStepper({
setEditing(true); setEditing(true);
}; };
// The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the // Segmented control: [−] value [+]. The value slot keeps a FIXED width (`cls.value`) whether it's
// arrows while you press-and-hold — never covers the live value you're setting. Clicking the value // showing the number or the edit input, so opening the editor never resizes the control or shifts
// opens an inline edit to type any integer directly (negatives allowed). // the buttons — the old absolutely-positioned overlay "popped" wider, this doesn't. While editing
// the whole pill borders in `accent` to read as focused.
const framed =
size === "sm"
? `border rounded-md overflow-hidden ${editing ? "border-accent" : "border-border"}`
: "";
return ( return (
<Tooltip hint={t("channels.row.priorityHint")}> <Tooltip hint={t("channels.row.priorityHint")}>
<div className="inline-flex items-center gap-1.5 cursor-help"> <div
{/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely role="group"
positioned OVER it (out of flow) so opening the editor never changes the row height/width — aria-label={t("channels.row.priorityHint")}
it just overlays, extending left for a longer number. */} className={`inline-flex items-center cursor-help ${framed}`}
<span className="relative inline-block w-6 h-5 text-right leading-5"> >
<PriorityStepButton
delta={-1}
Icon={Minus}
label={t("channels.row.lowerPriority")}
sizeCls={cls}
onHoldStart={start}
onStop={stop}
onTap={onTap}
/>
<span
className={`relative inline-flex items-center justify-center border-x ${
editing ? "border-accent" : "border-border"
} ${cls.value}`}
>
<button <button
onClick={beginEdit} onClick={beginEdit}
aria-label={t("channels.row.editPriority")} aria-label={t("channels.row.editPriority")}
title={t("channels.row.editPriority")} title={t("channels.row.editPriority")}
className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`} className={`w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
> >
{priority} {priority}
</button> </button>
@@ -134,28 +170,19 @@ export function PriorityStepper({
else if (e.key === "Escape") setEditing(false); else if (e.key === "Escape") setEditing(false);
}} }}
aria-label={t("channels.row.priorityHint")} aria-label={t("channels.row.priorityHint")}
className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded-sm px-1 outline-hidden z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" className="absolute inset-0 w-full h-full text-center font-medium tabular-nums text-accent bg-card outline-hidden [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
/> />
)} )}
</span> </span>
<span className="inline-flex flex-col"> <PriorityStepButton
<PriorityStepButton delta={1}
delta={1} Icon={Plus}
Icon={ArrowUp} label={t("channels.row.raisePriority")}
label={t("channels.row.raisePriority")} sizeCls={cls}
onHoldStart={start} onHoldStart={start}
onStop={stop} onStop={stop}
onTap={onTap} onTap={onTap}
/> />
<PriorityStepButton
delta={-1}
Icon={ArrowDown}
label={t("channels.row.lowerPriority")}
onHoldStart={start}
onStop={stop}
onTap={onTap}
/>
</span>
</div> </div>
</Tooltip> </Tooltip>
); );
@@ -46,6 +46,7 @@ import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
import { api, setActiveAccount, type SavedView } from "../lib/api"; import { api, setActiveAccount, type SavedView } from "../lib/api";
import { LS, accountKey } from "../lib/storage"; import { LS, accountKey } from "../lib/storage";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
const ID = 7; const ID = 7;
const view = (over: Partial<SavedView>): SavedView => ({ const view = (over: Partial<SavedView>): SavedView => ({
@@ -58,12 +59,19 @@ const view = (over: Partial<SavedView>): SavedView => ({
}); });
function Probe() { function Probe() {
const { loading } = useSavedViews(); const { loading, views } = useSavedViews();
return <div data-testid="loading">{String(loading)}</div>; return (
<div data-testid="loading" data-count={views.length}>
{String(loading)}
</div>
);
} }
async function mountResolved(views: SavedView[]) { async function mountResolved(views: SavedView[]) {
vi.spyOn(api, "savedViews").mockResolvedValue(views); // Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's
// identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing
// in the re-seed test; a shared reference would skip the effect and hide a missing guard).
vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v })));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render( render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
@@ -74,11 +82,15 @@ async function mountResolved(views: SavedView[]) {
); );
// Wait for the query to resolve so the first-resolve effect has run. // Wait for the query to resolve so the first-resolve effect has run.
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false")); await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
return qc;
} }
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID)); sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID));
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
// case would suppress seeding in the others.
window.history.replaceState({}, "", "/");
h.page = "feed"; h.page = "feed";
h.setFeedFilters.mockClear(); h.setFeedFilters.mockClear();
h.setPlexFilters.mockClear(); h.setPlexFilters.mockClear();
@@ -123,6 +135,46 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
expect(h.setFeedFilters).not.toHaveBeenCalled(); expect(h.setFeedFilters).not.toHaveBeenCalled();
}); });
it("feed: does NOT seed when the URL carries filter params (a shared/bookmarked view wins)", async () => {
// A link like ?sort=oldest is an explicit filter the user arrived on; the starred default must not
// stomp it. feedCanSeedDefault() bails on hasFilterParams — plex ignores the URL, so this is feed-only.
window.history.replaceState({}, "", "/?sort=oldest");
await mountResolved([
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
});
it("does NOT re-seed on a background refetch that returns a changed list (per-source one-shot guard)", async () => {
// The 60s refetch re-resolves the query; when it returns a CHANGED list (e.g. the user just added a
// view elsewhere) react-query hands down a new reference and the seed effect re-runs. seededRef must
// stop it re-applying the default over filters the user has since edited. (An identical refetch is
// deduped by structural sharing and never reaches the effect, so the changed list is the real test.)
const def = view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } });
let calls = 0;
vi.spyOn(api, "savedViews").mockImplementation(async () => {
calls += 1;
return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })];
});
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<SavedViewsProvider>
<Probe />
</SavedViewsProvider>
</QueryClientProvider>,
);
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
await act(async () => {
await qc.refetchQueries({ queryKey: qk.savedViews("feed") });
});
// Wait until the changed list has actually reached the component, so the seed effect has had its
// chance to (wrongly) re-fire — otherwise the assertion could win by racing ahead of the re-render.
await waitFor(() => expect(screen.getByTestId("loading").dataset.count).toBe("2"));
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
});
it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => { it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => {
h.page = "plex"; h.page = "plex";
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]); await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
@@ -14,6 +14,7 @@ import {
api, api,
coercePlexFilters, coercePlexFilters,
coerceTagModes, coerceTagModes,
coerceUntagged,
getActiveAccount, getActiveAccount,
plexFiltersSignature, plexFiltersSignature,
type FeedFilters, type FeedFilters,
@@ -42,6 +43,7 @@ export const asFilters = (raw: FeedFilters): FeedFilters => ({
tags: raw.tags ?? [], tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [], channelIds: raw.channelIds ?? [],
tagModes: coerceTagModes(raw), tagModes: coerceTagModes(raw),
untagged: coerceUntagged(raw),
}); });
type ViewSource = "feed" | "plex"; type ViewSource = "feed" | "plex";
@@ -101,6 +103,11 @@ export interface SavedViewsContext {
clearActive: () => void; clearActive: () => void;
create: (name: string) => Promise<boolean>; create: (name: string) => Promise<boolean>;
rename: (id: number, name: string) => Promise<boolean>; rename: (id: number, name: string) => Promise<boolean>;
/** Overwrite a view's captured filters (used by the modal's removable chips). */
updateFilters: (id: number, filters: Record<string, unknown>) => Promise<boolean>;
/** Save the module's CURRENTLY-applied filters into a view (the live→stored inverse of `apply`);
* resolves to the saved snapshot on success (so the caller can resync its editor) or null. */
saveCurrent: (id: number) => Promise<Record<string, unknown> | null>;
toggleDefault: (v: SavedView) => Promise<void>; toggleDefault: (v: SavedView) => Promise<void>;
remove: (v: SavedView) => Promise<boolean>; remove: (v: SavedView) => Promise<boolean>;
reorder: (nextIds: number[]) => Promise<void>; reorder: (nextIds: number[]) => Promise<void>;
@@ -256,6 +263,16 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
if (!next) return false; if (!next) return false;
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed")); return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
}, },
updateFilters: (id, filters) =>
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
saveCurrent: async (id) => {
const filters = cfg.stripForSave(cfg.filters);
const ok = await runViewMutation(
() => api.updateView(id, { filters }),
t("views.actionFailed"),
);
return ok ? filters : null;
},
toggleDefault: (v) => toggleDefault: (v) =>
runViewMutation( runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }), () => api.updateView(v.id, { is_default: !v.is_default }),
+45 -19
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { SlidersHorizontal } from "lucide-react"; import { SlidersHorizontal } from "lucide-react";
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api"; import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { useActiveFilters } from "../lib/useActiveFilters"; import { useActiveFilters } from "../lib/useActiveFilters";
@@ -15,6 +15,7 @@ import { type PanelItem } from "./PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource"; import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker"; import ChannelPicker from "./ChannelPicker";
import DateFilterSelect from "./DateFilterSelect"; import DateFilterSelect from "./DateFilterSelect";
import YearFilter from "./YearFilter";
import Combobox, { type ComboboxOption } from "./Combobox"; import Combobox, { type ComboboxOption } from "./Combobox";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider"; import { useChannelsActions } from "./ChannelsProvider";
@@ -104,12 +105,48 @@ export default function Sidebar({
const setTagMode = (category: TagCategory, mode: "or" | "and") => const setTagMode = (category: TagCategory, mode: "or" | "and") =>
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } }); setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
// The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to
// channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually
// exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears
// it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here.
function userTagsCombo(): ReactNode {
const noneOn = !!filters.untagged;
const inList = (id: number) => userTags.some((tg) => tg.id === id);
const onChange = (next: string[]) => {
if (next.includes(NO_TAG_SENTINEL) && !noneOn) {
setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true });
} else {
const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number);
setFilters({
...filters,
tags: [...filters.tags.filter((id) => !inList(id)), ...reals],
untagged: false,
});
}
};
return (
<Combobox
label={t("sidebar.widget.tags")}
options={[
{ value: NO_TAG_SENTINEL, label: t("sidebar.noTagOption") },
...visibleChips(userTags).map(tagOption),
]}
selected={noneOn ? [NO_TAG_SENTINEL] : selectedIn(userTags)}
onChange={onChange}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.other}
onMatchModeChange={(m) => setTagMode("other", m)}
/>
);
}
// The badge counts exactly what the chip row above the feed shows — same list, so they can't // The badge counts exactly what the chip row above the feed shows — same list, so they can't
// disagree about what's applied. // disagree about what's applied.
const activeCount = useActiveFilters(filters, setFilters).length; const activeCount = useActiveFilters(filters, setFilters).length;
const available: Record<string, boolean> = { const available: Record<string, boolean> = {
date: true, date: true,
year: true,
channel: true, channel: true,
language: languages.length > 0, language: languages.length > 0,
topic: topics.length > 0, topic: topics.length > 0,
@@ -124,7 +161,9 @@ export default function Sidebar({
return ( return (
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} /> <DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
); );
case "language": { case "year":
return <YearFilter filters={filters} setFilters={setFilters} />;
case "language":
return ( return (
<Combobox <Combobox
label={t("sidebar.widget.language")} label={t("sidebar.widget.language")}
@@ -136,8 +175,7 @@ export default function Sidebar({
onMatchModeChange={(m) => setTagMode("language", m)} onMatchModeChange={(m) => setTagMode("language", m)}
/> />
); );
} case "topic":
case "topic": {
return ( return (
<Combobox <Combobox
label={t("sidebar.widget.topic")} label={t("sidebar.widget.topic")}
@@ -149,22 +187,10 @@ export default function Sidebar({
onMatchModeChange={(m) => setTagMode("topic", m)} onMatchModeChange={(m) => setTagMode("topic", m)}
/> />
); );
} case "tags":
case "tags": {
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the // The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
// body is just the picker. // body is just the picker — user tags plus the "No tag" sentinel.
return ( return userTagsCombo();
<Combobox
label={t("sidebar.widget.tags")}
options={visibleChips(userTags).map(tagOption)}
selected={selectedIn(userTags)}
onChange={(next) => setTagSelection(userTags, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.other}
onMatchModeChange={(m) => setTagMode("other", m)}
/>
);
}
default: default:
return null; return null;
} }
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
import { filterChipClass } from "./FilterChipGroup";
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by
// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions
// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…"
// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N".
export interface FilterChip {
key: string;
label: string;
active: boolean;
onToggle: () => void;
}
function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) {
return (
<button
type="button"
onClick={chip.onToggle}
aria-pressed={chip.active}
className={`${filterChipClass}${extra ? ` ${extra}` : ""}`}
>
{chip.label}
</button>
);
}
export default function TagFilterChips({
chips,
className,
}: {
chips: FilterChip[];
className?: string;
}) {
const { t } = useTranslation();
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(null);
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
const key = chips.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}`).join("|");
const visible = useChipOverflowFit<number>({
rowRef,
chipsRef: chipRefs,
pillRef,
deps: key,
initial: chips.length,
decide: (g) =>
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
const hidden = chips.length - visible;
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
// If nothing overflows anymore, the "+N" trigger unmounts — force the popover closed so it can't
// linger against a gone reference.
useEffect(() => {
if (hidden === 0 && open) setOpen(false);
}, [hidden, open]);
const moreLabel = t("channels.tags.moreCount", { count: hidden });
return (
<div className={`relative flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
read each chip's right edge, the rem gap and the pill width off real boxes. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5 whitespace-nowrap"
>
{chips.map((c, i) => (
<span
key={c.key}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<span className={filterChipClass}>{c.label}</span>
</span>
))}
<span ref={pillRef} className={filterChipClass}>
+{chips.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<Chip key={c.key} chip={c} extra="shrink-0" />
))}
{hidden > 0 && (
<button
ref={pop.refs.setReference}
type="button"
aria-label={moreLabel}
className={`${filterChipClass} shrink-0 ${open ? "border-accent text-accent" : ""}`}
{...pop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
<PopoverSurface
popover={pop}
ariaLabel={moreLabel}
className="glass-menu flex max-w-[22rem] flex-wrap items-center gap-1.5 rounded-xl p-2 shadow-lg animate-[popIn_0.16s_ease]"
>
{chips.slice(visible).map((c) => (
<Chip key={c.key} chip={c} />
))}
</PopoverSurface>
</div>
);
}
+24 -3
View File
@@ -17,6 +17,7 @@ import {
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import VideoInfoCard from "./VideoInfoCard";
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";
@@ -105,6 +106,17 @@ function Actions({
); );
} }
// The hover-infobox trigger skins (E8·S3). The ⓘ is hover-revealed and dropped on touch (`reveal-hover`
// + the group opacity toggle), and lives OUTSIDE the six-button action budget — as a thumbnail-corner
// overlay for the thumbnailed views, and inline beside the actions for the one-line row.
const INFO_REVEAL =
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
const INFO_OVERLAY = clsx(
INFO_REVEAL,
"absolute bottom-1.5 left-1.5 z-10 rounded-md bg-black/70 p-1 text-white hover:bg-black/85",
);
const INFO_INLINE = clsx(INFO_REVEAL, "rounded-md p-1.5 text-muted hover:bg-surface hover:text-fg");
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click // Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href. // keep the native "open youtube.com in a new tab" behavior via the underlying href.
function openInApp( function openInApp(
@@ -312,6 +324,10 @@ function Thumb({
)} )}
</div> </div>
)} )}
{/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the
corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
rest of the hover chrome, dropped on touch. */}
<VideoInfoCard video={video} className={INFO_OVERLAY} />
{/* Resume progress bar (sits at the very bottom, under the duration badge). */} {/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && ( {pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40"> <div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
@@ -330,7 +346,7 @@ function Thumb({
// Keep this table and the classes in step; each number is measured against the worst case in the // Keep this table and the classes in step; each number is measured against the worst case in the
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from. // LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
const ROW_COL = { const ROW_COL = {
actions: 192, // w-48 — six buttons at 28 + five 4px gaps = 188 actions: 224, // w-48 (six buttons: 6×28 + 5×4 = 188) + gap-1 + the 28px inline info trigger
channel: 128, // w-32 channel: 128, // w-32
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
views: 64, // w-16 views: 64, // w-16
@@ -508,8 +524,13 @@ function VideoCard({
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
they're the shortest pointer trip from it. */} they're the shortest pointer trip from it. This row has no thumbnail to host the info
<div className="shrink-0 w-48">{actions}</div> trigger, so it sits inline right after the actions (still outside their six-button
budget). ROW_COL.actions already counts its width. */}
<div className="shrink-0 flex items-center gap-1">
<div className="w-48">{actions}</div>
<VideoInfoCard video={video} className={INFO_INLINE} />
</div>
{/* Everything else is shrink-0, so the title is the only thing that can give — which means {/* Everything else is shrink-0, so the title is the only thing that can give — which means
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
from the right as the row narrows, least important first: a title with no date beats a from the right as the row narrows, least important first: a title with no date beats a
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
//
// Contract of the feed-card hover infobox (E8·S3): the trigger stays closed until opened; on open the
// header (full title + channel) renders IMMEDIATELY from the Video we already hold, while the
// description (videoDetail) and the channel's tags (channelDetail.tag_ids resolved against the tag
// list) fill in from their on-demand queries; an empty description falls back to the "no description"
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui (hover mode → open via
// focus), so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const videoDetail = vi.fn();
const channelDetail = vi.fn();
const tags = vi.fn();
vi.mock("../lib/api", () => ({
api: {
videoDetail: (...a: unknown[]) => videoDetail(...a),
channelDetail: (...a: unknown[]) => channelDetail(...a),
tags: (...a: unknown[]) => tags(...a),
},
}));
import VideoInfoCard from "./VideoInfoCard";
import type { Video } 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.clearAllMocks();
});
const video = (over: Partial<Video> = {}): Video =>
({
id: "v1",
title: "The full untruncated title",
channel_id: "c1",
channel_title: "Test Channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/c1",
published_at: "2026-02-24T00:00:00Z",
thumbnail_url: null,
duration_seconds: 754,
view_count: 1512,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 0,
saved: false,
watch_url: "https://youtu.be/v1",
...over,
}) as Video;
const open = (v: Video = video()) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoInfoCard video={v} className="info-trigger" />
</FloatingTree>
</QueryClientProvider>,
);
fireEvent.focus(screen.getByRole("button", { name: "card.info" }));
};
describe("VideoInfoCard", () => {
it("renders a trigger, panel closed until opened (no fetch at rest)", () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoInfoCard video={video()} className="info-trigger" />
</FloatingTree>
</QueryClientProvider>,
);
expect(screen.getByRole("button", { name: "card.info" })).toBeTruthy();
expect(screen.queryByText("The full untruncated title")).toBeNull();
// `enabled: open` gates the queries — nothing fired while closed.
expect(videoDetail).not.toHaveBeenCalled();
expect(channelDetail).not.toHaveBeenCalled();
});
it("shows the header immediately from the Video, then fills in description + tags on load", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: "A longer description snippet." });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [1, 3] });
tags.mockResolvedValue([
{ id: 1, name: "English", category: "language" },
{ id: 2, name: "Unused", category: "topic" },
{ id: 3, name: "Astronomy", category: "topic" },
]);
open();
// Header is from the Video prop — present without waiting on any query.
expect(screen.getByText("The full untruncated title")).toBeTruthy();
expect(screen.getByText("Test Channel")).toBeTruthy();
// Description + only the channel's own tags (1 + 3, not 2) once the queries resolve.
expect(await screen.findByText("A longer description snippet.")).toBeTruthy();
expect(await screen.findByText("English")).toBeTruthy();
expect(await screen.findByText("Astronomy")).toBeTruthy();
expect(screen.queryByText("Unused")).toBeNull();
});
it("falls back to the no-description line when the video has none", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: null });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [] });
tags.mockResolvedValue([]);
open();
expect(await screen.findByText("card.noDescription")).toBeTruthy();
});
});
+143
View File
@@ -0,0 +1,143 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Info } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type Video } from "../lib/api";
import { formatDate, formatDuration, formatViews } from "../lib/format";
import { linkify } from "../lib/linkify";
import Avatar from "./Avatar";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
// small ⓘ button — a thumbnail-corner overlay in the four thumbnailed views, an inline button beside
// the actions in the one-line row (see the caller). It reveals only with the other hover controls on a
// mouse and is dropped entirely on touch (`.reveal-hover`), where a tap already plays/resumes and a
// hover card would only be an invisible trap. Hover mode also opens it on keyboard focus, so it stays
// reachable by Tab.
//
// The card face already carries title/channel/date/duration/views, so the box's real added value is
// the FULL title (the card clamps to 2 lines) plus the description + tags, which aren't in the feed
// list payload. Both are fetched ON DEMAND on open (like AboutCell): the description via
// `videoDetail`, the tags by resolving the channel's `tag_ids` (from `channelDetail`, shared with the
// channel page's cache) against the tag list. Until they land the header still shows from the Video
// we already have, so the box is never empty.
//
// The caller owns the trigger's placement + reveal via `className`: a thumbnail-corner overlay for the
// four thumbnailed views, an inline button beside the actions for the one-line row. The action ROW
// itself is budgeted to exactly six buttons (a seventh wraps in every view — see VideoCard's ROW_COL),
// so the trigger deliberately lives OUTSIDE it.
export default function VideoInfoCard({
video,
className,
}: {
video: Video;
/** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules
* live here so each host can match its surface (overlay vs inline). */
className?: string;
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "hover",
placement: "bottom-end",
offset: 6,
// A touch of open intent (so brushing past the button while reaching another control doesn't
// pop it), a longer close grace so the cursor can travel into the interactive card.
hoverDelay: { open: 140, close: 160 },
fitHeight: true,
});
const { data: detail } = useQuery({
queryKey: qk.videoDetail(video.id),
queryFn: () => api.videoDetail(video.id),
enabled: open,
staleTime: 5 * 60_000,
});
const { data: channel } = useQuery({
queryKey: qk.channel(video.channel_id),
queryFn: () => api.channelDetail(video.channel_id),
enabled: open && !!video.channel_id,
staleTime: 5 * 60_000,
});
const { data: tags } = useQuery({
queryKey: qk.tags(),
queryFn: () => api.tags(),
enabled: open,
staleTime: 5 * 60_000,
});
// The channel's tags (`tag_ids`), in the channel's own id order, resolved to names via the tag list.
// These are the channel-tags the channel actually carries; an unknown id (a just-deleted tag) drops.
const tagNames =
channel && tags
? channel.tag_ids
.map((id) => tags.find((tg) => tg.id === id))
.filter((tg): tg is NonNullable<typeof tg> => !!tg)
: [];
const description = detail?.description?.trim();
const metaBits = [
video.view_count != null ? `${formatViews(video.view_count)} ${t("card.views")}` : null,
video.duration_seconds != null ? formatDuration(video.duration_seconds) : null,
video.published_at ? formatDate(video.published_at, i18n.language) : null,
].filter(Boolean);
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
// both preventDefault (no navigation) and stopPropagation (no card open); harmless inline too.
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
aria-label={t("card.info")}
title={t("card.info")}
className={className}
>
<Info className="w-4 h-4" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={t("card.info")}
className="glass w-[340px] max-w-[92vw] overflow-y-auto rounded-lg px-3.5 py-3 animate-[fadeIn_0.12s_ease]"
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
>
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
<div className="mt-2 flex items-center gap-2">
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-6 h-6 rounded-full shrink-0"
/>
<span className="text-xs text-muted truncate">{video.channel_title}</span>
</div>
{metaBits.length > 0 && <p className="mt-1.5 text-xs text-muted">{metaBits.join(" · ")}</p>}
{description ? (
<p className="mt-2.5 text-xs leading-relaxed text-muted line-clamp-5 wrap-break-word">
{linkify(description)}
</p>
) : detail ? (
<p className="mt-2.5 text-xs italic text-muted">{t("card.noDescription")}</p>
) : null}
{tagNames.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tagNames.map((tg) => (
<span key={tg.id} className="glass-card px-2 py-0.5 rounded-full text-2xs">
{tg.name}
</span>
))}
</div>
)}
</PopoverSurface>
</>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ChevronDown } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type FeedFilters } from "../lib/api";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { filterChipClass } from "./FilterChipGroup";
import CountBadge from "./CountBadge";
import { useDebounced } from "../lib/useDebounced";
// The feed rail's "Year" control (item 8 / E8·S2): quick chips for the most recent years plus a
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
// filters the feed to that calendar year (a whole-year published_after/before range, applied in the
// api layer); it's a single-select toggle (click the active year to clear it) and mutually exclusive
// with the Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
//
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
// own category.
const QUICK = 6; // recent-year chips shown before the "More…" overflow
export default function YearFilter({
filters,
setFilters,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [sortByCount, setSortByCount] = useState(false);
// Debounce the search term (like the sibling summary facet in Sidebar) so typing doesn't refire the
// per-year count query on every keystroke — each distinct q is a fresh cache key, so without this
// an N-char query would issue N /feed/years requests. The year/date narrowings are dropped: this
// list is a live drill-down that must not change when a year (or upload-date) is picked.
const facetFilters: FeedFilters = {
...filters,
q: useDebounced(filters.q, 300),
year: undefined,
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
};
const yearsQuery = useQuery({
queryKey: qk.feedYears(facetFilters),
queryFn: () => api.feedYears(facetFilters),
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const years = yearsQuery.data?.years ?? [];
const selected = filters.year;
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
const pick = (year?: number) =>
setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined });
// Quick chips = the most recent years, plus the selected year if it falls outside that window (so the
// active pick is always visible), re-sorted newest-first.
const quick = useMemo(() => {
const top = years.slice(0, QUICK);
if (selected != null && !top.some((y) => y.year === selected)) {
const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 };
return [...top, sel].sort((a, b) => b.year - a.year);
}
return top;
}, [years, selected]);
const sorted = useMemo(
() =>
[...years].sort((a, b) =>
sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year,
),
[years, sortByCount],
);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
});
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
if (years.length === 0) {
if (yearsQuery.isLoading) {
return (
<p className="text-muted px-1 py-1.5 text-xs" role="status" aria-live="polite">
{t("common.loading")}
</p>
);
}
if (yearsQuery.isError) {
return (
<p className="text-muted px-1 py-1.5 text-xs" role="alert">
{t("common.loadError")}
</p>
);
}
return <p className="text-muted px-1 py-1.5 text-xs">{t("sidebar.year.none")}</p>;
}
return (
<div className="flex flex-wrap gap-1.5">
{quick.map((y) => {
const on = selected === y.year;
return (
<button
key={y.year}
type="button"
aria-pressed={on}
onClick={() => pick(on ? undefined : y.year)}
className={filterChipClass}
>
<span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />}
</button>
);
})}
{years.length > quick.length && (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={t("sidebar.year.moreLabel")}
className={filterChipClass}
>
<span>{t("sidebar.year.more")}</span>
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.year.moreLabel")}
className="glass-menu min-w-44 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
<div className="ml-auto flex gap-1">
{(
[
["sortYear", false],
["sortCount", true],
] as const
).map(([key, v]) => (
<button
key={key}
type="button"
aria-pressed={sortByCount === v}
onClick={() => setSortByCount(v)}
className={`text-2xs rounded-md border px-2 py-0.5 transition ${
sortByCount === v
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
}`}
>
{t("sidebar.year." + key)}
</button>
))}
</div>
</div>
<div
className="max-h-64 overflow-y-auto no-scrollbar"
role="group"
aria-label={t("sidebar.year.moreLabel")}
>
{sorted.map((y) => {
const on = selected === y.year;
return (
<PopoverItem
key={y.year}
role="menuitemradio"
checked={on}
onSelect={() => {
pick(on ? undefined : y.year);
setOpen(false);
}}
label={String(y.year)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${on ? "bg-accent" : "bg-transparent"}`}
/>
<span className="flex-1 tabular-nums">{y.year}</span>
<span className="text-2xs text-muted shrink-0 tabular-nums">{y.count}</span>
</PopoverItem>
);
})}
</div>
</PopoverSurface>
</>
)}
</div>
);
}
@@ -5,8 +5,8 @@
// "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change // "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change
// which channels the top chips show. // which channels the top chips show.
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { applySyncFilter } from "./channelColumns"; import { applySyncFilter, tagsColumnTest } from "./channelColumns";
import { type ManagedChannel } from "../lib/api"; import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({ const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
id: "c", id: "c",
@@ -51,3 +51,23 @@ describe("applySyncFilter", () => {
expect(applySyncFilter(rows, "needsFull").map((r) => r.id)).toEqual(["recentOnly", "none"]); expect(applySyncFilter(rows, "needsFull").map((r) => r.id)).toEqual(["recentOnly", "none"]);
}); });
}); });
describe("tagsColumnTest (item B — 'No tag')", () => {
const tagged = ch({ id: "tagged", tag_ids: [1, 2] });
const untagged = ch({ id: "untagged", tag_ids: [] });
it("the No-tag sentinel keeps only channels with no user tag", () => {
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL])).toBe(false);
expect(tagsColumnTest(untagged, [NO_TAG_SENTINEL])).toBe(true);
});
it("real tag values match ANY selected id (sentinel absent)", () => {
expect(tagsColumnTest(tagged, ["2"])).toBe(true);
expect(tagsColumnTest(tagged, ["3"])).toBe(false);
expect(tagsColumnTest(untagged, ["1"])).toBe(false);
});
it("the sentinel wins if it somehow co-exists with real ids", () => {
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false);
});
});
+22 -4
View File
@@ -2,14 +2,14 @@ import type { ComponentProps } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react"; import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
import type { ManagedChannel } from "../lib/api"; import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format"; import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
import type { Column } from "./DataTable"; import type { Column } from "./DataTable";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { PriorityStepper } from "./PriorityStepper"; import { PriorityStepper } from "./PriorityStepper";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell"; import AboutCell from "./AboutCell";
import { ChannelTagsControl } from "./ChannelTags"; import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical /** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different * in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
@@ -42,6 +42,14 @@ export function applySyncFilter(rows: ManagedChannel[], value: unknown): Managed
return rows; return rows;
} }
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
return values.some((v) => c.tag_ids.includes(Number(v)));
}
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and // The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
// full history are both in — folding the retired Sync column's at-a-glance status into the value it // full history are both in — folding the retired Sync column's at-a-glance status into the value it
// describes. Full detail stays in the hover tooltip. // describes. Full detail stays in the hover tooltip.
@@ -158,6 +166,10 @@ export interface ChannelColumnDeps {
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void; toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
createAndAttachTag: (channelId: string, name: string) => void; createAndAttachTag: (channelId: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void; onFilterByTag: (tagId: number, name: string) => void;
// E8·S4 — suggested user tags per channel (resolved to Tag + confidence), plus accept/dismiss.
suggestionsFor: (channelId: string) => SuggestedTag[];
onAcceptSuggestion: (channelId: string, tagId: number) => void;
onDismissSuggestion: (channelId: string, tagId: number) => void;
} }
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] { export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
@@ -177,13 +189,16 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
toggleTag, toggleTag,
createAndAttachTag, createAndAttachTag,
onFilterByTag, onFilterByTag,
suggestionsFor,
onAcceptSuggestion,
onDismissSuggestion,
} = deps; } = deps;
return [ return [
{ {
key: "priority", key: "priority",
header: t("channels.cols.priority"), header: t("channels.cols.priority"),
align: "center", align: "center",
width: "3.5rem", width: "5.5rem",
sortable: true, sortable: true,
hideInCard: true, hideInCard: true,
sortValue: (c) => c.priority, sortValue: (c) => c.priority,
@@ -289,7 +304,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
filter: { filter: {
kind: "multi", kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })), options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))), test: tagsColumnTest,
}, },
render: (c) => ( render: (c) => (
<ChannelTagsControl <ChannelTagsControl
@@ -298,6 +313,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(c.id, name)} onCreate={(name) => createAndAttachTag(c.id, name)}
onChipClick={onFilterByTag} onChipClick={onFilterByTag}
suggestions={suggestionsFor(c.id)}
onAcceptSuggestion={(tagId) => onAcceptSuggestion(c.id, tagId)}
onDismissSuggestion={(tagId) => onDismissSuggestion(c.id, tagId)}
/> />
), ),
}, },
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
//
// Container-logic contract of the tag-suggestion hook (E8·S4), the wiring the presentational
// ChannelTagsControl test can't reach: suggestionsFor resolves tag ids to Tags and DROPS a suggestion
// whose tag was deleted since the last compute; accept optimistically removes the WHOLE channel from
// the /tags/suggestions cache and toggles the tag on; dismiss is session-only and scoped to one
// (channel, tag); visible/high-confidence derive correctly. Runs against a real QueryClient so the
// optimistic cache write is the actual code path (not a re-implementation).
import { describe, it, expect, vi } from "vitest";
import type { ReactNode } from "react";
import { renderHook, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
};
});
// The confirm dialog's answer is controllable per test (the bulk-accept path gates on it).
const confirmState = vi.hoisted(() => ({ result: true }));
vi.mock("./ConfirmProvider", () => ({
useConfirm: () => () => Promise.resolve(confirmState.result),
}));
import { api, type Tag } from "../lib/api";
import type { TagSuggestions } from "../lib/api/tags";
import { qk } from "../lib/queryKeys";
import { useTagSuggestions } from "./useTagSuggestions";
const tag = (id: number, name: string): Tag => ({
id,
name,
color: null,
category: "other",
system: false,
channel_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
const tags = [docu, history];
// UC_a: two suggestions (one high-confidence). UC_b: one. UC_ghost: a suggestion for a tag id that no
// longer exists in the catalog (deleted since compute) → must be filtered out everywhere.
const seed = (): TagSuggestions => ({
UC_a: [
{ tag_id: docu.id, confidence: 0.95 },
{ tag_id: history.id, confidence: 0.6 },
],
UC_b: [{ tag_id: history.id, confidence: 0.7 }],
UC_ghost: [{ tag_id: 999, confidence: 0.99 }],
});
function setup() {
const qc = new QueryClient({
// Freeze the seeded cache so no refetch clobbers the optimistic writes under test.
defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnMount: false } },
});
qc.setQueryData(qk.tagSuggestions(), seed());
vi.spyOn(api, "tagSuggestions").mockResolvedValue(seed());
const toggleTag = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useTagSuggestions({ tags, toggleTag }), { wrapper });
return { qc, toggleTag, result };
}
describe("useTagSuggestions", () => {
it("resolves tag ids to Tags and drops suggestions whose tag was deleted", () => {
const { result } = setup();
const forA = result.current.suggestionsFor("UC_a");
expect(forA.map((s) => s.tag.id)).toEqual([docu.id, history.id]);
// The suggestion for the missing tag id 999 is dropped, not rendered as a broken chip.
expect(result.current.suggestionsFor("UC_ghost")).toEqual([]);
});
it("derives visible and high-confidence suggestions, excluding the deleted-tag one", () => {
const { result } = setup();
// UC_a(docu 0.95) + UC_a(history 0.6) + UC_b(history 0.7) = 3; UC_ghost excluded.
expect(result.current.visibleSuggestions).toHaveLength(3);
// Only the ≥0.9 one (UC_a/docu) is high-confidence.
expect(result.current.highConfidence).toEqual([
{ cid: "UC_a", tagId: docu.id, confidence: 0.95 },
]);
});
it("accept optimistically removes the whole channel from the cache and toggles the tag on", () => {
const { qc, toggleTag, result } = setup();
act(() => result.current.acceptSuggestion("UC_a", docu.id));
// The real tag is attached (hasTag=false → attach).
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
// The whole channel is dropped from the suggestions cache (attached lane XOR ghost lane), so its
// OTHER ghost chip (history) goes too — not just the accepted one.
const cache = qc.getQueryData<TagSuggestions>(qk.tagSuggestions());
expect(cache?.UC_a).toBeUndefined();
expect(cache?.UC_b).toBeDefined(); // other channels untouched
});
it("dismiss hides only that one (channel, tag) for the session", () => {
const { result } = setup();
act(() => result.current.dismissSuggestion("UC_a", history.id));
// UC_a loses only the history suggestion; docu stays.
expect(result.current.suggestionsFor("UC_a").map((s) => s.tag.id)).toEqual([docu.id]);
// The same tag on a DIFFERENT channel is not dismissed (dismiss is scoped to the pair).
expect(result.current.suggestionsFor("UC_b").map((s) => s.tag.id)).toEqual([history.id]);
});
it("bulk-accept applies ONLY the high-confidence suggestions after a confirm", async () => {
confirmState.result = true;
const { toggleTag, result } = setup();
await act(async () => {
await result.current.acceptAllHighConfidence();
});
// Only UC_a/docu (0.95 ≥ 0.9) is accepted — not history (0.6) or UC_b/history (0.7).
expect(toggleTag).toHaveBeenCalledTimes(1);
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
});
it("bulk-accept does nothing when the confirm is cancelled", async () => {
confirmState.result = false;
const { toggleTag, result } = setup();
await act(async () => {
await result.current.acceptAllHighConfidence();
});
expect(toggleTag).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,91 @@
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type Tag, type TagSuggestions } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useConfirm } from "./ConfirmProvider";
import type { SuggestedTag } from "./ChannelTags";
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
const HIGH_CONFIDENCE = 0.9;
type VisibleSuggestion = { cid: string; tagId: number; confidence: number };
/** Suggested-tag state for the Channel Manager (E8·S4): the /tags/suggestions query, a session-only
* dismiss set, and the accept / dismiss / bulk-accept actions. Suggestions are learned from the
* user's own manual-tag pattern and shown as ghost chips on UNTAGGED channels. Extracted as a hook
* (like useChannelTagActions) so the optimistic-cache and dismiss logic is unit-testable apart from
* the large Channels component. */
export function useTagSuggestions({
tags,
toggleTag,
}: {
/** The full tag catalog, to resolve a suggestion's tag_id → Tag (and drop tags deleted since compute). */
tags: Tag[];
/** The shared attach/detach toggle. Its onError must also refetch suggestions (see Channels) so a
* failed accept restores the ghost chips this hook optimistically removed. */
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
const suggestionsQuery = useQuery({ queryKey: qk.tagSuggestions(), queryFn: api.tagSuggestions });
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
const tagById = useMemo(() => new Map(tags.map((tg) => [tg.id, tg])), [tags]);
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
const suggestionsFor = (cid: string): SuggestedTag[] =>
(suggestions[cid] ?? []).flatMap((s) => {
const tag = tagById.get(s.tag_id);
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
});
const dismissSuggestion = (cid: string, tagId: number) =>
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
const acceptSuggestion = (cid: string, tagId: number) => {
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; on success
// its onSuccess re-invalidates suggestions, and on failure its onError refetches them — so a
// failed accept restores these ghost chips rather than leaving an empty cell (see Channels' onError).
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
if (!old?.[cid]) return old;
const next = { ...old };
delete next[cid];
return next;
});
toggleTag(cid, tagId, false);
};
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
// the bulk action accepts.
const visibleSuggestions: VisibleSuggestion[] = Object.keys(suggestions).flatMap((cid) =>
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
);
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
const acceptAllHighConfidence = async () => {
const ok = await confirm({
title: t("channels.tags.acceptAllTitle"),
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
});
if (!ok) return;
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
};
return {
suggestionsFor,
dismissSuggestion,
acceptSuggestion,
visibleSuggestions,
highConfidence,
acceptAllHighConfidence,
};
}
+3 -1
View File
@@ -17,5 +17,7 @@
"restart": "Restart", "restart": "Restart",
"play": "Play", "play": "Play",
"stream": "stream", "stream": "stream",
"views": "views" "views": "views",
"info": "Details",
"noDescription": "No description"
} }
@@ -22,6 +22,10 @@
"block": "Block channel", "block": "Block channel",
"unblock": "Unblock channel", "unblock": "Unblock channel",
"blockedBadge": "Blocked", "blockedBadge": "Blocked",
"hide": "Hide from feed",
"unhide": "Unhide",
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
"country": "Country", "country": "Country",
"language": "Language", "language": "Language",
"topics": "Topics", "topics": "Topics",
+18 -1
View File
@@ -47,11 +47,28 @@
}, },
"tags": { "tags": {
"label": "Tags", "label": "Tags",
"add": "Tag",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)", "yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
"noneYet": "No tags yet — create one below.", "noneYet": "No tags yet — create one below.",
"create": "Create tag", "create": "Create tag",
"createPlaceholder": "New tag…", "createPlaceholder": "New tag…",
"createFailed": "Could not create the tag." "createFailed": "Could not create the tag.",
"updateFailed": "Could not update the tag.",
"noTag": "No tag",
"moreCount_one": "{{count}} more tag",
"moreCount_other": "{{count}} more tags",
"suggestedHint": "Suggested from your tagging pattern ({{pct}}% match) — click to add",
"acceptSuggestion": "Add suggested tag {{name}}",
"dismissSuggestion": "Dismiss suggestion",
"suggestionsHint": "Tags suggested for your untagged channels, learned from how you've tagged similar ones. Click a ghost chip to add it, or × to dismiss.",
"suggestionsCount_one": "{{count}} suggestion",
"suggestionsCount_other": "{{count}} suggestions",
"acceptAllHigh_one": "Accept {{count}} confident",
"acceptAllHigh_other": "Accept {{count}} confident",
"acceptAllTitle": "Accept suggested tags",
"acceptAllConfirm_one": "Add the {{count}} high-confidence suggested tag to its channel?",
"acceptAllConfirm_other": "Add the {{count}} high-confidence suggested tags to their channels?",
"acceptAllConfirmCta": "Accept all"
}, },
"loading": "Loading channels…", "loading": "Loading channels…",
"empty": "No channels.", "empty": "No channels.",
+1
View File
@@ -3,6 +3,7 @@
"label": "Filtered by", "label": "Filtered by",
"remove": "Remove filter: {{name}}", "remove": "Remove filter: {{name}}",
"contentNone": "No content types", "contentNone": "No content types",
"untagged": "Untagged",
"count_one": "{{count}} filter", "count_one": "{{count}} filter",
"count_other": "{{count}} filters", "count_other": "{{count}} filters",
"moreCount_one": "{{count}} more filter", "moreCount_one": "{{count}} more filter",
+2
View File
@@ -45,6 +45,7 @@
}, },
"sort": { "sort": {
"added": "Recently added", "added": "Recently added",
"lastwatched": "Last watched",
"title": "Title", "title": "Title",
"year": "Year", "year": "Year",
"rating": "IMDb rating", "rating": "IMDb rating",
@@ -153,6 +154,7 @@
}, },
"player": { "player": {
"loading": "Loading…", "loading": "Loading…",
"buffering": "Buffering…",
"back": "Back", "back": "Back",
"skipIntro": "Skip intro", "skipIntro": "Skip intro",
"skipCredits": "Skip credits", "skipCredits": "Skip credits",
+11 -1
View File
@@ -27,6 +27,7 @@
"clearDates": "clear dates", "clearDates": "clear dates",
"reshuffle": "Reshuffle", "reshuffle": "Reshuffle",
"noMatchingTags": "No matching tags here", "noMatchingTags": "No matching tags here",
"noTagOption": "No tag",
"manageTags": "Manage", "manageTags": "Manage",
"widget": { "widget": {
"savedviews": "Saved views", "savedviews": "Saved views",
@@ -37,7 +38,16 @@
"language": "Language", "language": "Language",
"topic": "Topic", "topic": "Topic",
"tags": "Your tags", "tags": "Your tags",
"channel": "Channel" "channel": "Channel",
"year": "Year"
},
"year": {
"more": "More…",
"moreLabel": "All years",
"none": "No years yet",
"sortBy": "Sort",
"sortYear": "Year",
"sortCount": "Count"
}, },
"show": { "show": {
"unwatched": "Unwatched", "unwatched": "Unwatched",
+3
View File
@@ -19,6 +19,9 @@
"capturedFilters": "Captured filters", "capturedFilters": "Captured filters",
"capturedNone": "No filters — matches every video.", "capturedNone": "No filters — matches every video.",
"default": "Default", "default": "Default",
"saveCurrent": "Save current",
"saveCurrentHint": "Overwrite this view with the filters currently applied",
"saveCurrentUpToDate": "This view already matches the current filters",
"selectPrompt": "Select a view to see and edit it.", "selectPrompt": "Select a view to see and edit it.",
"modified": "Modified — the current filters differ from this saved view." "modified": "Modified — the current filters differ from this saved view."
} }
+3 -1
View File
@@ -17,5 +17,7 @@
"restart": "Elölről", "restart": "Elölről",
"play": "Lejátszás", "play": "Lejátszás",
"stream": "stream", "stream": "stream",
"views": "megtekintés" "views": "megtekintés",
"info": "Részletek",
"noDescription": "Nincs leírás"
} }
@@ -22,6 +22,10 @@
"block": "Csatorna tiltása", "block": "Csatorna tiltása",
"unblock": "Tiltás feloldása", "unblock": "Tiltás feloldása",
"blockedBadge": "Tiltva", "blockedBadge": "Tiltva",
"hide": "Elrejtés a hírfolyamból",
"unhide": "Megjelenítés",
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
"country": "Ország", "country": "Ország",
"language": "Nyelv", "language": "Nyelv",
"topics": "Témák", "topics": "Témák",
+18 -1
View File
@@ -47,11 +47,28 @@
}, },
"tags": { "tags": {
"label": "Címkék", "label": "Címkék",
"add": "Címke",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)", "yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
"noneYet": "Még nincs címke — hozz létre egyet lent.", "noneYet": "Még nincs címke — hozz létre egyet lent.",
"create": "Címke létrehozása", "create": "Címke létrehozása",
"createPlaceholder": "Új címke…", "createPlaceholder": "Új címke…",
"createFailed": "A címke létrehozása nem sikerült." "createFailed": "A címke létrehozása nem sikerült.",
"updateFailed": "A címke frissítése nem sikerült.",
"noTag": "Nincs címke",
"moreCount_one": "még {{count}} címke",
"moreCount_other": "még {{count}} címke",
"suggestedHint": "A címkézési mintád alapján javasolt ({{pct}}% egyezés) — kattints a hozzáadáshoz",
"acceptSuggestion": "{{name}} javasolt címke hozzáadása",
"dismissSuggestion": "Javaslat elvetése",
"suggestionsHint": "A címkézetlen csatornáidhoz javasolt címkék, abból tanulva, hogyan címkézted a hasonlókat. Kattints egy szellem-chipre a hozzáadáshoz, vagy ×-re az elvetéshez.",
"suggestionsCount_one": "{{count}} javaslat",
"suggestionsCount_other": "{{count}} javaslat",
"acceptAllHigh_one": "{{count}} biztos elfogadása",
"acceptAllHigh_other": "{{count}} biztos elfogadása",
"acceptAllTitle": "Javasolt címkék elfogadása",
"acceptAllConfirm_one": "Hozzáadod a(z) {{count}} nagy magabiztosságú javasolt címkét a csatornájához?",
"acceptAllConfirm_other": "Hozzáadod a(z) {{count}} nagy magabiztosságú javasolt címkét a csatornáikhoz?",
"acceptAllConfirmCta": "Mind elfogadása"
}, },
"loading": "Csatornák betöltése…", "loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.", "empty": "Nincsenek csatornák.",
+1
View File
@@ -3,6 +3,7 @@
"label": "Szűrve", "label": "Szűrve",
"remove": "Szűrő eltávolítása: {{name}}", "remove": "Szűrő eltávolítása: {{name}}",
"contentNone": "Nincs tartalomtípus", "contentNone": "Nincs tartalomtípus",
"untagged": "Címkézetlen",
"count_one": "{{count}} szűrő", "count_one": "{{count}} szűrő",
"count_other": "{{count}} szűrő", "count_other": "{{count}} szűrő",
"moreCount_one": "még {{count}} szűrő", "moreCount_one": "még {{count}} szűrő",
+2
View File
@@ -45,6 +45,7 @@
}, },
"sort": { "sort": {
"added": "Nemrég hozzáadott", "added": "Nemrég hozzáadott",
"lastwatched": "Utoljára nézve",
"title": "Cím", "title": "Cím",
"year": "Év", "year": "Év",
"rating": "IMDb pont", "rating": "IMDb pont",
@@ -153,6 +154,7 @@
}, },
"player": { "player": {
"loading": "Betöltés…", "loading": "Betöltés…",
"buffering": "Pufferelés…",
"back": "Vissza", "back": "Vissza",
"skipIntro": "Intro átugrása", "skipIntro": "Intro átugrása",
"skipCredits": "Stáblista átugrása", "skipCredits": "Stáblista átugrása",
+11 -1
View File
@@ -27,6 +27,7 @@
"clearDates": "dátumok törlése", "clearDates": "dátumok törlése",
"reshuffle": "Újrakeverés", "reshuffle": "Újrakeverés",
"noMatchingTags": "Nincs ide illő címke", "noMatchingTags": "Nincs ide illő címke",
"noTagOption": "Nincs címke",
"manageTags": "Kezelés", "manageTags": "Kezelés",
"widget": { "widget": {
"savedviews": "Mentett nézetek", "savedviews": "Mentett nézetek",
@@ -37,7 +38,16 @@
"language": "Nyelv", "language": "Nyelv",
"topic": "Téma", "topic": "Téma",
"tags": "Saját címkék", "tags": "Saját címkék",
"channel": "Csatorna" "channel": "Csatorna",
"year": "Év"
},
"year": {
"more": "Több…",
"moreLabel": "Összes év",
"none": "Még nincs év",
"sortBy": "Rendezés",
"sortYear": "Év",
"sortCount": "Darab"
}, },
"show": { "show": {
"unwatched": "Nem nézett", "unwatched": "Nem nézett",
+3
View File
@@ -19,6 +19,9 @@
"capturedFilters": "Rögzített szűrők", "capturedFilters": "Rögzített szűrők",
"capturedNone": "Nincs szűrő — minden videóra illeszkedik.", "capturedNone": "Nincs szűrő — minden videóra illeszkedik.",
"default": "Alapértelmezett", "default": "Alapértelmezett",
"saveCurrent": "Aktuális mentése",
"saveCurrentHint": "Felülírja ezt a nézetet a jelenleg alkalmazott szűrőkkel",
"saveCurrentUpToDate": "Ez a nézet már megegyezik a jelenlegi szűrőkkel",
"selectPrompt": "Válassz egy nézetet a megtekintéshez és szerkesztéshez.", "selectPrompt": "Válassz egy nézetet a megtekintéshez és szerkesztéshez.",
"modified": "Módosítva — a jelenlegi szűrők eltérnek a mentett nézettől." "modified": "Módosítva — a jelenlegi szűrők eltérnek a mentett nézettől."
} }
+9 -1
View File
@@ -32,7 +32,13 @@ export type * from "./api/setup";
import { feedApi } from "./api/feed"; import { feedApi } from "./api/feed";
export type * from "./api/feed"; export type * from "./api/feed";
export type * from "./api/tagModes"; export type * from "./api/tagModes";
export { DEFAULT_TAG_MODES, TAG_CATEGORIES, coerceTagModes } from "./api/tagModes"; export {
DEFAULT_TAG_MODES,
TAG_CATEGORIES,
NO_TAG_SENTINEL,
coerceTagModes,
coerceUntagged,
} from "./api/tagModes";
import { channelsApi } from "./api/channels"; import { channelsApi } from "./api/channels";
export type * from "./api/channels"; export type * from "./api/channels";
import { syncApi } from "./api/sync"; import { syncApi } from "./api/sync";
@@ -56,6 +62,8 @@ export {
plexFilterCount, plexFilterCount,
coercePlexFilters, coercePlexFilters,
plexFiltersSignature, plexFiltersSignature,
plexFiltersToParams,
parsePlexFilters,
isIntrinsicReadOnly, isIntrinsicReadOnly,
} from "./api/plex"; } from "./api/plex";
import { messagingApi } from "./api/messaging"; import { messagingApi } from "./api/messaging";
+1
View File
@@ -32,6 +32,7 @@ export interface ChannelDetail {
count_live: number; count_live: number;
deep_requested: boolean; deep_requested: boolean;
priority: number; priority: number;
hidden: boolean;
backfill_done: boolean; backfill_done: boolean;
from_explore: boolean; from_explore: boolean;
details_synced: boolean; details_synced: boolean;
+28 -3
View File
@@ -71,6 +71,9 @@ export interface FeedFilters {
// Library (scope "all") only — provenance filter: "organic" (default) hides search- // Library (scope "all") only — provenance filter: "organic" (default) hides search-
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my". // discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
librarySource?: "organic" | "all" | "search" | "plex"; librarySource?: "organic" | "all" | "search" | "plex";
/** "No tag" filter (item B): keep only channels the user has NOT personally tagged in the channel
* manager (no user-owned ChannelTag). Auto/system tags don't count. */
untagged?: boolean;
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */ /** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
channelIds: string[]; channelIds: string[];
maxAgeDays?: number; maxAgeDays?: number;
@@ -78,6 +81,16 @@ export interface FeedFilters {
maxDuration?: number; maxDuration?: number;
dateFrom?: string; dateFrom?: string;
dateTo?: string; dateTo?: string;
/** Filter-by-year (item 8): a single calendar year, expressed on the wire as a whole-year
* published_after/before range. Mutually exclusive with maxAgeDays/dateFrom/dateTo (all
* publish-date narrowings) — the setters clear the others, and `filterParams` lets year win. */
year?: number;
}
/** One year's video count for the year control's chips + "more…" dialog (item 8 / E8·S2). */
export interface YearCount {
year: number;
count: number;
} }
// The filter half of the query (everything except paging/sort), shared by the feed and // The filter half of the query (everything except paging/sort), shared by the feed and
@@ -89,6 +102,7 @@ function filterParams(f: FeedFilters): URLSearchParams {
for (const cat of TAG_CATEGORIES) { for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and"); if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
} }
if (f.untagged) p.set("untagged", "true");
if (f.q) p.set("q", f.q); if (f.q) p.set("q", f.q);
p.set("scope", f.scope); p.set("scope", f.scope);
p.set("show_normal", String(f.includeNormal)); p.set("show_normal", String(f.includeNormal));
@@ -96,9 +110,16 @@ function filterParams(f: FeedFilters): URLSearchParams {
p.set("include_live", String(f.includeLive)); p.set("include_live", String(f.includeLive));
p.set("show", f.show); p.set("show", f.show);
f.channelIds.forEach((id) => p.append("channel_ids", id)); f.channelIds.forEach((id) => p.append("channel_ids", id));
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays)); // Year (item 8) is a whole-year publish-date range and wins over the maxAge/custom-range controls
if (f.dateFrom) p.set("published_after", f.dateFrom); // (they're mutually exclusive publish-date narrowings — the setters enforce it, this guards the wire).
if (f.dateTo) p.set("published_before", f.dateTo); if (f.year) {
p.set("published_after", `${f.year}-01-01`);
p.set("published_before", `${f.year}-12-31`);
} else {
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
if (f.dateFrom) p.set("published_after", f.dateFrom);
if (f.dateTo) p.set("published_before", f.dateTo);
}
if (f.minDuration != null) p.set("min_duration", String(f.minDuration)); if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration)); if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
// Only relevant in Library scope; the backend ignores it for "my". Default = hide search. // Only relevant in Library scope; the backend ignores it for "my". Default = hide search.
@@ -144,6 +165,10 @@ export const feedApi = {
// the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13). // the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13).
feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record<string, number> }> => feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record<string, number> }> =>
req(`/api/feed/summary?${filterParams(f).toString()}`), req(`/api/feed/summary?${filterParams(f).toString()}`),
// Per-year video counts for the year control (item 8 / E8·S2). The backend drops the publish-date
// narrowing, so callers key this on the date-stripped filters (picking a year mustn't refetch it).
feedYears: (f: FeedFilters): Promise<{ years: YearCount[] }> =>
req(`/api/feed/years?${filterParams(f).toString()}`),
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these // idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
// recover from a transient gateway/keepalive 502 instead of surfacing it (see req()). // recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
setState: (id: string, status: string) => setState: (id: string, status: string) =>
+42
View File
@@ -334,6 +334,48 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.collections?.length) u.set("collections", f.collections.join(",")); if (f.collections?.length) u.set("collections", f.collections.join(","));
if (f.collectionMode === "all") u.set("collection_mode", "all"); if (f.collectionMode === "all") u.set("collection_mode", "all");
} }
// Public wrapper over the private appendPlexFilters so the URL mirror (plexUrlState) serializes the
// filter set through the SAME single source the API request uses — the two can never drift. Display-only
// titles (collectionTitles) stay out, exactly like the wire params and the saved-view signature.
export function plexFiltersToParams(f: PlexFilters): URLSearchParams {
const u = new URLSearchParams();
appendPlexFilters(u, f);
return u;
}
// The inverse of appendPlexFilters: parse a filter sub-querystring back into a full PlexFilters (missing
// keys → EMPTY defaults). Kept next to its encoder so a new wire key can't be added on one side only.
export function parsePlexFilters(p: URLSearchParams): PlexFilters {
const csv = (k: string): string[] => {
const v = p.get(k);
return v ? v.split(",").filter(Boolean) : [];
};
const num = (k: string): number | undefined => {
if (!p.has(k)) return undefined;
const n = Number(p.get(k));
return Number.isFinite(n) ? n : undefined;
};
const f: PlexFilters = { ...EMPTY_PLEX_FILTERS };
f.genres = csv("genres");
if (p.get("genre_mode") === "all") f.genreMode = "all";
f.contentRatings = csv("content_ratings");
f.yearMin = num("year_min");
f.yearMax = num("year_max");
f.ratingMin = num("rating_min");
f.durationMin = num("duration_min");
f.durationMax = num("duration_max");
const aw = p.get("added_within");
if (aw) f.addedWithin = aw;
f.directors = csv("directors");
f.actors = csv("actors");
if (p.get("actor_mode") === "all") f.actorMode = "all";
f.studios = csv("studios");
f.keywords = csv("keywords");
f.collections = csv("collections");
if (p.get("collection_mode") === "all") f.collectionMode = "all";
return f;
}
export interface PlexFacets { export interface PlexFacets {
genres: { value: string; count: number }[]; genres: { value: string; count: number }[];
content_ratings: { value: string; count: number }[]; content_ratings: { value: string; count: number }[];
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { coerceTagModes, DEFAULT_TAG_MODES } from "./tagModes"; import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES } from "./tagModes";
// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL // coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL
// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`. // parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`.
@@ -40,3 +40,15 @@ describe("coerceTagModes", () => {
expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or"); expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or");
}); });
}); });
// coerceUntagged guards the "No tag" (item B) flag on the same persisted/URL/saved-view paths.
describe("coerceUntagged", () => {
it("is true only for an explicit untagged:true", () => {
expect(coerceUntagged({ untagged: true })).toBe(true);
expect(coerceUntagged(undefined)).toBe(false);
expect(coerceUntagged({})).toBe(false);
expect(coerceUntagged(null)).toBe(false);
expect(coerceUntagged({ untagged: "true" })).toBe(false);
expect(coerceUntagged({ untagged: ["other"] })).toBe(false);
});
});
+10
View File
@@ -10,6 +10,11 @@ export type TagModes = Record<TagCategory, TagMode>;
export const TAG_CATEGORIES: readonly TagCategory[] = ["language", "topic", "other"]; export const TAG_CATEGORIES: readonly TagCategory[] = ["language", "topic", "other"];
export const DEFAULT_TAG_MODES: TagModes = { language: "or", topic: "or", other: "or" }; export const DEFAULT_TAG_MODES: TagModes = { language: "or", topic: "or", other: "or" };
/** UI sentinel for the "No tag" (item B) option — the feed's user-tags combobox and the channel
* manager's tag chip row both use it to mean "channels I haven't personally tagged". Not a real tag
* id (those are numeric strings), so it can never collide with one. */
export const NO_TAG_SENTINEL = "__none__";
/** Normalise a persisted/shared filter's tag match into the per-category shape, migrating the legacy /** Normalise a persisted/shared filter's tag match into the per-category shape, migrating the legacy
* global `tagMode` (which only ever affected topics) into the topic slot. */ * global `tagMode` (which only ever affected topics) into the topic slot. */
export function coerceTagModes(raw: unknown): TagModes { export function coerceTagModes(raw: unknown): TagModes {
@@ -22,3 +27,8 @@ export function coerceTagModes(raw: unknown): TagModes {
}; };
return { language: pick("language"), topic: pick("topic"), other: pick("other") }; return { language: pick("language"), topic: pick("topic"), other: pick("other") };
} }
/** Normalise a persisted/shared filter's "No tag" (item B) flag to a boolean. */
export function coerceUntagged(raw: unknown): boolean {
return (raw as { untagged?: unknown } | null)?.untagged === true;
}
+9
View File
@@ -10,8 +10,17 @@ export interface Tag {
channel_count: number; channel_count: number;
} }
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern (E8·S4).
export interface TagSuggestion {
tag_id: number;
confidence: number;
}
// Keyed by channel id; channels with no suggestion are omitted by the server.
export type TagSuggestions = Record<string, TagSuggestion[]>;
export const tagsApi = { export const tagsApi = {
tags: (): Promise<Tag[]> => req("/api/tags"), tags: (): Promise<Tag[]> => req("/api/tags"),
tagSuggestions: (): Promise<TagSuggestions> => req("/api/tags/suggestions"),
createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> => createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> =>
req("/api/tags", { method: "POST", body: JSON.stringify(t) }), req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
updateTag: (id: number, patch: { name?: string; color?: string }) => updateTag: (id: number, patch: { name?: string; color?: string }) =>
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
//
// S8 — the Channels-manager content state (active tab + column sort + the column-filter map) round-trips
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the S4/plexUrlState
// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
import { describe, expect, it } from "vitest";
import {
channelsParamsToState,
channelsParamUpdates,
hasChannelsParams,
type ChannelsUrlState,
} from "./channelsUrlState";
import type { FilterMap } from "../components/tableHeader";
// A concrete non-default filter map exercising both wire shapes: scalars (the sync chip, a select) and
// an array (a multi column).
const RICH_FILTERS: FilterMap = {
sync: "needsFull",
priority: "3",
tags: ["12", "34"],
};
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {} };
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(updates)) {
if (v != null) p.set(k, v);
}
return p;
}
describe("channelsParamUpdates", () => {
it("emits nothing for the all-default state", () => {
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null });
});
it("clears every key when passed null (off-manager page)", () => {
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null });
});
it("emits only non-default keys", () => {
const u = channelsParamUpdates({
view: "hidden",
sort: { key: "subscribers", dir: "desc" },
filters: { sync: "fully" },
});
expect(u.ctab).toBe("hidden");
expect(u.csort).toBe("subscribers:desc");
expect(u.cf).toBe(JSON.stringify({ sync: "fully" }));
});
it("drops empty filter entries so a cleared funnel emits no cf", () => {
expect(
channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf,
).toBeNull();
});
});
describe("round-trip", () => {
it("restores a rich state (scalar + array filters) from the emitted URL", () => {
const state: ChannelsUrlState = {
view: "blocked",
sort: { key: "priority", dir: "asc" },
filters: RICH_FILTERS,
};
const params = applyUpdates(channelsParamUpdates(state));
const back = channelsParamsToState(params, BASE);
expect(back.view).toBe("blocked");
expect(back.sort).toEqual({ key: "priority", dir: "asc" });
expect(back.filters).toEqual(RICH_FILTERS);
// the array shape survives the JSON round-trip, not collapsed to a scalar
expect(Array.isArray(back.filters.tags)).toBe(true);
});
it("keeps base values for absent keys (partial link)", () => {
const params = new URLSearchParams("csort=title:asc");
const back = channelsParamsToState(params, {
view: "discovery",
sort: null,
filters: { sync: "needsFull" },
});
expect(back.view).toBe("discovery"); // from base, no ctab
expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf
expect(back.sort).toEqual({ key: "title", dir: "asc" });
});
it("ignores an out-of-vocabulary tab / bad sort / corrupt cf and falls back to base", () => {
const params = new URLSearchParams("ctab=bogus&csort=key:sideways&cf=notjson");
const back = channelsParamsToState(params, { view: "subscribed", sort: null, filters: {} });
expect(back.view).toBe("subscribed"); // bogus tab → base
expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null)
expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty
});
});
describe("hasChannelsParams", () => {
it("detects any namespaced key", () => {
expect(hasChannelsParams(new URLSearchParams("ctab=hidden"))).toBe(true);
expect(hasChannelsParams(new URLSearchParams("cf=%7B%22sync%22%3A%22fully%22%7D"))).toBe(true);
});
it("ignores the feed's and plex's keys", () => {
expect(hasChannelsParams(new URLSearchParams("scope=all&pscope=movie&sort=oldest"))).toBe(
false,
);
});
});
+88
View File
@@ -0,0 +1,88 @@
// Serialize the Channels-manager content state (active tab + column sort + the column-filter map) to/
// from the address bar so a manager view is bookmarkable/shareable — the S8 analogue of the feed's
// urlState and Plex's plexUrlState (S4). Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex
// rail search) and stays OUT of the shared contract.
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
import { type FilterMap } from "../components/tableHeader";
// The content state S8 mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
// raw provider SortState (healed against live columns only at the point of use), so the user's real
// preference round-trips through the URL.
export interface ChannelsUrlState {
view: string;
sort: SortState;
filters: FilterMap;
}
const KEYS = ["ctab", "csort", "cf"] as const;
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default
// sort here (fallback null), so any sort emits — no default short-circuit needed.
// The FilterMap mixes scalar column filters (priority=select, name=text, the sync chip) with array ones
// (tags=multi), so it's carried as a compact JSON blob rather than a nested query string — JSON round-
// trips both shapes exactly (and future multi columns for free), and the values are opaque enum/id
// tokens where a readable querystring buys little. Empty entries are dropped so a cleared funnel emits
// no `cf`.
function encodeFilters(map: FilterMap): string | null {
const clean: FilterMap = {};
for (const [k, v] of Object.entries(map)) {
if (Array.isArray(v) ? v.length : v) clean[k] = v;
}
return Object.keys(clean).length ? JSON.stringify(clean) : null;
}
// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else
// heals to empty (same discipline as useFilterState's storage hydrate).
function decodeFilters(raw: string): FilterMap {
try {
const v = JSON.parse(raw);
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
const out: FilterMap = {};
for (const [k, val] of Object.entries(v)) {
if (typeof val === "string") out[k] = val;
else if (Array.isArray(val) && val.every((x) => typeof x === "string"))
out[k] = val as string[];
}
return out;
} catch {
return {};
}
}
/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted
* value, or null to delete it (so a control back at its default clears the key). Pass `null` to clear
* ALL manager keys — used when the app is off the Channels page (localStorage still holds the state; it
* re-mirrors on return). Feed the result into urlWithParams(). */
export function channelsParamUpdates(
state: ChannelsUrlState | null,
): Record<string, string | null> {
if (!state) return { ctab: null, csort: null, cf: null };
return {
ctab: state.view && state.view !== "subscribed" ? state.view : null,
csort: encodeSortParam(state.sort),
cf: encodeFilters(state.filters),
};
}
/** True if the query string carries any manager content key we recognize (a shared manager link). */
export function hasChannelsParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k));
}
/** Merge the manager content keys from the URL over a base state (the localStorage-seeded values); an
* absent or invalid key keeps the base value, so a partial link (only `?ctab=`) is honoured. */
export function channelsParamsToState(
params: URLSearchParams,
base: ChannelsUrlState,
): ChannelsUrlState {
const tab = params.get("ctab");
const csort = params.get("csort");
return {
view: tab && VIEWS.includes(tab) ? tab : base.view,
sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort,
filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters,
};
}
+16
View File
@@ -34,6 +34,22 @@ export function parseSortState(raw: unknown): SortState {
return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null; return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null;
} }
/** Encode a SortState to the compact `key:dir` wire form the shareable-URL modules (plex/channels)
* put in the address bar. Sort keys are simple words (no colon), so the plain join round-trips. Returns
* null for no-sort. A module with a default sort short-circuits that to null at its own call site. */
export function encodeSortParam(s: SortState): string | null {
return s ? `${s.key}:${s.dir}` : null;
}
/** Inverse of `encodeSortParam`: parse `key:dir` (or a bare key → asc) from the URL back into a
* SortState, healing anything malformed to null. One codec for both URL-state modules — no drift. */
export function decodeSortParam(raw: string): SortState {
const i = raw.indexOf(":");
const key = i === -1 ? raw : raw.slice(0, i);
const dir = i === -1 ? "asc" : raw.slice(i + 1);
return parseSortState({ key, dir });
}
/** The header-click cycle: unsorted → ascending → descending → unsorted. */ /** The header-click cycle: unsorted → ascending → descending → unsorted. */
export function cycleSort(current: SortState, key: string): SortState { export function cycleSort(current: SortState, key: string): SortState {
if (!current || current.key !== key) return { key, dir: "asc" }; if (!current || current.key !== key) return { key, dir: "asc" };
+7
View File
@@ -87,4 +87,11 @@ describe("selectDateChip", () => {
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] })); const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 }); expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
}); });
it("clears a selected year on any date pick (they're mutually exclusive)", () => {
// preset, custom, and 'any' all drop the year — it's the same publish-date narrowing.
expect(selectDateChip("30", base({ year: 2024 })).filters.year).toBeUndefined();
expect(selectDateChip("custom", base({ year: 2024 })).filters.year).toBeUndefined();
expect(selectDateChip("any", base({ year: 2024 })).filters.year).toBeUndefined();
});
}); });
+17 -3
View File
@@ -32,18 +32,32 @@ export function selectDateChip(
key: string, key: string,
filters: FeedFilters, filters: FeedFilters,
): { filters: FeedFilters; customOpen: boolean } { ): { filters: FeedFilters; customOpen: boolean } {
// A date pick and the year control are mutually exclusive publish-date narrowings, so any date
// selection clears the year (and vice versa, in YearFilter).
if (key === "custom") { if (key === "custom") {
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true }; return { filters: { ...filters, maxAgeDays: undefined, year: undefined }, customOpen: true };
} }
if (key === "any" || key === "") { if (key === "any" || key === "") {
return { return {
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined }, filters: {
...filters,
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
year: undefined,
},
customOpen: false, customOpen: false,
}; };
} }
const days = Number(key); const days = Number(key);
return { return {
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined }, filters: {
...filters,
maxAgeDays: days,
dateFrom: undefined,
dateTo: undefined,
year: undefined,
},
customOpen: false, customOpen: false,
}; };
} }
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
//
// Integration guard for the UX-8 single-Escape fix — the sibling of layerStack.dom.test.ts, but wired
// through the REAL `useLayer` hook and a REAL dispatched keydown instead of hand-rolled listeners. It
// mounts the two production listener shapes as React components:
// - the POPOVER exactly as AnchoredPopover uses it: `useLayer(open, onEscape)` installs the real
// `document` keydown listener that stamps the event and closes on topmost;
// - the PLAYER exactly as PlayerModal reads it: a `window` keydown listener that bails on
// `escapeHandled(e)` first, then on `anyAbove()`.
// The load-bearing subtlety the primitives test can only fake: the popover's close is wrapped in
// `flushSync`, so React tears the popover down (and `useLayer` cleanup pops its layer) SYNCHRONOUSLY
// between the `document` and `window` listeners — the real mid-dispatch flush. That makes the stack read
// "nothing above the player" by the time the player's listener runs, so ONLY the event stamp can stop
// the player from also closing. If the stamp regressed, test 1 would see the player close on the first
// Escape. floating-ui isn't involved here (we drive `useLayer` directly), so no observer stubs needed.
import { describe, it, expect, vi, afterEach } from "vitest";
import { useEffect, useState } from "react";
import { flushSync } from "react-dom";
import { render, act, cleanup } from "@testing-library/react";
import { useLayer, escapeHandled } from "./layerStack";
afterEach(cleanup);
// The player: registers a layer (no onEscape — it owns a richer handler) and a `window` listener that
// mirrors PlayerModal's guard order (consumed-stamp first, then anyAbove).
function Player({ onClose }: { onClose: () => void }) {
const { anyAbove } = useLayer(true);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (escapeHandled(e)) return; // the UX-8 fix
if (anyAbove()) return; // the old, race-prone guard
onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [anyAbove, onClose]);
return <div data-testid="player" />;
}
// The popover: the real AnchoredPopover usage. flushSync makes the unmount (and its layer removal) run
// synchronously inside the `document` listener — the production mid-dispatch flush the fix must survive.
function Popover({ onClosed }: { onClosed: () => void }) {
const [open, setOpen] = useState(true);
useLayer(open, () => {
flushSync(() => setOpen(false));
onClosed();
});
return open ? <div data-testid="popover" /> : null;
}
function dispatchEscape(): KeyboardEvent {
const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
act(() => {
document.dispatchEvent(e);
});
return e;
}
describe("layerStack Escape coordination — live useLayer + real dispatch (UX-8)", () => {
it("one Escape closes only the topmost popover; a second closes the player", () => {
const playerClose = vi.fn();
const popoverClosed = vi.fn();
const { queryByTestId } = render(
<>
<Player onClose={playerClose} />
<Popover onClosed={popoverClosed} />
</>,
);
// Player registers first, popover second → popover is topmost.
expect(queryByTestId("popover")).not.toBeNull();
// First Escape: popover acts (stamps + flushSync-closes, removing its layer synchronously), player
// defers on the stamp even though the stack now shows nothing above it.
const first = dispatchEscape();
expect(escapeHandled(first)).toBe(true);
expect(popoverClosed).toHaveBeenCalledOnce();
expect(queryByTestId("popover")).toBeNull();
expect(playerClose).not.toHaveBeenCalled();
// Second Escape: nothing above the player, unstamped → the player closes.
const second = dispatchEscape();
expect(escapeHandled(second)).toBe(false);
expect(playerClose).toHaveBeenCalledOnce();
});
it("with no layer above, a lone player closes on the first Escape", () => {
const playerClose = vi.fn();
render(<Player onClose={playerClose} />);
dispatchEscape();
expect(playerClose).toHaveBeenCalledOnce();
});
});
+1 -1
View File
@@ -16,7 +16,7 @@ export interface PanelLayout {
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are // Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
// appended by normalizeLayout, so nothing silently disappears. // appended by normalizeLayout, so nothing silently disappears.
const PANEL_ORDER: Record<PanelId, string[]> = { const PANEL_ORDER: Record<PanelId, string[]> = {
feed: ["date", "channel", "tags", "language", "topic"], feed: ["date", "year", "channel", "tags", "language", "topic"],
plex: [ plex: [
"playlists", "playlists",
"watch", "watch",
+12
View File
@@ -102,6 +102,16 @@ export function usePlexProgress(opts: {
const onPauseFlush = () => flush(true); const onPauseFlush = () => flush(true);
const video = videoRef.current; const video = videoRef.current;
video?.addEventListener("pause", onPauseFlush); video?.addEventListener("pause", onPauseFlush);
// A seek is a settled position too (S2: Plex is the source of truth, so it must see the new spot
// right away, not up to 10s later). Debounced so scrubbing — many `seeked` events in a row — pushes
// just ONCE, once the user lands. `final=true` bypasses the backend's ≥10s push-throttle, which is
// exactly why the debounce lives here: it bounds a scrub to one immediate Plex timeline call.
let seekTimer: number | undefined;
const onSeekFlush = () => {
window.clearTimeout(seekTimer);
seekTimer = window.setTimeout(() => flush(true), 800);
};
video?.addEventListener("seeked", onSeekFlush);
// Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload, // Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a // so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
// keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving). // keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
@@ -112,8 +122,10 @@ export function usePlexProgress(opts: {
window.addEventListener("pagehide", onHide); window.addEventListener("pagehide", onHide);
return () => { return () => {
window.clearInterval(iv); window.clearInterval(iv);
window.clearTimeout(seekTimer);
window.removeEventListener("pagehide", onHide); window.removeEventListener("pagehide", onHide);
video?.removeEventListener("pause", onPauseFlush); video?.removeEventListener("pause", onPauseFlush);
video?.removeEventListener("seeked", onSeekFlush);
flush(true); flush(true);
// Back (unmount) persists the position server-side via flush(), but reopening the same item in // Back (unmount) persists the position server-side via flush(), but reopening the same item in
// the SAME session read react-query's CACHED detail — whose position_seconds predated this // the SAME session read react-query's CACHED detail — whose position_seconds predated this
+10 -2
View File
@@ -4,8 +4,16 @@ import { healSort, type SortOption, type SortState } from "./columnSort";
// SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire // SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire
// call (`plexLibrary`) still splits it into `sort` + `sort_dir` at the server boundary. // call (`plexLibrary`) still splits it into `sort` + `sort_dir` at the server boundary.
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const; const MOVIE_SORTS = [
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const; "added",
"lastwatched",
"release",
"year",
"rating",
"duration",
"title",
] as const;
const SHOW_SORTS = ["added", "lastwatched", "release", "year", "rating", "title"] as const;
/** The sort keys a scope offers — movie libraries add `duration`; shows/both don't. */ /** The sort keys a scope offers — movie libraries add `duration`; shows/both don't. */
function plexSortKeys(scope: string): string[] { function plexSortKeys(scope: string): string[] {
+130
View File
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "./api";
import { PLEX_SORT_FALLBACK } from "./plexSort";
import {
hasPlexParams,
plexParamsToState,
plexParamUpdates,
type PlexUrlState,
} from "./plexUrlState";
// A concrete non-default filter set exercising every wire shape (csv arrays, scalars, modes).
const RICH_FILTERS: PlexFilters = {
genres: ["Drama", "Comedy"],
genreMode: "all",
contentRatings: ["PG-13"],
yearMin: 2000,
yearMax: 2020,
ratingMin: 7,
durationMin: 600,
durationMax: 7200,
addedWithin: "1m",
directors: ["Nolan"],
actors: ["Bale", "Caine"],
actorMode: "all",
studios: ["Warner"],
keywords: ["heist"],
collections: ["1234"],
collectionMode: "all",
collectionTitles: { "1234": "Marvel" },
};
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(updates)) {
if (v != null) p.set(k, v);
}
return p;
}
const BASE: PlexUrlState = {
scope: "both",
sort: PLEX_SORT_FALLBACK,
show: "all",
filters: EMPTY_PLEX_FILTERS,
};
describe("plexParamUpdates", () => {
it("emits nothing for the all-default state", () => {
expect(plexParamUpdates(BASE)).toEqual({
pscope: null,
psort: null,
pshow: null,
pf: null,
});
});
it("clears every key when passed null (off-plex page)", () => {
expect(plexParamUpdates(null)).toEqual({
pscope: null,
psort: null,
pshow: null,
pf: null,
});
});
it("emits only non-default keys", () => {
const u = plexParamUpdates({
scope: "movie",
sort: { key: "year", dir: "desc" },
show: "in_progress",
filters: EMPTY_PLEX_FILTERS,
});
expect(u.pscope).toBe("movie");
expect(u.psort).toBe("year:desc");
expect(u.pshow).toBe("in_progress");
expect(u.pf).toBeNull();
});
});
describe("round-trip", () => {
it("restores a rich state from the emitted URL", () => {
const state: PlexUrlState = {
scope: "show",
sort: { key: "rating", dir: "desc" },
show: "watched",
filters: RICH_FILTERS,
};
const params = applyUpdates(plexParamUpdates(state));
const back = plexParamsToState(params, BASE);
expect(back.scope).toBe("show");
expect(back.sort).toEqual({ key: "rating", dir: "desc" });
expect(back.show).toBe("watched");
// collectionTitles is display-only (excluded from the wire), so compare the rest.
const { collectionTitles: _drop, ...wire } = RICH_FILTERS;
expect(back.filters).toEqual(wire);
});
it("keeps base values for absent keys (partial link)", () => {
const params = new URLSearchParams("psort=year:asc");
const back = plexParamsToState(params, {
scope: "movie",
sort: PLEX_SORT_FALLBACK,
show: "unwatched",
filters: EMPTY_PLEX_FILTERS,
});
expect(back.scope).toBe("movie"); // from base, no pscope
expect(back.show).toBe("unwatched"); // from base, no pshow
expect(back.sort).toEqual({ key: "year", dir: "asc" });
});
it("ignores an out-of-vocabulary scope/show/sort and falls back to base", () => {
const params = new URLSearchParams("pscope=bogus&pshow=nope&psort=key:sideways");
const back = plexParamsToState(params, BASE);
expect(back.scope).toBe("both");
expect(back.show).toBe("all");
expect(back.sort).toEqual(PLEX_SORT_FALLBACK); // bad dir → parseSortState null → base
});
});
describe("hasPlexParams", () => {
it("detects any namespaced key", () => {
expect(hasPlexParams(new URLSearchParams("pf=genres%3DDrama"))).toBe(true);
expect(hasPlexParams(new URLSearchParams("pscope=movie"))).toBe(true);
});
it("ignores the feed's bare keys", () => {
expect(hasPlexParams(new URLSearchParams("scope=all&sort=oldest&show=watched"))).toBe(false);
});
});
+67
View File
@@ -0,0 +1,67 @@
// Serialize the Plex-browse content state (library scope + watch-state + sort + metadata filters) to/
// from the address bar so a Plex-browse view is bookmarkable/shareable — the S4 analogue of the feed's
// urlState. Keys are NAMESPACED (`pscope`/`psort`/`pshow`/`pf`) rather than reusing the feed's bare
// `scope`/`sort`/`show`: FeedFiltersProvider actively STRIPS its own KEYS from the URL whenever the app
// is off the feed page, so a shared key would be deleted out from under the Plex page. The namespaced
// keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Only non-
// default values are emitted, so URLs stay clean.
import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api";
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
import { PLEX_SORT_FALLBACK } from "./plexSort";
// The content state S4 mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of
// use), so the user's real preference round-trips through the URL.
export interface PlexUrlState {
scope: string;
sort: SortState;
show: string;
filters: PlexFilters;
}
const KEYS = ["pscope", "psort", "pshow", "pf"] as const;
const SCOPES = ["movie", "show", "both"];
const SHOWS = ["all", "unwatched", "in_progress", "watched"];
// Sort ⇄ `key:dir` (e.g. `year:desc`) via the shared codec in columnSort; the Plex default (`title:asc`)
// emits nothing.
function encodeSort(s: SortState): string | null {
if (!s || (s.key === PLEX_SORT_FALLBACK?.key && s.dir === PLEX_SORT_FALLBACK?.dir)) return null;
return encodeSortParam(s);
}
/** The address-bar delta for the Plex content state: each namespaced key mapped to its emitted value,
* or null to delete it (so a control back at its default clears the key). Pass `null` to clear ALL Plex
* keys — used when the app is off the Plex page (localStorage still holds the state; it re-mirrors on
* return). Feed the result into urlWithParams(). */
export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> {
if (!state) return { pscope: null, psort: null, pshow: null, pf: null };
const pf = plexFiltersToParams(state.filters).toString();
return {
pscope: state.scope && state.scope !== "both" ? state.scope : null,
psort: encodeSort(state.sort),
pshow: state.show && state.show !== "all" ? state.show : null,
pf: pf || null,
};
}
/** True if the query string carries any Plex content key we recognize (a shared Plex-browse link). */
export function hasPlexParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k));
}
/** Merge the Plex content keys from the URL over a base state (the localStorage-seeded defaults); an
* absent or invalid key keeps the base value, so a partial link (only `?psort=`) is honoured. */
export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState {
const scope = params.get("pscope");
const show = params.get("pshow");
const psort = params.get("psort");
const sort = psort != null ? (decodeSortParam(psort) ?? base.sort) : base.sort;
return {
scope: scope && SCOPES.includes(scope) ? scope : base.scope,
sort,
show: show && SHOWS.includes(show) ? show : base.show,
filters: params.has("pf")
? parsePlexFilters(new URLSearchParams(params.get("pf") ?? ""))
: base.filters,
};
}
+5
View File
@@ -14,6 +14,9 @@ export const qk = {
me: () => ["me"] as const, me: () => ["me"] as const,
myStatus: () => ["my-status"] as const, myStatus: () => ["my-status"] as const,
tags: () => ["tags"] as const, tags: () => ["tags"] as const,
// Per-channel suggested user tags, learned from the manual-tag pattern (E8·S4). Separate root so
// accepting a suggestion can invalidate it without touching the whole tag catalog.
tagSuggestions: () => ["tag-suggestions"] as const,
// --- feed / video --- // --- feed / video ---
// Every parameterized key guards on `!== undefined` (not truthiness), so "no arg → bare prefix // Every parameterized key guards on `!== undefined` (not truthiness), so "no arg → bare prefix
@@ -29,6 +32,8 @@ export const qk = {
filters !== undefined ? (["facets", filters] as const) : (["facets"] as const), filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
feedSummary: (filters?: FeedFilters) => feedSummary: (filters?: FeedFilters) =>
filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const), filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const),
feedYears: (filters?: FeedFilters) =>
filters !== undefined ? (["feed-years", filters] as const) : (["feed-years"] as const),
ytSearch: (q?: string | null, count?: number) => ytSearch: (q?: string | null, count?: number) =>
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const), q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
videoDetail: (id: string) => ["video-detail", id] as const, videoDetail: (id: string) => ["video-detail", id] as const,
+35
View File
@@ -14,6 +14,41 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.74.0",
date: "2026-08-28",
summary:
"A grab-bag batch across the Plex player, Plex browsing, and the Channel Manager: smoother episode auto-advance, your watch progress kept in step with Plex, shareable browse links, and an editable Personal Views panel.",
features: [
"Share a Plex browse view — or a Channel Manager view — just by copying its link. The scope, filters, sort, and active tab all live in the address bar now, so the link opens (and reloads) to exactly what you were looking at.",
"New “Last watched” sort for Plex, applied automatically when you switch to the In-progress view so the thing you were most recently watching is right at the top — and your previous sort comes back when you leave.",
"Personal Views are editable in place: remove a captured filter as a chip (with undo), or save your currently-applied filters into an existing view — no need to delete and recreate it.",
"The channel detail page gained an Unhide button, and the tag “+” now sits at a fixed spot so it no longer drifts to the side as you add tags.",
],
fixes: [
"Fixed the Plex player briefly stalling a few seconds into each auto-advanced episode; a loading indicator now shows while the stream warms up or while you seek.",
"Your Plex watch progress now stays in step with Plex as the source of truth — resuming, finishing, or rewinding on one side carries across to the other far more promptly.",
"Redesigned the channel priority control into a cleaner −/+ stepper with click-to-type, on both the manager table and the channel page (no more “popping” wider when you edit it).",
],
chores: [
"The batch went through a multi-agent deep-review pass — findings fixed and re-reviewed until clean, including two data-integrity fixes to the new watch-sync and shareable-link work.",
],
},
{
version: "0.73.0",
date: "2026-08-26",
summary:
"Feed filtering and the Channel Manager catch up with Plex: filter to channels you haven't tagged, filter the feed by year, hover any card for its full details, and let Siftlode suggest your own tags for untagged channels based on how you've tagged similar ones.",
features: [
"New “No tag” filter — narrow the feed and the Channel Manager to channels you haven't personally tagged yet, so what still needs organising is easy to find.",
"Filter the feed by year: quick chips for recent years with per-year video counts, plus a “More…” dialog listing every year, sortable by year or count.",
"Hover (or keyboard-focus) any feed card for an info box with the full title, channel, date, duration, a description snippet, and the channel's tags — without opening the video.",
"The Channel Manager now suggests your own tags for untagged channels, learned from how you've tagged similar ones. Accept a suggestion with one tap, dismiss it, or accept all the confident ones at once.",
],
chores: [
"The whole epic went through several multi-agent deep-review passes — findings fixed and re-reviewed each round until it came back clean.",
],
},
{ {
version: "0.72.1", version: "0.72.1",
date: "2026-08-24", date: "2026-08-24",
+17
View File
@@ -140,4 +140,21 @@ describe("filterParamUpdates", () => {
const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS); const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS);
expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" }); expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
}); });
it("round-trips the year filter; emitted only when set", () => {
expect(filtersToParams({ ...DEFAULTS, year: 2024 }).get("year")).toBe("2024");
expect(filtersToParams({ ...DEFAULTS, year: undefined }).has("year")).toBe(false);
expect(paramsToFilters(new URLSearchParams("year=2024"), DEFAULTS).year).toBe(2024);
// Junk / absent → undefined (off), never a NaN year.
expect(paramsToFilters(new URLSearchParams("year=nope"), DEFAULTS).year).toBeUndefined();
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).year).toBeUndefined();
});
it("round-trips the 'No tag' (untagged) flag; emitted only when on", () => {
expect(filtersToParams({ ...DEFAULTS, untagged: true }).get("notag")).toBe("1");
expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false);
expect(paramsToFilters(new URLSearchParams("notag=1"), DEFAULTS).untagged).toBe(true);
// Absent → the base value is left untouched (undefined here, i.e. off).
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).untagged).toBeUndefined();
});
}); });
+6
View File
@@ -20,10 +20,12 @@ const KEYS = [
"tagmode_topic", "tagmode_topic",
"tagmode_other", "tagmode_other",
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve "tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
"notag",
"channel", "channel",
"maxage", "maxage",
"from", "from",
"to", "to",
"year",
"mindur", "mindur",
"maxdur", "maxdur",
] as const; ] as const;
@@ -44,10 +46,12 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
for (const cat of TAG_CATEGORIES) { for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and"); if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
} }
if (f.untagged) p.set("notag", "1");
if (f.channelIds.length) p.set("channel", f.channelIds.join(",")); if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays)); if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
if (f.dateFrom) p.set("from", f.dateFrom); if (f.dateFrom) p.set("from", f.dateFrom);
if (f.dateTo) p.set("to", f.dateTo); if (f.dateTo) p.set("to", f.dateTo);
if (f.year) p.set("year", String(f.year));
if (f.minDuration != null) p.set("mindur", String(f.minDuration)); if (f.minDuration != null) p.set("mindur", String(f.minDuration));
if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration)); if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration));
return p; return p;
@@ -95,10 +99,12 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or", topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
other: params.get("tagmode_other") === "and" ? "and" : "or", other: params.get("tagmode_other") === "and" ? "and" : "or",
}; };
if (params.has("notag")) f.untagged = params.get("notag") === "1";
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean); f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
f.maxAgeDays = num(params.get("maxage")); f.maxAgeDays = num(params.get("maxage"));
f.dateFrom = params.get("from") || undefined; f.dateFrom = params.get("from") || undefined;
f.dateTo = params.get("to") || undefined; f.dateTo = params.get("to") || undefined;
f.year = num(params.get("year"));
f.minDuration = num(params.get("mindur")); f.minDuration = num(params.get("mindur"));
f.maxDuration = num(params.get("maxdur")); f.maxDuration = num(params.get("maxdur"));
return f; return f;
+18
View File
@@ -15,6 +15,7 @@ export interface ActiveFilter {
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = { const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [], tags: [],
tagModes: DEFAULT_TAG_MODES, tagModes: DEFAULT_TAG_MODES,
untagged: false,
channelIds: [], channelIds: [],
sort: "newest", sort: "newest",
includeNormal: true, includeNormal: true,
@@ -66,6 +67,15 @@ export function useActiveFilters(
}); });
} }
// "No tag" (item B): channels the user hasn't personally tagged. One removable chip.
if (filters.untagged) {
out.push({
key: "untagged",
label: t("feed.chips.untagged"),
remove: () => setFilters({ ...filters, untagged: false }),
});
}
if (filters.show !== "unwatched") { if (filters.show !== "unwatched") {
out.push({ out.push({
key: "show", key: "show",
@@ -110,6 +120,14 @@ export function useActiveFilters(
}); });
} }
if (filters.year) {
out.push({
key: "year",
label: String(filters.year),
remove: () => setFilters({ ...filters, year: undefined }),
});
}
if (filters.maxAgeDays || filters.dateFrom || filters.dateTo) { if (filters.maxAgeDays || filters.dateFrom || filters.dateTo) {
const label = filters.maxAgeDays const label = filters.maxAgeDays
? t("feed.chips.maxAge", { count: filters.maxAgeDays }) ? t("feed.chips.maxAge", { count: filters.maxAgeDays })