"""视频模块路由(/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), source_id: int | None = Query(None), type: str = Query("recommend", pattern="^(recommend|latest|hot|liked|viewed)$"), group: str | None = Query(None, pattern="^(series)$"), 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, source_id, group)) @router.get("/categories") async def get_categories(db: AsyncSession = Depends(get_db)): return ok(await video_service.list_categories(db)) @router.get("/sources") async def get_sources(db: AsyncSession = Depends(get_db)): """内容来源列表(麻豆/91...),供 App 分类页筛选""" return ok(await video_service.list_sources(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() # 站点管理员(按注册邮箱判定):可在 App 长按弹窗里下架任意视频 SITE_ADMIN_EMAIL = "256kb@gmail.com" @router.post("/{video_id}/offline") async def offline_video_app( video_id: int, reason: str = Form(default="管理员长按下架"), db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), identity: Identity = Depends(require_user), ): """App 端长按下架(仅限指定管理员邮箱);与后台 /admin/videos/{id}/offline 复用同一业务""" from sqlalchemy import select from app.core import errors from app.core.response import BizException from app.deps import AdminIdentity from app.models.user import User from app.services import admin_service user = (await db.execute( select(User).where(User.id == identity.user_id) )).scalars().first() if user is None or (user.email or "").lower() != SITE_ADMIN_EMAIL: raise BizException(errors.FORBIDDEN, "无权限") admin = AdminIdentity(admin_id=user.id, role="super") await admin_service.offline_video(db, redis, admin, video_id, reason, ip="app") 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, "source_id": series.source_id or 0, "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 # 系列所属来源名称 from app.models.video import Source source_name = "" if series.source_id: source_name = (await db.execute( select(Source.name).where(Source.id == series.source_id) )).scalar_one_or_none() or "" return ok({ "has_series": True, "current_episode_no": video.episode_no, "series": { "id": series.id, "title": series.title, "cover_url": series.cover_url, "source_id": series.source_id or 0, "source_name": source_name, "total_episodes": len(all_eps), }, "episodes": [ { "video_id": v.id, "episode_no": v.episode_no, "title": v.title, "cover_url": v.cover_url, "video_url": v.video_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, })