Add channels view toggle on the main page
New GET /api/channels/activity returns subscribed channels sorted by their latest video (no-video channels last) with the 3 newest videos per channel and cursor pagination. The feed page gains a 'Лента | Каналы' toggle (?view=channels) with compact video cards per channel row; category filters and the sidebar preserve the view.
This commit is contained in:
parent
0dc1336951
commit
d4950f6979
13 changed files with 721 additions and 16 deletions
|
|
@ -1,7 +1,8 @@
|
|||
import base64
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -14,13 +15,19 @@ from app.models.channel import Channel
|
|||
from app.models.channel_category import channel_categories
|
||||
from app.models.video import Video
|
||||
from app.services import sync
|
||||
from app.services.download_jobs import latest_jobs_map
|
||||
from app.services.google_oauth import OAuthNotConnected
|
||||
from app.services.video_presentation import channel_categories_map, serialize_video
|
||||
from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_session)])
|
||||
|
||||
ACTIVITY_DEFAULT_LIMIT = 20
|
||||
ACTIVITY_MAX_LIMIT = 100
|
||||
ACTIVITY_VIDEOS_PER_CHANNEL = 3
|
||||
|
||||
|
||||
class ChannelCategoriesUpdate(BaseModel):
|
||||
category_ids: list[int]
|
||||
|
|
@ -101,6 +108,131 @@ def list_channels(
|
|||
return [_serialize(c, category_map.get(c.id, []), new_videos_map.get(c.id, 0)) for c in channels]
|
||||
|
||||
|
||||
def _encode_activity_cursor(last_published_at: datetime | None, channel_id: int) -> str:
|
||||
raw = f"{last_published_at.isoformat() if last_published_at else ''}|{channel_id}"
|
||||
return base64.urlsafe_b64encode(raw.encode()).decode()
|
||||
|
||||
|
||||
def _decode_activity_cursor(cursor: str) -> tuple[datetime | None, int]:
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
||||
iso_raw, channel_id_raw = raw.rsplit("|", 1)
|
||||
published_at = datetime.fromisoformat(iso_raw) if iso_raw else None
|
||||
if published_at is not None and published_at.tzinfo is None:
|
||||
published_at = published_at.replace(tzinfo=timezone.utc)
|
||||
return published_at, int(channel_id_raw)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid cursor")
|
||||
|
||||
|
||||
# NOTE: this route must stay declared BEFORE /channels/{channel_id}, otherwise
|
||||
# "activity" is captured by the path parameter and validation fails with 422.
|
||||
@router.get("/channels/activity")
|
||||
def channel_activity(
|
||||
category_id: int | None = None,
|
||||
uncategorized: bool = False,
|
||||
limit: int = Query(ACTIVITY_DEFAULT_LIMIT, ge=1, le=ACTIVITY_MAX_LIMIT),
|
||||
cursor: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Subscribed channels ordered by the freshness of their latest video
|
||||
(channels without videos go last), with up to 3 recent videos each.
|
||||
Cursor is (last_published_at, channel_id); an empty iso marks the
|
||||
no-videos tail so pagination stays lossless across that boundary."""
|
||||
latest_video = (
|
||||
select(Video.channel_id, func.max(Video.published_at).label("last_published_at"))
|
||||
.group_by(Video.channel_id)
|
||||
.subquery()
|
||||
)
|
||||
query = db.query(Channel, latest_video.c.last_published_at).outerjoin(
|
||||
latest_video, latest_video.c.channel_id == Channel.id
|
||||
)
|
||||
query = query.filter(Channel.subscribed.is_(True))
|
||||
|
||||
if uncategorized:
|
||||
categorized_ids = select(channel_categories.c.channel_id)
|
||||
query = query.filter(~Channel.id.in_(categorized_ids))
|
||||
elif category_id is not None:
|
||||
channel_ids_in_category = select(channel_categories.c.channel_id).where(
|
||||
channel_categories.c.category_id == category_id
|
||||
)
|
||||
query = query.filter(Channel.id.in_(channel_ids_in_category))
|
||||
|
||||
if cursor:
|
||||
cursor_published_at, cursor_channel_id = _decode_activity_cursor(cursor)
|
||||
if cursor_published_at is not None:
|
||||
# Channels without videos sort last (NULLS LAST), so they are
|
||||
# also "after" this cursor and must pass the filter; the ORDER BY
|
||||
# keeps them behind every channel that still has videos.
|
||||
query = query.filter(
|
||||
(latest_video.c.last_published_at < cursor_published_at)
|
||||
| (
|
||||
(latest_video.c.last_published_at == cursor_published_at)
|
||||
& (Channel.id < cursor_channel_id)
|
||||
)
|
||||
| latest_video.c.last_published_at.is_(None)
|
||||
)
|
||||
else:
|
||||
query = query.filter(
|
||||
latest_video.c.last_published_at.is_(None) & (Channel.id < cursor_channel_id)
|
||||
)
|
||||
|
||||
rows = (
|
||||
query.order_by(latest_video.c.last_published_at.desc().nullslast(), Channel.id.desc())
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
last_channel, last_published_at = rows[limit - 1]
|
||||
next_cursor = _encode_activity_cursor(last_published_at, last_channel.id)
|
||||
rows = rows[:limit]
|
||||
|
||||
channels_by_id = {channel.id: channel for channel, _ in rows}
|
||||
channel_ids = list(channels_by_id)
|
||||
videos_by_channel: dict[int, list[Video]] = {channel_id: [] for channel_id in channel_ids}
|
||||
|
||||
if channel_ids:
|
||||
# Top-N videos per channel in a single windowed query (no N+1).
|
||||
row_number = func.row_number().over(
|
||||
partition_by=Video.channel_id,
|
||||
order_by=(Video.published_at.desc(), Video.id.desc()),
|
||||
).label("rn")
|
||||
windowed = (
|
||||
db.query(Video.id, row_number)
|
||||
.filter(Video.channel_id.in_(channel_ids))
|
||||
.subquery()
|
||||
)
|
||||
recent_ids = select(windowed.c.id).where(windowed.c.rn <= ACTIVITY_VIDEOS_PER_CHANNEL)
|
||||
videos = db.query(Video).filter(Video.id.in_(recent_ids)).all()
|
||||
for video in videos:
|
||||
videos_by_channel[video.channel_id].append(video)
|
||||
for channel_videos in videos_by_channel.values():
|
||||
channel_videos.sort(key=lambda video: (video.published_at, video.id), reverse=True)
|
||||
|
||||
categories_map = channel_categories_map(db, channel_ids)
|
||||
jobs_map = latest_jobs_map(db, [video.id for videos in videos_by_channel.values() for video in videos])
|
||||
new_videos_map = _new_videos_counts(db, channel_ids)
|
||||
|
||||
items = []
|
||||
for channel, _ in rows:
|
||||
categories = categories_map.get(channel.id, [])
|
||||
items.append(
|
||||
{
|
||||
"channel": _serialize(
|
||||
channel, [category["id"] for category in categories], new_videos_map.get(channel.id, 0)
|
||||
),
|
||||
"videos": [
|
||||
serialize_video(video, channel, categories, jobs_map.get(video.id))
|
||||
for video in videos_by_channel.get(channel.id, [])
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return {"items": items, "next_cursor": next_cursor}
|
||||
|
||||
|
||||
@router.get("/channels/{channel_id}")
|
||||
def get_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
channel = db.get(Channel, channel_id)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue