Fix local adult metadata and watch history removal

This commit is contained in:
ShukeBta
2026-05-28 14:52:20 +08:00
parent 352edd86ed
commit 32ccb33fed
95 changed files with 6589 additions and 1307 deletions
+4
View File
@@ -11,6 +11,9 @@ const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m
const LibraryPage = lazy(() =>
import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })),
)
const LibrariesPage = lazy(() =>
import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })),
)
const SearchPage = lazy(() =>
import('./pages/SearchPage').then((m) => ({ default: m.SearchPage })),
)
@@ -116,6 +119,7 @@ export default function App() {
}
>
<Route index element={<HomePage />} />
<Route path="libraries" element={<LibrariesPage />} />
<Route path="library/:id" element={<LibraryPage />} />
<Route path="discover" element={<DiscoverPage />} />
<Route path="search" element={<SearchPage />} />
+19 -1
View File
@@ -10,6 +10,21 @@ export interface SearchIntent {
language?: string
}
export interface ExternalMediaResult {
source: string
media_type?: string
title: string
overview?: string
poster_url?: string
backdrop_url?: string
year?: number
rating?: number
tmdb_id?: number
bangumi_id?: number
douban_id?: string
subscribe_keyword: string
}
export const aiAPI = {
status: () =>
api
@@ -18,7 +33,10 @@ export const aiAPI = {
smartSearch: (query: string) =>
api
.post<{ intent: SearchIntent; items: Media[] }>('/ai/search', { query })
.post<{ intent: SearchIntent; items: Media[]; external_items: ExternalMediaResult[] }>(
'/ai/search',
{ query },
)
.then((r) => r.data),
recommend: () => api.get<{ titles: string[] }>('/ai/recommend').then((r) => r.data.titles),
+22 -3
View File
@@ -4,13 +4,24 @@ import type { Media } from '../types'
// TMDb-derived "Match" rows used by trending/popular rails. We re-use the
// Media interface — only TMDb id / poster / overview are populated.
export interface DiscoverItem extends Partial<Media> {
tmdb_id: number
source?: string
media_type?: string
tmdb_id?: number
douban_id?: string
bangumi_id?: number
title: string
poster_url?: string
backdrop_url?: string
overview?: string
year: number
rating: number
year?: number
rating?: number
subscribe_keyword?: string
}
export interface DiscoverSection {
key: string
label: string
provider?: string
}
// 后端在 TMDb 不可达 / API key 缺失时统一返回 { items: [], error: "..." }
@@ -32,4 +43,12 @@ export const discoverAPI = {
items: r.data.items ?? [],
error: r.data.error,
})),
sections: () =>
api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections),
feed: (sectionKeys: string[]) =>
api
.get<Record<string, DiscoverItem[] | null>>('/discover/feed', {
params: { sections: sectionKeys.join(',') },
})
.then((r) => r.data),
}
+8 -1
View File
@@ -11,16 +11,23 @@ export interface DuplicateReport {
total_scanned: number
groups_found: number
items_marked: number
missing_removed?: number
groups: DuplicateGroup[]
}
export const duplicatesAPI = {
list: (libraryID = '') =>
api
.get<DuplicateReport>('/duplicates', {
params: libraryID ? { library_id: libraryID } : undefined,
})
.then((r) => ({ ...r.data, groups: r.data.groups ?? [] })),
scan: (libraryID = '') =>
api
.post<DuplicateReport>('/duplicates/scan', null, {
params: libraryID ? { library_id: libraryID } : undefined,
})
.then((r) => r.data),
.then((r) => ({ ...r.data, groups: r.data.groups ?? [] })),
unmark: (libraryID = '') =>
api
.post<{ unmarked: number }>('/duplicates/unmark', null, {
+9 -1
View File
@@ -5,7 +5,15 @@ export const subscriptionsAPI = {
list: () =>
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
create: (input: { name: string; feed_url: string; filter?: string; enabled?: boolean }) =>
create: (input: {
name: string
feed_url: string
filter?: string
media_type?: string
media_category?: string
save_path?: string
enabled?: boolean
}) =>
api.post<Subscription>('/subscriptions', input).then((r) => r.data),
remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data),
+2 -2
View File
@@ -12,8 +12,8 @@ export interface SubtitleTrack {
export const subtitlesAPI = {
list: (mediaId: string) =>
api
.get<{ tracks: SubtitleTrack[] }>(`/media/${mediaId}/subtitles`)
.then((r) => r.data.tracks),
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`)
.then((r) => r.data.tracks ?? []),
url: (mediaId: string, path: string) => {
const token = useAuthStore.getState().token ?? ''
+32 -63
View File
@@ -2,35 +2,27 @@
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import {
Activity, Bell, Cast, Clock, CloudDownload, Compass, Copy, Film,
FolderTree, Globe, HardDrive, Heart, Home, GalleryHorizontalEnd,
Link2, ListChecks, ListMusic, LogOut, MessageSquare, Rss, Search,
Server, Settings, Sliders, Sparkles, Cloud, Trash2, UserCog, Wrench,
Activity, Bell, Clock, CloudDownload, Compass, Film,
Cast, Globe, HardDrive, Heart, Home, Image,
ListMusic, LogOut, Rss, Search,
Settings, Sliders, Sparkles, UserCog, Wrench,
Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X
} from 'lucide-react'
import clsx from 'clsx'
import { AppFooter } from './AppFooter'
import { libraryAPI } from '../api/library'
import { useAuthStore } from '../stores/auth'
import type { Library } from '../types'
export function Layout() {
const navigate = useNavigate()
const location = useLocation()
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const [libraries, setLibraries] = useState<Library[]>([])
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
const [isProfileOpen, setIsProfileOpen] = useState(false)
const [searchFocused, setSearchFocused] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
// Automatically fetch libraries
useEffect(() => {
libraryAPI.list().then(setLibraries).catch(() => undefined)
}, [])
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
useEffect(() => {
const handleResize = () => {
@@ -99,76 +91,48 @@ export function Layout() {
<SectionHeader label="影音中心" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/" icon={<Home size={18} />} label="系统首页" end collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/libraries" icon={<LibraryIcon size={18} />} label="媒体库" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/poster-wall" icon={<Image size={18} />} label="海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/discover" icon={<Compass size={18} />} label="精彩发现" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/search" icon={<Search size={18} />} label="智能搜索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="影音海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 影视助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
{/* Navigation Group: Libraries */}
{/* Navigation Group: Personal */}
<div>
<SectionHeader label="媒体片库" visible={isSidebarOpen || isMobileDrawerOpen} />
<SectionHeader label="个人空间" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
{libraries.length === 0 && (isSidebarOpen || isMobileDrawerOpen) && (
<div className="px-4 py-2 text-xs text-gray-500 italic">暂无媒体片库</div>
)}
{libraries.map((lib) => (
<SidebarLink
key={lib.id}
to={`/library/${lib.id}`}
icon={<LibraryIcon size={18} />}
label={lib.name}
collapsed={!isSidebarOpen && !isMobileDrawerOpen}
/>
))}
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
{/* Navigation Group: Automation */}
<div>
<SectionHeader label="自动化运维" visible={isSidebarOpen || isMobileDrawerOpen} />
<SectionHeader label="下载订阅" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载中心" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 串流" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/download-clients" icon={<Sliders size={18} />} label="下载器管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="订阅管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点检索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
{/* Navigation Group: Account Profile */}
<div>
<SectionHeader label="个人资料" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
{/* Navigation Group: Admin Dashboard */}
{user?.role === 'admin' && (
<div>
<SectionHeader label="系统后台" visible={isSidebarOpen || isMobileDrawerOpen} />
<SectionHeader label="统一管理" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/admin" icon={<Settings size={18} />} label="后台主页" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行监控" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知配置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载客户端" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时机制" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储分析" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部挂载" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件管家" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="排重清理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 关联" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 智能助教" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统参数" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/admin" icon={<Settings size={18} />} label="媒体与用户" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点与下载器" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="整理与维护" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储与文件" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
)}
@@ -289,10 +253,15 @@ export function Layout() {
</Link>
{/* Notification alert bubble */}
<button className="relative rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all">
<Link
to="/notify-channels"
title="通知配置"
aria-label="打开通知配置"
className="relative rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all"
>
<Bell size={18} />
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-white animate-pulse" />
</button>
</Link>
{/* Horizontal divider lines */}
<span className="h-6 w-px bg-gray-200" />
@@ -454,4 +423,4 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
)}
</NavLink>
)
}
}
@@ -0,0 +1,57 @@
import { Link } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
type ShortcutItem = {
to: string
title: string
description: string
badge?: string
}
type ManagementShortcutsProps = {
title: string
description?: string
items: ShortcutItem[]
}
export function ManagementShortcuts({ title, description, items }: ManagementShortcutsProps) {
return (
<section className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="font-display text-lg font-bold text-ink-600">{title}</h2>
{description && <p className="mt-1 text-sm text-ink-50">{description}</p>}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{items.map((item) => (
<Link
key={item.to}
to={item.to}
className="group rounded-2xl border border-gray-200 bg-gray-50/70 p-4 transition hover:-translate-y-0.5 hover:border-primary-400/50 hover:bg-white hover:shadow-md"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-bold text-ink-600">{item.title}</h3>
{item.badge && (
<span className="shrink-0 rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] font-bold text-brand-500">
{item.badge}
</span>
)}
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-ink-50">
{item.description}
</p>
</div>
<ArrowRight
size={16}
className="mt-0.5 shrink-0 text-sand-500 transition group-hover:translate-x-0.5 group-hover:text-brand-500"
/>
</div>
</Link>
))}
</div>
</section>
)
}
+77 -3
View File
@@ -1,9 +1,11 @@
import { FormEvent, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loader2, Search, Sparkles, Wand2 } from 'lucide-react'
import { Loader2, Rss, Search, Sparkles, Wand2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { aiAPI, type SearchIntent } from '../api/ai'
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
import { imageURL } from '../api/client'
import { subscriptionsAPI } from '../api/subscriptions'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
@@ -26,6 +28,8 @@ export function AIAssistantPage() {
const [searching, setSearching] = useState(false)
const [intent, setIntent] = useState<SearchIntent | null>(null)
const [items, setItems] = useState<Media[]>([])
const [externalItems, setExternalItems] = useState<ExternalMediaResult[]>([])
const [subscribing, setSubscribing] = useState('')
const [recs, setRecs] = useState<string[] | null>(null)
const [recommending, setRecommending] = useState(false)
@@ -43,11 +47,13 @@ export function AIAssistantPage() {
setSearching(true)
setIntent(null)
setItems([])
setExternalItems([])
try {
const r = await aiAPI.smartSearch(query.trim())
setIntent(r.intent)
setItems(r.items)
if (r.items.length === 0) toast('未找到匹配项')
setExternalItems(r.external_items ?? [])
if (r.items.length === 0 && (r.external_items ?? []).length === 0) toast('未找到匹配项')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
@@ -187,6 +193,74 @@ export function AIAssistantPage() {
))}
</div>
)}
{externalItems.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{externalItems.map((item) => {
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
return (
<article key={key} className="rounded-2xl border border-gray-200 bg-gray-50 p-3">
<div className="flex gap-3">
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
<span>{item.source}</span>
{item.media_type && <span>{item.media_type}</span>}
{item.year ? <span>{item.year}</span> : null}
</div>
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
{item.overview || `订阅关键词:${keyword}`}
</p>
<button
disabled={subscribing === key}
onClick={async () => {
setSubscribing(key)
try {
const feed = `site-search://search?keyword=${encodeURIComponent(keyword)}&source=${encodeURIComponent(item.source)}`
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
filter: keyword,
media_type: item.media_type,
enabled: true,
})
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(
run.queued > 0
? `已订阅并加入 ${run.queued} 个下载`
: '已订阅,暂未在 PT 站点找到可下载资源',
)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'订阅失败'
toast.error(msg)
} finally {
setSubscribing('')
}
}}
className="mt-2 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
>
<Rss size={12} className="mr-1 inline" />
{subscribing === key ? '订阅中…' : '订阅并搜索 PT'}
</button>
</div>
</div>
</article>
)
})}
</div>
)}
</section>
{/* Recommendations */}
+13 -1
View File
@@ -6,6 +6,7 @@ import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, User } from '../types'
import { APIConfigsPanel } from '../components/APIConfigsPanel'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
export function AdminPage() {
const [tab, setTab] = useState<'library' | 'users' | 'api'>('library')
@@ -17,6 +18,16 @@ export function AdminPage() {
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">管理后台</h1>
<ManagementShortcuts
title="统一管理入口"
description="侧栏保持精简,完整管理能力统一从这里进入。"
items={[
{ to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置' },
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载' },
{ to: '/tools', title: '整理与维护', description: '媒体整理、任务维护和辅助工具' },
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件' },
]}
/>
<div className="flex flex-wrap gap-2 border-b border-gray-200">
{tabs.map((k) => (
<button
@@ -88,6 +99,7 @@ function LibraryPanel() {
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="variety">综艺</option>
<option value="anime">动漫</option>
<option value="music">音乐</option>
</select>
@@ -117,7 +129,7 @@ function LibraryPanel() {
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={async () => {
const r = await libraryAPI.scan(l.id)
toast.success(`扫描完成,新增 ${r.added}`)
toast.success(`扫描完成,新增 ${r.added},更新 ${r.updated ?? 0}`)
}}
>
扫描
+169 -128
View File
@@ -1,140 +1,152 @@
import { useEffect, useState } from 'react'
import { Sparkles, AlertTriangle, ExternalLink, Wifi } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Sparkles } from 'lucide-react'
import { discoverAPI, type DiscoverItem } from '../api/discover'
import { discoverAPI, type DiscoverItem, type DiscoverSection } 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')
}
const defaultSections = [
'tmdb_trending_day',
'douban_hot_movie',
'douban_hot_tv',
'bangumi_calendar',
]
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')
)
}
const storageKey = 'mediastation.discover.sections'
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 [sections, setSections] = useState<DiscoverSection[]>([])
const [selected, setSelected] = useState<string[]>(defaultSections)
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
Promise.all([
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.items)
setTrendingErr(t.error)
setPopular(p.items)
setPopularErr(p.error)
discoverAPI
.sections()
.then((items) => {
setSections(items)
const saved = readSavedSections(items)
setSelected(saved.length > 0 ? saved : defaultSections)
})
.catch(() => {
setSections(defaultSectionDefs)
setSelected(defaultSections)
})
.finally(() => setLoading(false))
}, [])
const anyErr = trendingErr || popularErr
const missingKey = isMissingKey(anyErr)
const networkErr = !missingKey && isNetworkError(anyErr)
const otherErr = !missingKey && !networkErr && anyErr
useEffect(() => {
if (selected.length === 0) {
setRows({})
setLoading(false)
return
}
setLoading(true)
setError('')
window.localStorage.setItem(storageKey, JSON.stringify(selected))
discoverAPI
.feed(selected)
.then((feed) => {
const next: Record<string, DiscoverItem[]> = {}
for (const key of selected) {
next[key] = feed[key] ?? []
}
setRows(next)
})
.catch((err) => {
setRows({})
setError(err instanceof Error ? err.message : String(err))
})
.finally(() => setLoading(false))
}, [selected])
const sectionMap = useMemo(
() => new Map(sections.map((section) => [section.key, section])),
[sections],
)
const hasContent = selected.some((key) => (rows[key] ?? []).length > 0)
const toggleSection = (key: string) => {
setSelected((current) => {
if (current.includes(key)) {
return current.filter((item) => item !== key)
}
return [...current, key]
})
}
return (
<div className="space-y-8 px-4 py-6 max-w-7xl mx-auto">
{/* Header */}
<header className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-2xl bg-gradient-to-br from-primary-500/20 to-primary-600/10 border border-primary-500/20">
<Sparkles className="h-8 w-8 text-brand-500" />
<div className="mx-auto max-w-7xl space-y-8 px-4 py-6">
<header className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="flex items-center gap-4">
<div className="rounded-2xl border border-primary-500/20 bg-gradient-to-br from-primary-500/20 to-primary-600/10 p-3">
<Sparkles className="h-8 w-8 text-brand-500" />
</div>
<div>
<h1 className="font-display text-4xl font-bold tracking-tight text-ink-600">
发现
</h1>
<p className="mt-1 text-base text-ink-50">
多源推荐:TMDb / 豆瓣 / Bangumi,可按需组合显示
</p>
</div>
</div>
<div>
<h1 className="font-display text-4xl font-bold text-ink-600 tracking-tight">发现</h1>
<p className="mt-1 text-base text-ink-50">
来自 TMDb 的当日热门与流行榜单
</p>
<div className="flex flex-wrap gap-2">
{sections.map((section) => {
const active = selected.includes(section.key)
return (
<button
key={section.key}
type="button"
onClick={() => toggleSection(section.key)}
className={
'rounded-full border px-3 py-1.5 text-xs font-semibold transition ' +
(active
? 'border-primary-400 bg-primary-400/15 text-brand-500'
: 'border-gray-200 bg-white text-gray-500 hover:border-primary-300 hover:text-ink-600')
}
>
{section.label}
</button>
)
})}
</div>
</header>
{loading && <DiscoverSkeleton />}
{/* 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-ink-600">TMDb API Key 未配置</h3>
<p className="text-sm text-ink-50 max-w-md mx-auto">
您需要在管理后台填入 TMDb API Key 才能查看发现内容。
</p>
<Link
to="/admin"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary-500/20 text-brand-500 hover:bg-primary-500/30 transition-colors font-medium"
>
前往管理后台
<ExternalLink className="h-4 w-4" />
</Link>
{!loading && error && (
<div className="flex items-center gap-3 rounded-2xl border border-red-500/20 bg-red-500/10 p-4">
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-red-400" />
<p className="text-red-300">{error}</p>
</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-ink-600">无法连接到 TMDb</h3>
<p className="text-sm text-ink-50 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-sand-500 max-w-lg mx-auto text-left">
<summary className="cursor-pointer hover:text-ink-50">查看原始错误</summary>
<pre className="mt-2 p-2 rounded bg-black/40 overflow-x-auto whitespace-pre-wrap">{anyErr}</pre>
</details>
{!loading && selected.length === 0 && (
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center text-sand-500">
至少选择一个推荐源,小宇宙才会开始转动。
</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 && !missingKey && (
{!loading && !error && selected.length > 0 && (
<div className="space-y-10">
{trending.length > 0 && <ContentRow title="今日趋势" items={trending} />}
{popular.length > 0 && <ContentRow title="热门电影" items={popular} />}
{selected.map((key) => {
const items = rows[key] ?? []
if (items.length === 0) return null
return (
<ContentRow
key={key}
title={sectionMap.get(key)?.label ?? key}
items={items}
/>
)
})}
{/* TMDB 配置 OK 但本次没拿到任何条目(极少见) */}
{!networkErr && trending.length === 0 && popular.length === 0 && !otherErr && (
<div className="text-center py-12">
<p className="text-sand-500">暂无发现内容</p>
{!hasContent && (
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center">
<p className="text-sand-500">
当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。
</p>
</div>
)}
</div>
@@ -146,10 +158,10 @@ export function DiscoverPage() {
function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) {
return (
<section className="space-y-4">
<h2 className="font-display text-2xl font-semibold text-ink-600 pl-1">{title}</h2>
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
<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} />
{items.map((item, index) => (
<DiscoverCard key={discoverKey(item, index)} item={item} />
))}
</div>
</section>
@@ -157,35 +169,39 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] })
}
function DiscoverCard({ item }: { item: DiscoverItem }) {
const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb')
return (
<div className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 hover:border-primary-500/30 transition-all duration-300">
<div className="aspect-[2/3] w-full bg-surface-900 relative overflow-hidden">
<div className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 transition-all duration-300 hover:border-primary-500/30">
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-900">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-gray-500 text-xs">
<div className="flex h-full w-full items-center justify-center text-xs text-gray-500">
无海报
</div>
)}
{item.rating > 0 && (
<div className="absolute top-1.5 right-1.5 rounded-xl 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 className="absolute left-1.5 top-1.5 rounded-xl border border-white/20 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-white backdrop-blur-sm">
{source}
</div>
{(item.rating ?? 0) > 0 && (
<div className="absolute right-1.5 top-1.5 rounded-xl border border-yellow-400/30 bg-black/70 px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 backdrop-blur-sm">
★ {(item.rating ?? 0).toFixed(1)}
</div>
)}
</div>
<div className="px-2.5 py-2 space-y-0.5">
<p className="text-xs font-medium text-ink-600 truncate group-hover:text-brand-500 transition-colors">
<div className="space-y-0.5 px-2.5 py-2">
<p className="truncate text-xs font-medium text-ink-600 transition-colors group-hover:text-brand-500">
{item.title}
</p>
{item.year > 0 && (
<p className="text-[11px] text-sand-500">{item.year}</p>
)}
<p className="text-[11px] text-sand-500">
{[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'}
</p>
</div>
</div>
)
@@ -194,12 +210,12 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
function DiscoverSkeleton() {
return (
<div className="space-y-8">
{[1, 2].map((section) => (
{[1, 2, 3].map((section) => (
<section key={section} className="space-y-4">
<div className="h-8 w-48 rounded-xl bg-gray-100 animate-pulse" />
<div className="h-8 w-48 animate-pulse rounded-xl bg-gray-100" />
<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-gray-100 animate-pulse" />
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
<div key={item} className="aspect-[2/3] animate-pulse rounded-xl bg-gray-100" />
))}
</div>
</section>
@@ -207,3 +223,28 @@ function DiscoverSkeleton() {
</div>
)
}
function discoverKey(item: DiscoverItem, index: number): string {
return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}`
}
function readSavedSections(sections: DiscoverSection[]): string[] {
try {
const raw = window.localStorage.getItem(storageKey)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
const allowed = new Set(sections.map((section) => section.key))
return parsed.filter((key) => typeof key === 'string' && allowed.has(key))
} catch {
return []
}
}
const defaultSectionDefs: DiscoverSection[] = [
{ key: 'tmdb_trending_day', label: 'TMDb 今日趋势', provider: 'tmdb' },
{ key: 'tmdb_popular_movie', label: 'TMDb 热门电影', provider: 'tmdb' },
{ key: 'douban_hot_movie', label: '豆瓣热门电影', provider: 'douban' },
{ key: 'douban_hot_tv', label: '豆瓣热门剧集', provider: 'douban' },
{ key: 'bangumi_calendar', label: 'Bangumi 每日放送', provider: 'bangumi' },
]
+13 -2
View File
@@ -28,12 +28,17 @@ export function DuplicatesPage() {
libraryAPI.list().then(setLibs)
}, [])
useEffect(() => {
duplicatesAPI.list(libID).then(setReport).catch(() => setReport(null))
}, [libID])
const scan = async () => {
setScanning(true)
try {
const r = await duplicatesAPI.scan(libID)
setReport(r)
toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记`)
const cleaned = r.missing_removed ? `, 清理 ${r.missing_removed} 条失效记录` : ''
toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记${cleaned}`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
@@ -89,7 +94,13 @@ export function DuplicatesPage() {
<p className="text-ink-50">扫描了 {report.total_scanned} 项,未发现重复。</p>
)}
{report && report.groups.map((g) => (
{report && report.missing_removed ? (
<p className="rounded-2xl border border-amber-300/50 bg-amber-50 px-4 py-3 text-sm text-amber-700">
已清理 {report.missing_removed} 条文件不存在的媒体记录,统计容量会在刷新后恢复正常。
</p>
) : null}
{report && (report.groups ?? []).map((g) => (
<section key={g.hash} className="glass-panel space-y-2">
<div className="flex items-center justify-between">
<p className="font-mono text-xs text-sand-500">{g.hash}</p>
+104 -71
View File
@@ -15,9 +15,10 @@ import { groupSeries } from '../utils/groupSeries'
type LibraryRow = { library: Library; cards: ReturnType<typeof groupSeries> }
const TYPE_ICONS: Record<string, React.ReactNode> = {
movie: <Film size={18} />, tv: <Tv size={18} />, anime: <PlayCircle size={18} />, music: <Music size={18} />,
movie: <Film size={18} />, tv: <Tv size={18} />, variety: <Tv size={18} />, anime: <PlayCircle size={18} />, music: <Music size={18} />,
}
const TYPE_LABELS: Record<string, string> = { movie: '电影', tv: '电视剧', anime: '动漫', music: '音乐' }
const TYPE_LABELS: Record<string, string> = { movie: '电影', tv: '电视剧', variety: '综艺', anime: '动漫', music: '音乐' }
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
export function HomePage() {
const [libraries, setLibraries] = useState<Library[]>([])
@@ -58,19 +59,18 @@ export function HomePage() {
}, [loading, libraries.length])
const fallbackCards = useMemo(() => groupSeries(fallback).slice(0, 16), [fallback])
// Pick a featured movie/show for the premium editorial billboard
const featuredItem = useMemo(() => {
if (history.length > 0 && history[0].media) {
return history[0].media;
}
if (rows.length > 0 && rows[0].cards.length > 0) {
return rows[0].cards[0].rep;
}
if (fallback.length > 0) {
return fallback[0];
}
return null;
}, [history, rows, fallback]);
const candidates = [
...(history.map((h) => h.media).filter(Boolean) as Media[]),
...rows.flatMap((row) => row.cards.map((card) => card.rep)),
...fallbackCards.map((card) => card.rep),
...fallback,
]
return candidates.find(hasArtwork) ?? candidates[0] ?? null
}, [history, rows, fallbackCards, fallback])
const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
const empty = !loading && history.length === 0 && rows.every((r) => r.cards.length === 0) && fallbackCards.length === 0
@@ -109,71 +109,104 @@ export function HomePage() {
<div className="space-y-12">
{/* ─── Premium Swiss-Editorial Billboard Hero ─── */}
{featuredItem && (
<section className="relative overflow-hidden rounded-3xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)]">
{/* Background Poster Cover & Soft Faded Backdrop */}
<section className="relative overflow-hidden rounded-[2rem] bg-white border border-gray-200/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)]">
{/* Background Artwork */}
<div className="absolute inset-0 z-0">
{featuredItem.poster_url ? (
<img
src={imageURL(featuredItem.poster_url)}
alt=""
className="w-full h-full object-cover object-top opacity-5 scale-105 blur-sm"
<div className="h-full w-full bg-[radial-gradient(circle_at_80%_20%,rgba(212,175,55,0.22),transparent_34%),linear-gradient(135deg,#fff7ed,#f8fafc_52%,#eef2ff)]" />
{featuredVisual && (
<img
src={imageURL(featuredVisual)}
alt=""
className="absolute inset-0 h-full w-full scale-105 object-cover object-center opacity-[0.34] blur-[1px]"
referrerPolicy="no-referrer"
onError={(event) => { event.currentTarget.style.display = 'none' }}
/>
) : (
<div className="w-full h-full bg-gray-50" />
)}
{/* Soft Bright Masking */}
<div className="absolute inset-0 bg-gradient-to-r from-white via-white/95 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
<div className="absolute inset-0 bg-[linear-gradient(90deg,#ffffff_0%,rgba(255,255,255,0.96)_37%,rgba(255,255,255,0.62)_68%,rgba(255,255,255,0.2)_100%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_82%_50%,rgba(201,149,74,0.24),transparent_32%)]" />
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-white to-transparent" />
</div>
{/* Billboard Content */}
<div className="relative z-10 px-8 py-14 sm:px-12 md:py-16 lg:py-20 max-w-3xl space-y-5">
<div className="inline-flex items-center gap-2 rounded-full bg-brand-50 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#c9954a] border border-brand-100/40">
<Sparkles size={12} fill="currentColor" />
<span>本周力荐 / Featured</span>
<div className="relative z-10 grid gap-8 px-6 py-8 sm:px-8 md:grid-cols-[minmax(0,1fr)_280px] md:px-12 md:py-12 lg:grid-cols-[minmax(0,1fr)_340px] lg:px-14 lg:py-14">
<div className="flex min-w-0 flex-col justify-center space-y-5">
<div className="inline-flex w-fit items-center gap-2 rounded-full bg-white/82 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#a8732d] border border-[#ead6b6] shadow-sm backdrop-blur">
<Sparkles size={12} fill="currentColor" />
<span>本周力荐 / Featured</span>
</div>
<div className="space-y-3">
<div className="inline-flex max-w-full items-center gap-2 rounded-2xl bg-gray-950 px-3 py-2 text-white shadow-lg shadow-gray-950/10">
<span className="h-2 w-2 rounded-full bg-[#d4af37]" />
<span className="truncate text-xs font-black tracking-[0.26em]">{featuredMark}</span>
</div>
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight leading-tight text-gray-950">
{featuredItem.title}
</h1>
</div>
{featuredItem.overview ? (
<p className="max-w-2xl text-gray-600 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
{featuredItem.overview}
</p>
) : (
<p className="max-w-2xl text-gray-500 text-sm italic">
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
</p>
)}
{/* Metadata Badges */}
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
{featuredItem.year > 0 && (
<span className="bg-white/85 px-2.5 py-1 rounded-xl text-gray-900 border border-gray-200 shadow-sm">{featuredItem.year} 年</span>
)}
{featuredItem.video_codec && (
<span className="rounded-lg bg-[#fff8e7] px-2 py-1 text-[#9a6a1e] border border-[#ead6b6] uppercase font-bold text-[10px]">
{featuredItem.video_codec}
</span>
)}
{featuredItem.container && (
<span className="rounded-lg bg-white/80 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
{featuredItem.container}
</span>
)}
</div>
{/* Buttons */}
<div className="flex flex-wrap items-center gap-4 pt-2">
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-lg shadow-gray-900/15 hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
<Link to="/discover" className="btn-outline bg-white/80 px-5 py-3.5 text-sm font-bold text-gray-700 border border-gray-200 hover:border-gray-300">
<span>发现更多精彩</span>
<ArrowRight size={16} />
</Link>
</div>
</div>
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight leading-tight text-gray-900">
{featuredItem.title}
</h1>
{featuredItem.overview ? (
<p className="text-gray-500 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
{featuredItem.overview}
</p>
) : (
<p className="text-gray-500 text-sm italic">
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
</p>
)}
{/* Metadata Badges */}
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
{featuredItem.year > 0 && (
<span className="bg-gray-100 px-2.5 py-1 rounded-xl text-gray-900 border border-gray-200">{featuredItem.year} 年</span>
)}
{featuredItem.video_codec && (
<span className="rounded-lg bg-brand-50 px-2 py-1 text-brand-700 border border-brand-100 uppercase font-bold text-[10px]">
{featuredItem.video_codec}
</span>
)}
{featuredItem.container && (
<span className="rounded-lg bg-gray-100 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
{featuredItem.container}
</span>
)}
</div>
{/* Buttons */}
<div className="flex flex-wrap items-center gap-4 pt-4">
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-sm hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
<Link to="/discover" className="btn-outline px-5 py-3.5 text-sm font-bold text-gray-700 border border-gray-200 hover:border-gray-300">
<span>发现更多精彩</span>
<ArrowRight size={16} />
</Link>
<div className="relative order-first mx-auto flex w-full max-w-[220px] items-center md:order-none md:max-w-[260px] lg:max-w-[310px]">
<div className="absolute -right-6 top-5 h-32 w-32 rounded-full bg-[#d4af37]/20 blur-3xl" />
<div className="absolute -left-5 bottom-8 h-28 w-28 rounded-full bg-brand-200/30 blur-3xl" />
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-[1.7rem] border border-white/70 bg-white p-2 shadow-[0_32px_80px_rgba(15,23,42,0.20)]">
<div className="flex h-full w-full flex-col items-center justify-center rounded-[1.25rem] bg-[linear-gradient(135deg,#f9fafb,#fff7ed)] text-center">
<Film className="mb-4 h-12 w-12 text-[#c9954a]" />
<span className="px-6 font-display text-3xl font-black tracking-tight text-gray-950">{featuredItem.title}</span>
</div>
{featuredPoster && (
<img
src={imageURL(featuredPoster)}
alt={featuredItem.title}
className="absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] rounded-[1.25rem] object-cover"
referrerPolicy="no-referrer"
onError={(event) => { event.currentTarget.style.display = 'none' }}
/>
)}
<div className="absolute inset-x-4 bottom-4 rounded-2xl bg-white/88 px-4 py-3 shadow-lg backdrop-blur">
<p className="truncate text-xs font-black tracking-[0.22em] text-[#a8732d]">MEDIASTATION PICK</p>
<p className="truncate text-sm font-extrabold text-gray-950">{featuredItem.title}</p>
</div>
</div>
</div>
</div>
</section>
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Film, Library as LibraryIcon, Music, PlayCircle, Tv } from 'lucide-react'
import { libraryAPI } from '../api/library'
import type { Library } from '../types'
const TYPE_ICONS: Record<string, React.ReactNode> = {
movie: <Film size={20} />,
tv: <Tv size={20} />,
anime: <PlayCircle size={20} />,
variety: <Tv size={20} />,
music: <Music size={20} />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '剧集',
anime: '动漫',
variety: '综艺',
music: '音乐',
}
export function LibrariesPage() {
const [libraries, setLibraries] = useState<Library[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
libraryAPI.list().then(setLibraries).finally(() => setLoading(false))
}, [])
if (loading) {
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
}
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1>
<p className="mt-1 text-sm text-ink-50">所有电影、剧集、番剧、综艺分类统一在这里查看。</p>
</div>
{libraries.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-sand-200 bg-white py-24 text-center">
<LibraryIcon className="mb-4 h-12 w-12 text-gray-400" />
<p className="text-sm text-ink-50">暂无媒体库,请到管理后台添加目录。</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{libraries.map((lib, index) => (
<motion.div
key={lib.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<Link
to={`/library/${lib.id}`}
className="group block rounded-3xl border border-sand-200 bg-white p-5 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
>
<div className="mb-6 flex items-start justify-between gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-600">
{TYPE_ICONS[lib.type] ?? <LibraryIcon size={20} />}
</div>
<span className="rounded-full bg-sand-100 px-3 py-1 text-xs font-semibold text-sand-600">
{TYPE_LABELS[lib.type] ?? lib.type}
</span>
</div>
<h2 className="font-display text-xl font-bold text-ink-600 group-hover:text-brand-600">
{lib.name}
</h2>
<p className="mt-2 line-clamp-2 break-all text-xs text-ink-50">{lib.path}</p>
</Link>
</motion.div>
))}
</div>
)}
</div>
)
}
+77 -43
View File
@@ -9,7 +9,7 @@ import type { Library, Media } from '../types'
import { MediaCard } from '../components/MediaCard'
import { imageURL } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { getSeriesKey, groupSeries, type SeriesCard } from '../utils/groupSeries'
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
export function LibraryPage() {
const { id = '' } = useParams()
@@ -25,8 +25,10 @@ export function LibraryPage() {
// 剧集模式:选中某个剧集后展开详情
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
const isSeries = library?.type === 'tv' || library?.type === 'anime'
const hasEpisodicItems = useMemo(() => items.some(isEpisodeLike), [items])
const isSeries = library?.type === 'tv' || library?.type === 'anime' || library?.type === 'variety' || hasEpisodicItems
// 折叠后的剧集卡片
const seriesCards = useMemo(() => {
@@ -53,6 +55,11 @@ export function LibraryPage() {
.map(([season, episodes]) => ({ season, episodes }))
}, [selectedSeries, items])
const visibleEpisodes = useMemo(() => {
if (selectedSeason == null) return selectedEpisodes[0]?.episodes ?? []
return selectedEpisodes.find((s) => s.season === selectedSeason)?.episodes ?? []
}, [selectedEpisodes, selectedSeason])
useEffect(() => {
if (!id) return
libraryAPI.list().then((all) => {
@@ -64,8 +71,8 @@ export function LibraryPage() {
useEffect(() => {
if (!id || !library) return
setLoading(true)
// tv/anime 拉取较多集数以支持前端分组
const limit = library.type === 'tv' || library.type === 'anime' ? 1000 : 200
// 所有库都可能包含季集型内容(综艺/纪录片/课程等),统一拉取较大页数用于前端分组。
const limit = 2000
libraryAPI
.listMedia(id, 1, limit)
.then((d) => {
@@ -76,8 +83,10 @@ export function LibraryPage() {
}, [id, library])
useEffect(() => {
if (loading) return
if (!isSeries) {
setSelectedSeries(null)
setSelectedSeason(null)
return
}
@@ -89,13 +98,23 @@ export function LibraryPage() {
const next = seriesCards.find((card) => card.key === key)
if (next) setSelectedSeries(next)
}, [isSeries, searchParams, seriesCards])
}, [isSeries, loading, searchParams, seriesCards])
useEffect(() => {
if (!selectedSeries || selectedEpisodes.length === 0) {
setSelectedSeason(null)
return
}
if (selectedSeason == null || !selectedEpisodes.some((s) => s.season === selectedSeason)) {
setSelectedSeason(selectedEpisodes[0].season)
}
}, [selectedSeries, selectedEpisodes, selectedSeason])
const handleScan = async () => {
setScanning(true)
try {
const r = await libraryAPI.scan(id)
toast.success(`扫描完成:新增 ${r.added} 项`)
toast.success(`扫描完成:新增 ${r.added} 项,更新 ${r.updated ?? 0} 项`)
setLibrary((l) => (l ? { ...l } : l))
} catch {
toast.error('扫描失败')
@@ -121,6 +140,7 @@ export function LibraryPage() {
const clearSelectedSeries = () => {
setSelectedSeries(null)
setSelectedSeason(null)
const next = new URLSearchParams(searchParams)
next.delete('series')
setSearchParams(next)
@@ -144,7 +164,7 @@ export function LibraryPage() {
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">
{library?.name ?? '媒体库'}
{!isSeries && <span className="text-sand-500"> ({total})</span>}
<span className="text-sand-500"> ({isSeries ? seriesCards.length : total})</span>
</h1>
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
</div>
@@ -200,7 +220,7 @@ export function LibraryPage() {
返回列表
</button>
<h2 className="font-display text-2xl font-bold text-ink-600 truncate">
{selectedSeries.rep.title}
{seriesTitle(selectedSeries.rep)}
</h2>
<span className="text-sm text-sand-500">共 {selectedSeries.count} 集</span>
</div>
@@ -221,12 +241,11 @@ export function LibraryPage() {
{/* 从第一集开始 */}
{(() => {
const firstEps = selectedEpisodes
.flatMap((s) => s.episodes)
.sort((a, b) =>
(a.season_num || 0) - (b.season_num || 0)
|| (a.episode_num || 0) - (b.episode_num || 0),
)
const firstEps = [...(visibleEpisodes.length > 0 ? visibleEpisodes : selectedEpisodes.flatMap((s) => s.episodes))]
firstEps.sort((a, b) =>
(a.season_num || 0) - (b.season_num || 0)
|| (a.episode_num || 0) - (b.episode_num || 0),
)
const first = firstEps.length > 0 ? firstEps[0] : null
return first ? (
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
@@ -240,37 +259,52 @@ export function LibraryPage() {
{/* 季 / 集列表 */}
<div className="space-y-6">
{selectedEpisodes.map(({ season, episodes }) => (
<div key={season}>
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
<div className="flex flex-wrap items-center gap-2">
{selectedEpisodes.map(({ season, episodes }) => (
<button
key={season}
onClick={() => setSelectedSeason(season)}
className={
'rounded-xl border px-4 py-2 text-sm font-semibold transition ' +
(selectedSeason === season
? 'border-brand-300 bg-brand-50 text-brand-700'
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
}
>
第 {season} 季 · {episodes.length} 集
</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{episodes.map((ep) => (
<Link
key={ep.id}
to={`/play/${ep.id}`}
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.episode_num || '—'}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600">
{ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title}
</p>
<p className="text-xs text-sand-500">
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
</p>
</div>
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
</Link>
))}
</div>
</button>
))}
</div>
<div>
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
第 {selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季
</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{visibleEpisodes.map((ep) => (
<Link
key={ep.id}
to={`/play/${ep.id}`}
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.episode_num || '—'}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600">
{ep.original_name || (ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title)}
</p>
<p className="text-xs text-sand-500">
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
</p>
</div>
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
</Link>
))}
</div>
))}
</div>
</div>
</motion.div>
)}
+78 -39
View File
@@ -35,6 +35,8 @@ export function PlayerPage() {
const [media, setMedia] = useState<Media | null>(null)
const [mode, setMode] = useState<Mode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [hlsUnavailable, setHlsUnavailable] = useState(false)
const [playerError, setPlayerError] = useState('')
// Load metadata and pick a default mode.
useEffect(() => {
@@ -44,10 +46,11 @@ export function PlayerPage() {
const forced = params.get('mode') as Mode | null
const auto = pickMode(m)
setMode(forced ?? auto)
setPlayerError('')
})
subtitlesAPI
.list(id)
.then(setSubs)
.then((tracks) => setSubs(tracks ?? []))
.catch(() => setSubs([]))
}, [id, params])
@@ -66,7 +69,9 @@ export function PlayerPage() {
hls.attachMedia(video)
hls.on(HlsCtor.Events.ERROR, (_, data) => {
if (data.fatal) {
toast.error('HLS 播放失败,尝试切换到直接播放')
setHlsUnavailable(true)
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
toast.error('HLS 转码失败,尝试切换到直接播放')
setMode('direct')
params.set('mode', 'direct')
setParams(params, { replace: true })
@@ -76,17 +81,26 @@ export function PlayerPage() {
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url
} else {
toast.error('当前浏览器不支持 HLS,降级到直接播放')
setHlsUnavailable(true)
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
toast.error('当前浏览器不支持 HLS,降级到直接播放')
setMode('direct')
}
void video.play().catch(() => undefined)
}).catch(() => {
setHlsUnavailable(true)
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
setMode('direct')
})
} else {
video.src = streamURL(media.id)
if (hlsUnavailable && needsTranscodeForBrowser(media)) {
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
}
void video.play().catch(() => undefined)
}
return teardownHls
}, [media, mode, params, setParams])
}, [hlsUnavailable, media, mode, params, setParams])
// Persist resume position every 10 seconds while playing.
useEffect(() => {
@@ -113,11 +127,14 @@ export function PlayerPage() {
// ESC = back.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') navigate(-1)
if (e.key === 'Escape') {
const target = media?.id || id
navigate(target ? `/media/${target}` : '/', { replace: true })
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [navigate])
}, [id, media?.id, navigate])
const teardownHls = () => {
if (hlsRef.current) {
@@ -126,35 +143,42 @@ export function PlayerPage() {
}
}
return (
<div className="-m-6 flex min-h-screen flex-col bg-black md:-m-8">
<button
onClick={() => navigate(-1)}
className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-ink-600 backdrop-blur transition hover:bg-black/80"
>
<ArrowLeft size={16} /> 返回
</button>
const goBack = () => {
const target = media?.id || id
navigate(target ? `/media/${target}` : '/', { replace: true })
}
<button
onClick={() => {
const next = mode === 'hls' ? 'direct' : 'hls'
setMode(next)
params.set('mode', next)
setParams(params, { replace: true })
}}
className="absolute right-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-ink-600 backdrop-blur transition hover:bg-black/80"
title="切换播放模式"
>
{mode === 'hls' ? (
<>
<RefreshCw size={14} /> HLS 转码中
</>
) : (
<>
<Sparkles size={14} /> 直接播放
</>
)}
</button>
return (
<div className="relative -m-6 flex min-h-screen flex-col overflow-hidden bg-black md:-m-8">
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
<button
onClick={goBack}
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
>
<ArrowLeft size={16} /> 返回
</button>
<button
onClick={() => {
const next = mode === 'hls' ? 'direct' : 'hls'
setMode(next)
params.set('mode', next)
setParams(params, { replace: true })
}}
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
title="切换播放模式"
>
{mode === 'hls' ? (
<>
<RefreshCw size={14} /> HLS 转码中
</>
) : (
<>
<Sparkles size={14} /> 直接播放
</>
)}
</button>
</div>
<div className="flex flex-1 items-center justify-center">
{media ? (
@@ -168,11 +192,17 @@ export function PlayerPage() {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
if (mode === 'direct') {
toast.error('直接播放失败,切换到 HLS 转码')
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
if (hlsUnavailable) {
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
}
} else {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
}
}}
@@ -191,6 +221,11 @@ export function PlayerPage() {
) : (
<p className="text-sand-500">加载中…</p>
)}
{playerError ? (
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
{playerError}
</div>
) : null}
</div>
</div>
)
@@ -201,11 +236,15 @@ const directVideoCodecs = ['h264', 'avc', 'avc1']
const directAudioCodecs = ['aac', 'mp3', 'opus']
function pickMode(m: Media): Mode {
return needsTranscodeForBrowser(m) ? 'hls' : 'direct'
}
function needsTranscodeForBrowser(m: Media): boolean {
const c = (m.container ?? '').toLowerCase()
const v = (m.video_codec ?? '').toLowerCase()
const a = (m.audio_codec ?? '').toLowerCase()
const containerOK = directContainers.some((x) => c.includes(x))
const videoOK = !v || directVideoCodecs.some((x) => v.includes(x))
const audioOK = !a || directAudioCodecs.some((x) => a.includes(x))
return containerOK && videoOK && audioOK ? 'direct' : 'hls'
return !(containerOK && videoOK && audioOK)
}
+110 -5
View File
@@ -1,9 +1,11 @@
import { ChangeEvent, FormEvent, useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Sparkles } from 'lucide-react'
import { Rss, Sparkles } from 'lucide-react'
import { aiAPI, type SearchIntent } from '../api/ai'
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
import { imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import { subscriptionsAPI } from '../api/subscriptions'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
@@ -16,6 +18,8 @@ export function SearchPage() {
const [aiAvailable, setAiAvailable] = useState(false)
const [intent, setIntent] = useState<SearchIntent | null>(null)
const [hasSearched, setHasSearched] = useState(false)
const [externalItems, setExternalItems] = useState<ExternalMediaResult[]>([])
const [subscribing, setSubscribing] = useState('')
useEffect(() => {
aiAPI
@@ -37,6 +41,7 @@ export function SearchPage() {
.search(query, 60)
.then((d) => {
setItems(d.items ?? [])
setExternalItems([])
setIntent(null)
})
.catch((err) => {
@@ -66,6 +71,7 @@ export function SearchPage() {
try {
const data = await aiAPI.smartSearch(q)
setItems(data.items ?? [])
setExternalItems(data.external_items ?? [])
setIntent(data.intent)
} catch (err) {
const msg =
@@ -86,15 +92,14 @@ export function SearchPage() {
<header className="flex items-center justify-between">
<h1 className="font-display text-3xl font-bold text-ink-600">搜索</h1>
<button
disabled={!aiAvailable}
className={
'neon-button !px-3 !py-1 !text-xs ' +
(aiOn ? '!border-accent-400 !bg-accent-400/20 !text-accent-400' : '')
}
onClick={() => setAiOn((on) => !on)}
title={aiAvailable ? '启用 AI 智能搜索' : '请先在 ai.* 中配置 API Key'}
title={aiAvailable ? '启用 AI 智能搜索' : '使用本地规则 + 外部数据源搜索'}
>
<Sparkles size={12} /> {aiOn ? 'AI 已开启' : 'AI 智能搜索'}
<Sparkles size={12} /> {aiOn ? '智能搜索已开启' : '智能搜索'}
</button>
</header>
@@ -156,12 +161,112 @@ export function SearchPage() {
)}
{items.length > 0 && (
<>
<div className="text-sm font-semibold text-ink-100">本地媒体库</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
))}
</div>
</>
)}
{externalItems.length > 0 && (
<ExternalResults
items={externalItems}
busyKey={subscribing}
onSubscribe={async (item) => {
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
setSubscribing(key)
try {
const feed = `site-search://search?keyword=${encodeURIComponent(keyword)}&source=${encodeURIComponent(item.source)}`
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
filter: keyword,
media_type: item.media_type,
enabled: true,
})
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(
run.queued > 0
? `已订阅并加入 ${run.queued} 个下载`
: '已订阅,暂未在 PT 站点找到可下载资源',
)
} catch (err) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'订阅失败'
toast.error(msg)
} finally {
setSubscribing('')
}
}}
/>
)}
</div>
)
}
function ExternalResults({
items,
busyKey,
onSubscribe,
}: {
items: ExternalMediaResult[]
busyKey: string
onSubscribe: (item: ExternalMediaResult) => Promise<void>
}) {
return (
<section className="space-y-3">
<div>
<h2 className="font-display text-xl font-semibold text-ink-600">外部数据源</h2>
<p className="text-xs text-ink-50">
来自 TMDb / 豆瓣 / Bangumi。订阅后会定期搜索已配置 PT 站点,并只入队最佳新资源。
</p>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => {
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
return (
<article key={key} className="glass-panel flex gap-3 !p-3">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] uppercase text-brand-500">
{item.source}
</span>
{item.media_type && <span className="text-xs text-sand-500">{item.media_type}</span>}
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
{item.rating ? <span className="text-xs text-amber-500">★ {item.rating.toFixed(1)}</span> : null}
</div>
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
{item.overview || `订阅关键词:${keyword}`}
</p>
<button
onClick={() => onSubscribe(item)}
disabled={busyKey === key}
className="mt-3 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
>
<Rss size={12} className="mr-1 inline" />
{busyKey === key ? '订阅中…' : '订阅并搜索 PT'}
</button>
</div>
</article>
)
})}
</div>
</section>
)
}
+12
View File
@@ -4,6 +4,7 @@ import { Globe, Plus, Trash2, Wifi, RefreshCw, X, Edit3, CheckCircle, XCircle, H
import { sitesAPI } from '../api/sites'
import type { Site } from '../types'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
// ── 站点类型映射 ──
const SITE_TYPE_LABELS: Record<string, string> = {
@@ -235,6 +236,17 @@ export function SitesPage() {
return (
<div className="space-y-6">
<ManagementShortcuts
title="站点与下载链路"
description="把站点、搜索、订阅和下载器放在同一工作流里,避免功能入口被隐藏。"
items={[
{ to: '/download-clients', title: '下载器管理', description: '添加、测试和维护下载器连接', badge: '必需' },
{ to: '/site-search', title: '站点检索', description: '跨 PT 站点搜索资源并创建下载任务' },
{ to: '/subscriptions', title: '订阅管理', description: '管理追剧追番和自动下载规则' },
{ to: '/downloads', title: '下载中心', description: '查看下载任务状态和历史记录' },
]}
/>
{/* 页头 */}
<div className="flex items-center justify-between">
<h1 className="font-display text-3xl font-bold text-ink-600">站点管理</h1>
+10 -3
View File
@@ -4,6 +4,7 @@ import { Activity, Cpu, Database, Film, HardDrive, Users } from 'lucide-react'
import { statsAPI } from '../api/stats'
import { MediaCard } from '../components/MediaCard'
import type { StatsSnapshot } from '../types'
import { groupSeries } from '../utils/groupSeries'
// fmtBytes is a tiny helper shared by the dashboard cards.
function fmtBytes(n: number): string {
@@ -54,6 +55,7 @@ export function StatsPage() {
snap.hardware.disk_total > 0
? (snap.hardware.disk_used / snap.hardware.disk_total) * 100
: 0
const recentlyAddedCards = groupSeries(snap.recently_added)
return (
<div className="space-y-8">
@@ -91,12 +93,17 @@ export function StatsPage() {
</div>
</section>
{snap.recently_added.length > 0 && (
{recentlyAddedCards.length > 0 && (
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-ink-600">最近入库</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">
{snap.recently_added.map((m) => (
<MediaCard key={m.id} media={m} />
{recentlyAddedCards.map((s) => (
<MediaCard
key={s.key}
media={s.rep}
count={s.count}
linkTo={s.count > 1 ? `/library/${s.rep.library_id}?series=${encodeURIComponent(s.key)}` : undefined}
/>
))}
</div>
</section>
+12
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Database, HardDrive, PieChart } from 'lucide-react'
import { storageAPI, type StorageBreakdown } from '../api/storage'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
function fmtBytes(n: number): string {
if (!n) return '0 B'
@@ -50,6 +51,17 @@ export function StoragePage() {
</div>
</header>
<ManagementShortcuts
title="存储与文件入口"
description="存储统计只负责看数据,文件管理、排重、回收与配置入口集中放在这里。"
items={[
{ to: '/files', title: '文件管理', description: '浏览服务器文件并执行基础文件操作' },
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略' },
{ to: '/duplicates', title: '重复清理', description: '扫描重复媒体并进行安全清理', badge: '清理' },
{ to: '/recycle', title: '回收站', description: '查看已删除资源并执行恢复或释放空间' },
]}
/>
<section className="grid gap-4 sm:grid-cols-3">
<Tile icon={<Database size={20} />} label="总占用" value={fmtBytes(data.total_bytes)} />
<Tile icon={<PieChart size={20} />} label="媒体库" value={`${data.by_library.length}`} />
+40 -4
View File
@@ -10,6 +10,9 @@ export function SubscriptionsPage() {
const [name, setName] = useState('')
const [feed, setFeed] = useState('')
const [filter, setFilter] = useState('')
const [mediaType, setMediaType] = useState('')
const [mediaCategory, setMediaCategory] = useState('')
const [savePath, setSavePath] = useState('')
const [loading, setLoading] = useState(true)
const refresh = () =>
@@ -25,11 +28,21 @@ export function SubscriptionsPage() {
const onCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await subscriptionsAPI.create({ name, feed_url: feed, filter })
await subscriptionsAPI.create({
name,
feed_url: feed,
filter,
media_type: mediaType || undefined,
media_category: mediaCategory || undefined,
save_path: savePath || undefined,
})
toast.success('已创建订阅')
setName('')
setFeed('')
setFilter('')
setMediaType('')
setMediaCategory('')
setSavePath('')
await refresh()
} catch (err: unknown) {
const msg =
@@ -42,10 +55,10 @@ export function SubscriptionsPage() {
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
<p className="text-sm text-ink-50">
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列。
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按媒体类型和二级分类写入下载目录。
</p>
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_1fr_auto]">
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-4">
<input
required
className="input-base"
@@ -66,7 +79,26 @@ export function SubscriptionsPage() {
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<button type="submit" className="neon-button">
<select className="input-base" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
<option value="">自动识别类型</option>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
</select>
<input
className="input-base"
placeholder="二级分类覆盖(如 综艺/日番,可选)"
value={mediaCategory}
onChange={(e) => setMediaCategory(e.target.value)}
/>
<input
className="input-base md:col-span-2"
placeholder="下载根目录覆盖(可选,默认使用下载器保存路径)"
value={savePath}
onChange={(e) => setSavePath(e.target.value)}
/>
<button type="submit" className="neon-button md:col-span-1">
<Plus size={16} /> 添加
</button>
</form>
@@ -82,6 +114,7 @@ export function SubscriptionsPage() {
<th className="py-2">名称</th>
<th>RSS</th>
<th>过滤器</th>
<th>分类</th>
<th>最近运行</th>
<th className="text-right">操作</th>
</tr>
@@ -94,6 +127,9 @@ export function SubscriptionsPage() {
{s.feed_url}
</td>
<td className="text-ink-100">{s.filter || '—'}</td>
<td className="text-ink-100">
{[s.media_type, s.media_category].filter(Boolean).join(' / ') || '自动'}
</td>
<td className="text-sand-500">
{s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'}
</td>
+13
View File
@@ -6,6 +6,7 @@ import { adminAPI } from '../api/admin'
import { libraryAPI, mediaAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import type { Library, Media, Setting } from '../types'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
// ToolsPage gathers admin-only one-off operations that don't belong on a
// dedicated screen of their own:
@@ -29,6 +30,18 @@ export function ToolsPage() {
</div>
</div>
<ManagementShortcuts
title="运维与自动化入口"
description="把整理、任务、通知和高级辅助功能统一放回工具台。"
items={[
{ to: '/strm', title: 'STRM 生成', description: '生成 STRM 文件供外部播放器或媒体服务使用' },
{ to: '/scheduler', title: '定时任务', description: '查看和维护自动扫描、刮削与订阅任务' },
{ to: '/tasks', title: '任务队列', description: '跟踪后台任务执行状态和失败原因' },
{ to: '/notify-channels', title: '通知渠道', description: '配置并测试消息通知渠道' },
{ to: '/assistant', title: 'AI 对话台', description: '进入管理员辅助诊断和问答界面' },
]}
/>
<OrganizePanel />
<NotifyPanel />
</div>
+6
View File
@@ -145,7 +145,10 @@ export interface ScanResult {
library_id: string
visited: number
added: number
updated?: number
probed: number
local_metadata?: number
removed?: number
}
export interface Setting {
@@ -170,6 +173,9 @@ export interface Subscription {
name: string
feed_url: string
filter: string
media_type?: string
media_category?: string
save_path?: string
enabled: boolean
last_run_at?: string
created_at: string
+38 -5
View File
@@ -9,9 +9,9 @@ import type { Media } from '../types'
*
* 折叠键优先级(命中第一个就分组):
*
* 1. tmdb_id (刮削匹配后最稳定)
* 2. bangumi_id (番剧)
* 3. series_id (后端某些场景下会预先聚合)
* 1. series_id (后端某些场景下会预先聚合)
* 2. 有季/集信息时用 library_id + 节目名(综艺每集常有不同 TMDB episode id)
* 3. tmdb_id / bangumi_id(电影或无季集条目)
* 4. library_id + title (fallback:同库同名视为同一剧)
*
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
@@ -19,10 +19,43 @@ import type { Media } from '../types'
export type SeriesCard = { key: string; rep: Media; count: number }
export function getSeriesKey(media: Media): string {
if (media.series_id) return `series:${media.series_id}`
if (isEpisodeLike(media)) {
return `lib:${media.library_id}|show:${normalizeTitle(seriesTitle(media))}`
}
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
if (media.series_id) return `series:${media.series_id}`
return `lib:${media.library_id}|${(media.title ?? '').toLowerCase().trim()}`
return `lib:${media.library_id}|${normalizeTitle(media.title)}`
}
export function isEpisodeLike(media: Media): boolean {
return (media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0
}
export function seriesTitle(media: Media): string {
const fromPath = seriesTitleFromPath(media.path)
return fromPath || media.title || media.original_name || '未命名节目'
}
function normalizeTitle(value?: string): string {
return (value ?? '')
.toLowerCase()
.replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ')
.replace(/\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*/g, ' ')
.replace(/[\s._-]+/g, ' ')
.trim()
}
function seriesTitleFromPath(path?: string): string {
if (!path) return ''
const parts = path.split(/[\\/]+/).filter(Boolean)
if (parts.length < 2) return ''
let dirIndex = parts.length - 2
if (/^(?:s\d{1,2}|season\s*\d{1,2}|第\s*\d{1,2}\s*季)$/i.test(parts[dirIndex])) {
dirIndex -= 1
}
if (dirIndex < 0) return ''
return normalizeTitle(parts[dirIndex])
}
export function groupSeries(items: Media[]): SeriesCard[] {