mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 22:06:38 +08:00
feat: Emby 兼容层完整实现 + 多模块功能增强
## Emby/Jellyfin 兼容层 (emby_compat.go / emby.go) - 新增 SystemInfoPublic、FindUser、Items、Item、LatestItems、ResumeItems - 新增 SetFavorite、MarkPlayed、RecordProgress 用户播放状态同步 - 新增 itemPayload、mediaSource、mediaStreams 媒体信息组装 - 双前缀路由 /emby/* 和 / 根路径,兼容 Infuse/Yamby/Senplayer/Kodi - 新增 Ping、SystemEndpoint、AuthByName 端点 - emby.go 扩展对应 handler 函数 ## 站点适配器 (site_adapter.go / site.go) - SiteConfig 扩展 UserAgent/Timeout/Extra/FlareSolverrURL 字段 - doRequest() GET 请求支持 FlareSolverr 代理绕过 Cloudflare/WAF - MTeam api_key 认证改为 Authorization: Bearer 格式 - Search() 重构为 sync.WaitGroup 并发执行,提升多站搜索性能 - siteModelToConfig() 改为 SiteService 方法,按 BrowserEmulation 填充 FlareSolverrURL ## 图片代理 (image_proxy.go) - 重构图片代理服务,支持更多来源和缓存策略 ## 下载管理 (downloads.go / download_clients.go / qbittorrent.go) - 下载任务增强:状态管理、进度追踪优化 - qBittorrent 客户端连接稳定性改进 ## 刮削与数据库 (scraper.go / tmdb.go / repository.go) - 刮削器增强 TMDB 集成,补全元数据字段 - repository 扩展查询方法 ## 前端 (web/src/) - HomePage: 首页布局重构,按媒体库分组展示,系列聚合优化 - DiscoverPage: 发现页增强,错误处理改进(API key 缺失/网络错误分离) - PosterWallPage: 海报墙优化,系列聚合展示 - MediaCard: 媒体卡片优化 - PlayerPage: 播放器改进 - 新增 utils/groupSeries.ts: 系列聚合工具函数 - .gitignore: 添加 .tmp_* 临时文件排除规则
This commit is contained in:
@@ -1,42 +1,64 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sparkles, AlertTriangle, ExternalLink } from 'lucide-react'
|
||||
import { Sparkles, AlertTriangle, ExternalLink, Wifi } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { discoverAPI, type DiscoverItem } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
|
||||
// 判断后端返回的错误是不是"未配置 API key"。其它(网络超时/被墙/上游 5xx)
|
||||
// 都归为"网络/上游故障",避免误导用户去再配一次 key。
|
||||
function isMissingKey(err?: string): boolean {
|
||||
if (!err) return false
|
||||
const low = err.toLowerCase()
|
||||
return low.includes('api key') || low.includes('apikey') || low.includes('not configured')
|
||||
}
|
||||
|
||||
function isNetworkError(err?: string): boolean {
|
||||
if (!err) return false
|
||||
const low = err.toLowerCase()
|
||||
return (
|
||||
low.includes('deadline exceeded') ||
|
||||
low.includes('timeout') ||
|
||||
low.includes('no such host') ||
|
||||
low.includes('connection refused') ||
|
||||
low.includes('eof') ||
|
||||
low.includes('tls') ||
|
||||
low.includes('reset')
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscoverPage() {
|
||||
const [trending, setTrending] = useState<DiscoverItem[]>([])
|
||||
const [popular, setPopular] = useState<DiscoverItem[]>([])
|
||||
const [trendingErr, setTrendingErr] = useState<string | undefined>()
|
||||
const [popularErr, setPopularErr] = useState<string | undefined>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
Promise.all([
|
||||
discoverAPI.trending().catch((err) => {
|
||||
console.error('Failed to fetch trending:', err)
|
||||
return [] as DiscoverItem[]
|
||||
}),
|
||||
discoverAPI.popular().catch((err) => {
|
||||
console.error('Failed to fetch popular:', err)
|
||||
return [] as DiscoverItem[]
|
||||
}),
|
||||
discoverAPI.trending().catch((err) => ({
|
||||
items: [] as DiscoverItem[],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})),
|
||||
discoverAPI.popular().catch((err) => ({
|
||||
items: [] as DiscoverItem[],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})),
|
||||
])
|
||||
.then(([t, p]) => {
|
||||
setTrending(t)
|
||||
setPopular(p)
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load discover data')
|
||||
setTrending(t.items)
|
||||
setTrendingErr(t.error)
|
||||
setPopular(p.items)
|
||||
setPopularErr(p.error)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// Check if TMDB API key is likely not configured
|
||||
const isTMDBMissing = !loading && trending.length === 0 && popular.length === 0 && !error
|
||||
const anyErr = trendingErr || popularErr
|
||||
const missingKey = isMissingKey(anyErr)
|
||||
const networkErr = !missingKey && isNetworkError(anyErr)
|
||||
const otherErr = !missingKey && !networkErr && anyErr
|
||||
|
||||
return (
|
||||
<div className="space-y-8 px-4 py-6 max-w-7xl mx-auto">
|
||||
@@ -53,26 +75,17 @@ export function DiscoverPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="rounded-2xl bg-red-500/10 border border-red-500/20 p-4 flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <DiscoverSkeleton />}
|
||||
|
||||
{/* TMDB API Key Missing */}
|
||||
{isTMDBMissing && (
|
||||
{/* TMDb API Key 未配置 */}
|
||||
{!loading && missingKey && (
|
||||
<div className="rounded-2xl bg-amber-500/10 border border-amber-500/20 p-6 text-center space-y-4">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">TMDb API Key 未配置</h3>
|
||||
<p className="text-sm text-slate-400 max-w-md mx-auto">
|
||||
您需要配置 TMDb API Key 才能查看发现内容。请前往管理后台配置。
|
||||
您需要在管理后台填入 TMDb API Key 才能查看发现内容。
|
||||
</p>
|
||||
<Link
|
||||
to="/admin"
|
||||
@@ -84,14 +97,42 @@ export function DiscoverPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 网络无法访问 TMDb */}
|
||||
{!loading && networkErr && (
|
||||
<div className="rounded-2xl bg-orange-500/10 border border-orange-500/20 p-6 text-center space-y-4">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Wifi className="h-8 w-8 text-orange-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">无法连接到 TMDb</h3>
|
||||
<p className="text-sm text-slate-400 max-w-lg mx-auto">
|
||||
服务器到 <code className="font-mono text-orange-300">api.themoviedb.org</code> 的连接超时。
|
||||
通常是因为部署机器没有走代理。可以在系统环境变量里设置
|
||||
<code className="font-mono text-orange-300 mx-1">HTTPS_PROXY</code>,
|
||||
或在「外部 API」配置里填写自建反代地址(tmdb_api_proxy / tmdb_image_proxy)。
|
||||
</p>
|
||||
<details className="text-xs text-slate-500 max-w-lg mx-auto text-left">
|
||||
<summary className="cursor-pointer hover:text-slate-400">查看原始错误</summary>
|
||||
<pre className="mt-2 p-2 rounded bg-black/40 overflow-x-auto whitespace-pre-wrap">{anyErr}</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 其它错误 */}
|
||||
{!loading && otherErr && (
|
||||
<div className="rounded-2xl bg-red-500/10 border border-red-500/20 p-4 flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-red-300">{otherErr}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Rows */}
|
||||
{!loading && !isTMDBMissing && (
|
||||
{!loading && !missingKey && (
|
||||
<div className="space-y-10">
|
||||
{trending.length > 0 && <ContentRow title="今日趋势" items={trending} />}
|
||||
{popular.length > 0 && <ContentRow title="热门电影" items={popular} />}
|
||||
|
||||
{/* Empty State (TMDB configured but no data) */}
|
||||
{trending.length === 0 && popular.length === 0 && (
|
||||
|
||||
{/* TMDB 配置 OK 但本次没拿到任何条目(极少见) */}
|
||||
{!networkErr && trending.length === 0 && popular.length === 0 && !otherErr && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-500">暂无发现内容</p>
|
||||
</div>
|
||||
@@ -106,7 +147,7 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] })
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="font-display text-2xl font-semibold text-white pl-1">{title}</h2>
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((item) => (
|
||||
<DiscoverCard key={item.tmdb_id} item={item} />
|
||||
))}
|
||||
@@ -117,8 +158,7 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] })
|
||||
|
||||
function DiscoverCard({ item }: { item: DiscoverItem }) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-2xl border border-white/5 bg-surface-800/60 hover:border-primary-500/30 transition-all duration-300 hover:shadow-2xl hover:shadow-primary-500/10 hover:-translate-y-1">
|
||||
{/* Poster */}
|
||||
<div className="group relative overflow-hidden rounded-xl border border-white/5 bg-surface-800/60 hover:border-primary-500/30 transition-all duration-300">
|
||||
<div className="aspect-[2/3] w-full bg-surface-900 relative overflow-hidden">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
@@ -129,26 +169,22 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
|
||||
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-600">
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-600 text-xs">
|
||||
无海报
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rating Badge */}
|
||||
{item.rating > 0 && (
|
||||
<div className="absolute top-3 right-3 rounded-lg bg-black/70 backdrop-blur-sm px-2 py-1 text-sm font-semibold text-yellow-400 border border-yellow-400/30">
|
||||
<div className="absolute top-1.5 right-1.5 rounded-md bg-black/70 backdrop-blur-sm px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 border border-yellow-400/30">
|
||||
★ {item.rating.toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4 space-y-1">
|
||||
<p className="font-medium text-white truncate group-hover:text-primary-400 transition-colors">
|
||||
<div className="px-2.5 py-2 space-y-0.5">
|
||||
<p className="text-xs font-medium text-white truncate group-hover:text-primary-400 transition-colors">
|
||||
{item.title}
|
||||
</p>
|
||||
{item.year > 0 && (
|
||||
<p className="text-sm text-slate-500">{item.year}</p>
|
||||
<p className="text-[11px] text-slate-500">{item.year}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,9 +197,9 @@ function DiscoverSkeleton() {
|
||||
{[1, 2].map((section) => (
|
||||
<section key={section} className="space-y-4">
|
||||
<div className="h-8 w-48 rounded-lg bg-surface-800 animate-pulse" />
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="aspect-[2/3] rounded-2xl bg-surface-800 animate-pulse" />
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||
<div key={i} className="aspect-[2/3] rounded-xl bg-surface-800 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user