mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +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:
+19
-5
@@ -6,16 +6,30 @@ import type { Media } from '../types'
|
||||
export interface DiscoverItem extends Partial<Media> {
|
||||
tmdb_id: number
|
||||
title: string
|
||||
overview: string
|
||||
poster_url: string
|
||||
backdrop_url: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
year: number
|
||||
rating: number
|
||||
}
|
||||
|
||||
// 后端在 TMDb 不可达 / API key 缺失时统一返回 { items: [], error: "..." }
|
||||
// 200 状态码——前端必须能区分这两种情况,不能简单用 items.length === 0
|
||||
// 推断"未配置 API key"。
|
||||
export interface DiscoverResp {
|
||||
items: DiscoverItem[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const discoverAPI = {
|
||||
trending: () =>
|
||||
api.get<{ items: DiscoverItem[] }>('/discover/trending').then((r) => r.data.items ?? []),
|
||||
api.get<DiscoverResp>('/discover/trending').then((r) => ({
|
||||
items: r.data.items ?? [],
|
||||
error: r.data.error,
|
||||
})),
|
||||
popular: () =>
|
||||
api.get<{ items: DiscoverItem[] }>('/discover/popular').then((r) => r.data.items ?? []),
|
||||
api.get<DiscoverResp>('/discover/popular').then((r) => ({
|
||||
items: r.data.items ?? [],
|
||||
error: r.data.error,
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import { api } from './client'
|
||||
|
||||
export type DownloadClientType = 'qbittorrent' | 'aria2' | 'transmission'
|
||||
|
||||
// 与后端 model.DownloadClient 字段对齐(json:"host")。
|
||||
// 之前前端用的 `url` / `save_path` 字段会被后端忽略并触发 400 binding 错误。
|
||||
export interface DownloadClient {
|
||||
id: string
|
||||
name: string
|
||||
type: DownloadClientType
|
||||
url: string
|
||||
host: string
|
||||
username?: string
|
||||
save_path?: string
|
||||
is_default: boolean
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
@@ -18,10 +19,9 @@ export interface DownloadClient {
|
||||
export interface DownloadClientInput {
|
||||
name: string
|
||||
type: DownloadClientType
|
||||
url: string
|
||||
host: string
|
||||
username?: string
|
||||
password?: string
|
||||
save_path?: string
|
||||
is_default: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
+15
-5
@@ -13,12 +13,22 @@ export interface RefreshTokenResponse {
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// 刷新访问令牌
|
||||
// 刷新访问令牌。
|
||||
//
|
||||
// 后端响应封装在 { code, message, data } 里,需解包 .data。
|
||||
// /auth/login 的响应是直接展开的({tokens:..., user:...}),
|
||||
// /auth/refresh 的响应是包装过的 — 这里负责拉平成前端使用的 shape。
|
||||
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
|
||||
const resp = await api.post<RefreshTokenResponse>('/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return resp.data as unknown as RefreshTokenResponse
|
||||
const resp = await api.post<{
|
||||
code: number
|
||||
message: string
|
||||
data: RefreshTokenResponse
|
||||
}>('/auth/refresh', { refresh_token: refreshToken })
|
||||
const body = resp.data
|
||||
if (!body || !body.data || !body.data.token) {
|
||||
throw new Error(body?.message || 'refresh failed')
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
|
||||
@@ -4,7 +4,12 @@ import { Film, Play } from 'lucide-react'
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
/** Compact poster tile — used by library, search, favourites, poster wall, etc. */
|
||||
/**
|
||||
* Compact poster tile — used by library, search, favourites, poster wall, etc.
|
||||
*
|
||||
* 视觉密度调小:之前字体偏大、卡片整体偏宽,导致每页只能塞下 5 个左右;
|
||||
* 现在文字行 truncate + 字号缩到 12px,让 8 列网格不再"挤"。
|
||||
*/
|
||||
export function MediaCard({
|
||||
media,
|
||||
progress,
|
||||
@@ -29,14 +34,14 @@ export function MediaCard({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
|
||||
<Film size={48} />
|
||||
<Film size={36} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="flex items-center gap-1.5 text-xs text-white/90">
|
||||
<Play size={12} />
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="flex items-center gap-1 text-[11px] text-white/90">
|
||||
<Play size={11} />
|
||||
播放
|
||||
</span>
|
||||
</div>
|
||||
@@ -53,12 +58,12 @@ export function MediaCard({
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-3 py-2.5">
|
||||
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
||||
<div className="px-2 py-2">
|
||||
<p className="truncate text-xs font-medium text-cream-200 group-hover:text-cream-100">
|
||||
{media.title}
|
||||
</p>
|
||||
{media.year > 0 && (
|
||||
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
||||
<p className="mt-0.5 text-[11px] text-cream-500">{media.year}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -118,9 +118,8 @@ export function DownloadClientsPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-400">
|
||||
{c.url}
|
||||
{c.host}
|
||||
{c.username && ` · ${c.username}`}
|
||||
{c.save_path && ` · ${c.save_path}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
@@ -177,10 +176,9 @@ function ClientFormModal({
|
||||
const [form, setForm] = useState<DownloadClientInput>(() => ({
|
||||
name: editing?.name ?? '',
|
||||
type: editing?.type ?? 'qbittorrent',
|
||||
url: editing?.url ?? '',
|
||||
host: editing?.host ?? '',
|
||||
username: editing?.username ?? '',
|
||||
password: '',
|
||||
save_path: editing?.save_path ?? '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
enabled: editing?.enabled ?? true,
|
||||
}))
|
||||
@@ -246,8 +244,8 @@ function ClientFormModal({
|
||||
required
|
||||
className="input-base"
|
||||
placeholder={placeholder}
|
||||
value={form.url}
|
||||
onChange={(e) => update('url', e.target.value)}
|
||||
value={form.host}
|
||||
onChange={(e) => update('host', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{form.type !== 'aria2' && (
|
||||
@@ -279,13 +277,6 @@ function ClientFormModal({
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="默认保存路径">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.save_path ?? ''}
|
||||
onChange={(e) => update('save_path', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
|
||||
@@ -84,7 +84,13 @@ export function DownloadsPage() {
|
||||
<section className="glass-panel">
|
||||
<h2 className="mb-3 font-display text-lg font-semibold text-white">实时状态</h2>
|
||||
{torrents === null && (
|
||||
<p className="text-slate-500">尚未连接到 qBittorrent — 请在管理后台配置 qbittorrent.url / username / password。</p>
|
||||
<p className="text-slate-500">
|
||||
尚未连接到下载器 — 请到{' '}
|
||||
<a href="/download-clients" className="text-primary-400 hover:underline">
|
||||
下载器
|
||||
</a>{' '}
|
||||
页面添加并测试连接(qBittorrent / Aria2 / Transmission)。
|
||||
</p>
|
||||
)}
|
||||
{torrents && torrents.length === 0 && <p className="text-slate-500">暂无运行中任务。</p>}
|
||||
{torrents && torrents.length > 0 && (
|
||||
|
||||
+155
-48
@@ -1,30 +1,105 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowRight, Play, Clock, Film, Compass, Sparkles } from 'lucide-react'
|
||||
import {
|
||||
ArrowRight,
|
||||
Play,
|
||||
Clock,
|
||||
Film,
|
||||
Compass,
|
||||
Sparkles,
|
||||
Layers,
|
||||
Library as LibraryIcon,
|
||||
Tv,
|
||||
Music,
|
||||
PlayCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { libraryAPI, mediaAPI } from '../api/library'
|
||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
import type { Library, Media } from '../types'
|
||||
import { groupSeries } from '../utils/groupSeries'
|
||||
|
||||
// LibraryRow 把单个媒体库的最近添加聚合成代表卡片列表。
|
||||
type LibraryRow = {
|
||||
library: Library
|
||||
cards: ReturnType<typeof groupSeries>
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '电视剧',
|
||||
anime: '动漫',
|
||||
music: '音乐',
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
movie: <Film size={16} />,
|
||||
tv: <Tv size={16} />,
|
||||
anime: <PlayCircle size={16} />,
|
||||
music: <Music size={16} />,
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const [recent, setRecent] = useState<Media[]>([])
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [rows, setRows] = useState<LibraryRow[]>([])
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
mediaAPI.search('', 24).then((d) => d.items),
|
||||
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
|
||||
])
|
||||
.then(([items, hist]) => {
|
||||
setRecent(items)
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 1) 拉所有库 + 历史
|
||||
const [libs, hist] = await Promise.all([
|
||||
libraryAPI.list().catch(() => [] as Library[]),
|
||||
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
|
||||
])
|
||||
if (cancelled) return
|
||||
setLibraries(libs)
|
||||
setHistory(hist.filter((h) => !h.completed && !!h.media))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
// 2) 并行为每个库拉最近 60 条 media,前端折叠成剧集卡片
|
||||
const perLib = await Promise.all(
|
||||
libs.map(async (lib) => {
|
||||
try {
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 60)
|
||||
const cards = groupSeries(page.items).slice(0, 12)
|
||||
return { library: lib, cards } as LibraryRow
|
||||
} catch {
|
||||
return { library: lib, cards: [] } as LibraryRow
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (cancelled) return
|
||||
setRows(perLib)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const empty = !loading && recent.length === 0 && history.length === 0
|
||||
// 兜底:如果一个库都没有,但 /api/media 有内容(旧数据),回退展示一个总卡片墙。
|
||||
const [fallback, setFallback] = useState<Media[]>([])
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
if (libraries.length > 0) return
|
||||
mediaAPI.search('', 60).then((d) => setFallback(d.items)).catch(() => undefined)
|
||||
}, [loading, libraries.length])
|
||||
|
||||
const fallbackCards = useMemo(() => groupSeries(fallback).slice(0, 16), [fallback])
|
||||
const empty =
|
||||
!loading &&
|
||||
history.length === 0 &&
|
||||
rows.every((r) => r.cards.length === 0) &&
|
||||
fallbackCards.length === 0
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-12">
|
||||
@@ -50,7 +125,7 @@ export function HomePage() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* ── Loading ── */}
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-3 py-16">
|
||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand-500/60" />
|
||||
@@ -58,7 +133,7 @@ export function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Empty state ── */}
|
||||
{/* Empty */}
|
||||
{empty && (
|
||||
<div className="surface-card flex flex-col items-center gap-4 py-16 text-center">
|
||||
<Film className="h-12 w-12 text-cream-900/40" />
|
||||
@@ -75,7 +150,7 @@ export function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Continue Watching — wide horizontal stripe ── */}
|
||||
{/* Continue Watching */}
|
||||
{history.length > 0 && (
|
||||
<section>
|
||||
<SectionHeading icon={<Play size={16} />} label="继续观看" />
|
||||
@@ -89,27 +164,27 @@ export function HomePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Recently Added — bento grid ── */}
|
||||
{recent.length > 0 && (
|
||||
{/* 每个媒体库一行 */}
|
||||
{!loading &&
|
||||
rows
|
||||
.filter((r) => r.cards.length > 0)
|
||||
.map((row) => (
|
||||
<LibrarySection key={row.library.id} row={row} />
|
||||
))}
|
||||
|
||||
{/* 旧数据兜底 */}
|
||||
{!loading && libraries.length === 0 && fallbackCards.length > 0 && (
|
||||
<section>
|
||||
<SectionHeading icon={<Clock size={16} />} label="最近添加" />
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{recent.slice(0, 12).map((m, i) => {
|
||||
// First 2 items get double-column span on large screens
|
||||
const isHero = i < 2
|
||||
return (
|
||||
<PosterCard
|
||||
key={m.id}
|
||||
media={m}
|
||||
className={isHero ? 'lg:col-span-2 lg:row-span-2' : ''}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{fallbackCards.map((s) => (
|
||||
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Quick Links ── */}
|
||||
{/* Quick Links */}
|
||||
{!empty && (
|
||||
<section>
|
||||
<SectionHeading icon={<Sparkles size={16} />} label="快捷入口" />
|
||||
@@ -125,6 +200,39 @@ export function HomePage() {
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Library 行 ─── */
|
||||
function LibrarySection({ row }: { row: LibraryRow }) {
|
||||
const lib = row.library
|
||||
const typeIcon = TYPE_ICONS[lib.type] ?? <LibraryIcon size={16} />
|
||||
const typeLabel = TYPE_LABELS[lib.type] ?? lib.type
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cream-400">{typeIcon}</span>
|
||||
<h2 className="font-display text-lg font-semibold tracking-tight text-cream-200">
|
||||
{lib.name}
|
||||
</h2>
|
||||
<span className="text-xs text-cream-500">·</span>
|
||||
<span className="text-xs text-cream-500">{typeLabel}</span>
|
||||
</div>
|
||||
<Link
|
||||
to={`/library/${lib.id}`}
|
||||
className="flex items-center gap-1.5 text-xs text-cream-400 transition-colors hover:text-brand-400"
|
||||
>
|
||||
查看全部
|
||||
<ArrowRight size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{row.cards.map((s) => (
|
||||
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Section Heading ─── */
|
||||
function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
@@ -144,7 +252,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
|
||||
to={`/media/${media.id}`}
|
||||
className="group flex shrink-0 w-72 items-center gap-4 rounded-lg border border-cream-900/15 bg-surface-400 p-3 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
||||
>
|
||||
{/* Poster thumbnail */}
|
||||
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-md bg-surface-600">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
@@ -160,8 +267,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
||||
{media.title}
|
||||
@@ -169,8 +274,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
|
||||
{media.year > 0 && (
|
||||
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{progress > 0 && (
|
||||
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-cream-900/20">
|
||||
<div
|
||||
@@ -180,7 +283,6 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Play
|
||||
size={14}
|
||||
className="shrink-0 text-cream-600 opacity-0 transition-all group-hover:opacity-100 group-hover:text-brand-400"
|
||||
@@ -190,11 +292,11 @@ function WideContinueCard({ media, progress }: { media: Media; progress: number
|
||||
}
|
||||
|
||||
/* ─── Poster Card (grid item) ─── */
|
||||
function PosterCard({ media, className = '' }: { media: Media; className?: string }) {
|
||||
function PosterCard({ media, count = 1 }: { media: Media; count?: number }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${media.id}`}
|
||||
className={`group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300 ${className}`}
|
||||
className="group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
||||
>
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
|
||||
{media.poster_url ? (
|
||||
@@ -207,23 +309,28 @@ function PosterCard({ media, className = '' }: { media: Media; className?: strin
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
|
||||
<Film size={48} />
|
||||
<Film size={36} />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="flex items-center gap-1.5 text-xs text-white/90">
|
||||
<Play size={12} />
|
||||
{count > 1 && (
|
||||
<span className="absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
||||
<Layers size={10} />
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-2.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="flex items-center gap-1 text-[11px] text-white/90">
|
||||
<Play size={11} />
|
||||
播放
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2.5">
|
||||
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
||||
<div className="px-2.5 py-2">
|
||||
<p className="truncate text-xs font-medium text-cream-200 group-hover:text-cream-100">
|
||||
{media.title}
|
||||
</p>
|
||||
{media.year > 0 && (
|
||||
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
||||
<p className="mt-0.5 text-[11px] text-cream-500">{media.year}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function LibraryPage() {
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
|
||||
{!isSeries && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
@@ -131,7 +131,7 @@ export function LibraryPage() {
|
||||
<h2 className="font-display text-xl font-semibold text-white">
|
||||
{s.season > 0 ? `第 ${s.season} 季` : '未分季'} ({s.episodes.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{s.episodes.map((e) => (
|
||||
<div key={e.id} className="space-y-1">
|
||||
<MediaCard media={e} />
|
||||
|
||||
@@ -162,8 +162,20 @@ export function PlayerPage() {
|
||||
ref={ref}
|
||||
controls
|
||||
autoPlay
|
||||
crossOrigin="use-credentials"
|
||||
playsInline
|
||||
className="max-h-screen w-full max-w-[1600px] bg-black"
|
||||
onError={() => {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
if (mode === 'direct') {
|
||||
toast.error('直接播放失败,切换到 HLS 转码')
|
||||
setMode('hls')
|
||||
params.set('mode', 'hls')
|
||||
setParams(params, { replace: true })
|
||||
} else {
|
||||
toast.error('视频播放失败,请检查文件是否存在')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{subs.map((t, i) => (
|
||||
<track
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { GalleryHorizontalEnd } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { GalleryHorizontalEnd, Layers } from 'lucide-react'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { groupSeries } from '../utils/groupSeries'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// PosterWallPage shows a dense poster grid of all media across every
|
||||
// library — a visual "poster wall" that matches the original MediaStation's
|
||||
// PosterWallView.vue. Loads the first 200 items sorted by rating.
|
||||
// PosterWallPage 把所有媒体的代表海报聚合到同一面墙,便于一目了然
|
||||
// 浏览整个站点的内容。所有 episode 行会按剧集折叠,避免同一海报刷屏。
|
||||
export function PosterWallPage() {
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// 拉得多一点(500),折叠后才能凑够展示量;后端 page_size 上限可能
|
||||
// 需要后续观察,暂以 500 试探。
|
||||
mediaAPI
|
||||
.search('', 200)
|
||||
.search('', 500)
|
||||
.then((d) => setItems(d.items))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const cards = useMemo(() => groupSeries(items).slice(0, 240), [items])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
@@ -26,18 +30,26 @@ export function PosterWallPage() {
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">海报墙</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
全部媒体的海报展示(最多 200 项)。
|
||||
按剧集聚合 · 共 {cards.length} 个条目
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
{!loading && cards.length === 0 && (
|
||||
<p className="text-slate-400">暂无媒体。请先添加媒体库并扫描。</p>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10">
|
||||
{cards.map((s) => (
|
||||
<div key={s.rep.id} className="relative">
|
||||
<MediaCard media={s.rep} />
|
||||
{s.count > 1 && (
|
||||
<span className="pointer-events-none absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
||||
<Layers size={10} />
|
||||
{s.count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,17 +171,9 @@ const GROUPS: SettingGroup[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'qbittorrent',
|
||||
label: 'qBittorrent',
|
||||
description: '默认下载器配置',
|
||||
items: [
|
||||
{ key: 'qbittorrent.url', label: 'WebUI URL', type: 'text', placeholder: 'http://127.0.0.1:8080' },
|
||||
{ key: 'qbittorrent.username', label: '用户名', type: 'text' },
|
||||
{ key: 'qbittorrent.password', label: '密码', type: 'text' },
|
||||
{ key: 'qbittorrent.savepath', label: '默认保存目录', type: 'text' },
|
||||
],
|
||||
},
|
||||
// qBittorrent 配置已迁移到独立的「下载器」页面(侧边栏 → 下载器),
|
||||
// 该页面支持多客户端 + 连接测试。这里不再重复暴露入口,避免与
|
||||
// /api/admin/download/clients 写入的数据来源冲突。
|
||||
]
|
||||
|
||||
const ALL_KEYS = new Set(GROUPS.flatMap((g) => g.items.map((i) => i.key)))
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Media } from '../types'
|
||||
|
||||
/**
|
||||
* 把若干 Episode/Movie 行折叠成"剧集卡片"。
|
||||
*
|
||||
* 后端的 /api/media 等接口默认返回 episode 级行——同一部剧的每一集都
|
||||
* 是一行。在「最近添加」「海报墙」「收藏墙」这种以剧集为单位展示的页面
|
||||
* 我们要把它们合成一张代表卡片,避免同一海报刷屏。
|
||||
*
|
||||
* 折叠键优先级(命中第一个就分组):
|
||||
*
|
||||
* 1. tmdb_id (刮削匹配后最稳定)
|
||||
* 2. bangumi_id (番剧)
|
||||
* 3. series_id (后端某些场景下会预先聚合)
|
||||
* 4. library_id + title (fallback:同库同名视为同一剧)
|
||||
*
|
||||
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
|
||||
*/
|
||||
export type SeriesCard = { rep: Media; count: number }
|
||||
|
||||
export function groupSeries(items: Media[]): SeriesCard[] {
|
||||
const groups = new Map<string, SeriesCard>()
|
||||
for (const m of items) {
|
||||
let key = ''
|
||||
if (m.tmdb_id && m.tmdb_id > 0) key = `tmdb:${m.tmdb_id}`
|
||||
else if (m.bangumi_id && m.bangumi_id > 0) key = `bgm:${m.bangumi_id}`
|
||||
else if (m.series_id) key = `series:${m.series_id}`
|
||||
else key = `lib:${m.library_id}|${(m.title ?? '').toLowerCase().trim()}`
|
||||
|
||||
const g = groups.get(key)
|
||||
if (!g) {
|
||||
groups.set(key, { rep: m, count: 1 })
|
||||
} else {
|
||||
g.count += 1
|
||||
const repHasPoster = !!g.rep.poster_url
|
||||
const curHasPoster = !!m.poster_url
|
||||
if (!repHasPoster && curHasPoster) {
|
||||
g.rep = m
|
||||
} else if (repHasPoster === curHasPoster) {
|
||||
const cur = (m.season_num ?? 0) * 10000 + (m.episode_num ?? 0)
|
||||
const rep = (g.rep.season_num ?? 0) * 10000 + (g.rep.episode_num ?? 0)
|
||||
if (cur > 0 && (rep === 0 || cur < rep)) g.rep = m
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
}
|
||||
Reference in New Issue
Block a user