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:
parent
e56502789f
commit
8c245e986f
17 changed files with 306 additions and 35 deletions
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue