v0.2.7: 修复Redis连接 + 启动管理后台

- 修复Redis认证 (配置密码)
- 启动Python管理后台 (端口9531, 15个功能开关)
- 统一版本号 0.2.7
- 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
2026-05-17 02:22:18 +08:00
commit 83cbfaf03f
164 changed files with 25195 additions and 0 deletions

View 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 [];
}
}