This commit is contained in:
lz_db
2026-10-25 11:46:44 +08:00
parent 893dfed0ed
commit 494f667719
63 changed files with 5213 additions and 239 deletions
+141 -11
View File
@@ -20,7 +20,7 @@ from app.models.billing import WatchRecord
from app.models.incentive import VideoEarning
from app.models.interaction import Favorite, Like
from app.models.user import User
from app.models.video import Category, Tag, Video, VideoTag
from app.models.video import Category, Series, Tag, Video, VideoTag
from app.schemas.video import PublishVideoReq, UploadTokenReq
from app.services import membership_service, sensitive_service, storage_service, wallet_service
@@ -79,6 +79,18 @@ async def serialize_videos(
tags_map = await get_tags_map(db, video_ids)
# 内容来源(麻豆 / 91 ...)
from app.models.video import Source
source_ids = {v.source_id for v in videos if v.source_id}
source_map = {}
if source_ids:
source_map = {
r.id: r.name for r in (
await db.execute(select(Source.id, Source.name).where(Source.id.in_(source_ids)))
).all()
}
liked: set[int] = set()
favorited: set[int] = set()
if viewer_user_id:
@@ -101,6 +113,8 @@ async def serialize_videos(
"video_url": v.video_url,
"duration": v.duration,
"category_id": v.category_id,
"source_id": v.source_id or 0,
"source_name": source_map.get(v.source_id, ""),
"author": _author_brief(authors.get(v.user_id)),
"view_count": v.view_count,
"like_count": v.like_count,
@@ -114,6 +128,16 @@ async def serialize_videos(
]
async def list_sources(db: AsyncSession) -> dict:
"""启用中的内容来源列表(App 分类页来源筛选)"""
from app.models.video import Source
rows = (await db.execute(
select(Source).where(Source.status == 1).order_by(Source.sort, Source.id)
)).scalars().all()
return {"list": [{"source_id": s.id, "name": s.name, "code": s.code} for s in rows]}
async def get_tags_map(db: AsyncSession, video_ids: list[int]) -> dict[int, list[str]]:
if not video_ids:
return {}
@@ -157,21 +181,110 @@ async def get_feed(
cursor: str | None,
limit: int,
category_id: int | None,
source_id: int | None = None,
group: str | None = None,
) -> dict:
# group=series:系列型分类(如 AI短剧)按「剧」聚合,每部剧只返回首集一条
if group == "series":
return await _feed_series(db, identity, cursor, limit, category_id, source_id)
if feed_type == "latest":
return await _feed_latest(db, identity, cursor, limit, category_id)
return await _feed_latest(db, identity, cursor, limit, category_id, source_id)
if feed_type in ("liked", "viewed"):
return await _feed_ranked(db, identity, feed_type, cursor, limit, category_id)
return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_id)
return await _feed_ranked(db, identity, feed_type, cursor, limit, category_id, source_id)
return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_id, source_id)
async def _feed_series(
db: AsyncSession,
identity: Identity,
cursor: str | None,
limit: int,
category_id: int | None,
source_id: int | None = None,
) -> dict:
"""系列聚合 Feed:一部剧一条(视频 id 为首集),按剧集播放量倒序。
卡片封面/标题取 Series(回退首集封面),点击后客户端拿到的 video_id 即首集,
播放页会按 /videos/{id}/series 拉全集列表,从而支持选集。
"""
if not category_id:
return {"next_cursor": None, "list": []}
# 仅返回至少有一集已发布的系列
live_series_ids = select(Video.series_id).where(
Video.status == STATUS_PUBLISHED, Video.series_id.isnot(None)
).distinct()
stmt = select(Series).where(
Series.status == 1,
Series.category_id == category_id,
Series.id.in_(live_series_ids),
)
if source_id:
stmt = stmt.where(Series.source_id == source_id)
offset = int(_decode_cursor(cursor).get("o", 0))
rows = list(
(await db.execute(
stmt.order_by(Series.view_count.desc(), Series.id.desc())
.offset(offset)
.limit(limit + 1)
)).scalars()
)
page, rows = (len(rows) > limit), rows[:limit]
if not rows:
return {"next_cursor": None, "list": []}
# 取这些剧的全部已发布集数,按 episode_no + id 找首集,同时统计实际集数
series_ids = [s.id for s in rows]
episodes = list(
(await db.execute(
select(Video).where(
Video.series_id.in_(series_ids), Video.status == STATUS_PUBLISHED
)
)).scalars()
)
first_ep: dict[int, Video] = {}
ep_count: dict[int, int] = {}
for v in episodes:
ep_count[v.series_id] = ep_count.get(v.series_id, 0) + 1
cur = first_ep.get(v.series_id)
if cur is None or _ep_key(v) < _ep_key(cur):
first_ep[v.series_id] = v
first_videos = [first_ep[s.id] for s in rows if s.id in first_ep]
serialized = await serialize_videos(db, first_videos, identity.user_id)
by_id = {it["video_id"]: it for it in serialized}
items = []
for s in rows:
v = first_ep.get(s.id)
if v is None:
continue
it = by_id[v.id]
# 系列封面/标题优先,空值回退首集自身字段
it["title"] = s.title or it["title"]
it["cover_url"] = s.cover_url or it["cover_url"]
it["view_count"] = s.view_count
it["series_id"] = s.id
it["total_episodes"] = ep_count.get(s.id, s.total_episodes)
items.append(it)
next_cursor = _encode_cursor({"o": offset + limit}) if page else None
return {"next_cursor": next_cursor, "list": items}
def _ep_key(v: Video) -> tuple[int, int]:
"""首集排序键:episode_no 缺失视为最大,再按 id 兜底"""
return (v.episode_no if v.episode_no is not None else 1_000_000, v.id)
async def _feed_ranked(
db: AsyncSession, identity: Identity, feed_type: str, cursor: str | None,
limit: int, category_id: int | None,
limit: int, category_id: int | None, source_id: int | None = None,
) -> dict:
"""liked / viewed:按点赞量 / 播放量严格倒序(值 + id 打破并列游标)"""
col = Video.like_count if feed_type == "liked" else Video.view_count
stmt = await _base_pool_stmt(category_id)
stmt = _base_pool_stmt(category_id, source_id)
c = _decode_cursor(cursor)
if c:
val, vid = int(c["n"]), int(c["i"])
@@ -191,18 +304,21 @@ async def _feed_ranked(
}
async def _base_pool_stmt(category_id: int | None):
def _base_pool_stmt(category_id: int | None, source_id: int | None = None):
stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None))
if category_id:
stmt = stmt.where(Video.category_id == category_id)
if source_id:
stmt = stmt.where(Video.source_id == source_id)
return stmt
async def _feed_latest(
db: AsyncSession, identity: Identity, cursor: str | None, limit: int, category_id: int | None
db: AsyncSession, identity: Identity, cursor: str | None, limit: int, category_id: int | None,
source_id: int | None = None,
) -> dict:
"""latest:published_at 游标倒序(ts + id 打破并列)"""
stmt = await _base_pool_stmt(category_id)
stmt = _base_pool_stmt(category_id, source_id)
c = _decode_cursor(cursor)
if c:
ts, vid = datetime.fromtimestamp(c["p"]), int(c["i"])
@@ -260,6 +376,7 @@ async def _feed_scored(
cursor: str | None,
limit: int,
category_id: int | None,
source_id: int | None = None,
) -> dict:
"""recommend / hot:候选池打分 → 当日种子确定性加权抽样 → 当日去重(不足兜底)→ 偏移游标"""
now = datetime.now()
@@ -267,7 +384,7 @@ async def _feed_scored(
subject = identity.subject or "anon"
# 1. 候选池:近 30 天已发布,封顶 FEED_POOL_SIZE
stmt = (await _base_pool_stmt(category_id)).where(
stmt = _base_pool_stmt(category_id, source_id).where(
Video.published_at > now - timedelta(days=30)
).order_by(Video.published_at.desc()).limit(FEED_POOL_SIZE)
pool = list((await db.execute(stmt)).scalars())
@@ -505,7 +622,20 @@ async def list_categories(db: AsyncSession) -> list[dict]:
rows = (await db.execute(
select(Category).where(Category.status == 1).order_by(Category.sort, Category.id)
)).scalars().all()
return [{"category_id": c.id, "name": c.name} for c in rows]
# 系列型分类标记(该分类下存在正常状态的系列),App 据此按「剧」聚合展示
cnt_rows = (await db.execute(
select(Series.category_id, func.count()).where(Series.status == 1)
.group_by(Series.category_id)
)).all()
series_cnt = {cid: n for cid, n in cnt_rows}
return [
{
"category_id": c.id,
"name": c.name,
"has_series": series_cnt.get(c.id, 0) > 0,
}
for c in rows
]
# ---------- 上传发布(5.5 / 5.6 / 5.7) ----------