diff --git a/backend/alembic/versions/0030_search_finds.py b/backend/alembic/versions/0030_search_finds.py new file mode 100644 index 0000000..975027a --- /dev/null +++ b/backend/alembic/versions/0030_search_finds.py @@ -0,0 +1,47 @@ +"""per-user search finds + +Revision ID: 0030_search_finds +Revises: 0029_unaccent_search +Create Date: 2026-06-30 + +Adds the `search_finds` table: a per-user record of which videos a user surfaced via their own +live YouTube search. This lets "videos I searched for" appear in that user's Mine feed (and the +Mine Source filter), independent of the global `videos.via_search` flag (which marks a video as +search-discovered by anyone, for the shared Library's Source filter). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0030_search_finds" +down_revision: Union[str, None] = "0029_unaccent_search" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "search_finds", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("video_id", sa.String(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["video_id"], ["videos.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "video_id", name="uq_user_search_video"), + ) + op.create_index("ix_search_finds_user_id", "search_finds", ["user_id"]) + op.create_index("ix_search_finds_video_id", "search_finds", ["video_id"]) + + +def downgrade() -> None: + op.drop_index("ix_search_finds_video_id", table_name="search_finds") + op.drop_index("ix_search_finds_user_id", table_name="search_finds") + op.drop_table("search_finds") diff --git a/backend/alembic/versions/0031_title_fts.py b/backend/alembic/versions/0031_title_fts.py new file mode 100644 index 0000000..ea16ac9 --- /dev/null +++ b/backend/alembic/versions/0031_title_fts.py @@ -0,0 +1,49 @@ +"""full-text relevance search on video titles + +Revision ID: 0031_title_fts +Revises: 0030_search_finds +Create Date: 2026-06-30 + +Adds a PostgreSQL full-text search index over video titles so the feed search box can rank by +relevance (a YouTube-like "fuzzy" search: word-order-independent, multi-word AND, prefix on the +word being typed) instead of a whole-phrase substring match. + +A custom text-search configuration `unaccent_simple` = the `simple` config (no stemming, no +stopwords — right for a multilingual HU/EN/DE catalog) plus the `unaccent` dictionary, so the +search stays accent-insensitive ("tiesto" matches "Tiësto"). A GIN expression index on +`to_tsvector('public.unaccent_simple', coalesce(title,''))` makes the @@ match fast; the feed +query uses the exact same expression so the planner picks the index. `unaccent` is already +enabled (migration 0029). The config-based 2-arg to_tsvector is IMMUTABLE, so it's index-safe. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0031_title_fts" +down_revision: Union[str, None] = "0030_search_finds" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)" + ) + op.execute( + """ + ALTER TEXT SEARCH CONFIGURATION public.unaccent_simple + ALTER MAPPING FOR asciiword, asciihword, hword_asciipart, word, hword, hword_part + WITH unaccent, simple + """ + ) + op.execute( + """ + CREATE INDEX ix_videos_title_fts ON videos + USING gin (to_tsvector('public.unaccent_simple', coalesce(title, ''))) + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_videos_title_fts") + op.execute("DROP TEXT SEARCH CONFIGURATION IF EXISTS public.unaccent_simple") diff --git a/backend/alembic/versions/0032_search_document.py b/backend/alembic/versions/0032_search_document.py new file mode 100644 index 0000000..3683df4 --- /dev/null +++ b/backend/alembic/versions/0032_search_document.py @@ -0,0 +1,65 @@ +"""richer full-text search document (title + keywords + search terms + description) + +Revision ID: 0032_search_document +Revises: 0031_title_fts +Create Date: 2026-06-30 + +Broadens the feed's full-text search from the title alone to a weighted search document, so the +local search behaves more like YouTube's — finding videos by the uploader's own keywords or by +the search query that surfaced them, not only by words in the title: + +- `keywords` — the creator's snippet.tags (already fetched with the snippet we request, so + zero extra quota), stored space-joined. +- `search_terms` — distinct live-search queries that surfaced the video (across all users), + appended by the search route. Folds YouTube's relevance judgement into local + search: a video YT returned for a query becomes findable by it locally. + +These plus a truncated description feed a STORED generated `search_vector` column, weighted +title (A) > keywords+search_terms (B) > description (C) so ts_rank ranks title hits highest. A +plain GIN index on the column guarantees the planner uses it (no expression/param matching). The +title-only expression index from 0031 is dropped (superseded). Existing rows get title + +description indexed immediately; keywords/search_terms fill in as videos are (re-)enriched and +searched. Uses the `unaccent_simple` config from 0031 (accent-insensitive). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0032_search_document" +down_revision: Union[str, None] = "0031_title_fts" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_SEARCH_VECTOR_EXPR = ( + "setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || " + "setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || " + "coalesce(search_terms, '')), 'B') || " + "setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')" +) + + +def upgrade() -> None: + op.add_column("videos", sa.Column("keywords", sa.Text(), nullable=True)) + op.add_column("videos", sa.Column("search_terms", sa.Text(), nullable=True)) + # Replace the title-only index with a generated weighted search_vector + its GIN index. + op.execute("DROP INDEX IF EXISTS ix_videos_title_fts") + op.execute( + f"ALTER TABLE videos ADD COLUMN search_vector tsvector " + f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED" + ) + op.execute( + "CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_videos_search_vector") + op.drop_column("videos", "search_vector") + op.drop_column("videos", "search_terms") + op.drop_column("videos", "keywords") + # Recreate the title-only FTS index from 0031. + op.execute( + "CREATE INDEX ix_videos_title_fts ON videos " + "USING gin (to_tsvector('public.unaccent_simple', coalesce(title, '')))" + ) diff --git a/backend/app/models.py b/backend/app/models.py index f40653d..a35a10f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -3,6 +3,7 @@ from datetime import date, datetime from sqlalchemy import ( BigInteger, Boolean, + Computed, Date, DateTime, Float, @@ -14,6 +15,7 @@ from sqlalchemy import ( UniqueConstraint, func, ) +from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base @@ -237,6 +239,14 @@ class Video(Base, TimestampMixin): topic_categories: Mapped[list | None] = mapped_column(JSON) default_language: Mapped[str | None] = mapped_column(String(16)) detected_language: Mapped[str | None] = mapped_column(String(16)) + # Creator-supplied keyword tags (snippet.tags joined by spaces) — fetched for free with the + # snippet we already request. Indexed into search_vector so the feed search matches the + # uploader's own keywords, not just words in the title. + keywords: Mapped[str | None] = mapped_column(Text) + # Accumulated distinct live-search queries that surfaced this video (across all users) — + # folds YouTube's own relevance judgement into the local search: a video YouTube returned + # for "ti amo magyarul" becomes findable by that query locally even if its title lacks it. + search_terms: Mapped[str | None] = mapped_column(Text) is_short: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false", index=True ) @@ -272,6 +282,21 @@ class Video(Base, TimestampMixin): ) unavailable_reason: Mapped[str | None] = mapped_column(String(24)) + # Weighted full-text search document (DB-generated, never written by the app): title (A) > + # creator keywords + search queries (B) > truncated description (C). ts_rank honours the + # weights so title matches outrank description matches. Backed by a GIN index (migration + # 0032). The accent-insensitive `unaccent_simple` config comes from migration 0031. + search_vector: Mapped[object | None] = mapped_column( + TSVECTOR, + Computed( + "setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || " + "setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || " + "coalesce(search_terms, '')), 'B') || " + "setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')", + persisted=True, + ), + ) + channel: Mapped["Channel"] = relationship(back_populates="videos") @@ -344,6 +369,25 @@ class VideoState(Base, UpdatedAtMixin): progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class SearchFind(Base, TimestampMixin): + """Per-user record of a video the user surfaced via their own live YouTube search. + + Lets "videos I searched for" appear in that user's own (Mine) feed and the Mine Source + filter — distinct from the GLOBAL `Video.via_search` provenance, which marks a video as + search-discovered by *anyone* and drives the shared Library's Source filter.""" + + __tablename__ = "search_finds" + __table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_video"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + video_id: Mapped[str] = mapped_column( + ForeignKey("videos.id", ondelete="CASCADE"), index=True + ) + + class ApiQuotaUsage(Base): """Tracks YouTube Data API units spent per Pacific-time day (the quota resets at midnight Pacific). The whole app shares one daily budget.""" diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 09648d5..528a42e 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -1,10 +1,11 @@ import base64 import binascii import json +import re from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import Select, and_, false, func, or_, select +from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session, aliased from sqlalchemy.orm.exc import StaleDataError @@ -17,6 +18,7 @@ from app.models import ( ChannelTag, Playlist, PlaylistItem, + SearchFind, Subscription, Tag, User, @@ -153,14 +155,20 @@ def _filtered_query( ), ) else: - # Only channels this user is subscribed to (and hasn't hidden). - query = query.join( + # "Mine": the user's own videos = their non-hidden subscriptions OR videos they + # surfaced via their own live YouTube search. Both are LEFT-joined so the Source + # filter below can include either or both, and the subscription row stays available + # for the priority sort. + query = query.outerjoin( Subscription, and_( Subscription.channel_id == Video.channel_id, Subscription.user_id == user.id, Subscription.hidden.is_(False), ), + ).outerjoin( + SearchFind, + and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id), ) query = query.outerjoin( @@ -172,16 +180,25 @@ def _filtered_query( # either way they shouldn't show in any feed view meanwhile. query = query.where(Video.unavailable_since.is_(None)) - # Provenance filter for the Library: search-discovered videos are "noise" hidden by - # default ("organic"), can be mixed in ("all"), or shown exclusively ("search"). Only - # meaningful in "all" scope: "my" already restricts to subscribed channels, where a - # once-searched video is legitimately the user's. + # Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag + # (search-discovered by anyone). In "my" scope it's the user's OWN provenance: + # "organic" = your subscriptions, "search" = videos you found via your own search, + # "all" = both. Default "organic" keeps the Mine feed = subscriptions only. if scope == "all": if library_source == "organic": query = query.where(Video.via_search.is_(False)) elif library_source == "search": query = query.where(Video.via_search.is_(True)) # "all" → both organic and search-discovered videos + else: + subscribed = Subscription.channel_id.isnot(None) + searched = SearchFind.video_id.isnot(None) + if library_source == "search": + query = query.where(searched) + elif library_source == "all": + query = query.where(or_(subscribed, searched)) + else: # "organic" (default): subscriptions only + query = query.where(subscribed) if channel_id: query = query.where(Video.channel_id == channel_id) @@ -204,16 +221,36 @@ def _filtered_query( published_before, datetime.min.time(), tzinfo=timezone.utc ) + timedelta(days=1) query = query.where(Video.published_at < end) + # Full-text relevance search over the weighted search document (title > creator keywords + + # the queries that surfaced the video > description; see Video.search_vector). YouTube-like + # "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed, + # accent-insensitive (unaccent_simple). The channel name still matches as a substring so + # channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort. + rank_expr = None if q: - # Accent-insensitive: unaccent() both sides so "tiesto" matches "Tiësto" (ILIKE alone - # is case- but not diacritic-insensitive). unaccent is enabled by migration 0029. - like = func.unaccent(f"%{q}%") - query = query.where( - or_( - func.unaccent(Video.title).ilike(like), - func.unaccent(Channel.title).ilike(like), + ts_str = _to_tsquery_str(q) + if ts_str: + tsq = func.to_tsquery("public.unaccent_simple", ts_str) + query = query.where( + or_( + Video.search_vector.op("@@")(tsq), + func.unaccent(Channel.title).ilike(func.unaccent(f"%{q}%")), + ) + ) + # Scale ts_rank (a float4) to an integer score so the keyset cursor compares it + # EXACTLY — a raw float4 vs the float8 cursor value mismatches on round-trip and + # breaks paging (the same page repeats). 1e6 granularity is ample; ties break on + # published_at then id. + rank_expr = cast(func.ts_rank(Video.search_vector, tsq) * 1000000, BigInteger) + else: + # No usable tokens (e.g. only punctuation): fall back to a plain substring match. + like = func.unaccent(f"%{q}%") + query = query.where( + or_( + func.unaccent(Video.title).ilike(like), + func.unaccent(Channel.title).ilike(like), + ) ) - ) if tags: # AND across tag categories (e.g. language AND topic narrows), OR within a @@ -271,7 +308,19 @@ def _filtered_query( else: # all query = query.where(status_expr != "hidden") - return query, status_expr + return query, status_expr, rank_expr + + +def _to_tsquery_str(q: str) -> str | None: + """Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word + prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*". + Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in + (the string is built from sanitised tokens, never the raw query).""" + tokens = [t for t in re.findall(r"\w+", q, flags=re.UNICODE) if any(c.isalnum() for c in t)] + if not tokens: + return None + tokens[-1] = tokens[-1] + ":*" + return " & ".join(tokens) # Shared query parameters for /feed and /feed/count. @@ -320,7 +369,7 @@ def _feed_params( # replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows, # and the page boundary is stable even as the scheduler ingests new videos mid-scroll # (OFFSET would shift and duplicate/skip rows). -def _sort_keys(sort: str, seed: int) -> list[dict]: +def _sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]: pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"} table: dict[str, list[dict]] = { "newest": [pub], @@ -341,6 +390,13 @@ def _sort_keys(sort: str, seed: int) -> list[dict]: # the cursor stays valid across pages of one shuffled run. "shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}], } + # Relevance (search-result ranking) only exists when there's a query to rank against; + # without `rank_expr` it falls through to newest. Ties break by newest, then id. + if rank_expr is not None: + table["relevance"] = [ + {"expr": rank_expr, "asc": False, "nullable": False, "kind": "num"}, + pub, + ] return table.get(sort) or table["newest"] @@ -409,8 +465,8 @@ def get_feed( user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - query, _status = _filtered_query(db, user, **params) - keys = _sort_keys(sort, seed) + query, _status, rank_expr = _filtered_query(db, user, **params) + keys = _sort_keys(sort, seed, rank_expr) # Expose each key column on the row so we can build the next cursor from the last item. query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)]) @@ -439,7 +495,7 @@ def get_feed_count( user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - query, _status = _filtered_query(db, user, **params) + query, _status, _rank = _filtered_query(db, user, **params) total = db.scalar(select(func.count()).select_from(query.subquery())) return {"count": total or 0} @@ -467,7 +523,7 @@ def get_facets( # keeps them applied, so each remaining chip narrows to channels that ALSO have all # already-selected topics — and tags that can't co-occur drop to zero (hidden). conjunctive = category == "topic" and params.get("tag_mode") == "and" - base, _status = _filtered_query( + base, _status, _rank = _filtered_query( db, user, **{**params, "exclude_tag_category": None if conjunctive else category}, diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index efba64f..0da0e61 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -16,13 +16,14 @@ from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import and_, select +from sqlalchemy import and_, func, select, update +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session, aliased from app import quota, sysconfig from app.auth import require_human from app.db import get_db -from app.models import Channel, User, Video, VideoState +from app.models import Channel, SearchFind, User, Video, VideoState from app.routes.feed import _serialize, feed_columns from app.sync.subscriptions import apply_channel_details from app.sync.videos import _insert_stubs, apply_video_details, parse_dt @@ -213,6 +214,31 @@ def search_youtube( v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN } ordered = [vid for vid in ids if vid in keep] + + # 6) Record these as THIS user's search finds so they surface in their own Mine feed + # (and the Mine Source filter), independent of the global via_search flag. Idempotent. + if ordered: + db.execute( + pg_insert(SearchFind) + .values([{"user_id": user.id, "video_id": vid} for vid in ordered]) + .on_conflict_do_nothing(index_elements=["user_id", "video_id"]) + ) + # 7) Fold the query into each result's search_terms (shared across users) so local + # full-text search inherits YouTube's relevance — a video YT returned for this query + # becomes findable by it even when its title doesn't contain the words. Skip rows + # that already include the term; the generated search_vector updates automatically. + db.execute( + update(Video) + .where(Video.id.in_(ordered)) + .where(func.coalesce(Video.search_terms, "").notilike(f"%{term}%")) + .values( + search_terms=func.trim( + func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term) + ) + ) + ) + db.commit() + return { "items": _serialize_ids(db, user, ordered), "next_cursor": page["next_page_token"], diff --git a/backend/app/sync/videos.py b/backend/app/sync/videos.py index de0df77..a7a77d9 100644 --- a/backend/app/sync/videos.py +++ b/backend/app/sync/videos.py @@ -234,6 +234,9 @@ def apply_video_details(video: Video, item: dict) -> None: video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None video.like_count = int(stats["likeCount"]) if stats.get("likeCount") else None video.category_id = int(snippet["categoryId"]) if snippet.get("categoryId") else None + # Creator keyword tags (free with the snippet we already fetch) → search document. + tags = snippet.get("tags") + video.keywords = " ".join(tags) if tags else None video.topic_categories = topics.get("topicCategories") video.default_language = snippet.get("defaultLanguage") or snippet.get( "defaultAudioLanguage" diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 0223735..7205633 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -23,9 +23,11 @@ const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); // Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings // (which encode both). One entry per concept; a single arrow flips the direction. -type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle"; +// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts. +type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle" | "relevance"; const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"]; -const SORT_MAP: Record, { asc: string; desc: string }> = { +const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"]; +const SORT_MAP: Record, { asc: string; desc: string }> = { date: { desc: "newest", asc: "oldest" }, popular: { desc: "views", asc: "views_asc" }, duration: { desc: "duration_desc", asc: "duration_asc" }, @@ -33,11 +35,11 @@ const SORT_MAP: Record, { asc: string; desc: string subscribers: { desc: "subscribers", asc: "subscribers_asc" }, priority: { desc: "priority", asc: "priority_asc" }, }; -const SORT_DEFAULT_DIR: Record, "asc" | "desc"> = { +const SORT_DEFAULT_DIR: Record, "asc" | "desc"> = { date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc", }; function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } { - if (s === "shuffle") return { key: "shuffle", dir: "desc" }; + if (s === "shuffle" || s === "relevance") return { key: s, dir: "desc" }; for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) { if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" }; if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" }; @@ -45,7 +47,8 @@ function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } { return { key: "date", dir: "desc" }; } function buildSort(key: SortKey, dir: "asc" | "desc"): string { - return key === "shuffle" ? "shuffle" : SORT_MAP[key][dir]; + if (key === "shuffle" || key === "relevance") return key; + return SORT_MAP[key][dir]; } function matchesView(status: string, show: string): boolean { @@ -134,6 +137,18 @@ export default function Feed({ retry: false, }); + // Switching to the relevance sort when a search starts happens atomically in the header's + // input onChange (race-free with the query update). Here we only handle the reverse: when + // the term is cleared, fall back to the default sort — relevance has no dropdown option and + // doesn't rank without a query. + const qEmpty = !filters.q.trim(); + useEffect(() => { + if (qEmpty && parseSort(filters.sort).key === "relevance") { + setFilters({ ...filters, sort: buildSort("date", "desc") }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [qEmpty]); + // Drop optimistic status overrides when filters change OR fresh server data // arrives — after a refetch the server is authoritative, so a stale override // (e.g. a video reverted to "new" from the notification center) won't keep it @@ -296,6 +311,9 @@ export default function Feed({ // The search just ingested new catalog videos, so the normal feed (which was // disabled and still holds its pre-search cache) is stale. Drop those caches so // it re-fetches fresh on return instead of showing the old (often empty) result. + // Surface the just-searched videos in the feed you return to: switch the Source + // filter to "search" (your own search finds) and rank them by relevance. + setFilters({ ...filters, librarySource: "search", sort: "relevance" }); onExitYtSearch(); qc.removeQueries({ queryKey: ["feed"] }); qc.removeQueries({ queryKey: ["feed-count"] }); @@ -445,7 +463,7 @@ export default function Feed({ ); })} - {filters.scope === "all" && ( + {( <>