- 播放上下文:作品链接带上来源(搜索、标签、作者、hashtag),上一条/下一条 在该列表内移动;当前作品不在列表中时退回全部作品的发布时间顺序 - 顶部提示标签显示当前列表与位置,点击打开侧栏列表,✕ 退出 - 侧栏改为「评论 | TA的作品 | 搜索结果/#hashtag」标签页,作品封面网格可就地切换 - 作者模式:打开「TA的作品」进入,关闭侧栏不退出,点提示 ✕ 后回到原来的列表 - 从搜索结果进入或点 hashtag 时自动打开对应列表 - 描述中的 hashtag 可点击,按完整 hashtag 精确匹配(#丰川祥子 不含 #丰川祥子cos) - 新增 /api/playlist;搜索 SQL 抽到 lib/search-query.ts 与播放页共用,保证顺序一致 - 移动端:底部抽屉、点作品后收起,媒体区支持上下滑切换 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
6.2 KiB
TypeScript
200 lines
6.2 KiB
TypeScript
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<string[]> {
|
|
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<ResolvedPlayback> {
|
|
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<string, PlaylistItem>();
|
|
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 };
|
|
}
|