From 0d18595b6833b71fb269844f8798f81c894a4319 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Sat, 25 Oct 2025 19:22:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E8=BD=AC=E5=BD=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E4=B8=AD=E6=96=87=E8=84=9A=E6=9C=AC=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=EF=BC=8C=E6=92=AD=E6=94=BE=E7=95=8C=E9=9D=A2=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/stt/page.tsx | 143 ++++++ app/admin/stt/videos/page.tsx | 424 ++++++++++++++++++ app/api/admin/stt/videos/route.ts | 60 +++ app/api/fetcher/index.ts | 2 + app/api/search/route.ts | 164 +++++++ app/api/stt/index.ts | 70 ++- app/api/stt/prompt.md | 32 +- app/aweme/[awemeId]/Client.tsx | 19 +- .../[awemeId]/components/MediaControls.tsx | 81 ++++ app/aweme/[awemeId]/components/MoreMenu.tsx | 79 ++++ .../[awemeId]/components/TranscriptPanel.tsx | 154 +++++++ app/aweme/[awemeId]/page.tsx | 9 +- app/aweme/[awemeId]/types.ts | 6 + app/components/SearchBox.tsx | 40 ++ app/globals.css | 9 + app/page.tsx | 10 +- app/search/page.tsx | 268 +++++++++++ bun.lock | 3 + lib/json.ts | 10 + package.json | 3 +- .../20251019082632_init/migration.sql | 84 ---- .../migration.sql | 9 - .../migration.sql | 50 --- .../20251019113302_add/migration.sql | 15 - .../20251019120440_add/migration.sql | 3 - .../migration.sql | 2 - .../migration.sql | 6 - .../migration.sql | 2 - .../migration.sql | 2 - .../migration.sql | 22 - .../migration.sql | 41 -- .../20251025_baseline/migration.sql | 200 +++++++++ prisma/migrations/migration_lock.toml | 3 - prisma/schema.prisma | 206 ++++----- 34 files changed, 1827 insertions(+), 404 deletions(-) create mode 100644 app/admin/stt/page.tsx create mode 100644 app/admin/stt/videos/page.tsx create mode 100644 app/api/admin/stt/videos/route.ts create mode 100644 app/api/search/route.ts create mode 100644 app/aweme/[awemeId]/components/MoreMenu.tsx create mode 100644 app/aweme/[awemeId]/components/TranscriptPanel.tsx create mode 100644 app/components/SearchBox.tsx create mode 100644 app/search/page.tsx create mode 100644 lib/json.ts delete mode 100644 prisma/migrations/20251019082632_init/migration.sql delete mode 100644 prisma/migrations/20251019101440_add_video_url_and_tags/migration.sql delete mode 100644 prisma/migrations/20251019111904_add_image_post_and_image_file_models/migration.sql delete mode 100644 prisma/migrations/20251019113302_add/migration.sql delete mode 100644 prisma/migrations/20251019120440_add/migration.sql delete mode 100644 prisma/migrations/20251019140306_add_video_cover_url/migration.sql delete mode 100644 prisma/migrations/20251021014140_add_raw_json_and_fps/migration.sql delete mode 100644 prisma/migrations/20251022131006_add_post_animation/migration.sql delete mode 100644 prisma/migrations/20251023021547_add_duration_to_image_file/migration.sql delete mode 100644 prisma/migrations/20251023041400_add_comment_images_for_stickers_and_images/migration.sql delete mode 100644 prisma/migrations/20251023050111_add_cascade_delete/migration.sql create mode 100644 prisma/migrations/20251025_baseline/migration.sql delete mode 100644 prisma/migrations/migration_lock.toml diff --git a/app/admin/stt/page.tsx b/app/admin/stt/page.tsx new file mode 100644 index 0000000..272c31e --- /dev/null +++ b/app/admin/stt/page.tsx @@ -0,0 +1,143 @@ +'use client'; + +import Link from 'next/link'; +import BackButton from '@/app/components/BackButton'; + +export default function SttAdminPage() { + return ( +
+
+ + +
+

+ STT 管理中心 +

+

+ 管理和配置视频语音转写功能 +

+
+ +
+ {/* 视频转写管理 */} + +
+
+ + + +
+
+

+ 视频转写管理 +

+

+ 查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写 +

+
+ 进入管理 + + + +
+
+
+ + + {/* 转写设置(预留) */} +
+
+
+ + + + +
+
+

+ 转写设置 +

+

+ 配置转写 API、模型参数和其他高级选项 +

+
+ 即将推出 +
+
+
+
+ + {/* 转写统计(预留) */} +
+
+
+ + + +
+
+

+ 转写统计 +

+

+ 查看转写使用情况、成功率、语言分布等统计数据 +

+
+ 即将推出 +
+
+
+
+
+
+
+ ); +} diff --git a/app/admin/stt/videos/page.tsx b/app/admin/stt/videos/page.tsx new file mode 100644 index 0000000..1bd85ef --- /dev/null +++ b/app/admin/stt/videos/page.tsx @@ -0,0 +1,424 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import BackButton from '@/app/components/BackButton'; + +type VideoTranscript = { + id: string; + speech_detected: boolean; + language: string | null; + audio_type: string | null; + transcript: string | null; + non_speech_summary: string | null; +}; + +type Video = { + aweme_id: string; + desc: string; + cover_url: string; + duration_ms: number; + author: { + nickname: string; + }; + transcript: VideoTranscript | null; +}; + +type ApiResponse = { + videos: Video[]; + total: number; +}; + +export default function SttVideosPage() { + const [videos, setVideos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [transcribing, setTranscribing] = useState>(new Set()); + const [filter, setFilter] = useState<'all' | 'transcribed' | 'pending'>('all'); + const [batchTranscribing, setBatchTranscribing] = useState(false); + const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 }); + + useEffect(() => { + fetchVideos(); + }, []); + + const fetchVideos = async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/stt/videos'); + if (!response.ok) { + throw new Error('Failed to fetch videos'); + } + const data: ApiResponse = await response.json(); + setVideos(data.videos); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }; + + // 简单的 sleep 工具 + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + type PollOptions = { + intervalMs?: number; + maxAttempts?: number; + }; + + // 轮询某个视频的转写状态直至完成或超时 + const pollVideoUntilTranscribed = async ( + awemeId: string, + { intervalMs = 2000, maxAttempts = 30 }: PollOptions = {} + ): Promise => { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const resp = await fetch('/api/admin/stt/videos'); + if (resp.ok) { + const data: ApiResponse = await resp.json(); + setVideos(data.videos); + const v = data.videos.find((x) => x.aweme_id === awemeId); + if (v && v.transcript) { + return true; + } + } + } catch (e) { + // 忽略网络错误,继续轮询 + } + await sleep(intervalMs); + } + return false; + }; + + const handleTranscribe = async (awemeId: string) => { + setTranscribing(prev => new Set(prev).add(awemeId)); + try { + const response = await fetch(`/api/stt?awemeId=${awemeId}`); + if (!response.ok) { + throw new Error('Failed to transcribe video'); + } + // 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态 + const done = await pollVideoUntilTranscribed(awemeId); + if (!done) { + // 兜底刷新一次 + await fetchVideos(); + } + } catch (err) { + alert(err instanceof Error ? err.message : 'Transcription failed'); + } finally { + setTranscribing(prev => { + const next = new Set(prev); + next.delete(awemeId); + return next; + }); + } + }; + + const handleBatchTranscribe = async () => { + const pendingVideos = videos.filter(v => v.transcript === null); + if (pendingVideos.length === 0) { + alert('没有待转写的视频'); + return; + } + + if (!confirm(`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`)) { + return; + } + + setBatchTranscribing(true); + setBatchProgress({ current: 0, total: pendingVideos.length }); + + const pollers: Promise[] = []; + for (let i = 0; i < pendingVideos.length; i++) { + const video = pendingVideos[i]; + setBatchProgress({ current: i + 1, total: pendingVideos.length }); + setTranscribing(prev => new Set(prev).add(video.aweme_id)); + + try { + const response = await fetch(`/api/stt?awemeId=${video.aweme_id}`); + if (!response.ok) { + console.error(`Failed to transcribe video ${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); + } catch (err) { + console.error(`Error transcribing video ${video.aweme_id}:`, err); + } + } + + // 等待所有轮询结束(完成或超时),再进行最终刷新 + if (pollers.length > 0) { + await Promise.allSettled(pollers); + } + await fetchVideos(); + setBatchTranscribing(false); + setBatchProgress({ current: 0, total: 0 }); + alert('批量转写完成!'); + }; + + const filteredVideos = videos.filter(v => { + if (filter === 'transcribed') return v.transcript !== null; + if (filter === 'pending') return v.transcript === null; + return true; + }); + + const stats = { + total: videos.length, + transcribed: videos.filter(v => v.transcript !== null).length, + pending: videos.filter(v => v.transcript === null).length, + }; + + if (loading) { + return ( +
+
+
+
+

