完成转录功能,中文脚本搜索,播放界面显示脚本

This commit is contained in:
feie9456 2025-10-25 19:22:41 +08:00
parent d1b4fb6be0
commit 0d18595b68
34 changed files with 1827 additions and 404 deletions

143
app/admin/stt/page.tsx Normal file
View File

@ -0,0 +1,143 @@
'use client';
import Link from 'next/link';
import BackButton from '@/app/components/BackButton';
export default function SttAdminPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-8">
<BackButton />
<div className="mt-8">
<h1 className="text-3xl font-bold text-gray-900">
STT
</h1>
<p className="text-gray-600 mt-2">
</p>
</div>
<div className="mt-8 grid gap-6">
{/* 视频转写管理 */}
<Link
href="/admin/stt/videos"
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200 hover:border-blue-500"
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
</h2>
<p className="text-gray-600 mt-1">
</p>
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</div>
</Link>
{/* 转写设置(预留) */}
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
</h2>
<p className="text-gray-600 mt-1">
API
</p>
<div className="mt-4 text-gray-400 font-medium">
</div>
</div>
</div>
</div>
{/* 转写统计(预留) */}
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
</h2>
<p className="text-gray-600 mt-1">
使
</p>
<div className="mt-4 text-gray-400 font-medium">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<Video[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [transcribing, setTranscribing] = useState<Set<string>>(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<boolean> => {
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<boolean>[] = [];
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 (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p className="text-red-600">: {error}</p>
<button
onClick={fetchVideos}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-8">
{/* Header */}
<div className="mb-8">
<BackButton />
<h1 className="text-3xl font-bold text-gray-900 mt-4"></h1>
<p className="text-gray-600 mt-2"></p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-600"></div>
<div className="text-3xl font-bold text-gray-900 mt-2">{stats.total}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-600"></div>
<div className="text-3xl font-bold text-green-600 mt-2">{stats.transcribed}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-600"></div>
<div className="text-3xl font-bold text-orange-600 mt-2">{stats.pending}</div>
</div>
</div>
{/* Filter */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex gap-4">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'all'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
({stats.total})
</button>
<button
onClick={() => setFilter('transcribed')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'transcribed'
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
({stats.transcribed})
</button>
<button
onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'pending'
? 'bg-orange-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
({stats.pending})
</button>
</div>
<div className="flex items-center gap-4">
{batchTranscribing && (
<div className="text-sm text-gray-600">
: {batchProgress.current} / {batchProgress.total}
</div>
)}
<button
onClick={handleBatchTranscribe}
disabled={batchTranscribing || stats.pending === 0}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
>
{batchTranscribing ? (
<span className="flex items-center gap-2">
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
...
</span>
) : (
'全部转写'
)}
</button>
</div>
</div>
</div>
{/* Video List */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredVideos.map((video) => (
<tr key={video.aweme_id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-4">
<img
src={video.cover_url}
alt=""
className="w-20 h-28 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<Link
href={`/aweme/${video.aweme_id}`}
className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2"
>
{video.desc || '无描述'}
</Link>
<p className="text-sm text-gray-500 mt-1">
@{video.author.nickname}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{Math.floor(video.duration_ms / 1000)}s
</td>
<td className="px-6 py-4">
{video.transcript ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
</span>
{video.transcript.language && (
<span className="text-xs text-gray-500">
{video.transcript.language}
</span>
)}
</div>
{video.transcript.speech_detected ? (
<div className="text-xs text-gray-600 line-clamp-5 max-w-md">
{video.transcript.transcript}
</div>
) : (
<div className="text-xs text-gray-500">
<span className="font-medium">
{video.transcript.audio_type || '非语音'}
</span>
{video.transcript.non_speech_summary && (
<span className="ml-1">
- {video.transcript.non_speech_summary}
</span>
)}
</div>
)}
</div>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{video.transcript ? (
<button
onClick={() => handleTranscribe(video.aweme_id)}
disabled={transcribing.has(video.aweme_id)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{transcribing.has(video.aweme_id) ? (
<span className="flex items-center gap-2">
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
...
</span>
) : (
'重新转写'
)}
</button>
) : (
<button
onClick={() => handleTranscribe(video.aweme_id)}
disabled={transcribing.has(video.aweme_id)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed transition-colors"
>
{transcribing.has(video.aweme_id) ? (
<span className="flex items-center gap-2">
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
...
</span>
) : (
'开始转写'
)}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredVideos.length === 0 && (
<div className="text-center py-12 text-gray-500">
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -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 }
);
}
}

View File

@ -12,6 +12,7 @@ import { saveToDB, saveImagePostToDB } from '@/app/api/fetcher/persist';
import chalk from 'chalk'; import chalk from 'chalk';
import { acquireIsolatedContext, releaseIsolatedContext } from '@/app/api/fetcher/browser'; import { acquireIsolatedContext, releaseIsolatedContext } from '@/app/api/fetcher/browser';
import { extractFirstFrame } from '@/app/api/media'; import { extractFirstFrame } from '@/app/api/media';
import { transcriptAweme } from '../stt';
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/'; const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
const COMMENT_PATH = '/aweme/v1/web/comment/list/'; 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); const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl, fps ?? undefined);
console.log(chalk.green.bold('✓ 视频作品保存成功')); console.log(chalk.green.bold('✓ 视频作品保存成功'));
transcriptAweme(detail.aweme_detail.aweme_id).catch((e) => {}); // 异步转写,不阻塞主流程
return { type: "video", ...saved }; return { type: "video", ...saved };
} else { } else {
throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE'); throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE');

164
app/api/search/route.ts Normal file
View File

@ -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=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
)
ELSE
ts_headline(
'zhcfg',
v.desc,
tsq.query,
'StartSel=<mark>,StopSel=</mark>,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=<mark>,StopSel=</mark>,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 });
}
}

View File

@ -4,45 +4,62 @@ import prompt from "./prompt.md";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { downloadFile } from "@/lib/minio"; import { downloadFile } from "@/lib/minio";
import { extractAudio } from "../media"; import { extractAudio } from "../media";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
const client = new OpenAI({ const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_API_BASE_URL, 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<typeof SttSchema>;
async function transcriptAudio(audio: Buffer | string) { async function transcriptAudio(audio: Buffer | string) {
if (typeof audio === "string") { if (typeof audio === "string") {
audio = fs.readFileSync(audio); audio = fs.readFileSync(audio);
} }
const base64Audio = Buffer.from(audio).toString("base64"); const base64Audio = Buffer.from(audio).toString("base64");
const response = await client.chat.completions.create({ const completion = await client.chat.completions.create({
model: "gemini-2.5-flash-lite", model: "gemini-2.5-flash",
messages: [ messages: [
{ {
role: "user", role: "user",
content: [ content: [
{ { type: "text", text: prompt },
type: "text",
text: prompt,
},
{ {
type: "input_audio", type: "input_audio",
input_audio: { input_audio: { data: base64Audio, format: "mp3" },
data: base64Audio,
format: "mp3",
},
}, },
], ],
}, },
], ],
response_format: zodResponseFormat(SttSchema, "stt_result"),
}); });
console.log(response.choices[0].message.content); const data = completion.choices?.[0]?.message
return response.choices[0].message.content; 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<SttResult> {
const aweme = await prisma.video.findUnique({ const aweme = await prisma.video.findUnique({
where: { aweme_id: awemeId }, where: { aweme_id: awemeId },
}); });
@ -52,11 +69,34 @@ export async function transcriptAweme(awemeId: string) {
const vPath = aweme.video_url const vPath = aweme.video_url
const buffer = await downloadFile(vPath); 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) { if (!audioDat || !audioDat.buffer) {
throw new Error("Failed to extract audio from video"); 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;
} }

View File

@ -1,42 +1,24 @@
你将接收一段音频。请完成: 你将接收一段音频。请完成你将接收一段音频。语音活动检测VAD与声源分类。
A.语音活动检测VAD与声源分类
B.条件式处理:
- 若包含可辨识的人类发言:** 进行转录 **(保留原语言,不翻译),并尽可能给出说话人分离与时间戳;
- 若不包含人类发言:** 不转录 **,仅返回音频类型与简要描述。
C.严格输出为下方 JSON字段不得缺失或额外编造。听不清处用“[听不清]”。
** 输出 JSON Schema示例** **输出 JSON Schema示例**
```json ```json
{ {
"speech_detected": true, "speech_detected": true,
"language": "zh-CN", "language": "zh-CN",
"audio_type": null, "audio_type": null,
"background": "music | ambience | none | unknown", "transcript": ["大家好我是xxx", "欢迎来到今天的视频", "今天我们来聊聊AI的未来"],
"transcript": [
{
"start": 0.00,
"end": 3.42,
"text": "大家好,我是……"
},
{
"start": 3.50,
"end": 6.10,
"text": "欢迎来到今天的节目。"
}
],
"non_speech_summary": null, "non_speech_summary": null,
} }
``` ```
>
** 当无发言时返回:** **当无发言时返回:**
```json ```json
{ {
"speech_detected": false, "speech_detected": false,
"language": null, "language": null,
"audio_type": "music | ambience | animal | mechanical | other", "audio_type": "music | ambience | animal | mechanical | other",
"background": "none",
"transcript": [], "transcript": [],
"non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。", "non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。",
} }
@ -44,8 +26,4 @@ C.严格输出为下方 JSON字段不得缺失或额外编造。听不清处
** 规则补充 ** ** 规则补充 **
* 只要存在可理解的人类发言(即便有音乐 / 噪声),就执行转录,并在 `background` 标注“music / ambience”。
* 不要将唱词 / 哼唱视为“发言”;若仅有人声演唱且无口语发言,视为 ** 音乐 **
* 不要臆测未听清内容;不要添加与音频无关的信息。
* 时间单位统一为秒,保留两位小数。
* 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。 * 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。

View File

@ -1,15 +1,16 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useRef } from "react"; import { useMemo, useRef, useState } from "react";
import { Pause, Play } from "lucide-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 { BackgroundCanvas } from "./components/BackgroundCanvas";
import { CommentPanel } from "./components/CommentPanel"; import { CommentPanel } from "./components/CommentPanel";
import { ImageCarousel } from "./components/ImageCarousel"; import { ImageCarousel } from "./components/ImageCarousel";
import { ImageNavigationButtons } from "./components/ImageNavigationButtons"; import { ImageNavigationButtons } from "./components/ImageNavigationButtons";
import { MediaControls } from "./components/MediaControls"; import { MediaControls } from "./components/MediaControls";
import { NavigationButtons } from "./components/NavigationButtons"; import { NavigationButtons } from "./components/NavigationButtons";
import { TranscriptPanel } from "./components/TranscriptPanel";
import { VideoPlayer } from "./components/VideoPlayer"; import { VideoPlayer } from "./components/VideoPlayer";
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas"; import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
import { useCommentState } from "./hooks/useCommentState"; import { useCommentState } from "./hooks/useCommentState";
@ -17,21 +18,24 @@ import { useImageCarousel } from "./hooks/useImageCarousel";
import { useNavigation } from "./hooks/useNavigation"; import { useNavigation } from "./hooks/useNavigation";
import { usePlayerState } from "./hooks/usePlayerState"; import { usePlayerState } from "./hooks/usePlayerState";
import { useVideoPlayer } from "./hooks/useVideoPlayer"; import { useVideoPlayer } from "./hooks/useVideoPlayer";
import { Prisma } from "@prisma/client";
const SEGMENT_MS = 4000; const SEGMENT_MS = 4000;
interface AwemeDetailClientProps { interface AwemeDetailClientProps {
data: AwemeData; data: AwemeData;
neighbors: Neighbors; neighbors: Neighbors;
transcript: VideoTranscript | null;
} }
export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClientProps) { export default function AwemeDetailClient({ data, neighbors, transcript }: AwemeDetailClientProps) {
const router = useRouter(); const router = useRouter();
const isVideo = data.type === "video"; const isVideo = data.type === "video";
// 状态管理 // 状态管理
const playerState = usePlayerState(); const playerState = usePlayerState();
const commentState = useCommentState(); const commentState = useCommentState();
const [transcriptOpen, setTranscriptOpen] = useState(false);
// 引用 // 引用
const mediaContainerRef = useRef<HTMLDivElement | null>(null); const mediaContainerRef = useRef<HTMLDivElement | null>(null);
@ -284,6 +288,8 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
segmentProgress={imageCarouselState.segProgress} segmentProgress={imageCarouselState.segProgress}
musicUrl={isVideo ? undefined : (data as ImageData).music_url} musicUrl={isVideo ? undefined : (data as ImageData).music_url}
audioRef={audioRef} audioRef={audioRef}
hasTranscript={isVideo && !!transcript?.speech_detected}
onShowTranscript={() => setTranscriptOpen(true)}
onTogglePlay={togglePlay} onTogglePlay={togglePlay}
onSeek={seekTo} onSeek={seekTo}
onVolumeChange={playerState.setVolume} onVolumeChange={playerState.setVolume}
@ -316,6 +322,13 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
awemeId={data.aweme_id} awemeId={data.aweme_id}
mounted={commentState.mounted} mounted={commentState.mounted}
/> />
{/* 转录面板 */}
<TranscriptPanel
open={transcriptOpen}
onClose={() => setTranscriptOpen(false)}
transcript={transcript}
/>
</div> </div>
</div> </div>
); );

View File

@ -12,12 +12,14 @@ import {
RotateCw, RotateCw,
Volume2, Volume2,
VolumeX, VolumeX,
FileText,
} from "lucide-react"; } from "lucide-react";
import type { RefObject } from "react"; import type { RefObject } from "react";
import type { LoopMode, ObjectFit, User } from "../types.ts"; import type { LoopMode, ObjectFit, User } from "../types.ts";
import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils"; import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils";
import { ProgressBar } from "./ProgressBar"; import { ProgressBar } from "./ProgressBar";
import { SegmentedProgressBar } from "./SegmentedProgressBar"; import { SegmentedProgressBar } from "./SegmentedProgressBar";
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
interface MediaControlsProps { interface MediaControlsProps {
isVideo: boolean; isVideo: boolean;
@ -40,6 +42,9 @@ interface MediaControlsProps {
segmentProgress?: number; segmentProgress?: number;
musicUrl?: string | null; musicUrl?: string | null;
audioRef?: RefObject<HTMLAudioElement | null>; audioRef?: RefObject<HTMLAudioElement | null>;
// 转录相关
hasTranscript?: boolean;
onShowTranscript?: () => void;
// 回调 // 回调
onTogglePlay: () => void; onTogglePlay: () => void;
onSeek: (ratio: number) => void; onSeek: (ratio: number) => void;
@ -71,6 +76,8 @@ export function MediaControls({
segmentProgress = 0, segmentProgress = 0,
musicUrl, musicUrl,
audioRef, audioRef,
hasTranscript = false,
onShowTranscript,
onTogglePlay, onTogglePlay,
onSeek, onSeek,
onVolumeChange, onVolumeChange,
@ -228,6 +235,18 @@ export function MediaControls({
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />} {objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
</button> </button>
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
{isVideo && hasTranscript && onShowTranscript && (
<button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={onShowTranscript}
aria-label="显示转录文本"
title="显示转录文本"
>
<FileText size={18} />
</button>
)}
{/* 下载 - 中等屏幕以上显示 */} {/* 下载 - 中等屏幕以上显示 */}
<button <button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
@ -238,6 +257,66 @@ export function MediaControls({
<Download size={18} /> <Download size={18} />
</button> </button>
{/* 更多菜单 - 只在有按钮被折叠时显示 */}
{/* sm屏幕以下会隐藏适配模式md屏幕以下会隐藏循环、转录、下载、倍速 */}
<div className="sm:block md:hidden">
<MoreMenu>
{/* 小屏幕隐藏的适配模式 */}
<div className="sm:hidden">
<MoreMenuItem
icon={objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
label={objectFit === "contain" ? "填充模式" : "适应模式"}
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
/>
</div>
{/* 中等屏幕以下隐藏的循环模式 */}
<div className="md:hidden">
<MoreMenuItem
icon={loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
label={loopMode === "loop" ? "循环播放" : "顺序播放"}
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
/>
</div>
{/* 中等屏幕以下隐藏的转录按钮 */}
{isVideo && hasTranscript && onShowTranscript && (
<div className="md:hidden">
<MoreMenuItem
icon={<FileText size={18} />}
label="显示转录"
onClick={onShowTranscript}
/>
</div>
)}
{/* 中等屏幕以下隐藏的下载 */}
<div className="md:hidden">
<MoreMenuItem
icon={<Download size={18} />}
label={isVideo ? "下载视频" : "下载图片"}
onClick={onDownload}
/>
</div>
{/* 仅在视频模式下显示的倍速(中等屏幕以下) */}
{isVideo && (
<div className="md:hidden">
<MoreMenuItem
icon={<span className="text-sm font-mono">{rate}x</span>}
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);
}}
/>
</div>
)}
</MoreMenu>
</div>
{/* 全屏 - 所有设备都显示 */} {/* 全屏 - 所有设备都显示 */}
<button <button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
@ -246,6 +325,8 @@ export function MediaControls({
> >
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />} {isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
</button> </button>
</div> </div>
</div> </div>

View File

@ -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<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 (
<div className="relative">
{/* 更多按钮 */}
<button
ref={buttonRef}
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => setIsOpen(!isOpen)}
aria-label="更多选项"
title="更多选项"
>
{isOpen ? <X size={18} /> : <MoreVertical size={18} />}
</button>
{/* 弹出菜单 */}
{isOpen && (
<div
ref={menuRef}
className="absolute bottom-full right-0 mb-2 bg-zinc-900/95 backdrop-blur-xl border border-white/20 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
style={{ minWidth: "200px" }}
>
<div className="p-2 flex flex-col gap-1">
{children}
</div>
</div>
)}
</div>
);
}
interface MoreMenuItemProps {
icon: React.ReactNode;
label: string;
onClick: () => void;
}
export function MoreMenuItem({ icon, label, onClick }: MoreMenuItemProps) {
return (
<button
className="flex items-center gap-3 px-3 py-2.5 text-white/90 hover:bg-white/10 rounded-lg transition-colors text-left w-full"
onClick={onClick}
>
<span className="flex-shrink-0">{icon}</span>
<span className="text-sm">{label}</span>
</button>
);
}

View File

@ -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<number | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h2 className="text-lg font-semibold text-white"></h2>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="关闭"
>
<X size={20} className="text-white/80" />
</button>
</div>
{/* 内容 */}
<div className="p-8 text-center text-white/60">
<p></p>
</div>
</div>
</div>
);
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
<div>
<h2 className="text-lg font-semibold text-white"></h2>
{transcript.language && (
<p className="text-xs text-white/50 mt-1">
: {transcript.language}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopyAll}
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-600/20 text-blue-300 rounded-lg hover:bg-blue-600/30 transition-colors"
>
{copiedAll ? (
<>
<CheckCheck size={16} />
</>
) : (
<>
<Copy size={16} />
</>
)}
</button>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="关闭"
>
<X size={20} className="text-white/80" />
</button>
</div>
</div>
{/* 转录列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{transcript.transcript.map((text, index) => (
<div
key={index}
className="group bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleCopy(text, index)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-blue-400">
#{index + 1}
</span>
</div>
<p className="text-sm text-white/90 leading-relaxed break-words">
{text}
</p>
</div>
<button
onClick={() => handleCopy(text, index)}
className="flex-shrink-0 p-2 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-white/10 transition-all"
aria-label="复制"
>
{copiedIndex === index ? (
<Check size={16} className="text-green-400" />
) : (
<Copy size={16} className="text-white/60" />
)}
</button>
</div>
</div>
))}
</div>
{/* 底部统计 */}
<div className="flex-shrink-0 p-3 border-t border-white/10 text-center text-xs text-white/50">
{transcript.transcript.length}
</div>
</div>
</div>
);
}

View File

@ -3,7 +3,7 @@ import BackButton from "@/app/components/BackButton";
import AwemeDetailClient from "./Client"; import AwemeDetailClient from "./Client";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getFileUrl } from "@/lib/minio"; import { getFileUrl } from "@/lib/minio";
import { AwemeData } from "./types"; import { AwemeData, VideoTranscript } from "./types";
export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> {
const id = (await params).awemeId; const id = (await params).awemeId;
@ -82,13 +82,16 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
const aweme = post! const aweme = post!
return { return {
type: "image" as const, 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'), 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 // Compute prev/next neighbors by created_at across videos and image posts
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at); 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" 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"
/> />
</div> </div>
<AwemeDetailClient data={data} neighbors={neighbors} /> <AwemeDetailClient data={data} neighbors={neighbors} transcript={transcript} />
</main> </main>
); );
} }

View File

@ -37,6 +37,12 @@ export type ImageData = {
export type AwemeData = VideoData | ImageData; export type AwemeData = VideoData | ImageData;
export type VideoTranscript = {
speech_detected: boolean;
language: string | null;
transcript: string[];
};
export type Neighbors = { export type Neighbors = {
prev: { aweme_id: string } | null; prev: { aweme_id: string } | null;
next: { aweme_id: string } | null; next: { aweme_id: string } | null;

View File

@ -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 (
<form onSubmit={handleSubmit} className="relative w-full max-w-2xl mb-8">
<input
type="text"
value={query}
onChange={(e) => 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"
/>
<Search
size={20}
className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400"
/>
<button
type="submit"
className="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!query.trim()}
>
</button>
</form>
);
}

View File

@ -28,6 +28,15 @@ body {
/* 滚动条隐藏 */ /* 滚动条隐藏 */
.no-scrollbar::-webkit-scrollbar { display: none; } .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; } .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.h-screen{ .h-screen{

View File

@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import FeedMasonry from "./components/FeedMasonry"; import FeedMasonry from "./components/FeedMasonry";
import SearchBox from "./components/SearchBox";
import type { FeedItem } from "./types/feed"; import type { FeedItem } from "./types/feed";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getFileUrl } from "@/lib/minio"; import { getFileUrl } from "@/lib/minio";
@ -53,9 +54,12 @@ export default async function Home() {
return ( return (
<main className="min-h-screen w-full px-4 py-8 md:py-12"> <main className="min-h-screen w-full px-4 py-8 md:py-12">
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight mb-6"> <div className="flex flex-col items-center mb-8">
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight mb-6">
</h1>
</h1>
<SearchBox />
</div>
{(() => { {(() => {
const initial = feed.slice(0, 24); const initial = feed.slice(0, 24);

268
app/search/page.tsx Normal file
View File

@ -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; // 后端返回的高亮片段,已包含<mark>标签
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<SearchResultItem[]>([]);
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 (
<div className="min-h-screen bg-zinc-950 text-white">
{/* 顶部搜索栏 */}
<header className="sticky top-0 z-50 bg-zinc-900/80 backdrop-blur-xl border-b border-white/10">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
<button
onClick={() => router.push("/")}
className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="返回首页"
>
<ArrowLeft size={24} />
</button>
<form onSubmit={handleSearch} className="flex-1 flex items-center gap-3">
<div className="relative flex-1">
<input
type="text"
value={query}
onChange={(e) => 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
/>
<Search
size={20}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
/>
{query && (
<button
type="button"
onClick={handleClear}
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-white/10 transition-colors"
aria-label="清空"
>
<X size={18} className="text-white/60" />
</button>
)}
</div>
<button
type="submit"
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading || !query.trim()}
>
{loading ? "搜索中..." : "搜索"}
</button>
</form>
</div>
</header>
{/* 结果区域 */}
<main className="max-w-7xl mx-auto px-4 py-8">
{loading && (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
<p className="text-white/60">...</p>
</div>
</div>
)}
{!loading && searched && results.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-white/60">
<Search size={64} className="mb-4 opacity-20" />
<p className="text-xl"></p>
<p className="text-sm mt-2"></p>
</div>
)}
{!loading && !searched && !queryParam && (
<div className="flex flex-col items-center justify-center py-20 text-white/40">
<Search size={64} className="mb-4 opacity-20" />
<p className="text-xl"></p>
<p className="text-sm mt-2"></p>
</div>
)}
{!loading && results.length > 0 && (
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg text-white/80">
<span className="text-white font-semibold">{results.length}</span>
</h2>
</div>
{/* 单列列表布局 */}
<div className="space-y-4">
{results.map((item) => {
const content = item.type === 'video' ? item.video : item.imagePost;
if (!content) return null;
return (
<div
key={item.awemeId}
className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl overflow-hidden hover:border-white/20 transition-all hover:-translate-y-1"
>
{/* 主内容区 */}
<div className="flex gap-4 p-4">
{/* 左侧:封面预览 */}
<Link
href={`/aweme/${content.aweme_id}`}
target="_blank"
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
>
<img
src={content.cover_url || ""}
alt={content.desc}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</Link>
{/* 右侧:信息区 */}
<div className="flex-1 min-w-0 flex flex-col">
{/* 类型标签 */}
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-0.5 text-xs rounded ${
item.type === 'video'
? 'bg-blue-600/20 text-blue-300'
: 'bg-purple-600/20 text-purple-300'
}`}>
{item.type === 'video' ? '视频' : '图文'}
</span>
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded">
<MessageSquare size={12} />
: {(item.rank * 100).toFixed(1)}%
</span>
</div>
{/* 描述 */}
<Link href={`/aweme/${content.aweme_id}`} target="_blank">
<p className="text-white/90 text-sm sm:text-base line-clamp-2 mb-3 hover:text-white transition-colors">
{content.desc || "无描述"}
</p>
</Link>
{/* 作者信息 */}
<div className="flex items-center gap-2 mb-4">
<img
src={content.author.avatar_url || ""}
alt={content.author.nickname}
className="w-6 h-6 rounded-full ring-1 ring-white/20"
/>
<span className="text-xs text-white/60">
{content.author.nickname}
</span>
</div>
{/* 匹配详情展示后端返回的snippet已包含<mark>标签) */}
<div className="space-y-3">
<div>
<h4 className="flex items-center gap-2 text-xs font-medium text-green-300 mb-1.5">
<MessageSquare size={14} />
</h4>
<div
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
dangerouslySetInnerHTML={{ __html: item.snippet }}
style={{
wordBreak: 'break-word',
}}
/>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</main>
</div>
);
}

View File

@ -15,6 +15,7 @@
"puppeteer-extra-plugin-stealth": "^2.11.2", "puppeteer-extra-plugin-stealth": "^2.11.2",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"zod": "^4.1.12",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",
@ -660,6 +661,8 @@
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "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/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=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],

10
lib/json.ts Normal file
View File

@ -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 || {}) },
}
);
}

View File

@ -20,7 +20,8 @@
"playwright-extra": "^4.3.6", "playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2", "puppeteer-extra-plugin-stealth": "^2.11.2",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0" "react-dom": "19.1.0",
"zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "Video" ADD COLUMN "height" INTEGER,
ADD COLUMN "width" INTEGER;

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Video" ADD COLUMN "cover_url" TEXT;

View File

@ -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;

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "ImageFile" ADD COLUMN "animated" TEXT;

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "ImageFile" ADD COLUMN "duration" INTEGER;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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"

View File

@ -4,172 +4,152 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model Author { model Author {
// 抖音作者;以 sec_uid 稳定标识 sec_uid String @id
sec_uid String @id uid String? @unique
uid String? @unique
nickname String nickname String
signature String? signature String?
avatar_url String? avatar_url String?
follower_count BigInt @default(0) follower_count BigInt @default(0)
total_favorited BigInt @default(0) total_favorited BigInt @default(0)
unique_id String? // 抖音号 unique_id String?
short_id String? short_id String?
videos Video[] createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
imagePosts ImagePost[] imagePosts ImagePost[]
videos Video[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model Video { model Video {
aweme_id String @id aweme_id String @id
desc String desc String
preview_title String? preview_title String?
duration_ms Int duration_ms Int
created_at DateTime created_at DateTime
share_url String share_url String
digg_count BigInt @default(0)
digg_count BigInt @default(0) comment_count BigInt @default(0)
comment_count BigInt @default(0) share_count BigInt @default(0)
share_count BigInt @default(0) collect_count BigInt @default(0)
collect_count BigInt @default(0) authorId String
createdAt DateTime @default(now())
// 视频分辨率(用于前端预布局) updatedAt DateTime @updatedAt
width Int? tags String[]
height Int? video_url String
height Int?
// 视频帧率 width Int?
fps Int? cover_url String?
fps Int?
// 视频封面(首帧提取后上传到 MinIO 的外链) raw_json Json?
cover_url String? comments Comment[]
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
authorId String transcript VideoTranscript?
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
@@index([authorId]) @@index([authorId])
@@index([created_at]) @@index([created_at])
} }
model CommentUser { model CommentUser {
id String @id @default(cuid()) id String @id @default(cuid())
nickname String nickname String
avatar_url String? avatar_url String?
createdAt DateTime @default(now())
// 以 (nickname, avatar_url) 近似去重;如果你从响应里拿到用户 uid可以改为以 uid 作为主键 updatedAt DateTime @updatedAt
@@unique([nickname, avatar_url])
comments Comment[] comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @@unique([nickname, avatar_url])
} }
model Comment { model Comment {
cid String @id cid String @id
text String text String
digg_count BigInt @default(0) digg_count BigInt @default(0)
created_at DateTime created_at DateTime
videoId String?
// 可关联视频或图文中的一种 userId String
videoId String? createdAt DateTime @default(now())
video Video? @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade) updatedAt DateTime @updatedAt
imagePostId String?
imagePostId String? imagePost ImagePost? @relation(fields: [imagePostId], references: [aweme_id], onDelete: Cascade)
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)
userId String images CommentImage[]
user CommentUser @relation(fields: [userId], references: [id], onDelete: Cascade)
// 评论下的贴纸/配图(统一按图片存储)
images CommentImage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([videoId, created_at]) @@index([videoId, created_at])
@@index([imagePostId, created_at]) @@index([imagePostId, created_at])
} }
// 评论图片(贴纸和配图统一保存在这里)
model CommentImage { model CommentImage {
id String @id @default(cuid()) id String @id @default(cuid())
commentId String commentId String
comment Comment @relation(fields: [commentId], references: [cid], onDelete: Cascade) 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]) @@unique([commentId, order])
@@index([commentId, order])
} }
// 图片作品(图文)
model ImagePost { model ImagePost {
aweme_id String @id aweme_id String @id
desc String desc String
created_at DateTime created_at DateTime
share_url String share_url String
digg_count BigInt @default(0)
digg_count BigInt @default(0) comment_count BigInt @default(0)
comment_count BigInt @default(0) share_count BigInt @default(0)
share_count BigInt @default(0) collect_count BigInt @default(0)
collect_count BigInt @default(0)
authorId String authorId String
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
tags String[] tags String[]
music_url String? // 背景音乐(已上传到 MinIO 的外链) music_url String?
createdAt DateTime @default(now())
images ImageFile[] updatedAt DateTime @updatedAt
comments Comment[]
// 保存完整的接口原始 JSON 数据(用于备份和后续分析)
raw_json Json? raw_json Json?
comments Comment[]
createdAt DateTime @default(now()) images ImageFile[]
updatedAt DateTime @updatedAt author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
@@index([authorId]) @@index([authorId])
@@index([created_at]) @@index([created_at])
} }
// 图片作品中的单张图片(已上传到 MinIO 的外链)
model ImageFile { model ImageFile {
id String @id @default(cuid()) id String @id @default(cuid())
postId String postId String
post ImagePost @relation(fields: [postId], references: [aweme_id], onDelete: Cascade)
url String url String
order Int // 在作品中的顺序(从 0 开始) order Int
width Int? width Int?
height Int? height Int?
animated String? // 如果是动图,存储 video 格式的 URL
duration Int? // 动图时长(毫秒),仅当 animated 不为 null 时有值
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
animated String?
duration Int?
post ImagePost @relation(fields: [postId], references: [aweme_id], onDelete: Cascade)
@@index([postId, order])
@@unique([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)
} }