326 lines
11 KiB
TypeScript
Executable File
326 lines
11 KiB
TypeScript
Executable File
import { buildImageUrl, buildWebUrl, buildSearchUrl, buildDetailUrl } from './tmdb-api';
|
||
// Native fetch available in Node 20+
|
||
import { getDb } from '../database/database';
|
||
import { localTimestamp } from '../utils/time';
|
||
|
||
export interface ContentInfo {
|
||
keyword: string;
|
||
title: string;
|
||
description: string;
|
||
tags: string[];
|
||
cover: string;
|
||
source: string;
|
||
/** TMDB 详情页链接 */
|
||
tmdb_url?: string;
|
||
/** 评分 e.g. "7.3" */
|
||
rating?: string;
|
||
/** 评分人数 e.g. "12345" */
|
||
rating_count?: string;
|
||
/** 发布年份 e.g. "2025" */
|
||
year?: string;
|
||
/** 类型标签 e.g. ["动作", "科幻"] */
|
||
genres?: string[];
|
||
/** 导演 e.g. "克里斯托弗·诺兰" */
|
||
directors?: string;
|
||
/** 演员(前5个) e.g. "基里安·墨菲 / 艾米莉·布朗特" */
|
||
actors?: string;
|
||
/** 制片国家/地区 e.g. "美国 / 英国" */
|
||
region?: string;
|
||
/** 片长 e.g. "180分钟" */
|
||
duration?: string;
|
||
}
|
||
|
||
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||
|
||
export async function getContentInfo(keyword: string): Promise<ContentInfo | null> {
|
||
if (!keyword || keyword.length < 1) return null;
|
||
|
||
const db = getDb();
|
||
const tmdbToken = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || '';
|
||
if (!tmdbToken) return null;
|
||
|
||
const cached = db.prepare('SELECT * FROM content_cache WHERE keyword = ?').get(keyword) as any;
|
||
if (cached) {
|
||
const age = Date.now() - new Date(cached.updated_at + 'Z').getTime();
|
||
if (age < CACHE_TTL_MS) {
|
||
return rowToContentInfo(cached);
|
||
}
|
||
}
|
||
|
||
try {
|
||
const info = await fetchFromTMDB(keyword, tmdbToken);
|
||
if (info) {
|
||
db.prepare(`
|
||
INSERT OR REPLACE INTO content_cache
|
||
(keyword, title, description, tags, cover, douban_url, source, updated_at,
|
||
rating, rating_count, year, genres, directors, actors, region, duration)
|
||
VALUES (?, ?, ?, ?, ?, ?, 'tmdb', ?,
|
||
?, ?, ?, ?, ?, ?, ?, ?)
|
||
`).run(
|
||
keyword, info.title, info.description, JSON.stringify(info.tags), info.cover, info.tmdb_url || '', localTimestamp(),
|
||
info.rating || '', info.rating_count || '', info.year || '',
|
||
JSON.stringify(info.genres || []), info.directors || '', info.actors || '',
|
||
info.region || '', info.duration || ''
|
||
);
|
||
return info;
|
||
}
|
||
} catch (err) {
|
||
console.error(`[Content] Failed to fetch for "${keyword}":`, err);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function rowToContentInfo(row: any): ContentInfo {
|
||
return {
|
||
keyword: row.keyword,
|
||
title: row.title || '',
|
||
description: row.description || '',
|
||
tags: safeParseTags(row.tags),
|
||
cover: row.cover || '',
|
||
source: row.source || (row.title ? 'tmdb' : 'cache'),
|
||
tmdb_url: row.douban_url || '',
|
||
rating: row.rating || '',
|
||
rating_count: row.rating_count || '',
|
||
year: row.year || '',
|
||
genres: safeParseTags(row.genres),
|
||
directors: row.directors || '',
|
||
actors: row.actors || '',
|
||
region: row.region || '',
|
||
duration: row.duration || '',
|
||
};
|
||
}
|
||
|
||
async function fetchFromTMDB(keyword: string, tmdbToken: string): Promise<ContentInfo | null> {
|
||
// Step 1: TMDB search — search both movie and TV in parallel
|
||
let movieResults: any[] = [];
|
||
let tvResults: any[] = [];
|
||
|
||
try {
|
||
const searchUrl = buildSearchUrl('movie', keyword);
|
||
const searchResp = await fetch(searchUrl, {
|
||
headers: { 'Authorization': `Bearer ${tmdbToken}` },
|
||
signal: AbortSignal.timeout(8000),
|
||
});
|
||
if (searchResp.ok) {
|
||
const searchData = await searchResp.json() as any;
|
||
if (Array.isArray(searchData.results)) {
|
||
movieResults = searchData.results;
|
||
}
|
||
}
|
||
} catch {
|
||
console.warn(`[Content] TMDB movie search failed for "${keyword}"`);
|
||
}
|
||
|
||
try {
|
||
const searchUrl = buildSearchUrl('tv', keyword);
|
||
const searchResp = await fetch(searchUrl, {
|
||
headers: { 'Authorization': `Bearer ${tmdbToken}` },
|
||
signal: AbortSignal.timeout(8000),
|
||
});
|
||
if (searchResp.ok) {
|
||
const searchData = await searchResp.json() as any;
|
||
if (Array.isArray(searchData.results)) {
|
||
tvResults = searchData.results;
|
||
}
|
||
}
|
||
} catch {
|
||
console.warn(`[Content] TMDB TV search failed for "${keyword}"`);
|
||
}
|
||
|
||
// Step 2: Score and rank all results
|
||
const isChineseKeyword = /[\u4e00-\u9fff]/.test(keyword);
|
||
const kwLower = keyword.toLowerCase();
|
||
|
||
// Score function: higher = better match
|
||
function scoreResult(item: any, type: 'tv' | 'movie'): number {
|
||
const name = (type === 'tv' ? (item.name || item.original_name || '') : (item.title || item.original_title || '')).toLowerCase();
|
||
// Exact match gets highest priority
|
||
if (name === kwLower) return 100;
|
||
// Name starts with keyword
|
||
if (name.startsWith(kwLower)) return 80;
|
||
// Name contains keyword as a standalone segment
|
||
if (name.includes(kwLower)) return 60;
|
||
// Keyword contains significant portion of name
|
||
const cleanName = name.replace(/[^a-z0-9\u4e00-\u9fff]/g, '');
|
||
if (kwLower.includes(cleanName) && cleanName.length >= 2) return 40;
|
||
// Partial match
|
||
if (name.includes(kwLower) || kwLower.includes(cleanName)) return 20;
|
||
return 0;
|
||
}
|
||
|
||
// Score all TV results
|
||
const scoredTV = tvResults.map((r: any) => ({ item: r, score: scoreResult(r, 'tv') })).filter(r => r.score > 0);
|
||
// Score all movie results
|
||
const scoredMovie = movieResults.map((r: any) => ({ item: r, score: scoreResult(r, 'movie') })).filter(r => r.score > 0);
|
||
|
||
// Sort by score descending
|
||
scoredTV.sort((a, b) => b.score - a.score);
|
||
scoredMovie.sort((a, b) => b.score - a.score);
|
||
|
||
const tvBest = scoredTV[0]?.item || null;
|
||
const movieBest = scoredMovie[0]?.item || null;
|
||
const tvBestScore = scoredTV[0]?.score || 0;
|
||
const movieBestScore = scoredMovie[0]?.score || 0;
|
||
|
||
let best: any = null;
|
||
let mediaType: 'movie' | 'tv' = 'movie';
|
||
let movie: any = null;
|
||
|
||
if (tvBest && movieBest) {
|
||
// Both have matches — score-based comparison
|
||
// For Chinese keywords: TV gets +15 score bonus to prefer series over movies
|
||
const tvScore = tvBestScore + (isChineseKeyword ? 15 : 0);
|
||
const movieScore = movieBestScore;
|
||
if (tvScore > movieScore) {
|
||
best = tvBest;
|
||
mediaType = 'tv';
|
||
} else if (movieScore > tvScore) {
|
||
best = movieBest;
|
||
mediaType = 'movie';
|
||
} else {
|
||
// Tie — prefer TV for Chinese keywords, otherwise pick higher vote count
|
||
if (isChineseKeyword) {
|
||
best = tvBest;
|
||
mediaType = 'tv';
|
||
} else {
|
||
const tvVotes = tvBest.vote_count || 0;
|
||
const movieVotes = movieBest.vote_count || 0;
|
||
best = tvVotes >= movieVotes ? tvBest : movieBest;
|
||
mediaType = tvVotes >= movieVotes ? 'tv' : 'movie';
|
||
}
|
||
}
|
||
} else if (tvBest) {
|
||
best = tvBest;
|
||
mediaType = 'tv';
|
||
} else if (movieBest) {
|
||
best = movieBest;
|
||
mediaType = 'movie';
|
||
} else if (scoredTV.length > 0 && !scoredMovie.length) {
|
||
best = scoredTV[0].item;
|
||
mediaType = 'tv';
|
||
} else if (scoredMovie.length > 0) {
|
||
best = scoredMovie[0].item;
|
||
mediaType = 'movie';
|
||
} else if (tvResults.length > 0 && !movieResults.length) {
|
||
best = tvResults[0];
|
||
mediaType = 'tv';
|
||
} else if (movieResults.length > 0) {
|
||
best = movieResults[0];
|
||
mediaType = 'movie';
|
||
} else {
|
||
return null;
|
||
}
|
||
|
||
let tmdbId = best.id;
|
||
try {
|
||
const detailUrl = buildDetailUrl(mediaType as 'movie' | 'tv', tmdbId);
|
||
const detailResp = await fetch(detailUrl, {
|
||
headers: { 'Authorization': `Bearer ${tmdbToken}` },
|
||
signal: AbortSignal.timeout(8000),
|
||
});
|
||
if (detailResp.ok) {
|
||
movie = await detailResp.json() as any;
|
||
}
|
||
} catch {
|
||
console.warn(`[Content] TMDB detail failed for ${mediaType} id ${tmdbId}`);
|
||
return null;
|
||
}
|
||
|
||
if (!movie) return null;
|
||
|
||
// Extract TMDB data (use title for movie, name for TV)
|
||
const title = movie.title || movie.name || keyword;
|
||
const rating = movie.vote_average > 0 ? String(Math.round(movie.vote_average * 10) / 10) : '';
|
||
const ratingCount = movie.vote_count ? String(movie.vote_count) : '';
|
||
// Use release_date for movie, first_air_date for TV
|
||
const year = movie.release_date ? movie.release_date.substring(0, 4) : (movie.first_air_date ? movie.first_air_date.substring(0, 4) : '');
|
||
const genres = Array.isArray(movie.genres) ? movie.genres.map((g: any) => g.name).filter(Boolean) : [];
|
||
// Directors: tv shows have limited crew data, fall back to "creator" for TV
|
||
const directors = Array.isArray(movie.credits?.crew)
|
||
? movie.credits.crew.filter((c: any) => c.job === 'Director').map((c: any) => c.name).filter(Boolean).join(' / ')
|
||
: '';
|
||
const actors = Array.isArray(movie.credits?.cast)
|
||
? movie.credits.cast.slice(0, 5).map((c: any) => c.name).filter(Boolean).join(' / ')
|
||
: '';
|
||
const region = Array.isArray(movie.production_countries)
|
||
? movie.production_countries.map((c: any) => c.name).filter(Boolean).join(' / ')
|
||
: (Array.isArray(movie.origin_country) ? movie.origin_country.join(' / ') : '');
|
||
const duration = mediaType === 'movie'
|
||
? (movie.runtime > 0 ? `${movie.runtime}分钟` : '')
|
||
: (movie.episode_run_time && movie.episode_run_time.length > 0 ? `每集${movie.episode_run_time[0]}分钟` : '');
|
||
const description = movie.overview ? movie.overview.substring(0, 200) : '';
|
||
const cover = movie.poster_path ? buildImageUrl(movie.poster_path) : '';
|
||
|
||
// TMDB detail page URL
|
||
const tmdbUrl = buildWebUrl(mediaType as 'movie' | 'tv', tmdbId);
|
||
|
||
// Generate tags from keyword + title
|
||
const tags = genTags({ keyword, title });
|
||
|
||
// Build description fallback
|
||
let desc = description;
|
||
if (!desc) {
|
||
const parts: string[] = [];
|
||
if (year) parts.push(`${year}年`);
|
||
if (genres.length > 0) parts.push(genres.slice(0, 3).join(' / '));
|
||
if (duration) parts.push(duration);
|
||
desc = parts.length > 0 ? parts.join(' · ') : '';
|
||
}
|
||
|
||
return {
|
||
keyword,
|
||
title,
|
||
description: desc,
|
||
tags,
|
||
cover,
|
||
source: 'tmdb',
|
||
tmdb_url: tmdbUrl,
|
||
rating,
|
||
rating_count: ratingCount,
|
||
year,
|
||
genres,
|
||
directors,
|
||
actors,
|
||
region,
|
||
duration,
|
||
};
|
||
}
|
||
|
||
function genTags(opts: { keyword: string; title: string }): string[] {
|
||
const { keyword, title } = opts;
|
||
const tags: string[] = [];
|
||
if (keyword.length <= 8) tags.push(keyword);
|
||
|
||
const txt = (title + ' ' + keyword).toLowerCase();
|
||
const isDonghua = /动画|动漫/i.test(txt);
|
||
if (isDonghua) {
|
||
tags.push('动画'); tags.push('国漫');
|
||
} else {
|
||
tags.push('电影');
|
||
}
|
||
|
||
const genreMap: Record<string, string[]> = {
|
||
'动画': ['动画'], '动漫': ['动漫'], '国漫': ['国漫'],
|
||
'剧场版': ['剧场版'], '年番': ['年番'],
|
||
'动作': ['动作'], '奇幻': ['奇幻'], '玄幻': ['玄幻'],
|
||
'仙侠': ['仙侠'], '古装': ['古装'], '爱情': ['爱情'],
|
||
'科幻': ['科幻'], '喜剧': ['喜剧'], '悬疑': ['悬疑'],
|
||
'冒险': ['冒险'], '战争': ['战争'], '纪录': ['纪录片'], '真人': ['真人秀'],
|
||
};
|
||
for (const [key, vals] of Object.entries(genreMap)) {
|
||
if (txt.includes(key)) {
|
||
for (const v of vals) { if (!tags.includes(v)) tags.push(v); }
|
||
}
|
||
}
|
||
return tags;
|
||
}
|
||
|
||
function safeParseTags(tagsStr: string | null | undefined): string[] {
|
||
if (!tagsStr) return [];
|
||
try {
|
||
const parsed = JSON.parse(tagsStr);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
} |