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:
+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>
|
||||
|
||||
Reference in New Issue
Block a user