v0.2.7: 修复Redis连接 + 启动管理后台
- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
325
source_clean/src/content/content.service.ts
Executable file
325
source_clean/src/content/content.service.ts
Executable file
@@ -0,0 +1,325 @@
|
||||
// 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 = `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`;
|
||||
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 = `https://api.themoviedb.org/3/search/tv?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`;
|
||||
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 = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}?language=zh-CN&append_to_response=credits`;
|
||||
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 ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : '';
|
||||
|
||||
// TMDB detail page URL
|
||||
const tmdbUrl = `https://www.themoviedb.org/${mediaType}/${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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user