mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
Fix local adult metadata and watch history removal
This commit is contained in:
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user