- {item.desc} -
- - {item.type === "video" ? "视频" : "图文"} - -diff --git a/README.md b/README.md index 2950c50..361646c 100644 --- a/README.md +++ b/README.md @@ -104,3 +104,9 @@ npx tsx scripts/stt-smoke.ts 7682010237578136858 ``` 脚本将供应商任务 ID 和结果保存在 `/tmp/douyin-asr-<视频ID>.json`。已有未完成任务时应查询原任务,不要重复提交。 + +### 手动标签兼容 + +保留服务器版本的手动标签、首页批量打标签、详情页编辑及标签筛选;标签只来自 `Video.manual_content_tags`,不再回退到模型自动分类。转写成功、失败或重试都不会修改手动标签。后台转写列表支持手动标签筛选与批量添加/清空。 + +已有 `/admin/asr`、`/admin/asr/videos`、`/api/admin/asr/videos` 地址继续可用;`/api/asr` 与 `/api/stt` 一样,POST 入队、GET 只读。旧内存批处理和分块模型调用已移除。 diff --git a/app/admin/asr/page.tsx b/app/admin/asr/page.tsx new file mode 100644 index 0000000..49b7813 --- /dev/null +++ b/app/admin/asr/page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Link from "next/link"; +import BackButton from "@/app/components/BackButton"; + +export default function AsrAdminPage() { + return ( +
管理和配置视频语音识别功能
++ 查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写 +
++ 配置转写 API、模型参数和其他高级选项 +
++ 查看转写使用情况、成功率、语言分布等统计数据 +
+管理和配置视频语音转写功能
-- 查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写 -
-- 配置转写 API、模型参数和其他高级选项 -
-- 查看转写使用情况、成功率、语言分布等统计数据 -
-| + 0 && + shown.slice(0, 200).every((v) => selected.has(v.aweme_id)) + } + onChange={(e) => + setSelected( + e.target.checked + ? new Set(shown.slice(0, 200).map((v) => v.aweme_id)) + : new Set(), + ) + } + /> + | 视频 | 时长 | 状态与文字 | @@ -175,6 +265,24 @@ export default function SttVideosPage() { key={video.aweme_id} className="border-t border-gray-100 align-top" > ++ = 200 + } + onChange={(e) => + setSelected((previous) => { + const next = new Set(previous); + if (e.target.checked) next.add(video.aweme_id); + else next.delete(video.aweme_id); + return next; + }) + } + /> + |
{video.cover_url && (
@@ -197,6 +305,9 @@ export default function SttVideosPage() {
@{video.author.nickname} ++ {video.content_tags.join("、")} + |
diff --git a/app/api/admin/asr/videos/route.ts b/app/api/admin/asr/videos/route.ts
new file mode 100644
index 0000000..8088631
--- /dev/null
+++ b/app/api/admin/asr/videos/route.ts
@@ -0,0 +1 @@
+export { GET } from "../../stt/videos/route";
diff --git a/app/api/admin/stt/videos/route.ts b/app/api/admin/stt/videos/route.ts
index cff2223..78bc5df 100644
--- a/app/api/admin/stt/videos/route.ts
+++ b/app/api/admin/stt/videos/route.ts
@@ -31,6 +31,7 @@ export async function GET() {
author: {
nickname: v.author.nickname,
},
+ content_tags: v.manual_content_tags,
transcription: transcriptionState(v.transcript, v.transcriptionTask),
}));
diff --git a/app/api/asr/route.ts b/app/api/asr/route.ts
new file mode 100644
index 0000000..64a93cf
--- /dev/null
+++ b/app/api/asr/route.ts
@@ -0,0 +1 @@
+export { GET, POST } from "../stt/route";
diff --git a/app/api/asr/tags/route.ts b/app/api/asr/tags/route.ts
new file mode 100644
index 0000000..f6e3b65
--- /dev/null
+++ b/app/api/asr/tags/route.ts
@@ -0,0 +1,121 @@
+export const runtime = "nodejs";
+
+import { type NextRequest, NextResponse } from "next/server";
+import { isVideoContentTag } from "@/lib/video-tags";
+import { prisma } from "@/lib/prisma";
+
+type UpdateMode = "set" | "add";
+
+function uniqueValidTags(tags: unknown[]) {
+ const validTags: string[] = [];
+ for (const tag of tags) {
+ if (typeof tag !== "string" || !isVideoContentTag(tag)) {
+ throw new Error("Invalid tag");
+ }
+ if (!validTags.includes(tag)) validTags.push(tag);
+ }
+ return validTags;
+}
+
+export async function POST(req: NextRequest) {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ if (!body || typeof body !== "object") {
+ return NextResponse.json(
+ { error: "Invalid request body" },
+ { status: 400 },
+ );
+ }
+
+ const raw = body as {
+ awemeId?: unknown;
+ awemeIds?: unknown;
+ tag?: unknown;
+ contentTags?: unknown;
+ mode?: unknown;
+ };
+ const awemeIds = Array.isArray(raw.awemeIds)
+ ? raw.awemeIds
+ : typeof raw.awemeId === "string"
+ ? [raw.awemeId]
+ : [];
+ const normalizedAwemeIds = [...new Set(awemeIds)]
+ .filter(
+ (id): id is string => typeof id === "string" && id.trim().length > 0,
+ )
+ .map((id) => id.trim());
+
+ if (normalizedAwemeIds.length === 0 || normalizedAwemeIds.length > 200) {
+ return NextResponse.json(
+ { error: "awemeIds must contain 1 to 200 ids" },
+ { status: 400 },
+ );
+ }
+
+ const mode: UpdateMode = raw.mode === "add" ? "add" : "set";
+ const rawTags = Array.isArray(raw.contentTags)
+ ? raw.contentTags
+ : typeof raw.tag === "string"
+ ? [raw.tag]
+ : [];
+
+ let contentTags: string[];
+ try {
+ contentTags = uniqueValidTags(rawTags);
+ } catch {
+ return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
+ }
+
+ if (mode === "add" && contentTags.length === 0) {
+ return NextResponse.json(
+ { error: "add mode requires at least one tag" },
+ { status: 400 },
+ );
+ }
+
+ const videos = await prisma.video.findMany({
+ where: { aweme_id: { in: normalizedAwemeIds } },
+ select: {
+ aweme_id: true,
+ manual_content_tags: true,
+ },
+ });
+
+ if (videos.length === 0) {
+ return NextResponse.json({ error: "No videos found" }, { status: 404 });
+ }
+
+ const updates = videos.map((video) => {
+ const baseTags = video.manual_content_tags;
+ const manualTags =
+ mode === "add"
+ ? [...new Set([...baseTags, ...contentTags])]
+ : contentTags;
+ return { awemeId: video.aweme_id, manualTags };
+ });
+
+ await prisma.$transaction(
+ updates.map((update) =>
+ prisma.video.update({
+ where: { aweme_id: update.awemeId },
+ data: { manual_content_tags: update.manualTags },
+ }),
+ ),
+ );
+
+ return NextResponse.json({
+ updated: updates.map((update) => ({
+ aweme_id: update.awemeId,
+ manual_content_tags: update.manualTags,
+ content_tags: update.manualTags,
+ })),
+ missing: normalizedAwemeIds.filter(
+ (id) => !videos.some((video) => video.aweme_id === id),
+ ),
+ });
+}
diff --git a/app/api/feed/route.ts b/app/api/feed/route.ts
index 1ef8ec4..241adf5 100644
--- a/app/api/feed/route.ts
+++ b/app/api/feed/route.ts
@@ -1,7 +1,8 @@
-import { NextRequest, NextResponse } from "next/server";
-import { prisma } from "@/lib/prisma";
+import { type NextRequest, NextResponse } from "next/server";
import type { FeedItem, FeedResponse } from "@/app/types/feed";
+import { isVideoContentTag } from "@/lib/video-tags";
import { getFileUrl } from "@/lib/minio";
+import { prisma } from "@/lib/prisma";
// Contract
// Inputs: search params { before?: ISOString, limit?: number }
@@ -11,24 +12,45 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const limitParam = searchParams.get("limit");
const beforeParam = searchParams.get("before");
+ const rawTag = (searchParams.get("tag") || "").trim();
+ const selectedTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null;
+
+ if (rawTag && !selectedTag) {
+ return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
+ }
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
const before = beforeParam ? new Date(beforeParam) : null;
+ const videoWhere = {
+ ...(before ? { created_at: { lt: before } } : {}),
+ ...(selectedTag
+ ? {
+ manual_content_tags: { has: selectedTag },
+ }
+ : {}),
+ };
// fetch chunk from both tables
const [videos, posts] = await Promise.all([
prisma.video.findMany({
- where: before ? { created_at: { lt: before } } : undefined,
+ where: videoWhere,
orderBy: { created_at: "desc" },
take: limit,
- include: { author: true },
- }),
- prisma.imagePost.findMany({
- where: before ? { created_at: { lt: before } } : undefined,
- orderBy: { created_at: "desc" },
- take: limit,
- include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
+ include: {
+ author: true,
+ },
}),
+ selectedTag
+ ? Promise.resolve([])
+ : prisma.imagePost.findMany({
+ where: before ? { created_at: { lt: before } } : undefined,
+ orderBy: { created_at: "desc" },
+ take: limit,
+ include: {
+ author: true,
+ images: { orderBy: { order: "asc" }, take: 1 },
+ },
+ }),
]);
const merged: FeedItem[] = [
@@ -47,29 +69,33 @@ export async function GET(req: NextRequest) {
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count),
+ content_tags: v.manual_content_tags,
})),
- ...posts.map((p) => ({
- type: "image" as const,
- aweme_id: p.aweme_id,
- created_at: p.created_at,
- desc: p.desc,
- cover_url: getFileUrl(p.images?.[0]?.url ?? null),
- width: p.images?.[0]?.width ?? null,
- height: p.images?.[0]?.height ?? null,
- author: {
- nickname: p.author.nickname,
- avatar_url: getFileUrl(p.author.avatar_url ?? ""),
- sec_uid: p.author.sec_uid,
- },
- likes: Number(p.digg_count),
- })),
+ ...posts.map((p) => {
+ const coverPath = p.images?.[0]?.url;
+ return {
+ type: "image" as const,
+ aweme_id: p.aweme_id,
+ created_at: p.created_at,
+ desc: p.desc,
+ cover_url: coverPath ? getFileUrl(coverPath) : null,
+ width: p.images?.[0]?.width ?? null,
+ height: p.images?.[0]?.height ?? null,
+ author: {
+ nickname: p.author.nickname,
+ avatar_url: getFileUrl(p.author.avatar_url ?? ""),
+ sec_uid: p.author.sec_uid,
+ },
+ likes: Number(p.digg_count),
+ };
+ }),
]
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
.slice(0, limit);
const nextCursor =
merged.length > 0
- ? new Date(merged[merged.length - 1].created_at as any).toISOString()
+ ? new Date(merged[merged.length - 1].created_at).toISOString()
: null;
const payload: FeedResponse = { items: merged, nextCursor };
return NextResponse.json(payload);
diff --git a/app/api/search/route.ts b/app/api/search/route.ts
index 4bc728c..d9ed851 100644
--- a/app/api/search/route.ts
+++ b/app/api/search/route.ts
@@ -1,11 +1,32 @@
+import { NextResponse } from "next/server";
+import { isVideoContentTag } from "@/lib/video-tags";
import { json } from "@/lib/json";
import { getFileUrl } from "@/lib/minio";
-import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例
-import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+
+type SearchRow = {
+ id: string;
+ awemeId: string;
+ type: "video" | "image";
+ rank: number;
+ snippet: string;
+ contentTags: string[];
+};
+
+function sanitizeSnippetHtml(snippet: string) {
+ return snippet
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("<mark>", "")
+ .replaceAll("</mark>", "");
+}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = (searchParams.get("q") || "").trim();
+ const rawTag = (searchParams.get("tag") || "").trim();
+ const contentTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null;
const page = Math.max(1, Number(searchParams.get("page") || 1));
const limit = Math.min(
50,
@@ -13,7 +34,11 @@ export async function GET(req: Request) {
);
const offset = (page - 1) * limit;
- if (!q) {
+ if (rawTag && !contentTag) {
+ return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
+ }
+
+ if (!q && !contentTag) {
return NextResponse.json({ results: [], total: 0 });
}
@@ -23,90 +48,117 @@ export async function GET(req: Request) {
// - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc
// - 使用 UNION ALL 合并视频和图文的搜索结果
// - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配
- const rows = await prisma.$queryRaw<
- {
- id: string;
- awemeId: string;
- type: "video" | "image";
- rank: number;
- snippet: string;
- }[]
- >`
- WITH tsq AS (
- SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
- )
- -- 视频搜索结果
- SELECT
- COALESCE(vt.id, 'desc:' || v.aweme_id) AS id,
- v.aweme_id AS "awemeId",
- 'video'::text AS type,
- GREATEST(
- COALESCE(ts_rank(vt.transcript_zh_tsv, tsq.query), 0),
- COALESCE(ts_rank(to_tsvector('zhcfg', v.desc), tsq.query), 0)
- ) AS rank,
- CASE
- WHEN vt.transcript_zh_tsv @@ tsq.query THEN
+ const rows = q
+ ? await prisma.$queryRaw
|---|