Split shorts from regular videos with a vertical shorts feed
- Detect shorts by duration (SHORTS_MAX_DURATION_SECONDS, default 180)
and expose is_short in feed, video and channel-activity DTOs.
- Feed gains type=all|long|short and anchor; lists show a two-mode
'Обычные | Shorts' filter (no 'Все') persisted in the URL.
- Clicking a short opens /shorts/🆔 a vertical scroll-snap feed with
autoplay for the active slide, context-aware endpoints and infinite
loading.
- Keep the sound choice across swipes, syncing with the player's own
mute control and guarding against the widget's stale isMuted() reads.
This commit is contained in:
parent
43adec5224
commit
252f38597d
25 changed files with 1297 additions and 88 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import base64
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
|
|
@ -82,6 +83,8 @@ def get_feed(
|
|||
downloaded: bool = False,
|
||||
search: str | None = Query(None, max_length=200),
|
||||
new_only: bool = False,
|
||||
video_type: Literal["all", "long", "short"] = Query("all", alias="type"),
|
||||
anchor: str | None = None,
|
||||
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
|
||||
cursor: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
|
|
@ -118,6 +121,33 @@ def get_feed(
|
|||
or_(Video.title.ilike(needle, escape="\\"), Channel.title.ilike(needle, escape="\\"))
|
||||
)
|
||||
|
||||
if video_type == "short":
|
||||
query = query.filter(
|
||||
Video.duration_seconds.is_not(None),
|
||||
Video.duration_seconds <= settings.shorts_max_duration_seconds,
|
||||
)
|
||||
elif video_type == "long":
|
||||
query = query.filter(
|
||||
or_(
|
||||
Video.duration_seconds.is_(None),
|
||||
Video.duration_seconds > settings.shorts_max_duration_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
# The anchor is a one-off starting point for the Shorts feed: only the
|
||||
# first page (no cursor) is shifted to start at that video and go older.
|
||||
# Unknown anchors degrade silently to a regular first page.
|
||||
if anchor and not cursor:
|
||||
anchor_video = db.query(Video).filter(Video.youtube_video_id == anchor).first()
|
||||
if anchor_video is not None:
|
||||
query = query.filter(
|
||||
(Video.published_at < anchor_video.published_at)
|
||||
| (
|
||||
(Video.published_at == anchor_video.published_at)
|
||||
& (Video.id <= anchor_video.id)
|
||||
)
|
||||
)
|
||||
|
||||
if cursor:
|
||||
cursor_published_at, cursor_id = _decode_cursor(cursor)
|
||||
query = query.filter(
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ class Settings(BaseSettings):
|
|||
# we have already synced).
|
||||
videos_known_stop_threshold: int = 50
|
||||
new_videos_window_days: int = 2
|
||||
# Videos with a known duration at or below this threshold (seconds) are
|
||||
# treated as Shorts; unknown duration (NULL) counts as a regular video.
|
||||
shorts_max_duration_seconds: int = 180
|
||||
|
||||
log_level: str = "INFO"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models.category import Category
|
||||
from app.models.channel import Channel
|
||||
from app.models.channel_category import channel_categories
|
||||
|
|
@ -51,6 +52,8 @@ def serialize_video(
|
|||
"thumbnail_url": video.thumbnail_url,
|
||||
"published_at": video.published_at,
|
||||
"duration_seconds": video.duration_seconds,
|
||||
"is_short": video.duration_seconds is not None
|
||||
and video.duration_seconds <= settings.shorts_max_duration_seconds,
|
||||
"youtube_url": video.youtube_url,
|
||||
"categories": categories,
|
||||
"local": _serialize_local(download_job),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue