From eac6ad23432cf1cf503bb391b3b7a9fae57232cb Mon Sep 17 00:00:00 2001 From: feie9454 Date: Wed, 29 Apr 2026 21:20:21 +0800 Subject: [PATCH] feat: add manual video tagging --- app/admin/asr/videos/page.tsx | 323 ++++++++++------ app/api/admin/asr/videos/route.ts | 49 ++- app/api/asr/route.ts | 37 +- app/api/asr/tags/route.ts | 127 +++++++ app/api/feed/route.ts | 17 +- app/api/search/route.ts | 35 +- app/aweme/[awemeId]/Client.tsx | 158 ++++---- .../[awemeId]/components/TranscriptPanel.tsx | 7 +- .../[awemeId]/components/VideoTagEditor.tsx | 138 +++++++ app/aweme/[awemeId]/hooks/useVideoPlayer.ts | 34 +- app/aweme/[awemeId]/page.tsx | 123 +++--- app/aweme/[awemeId]/types.ts | 3 + app/components/FeedMasonry.tsx | 350 ++++++++++++------ app/page.tsx | 21 +- lib/asr-tags.ts | 9 + .../migration.sql | 5 + prisma/schema.prisma | 48 +-- 17 files changed, 1066 insertions(+), 418 deletions(-) create mode 100644 app/api/asr/tags/route.ts create mode 100644 app/aweme/[awemeId]/components/VideoTagEditor.tsx create mode 100644 prisma/migrations/20260429_add_manual_video_tags/migration.sql diff --git a/app/admin/asr/videos/page.tsx b/app/admin/asr/videos/page.tsx index 615c722..2bfd283 100644 --- a/app/admin/asr/videos/page.tsx +++ b/app/admin/asr/videos/page.tsx @@ -20,6 +20,8 @@ type Video = { desc: string; cover_url: string; duration_ms: number; + content_tags: string[]; + manual_content_tags: string[]; author: { nickname: string; }; @@ -40,11 +42,19 @@ export default function AsrVideosPage() { "all" | "transcribed" | "pending" | "tagged" | "missingTags" >("all"); const [batchTranscribing, setBatchTranscribing] = useState(false); - const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 }); + const [batchProgress, setBatchProgress] = useState({ + current: 0, + total: 0, + completed: 0, + failed: 0, + currentAwemeId: null as string | null, + currentDesc: null as string | null, + }); + const [batchMessage, setBatchMessage] = useState(null); - const fetchVideos = useCallback(async () => { + const fetchVideos = useCallback(async (showLoading = true) => { try { - setLoading(true); + if (showLoading) setLoading(true); const response = await fetch("/api/admin/asr/videos"); if (!response.ok) { throw new Error("Failed to fetch videos"); @@ -54,7 +64,7 @@ export default function AsrVideosPage() { } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); } finally { - setLoading(false); + if (showLoading) setLoading(false); } }, []); @@ -66,8 +76,7 @@ export default function AsrVideosPage() { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const hasContentTags = (video: Video) => - (video.transcript?.content_tags.length ?? 0) > 0; + const hasContentTags = (video: Video) => video.content_tags.length > 0; type PollOptions = { intervalMs?: number; @@ -77,7 +86,7 @@ export default function AsrVideosPage() { // 轮询某个视频的识别和标签状态直至完成或超时 const pollVideoUntilTranscribed = async ( awemeId: string, - { intervalMs = 2000, maxAttempts = 30 }: PollOptions = {}, + { intervalMs = 2000, maxAttempts = 300 }: PollOptions = {}, ): Promise => { for (let attempt = 0; attempt < maxAttempts; attempt++) { try { @@ -86,7 +95,7 @@ export default function AsrVideosPage() { const data: ApiResponse = await resp.json(); setVideos(data.videos); const v = data.videos.find((x) => x.aweme_id === awemeId); - if ((v?.transcript?.content_tags.length ?? 0) > 0) { + if ((v?.content_tags.length ?? 0) > 0) { return true; } } @@ -101,7 +110,9 @@ export default function AsrVideosPage() { const handleTranscribe = async (awemeId: string) => { setTranscribing((prev) => new Set(prev).add(awemeId)); try { - const response = await fetch(`/api/asr?awemeId=${awemeId}`); + const response = await fetch( + `/api/asr?awemeId=${encodeURIComponent(awemeId)}&async=1`, + ); if (!response.ok) { throw new Error("Failed to transcribe video"); } @@ -109,7 +120,7 @@ export default function AsrVideosPage() { const done = await pollVideoUntilTranscribed(awemeId); if (!done) { // 兜底刷新一次 - await fetchVideos(); + await fetchVideos(false); } } catch (err) { alert(err instanceof Error ? err.message : "Transcription failed"); @@ -138,41 +149,58 @@ export default function AsrVideosPage() { } setBatchTranscribing(true); - setBatchProgress({ current: 0, total: pendingVideos.length }); + setBatchMessage(null); + setBatchProgress({ + current: 0, + total: pendingVideos.length, + completed: 0, + failed: 0, + currentAwemeId: null, + currentDesc: null, + }); - const pollers: Promise[] = []; for (let i = 0; i < pendingVideos.length; i++) { const video = pendingVideos[i]; - setBatchProgress({ current: i + 1, total: pendingVideos.length }); + setBatchProgress((prev) => ({ + ...prev, + current: i + 1, + currentAwemeId: video.aweme_id, + currentDesc: video.desc || "无描述", + })); setTranscribing((prev) => new Set(prev).add(video.aweme_id)); try { - const response = await fetch(`/api/asr?awemeId=${video.aweme_id}`); + const response = await fetch( + `/api/asr?awemeId=${encodeURIComponent(video.aweme_id)}&async=1`, + ); if (!response.ok) { - console.error(`Failed to transcribe video ${video.aweme_id}`); + throw new Error(`Failed to start ASR for ${video.aweme_id}`); } - // 为该视频启动后台轮询,不阻塞批处理的顺序执行 - const p = pollVideoUntilTranscribed(video.aweme_id).finally(() => { - setTranscribing((prev) => { - const next = new Set(prev); - next.delete(video.aweme_id); - return next; - }); - }); - pollers.push(p); + + const done = await pollVideoUntilTranscribed(video.aweme_id); + setBatchProgress((prev) => ({ + ...prev, + completed: done ? prev.completed + 1 : prev.completed, + failed: done ? prev.failed : prev.failed + 1, + })); } catch (err) { console.error(`Error transcribing video ${video.aweme_id}:`, err); + setBatchProgress((prev) => ({ + ...prev, + failed: prev.failed + 1, + })); + } finally { + setTranscribing((prev) => { + const next = new Set(prev); + next.delete(video.aweme_id); + return next; + }); } } - // 等待所有轮询结束(完成或超时),再进行最终刷新 - if (pollers.length > 0) { - await Promise.allSettled(pollers); - } - await fetchVideos(); + await fetchVideos(false); setBatchTranscribing(false); - setBatchProgress({ current: 0, total: 0 }); - alert("缺失标签生成完成!"); + setBatchMessage("缺失标签生成流程已结束"); }; const filteredVideos = videos.filter((v) => { @@ -190,6 +218,11 @@ export default function AsrVideosPage() { tagged: videos.filter((v) => hasContentTags(v)).length, missingTags: videos.filter((v) => !hasContentTags(v)).length, }; + const processedCount = batchProgress.completed + batchProgress.failed; + const progressPercent = + batchProgress.total > 0 + ? Math.round((processedCount / batchProgress.total) * 100) + : 0; if (loading) { return ( @@ -212,7 +245,7 @@ export default function AsrVideosPage() {

错误: {error}

- - - - - -
- {batchTranscribing && ( -
- 正在生成: {batchProgress.current} / {batchProgress.total} -
- )} - + + + + +
+ +
+ {batchTranscribing && ( +
+ 正在生成: {batchProgress.current} / {batchProgress.total} +
)} - + +
+ + {(batchTranscribing || batchMessage) && ( +
+
+ + {batchTranscribing ? "正在生成缺失标签" : batchMessage} + + + {processedCount} / {batchProgress.total} + +
+
+
+
+
+ + 已完成 {batchProgress.completed},失败/超时{" "} + {batchProgress.failed} + + {batchTranscribing && batchProgress.currentAwemeId && ( + + 当前: {batchProgress.currentDesc} ( + {batchProgress.currentAwemeId}) + + )} +
+
+ )}
@@ -429,28 +496,46 @@ export default function AsrVideosPage() { )} )} - {video.transcript.content_tags.length > 0 && ( + {video.content_tags.length > 0 && (
- {video.transcript.content_tags.map((tag) => ( + {video.content_tags.map((tag) => ( {tag} ))}
)} - {video.transcript.content_tags.length === 0 && ( + {video.content_tags.length === 0 && ( 未生成标签 )} ) : ( - - 待转写 - +
+ + 待转写 + + {video.content_tags.length > 0 && ( +
+ {video.content_tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
)} @@ -466,7 +551,7 @@ export default function AsrVideosPage() { 生成中... - ) : video.transcript.content_tags.length === 0 ? ( + ) : !hasContentTags(video) ? ( "生成标签" ) : ( "重新识别" diff --git a/app/api/admin/asr/videos/route.ts b/app/api/admin/asr/videos/route.ts index a62c0a3..e3d13fa 100644 --- a/app/api/admin/asr/videos/route.ts +++ b/app/api/admin/asr/videos/route.ts @@ -27,26 +27,35 @@ export async function GET() { }, }); - const formattedVideos = videos.map((v) => ({ - aweme_id: v.aweme_id, - desc: v.desc, - cover_url: getFileUrl(v.cover_url ?? ""), - duration_ms: v.duration_ms, - author: { - nickname: v.author.nickname, - }, - transcript: v.transcript - ? { - id: v.transcript.id, - speech_detected: v.transcript.speech_detected, - language: v.transcript.language, - audio_type: v.transcript.audio_type, - transcript: v.transcript.transcript, - non_speech_summary: v.transcript.non_speech_summary, - content_tags: v.transcript.content_tags, - } - : null, - })); + const formattedVideos = videos.map((v) => { + const contentTags = + v.manual_content_tags.length > 0 + ? v.manual_content_tags + : (v.transcript?.content_tags ?? []); + + return { + aweme_id: v.aweme_id, + desc: v.desc, + cover_url: getFileUrl(v.cover_url ?? ""), + duration_ms: v.duration_ms, + manual_content_tags: v.manual_content_tags, + content_tags: contentTags, + author: { + nickname: v.author.nickname, + }, + transcript: v.transcript + ? { + id: v.transcript.id, + speech_detected: v.transcript.speech_detected, + language: v.transcript.language, + audio_type: v.transcript.audio_type, + transcript: v.transcript.transcript, + non_speech_summary: v.transcript.non_speech_summary, + content_tags: v.transcript.content_tags, + } + : null, + }; + }); return NextResponse.json({ videos: formattedVideos, diff --git a/app/api/asr/route.ts b/app/api/asr/route.ts index 5bc7f74..6d2e9b7 100644 --- a/app/api/asr/route.ts +++ b/app/api/asr/route.ts @@ -3,6 +3,30 @@ export const runtime = "nodejs"; import { type NextRequest, NextResponse } from "next/server"; import { transcriptAweme } from "."; +const runningJobs = new Map>(); + +function startBackgroundAsr(awemeId: string) { + if (runningJobs.has(awemeId)) return false; + + const job = transcriptAweme(awemeId, { + log: (message) => console.log(`[ASR ${awemeId}] ${message}`), + }) + .then((result) => { + console.log( + `[ASR ${awemeId}] done tags=${result.content_tags.join("、")}`, + ); + }) + .catch((error) => { + console.error(`[ASR ${awemeId}] failed:`, error); + }) + .finally(() => { + runningJobs.delete(awemeId); + }); + + runningJobs.set(awemeId, job); + return true; +} + export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const awemeId = searchParams.get("awemeId"); @@ -12,8 +36,19 @@ export async function GET(req: NextRequest) { { status: 400 }, ); } + const asyncMode = + searchParams.get("async") === "1" || searchParams.get("mode") === "async"; + + if (asyncMode) { + const started = startBackgroundAsr(awemeId); + return NextResponse.json( + { awemeId, status: started ? "started" : "running" }, + { status: 202 }, + ); + } + try { - const result = await transcriptAweme(awemeId, /* {log: (msg) => console.log(`[ASR] ${msg}`)} */); + const result = await transcriptAweme(awemeId); return NextResponse.json(result); } catch (error) { console.error("ASR failed:", error); diff --git a/app/api/asr/tags/route.ts b/app/api/asr/tags/route.ts new file mode 100644 index 0000000..4a5580d --- /dev/null +++ b/app/api/asr/tags/route.ts @@ -0,0 +1,127 @@ +export const runtime = "nodejs"; + +import { type NextRequest, NextResponse } from "next/server"; +import { isAsrContentTag } from "@/lib/asr-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" || !isAsrContentTag(tag)) { + throw new Error("Invalid tag"); + } + if (!validTags.includes(tag)) validTags.push(tag); + } + return validTags; +} + +function resolveContentTags(manualTags: string[], asrTags: string[]) { + return manualTags.length > 0 ? manualTags : asrTags; +} + +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, + transcript: { select: { content_tags: true } }, + }, + }); + + if (videos.length === 0) { + return NextResponse.json({ error: "No videos found" }, { status: 404 }); + } + + const updates = videos.map((video) => { + const asrTags = video.transcript?.content_tags ?? []; + const baseTags = resolveContentTags(video.manual_content_tags, asrTags); + const manualTags = + mode === "add" + ? [...new Set([...baseTags, ...contentTags])] + : contentTags; + return { awemeId: video.aweme_id, manualTags, asrTags }; + }); + + 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: resolveContentTags(update.manualTags, update.asrTags), + })), + 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 301220c..a87225d 100644 --- a/app/api/feed/route.ts +++ b/app/api/feed/route.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import type { FeedItem, FeedResponse } from "@/app/types/feed"; -import { isAsrContentTag } from "@/lib/asr-tags"; +import { isAsrContentTag, resolveEffectiveContentTags } from "@/lib/asr-tags"; import { getFileUrl } from "@/lib/minio"; import { prisma } from "@/lib/prisma"; @@ -24,7 +24,15 @@ export async function GET(req: NextRequest) { const videoWhere = { ...(before ? { created_at: { lt: before } } : {}), ...(selectedTag - ? { transcript: { is: { content_tags: { has: selectedTag } } } } + ? { + OR: [ + { manual_content_tags: { has: selectedTag } }, + { + manual_content_tags: { isEmpty: true }, + transcript: { is: { content_tags: { has: selectedTag } } }, + }, + ], + } : {}), }; @@ -68,7 +76,10 @@ export async function GET(req: NextRequest) { sec_uid: v.author.sec_uid, }, likes: Number(v.digg_count), - content_tags: v.transcript?.content_tags ?? [], + content_tags: resolveEffectiveContentTags( + v.manual_content_tags, + v.transcript?.content_tags, + ), })), ...posts.map((p) => { const coverPath = p.images?.[0]?.url; diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 75668a0..9315fb1 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -78,7 +78,10 @@ export async function GET(req: Request) { 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' ) END AS snippet, - COALESCE(vt.content_tags, ARRAY[]::text[]) AS "contentTags" + CASE + WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags + ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) + END AS "contentTags" FROM "Video" v LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id, tsq @@ -86,7 +89,8 @@ export async function GET(req: Request) { (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query) AND CASE WHEN ${contentTag}::text IS NULL THEN TRUE - ELSE vt.content_tags @> ARRAY[${contentTag}]::text[] + WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[] + ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[] END UNION ALL @@ -114,7 +118,7 @@ export async function GET(req: Request) { ` : await prisma.$queryRaw` SELECT - vt.id AS id, + COALESCE(vt.id, 'tag:' || v.aweme_id) AS id, v.aweme_id AS "awemeId", 'video'::text AS type, 1.0::double precision AS rank, @@ -124,10 +128,16 @@ export async function GET(req: Request) { v.desc, '标签匹配' ) AS snippet, - vt.content_tags AS "contentTags" - FROM "VideoTranscript" vt - JOIN "Video" v ON v.aweme_id = vt."videoId" - WHERE vt.content_tags @> ARRAY[${contentTag}]::text[] + CASE + WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags + ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) + END AS "contentTags" + FROM "Video" v + LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id + WHERE CASE + WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[] + ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[] + END ORDER BY v.created_at DESC OFFSET ${offset} LIMIT ${limit}; @@ -145,7 +155,8 @@ export async function GET(req: Request) { WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query) AND CASE WHEN ${contentTag}::text IS NULL THEN TRUE - ELSE vt.content_tags @> ARRAY[${contentTag}]::text[] + WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[] + ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[] END) + (SELECT COUNT(*)::int FROM "ImagePost" ip @@ -156,8 +167,12 @@ export async function GET(req: Request) { ` : await prisma.$queryRaw<{ count: number }[]>` SELECT COUNT(*)::int AS count - FROM "VideoTranscript" vt - WHERE vt.content_tags @> ARRAY[${contentTag}]::text[]; + FROM "Video" v + LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id + WHERE CASE + WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[] + ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[] + END; `; // 分离视频和图文ID diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index 901b1d7..46bb901 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -1,15 +1,8 @@ "use client"; +import { Play } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Play } from "lucide-react"; -import type { - AwemeData, - ImageData, - Neighbors, - VideoData, - VideoTranscript, -} from "./types.ts"; import { BackgroundCanvas } from "./components/BackgroundCanvas"; import { CommentPanel } from "./components/CommentPanel"; import { ImageCarousel } from "./components/ImageCarousel"; @@ -18,15 +11,37 @@ import { MediaControls } from "./components/MediaControls"; import { NavigationButtons } from "./components/NavigationButtons"; import { TranscriptPanel } from "./components/TranscriptPanel"; import { VideoPlayer } from "./components/VideoPlayer"; +import { VideoTagEditor } from "./components/VideoTagEditor"; import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas"; import { useCommentState } from "./hooks/useCommentState"; import { useImageCarousel } from "./hooks/useImageCarousel"; import { useNavigation } from "./hooks/useNavigation"; import { usePlayerState } from "./hooks/usePlayerState"; import { useVideoPlayer } from "./hooks/useVideoPlayer"; +import type { + AwemeData, + ImageData, + Neighbors, + VideoData, + VideoTranscript, +} from "./types.ts"; const SEGMENT_MS = 4000; +type WebkitVideoElement = HTMLVideoElement & { + webkitEnterFullscreen?: () => void; +}; + +type MediaPositionStateInput = { + duration: number; + position: number; + playbackRate?: number; +}; + +type MediaSessionWithPositionState = MediaSession & { + setPositionState?: (state: MediaPositionStateInput) => void; +}; + interface AwemeDetailClientProps { data: AwemeData; neighbors: Neighbors; @@ -45,6 +60,9 @@ export default function AwemeDetailClient({ const playerState = usePlayerState(); const commentState = useCommentState(); const [transcriptOpen, setTranscriptOpen] = useState(false); + const [contentTags, setContentTags] = useState( + isVideo ? (data as VideoData).content_tags : [], + ); // 引用 const mediaContainerRef = useRef(null); @@ -68,6 +86,7 @@ export default function AwemeDetailClient({ // 图文轮播状态 const images = isVideo ? [] : (data as ImageData).images; + const imageCount = images.length; const imageCarouselState = useImageCarousel({ images, isPlaying: playerState.isPlaying, @@ -80,40 +99,42 @@ export default function AwemeDetailClient({ }); // 视频播放器 hooks - if (isVideo) { - useVideoPlayer({ - awemeId: data.aweme_id, - videoRef, - volume: playerState.volume, - rate: playerState.rate, - loopMode: playerState.loopMode, - neighbors, - progressRestored: playerState.progressRestored, - setIsPlaying: playerState.setIsPlaying, - setProgress: playerState.setProgress, - setProgressRestored: playerState.setProgressRestored, - }); - } + useVideoPlayer({ + enabled: isVideo, + awemeId: data.aweme_id, + videoRef, + volume: playerState.volume, + rate: playerState.rate, + loopMode: playerState.loopMode, + neighbors, + progressRestored: playerState.progressRestored, + setIsPlaying: playerState.setIsPlaying, + setProgress: playerState.setProgress, + setProgressRestored: playerState.setProgressRestored, + }); // 媒体操作函数 - const seekTo = (ratio: number) => { - ratio = Math.min(1, Math.max(0, ratio)); - if (isVideo) { - const v = videoRef.current; - if (!v || !v.duration) return; - v.currentTime = v.duration * ratio; - return; - } - if (!images?.length) return; - imageCarouselState.seekTo(ratio); - }; + const seekTo = useCallback( + (ratio: number) => { + const clampedRatio = Math.min(1, Math.max(0, ratio)); + if (isVideo) { + const v = videoRef.current; + if (!v || !v.duration) return; + v.currentTime = v.duration * clampedRatio; + return; + } + if (!imageCount) return; + imageCarouselState.seekTo(clampedRatio); + }, + [imageCarouselState.seekTo, imageCount, isVideo], + ); const seekImageByVisualRatio = (ratio: number) => { - if (!images?.length) return; + if (!imageCount) return; const clampedRatio = Math.min(1, Math.max(0, ratio)); - const rawSegment = clampedRatio * images.length; - const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment)); + const rawSegment = clampedRatio * imageCount; + const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment)); const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex)); imageCarouselState.goToIndex(targetIndex, segmentProgress); }; @@ -152,14 +173,12 @@ export default function AwemeDetailClient({ document.body.requestFullscreen().catch(() => {}); return; } - const vRef = videoRef.current; - if (vRef && vRef.requestFullscreen) { + const vRef = videoRef.current as WebkitVideoElement | null; + if (vRef?.requestFullscreen) { vRef.requestFullscreen().catch(() => {}); return; } - // @ts-ignore - if (vRef && vRef.webkitEnterFullscreen) { - // @ts-ignore + if (vRef?.webkitEnterFullscreen) { vRef.webkitEnterFullscreen(); } } else { @@ -167,20 +186,20 @@ export default function AwemeDetailClient({ } }; - const prevImg = () => { - if (!images?.length) return; + const prevImg = useCallback(() => { + if (!imageCount) return; const next = Math.max(0, imageCarouselState.idxRef.current - 1); imageCarouselState.goToIndex(next); - }; + }, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]); - const nextImg = () => { - if (!images?.length) return; + const nextImg = useCallback(() => { + if (!imageCount) return; const next = Math.min( - images.length - 1, + imageCount - 1, imageCarouselState.idxRef.current + 1, ); imageCarouselState.goToIndex(next); - }; + }, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]); const handleDownload = () => { if (isVideo) { @@ -249,7 +268,7 @@ export default function AwemeDetailClient({ if (typeof window === "undefined") return; if (!("mediaSession" in navigator)) return; - const ms = (navigator as any).mediaSession as MediaSession; + const ms = navigator.mediaSession as MediaSessionWithPositionState; // 元信息:标题、作者、专辑(使用创建时间),封面图(优先当前图片,其次作者头像) const title = data.desc || `作品 ${data.aweme_id}`; @@ -282,12 +301,12 @@ export default function AwemeDetailClient({ const updatePosition = () => { try { - // @ts-ignore: setPositionState 可能不存在 if (!ms.setPositionState) return; if (isVideo) { const v = videoRef.current; - if (!v || !isFinite(v.duration) || isNaN(v.duration)) return; - // @ts-ignore + if (!v || !Number.isFinite(v.duration) || Number.isNaN(v.duration)) { + return; + } ms.setPositionState({ duration: Math.max(0, v.duration), position: Math.max(0, v.currentTime), @@ -300,14 +319,12 @@ export default function AwemeDetailClient({ 0, Math.min(duration, (playerState.progress * totalMs) / 1000), ); - // @ts-ignore ms.setPositionState({ duration, position, playbackRate: 1 }); } } catch {} }; // 绑定视频事件以同步状态 - let timeUpdateTimer: number | null = null; const v = videoRef.current; if (isVideo && v) { const onPlay = () => { @@ -331,7 +348,6 @@ export default function AwemeDetailClient({ updatePosition(); // 清理 - timeUpdateTimer = null; return () => { v.removeEventListener("play", onPlay); v.removeEventListener("pause", onPause); @@ -346,15 +362,10 @@ export default function AwemeDetailClient({ } // 依赖:当这些变化时刷新元信息与位置状态 }, [ - isVideo, - data.aweme_id, - data.desc, - data.author.nickname, - data.author.avatar_url, - data.created_at, - imageCarouselState.idx, + data, imageCarouselState.totalDurationMs, images, + isVideo, playerState.isPlaying, playerState.progress, playerState.rate, @@ -364,7 +375,7 @@ export default function AwemeDetailClient({ useEffect(() => { if (typeof window === "undefined") return; if (!("mediaSession" in navigator)) return; - const ms = (navigator as any).mediaSession as MediaSession; + const ms = navigator.mediaSession; const getImagesTotalMs = () => imageCarouselState.totalDurationMs; @@ -421,7 +432,7 @@ export default function AwemeDetailClient({ const handleSeekTo = (seekTimeSec: number) => { if (isVideo) { const v = videoRef.current; - if (v && isFinite(v.duration)) { + if (v && Number.isFinite(v.duration)) { v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec)); } } else if (images?.length) { @@ -434,13 +445,13 @@ export default function AwemeDetailClient({ ms.setActionHandler("play", handlePlay); ms.setActionHandler("pause", handlePause); ms.setActionHandler("stop", handleStop); - ms.setActionHandler("seekbackward", (details: any) => + ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) => handleSeekDelta(-((details?.seekOffset as number) || 10)), ); - ms.setActionHandler("seekforward", (details: any) => + ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) => handleSeekDelta((details?.seekOffset as number) || 10), ); - ms.setActionHandler("seekto", (details: any) => { + ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => { if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime); }); ms.setActionHandler("previoustrack", () => { @@ -477,8 +488,12 @@ export default function AwemeDetailClient({ neighbors.next, router, playerState.progress, + playerState.setIsPlaying, pauseImageMedia, imageCarouselState.totalDurationMs, + nextImg, + prevImg, + seekTo, ]); return ( @@ -537,6 +552,14 @@ export default function AwemeDetailClient({ )} {/* 媒体控制栏 */} + {isVideo && ( + + )} + setTranscriptOpen(false)} transcript={transcript} + contentTags={contentTags} /> diff --git a/app/aweme/[awemeId]/components/TranscriptPanel.tsx b/app/aweme/[awemeId]/components/TranscriptPanel.tsx index bde578b..5a6a3da 100644 --- a/app/aweme/[awemeId]/components/TranscriptPanel.tsx +++ b/app/aweme/[awemeId]/components/TranscriptPanel.tsx @@ -8,14 +8,17 @@ interface TranscriptPanelProps { open: boolean; onClose: () => void; transcript: VideoTranscript | null; + contentTags?: string[]; } export function TranscriptPanel({ open, onClose, transcript, + contentTags, }: TranscriptPanelProps) { const [copiedIndex, setCopiedIndex] = useState(null); + const displayTags = contentTags ?? transcript?.content_tags ?? []; const [copiedAll, setCopiedAll] = useState(false); if (!open) return null; @@ -94,9 +97,9 @@ export function TranscriptPanel({ 语言: {transcript.language}

)} - {transcript.content_tags?.length > 0 && ( + {displayTags.length > 0 && (
- {transcript.content_tags.map((tag) => ( + {displayTags.map((tag) => ( void; +}; + +export function VideoTagEditor({ + awemeId, + tags, + onTagsChange, +}: VideoTagEditorProps) { + const [open, setOpen] = useState(false); + const [draftTags, setDraftTags] = useState(tags); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) setDraftTags(tags); + }, [open, tags]); + + const toggleTag = (tag: string) => { + setDraftTags((current) => + current.includes(tag) + ? current.filter((item) => item !== tag) + : [...current, tag], + ); + }; + + const saveTags = async () => { + setSaving(true); + setError(null); + try { + const response = await fetch("/api/asr/tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + awemeId, + contentTags: draftTags, + mode: "set", + }), + }); + if (!response.ok) throw new Error("保存标签失败"); + const data: TagUpdateResponse = await response.json(); + const updated = data.updated?.find((item) => item.aweme_id === awemeId); + onTagsChange(updated?.content_tags ?? draftTags); + setOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : "保存标签失败"); + } finally { + setSaving(false); + } + }; + + return ( +
+ + + {open && ( +
+
+
手动标签
+ +
+ +
+ {ASR_CONTENT_TAGS.map((tag) => { + const selected = draftTags.includes(tag); + return ( + + ); + })} +
+ + {error &&
{error}
} + +
+ + +
+
+ )} +
+ ); +} diff --git a/app/aweme/[awemeId]/hooks/useVideoPlayer.ts b/app/aweme/[awemeId]/hooks/useVideoPlayer.ts index 39defba..719869c 100644 --- a/app/aweme/[awemeId]/hooks/useVideoPlayer.ts +++ b/app/aweme/[awemeId]/hooks/useVideoPlayer.ts @@ -1,9 +1,10 @@ -import { useEffect, useRef } from "react"; -import type { RefObject } from "react"; import { useRouter } from "next/navigation"; +import type { RefObject } from "react"; +import { useEffect } from "react"; import type { LoopMode, Neighbors } from "../types.ts"; interface UseVideoPlayerProps { + enabled?: boolean; awemeId: string; videoRef: RefObject; volume: number; @@ -17,6 +18,7 @@ interface UseVideoPlayerProps { } export function useVideoPlayer({ + enabled = true, awemeId, videoRef, volume, @@ -32,6 +34,7 @@ export function useVideoPlayer({ // 恢复播放进度 useEffect(() => { + if (!enabled) return; if (progressRestored) return; const v = videoRef.current; if (!v) return; @@ -74,10 +77,11 @@ export function useVideoPlayer({ v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true }); return () => v.removeEventListener("loadedmetadata", onLoadedMetadata); } - }, [awemeId, progressRestored, videoRef, setProgressRestored]); + }, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]); // 保存播放进度 useEffect(() => { + if (!enabled) return; const v = videoRef.current; if (!v) return; @@ -105,10 +109,11 @@ export function useVideoPlayer({ window.removeEventListener("beforeunload", onBeforeUnload); saveProgress(); }; - }, [awemeId, videoRef]); + }, [awemeId, enabled, videoRef]); // 监听播放状态和进度 useEffect(() => { + if (!enabled) return; const v = videoRef.current; if (!v) return; @@ -137,10 +142,19 @@ export function useVideoPlayer({ v.removeEventListener("pause", onPause); v.removeEventListener("ended", onEnded); }; - }, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]); + }, [ + enabled, + loopMode, + neighbors?.next, + router, + videoRef, + setIsPlaying, + setProgress, + ]); // 自动播放检测 useEffect(() => { + if (!enabled) return; const v = videoRef.current; if (!v) return; @@ -148,7 +162,7 @@ export function useVideoPlayer({ try { await v.play(); setIsPlaying(true); - } catch (error) { + } catch { console.log("自动播放被阻止,需要用户交互"); setIsPlaying(false); } @@ -160,16 +174,18 @@ export function useVideoPlayer({ v.addEventListener("loadedmetadata", checkAutoplay, { once: true }); return () => v.removeEventListener("loadedmetadata", checkAutoplay); } - }, [awemeId, videoRef, setIsPlaying]); + }, [enabled, videoRef, setIsPlaying]); // 更新音量和倍速 useEffect(() => { + if (!enabled) return; const v = videoRef.current; if (v) v.volume = volume; - }, [volume, videoRef]); + }, [enabled, volume, videoRef]); useEffect(() => { + if (!enabled) return; const v = videoRef.current; if (v) v.playbackRate = rate; - }, [rate, videoRef]); + }, [enabled, rate, videoRef]); } diff --git a/app/aweme/[awemeId]/page.tsx b/app/aweme/[awemeId]/page.tsx index 58ed4eb..e3e2962 100644 --- a/app/aweme/[awemeId]/page.tsx +++ b/app/aweme/[awemeId]/page.tsx @@ -1,9 +1,10 @@ -import { prisma } from "@/lib/prisma"; -import BackButton from "@/app/components/BackButton"; -import AwemeDetailClient from "./Client"; import type { Metadata } from "next"; +import BackButton from "@/app/components/BackButton"; +import { resolveEffectiveContentTags } from "@/lib/asr-tags"; import { getFileUrl } from "@/lib/minio"; -import { AwemeData, VideoTranscript } from "./types"; +import { prisma } from "@/lib/prisma"; +import AwemeDetailClient from "./Client"; +import type { AwemeData, VideoTranscript } from "./types"; export async function generateMetadata({ params, @@ -53,7 +54,7 @@ export default async function AwemeDetail({ const [video, post] = await Promise.all([ prisma.video.findUnique({ where: { aweme_id: id }, - include: { author: true }, + include: { author: true, transcript: true }, }), prisma.imagePost.findUnique({ where: { aweme_id: id }, @@ -63,65 +64,77 @@ export default async function AwemeDetail({ if (!video && !post) return
找不到该作品
; - const isVideo = !!video; + const isVideo = Boolean(video); // 获取评论总数 const commentsCount = await prisma.comment.count({ where: isVideo ? { videoId: id } : { imagePostId: id }, }); - const aweme = isVideo ? video : post; - const data: AwemeData = { - aweme_id: aweme!.aweme_id, - desc: aweme!.desc, - created_at: aweme!.created_at, - likesCount: Number(aweme!.digg_count), - commentsCount, - author: { - nickname: aweme!.author.nickname, - avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"), - sec_uid: aweme!.author.sec_uid, - }, - ...(() => { - if (isVideo) { - const aweme = video!; - return { - type: "video" as const, - cover_url: getFileUrl(aweme!.cover_url ?? "default-cover.png"), - cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 }, - duration_ms: aweme!.duration_ms, - video_url: getFileUrl(aweme!.video_url), - width: aweme!.width ?? null, - height: aweme!.height ?? null, - }; - } else { - const aweme = post!; - return { - type: "image" as const, - cover_url: getFileUrl(aweme!.images[0].url ?? "default-cover.png"), - cover_size: { - h: aweme!.images[0].height ?? 0, - w: aweme!.images[0].width ?? 0, - }, - images: aweme!.images.map((img) => ({ - ...img, - url: getFileUrl(img.url), - animated: img.animated ? getFileUrl(img.animated) : null, - })), - music_url: getFileUrl(aweme!.music_url || "default-music.mp3"), - }; - } - })(), - }; + let data: AwemeData; + let currentCreatedAt: Date; + let transcript: VideoTranscript | null = null; - const transcript: VideoTranscript | null = isVideo - ? await prisma.videoTranscript.findUnique({ - where: { videoId: id }, - }) - : null; + if (video) { + transcript = video.transcript ?? null; + currentCreatedAt = video.created_at; + data = { + type: "video", + aweme_id: video.aweme_id, + desc: video.desc, + created_at: video.created_at, + likesCount: Number(video.digg_count), + commentsCount, + author: { + nickname: video.author.nickname, + avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"), + sec_uid: video.author.sec_uid, + }, + cover_url: getFileUrl(video.cover_url ?? "default-cover.png"), + cover_size: { h: video.height ?? 0, w: video.width ?? 0 }, + duration_ms: video.duration_ms, + video_url: getFileUrl(video.video_url), + width: video.width ?? null, + height: video.height ?? null, + content_tags: resolveEffectiveContentTags( + video.manual_content_tags, + transcript?.content_tags, + ), + manual_content_tags: video.manual_content_tags, + asr_content_tags: transcript?.content_tags ?? [], + }; + } else if (post) { + const coverImage = post.images[0]; + currentCreatedAt = post.created_at; + data = { + type: "image", + aweme_id: post.aweme_id, + desc: post.desc, + created_at: post.created_at, + likesCount: Number(post.digg_count), + commentsCount, + author: { + nickname: post.author.nickname, + avatar_url: getFileUrl(post.author.avatar_url || "default-avatar.png"), + sec_uid: post.author.sec_uid, + }, + cover_url: getFileUrl(coverImage?.url ?? "default-cover.png"), + cover_size: { + h: coverImage?.height ?? 0, + w: coverImage?.width ?? 0, + }, + images: post.images.map((img) => ({ + ...img, + url: getFileUrl(img.url), + animated: img.animated ? getFileUrl(img.animated) : null, + })), + music_url: getFileUrl(post.music_url || "default-music.mp3"), + }; + } else { + return
找不到该作品
; + } // Compute prev/next neighbors by created_at across videos and image posts - const currentCreatedAt = isVideo ? video!.created_at : post!.created_at; const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([ prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, diff --git a/app/aweme/[awemeId]/types.ts b/app/aweme/[awemeId]/types.ts index a864a73..57005b0 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -24,6 +24,9 @@ export type VideoData = { video_url: string; width: number | null; height: number | null; + content_tags: string[]; + manual_content_tags: string[]; + asr_content_tags: string[]; author: User; commentsCount: number; likesCount: number; diff --git a/app/components/FeedMasonry.tsx b/app/components/FeedMasonry.tsx index 3853d8d..a15c43c 100644 --- a/app/components/FeedMasonry.tsx +++ b/app/components/FeedMasonry.tsx @@ -1,9 +1,10 @@ "use client"; -import { ThumbsUp } from "lucide-react"; +import { Check, ThumbsUp, X } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useCallback, useEffect, useRef, useState } from "react"; import type { FeedItem, FeedResponse } from "@/app/types/feed"; +import { ASR_CONTENT_TAGS } from "@/lib/asr-tags"; import HoverVideo from "./HoverVideo"; const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"]; @@ -18,6 +19,13 @@ type Props = { fetchUrl?: string; }; +type TagUpdateResponse = { + updated?: { + aweme_id: string; + content_tags: string[]; + }[]; +}; + export default function FeedMasonry({ initialItems, initialCursor, @@ -27,6 +35,10 @@ export default function FeedMasonry({ const [cursor, setCursor] = useState(initialCursor); const [loading, setLoading] = useState(false); const [ended, setEnded] = useState(!initialCursor); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [batchTag, setBatchTag] = useState(ASR_CONTENT_TAGS[0]); + const [batchSaving, setBatchSaving] = useState(false); + const [batchError, setBatchError] = useState(null); const sentinelRef = useRef(null); const containerRef = useRef(null); @@ -168,115 +180,193 @@ export default function FeedMasonry({ return () => io.disconnect(); }, [fetchMore]); - const renderCard = useCallback( - (item: FeedItem) => ( -
- -
- {item.type === "video" ? ( - - ) : ( - {item.desc?.slice(0, - )} -
-
-
-

- {item.desc} -

- {!!item.content_tags?.length && ( -
- {item.content_tags.map((tag) => ( - - {tag} - - ))} -
- )} -
- - {item.type === "video" ? "视频" : "图文"} - -
-
- + const toggleSelection = useCallback((awemeId: string) => { + setSelectedIds((current) => { + const next = new Set(current); + if (next.has(awemeId)) next.delete(awemeId); + else next.add(awemeId); + return next; + }); + setBatchError(null); + }, []); -
- {item.author.sec_uid ? ( - -
- {item.author.avatar_url ? ( - avatar - ) : null} -
- - {item.author.nickname} - - - ) : ( -
-
- {item.author.avatar_url ? ( - avatar - ) : null} -
- - {item.author.nickname} - -
- )} - - {item.likes}{" "} - - -
-
- ), + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + setBatchError(null); + }, []); + + const applyUpdatedTags = useCallback( + (updates: TagUpdateResponse["updated"]) => { + if (!updates?.length) return; + const tagMap = new Map( + updates.map((item) => [item.aweme_id, item.content_tags] as const), + ); + setColumns((currentColumns) => + currentColumns.map((column) => + column.map((item) => + tagMap.has(item.aweme_id) + ? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] } + : item, + ), + ), + ); + }, [], ); + const handleBatchAddTag = useCallback(async () => { + const awemeIds = [...selectedIds]; + if (awemeIds.length === 0 || batchSaving) return; + setBatchSaving(true); + setBatchError(null); + try { + const response = await fetch("/api/asr/tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ awemeIds, tag: batchTag, mode: "add" }), + }); + if (!response.ok) throw new Error("批量添加标签失败"); + const data: TagUpdateResponse = await response.json(); + applyUpdatedTags(data.updated); + clearSelection(); + } catch (error) { + setBatchError( + error instanceof Error ? error.message : "批量添加标签失败", + ); + } finally { + setBatchSaving(false); + } + }, [applyUpdatedTags, batchSaving, batchTag, clearSelection, selectedIds]); + + const renderCard = useCallback( + (item: FeedItem) => { + const selected = selectedIds.has(item.aweme_id); + return ( +
+ {item.type === "video" && ( + + )} + +
+ {item.type === "video" ? ( + + ) : ( + {item.desc?.slice(0, + )} +
+
+
+

+ {item.desc} +

+ {!!item.content_tags?.length && ( +
+ {item.content_tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ + {item.type === "video" ? "视频" : "图文"} + +
+
+ + +
+ {item.author.sec_uid ? ( + +
+ {item.author.avatar_url ? ( + avatar + ) : null} +
+ + {item.author.nickname} + + + ) : ( +
+
+ {item.author.avatar_url ? ( + avatar + ) : null} +
+ + {item.author.nickname} + +
+ )} + + {item.likes}{" "} + + +
+
+ ); + }, + [selectedIds, toggleSelection], + ); + return ( <> {/* Masonry(按列渲染,动态分配到最短列) */} @@ -299,6 +389,54 @@ export default function FeedMasonry({ > {ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
+ {selectedIds.size > 0 && ( +
+
+
+ + 已选择 {selectedIds.size} 个视频 + + {batchError && ( + {batchError} + )} +
+
+ + + +
+
+
+ )} ); } diff --git a/app/page.tsx b/app/page.tsx index c3e5ce7..f35fc89 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,10 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { ASR_CONTENT_TAGS, isAsrContentTag } from "@/lib/asr-tags"; +import { + ASR_CONTENT_TAGS, + isAsrContentTag, + resolveEffectiveContentTags, +} from "@/lib/asr-tags"; import { getFileUrl } from "@/lib/minio"; import { prisma } from "@/lib/prisma"; import FeedMasonry from "./components/FeedMasonry"; @@ -26,7 +30,15 @@ export default async function Home({ const [videos, posts] = await Promise.all([ prisma.video.findMany({ where: selectedTag - ? { transcript: { is: { content_tags: { has: selectedTag } } } } + ? { + OR: [ + { manual_content_tags: { has: selectedTag } }, + { + manual_content_tags: { isEmpty: true }, + transcript: { is: { content_tags: { has: selectedTag } } }, + }, + ], + } : undefined, orderBy: { created_at: "desc" }, take: 60, @@ -63,7 +75,10 @@ export default async function Home({ sec_uid: v.author.sec_uid, }, likes: Number(v.digg_count), - content_tags: v.transcript?.content_tags ?? [], + content_tags: resolveEffectiveContentTags( + v.manual_content_tags, + v.transcript?.content_tags, + ), })), ...posts.map((p) => { const coverPath = p.images?.[0]?.url; diff --git a/lib/asr-tags.ts b/lib/asr-tags.ts index f5484f3..831820f 100644 --- a/lib/asr-tags.ts +++ b/lib/asr-tags.ts @@ -38,3 +38,12 @@ export function normalizeAsrContentTags(tags: unknown): AsrContentTag[] { const uniqueTags = [...new Set(normalized)].slice(0, 3); return uniqueTags.length > 0 ? uniqueTags : ["其他"]; } + +export function resolveEffectiveContentTags( + manualTags: readonly string[] | null | undefined, + asrTags: readonly string[] | null | undefined, +) { + return manualTags && manualTags.length > 0 + ? [...manualTags] + : [...(asrTags ?? [])]; +} diff --git a/prisma/migrations/20260429_add_manual_video_tags/migration.sql b/prisma/migrations/20260429_add_manual_video_tags/migration.sql new file mode 100644 index 0000000..171ae8b --- /dev/null +++ b/prisma/migrations/20260429_add_manual_video_tags/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "Video" +ADD COLUMN "manual_content_tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; + +CREATE INDEX "video_manual_content_tags_idx" +ON "Video" USING GIN ("manual_content_tags"); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 43dda07..f6c2457 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,32 +24,34 @@ model Author { } model Video { - aweme_id String @id - desc String - preview_title String? - duration_ms Int - created_at DateTime - share_url String - digg_count BigInt @default(0) - comment_count BigInt @default(0) - share_count BigInt @default(0) - collect_count BigInt @default(0) - authorId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - tags String[] - video_url String - height Int? - width Int? - cover_url String? - fps Int? - raw_json Json? - comments Comment[] - author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade) - transcript VideoTranscript? + aweme_id String @id + desc String + preview_title String? + duration_ms Int + created_at DateTime + share_url String + digg_count BigInt @default(0) + comment_count BigInt @default(0) + share_count BigInt @default(0) + collect_count BigInt @default(0) + authorId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tags String[] + manual_content_tags String[] @default([]) + video_url String + height Int? + width Int? + cover_url String? + fps Int? + raw_json Json? + comments Comment[] + author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade) + transcript VideoTranscript? @@index([authorId]) @@index([created_at]) + @@index([manual_content_tags], map: "video_manual_content_tags_idx", type: Gin) } model CommentUser {