加载中...

+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+

错误: {error}

+ +
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ +

视频转写管理

+

管理视频的语音转写状态

+
+ + {/* Stats */} +
+
+
总视频数
+
{stats.total}
+
+
+
已转写
+
{stats.transcribed}
+
+
+
待转写
+
{stats.pending}
+
+
+ + {/* Filter */} +
+
+
+ + + +
+
+ {batchTranscribing && ( +
+ 正在转写: {batchProgress.current} / {batchProgress.total} +
+ )} + +
+
+
+ + {/* Video List */} +
+
+ + + + + + + + + + + {filteredVideos.map((video) => ( + + + + + + + ))} + +
+ 视频 + + 时长 + + 转写状态 + + 操作 +
+
+ +
+ + {video.desc || '无描述'} + +

+ @{video.author.nickname} +

+
+
+
+ {Math.floor(video.duration_ms / 1000)}s + + {video.transcript ? ( +
+
+ + 已转写 + + {video.transcript.language && ( + + {video.transcript.language} + + )} +
+ {video.transcript.speech_detected ? ( +
+ {video.transcript.transcript} +
+ ) : ( +
+ + {video.transcript.audio_type || '非语音'} + + {video.transcript.non_speech_summary && ( + + - {video.transcript.non_speech_summary} + + )} +
+ )} +
+ ) : ( + + 待转写 + + )} +
+ {video.transcript ? ( + + ) : ( + + )} +
+
+ + {filteredVideos.length === 0 && ( +
+ 没有找到视频 +
+ )} +
+
+
+ ); +} diff --git a/app/api/admin/stt/videos/route.ts b/app/api/admin/stt/videos/route.ts new file mode 100644 index 0000000..6a0c7d5 --- /dev/null +++ b/app/api/admin/stt/videos/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getFileUrl } from '@/lib/minio'; + +export async function GET() { + try { + const videos = await prisma.video.findMany({ + orderBy: { created_at: 'desc' }, + take: 1000, + include: { + author: { + select: { + nickname: true, + }, + }, + transcript: { + select: { + id: true, + speech_detected: true, + language: true, + audio_type: true, + transcript: true, + non_speech_summary: true, + }, + }, + }, + }); + + 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, + } + : null, + })); + + return NextResponse.json({ + videos: formattedVideos, + total: videos.length, + }); + } catch (error) { + console.error('Failed to fetch videos:', error); + return NextResponse.json( + { error: 'Failed to fetch videos' }, + { status: 500 } + ); + } +} diff --git a/app/api/fetcher/index.ts b/app/api/fetcher/index.ts index 8cd6be5..2b6420f 100644 --- a/app/api/fetcher/index.ts +++ b/app/api/fetcher/index.ts @@ -12,6 +12,7 @@ import { saveToDB, saveImagePostToDB } from '@/app/api/fetcher/persist'; import chalk from 'chalk'; import { acquireIsolatedContext, releaseIsolatedContext } from '@/app/api/fetcher/browser'; import { extractFirstFrame } from '@/app/api/media'; +import { transcriptAweme } from '../stt'; const DETAIL_PATH = '/aweme/v1/web/aweme/detail/'; const COMMENT_PATH = '/aweme/v1/web/comment/list/'; @@ -308,6 +309,7 @@ export async function scrapeDouyin(url: string) { const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl, fps ?? undefined); console.log(chalk.green.bold('✓ 视频作品保存成功')); + transcriptAweme(detail.aweme_detail.aweme_id).catch((e) => {}); // 异步转写,不阻塞主流程 return { type: "video", ...saved }; } else { throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE'); diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..4208bf0 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,164 @@ +import { json } from '@/lib/json'; +import { getFileUrl } from '@/lib/minio'; +import { prisma } from '@/lib/prisma'; // 你的 Prisma 客户端实例 +import { NextResponse } from 'next/server'; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const q = (searchParams.get('q') || '').trim(); + const page = Math.max(1, Number(searchParams.get('page') || 1)); + const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit') || 20))); + const offset = (page - 1) * limit; + + if (!q) { + return NextResponse.json({ results: [], total: 0 }); + } + + try { + // 核心查询:Postgres 中文全文检索(视频转写 + 视频desc + 图文desc)+ 相关度排序 + 摘要高亮 + // 说明: + // - 合并搜索来源: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 + ts_headline( + 'zhcfg', + array_to_string(vt.transcript, ' '), + tsq.query, + 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' + ) + ELSE + ts_headline( + 'zhcfg', + v.desc, + tsq.query, + 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' + ) + END AS snippet + FROM "Video" v + LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id, + tsq + WHERE + (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query) + + UNION ALL + + -- 图文搜索结果 + SELECT + 'img_desc:' || ip.aweme_id AS id, + ip.aweme_id AS "awemeId", + 'image'::text AS type, + ts_rank(to_tsvector('zhcfg', ip.desc), tsq.query) AS rank, + ts_headline( + 'zhcfg', + ip.desc, + tsq.query, + 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' + ) AS snippet + FROM "ImagePost" ip, tsq + WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query + + ORDER BY rank DESC + OFFSET ${offset} + LIMIT ${limit}; + `; + + // 查询总数(参数化,避免注入) + const totalRows = await prisma.$queryRaw<{ + count: number; + }[]>` + WITH tsq AS ( + SELECT websearch_to_tsquery('zhcfg', ${q}) AS query + ) + SELECT ( + (SELECT COUNT(*)::int FROM "Video" v + LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id + WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)) + + + (SELECT COUNT(*)::int FROM "ImagePost" ip + WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query) + ) AS count + FROM tsq; + `; + + // 分离视频和图文ID + const videoIds = rows.filter(r => r.type === 'video').map(r => r.awemeId); + const imagePostIds = rows.filter(r => r.type === 'image').map(r => r.awemeId); + + // 批量查询视频元信息 + const videos = videoIds.length > 0 ? (await prisma.video.findMany({ + where: { aweme_id: { in: videoIds } }, + select: { + aweme_id: true, + desc: true, + cover_url: true, + video_url: true, + duration_ms: true, + author: true + }, + })).map(v => ( + { ...v, cover_url: getFileUrl(v.cover_url || ''), + author: { ...v.author, avatar_url: getFileUrl(v.author.avatar_url || '') }, + video_url: getFileUrl(v.video_url || '') }) + ) : []; + + // 批量查询图文元信息 + const imagePosts = imagePostIds.length > 0 ? (await prisma.imagePost.findMany({ + where: { aweme_id: { in: imagePostIds } }, + select: { + aweme_id: true, + desc: true, + author: true, + images: { + orderBy: { order: 'asc' }, + take: 1, + select: { + url: true, + width: true, + height: true, + } + } + }, + })).map(ip => ({ + ...ip, + author: { ...ip.author, avatar_url: getFileUrl(ip.author.avatar_url || '') }, + cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null, + })) : []; + + return json({ + results: rows.map(r => ({ + ...r, + video: r.type === 'video' ? videos.find(v => v.aweme_id === r.awemeId) : undefined, + imagePost: r.type === 'image' ? imagePosts.find(ip => ip.aweme_id === r.awemeId) : undefined, + })), + total: totalRows?.[0]?.count ?? 0, + page, + limit, + }); + } catch (err) { + console.error('Search error:', err); + return NextResponse.json({ error: 'Search failed' }, { status: 500 }); + } +} diff --git a/app/api/stt/index.ts b/app/api/stt/index.ts index b5a77a2..969d614 100644 --- a/app/api/stt/index.ts +++ b/app/api/stt/index.ts @@ -4,45 +4,62 @@ import prompt from "./prompt.md"; import { prisma } from "@/lib/prisma"; import { downloadFile } from "@/lib/minio"; import { extractAudio } from "../media"; +import { z } from "zod"; +import { zodResponseFormat } from "openai/helpers/zod"; const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: process.env.OPENAI_API_BASE_URL, }); +// Zod schema aligned with prompt.md +export const SttSchema = z + .object({ + speech_detected: z.boolean(), + language: z.string().min(1).nullable(), + audio_type: z.string().nullable(), + transcript: z.array(z.string()), + non_speech_summary: z.string().nullable(), + }) + .strict(); + +export type SttResult = z.infer; + async function transcriptAudio(audio: Buffer | string) { if (typeof audio === "string") { audio = fs.readFileSync(audio); } const base64Audio = Buffer.from(audio).toString("base64"); - const response = await client.chat.completions.create({ - model: "gemini-2.5-flash-lite", + const completion = await client.chat.completions.create({ + model: "gemini-2.5-flash", messages: [ { role: "user", content: [ - { - type: "text", - text: prompt, - }, + { type: "text", text: prompt }, { type: "input_audio", - input_audio: { - data: base64Audio, - format: "mp3", - }, + input_audio: { data: base64Audio, format: "mp3" }, }, ], }, ], + response_format: zodResponseFormat(SttSchema, "stt_result"), }); - console.log(response.choices[0].message.content); - return response.choices[0].message.content; + const data = completion.choices?.[0]?.message + if (!data) { + throw new Error("No STT result returned from model"); + } + const parsed = SttSchema.safeParse(data?.content).data; + if (!parsed) { + throw new Error("Failed to parse STT result with zod"); + } + return parsed; } -export async function transcriptAweme(awemeId: string) { +export async function transcriptAweme(awemeId: string): Promise { const aweme = await prisma.video.findUnique({ where: { aweme_id: awemeId }, }); @@ -52,11 +69,34 @@ export async function transcriptAweme(awemeId: string) { const vPath = aweme.video_url const buffer = await downloadFile(vPath); - const audioDat = await extractAudio(buffer, { format: "mp3", bitrateKbps: 96 }); + const audioDat = await extractAudio(buffer, { format: "mp3", bitrateKbps: 128 }); if (!audioDat || !audioDat.buffer) { throw new Error("Failed to extract audio from video"); } - return await transcriptAudio(audioDat.buffer); + // 调用大模型生成转写结果 + const result = await transcriptAudio(audioDat.buffer); + + // 将转写结果持久化到数据库 + await prisma.videoTranscript.upsert({ + where: { videoId: awemeId }, + update: { + speech_detected: result.speech_detected, + language: result.language, + audio_type: result.audio_type, + transcript: result.transcript, + non_speech_summary: result.non_speech_summary, + }, + create: { + videoId: awemeId, + speech_detected: result.speech_detected, + language: result.language, + audio_type: result.audio_type, + transcript: result.transcript, + non_speech_summary: result.non_speech_summary, + }, + }); + + return result; } diff --git a/app/api/stt/prompt.md b/app/api/stt/prompt.md index f23aa09..ef32b13 100644 --- a/app/api/stt/prompt.md +++ b/app/api/stt/prompt.md @@ -1,42 +1,24 @@ -你将接收一段音频。请完成: -A.语音活动检测(VAD)与声源分类; -B.条件式处理: -- 若包含可辨识的人类发言:** 进行转录 **(保留原语言,不翻译),并尽可能给出说话人分离与时间戳; -- 若不包含人类发言:** 不转录 **,仅返回音频类型与简要描述。 -C.严格输出为下方 JSON,字段不得缺失或额外编造。听不清处用“[听不清]”。 +你将接收一段音频。请完成:你将接收一段音频。语音活动检测(VAD)与声源分类。 -** 输出 JSON Schema(示例)** +**输出 JSON Schema(示例)** ```json { "speech_detected": true, "language": "zh-CN", "audio_type": null, - "background": "music | ambience | none | unknown", - "transcript": [ - { - "start": 0.00, - "end": 3.42, - "text": "大家好,我是……" - }, - { - "start": 3.50, - "end": 6.10, - "text": "欢迎来到今天的节目。" - } - ], + "transcript": ["大家好,我是xxx", "欢迎来到今天的视频", "今天我们来聊聊AI的未来"], "non_speech_summary": null, } ``` - > -** 当无发言时返回:** + +**当无发言时返回:** ```json { "speech_detected": false, "language": null, "audio_type": "music | ambience | animal | mechanical | other", - "background": "none", "transcript": [], "non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。", } @@ -44,8 +26,4 @@ C.严格输出为下方 JSON,字段不得缺失或额外编造。听不清处 ** 规则补充 ** -* 只要存在可理解的人类发言(即便有音乐 / 噪声),就执行转录,并在 `background` 标注“music / ambience”。 -* 不要将唱词 / 哼唱视为“发言”;若仅有人声演唱且无口语发言,视为 ** 音乐 **。 -* 不要臆测未听清内容;不要添加与音频无关的信息。 -* 时间单位统一为秒,保留两位小数。 * 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。 diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index 3f0f53d..fda41f4 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -1,15 +1,16 @@ "use client"; import { useRouter } from "next/navigation"; -import { useMemo, useRef } from "react"; +import { useMemo, useRef, useState } from "react"; import { Pause, Play } from "lucide-react"; -import type { AwemeData, ImageData, Neighbors, VideoData } from "./types.ts"; +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"; import { ImageNavigationButtons } from "./components/ImageNavigationButtons"; import { MediaControls } from "./components/MediaControls"; import { NavigationButtons } from "./components/NavigationButtons"; +import { TranscriptPanel } from "./components/TranscriptPanel"; import { VideoPlayer } from "./components/VideoPlayer"; import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas"; import { useCommentState } from "./hooks/useCommentState"; @@ -17,21 +18,24 @@ import { useImageCarousel } from "./hooks/useImageCarousel"; import { useNavigation } from "./hooks/useNavigation"; import { usePlayerState } from "./hooks/usePlayerState"; import { useVideoPlayer } from "./hooks/useVideoPlayer"; +import { Prisma } from "@prisma/client"; const SEGMENT_MS = 4000; interface AwemeDetailClientProps { data: AwemeData; neighbors: Neighbors; + transcript: VideoTranscript | null; } -export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClientProps) { +export default function AwemeDetailClient({ data, neighbors, transcript }: AwemeDetailClientProps) { const router = useRouter(); const isVideo = data.type === "video"; // 状态管理 const playerState = usePlayerState(); const commentState = useCommentState(); + const [transcriptOpen, setTranscriptOpen] = useState(false); // 引用 const mediaContainerRef = useRef(null); @@ -284,6 +288,8 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient segmentProgress={imageCarouselState.segProgress} musicUrl={isVideo ? undefined : (data as ImageData).music_url} audioRef={audioRef} + hasTranscript={isVideo && !!transcript?.speech_detected} + onShowTranscript={() => setTranscriptOpen(true)} onTogglePlay={togglePlay} onSeek={seekTo} onVolumeChange={playerState.setVolume} @@ -316,6 +322,13 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient awemeId={data.aweme_id} mounted={commentState.mounted} /> + + {/* 转录面板 */} + setTranscriptOpen(false)} + transcript={transcript} + /> ); diff --git a/app/aweme/[awemeId]/components/MediaControls.tsx b/app/aweme/[awemeId]/components/MediaControls.tsx index 2998565..b387780 100644 --- a/app/aweme/[awemeId]/components/MediaControls.tsx +++ b/app/aweme/[awemeId]/components/MediaControls.tsx @@ -12,12 +12,14 @@ import { RotateCw, Volume2, VolumeX, + FileText, } from "lucide-react"; import type { RefObject } from "react"; import type { LoopMode, ObjectFit, User } from "../types.ts"; import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils"; import { ProgressBar } from "./ProgressBar"; import { SegmentedProgressBar } from "./SegmentedProgressBar"; +import { MoreMenu, MoreMenuItem } from "./MoreMenu"; interface MediaControlsProps { isVideo: boolean; @@ -40,6 +42,9 @@ interface MediaControlsProps { segmentProgress?: number; musicUrl?: string | null; audioRef?: RefObject; + // 转录相关 + hasTranscript?: boolean; + onShowTranscript?: () => void; // 回调 onTogglePlay: () => void; onSeek: (ratio: number) => void; @@ -71,6 +76,8 @@ export function MediaControls({ segmentProgress = 0, musicUrl, audioRef, + hasTranscript = false, + onShowTranscript, onTogglePlay, onSeek, onVolumeChange, @@ -228,6 +235,18 @@ export function MediaControls({ {objectFit === "contain" ? : } + {/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */} + {isVideo && hasTranscript && onShowTranscript && ( + + )} + {/* 下载 - 中等屏幕以上显示 */} + {/* 更多菜单 - 只在有按钮被折叠时显示 */} + {/* sm屏幕以下会隐藏适配模式,md屏幕以下会隐藏循环、转录、下载、倍速 */} +
+ + {/* 小屏幕隐藏的适配模式 */} +
+ : } + label={objectFit === "contain" ? "填充模式" : "适应模式"} + onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")} + /> +
+ + {/* 中等屏幕以下隐藏的循环模式 */} +
+ : } + label={loopMode === "loop" ? "循环播放" : "顺序播放"} + onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")} + /> +
+ + {/* 中等屏幕以下隐藏的转录按钮 */} + {isVideo && hasTranscript && onShowTranscript && ( +
+ } + label="显示转录" + onClick={onShowTranscript} + /> +
+ )} + + {/* 中等屏幕以下隐藏的下载 */} +
+ } + label={isVideo ? "下载视频" : "下载图片"} + onClick={onDownload} + /> +
+ + {/* 仅在视频模式下显示的倍速(中等屏幕以下) */} + {isVideo && ( +
+ {rate}x} + label="播放速度" + onClick={() => { + const steps = [1, 1.25, 1.5, 2, 0.75, 0.5]; + const i = steps.indexOf(rate); + const next = steps[(i + 1) % steps.length]; + onRateChange(next); + }} + /> +
+ )} +
+
+ {/* 全屏 - 所有设备都显示 */} + + diff --git a/app/aweme/[awemeId]/components/MoreMenu.tsx b/app/aweme/[awemeId]/components/MoreMenu.tsx new file mode 100644 index 0000000..ec8b00d --- /dev/null +++ b/app/aweme/[awemeId]/components/MoreMenu.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { MoreVertical, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +interface MoreMenuProps { + children: React.ReactNode; +} + +export function MoreMenu({ children }: MoreMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + const buttonRef = useRef(null); + + // 点击外部关闭菜单 + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + menuRef.current && + buttonRef.current && + !menuRef.current.contains(event.target as Node) && + !buttonRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen]); + + return ( +
+ {/* 更多按钮 */} + + + {/* 弹出菜单 */} + {isOpen && ( +
+
+ {children} +
+
+ )} +
+ ); +} + +interface MoreMenuItemProps { + icon: React.ReactNode; + label: string; + onClick: () => void; +} + +export function MoreMenuItem({ icon, label, onClick }: MoreMenuItemProps) { + return ( + + ); +} diff --git a/app/aweme/[awemeId]/components/TranscriptPanel.tsx b/app/aweme/[awemeId]/components/TranscriptPanel.tsx new file mode 100644 index 0000000..33fdc0c --- /dev/null +++ b/app/aweme/[awemeId]/components/TranscriptPanel.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { X, Copy, CheckCheck, Check } from "lucide-react"; +import { useState } from "react"; +import type { VideoTranscript } from "../types"; + +interface TranscriptPanelProps { + open: boolean; + onClose: () => void; + transcript: VideoTranscript | null; +} + +export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelProps) { + const [copiedIndex, setCopiedIndex] = useState(null); + const [copiedAll, setCopiedAll] = useState(false); + + if (!open) return null; + + const handleCopy = (text: string, index: number) => { + navigator.clipboard.writeText(text).then(() => { + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); + }); + }; + + const handleCopyAll = () => { + if (!transcript?.transcript) return; + const allText = transcript.transcript.join("\n"); + navigator.clipboard.writeText(allText).then(() => { + setCopiedAll(true); + setTimeout(() => setCopiedAll(false), 2000); + }); + }; + + if (!transcript?.speech_detected || !transcript.transcript?.length) { + return ( +
+
e.stopPropagation()} + > + {/* 头部 */} +
+

语音转录

+ +
+ + {/* 内容 */} +
+

该视频暂无语音转录内容

+
+
+
+ ); + } + + return ( +
+
e.stopPropagation()} + > + {/* 头部 */} +
+
+

