- Narrow the title hover target to the actual text, not the whole row. - Linkify descriptions: timestamps (mm:ss / hh:mm:ss) seek the player; emails become mailto:; hashtags link to YouTube's hashtag feed; other URLs open in a new tab. Blank lines are stripped so the popover isn't mostly whitespace. - YouTube links play in the inline player: a link to the current video seeks (honoring t=), a link to another video navigates the player to it, with a Back button to the original. While on a linked video the title/author come from the player and its views/date/duration + a clickable channel come from the detail endpoint, which falls back to the YouTube API (videos.list, attributed to the user) for videos not in our DB.
346 lines
12 KiB
Python
346 lines
12 KiB
Python
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.orm import Session, aliased
|
|
|
|
from app import quota
|
|
from app.auth import current_user
|
|
from app.db import get_db
|
|
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState
|
|
from app.sync.videos import parse_iso8601_duration
|
|
from app.youtube.client import YouTubeClient, YouTubeError
|
|
|
|
router = APIRouter(prefix="/api", tags=["feed"])
|
|
|
|
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,
|
|
"title": row.title,
|
|
"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,
|
|
"view_count": row.view_count,
|
|
"is_short": row.is_short,
|
|
"live_status": row.live_status,
|
|
"status": row.status or "new",
|
|
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
|
|
}
|
|
|
|
|
|
def _filtered_query(
|
|
db: Session,
|
|
user: User,
|
|
*,
|
|
tags: list[int],
|
|
tag_mode: str,
|
|
channel_id: str | None,
|
|
q: str | None,
|
|
min_duration: int | None,
|
|
max_duration: int | None,
|
|
max_age_days: int | None,
|
|
published_after: date | None,
|
|
published_before: date | None,
|
|
show_normal: bool,
|
|
include_shorts: bool,
|
|
include_live: bool,
|
|
show: str,
|
|
) -> tuple[Select, object]:
|
|
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
|
|
Returns the column-bearing select plus the watch-status expression for sorting."""
|
|
state = aliased(VideoState)
|
|
status_expr = func.coalesce(state.status, "new")
|
|
|
|
query = (
|
|
select(
|
|
Video.id,
|
|
Video.title,
|
|
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,
|
|
Video.view_count,
|
|
Video.is_short,
|
|
Video.live_status,
|
|
status_expr.label("status"),
|
|
)
|
|
.join(Channel, Channel.id == Video.channel_id)
|
|
# Only channels this user is subscribed to (and hasn't hidden).
|
|
.join(
|
|
Subscription,
|
|
and_(
|
|
Subscription.channel_id == Video.channel_id,
|
|
Subscription.user_id == user.id,
|
|
Subscription.hidden.is_(False),
|
|
),
|
|
)
|
|
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
|
)
|
|
|
|
if channel_id:
|
|
query = query.where(Video.channel_id == channel_id)
|
|
if min_duration is not None:
|
|
query = query.where(Video.duration_seconds >= min_duration)
|
|
if max_duration is not None:
|
|
query = query.where(Video.duration_seconds <= max_duration)
|
|
if max_age_days is not None:
|
|
cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400
|
|
query = query.where(
|
|
Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
|
)
|
|
if published_after is not None:
|
|
start = datetime.combine(
|
|
published_after, datetime.min.time(), tzinfo=timezone.utc
|
|
)
|
|
query = query.where(Video.published_at >= start)
|
|
if published_before is not None:
|
|
end = datetime.combine(
|
|
published_before, datetime.min.time(), tzinfo=timezone.utc
|
|
) + timedelta(days=1)
|
|
query = query.where(Video.published_at < end)
|
|
if q:
|
|
like = f"%{q}%"
|
|
query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like)))
|
|
|
|
if tags:
|
|
# AND across tag categories (e.g. language AND topic narrows), OR within a
|
|
# category; the any/all toggle controls multiple topic tags.
|
|
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)
|
|
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))
|
|
|
|
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
|
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
|
explicit_view = show in ("watched", "saved", "hidden")
|
|
if not explicit_view:
|
|
type_clauses = []
|
|
if show_normal:
|
|
type_clauses.append(
|
|
and_(Video.is_short.is_(False), Video.live_status.notin_(HIDDEN_LIVE))
|
|
)
|
|
if include_shorts:
|
|
type_clauses.append(Video.is_short.is_(True))
|
|
if include_live:
|
|
type_clauses.append(Video.live_status.in_(HIDDEN_LIVE))
|
|
query = query.where(or_(*type_clauses) if type_clauses else false())
|
|
|
|
if show == "unwatched":
|
|
query = query.where(status_expr.notin_(("watched", "hidden")))
|
|
elif show == "watched":
|
|
query = query.where(status_expr == "watched")
|
|
elif show == "saved":
|
|
query = query.where(status_expr == "saved")
|
|
elif show == "hidden":
|
|
query = query.where(status_expr == "hidden")
|
|
else: # all
|
|
query = query.where(status_expr != "hidden")
|
|
|
|
return query, status_expr
|
|
|
|
|
|
# Shared query parameters for /feed and /feed/count.
|
|
def _feed_params(
|
|
tags: list[int] = Query(default=[]),
|
|
tag_mode: str = "or",
|
|
channel_id: str | None = None,
|
|
q: str | None = None,
|
|
min_duration: int | None = None,
|
|
max_duration: int | None = None,
|
|
max_age_days: int | None = None,
|
|
published_after: date | None = None,
|
|
published_before: date | None = None,
|
|
show_normal: bool = True,
|
|
include_shorts: bool = False,
|
|
include_live: bool = False,
|
|
show: str = "unwatched",
|
|
) -> dict:
|
|
return {
|
|
"tags": tags,
|
|
"tag_mode": tag_mode,
|
|
"channel_id": channel_id,
|
|
"q": q,
|
|
"min_duration": min_duration,
|
|
"max_duration": max_duration,
|
|
"max_age_days": max_age_days,
|
|
"published_after": published_after,
|
|
"published_before": published_before,
|
|
"show_normal": show_normal,
|
|
"include_shorts": include_shorts,
|
|
"include_live": include_live,
|
|
"show": show,
|
|
}
|
|
|
|
|
|
SORTS = {
|
|
"newest": Video.published_at.desc().nulls_last(),
|
|
"oldest": Video.published_at.asc().nulls_last(),
|
|
"views": Video.view_count.desc().nulls_last(),
|
|
"duration_desc": Video.duration_seconds.desc().nulls_last(),
|
|
"duration_asc": Video.duration_seconds.asc().nulls_last(),
|
|
"title": func.lower(Video.title).asc().nulls_last(),
|
|
"subscribers": Channel.subscriber_count.desc().nulls_last(),
|
|
# Your per-channel priority (set in the channel manager), newest first within a tier.
|
|
"priority": Subscription.priority.desc(),
|
|
}
|
|
|
|
|
|
@router.get("/feed")
|
|
def get_feed(
|
|
params: dict = Depends(_feed_params),
|
|
sort: str = "newest",
|
|
seed: int = 0,
|
|
limit: int = Query(default=60, le=200),
|
|
offset: int = 0,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
query, _status = _filtered_query(db, user, **params)
|
|
if sort == "priority":
|
|
query = query.order_by(
|
|
Subscription.priority.desc(), Video.published_at.desc().nulls_last()
|
|
)
|
|
else:
|
|
order = SORTS.get(sort)
|
|
if order is None and sort == "shuffle":
|
|
order = func.md5(func.concat(Video.id, str(seed)))
|
|
query = query.order_by(order if order is not None else SORTS["newest"])
|
|
|
|
rows = db.execute(query.offset(offset).limit(limit + 1)).all()
|
|
has_more = len(rows) > limit
|
|
return {
|
|
"items": [_serialize(r) for r in rows[:limit]],
|
|
"has_more": has_more,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
}
|
|
|
|
|
|
@router.get("/feed/count")
|
|
def get_feed_count(
|
|
params: dict = Depends(_feed_params),
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
query, _status = _filtered_query(db, user, **params)
|
|
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
|
return {"count": total or 0}
|
|
|
|
|
|
@router.post("/videos/{video_id}/state")
|
|
def set_video_state(
|
|
video_id: str,
|
|
payload: dict,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
status = payload.get("status")
|
|
if status not in VALID_STATES:
|
|
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}")
|
|
if db.get(Video, video_id) is None:
|
|
raise HTTPException(status_code=404, detail="Unknown video")
|
|
|
|
row = db.execute(
|
|
select(VideoState).where(
|
|
VideoState.user_id == user.id, VideoState.video_id == video_id
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
if status == "new":
|
|
if row is not None:
|
|
db.delete(row)
|
|
db.commit()
|
|
return {"video_id": video_id, "status": "new"}
|
|
|
|
if row is None:
|
|
row = VideoState(user_id=user.id, video_id=video_id)
|
|
db.add(row)
|
|
row.status = status
|
|
row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at
|
|
db.commit()
|
|
return {"video_id": video_id, "status": status}
|
|
|
|
|
|
@router.get("/videos/{video_id}")
|
|
def get_video_detail(
|
|
video_id: str,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""On-demand detail (description, like count) — kept out of the feed list payload
|
|
so the feed stays lean; fetched lazily, e.g. for the title hover popover.
|
|
|
|
Videos we already store are served from the DB for free. A video that isn't in
|
|
our DB (e.g. a YouTube link inside another video's description that the in-app
|
|
player navigated to) is resolved via the YouTube API (videos.list, 1 unit,
|
|
attributed to the requesting user)."""
|
|
v = db.get(Video, video_id)
|
|
if v is not None:
|
|
return {
|
|
"id": v.id,
|
|
"description": v.description,
|
|
"like_count": v.like_count,
|
|
"in_db": True,
|
|
"channel_id": v.channel_id,
|
|
"channel_title": v.channel.title if v.channel else None,
|
|
"published_at": v.published_at.isoformat() if v.published_at else None,
|
|
"view_count": v.view_count,
|
|
"duration_seconds": v.duration_seconds,
|
|
}
|
|
|
|
try:
|
|
with quota.attribute(user.id, "video_lookup"), YouTubeClient(db, user) as yt:
|
|
items = yt.get_videos([video_id])
|
|
except YouTubeError as exc:
|
|
raise HTTPException(status_code=502, detail=f"YouTube lookup failed: {exc}")
|
|
if not items:
|
|
raise HTTPException(status_code=404, detail="Unknown video")
|
|
|
|
snippet = items[0].get("snippet", {})
|
|
stats = items[0].get("statistics", {})
|
|
likes = stats.get("likeCount")
|
|
views = stats.get("viewCount")
|
|
return {
|
|
"id": video_id,
|
|
"description": snippet.get("description"),
|
|
"like_count": int(likes) if likes is not None else None,
|
|
"in_db": False,
|
|
"channel_id": snippet.get("channelId"),
|
|
"channel_title": snippet.get("channelTitle"),
|
|
"published_at": snippet.get("publishedAt"),
|
|
"view_count": int(views) if views is not None else None,
|
|
"duration_seconds": parse_iso8601_duration(
|
|
items[0].get("contentDetails", {}).get("duration")
|
|
),
|
|
}
|