fix: address reader-UI feedback (shorts, search, tags, login, UX)

- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
  consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
  flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
  OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
  like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
  undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
This commit is contained in:
npeter83 2026-06-11 03:07:49 +02:00
parent e56502789f
commit 8c245e986f
17 changed files with 306 additions and 35 deletions

View file

@ -6,7 +6,7 @@ from sqlalchemy.orm import Session, aliased
from app.auth import current_user
from app.db import get_db
from app.models import Channel, ChannelTag, User, Video, VideoState
from app.models import Channel, ChannelTag, Tag, User, Video, VideoState
router = APIRouter(prefix="/api", tags=["feed"])
@ -14,6 +14,12 @@ VALID_STATES = {"new", "watched", "saved", "hidden"}
HIDDEN_LIVE = ("live", "upcoming")
def _channel_url(channel_id: str, handle: str | None) -> str:
if handle and handle.startswith("@"):
return f"https://www.youtube.com/{handle}"
return f"https://www.youtube.com/channel/{channel_id}"
def _serialize(row) -> dict:
return {
"id": row.id,
@ -21,6 +27,7 @@ def _serialize(row) -> dict:
"channel_id": row.channel_id,
"channel_title": row.channel_title,
"channel_thumbnail": row.channel_thumbnail,
"channel_url": _channel_url(row.channel_id, row.channel_handle),
"published_at": row.published_at.isoformat() if row.published_at else None,
"thumbnail_url": row.thumbnail_url,
"duration_seconds": row.duration_seconds,
@ -61,6 +68,7 @@ def get_feed(
Video.channel_id,
Channel.title.label("channel_title"),
Channel.thumbnail_url.label("channel_thumbnail"),
Channel.handle.label("channel_handle"),
Video.published_at,
Video.thumbnail_url,
Video.duration_seconds,
@ -91,23 +99,35 @@ def get_feed(
Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc)
)
if q:
# Title (and channel name) only — searching descriptions produced noisy matches.
like = f"%{q}%"
query = query.where(or_(Video.title.ilike(like), Video.description.ilike(like)))
query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like)))
if tags:
# Group selected tags by category: AND across categories (e.g. language AND
# topic narrows results), OR within a category. The any/all toggle controls
# whether multiple topic tags are OR'd (any) or AND'd (all).
cat_rows = db.execute(
select(Tag.id, Tag.category).where(Tag.id.in_(tags))
).all()
by_category: dict[str, list[int]] = {}
for tag_id, category in cat_rows:
by_category.setdefault(category, []).append(tag_id)
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
if tag_mode == "and":
sub = (
select(ChannelTag.channel_id)
.where(ChannelTag.tag_id.in_(tags), visible)
.group_by(ChannelTag.channel_id)
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(tags)))
)
else:
sub = select(ChannelTag.channel_id).where(
ChannelTag.tag_id.in_(tags), visible
)
query = query.where(Video.channel_id.in_(sub))
for category, ids in by_category.items():
if category == "topic" and tag_mode == "and" and len(ids) > 1:
sub = (
select(ChannelTag.channel_id)
.where(ChannelTag.tag_id.in_(ids), visible)
.group_by(ChannelTag.channel_id)
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids)))
)
else:
sub = select(ChannelTag.channel_id).where(
ChannelTag.tag_id.in_(ids), visible
)
query = query.where(Video.channel_id.in_(sub))
# Watch-state visibility.
if show == "unwatched":