语音转录

+ {transcript.language && ( +

+ 语言: {transcript.language} +

+ )} +
+
+ + +
+
+ + {/* 转录列表 */} +
+ {transcript.transcript.map((text, index) => ( +
handleCopy(text, index)} + > +
+
+
+ + #{index + 1} + +
+

+ {text} +

+
+ +
+
+ ))} +
+ + {/* 底部统计 */} +
+ 共 {transcript.transcript.length} 段转录内容 +
+
+
+ ); +} diff --git a/app/aweme/[awemeId]/page.tsx b/app/aweme/[awemeId]/page.tsx index 68bd282..36e72cf 100644 --- a/app/aweme/[awemeId]/page.tsx +++ b/app/aweme/[awemeId]/page.tsx @@ -3,7 +3,7 @@ import BackButton from "@/app/components/BackButton"; import AwemeDetailClient from "./Client"; import type { Metadata } from "next"; import { getFileUrl } from "@/lib/minio"; -import { AwemeData } from "./types"; +import { AwemeData, VideoTranscript } from "./types"; export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise { const id = (await params).awemeId; @@ -82,13 +82,16 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI const aweme = post! return { type: "image" as const, - images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url), animated: getFileUrl(img.animated??'default-animated.png'), })), + images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url), animated: getFileUrl(img.animated ?? 'default-animated.png'), })), music_url: getFileUrl(aweme!.music_url || 'default-music.mp3'), }; } })() } + const transcript: VideoTranscript | null = isVideo ? await prisma.videoTranscript.findUnique({ + where: { videoId: id }, + }) : null; // Compute prev/next neighbors by created_at across videos and image posts const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at); @@ -125,7 +128,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25" /> - + ); } diff --git a/app/aweme/[awemeId]/types.ts b/app/aweme/[awemeId]/types.ts index 804874a..21c317f 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -37,6 +37,12 @@ export type ImageData = { export type AwemeData = VideoData | ImageData; +export type VideoTranscript = { + speech_detected: boolean; + language: string | null; + transcript: string[]; +}; + export type Neighbors = { prev: { aweme_id: string } | null; next: { aweme_id: string } | null; diff --git a/app/components/SearchBox.tsx b/app/components/SearchBox.tsx new file mode 100644 index 0000000..44e46ec --- /dev/null +++ b/app/components/SearchBox.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Search } from "lucide-react"; + +export default function SearchBox() { + const [query, setQuery] = useState(""); + const router = useRouter(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = query.trim(); + if (!trimmed) return; + router.push(`/search?q=${encodeURIComponent(trimmed)}`); + }; + + return ( +
+ setQuery(e.target.value)} + placeholder="搜索视频、图文描述或语音内容..." + className="w-full bg-white/80 dark:bg-zinc-800/80 border border-zinc-300 dark:border-zinc-700 rounded-full px-5 py-3 pl-12 pr-12 text-zinc-900 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all shadow-sm" + /> + + + + ); +} diff --git a/app/globals.css b/app/globals.css index 4ad7460..965dac9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -28,6 +28,15 @@ body { /* 滚动条隐藏 */ .no-scrollbar::-webkit-scrollbar { display: none; } + +/* 搜索结果高亮标记样式 */ +mark { + background-color: rgb(234 179 8 / 0.3); /* yellow-500/30 */ + color: rgb(253 224 71); /* yellow-300 */ + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-weight: 500; +} .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } .h-screen{ diff --git a/app/page.tsx b/app/page.tsx index 762abb0..fcd10db 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,6 @@ import { prisma } from "@/lib/prisma"; import FeedMasonry from "./components/FeedMasonry"; +import SearchBox from "./components/SearchBox"; import type { FeedItem } from "./types/feed"; import type { Metadata } from "next"; import { getFileUrl } from "@/lib/minio"; @@ -53,9 +54,12 @@ export default async function Home() { return (
-

- 作品集 -

+
+

+ 作品集 +

+ +
{(() => { const initial = feed.slice(0, 24); diff --git a/app/search/page.tsx b/app/search/page.tsx new file mode 100644 index 0000000..4d40b9e --- /dev/null +++ b/app/search/page.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { Search, ArrowLeft, X, MessageSquare } from "lucide-react"; + +// 匹配搜索结果类型(适配后端API) +type SearchResultItem = { + id: string; + awemeId: string; + type: 'video' | 'image'; + rank: number; + snippet: string; // 后端返回的高亮片段,已包含标签 + video?: { + aweme_id: string; + desc: string; + cover_url: string | null; + video_url: string | null; + duration_ms: number; + author: { + sec_uid: string; + nickname: string; + avatar_url: string | null; + }; + }; + imagePost?: { + aweme_id: string; + desc: string; + cover_url: string | null; + author: { + sec_uid: string; + nickname: string; + avatar_url: string | null; + }; + }; +}; + +export default function SearchPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const queryParam = searchParams.get("q") || ""; + + const [query, setQuery] = useState(queryParam); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(false); + + const performSearch = useCallback(async (q: string) => { + if (!q.trim()) { + setResults([]); + setSearched(false); + return; + } + + setLoading(true); + setSearched(true); + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`); + if (!res.ok) throw new Error("Search failed"); + const data = await res.json(); + setResults(data.results || []); + } catch (err) { + console.error("Search error:", err); + setResults([]); + } finally { + setLoading(false); + } + }, []); + + // 初始查询 + useEffect(() => { + if (queryParam) { + performSearch(queryParam); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = query.trim(); + if (!trimmed) return; + // 更新 URL + router.push(`/search?q=${encodeURIComponent(trimmed)}`); + performSearch(trimmed); + }; + + const handleClear = () => { + setQuery(""); + setResults([]); + setSearched(false); + router.push("/search"); + }; + + return ( +
+ {/* 顶部搜索栏 */} +
+
+ + +
+
+ setQuery(e.target.value)} + placeholder="搜索视频、图文描述或语音内容..." + className="w-full bg-white/5 border border-white/10 rounded-full px-5 py-3 pl-12 pr-12 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all" + autoFocus + /> + + {query && ( + + )} +
+ +
+
+
+ + {/* 结果区域 */} +
+ {loading && ( +
+
+
+

正在搜索...

+
+
+ )} + + {!loading && searched && results.length === 0 && ( +
+ +

未找到相关内容

+

试试其他关键词

+
+ )} + + {!loading && !searched && !queryParam && ( +
+ +

输入关键词开始搜索

+

支持搜索视频描述、图文描述及语音转写内容

+
+ )} + + {!loading && results.length > 0 && ( +
+
+

+ 找到 {results.length} 个结果 +

+
+ + {/* 单列列表布局 */} +
+ {results.map((item) => { + const content = item.type === 'video' ? item.video : item.imagePost; + if (!content) return null; + + return ( +
+ {/* 主内容区 */} +
+ {/* 左侧:封面预览 */} + + {content.desc} + + + {/* 右侧:信息区 */} +
+ {/* 类型标签 */} +
+ + {item.type === 'video' ? '视频' : '图文'} + + + + 匹配度: {(item.rank * 100).toFixed(1)}% + +
+ + {/* 描述 */} + +

+ {content.desc || "无描述"} +

+ + + {/* 作者信息 */} +
+ {content.author.nickname} + + {content.author.nickname} + +
+ + {/* 匹配详情:展示后端返回的snippet(已包含标签) */} +
+
+

+ + 匹配内容 +

+
+
+
+
+
+
+ ); + })} +
+
+ )} +
+
+ ); +} diff --git a/bun.lock b/bun.lock index 4c64acf..70d6fff 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "puppeteer-extra-plugin-stealth": "^2.11.2", "react": "19.1.0", "react-dom": "19.1.0", + "zod": "^4.1.12", }, "devDependencies": { "@biomejs/biome": "2.2.0", @@ -660,6 +661,8 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], diff --git a/lib/json.ts b/lib/json.ts new file mode 100644 index 0000000..4d00a00 --- /dev/null +++ b/lib/json.ts @@ -0,0 +1,10 @@ +// lib/json.ts +export function json(data: unknown, init?: ResponseInit) { + return new Response( + JSON.stringify(data, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)), + { + ...init, + headers: { 'content-type': 'application/json; charset=utf-8', ...(init?.headers || {}) }, + } + ); +} diff --git a/package.json b/package.json index a3e1670..34ba239 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "playwright-extra": "^4.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "zod": "^4.1.12" }, "devDependencies": { "@biomejs/biome": "2.2.0", diff --git a/prisma/migrations/20251019082632_init/migration.sql b/prisma/migrations/20251019082632_init/migration.sql deleted file mode 100644 index 4eadbc4..0000000 --- a/prisma/migrations/20251019082632_init/migration.sql +++ /dev/null @@ -1,84 +0,0 @@ --- CreateTable -CREATE TABLE "Author" ( - "sec_uid" TEXT NOT NULL, - "uid" TEXT, - "nickname" TEXT NOT NULL, - "signature" TEXT, - "avatar_url" TEXT, - "follower_count" BIGINT NOT NULL DEFAULT 0, - "total_favorited" BIGINT NOT NULL DEFAULT 0, - "unique_id" TEXT, - "short_id" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Author_pkey" PRIMARY KEY ("sec_uid") -); - --- CreateTable -CREATE TABLE "Video" ( - "aweme_id" TEXT NOT NULL, - "desc" TEXT NOT NULL, - "preview_title" TEXT, - "duration_ms" INTEGER NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL, - "share_url" TEXT NOT NULL, - "digg_count" BIGINT NOT NULL DEFAULT 0, - "comment_count" BIGINT NOT NULL DEFAULT 0, - "share_count" BIGINT NOT NULL DEFAULT 0, - "collect_count" BIGINT NOT NULL DEFAULT 0, - "authorId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Video_pkey" PRIMARY KEY ("aweme_id") -); - --- CreateTable -CREATE TABLE "CommentUser" ( - "id" TEXT NOT NULL, - "nickname" TEXT NOT NULL, - "avatar_url" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "CommentUser_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Comment" ( - "cid" TEXT NOT NULL, - "text" TEXT NOT NULL, - "digg_count" BIGINT NOT NULL DEFAULT 0, - "created_at" TIMESTAMP(3) NOT NULL, - "videoId" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Comment_pkey" PRIMARY KEY ("cid") -); - --- CreateIndex -CREATE UNIQUE INDEX "Author_uid_key" ON "Author"("uid"); - --- CreateIndex -CREATE INDEX "Video_authorId_idx" ON "Video"("authorId"); - --- CreateIndex -CREATE INDEX "Video_created_at_idx" ON "Video"("created_at"); - --- CreateIndex -CREATE UNIQUE INDEX "CommentUser_nickname_avatar_url_key" ON "CommentUser"("nickname", "avatar_url"); - --- CreateIndex -CREATE INDEX "Comment_videoId_created_at_idx" ON "Comment"("videoId", "created_at"); - --- AddForeignKey -ALTER TABLE "Video" ADD CONSTRAINT "Video_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "CommentUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251019101440_add_video_url_and_tags/migration.sql b/prisma/migrations/20251019101440_add_video_url_and_tags/migration.sql deleted file mode 100644 index 794fab5..0000000 --- a/prisma/migrations/20251019101440_add_video_url_and_tags/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ -/* - Warnings: - - - Added the required column `video_url` to the `Video` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "Video" ADD COLUMN "tags" TEXT[], -ADD COLUMN "video_url" TEXT NOT NULL; diff --git a/prisma/migrations/20251019111904_add_image_post_and_image_file_models/migration.sql b/prisma/migrations/20251019111904_add_image_post_and_image_file_models/migration.sql deleted file mode 100644 index 3628596..0000000 --- a/prisma/migrations/20251019111904_add_image_post_and_image_file_models/migration.sql +++ /dev/null @@ -1,50 +0,0 @@ --- CreateTable -CREATE TABLE "ImagePost" ( - "aweme_id" TEXT NOT NULL, - "desc" TEXT NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL, - "share_url" TEXT NOT NULL, - "digg_count" BIGINT NOT NULL DEFAULT 0, - "comment_count" BIGINT NOT NULL DEFAULT 0, - "share_count" BIGINT NOT NULL DEFAULT 0, - "collect_count" BIGINT NOT NULL DEFAULT 0, - "authorId" TEXT NOT NULL, - "tags" TEXT[], - "music_url" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ImagePost_pkey" PRIMARY KEY ("aweme_id") -); - --- CreateTable -CREATE TABLE "ImageFile" ( - "id" TEXT NOT NULL, - "postId" TEXT NOT NULL, - "url" TEXT NOT NULL, - "order" INTEGER NOT NULL, - "width" INTEGER, - "height" INTEGER, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ImageFile_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "ImagePost_authorId_idx" ON "ImagePost"("authorId"); - --- CreateIndex -CREATE INDEX "ImagePost_created_at_idx" ON "ImagePost"("created_at"); - --- CreateIndex -CREATE INDEX "ImageFile_postId_order_idx" ON "ImageFile"("postId", "order"); - --- CreateIndex -CREATE UNIQUE INDEX "ImageFile_postId_order_key" ON "ImageFile"("postId", "order"); - --- AddForeignKey -ALTER TABLE "ImagePost" ADD CONSTRAINT "ImagePost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ImageFile" ADD CONSTRAINT "ImageFile_postId_fkey" FOREIGN KEY ("postId") REFERENCES "ImagePost"("aweme_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251019113302_add/migration.sql b/prisma/migrations/20251019113302_add/migration.sql deleted file mode 100644 index 4ed0b03..0000000 --- a/prisma/migrations/20251019113302_add/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ --- DropForeignKey -ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_videoId_fkey"; - --- AlterTable -ALTER TABLE "Comment" ADD COLUMN "imagePostId" TEXT, -ALTER COLUMN "videoId" DROP NOT NULL; - --- CreateIndex -CREATE INDEX "Comment_imagePostId_created_at_idx" ON "Comment"("imagePostId", "created_at"); - --- AddForeignKey -ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Comment" ADD CONSTRAINT "Comment_imagePostId_fkey" FOREIGN KEY ("imagePostId") REFERENCES "ImagePost"("aweme_id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20251019120440_add/migration.sql b/prisma/migrations/20251019120440_add/migration.sql deleted file mode 100644 index fff044f..0000000 --- a/prisma/migrations/20251019120440_add/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "Video" ADD COLUMN "height" INTEGER, -ADD COLUMN "width" INTEGER; diff --git a/prisma/migrations/20251019140306_add_video_cover_url/migration.sql b/prisma/migrations/20251019140306_add_video_cover_url/migration.sql deleted file mode 100644 index 53bd87f..0000000 --- a/prisma/migrations/20251019140306_add_video_cover_url/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Video" ADD COLUMN "cover_url" TEXT; diff --git a/prisma/migrations/20251021014140_add_raw_json_and_fps/migration.sql b/prisma/migrations/20251021014140_add_raw_json_and_fps/migration.sql deleted file mode 100644 index fef5c94..0000000 --- a/prisma/migrations/20251021014140_add_raw_json_and_fps/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ --- AlterTable -ALTER TABLE "ImagePost" ADD COLUMN "raw_json" JSONB; - --- AlterTable -ALTER TABLE "Video" ADD COLUMN "fps" INTEGER, -ADD COLUMN "raw_json" JSONB; diff --git a/prisma/migrations/20251022131006_add_post_animation/migration.sql b/prisma/migrations/20251022131006_add_post_animation/migration.sql deleted file mode 100644 index 0c3d8a0..0000000 --- a/prisma/migrations/20251022131006_add_post_animation/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "ImageFile" ADD COLUMN "animated" TEXT; diff --git a/prisma/migrations/20251023021547_add_duration_to_image_file/migration.sql b/prisma/migrations/20251023021547_add_duration_to_image_file/migration.sql deleted file mode 100644 index a4a126f..0000000 --- a/prisma/migrations/20251023021547_add_duration_to_image_file/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "ImageFile" ADD COLUMN "duration" INTEGER; diff --git a/prisma/migrations/20251023041400_add_comment_images_for_stickers_and_images/migration.sql b/prisma/migrations/20251023041400_add_comment_images_for_stickers_and_images/migration.sql deleted file mode 100644 index 86b6ec0..0000000 --- a/prisma/migrations/20251023041400_add_comment_images_for_stickers_and_images/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ --- CreateTable -CREATE TABLE "CommentImage" ( - "id" TEXT NOT NULL, - "commentId" TEXT NOT NULL, - "url" TEXT NOT NULL, - "order" INTEGER NOT NULL, - "width" INTEGER, - "height" INTEGER, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "CommentImage_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "CommentImage_commentId_order_idx" ON "CommentImage"("commentId", "order"); - --- CreateIndex -CREATE UNIQUE INDEX "CommentImage_commentId_order_key" ON "CommentImage"("commentId", "order"); - --- AddForeignKey -ALTER TABLE "CommentImage" ADD CONSTRAINT "CommentImage_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("cid") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251023050111_add_cascade_delete/migration.sql b/prisma/migrations/20251023050111_add_cascade_delete/migration.sql deleted file mode 100644 index bdb11f0..0000000 --- a/prisma/migrations/20251023050111_add_cascade_delete/migration.sql +++ /dev/null @@ -1,41 +0,0 @@ --- DropForeignKey -ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_imagePostId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_userId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_videoId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."CommentImage" DROP CONSTRAINT "CommentImage_commentId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."ImageFile" DROP CONSTRAINT "ImageFile_postId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."ImagePost" DROP CONSTRAINT "ImagePost_authorId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."Video" DROP CONSTRAINT "Video_authorId_fkey"; - --- AddForeignKey -ALTER TABLE "Video" ADD CONSTRAINT "Video_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Comment" ADD CONSTRAINT "Comment_imagePostId_fkey" FOREIGN KEY ("imagePostId") REFERENCES "ImagePost"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "CommentUser"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "CommentImage" ADD CONSTRAINT "CommentImage_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("cid") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ImagePost" ADD CONSTRAINT "ImagePost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ImageFile" ADD CONSTRAINT "ImageFile_postId_fkey" FOREIGN KEY ("postId") REFERENCES "ImagePost"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251025_baseline/migration.sql b/prisma/migrations/20251025_baseline/migration.sql new file mode 100644 index 0000000..533e7c3 --- /dev/null +++ b/prisma/migrations/20251025_baseline/migration.sql @@ -0,0 +1,200 @@ +-- CreateTable +CREATE TABLE "Author" ( + "sec_uid" TEXT NOT NULL, + "uid" TEXT, + "nickname" TEXT NOT NULL, + "signature" TEXT, + "avatar_url" TEXT, + "follower_count" BIGINT NOT NULL DEFAULT 0, + "total_favorited" BIGINT NOT NULL DEFAULT 0, + "unique_id" TEXT, + "short_id" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Author_pkey" PRIMARY KEY ("sec_uid") +); + +-- CreateTable +CREATE TABLE "Video" ( + "aweme_id" TEXT NOT NULL, + "desc" TEXT NOT NULL, + "preview_title" TEXT, + "duration_ms" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL, + "share_url" TEXT NOT NULL, + "digg_count" BIGINT NOT NULL DEFAULT 0, + "comment_count" BIGINT NOT NULL DEFAULT 0, + "share_count" BIGINT NOT NULL DEFAULT 0, + "collect_count" BIGINT NOT NULL DEFAULT 0, + "width" INTEGER, + "height" INTEGER, + "fps" INTEGER, + "cover_url" TEXT, + "authorId" TEXT NOT NULL, + "tags" TEXT[], + "video_url" TEXT NOT NULL, + "raw_json" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Video_pkey" PRIMARY KEY ("aweme_id") +); + +-- CreateTable +CREATE TABLE "CommentUser" ( + "id" TEXT NOT NULL, + "nickname" TEXT NOT NULL, + "avatar_url" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CommentUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Comment" ( + "cid" TEXT NOT NULL, + "text" TEXT NOT NULL, + "digg_count" BIGINT NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL, + "videoId" TEXT, + "imagePostId" TEXT, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Comment_pkey" PRIMARY KEY ("cid") +); + +-- CreateTable +CREATE TABLE "CommentImage" ( + "id" TEXT NOT NULL, + "commentId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "width" INTEGER, + "height" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CommentImage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ImagePost" ( + "aweme_id" TEXT NOT NULL, + "desc" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL, + "share_url" TEXT NOT NULL, + "digg_count" BIGINT NOT NULL DEFAULT 0, + "comment_count" BIGINT NOT NULL DEFAULT 0, + "share_count" BIGINT NOT NULL DEFAULT 0, + "collect_count" BIGINT NOT NULL DEFAULT 0, + "authorId" TEXT NOT NULL, + "tags" TEXT[], + "music_url" TEXT, + "raw_json" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ImagePost_pkey" PRIMARY KEY ("aweme_id") +); + +-- CreateTable +CREATE TABLE "ImageFile" ( + "id" TEXT NOT NULL, + "postId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "width" INTEGER, + "height" INTEGER, + "animated" TEXT, + "duration" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ImageFile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VideoTranscript" ( + "id" TEXT NOT NULL, + "videoId" TEXT NOT NULL, + "speech_detected" BOOLEAN NOT NULL, + "language" TEXT, + "audio_type" TEXT, + "transcript" TEXT[], + "non_speech_summary" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VideoTranscript_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Author_uid_key" ON "Author"("uid"); + +-- CreateIndex +CREATE INDEX "Video_authorId_idx" ON "Video"("authorId"); + +-- CreateIndex +CREATE INDEX "Video_created_at_idx" ON "Video"("created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "CommentUser_nickname_avatar_url_key" ON "CommentUser"("nickname", "avatar_url"); + +-- CreateIndex +CREATE INDEX "Comment_videoId_created_at_idx" ON "Comment"("videoId", "created_at"); + +-- CreateIndex +CREATE INDEX "Comment_imagePostId_created_at_idx" ON "Comment"("imagePostId", "created_at"); + +-- CreateIndex +CREATE INDEX "CommentImage_commentId_order_idx" ON "CommentImage"("commentId", "order"); + +-- CreateIndex +CREATE UNIQUE INDEX "CommentImage_commentId_order_key" ON "CommentImage"("commentId", "order"); + +-- CreateIndex +CREATE INDEX "ImagePost_authorId_idx" ON "ImagePost"("authorId"); + +-- CreateIndex +CREATE INDEX "ImagePost_created_at_idx" ON "ImagePost"("created_at"); + +-- CreateIndex +CREATE INDEX "ImageFile_postId_order_idx" ON "ImageFile"("postId", "order"); + +-- CreateIndex +CREATE UNIQUE INDEX "ImageFile_postId_order_key" ON "ImageFile"("postId", "order"); + +-- CreateIndex +CREATE UNIQUE INDEX "VideoTranscript_videoId_key" ON "VideoTranscript"("videoId"); + +-- CreateIndex +CREATE INDEX "VideoTranscript_videoId_idx" ON "VideoTranscript"("videoId"); + +-- AddForeignKey +ALTER TABLE "Video" ADD CONSTRAINT "Video_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_imagePostId_fkey" FOREIGN KEY ("imagePostId") REFERENCES "ImagePost"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "CommentUser"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommentImage" ADD CONSTRAINT "CommentImage_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("cid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ImagePost" ADD CONSTRAINT "ImagePost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ImageFile" ADD CONSTRAINT "ImageFile_postId_fkey" FOREIGN KEY ("postId") REFERENCES "ImagePost"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VideoTranscript" ADD CONSTRAINT "VideoTranscript_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml deleted file mode 100644 index 044d57c..0000000 --- a/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ffa1e8..20c3e17 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,172 +4,152 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") + url = env("DATABASE_URL") } model Author { - // 抖音作者;以 sec_uid 稳定标识 - sec_uid String @id - uid String? @unique + sec_uid String @id + uid String? @unique nickname String signature String? avatar_url String? - follower_count BigInt @default(0) - total_favorited BigInt @default(0) - unique_id String? // 抖音号 + follower_count BigInt @default(0) + total_favorited BigInt @default(0) + unique_id String? short_id String? - videos Video[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt imagePosts ImagePost[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + videos Video[] } 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) - - // 视频分辨率(用于前端预布局) - width Int? - height Int? - - // 视频帧率 - fps Int? - - // 视频封面(首帧提取后上传到 MinIO 的外链) - cover_url String? - - authorId String - author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade) - - comments Comment[] - - tags String[] // 视频标签列表 - video_url String // 视频文件 URL - - // 保存完整的接口原始 JSON 数据(用于备份和后续分析) - raw_json Json? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + 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? @@index([authorId]) @@index([created_at]) } model CommentUser { - id String @id @default(cuid()) + id String @id @default(cuid()) nickname String avatar_url String? - - // 以 (nickname, avatar_url) 近似去重;如果你从响应里拿到用户 uid,可以改为以 uid 作为主键 - @@unique([nickname, avatar_url]) - + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt comments Comment[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + + @@unique([nickname, avatar_url]) } model Comment { - cid String @id - text String - digg_count BigInt @default(0) - created_at DateTime - - // 可关联视频或图文中的一种 - videoId String? - video Video? @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade) - - imagePostId String? - imagePost ImagePost? @relation(fields: [imagePostId], references: [aweme_id], onDelete: Cascade) - - userId String - user CommentUser @relation(fields: [userId], references: [id], onDelete: Cascade) - - // 评论下的贴纸/配图(统一按图片存储) - images CommentImage[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + cid String @id + text String + digg_count BigInt @default(0) + created_at DateTime + videoId String? + userId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + imagePostId String? + imagePost ImagePost? @relation(fields: [imagePostId], references: [aweme_id], onDelete: Cascade) + user CommentUser @relation(fields: [userId], references: [id], onDelete: Cascade) + video Video? @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade) + images CommentImage[] @@index([videoId, created_at]) @@index([imagePostId, created_at]) } -// 评论图片(贴纸和配图统一保存在这里) model CommentImage { - id String @id @default(cuid()) - commentId String - comment Comment @relation(fields: [commentId], references: [cid], onDelete: Cascade) + id String @id @default(cuid()) + commentId String + url String + order Int + width Int? + height Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + comment Comment @relation(fields: [commentId], references: [cid], onDelete: Cascade) - url String - order Int // 在该评论中的顺序(0 开始) - width Int? - height Int? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([commentId, order]) @@unique([commentId, order]) + @@index([commentId, order]) } -// 图片作品(图文) model ImagePost { - aweme_id String @id + aweme_id String @id desc String 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) - + digg_count BigInt @default(0) + comment_count BigInt @default(0) + share_count BigInt @default(0) + collect_count BigInt @default(0) authorId String - author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade) - tags String[] - music_url String? // 背景音乐(已上传到 MinIO 的外链) - - images ImageFile[] - comments Comment[] - - // 保存完整的接口原始 JSON 数据(用于备份和后续分析) + music_url String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt raw_json Json? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + comments Comment[] + images ImageFile[] + author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade) @@index([authorId]) @@index([created_at]) } -// 图片作品中的单张图片(已上传到 MinIO 的外链) model ImageFile { id String @id @default(cuid()) postId String - post ImagePost @relation(fields: [postId], references: [aweme_id], onDelete: Cascade) url String - order Int // 在作品中的顺序(从 0 开始) + order Int width Int? height Int? - - animated String? // 如果是动图,存储 video 格式的 URL - duration Int? // 动图时长(毫秒),仅当 animated 不为 null 时有值 - createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + animated String? + duration Int? + post ImagePost @relation(fields: [postId], references: [aweme_id], onDelete: Cascade) - @@index([postId, order]) @@unique([postId, order]) + @@index([postId, order]) +} + +model VideoTranscript { + id String @id @default(cuid()) + videoId String @unique + speech_detected Boolean + language String? + audio_type String? + transcript String[] + non_speech_summary String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + transcript_zh_tsv Unsupported("tsvector")? + video Video @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade) + + @@index([videoId]) + @@index([transcript_zh_tsv], map: "video_transcript_zh_idx", type: Gin) }