import { Prisma } from "@prisma/client"; import { hashtagToPgPattern } from "@/lib/hashtag"; import { getFileUrl } from "@/lib/minio"; import { awemeHref, type PlaybackContext, type PlaylistItem, playbackContextQuery, } from "@/lib/playback-context"; import { prisma } from "@/lib/prisma"; import { parseSearchQuery } from "@/lib/search"; import { searchIds } from "@/lib/search-query"; export type NeighborLink = { aweme_id: string; href: string }; export type PlaybackNeighbors = { prev: NeighborLink | null; next: NeighborLink | null; }; export type PlaybackContextInfo = { kind: PlaybackContext["kind"]; /** 顶部提示里的说明,如「搜索「百姓」」 */ label: string; /** 侧栏列表标签页的标题,如「搜索结果」「#丰川祥子」 */ listTitle: string; /** 从 1 开始 */ position: number; total: number; }; export type ResolvedPlayback = { neighbors: PlaybackNeighbors; /** 当前生效的上下文(URL 里的上下文不包含当前作品时为 null,退回全部作品) */ context: PlaybackContextInfo | null; /** URL 里原本的上下文查询串,侧栏里跳转时原样带上 */ contextQuery: string; }; /** 侧栏一次最多列出的作品数(搜索词很宽泛时避免一次塞几千张封面) */ export const PLAYLIST_LIMIT = 500; // 全部作品 / 某作者的作品 / 带某个 hashtag 的作品:视频与图文合并,按发布时间倒序(与首页、作者页一致) async function timelineIds(filter: Prisma.Sql = Prisma.empty) { const rows = await prisma.$queryRaw<{ id: string }[]>` SELECT aweme_id AS id FROM ( SELECT aweme_id, created_at, "authorId", "desc" FROM "Video" UNION ALL SELECT aweme_id, created_at, "authorId", "desc" FROM "ImagePost" ) works ${filter} ORDER BY created_at DESC, aweme_id DESC `; return rows.map((r) => r.id); } // 数据量小(全库约两千个作品),直接取完整的有序 ID 列表再定位当前作品, // 比为每种上下文分别写游标查询简单得多。数据量大了再换成游标查询,前端不用动。 export async function contextIds( ctx: PlaybackContext | null, ): Promise { if (!ctx) return timelineIds(); switch (ctx.kind) { case "author": return timelineIds(Prisma.sql`WHERE "authorId" = ${ctx.secUid}`); case "hashtag": return timelineIds( Prisma.sql`WHERE lower("desc") ~ ${hashtagToPgPattern(ctx.name)}`, ); case "tag": return searchIds({ terms: [], excludes: [], contentTag: ctx.tag }); case "search": { const { terms, excludes } = parseSearchQuery(ctx.q); if (terms.length === 0 && !ctx.tag) return []; return searchIds({ terms, excludes, contentTag: ctx.tag }); } } } async function contextLabels( ctx: PlaybackContext, ): Promise<{ label: string; listTitle: string }> { switch (ctx.kind) { case "search": if (!ctx.q) return { label: `标签「${ctx.tag}」`, listTitle: "搜索结果" }; return { label: ctx.tag ? `搜索「${ctx.q}」· ${ctx.tag}` : `搜索「${ctx.q}」`, listTitle: "搜索结果", }; case "tag": return { label: `标签「${ctx.tag}」`, listTitle: ctx.tag }; case "hashtag": return { label: `#${ctx.name}`, listTitle: `#${ctx.name}` }; case "author": { const author = await prisma.author.findUnique({ where: { sec_uid: ctx.secUid }, select: { nickname: true }, }); const label = `${author?.nickname ?? "作者"} 的作品`; return { label, listTitle: label }; } } } function neighborsAt( ids: string[], index: number, query: string, ): PlaybackNeighbors { const link = (id: string | undefined) => id ? { aweme_id: id, href: awemeHref(id, query) } : null; return { prev: link(ids[index - 1]), next: link(ids[index + 1]) }; } export async function resolvePlayback( awemeId: string, ctx: PlaybackContext | null, ): Promise { const contextQuery = playbackContextQuery(ctx); if (ctx) { const [ids, labels] = await Promise.all([ contextIds(ctx), contextLabels(ctx), ]); const index = ids.indexOf(awemeId); if (index !== -1) { return { neighbors: neighborsAt(ids, index, contextQuery), context: { kind: ctx.kind, ...labels, position: index + 1, total: ids.length, }, contextQuery, }; } // 当前作品不在这个列表里(例如在作者模式里切到了不在搜索结果中的作品), // 退回全部作品的顺序;继续往下切时不再带上原来的上下文。 } const ids = await timelineIds(); const index = ids.indexOf(awemeId); return { neighbors: index === -1 ? { prev: null, next: null } : neighborsAt(ids, index, ""), context: null, contextQuery, }; } /** 按上下文顺序列出作品(供侧栏网格使用),最多 PLAYLIST_LIMIT 个。 */ export async function contextItems( ctx: PlaybackContext, ): Promise<{ items: PlaylistItem[]; total: number }> { const allIds = await contextIds(ctx); const ids = allIds.slice(0, PLAYLIST_LIMIT); const [videos, posts] = await Promise.all([ prisma.video.findMany({ where: { aweme_id: { in: ids } }, select: { aweme_id: true, desc: true, cover_url: true, digg_count: true }, }), prisma.imagePost.findMany({ where: { aweme_id: { in: ids } }, select: { aweme_id: true, desc: true, digg_count: true, images: { orderBy: { order: "asc" }, take: 1, select: { url: true } }, }, }), ]); const byId = new Map(); for (const v of videos) { byId.set(v.aweme_id, { aweme_id: v.aweme_id, type: "video", desc: v.desc, cover_url: v.cover_url ? getFileUrl(v.cover_url) : null, likes: Number(v.digg_count), }); } for (const p of posts) { byId.set(p.aweme_id, { aweme_id: p.aweme_id, type: "image", desc: p.desc, cover_url: p.images[0] ? getFileUrl(p.images[0].url) : null, likes: Number(p.digg_count), }); } const items = ids .map((id) => byId.get(id)) .filter((item): item is PlaylistItem => Boolean(item)); return { items, total: allIds.length }; }