Merge: Mine search finds, relevance search, and a weighted search document

feature/mine-search-finds-and-fts:
- per-user search finds surface in the Mine feed + Source filter there
- full-text relevance ranking (new Relevance sort, auto-selected when searching)
- search broadened to a weighted document (title + creator keywords + the
  queries that surfaced a video + description), DB-generated search_vector.
This commit is contained in:
npeter83 2026-06-30 02:16:32 +02:00
commit bb7b9972fd
12 changed files with 360 additions and 39 deletions

View file

@ -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")

View file

@ -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")

View file

@ -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, '')))"
)

View file

@ -3,6 +3,7 @@ from datetime import date, datetime
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
Boolean, Boolean,
Computed,
Date, Date,
DateTime, DateTime,
Float, Float,
@ -14,6 +15,7 @@ from sqlalchemy import (
UniqueConstraint, UniqueConstraint,
func, func,
) )
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base from app.db import Base
@ -237,6 +239,14 @@ class Video(Base, TimestampMixin):
topic_categories: Mapped[list | None] = mapped_column(JSON) topic_categories: Mapped[list | None] = mapped_column(JSON)
default_language: Mapped[str | None] = mapped_column(String(16)) default_language: Mapped[str | None] = mapped_column(String(16))
detected_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( is_short: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True 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)) 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") 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)) 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): class ApiQuotaUsage(Base):
"""Tracks YouTube Data API units spent per Pacific-time day (the quota resets at """Tracks YouTube Data API units spent per Pacific-time day (the quota resets at
midnight Pacific). The whole app shares one daily budget.""" midnight Pacific). The whole app shares one daily budget."""

View file

