Files
video/server/app/api/v1/videos.py
T
2026-10-16 13:51:00 +08:00

206 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视频模块路由(/videos/* + /tags/* + /search/*,对应《03-接口文档》第 5 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Form, Query, Request, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import ratelimit
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.video import EditVideoReq, PublishVideoReq, UploadTokenReq, WatchCompleteReq, WatchReq
from app.services import video_service
router = APIRouter(prefix="/videos", tags=["视频"])
router_tags = APIRouter(prefix="/tags", tags=["视频"])
router_search = APIRouter(prefix="/search", tags=["搜索"])
# 搜索限流:30 次/分钟(登录按用户,游客按 IP)
SEARCH_RATE_LIMIT = 30
# 上传凭证 + 发布限流:5 次/小时(同一动作桶)
UPLOAD_RATE_LIMIT = 5
# 注意:/videos/feed、/videos/categories、/videos/search、/videos/upload/* 须先于 /videos/{video_id} 注册
@router.get("/feed")
async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20),
category_id: int | None = Query(None),
type: str = Query("recommend", pattern="^(recommend|latest|hot)$"),
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.get_feed(db, redis, identity, type, cursor, limit, category_id))
@router.get("/categories")
async def get_categories(db: AsyncSession = Depends(get_db)):
return ok(await video_service.list_categories(db))
@router.get("/search")
async def search_videos(request: Request, keyword: str = Query(min_length=1, max_length=30),
sort: str = Query("hot", pattern="^(hot|latest)$"),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
subject = identity.subject or (request.client.host if request.client else "anon")
await ratelimit.hit_subject(redis, subject, "search", SEARCH_RATE_LIMIT, 60)
return ok(await video_service.search_videos(db, redis, identity, keyword, sort, page, page_size))
@router_search.get("/hot")
async def get_hot_search(redis: aioredis.Redis = Depends(get_redis)):
return ok(await video_service.get_hot_search(redis))
@router_tags.get("/{tag_name}/videos")
async def get_tag_videos(tag_name: str, sort: str = Query("hot", pattern="^(hot|latest)$"),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db), identity: Identity = Depends(get_identity)):
return ok(await video_service.get_tag_videos(db, identity, tag_name, sort, page, page_size))
@router.post("/upload/token")
async def get_upload_token(req: UploadTokenReq, redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "upload", UPLOAD_RATE_LIMIT, 3600, "上传过于频繁,请稍后再试")
return ok(await video_service.get_upload_token(redis, identity, req))
@router.post("/upload/file")
async def upload_file(file: UploadFile, object_key: str = Form(...),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await video_service.register_upload(redis, identity, object_key, file))
@router.post("")
async def publish_video(req: PublishVideoReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "upload", UPLOAD_RATE_LIMIT, 3600, "发布过于频繁,请稍后再试")
return ok(await video_service.publish_video(db, redis, identity, req))
@router.get("/{video_id}")
async def get_detail(video_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.get_detail(db, redis, identity, video_id))
@router.put("/{video_id}")
async def update_video(video_id: int, req: EditVideoReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await video_service.update_video(db, redis, identity, video_id, req))
@router.delete("/{video_id}")
async def delete_video(video_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await video_service.delete_video(db, redis, identity, video_id)
return ok()
@router.post("/{video_id}/watch")
async def report_watch(video_id: int, req: WatchReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.report_watch(db, redis, identity, video_id, req.position))
@router.post("/{video_id}/watch/complete")
async def report_complete(video_id: int, req: WatchCompleteReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.report_complete(db, redis, identity, video_id, req.watch_duration))
# ---------- 系列(Series)----------
@router.get("/series/{series_id}")
async def get_series(series_id: int, db: AsyncSession = Depends(get_db)):
"""系列详情 + 全部集数列表(按 episode_no 排序)"""
from app.models.video import Series, Video
from sqlalchemy import select
series = await db.get(Series, series_id)
if not series or series.status != 1:
raise BizException(errors.NOT_FOUND, "系列不存在或已隐藏")
# 关联视频(已发布)
stmt = select(Video).where(
Video.series_id == series_id, Video.status == 2
).order_by(Video.episode_no.asc(), Video.id.asc())
eps = (await db.execute(stmt)).scalars().all()
return ok({
"series": {
"id": series.id,
"title": series.title,
"cover_url": series.cover_url,
"description": series.description,
"total_episodes": len(eps),
},
"episodes": [
{
"video_id": v.id,
"episode_no": v.episode_no,
"title": v.title,
"cover_url": v.cover_url,
"duration": v.duration,
"video_url": v.video_url,
}
for v in eps
],
})
@router.get("/{video_id}/series")
async def get_video_series(video_id: int, db: AsyncSession = Depends(get_db)):
"""当前视频所属系列 + 上下集(用于 PlayerScreen 抽屉入口)"""
from app.models.video import Series, Video
from sqlalchemy import select
video = await db.get(Video, video_id)
if not video or not video.series_id or video.status != 2:
return ok({"has_series": False})
series = await db.get(Series, video.series_id)
if not series or series.status != 1:
return ok({"has_series": False})
stmt = select(Video).where(
Video.series_id == series.id, Video.status == 2
).order_by(Video.episode_no.asc(), Video.id.asc())
all_eps = (await db.execute(stmt)).scalars().all()
# 找当前集索引
current_idx = next((i for i, v in enumerate(all_eps) if v.id == video_id), -1)
prev_ep = all_eps[current_idx - 1] if current_idx > 0 else None
next_ep = all_eps[current_idx + 1] if 0 <= current_idx < len(all_eps) - 1 else None
return ok({
"has_series": True,
"current_episode_no": video.episode_no,
"series": {
"id": series.id,
"title": series.title,
"cover_url": series.cover_url,
"total_episodes": len(all_eps),
},
"episodes": [
{
"video_id": v.id,
"episode_no": v.episode_no,
"title": v.title,
"cover_url": v.cover_url,
"duration": v.duration,
}
for v in all_eps
],
"prev_video_id": prev_ep.id if prev_ep else None,
"next_video_id": next_ep.id if next_ep else None,
})