1
This commit is contained in:
@@ -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) ----------
|
||||
|
||||
Reference in New Issue
Block a user