@ -1,10 +1,11 @@
import base64 import base64
import binascii import binascii
import json import json
import re
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query 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.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from sqlalchemy.orm.exc import StaleDataError from sqlalchemy.orm.exc import StaleDataError
@ -17,6 +18,7 @@ from app.models import (
ChannelTag, ChannelTag,
Playlist, Playlist,
PlaylistItem, PlaylistItem,
SearchFind,
Subscription, Subscription,
Tag, Tag,
User, User,
@ -153,14 +155,20 @@ def _filtered_query(
), ),
) )
else: else:
# Only channels this user is subscribed to (and hasn't hidden). # "Mine": the user's own videos = their non-hidden subscriptions OR videos they
query = query.join( # 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, Subscription,
and_( and_(
Subscription.channel_id == Video.channel_id, Subscription.channel_id == Video.channel_id,
Subscription.user_id == user.id, Subscription.user_id == user.id,
Subscription.hidden.is_(False), Subscription.hidden.is_(False),
), ),
).outerjoin(
SearchFind,
and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id),
) )
query = query.outerjoin( query = query.outerjoin(
@ -172,16 +180,25 @@ def _filtered_query(
# either way they shouldn't show in any feed view meanwhile. # either way they shouldn't show in any feed view meanwhile.
query = query.where(Video.unavailable_since.is_(None)) query = query.where(Video.unavailable_since.is_(None))
# Provenance filter for the Library: search-discovered videos are "noise" hidden by # Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag
# default ("organic"), can be mixed in ("all"), or shown exclusively ("search"). Only # (search-discovered by anyone). In "my" scope it's the user's OWN provenance:
# meaningful in "all" scope: "my" already restricts to subscribed channels, where a # "organic" = your subscriptions, "search" = videos you found via your own search,
# once-searched video is legitimately the user's. # "all" = both. Default "organic" keeps the Mine feed = subscriptions only.
if scope == "all": if scope == "all":
if library_source == "organic": if library_source == "organic":
query = query.where(Video.via_search.is_(False)) query = query.where(Video.via_search.is_(False))
elif library_source == "search": elif library_source == "search":
query = query.where(Video.via_search.is_(True)) query = query.where(Video.via_search.is_(True))
# "all" → both organic and search-discovered videos # "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: if channel_id:
query = query.where(Video.channel_id == channel_id) query = query.where(Video.channel_id == channel_id)
@ -204,9 +221,29 @@ def _filtered_query(
published_before, datetime.min.time(), tzinfo=timezone.utc published_before, datetime.min.time(), tzinfo=timezone.utc
) + timedelta(days=1) ) + timedelta(days=1)
query = query.where(Video.published_at < end) 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: if q:
# Accent-insensitive: unaccent() both sides so "tiesto" matches "Tiësto" (ILIKE alone ts_str = _to_tsquery_str(q)
# is case- but not diacritic-insensitive). unaccent is enabled by migration 0029. 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}%") like = func.unaccent(f"%{q}%")
query = query.where( query = query.where(
or_( or_(
@ -271,7 +308,19 @@ def _filtered_query(
else: # all else: # all
query = query.where(status_expr != "hidden") 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. # 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, # 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 # and the page boundary is stable even as the scheduler ingests new videos mid-scroll
# (OFFSET would shift and duplicate/skip rows). # (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"} pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"}
table: dict[str, list[dict]] = { table: dict[str, list[dict]] = {
"newest": [pub], "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. # 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"}], "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"] return table.get(sort) or table["newest"]
@ -409,8 +465,8 @@ def get_feed(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status = _filtered_query(db, user, **params) query, _status, rank_expr = _filtered_query(db, user, **params)
keys = _sort_keys(sort, seed) 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. # 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)]) 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), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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())) total = db.scalar(select(func.count()).select_from(query.subquery()))
return {"count": total or 0} 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 # 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). # already-selected topics — and tags that can't co-occur drop to zero (hidden).
conjunctive = category == "topic" and params.get("tag_mode") == "and" conjunctive = category == "topic" and params.get("tag_mode") == "and"
base, _status = _filtered_query( base, _status, _rank = _filtered_query(
db, db,
user, user,
**{**params, "exclude_tag_category": None if conjunctive else category}, **{**params, "exclude_tag_category": None if conjunctive else category},

View file

@ -16,13 +16,14 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException 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 sqlalchemy.orm import Session, aliased
from app import quota, sysconfig from app import quota, sysconfig
from app.auth import require_human from app.auth import require_human
from app.db import get_db 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.routes.feed import _serialize, feed_columns
from app.sync.subscriptions import apply_channel_details from app.sync.subscriptions import apply_channel_details
from app.sync.videos import _insert_stubs, apply_video_details, parse_dt 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 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] 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 { return {
"items": _serialize_ids(db, user, ordered), "items": _serialize_ids(db, user, ordered),
"next_cursor": page["next_page_token"], "next_cursor": page["next_page_token"],

View file

@ -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.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
video.like_count = int(stats["likeCount"]) if stats.get("likeCount") 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 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.topic_categories = topics.get("topicCategories")
video.default_language = snippet.get("defaultLanguage") or snippet.get( video.default_language = snippet.get("defaultLanguage") or snippet.get(
"defaultAudioLanguage" "defaultAudioLanguage"

View file

@ -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 // 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. // (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_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"];
const SORT_MAP: Record<Exclude<SortKey, "shuffle">, { asc: string; desc: string }> = { const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
date: { desc: "newest", asc: "oldest" }, date: { desc: "newest", asc: "oldest" },
popular: { desc: "views", asc: "views_asc" }, popular: { desc: "views", asc: "views_asc" },
duration: { desc: "duration_desc", asc: "duration_asc" }, duration: { desc: "duration_desc", asc: "duration_asc" },
@ -33,11 +35,11 @@ const SORT_MAP: Record<Exclude<SortKey, "shuffle">, { asc: string; desc: string
subscribers: { desc: "subscribers", asc: "subscribers_asc" }, subscribers: { desc: "subscribers", asc: "subscribers_asc" },
priority: { desc: "priority", asc: "priority_asc" }, priority: { desc: "priority", asc: "priority_asc" },
}; };
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle">, "asc" | "desc"> = { const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle" | "relevance">, "asc" | "desc"> = {
date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc", date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc",
}; };
function parseSort(s: string): { key: SortKey; dir: "asc" | "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)[]) { 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].asc === s) return { key: k, dir: "asc" };
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" }; 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" }; return { key: "date", dir: "desc" };
} }
function buildSort(key: SortKey, dir: "asc" | "desc"): string { 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 { function matchesView(status: string, show: string): boolean {
@ -134,6 +137,18 @@ export default function Feed({
retry: false, 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 // Drop optimistic status overrides when filters change OR fresh server data
// arrives — after a refetch the server is authoritative, so a stale override // 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 // (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 // 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 // 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. // 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(); onExitYtSearch();
qc.removeQueries({ queryKey: ["feed"] }); qc.removeQueries({ queryKey: ["feed"] });
qc.removeQueries({ queryKey: ["feed-count"] }); qc.removeQueries({ queryKey: ["feed-count"] });
@ -445,7 +463,7 @@ export default function Feed({
</button> </button>
); );
})} })}
{filters.scope === "all" && ( {(
<> <>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<label className="inline-flex items-center gap-1.5 text-xs text-muted"> <label className="inline-flex items-center gap-1.5 text-xs text-muted">
@ -484,7 +502,9 @@ export default function Feed({
value={sortKey} value={sortKey}
onChange={(e) => { onChange={(e) => {
const key = e.target.value as SortKey; const key = e.target.value as SortKey;
const dir = key === "shuffle" ? "desc" : SORT_DEFAULT_DIR[key]; const dir = DIRECTIONLESS.includes(key)
? "desc"
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
setFilters({ setFilters({
...filters, ...filters,
sort: buildSort(key, dir), sort: buildSort(key, dir),
@ -493,13 +513,14 @@ export default function Feed({
}} }}
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent" className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
> >
{SORT_KEYS.map((k) => ( {/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
{(filters.q.trim() ? (["relevance", ...SORT_KEYS] as SortKey[]) : SORT_KEYS).map((k) => (
<option key={k} value={k}> <option key={k} value={k}>
{t("feed.sortKey." + k)} {t("feed.sortKey." + k)}
</option> </option>
))} ))}
</select> </select>
{sortKey !== "shuffle" && ( {!DIRECTIONLESS.includes(sortKey) && (
<button <button
onClick={() => onClick={() =>
setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") }) setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") })

View file

@ -67,7 +67,14 @@ export default function Header({
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" /> <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input <input
value={filters.q} value={filters.q}
onChange={(e) => setFilters({ ...filters, q: e.target.value })} onChange={(e) => {
const q = e.target.value;
// When a search first appears, rank the feed by relevance — set atomically with
// the query (race-free vs. per-keystroke updates). Only overrides the default
// "newest" sort; a custom sort the user chose is left alone. Cleared in Feed.
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}}
onKeyDown={(e) => { onKeyDown={(e) => {
// Enter runs a live YouTube search for the typed term (the box itself filters // Enter runs a live YouTube search for the typed term (the box itself filters
// the local catalog as you type; Enter escalates to the YouTube API). // the local catalog as you type; Enter escalates to the YouTube API).

View file

@ -21,7 +21,8 @@
"title": "Name", "title": "Name",
"subscribers": "Kanal-Abonnenten", "subscribers": "Kanal-Abonnenten",
"priority": "Kanal-Priorität", "priority": "Kanal-Priorität",
"shuffle": "Überraschung" "shuffle": "Überraschung",
"relevance": "Relevanz"
}, },
"loadingMore": "Mehr wird geladen…", "loadingMore": "Mehr wird geladen…",
"hiddenNamed": "Ausgeblendet: „{{title}}”", "hiddenNamed": "Ausgeblendet: „{{title}}”",
@ -32,7 +33,7 @@
"unwatch": "Nicht mehr als angesehen", "unwatch": "Nicht mehr als angesehen",
"source": { "source": {
"label": "Quelle", "label": "Quelle",
"tip": "Bibliothek danach filtern, wie Videos hierher kamen: nur organischer Katalog, mit Suchergebnissen, oder nur über Live-YouTube-Suche gefundene Videos.", "tip": "Danach filtern, wie Videos hierher kamen: nur deine Abos/Katalog, samt deiner über Live-YouTube-Suche gefundenen Videos, oder nur diese Suchergebnisse.",
"organic": "Ohne Suchergebnisse", "organic": "Ohne Suchergebnisse",
"all": "Mit Suchergebnissen", "all": "Mit Suchergebnissen",
"only": "Nur Suchergebnisse" "only": "Nur Suchergebnisse"

View file

@ -21,7 +21,8 @@
"title": "Name", "title": "Name",
"subscribers": "Channel subscribers", "subscribers": "Channel subscribers",
"priority": "Channel priority", "priority": "Channel priority",
"shuffle": "Surprise me" "shuffle": "Surprise me",
"relevance": "Relevance"
}, },
"loadingMore": "Loading more…", "loadingMore": "Loading more…",
"hiddenNamed": "Hidden “{{title}}”", "hiddenNamed": "Hidden “{{title}}”",
@ -32,7 +33,7 @@
"unwatch": "Unwatch", "unwatch": "Unwatch",
"source": { "source": {
"label": "Source", "label": "Source",
"tip": "Filter the Library by how videos got here: organic catalog only, plus search results, or only videos found via live YouTube search.", "tip": "Filter by how videos got here: your subscriptions/catalog only, plus videos you found via live YouTube search, or only those search results.",
"organic": "Hide search results", "organic": "Hide search results",
"all": "Include search results", "all": "Include search results",
"only": "Search results only" "only": "Search results only"

View file

@ -21,7 +21,8 @@
"title": "Név", "title": "Név",
"subscribers": "Csatorna feliratkozók", "subscribers": "Csatorna feliratkozók",
"priority": "Csatorna prioritás", "priority": "Csatorna prioritás",
"shuffle": "Meglepetés" "shuffle": "Meglepetés",
"relevance": "Relevancia"
}, },
"loadingMore": "Továbbiak betöltése…", "loadingMore": "Továbbiak betöltése…",
"hiddenNamed": "Elrejtve: „{{title}}”", "hiddenNamed": "Elrejtve: „{{title}}”",
@ -32,7 +33,7 @@
"unwatch": "Megnézés visszavonása", "unwatch": "Megnézés visszavonása",
"source": { "source": {
"label": "Forrás", "label": "Forrás",
"tip": "Szűrd a könyvtárat aszerint, hogyan kerültek be a videók: csak organikus katalógus, kereséssel együtt, vagy csak az élő YouTube-keresésből talált videók.", "tip": "Szűrés aszerint, hogyan kerültek be a videók: csak a feliratkozásaid/katalógus, az élő YouTube-keresésből talált videóiddal együtt, vagy csak azok a keresési találatok.",
"organic": "Keresés nélkül", "organic": "Keresés nélkül",
"all": "Kereséssel együtt", "all": "Kereséssel együtt",
"only": "Csak keresésből" "only": "Csak keresésből"