feat(feed): per-user search finds in Mine scope + full-text relevance search

Two related search improvements:

1) Your own live-search results now belong to your Mine feed. A new per-user
   search_finds table (migration 0030) records each video you surface via your
   YouTube search (the route inserts them idempotently). The Mine feed becomes
   'your non-hidden subscriptions OR your search finds', and the Source filter
   now applies in Mine too: organic = subscriptions, search = your search finds,
   all = both (default stays organic, so the main feed is unchanged). The shared
   Library keeps using the global via_search flag.

2) Feed search ranks by relevance instead of a whole-phrase substring. A custom
   unaccent_simple text-search config + GIN index (migration 0031) back a
   YouTube-like fuzzy match: word-order-independent, multi-word AND, prefix on
   the word being typed, accent-insensitive. A new 'relevance' sort orders by
   ts_rank; the channel name still matches as a substring. The rank is scaled to
   an integer so the keyset cursor pages it exactly (a raw float4 breaks paging).
   _filtered_query returns the rank expr so only the feed list uses it.
This commit is contained in:
npeter83 2026-06-30 00:39:09 +02:00
parent a4abfa402f
commit 6cef826ecc
5 changed files with 208 additions and 22 deletions

View file

@ -17,12 +17,13 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, select
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,17 @@ 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"])
)
db.commit()
return {
"items": _serialize_ids(db, user, ordered),
"next_cursor": page["next_page_token"],