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.
This commit is contained in:
@@ -450,7 +450,11 @@ def feed_years(db: Session, user: User, **params) -> list[dict]:
|
||||
**{**params, "published_after": None, "published_before": None, "max_age_days": None},
|
||||
)
|
||||
pub = base.with_only_columns(Video.published_at.label("pub")).subquery()
|
||||
year = func.extract("year", pub.c.pub)
|
||||
# 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()
|
||||
|
||||
@@ -51,17 +51,25 @@ def compute_tag_suggestions(db: Session, user: User) -> dict[str, list[dict]]:
|
||||
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 manages. A user can only tag a subscribed channel
|
||||
# (attach_tag requires a subscription), so example channels are a subset of this too — which
|
||||
# lets us bound the auto-tag scan to just these channels.
|
||||
subscribed: set[str] = set(
|
||||
db.scalars(select(Subscription.channel_id).where(Subscription.user_id == user.id)).all()
|
||||
)
|
||||
# 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").
|
||||
# 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(
|
||||
@@ -69,6 +77,8 @@ def compute_tag_suggestions(db: Session, user: User) -> dict[str, list[dict]]:
|
||||
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)
|
||||
|
||||
@@ -104,7 +114,7 @@ def compute_tag_suggestions(db: Session, user: User) -> dict[str, list[dict]]:
|
||||
|
||||
suggestions: dict[str, list[dict]] = {}
|
||||
for cid in subscribed:
|
||||
if cid in user_tagged: # already has a user tag → not "untagged"
|
||||
if cid in hidden or cid in user_tagged: # hidden → no chips shown; tagged → not "untagged"
|
||||
continue
|
||||
cfeat = features.get(cid)
|
||||
if not cfeat:
|
||||
|
||||
@@ -253,6 +253,63 @@ def test_feed_years_counts_ignore_the_date_narrowing_but_honour_other_filters(
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
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,
|
||||
|
||||
@@ -14,6 +14,8 @@ 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,
|
||||
)
|
||||
@@ -26,10 +28,10 @@ def _auto(db, name, category="topic"):
|
||||
return tag
|
||||
|
||||
|
||||
def _subscribe(db, user, ch_id, *, auto_tag_ids=(), title=None):
|
||||
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))
|
||||
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"))
|
||||
|
||||
@@ -116,3 +118,169 @@ def test_partial_topic_overlap_is_a_fraction_of_the_profile(db):
|
||||
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
|
||||
|
||||
@@ -101,6 +101,7 @@ function SuggestedChipsLane({
|
||||
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);
|
||||
@@ -163,6 +164,7 @@ function SuggestedChipsLane({
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${GHOST_PILL} hover:text-accent transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
@@ -424,6 +426,7 @@ export function ChannelTagsControl({
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${pillClass} hover:text-fg transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel, type TagSuggestions } from "../lib/api";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import type { SuggestedTag } from "./ChannelTags";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
@@ -17,6 +16,7 @@ import Tabs, { type TabDef } from "./Tabs";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
@@ -56,11 +56,6 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
cards: LayoutGrid,
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
export default function Channels() {
|
||||
const me = useMe();
|
||||
const canWrite = me.can_write;
|
||||
@@ -105,10 +100,6 @@ export default function Channels() {
|
||||
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
|
||||
const suggestionsQuery = useQuery({
|
||||
queryKey: qk.tagSuggestions(),
|
||||
queryFn: api.tagSuggestions,
|
||||
});
|
||||
const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
||||
const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
@@ -183,56 +174,29 @@ export default function Channels() {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(id) });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
},
|
||||
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
// 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).
|
||||
const tagById = useMemo(
|
||||
() => new Map((tagsQuery.data ?? []).map((tg) => [tg.id, tg])),
|
||||
[tagsQuery.data],
|
||||
);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
|
||||
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
|
||||
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; its onSuccess
|
||||
// re-invalidates suggestions + the channel detail.
|
||||
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 = 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));
|
||||
};
|
||||
// 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({
|
||||
mutationFn: () => api.syncSubscriptions(),
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -32,8 +33,13 @@ export default function YearFilter({
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,7 @@
|
||||
"create": "Create tag",
|
||||
"createPlaceholder": "New 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",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"create": "Címke létrehozása",
|
||||
"createPlaceholder": "Új címke…",
|
||||
"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",
|
||||
|
||||
@@ -14,6 +14,21 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
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",
|
||||
date: "2026-08-24",
|
||||
|
||||
Reference in New Issue
Block a user