206 lines
8.7 KiB
Python
206 lines
8.7 KiB
Python
"""视频模块路由(/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,
|
||
})
|