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 { 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 { // 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 = { '动画': ['动画'], '动漫': ['动漫'], '国漫': ['国漫'], '剧场版': ['剧场版'], '年番': ['年番'], '动作': ['动作'], '奇幻': ['奇幻'], '玄幻': ['玄幻'], '仙侠': ['仙侠'], '古装': ['古装'], '爱情': ['爱情'], '科幻': ['科幻'], '喜剧': ['喜剧'], '悬疑': ['悬疑'], '冒险': ['冒险'], '战争': ['战争'], '纪录': ['纪录片'], '真人': ['真人秀'], }; 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 []; } }