mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
feat: port missing MediaStation features (DLNA, STRM, files, dup, sched, api-configs, emby, storage)
Audit-driven port from the original Python MediaStation. Eight major
subsystems that were absent from the Go rewrite are now in place,
each with its own service, handler, frontend page and smoke-test
assertions.
Backend services
- service/crypto.go: AES-256-GCM encrypt/decrypt for at-rest secrets
keyed off the JWT secret. Legacy plaintext rows pass through
unchanged for smooth upgrades. Unit-tested.
- service/api_config.go: third-party provider config (TMDb, Bangumi,
TheTVDB, Fanart, Douban, OpenAI). Seeds defaults on first run.
Encrypts api_key on write, returns masked 'abc1****wxyz' projection.
- service/duplicate.go: sparse-sample MD5 (head + middle + tail, 1MiB
each, plus file-size suffix) duplicate finder. Picks 'best' primary
(matched > size > id) and marks others is_duplicate=true.
- service/filemanager.go: server-side allow-listed file browser used
by the library-path picker. Strict path-traversal protection.
- service/dlna.go: real SSDP M-SEARCH discovery + AVTransport
SetAVTransportURI/Play SOAP cast. 30 s discovery cache.
- service/scheduler.go: 3 recurring background jobs (library_scan
60min, transcode_cleanup 24h, recycle_purge 24h with 30-day
cutoff). Status + run-now endpoints.
- service/cache_cleanup.go: walkAndPrune helper used by scheduler.
- service/storage.go: DB-only disk-usage breakdown by library and by
container format.
- service/emby_compat.go: read-only Emby/Jellyfin shim
(System/Info, Users, Users/x/Views, Items, PlaybackInfo) so Infuse
/ VidHub / Kodi can browse MediaStationGo libraries.
Model updates
- Media: new strm_url (302 redirect target), file_hash, is_duplicate,
duplicate_of fields.
- APIConfig: new table for encrypted provider secrets.
- AutoMigrate registers APIConfig.
Stream layer
- StreamService.ServeFile now redirects 302 to strm_url when set so
WebDAV / Alist / S3 / HTTP direct links work transparently.
Handlers + routes
- Authed: GET /files, GET /storage, GET /dlna/devices, POST /dlna/cast,
PUT/DELETE /media/:id/strm, POST /strm/import,
POST /duplicates/{scan,unmark}.
- Admin: GET/PUT/DELETE /admin/api-configs/:provider,
GET /admin/scheduler, POST /admin/scheduler/:name/run.
- New /emby/* group: System/Info, Users, Users/:userId/Views,
Users/:userId/Items, Items/:id/PlaybackInfo (auth-required).
Frontend pages (lazy-loaded, 7 new chunks)
- DlnaPage: device list + media picker + cast button.
- FileManagerPage: root selector + breadcrumb + sortable listing.
- APIConfigsPage: per-provider card with masked-key editor.
- StoragePage: usage tiles + per-library bars + per-container grid.
- DuplicatesPage: scan form + grouped report with primary highlight.
- SchedulerPage: live job table with run-now button (5s refresh).
- Sidebar reorganised: 自动化 group adds DLNA, 管理 group adds
存储 / 文件浏览 / 重复文件 / 定时任务 / API 配置.
Smoke test additions (all admin-only)
- api-configs seeded with 6 providers
- api-config encrypted in db (sqlite3 enc:v1: prefix check)
- storage breakdown
- file browser lists library root + rejects /etc (path traversal)
- dlna devices endpoint
- scheduler exposes 3 jobs + run library_scan
- emby /System/Info + /Users/{x}/Views
- strm set + stream 302 + strm clear
- duplicate scan
Verified: go build, go vet, go test (incl. new TestCrypto* suite + the
existing TestParseEpisode/TestCleanQuery/TestSrtToVTT/TestStripASSTags/
TestBuildFFmpegArgs); tsc -b && vite build emits 28 route chunks plus
the deferred hls chunk; main bundle 253 KB / 85 KB gzipped; smoke test
PASS=42 / FAIL=0.
This commit is contained in:
@@ -47,6 +47,22 @@ const TasksPage = lazy(() => import('./pages/TasksPage').then((m) => ({ default:
|
||||
const RecycleBinPage = lazy(() =>
|
||||
import('./pages/RecycleBinPage').then((m) => ({ default: m.RecycleBinPage })),
|
||||
)
|
||||
const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
|
||||
const FileManagerPage = lazy(() =>
|
||||
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
||||
)
|
||||
const APIConfigsPage = lazy(() =>
|
||||
import('./pages/APIConfigsPage').then((m) => ({ default: m.APIConfigsPage })),
|
||||
)
|
||||
const StoragePage = lazy(() =>
|
||||
import('./pages/StoragePage').then((m) => ({ default: m.StoragePage })),
|
||||
)
|
||||
const DuplicatesPage = lazy(() =>
|
||||
import('./pages/DuplicatesPage').then((m) => ({ default: m.DuplicatesPage })),
|
||||
)
|
||||
const SchedulerPage = lazy(() =>
|
||||
import('./pages/SchedulerPage').then((m) => ({ default: m.SchedulerPage })),
|
||||
)
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||
|
||||
@@ -75,6 +91,47 @@ export default function App() {
|
||||
<Route path="downloads" element={<DownloadsPage />} />
|
||||
<Route path="subscriptions" element={<SubscriptionsPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="dlna" element={<DlnaPage />} />
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<FileManagerPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="storage"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<StoragePage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="duplicates"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<DuplicatesPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduler"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<SchedulerPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="api-configs"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<APIConfigsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="tasks"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface APIConfig {
|
||||
id: string
|
||||
provider: string
|
||||
base_url?: string
|
||||
extra?: string
|
||||
enabled: boolean
|
||||
description?: string
|
||||
has_key: boolean
|
||||
masked_key?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface APIConfigPatch {
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
extra?: string
|
||||
enabled?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const apiConfigsAPI = {
|
||||
list: () => api.get<{ items: APIConfig[] }>('/admin/api-configs').then((r) => r.data.items),
|
||||
get: (provider: string) => api.get<APIConfig>(`/admin/api-configs/${provider}`).then((r) => r.data),
|
||||
update: (provider: string, patch: APIConfigPatch) =>
|
||||
api.put<APIConfig>(`/admin/api-configs/${provider}`, patch).then((r) => r.data),
|
||||
remove: (provider: string) => api.delete(`/admin/api-configs/${provider}`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface DLNADevice {
|
||||
udn: string
|
||||
friendly_name: string
|
||||
manufacturer: string
|
||||
model_name: string
|
||||
location: string
|
||||
control_url: string
|
||||
ip_address: string
|
||||
}
|
||||
|
||||
export const dlnaAPI = {
|
||||
list: (force = false) =>
|
||||
api
|
||||
.get<{ devices: DLNADevice[] }>('/dlna/devices', { params: { force: force ? 'true' : '' } })
|
||||
.then((r) => r.data.devices),
|
||||
|
||||
cast: (controlURL: string, mediaURL: string) =>
|
||||
api
|
||||
.post('/dlna/cast', { control_url: controlURL, media_url: mediaURL })
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { api } from './client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export interface DuplicateGroup {
|
||||
hash: string
|
||||
primary: Media
|
||||
duplicates: Media[]
|
||||
}
|
||||
|
||||
export interface DuplicateReport {
|
||||
total_scanned: number
|
||||
groups_found: number
|
||||
items_marked: number
|
||||
groups: DuplicateGroup[]
|
||||
}
|
||||
|
||||
export const duplicatesAPI = {
|
||||
scan: (libraryID = '') =>
|
||||
api
|
||||
.post<DuplicateReport>('/duplicates/scan', null, {
|
||||
params: libraryID ? { library_id: libraryID } : undefined,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
unmark: (libraryID = '') =>
|
||||
api
|
||||
.post<{ unmarked: number }>('/duplicates/unmark', null, {
|
||||
params: libraryID ? { library_id: libraryID } : undefined,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface FileEntry {
|
||||
name: string
|
||||
path: string
|
||||
is_dir: boolean
|
||||
size: number
|
||||
modified: number
|
||||
}
|
||||
|
||||
export interface FileListing {
|
||||
path: string
|
||||
parent?: string
|
||||
roots?: { label: string; path: string }[]
|
||||
entries: FileEntry[] | null
|
||||
}
|
||||
|
||||
export const filesAPI = {
|
||||
list: (path = '', max = 1000) =>
|
||||
api
|
||||
.get<FileListing>('/files', { params: { path, max } })
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface JobStatus {
|
||||
name: string
|
||||
interval: string
|
||||
last_run?: string
|
||||
last_err?: string
|
||||
}
|
||||
|
||||
export const schedulerAPI = {
|
||||
status: () => api.get<{ jobs: JobStatus[] }>('/admin/scheduler').then((r) => r.data.jobs),
|
||||
run: (name: string) => api.post(`/admin/scheduler/${name}/run`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface LibraryUsage {
|
||||
library_id: string
|
||||
name: string
|
||||
type: string
|
||||
path: string
|
||||
media_count: number
|
||||
total_bytes: number
|
||||
total_seconds: number
|
||||
}
|
||||
|
||||
export interface ContainerStat {
|
||||
container: string
|
||||
count: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export interface StorageBreakdown {
|
||||
total_bytes: number
|
||||
total_seconds: number
|
||||
by_library: LibraryUsage[]
|
||||
by_container: ContainerStat[]
|
||||
}
|
||||
|
||||
export const storageAPI = {
|
||||
breakdown: () => api.get<StorageBreakdown>('/storage').then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { api } from './client'
|
||||
|
||||
export const strmAPI = {
|
||||
set: (mediaID: string, url: string) =>
|
||||
api.put(`/media/${mediaID}/strm`, { url }).then((r) => r.data),
|
||||
clear: (mediaID: string) => api.delete(`/media/${mediaID}/strm`).then((r) => r.data),
|
||||
importURL: (libraryID: string, title: string, url: string) =>
|
||||
api.post('/strm/import', { library_id: libraryID, title, url }).then((r) => r.data),
|
||||
}
|
||||
@@ -2,11 +2,17 @@ import { useEffect, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Activity,
|
||||
Cast,
|
||||
Clock,
|
||||
CloudDownload,
|
||||
Compass,
|
||||
Copy,
|
||||
Film,
|
||||
FolderTree,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
KeyRound,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
LogOut,
|
||||
@@ -76,6 +82,7 @@ export function Layout() {
|
||||
</div>
|
||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
|
||||
|
||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||
账号
|
||||
@@ -89,6 +96,11 @@ export function Layout() {
|
||||
</div>
|
||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
||||
<SidebarLink to="/api-configs" icon={<KeyRound size={18} />} label="API 配置" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Eye, KeyRound, Save, Trash2 } from 'lucide-react'
|
||||
|
||||
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
|
||||
|
||||
// APIConfigsPage manages third-party API keys (TMDb / Bangumi / TheTVDB /
|
||||
// Fanart / OpenAI / Douban). Plaintext keys are never returned by the
|
||||
// backend — only a "abc1****wxyz" mask. The actual secret is encrypted
|
||||
// in SQLite with AES-GCM keyed off the JWT secret.
|
||||
export function APIConfigsPage() {
|
||||
const [items, setItems] = useState<APIConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const refresh = () =>
|
||||
apiConfigsAPI
|
||||
.list()
|
||||
.then(setItems)
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<KeyRound className="h-6 w-6 text-primary-400" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">外部 API 配置</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 的密钥。
|
||||
后端使用 AES-GCM 加密存储,数据库泄漏时密钥仍然安全。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<ProviderCard key={item.id} item={item} onUpdated={refresh} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => void }) {
|
||||
const [apiKey, setAPIKey] = useState('')
|
||||
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
||||
const [enabled, setEnabled] = useState(item.enabled)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
||||
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
||||
await apiConfigsAPI.update(item.provider, patch)
|
||||
toast.success(`${item.provider} 已保存`)
|
||||
setAPIKey('')
|
||||
onUpdated()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testKey = async () => {
|
||||
// No /test endpoint yet — render a hint instead.
|
||||
toast(`已配置 ${item.has_key ? '✓' : '✗'} 密钥(在线测试请用对应功能页面)`)
|
||||
}
|
||||
|
||||
const removeKey = async () => {
|
||||
if (!confirm(`确定清除 ${item.provider} 的 API Key?`)) return
|
||||
await apiConfigsAPI.remove(item.provider)
|
||||
toast.success('已清除')
|
||||
onUpdated()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="glass-panel grid gap-3 md:grid-cols-[1fr_2fr]">
|
||||
<div>
|
||||
<p className="font-display text-lg font-semibold text-white">{item.provider}</p>
|
||||
{item.description && (
|
||||
<p className="text-xs text-slate-400">{item.description}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
状态: {item.has_key ? <span className="text-emerald-400">已配置</span> : <span className="text-slate-500">未配置</span>}
|
||||
{item.has_key && (
|
||||
<span className="ml-2 font-mono text-primary-400">{item.masked_key}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs text-slate-400">
|
||||
API Key (留空保留原值)
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
type="password"
|
||||
placeholder={item.has_key ? '••••••••••••' : '尚未配置'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-slate-400">
|
||||
Base URL (可选)
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
placeholder="https://api.themoviedb.org/3"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-xs text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={saving} className="neon-button !text-xs">
|
||||
<Save size={12} /> 保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={testKey}
|
||||
className="neon-button !text-xs !border-slate-400/40 !text-slate-300"
|
||||
>
|
||||
<Eye size={12} /> 状态
|
||||
</button>
|
||||
{item.has_key && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeKey}
|
||||
className="neon-button !text-xs !border-red-400/40 !text-red-400"
|
||||
>
|
||||
<Trash2 size={12} /> 清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Cast, RefreshCw, Tv } from 'lucide-react'
|
||||
|
||||
import { dlnaAPI, type DLNADevice } from '../api/dlna'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { streamURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// DlnaPage scans the LAN for UPnP MediaRenderer devices and lets the
|
||||
// user push a media item to one of them via SetAVTransportURI + Play.
|
||||
export function DlnaPage() {
|
||||
const [devices, setDevices] = useState<DLNADevice[]>([])
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [media, setMedia] = useState<Media[]>([])
|
||||
const [selectedMedia, setSelectedMedia] = useState<string>('')
|
||||
|
||||
const scan = (force: boolean) => {
|
||||
setScanning(true)
|
||||
dlnaAPI
|
||||
.list(force)
|
||||
.then(setDevices)
|
||||
.catch(() => toast.error('设备发现失败,容器网络可能不支持组播'))
|
||||
.finally(() => setScanning(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scan(false)
|
||||
mediaAPI.search('', 30).then((d) => {
|
||||
setMedia(d.items)
|
||||
if (d.items.length > 0) setSelectedMedia(d.items[0].id)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const cast = async (dev: DLNADevice) => {
|
||||
if (!selectedMedia) {
|
||||
toast.error('请先选择一个媒体')
|
||||
return
|
||||
}
|
||||
// Build the absolute URL the renderer will pull from.
|
||||
const url = window.location.origin + streamURL(selectedMedia)
|
||||
try {
|
||||
await dlnaAPI.cast(dev.control_url, url)
|
||||
toast.success(`已投屏到 ${dev.friendly_name}`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'投屏失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Cast className="h-6 w-6 text-primary-400" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">DLNA 投屏</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
扫描局域网中的 UPnP MediaRenderer 设备(电视、机顶盒等),选择媒体后一键播放。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="glass-panel space-y-3">
|
||||
<label className="block text-sm text-slate-300">选择媒体:</label>
|
||||
<select
|
||||
className="input-base"
|
||||
value={selectedMedia}
|
||||
onChange={(e) => setSelectedMedia(e.target.value)}
|
||||
>
|
||||
{media.length === 0 && <option>暂无媒体</option>}
|
||||
{media.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-xl font-semibold text-white">
|
||||
设备 ({devices.length})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => scan(true)}
|
||||
disabled={scanning}
|
||||
className="neon-button !px-3 !py-1 !text-xs"
|
||||
>
|
||||
<RefreshCw size={12} className={scanning ? 'animate-spin' : ''} /> 重新扫描
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{devices.length === 0 && !scanning && (
|
||||
<div className="glass-panel">
|
||||
<p className="text-slate-300">
|
||||
未发现任何 DLNA 设备。请确保:服务器与设备在同一局域网,容器使用 host 网络模式,
|
||||
目标设备已开启 DLNA / 屏幕镜像。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{devices.map((dev) => (
|
||||
<div key={dev.udn} className="glass-panel space-y-2 !p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tv size={18} className="text-primary-400" />
|
||||
<p className="font-medium text-white">{dev.friendly_name || dev.model_name}</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
{dev.manufacturer} · {dev.ip_address}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => cast(dev)}
|
||||
disabled={!dev.control_url}
|
||||
className="neon-button w-full !text-xs"
|
||||
>
|
||||
<Cast size={12} /> 投屏
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Copy, Trash2 } from 'lucide-react'
|
||||
|
||||
import { duplicatesAPI, type DuplicateReport } from '../api/duplicates'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library } from '../types'
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(1)} ${u[i]}`
|
||||
}
|
||||
|
||||
export function DuplicatesPage() {
|
||||
const [libs, setLibs] = useState<Library[]>([])
|
||||
const [libID, setLibID] = useState('')
|
||||
const [report, setReport] = useState<DuplicateReport | null>(null)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibs)
|
||||
}, [])
|
||||
|
||||
const scan = async () => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const r = await duplicatesAPI.scan(libID)
|
||||
setReport(r)
|
||||
toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'扫描失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const unmark = async () => {
|
||||
if (!confirm('清除所有重复标记?(磁盘文件不会被删除)')) return
|
||||
const r = await duplicatesAPI.unmark(libID)
|
||||
toast.success(`已清除 ${r.unmarked} 项`)
|
||||
setReport(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Copy className="h-6 w-6 text-primary-400" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">重复文件</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
通过稀疏采样 MD5(头部 / 中部 / 尾部各 1 MiB + 文件大小)检测重复媒体,
|
||||
同一组中保留刮削过的较大文件作为主条目,其余标记为重复。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="glass-panel grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
||||
<select
|
||||
className="input-base"
|
||||
value={libID}
|
||||
onChange={(e) => setLibID(e.target.value)}
|
||||
>
|
||||
<option value="">所有媒体库</option>
|
||||
{libs.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={scan} disabled={scanning} className="neon-button">
|
||||
{scanning ? '扫描中…' : '开始扫描'}
|
||||
</button>
|
||||
<button onClick={unmark} className="neon-button !border-red-400/40 !text-red-400">
|
||||
<Trash2 size={14} /> 清除标记
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{report && report.groups_found === 0 && (
|
||||
<p className="text-slate-400">扫描了 {report.total_scanned} 项,未发现重复。</p>
|
||||
)}
|
||||
|
||||
{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-slate-500">{g.hash}</p>
|
||||
<span className="rounded border border-emerald-400/40 px-2 py-0.5 text-xs text-emerald-400">
|
||||
主条目
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium text-white">{g.primary.title}</p>
|
||||
<p className="font-mono text-xs text-slate-400">
|
||||
{g.primary.path} · {fmtBytes(g.primary.size_bytes)}
|
||||
</p>
|
||||
<div className="space-y-1 border-t border-white/5 pt-2">
|
||||
<p className="text-xs uppercase tracking-wider text-red-400">
|
||||
重复 ({g.duplicates.length})
|
||||
</p>
|
||||
{g.duplicates.map((d) => (
|
||||
<div key={d.id} className="text-xs text-slate-400">
|
||||
<span className="text-white">{d.title}</span> · {d.path} · {fmtBytes(d.size_bytes)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ChevronUp, FileVideo, Folder, FolderOpen, Home } from 'lucide-react'
|
||||
|
||||
import { filesAPI, type FileEntry, type FileListing } from '../api/files'
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(1)} ${u[i]}`
|
||||
}
|
||||
|
||||
// FileManagerPage browses the server's filesystem within the allowed
|
||||
// roots so the operator can pick library paths visually.
|
||||
export function FileManagerPage() {
|
||||
const [path, setPath] = useState('')
|
||||
const [data, setData] = useState<FileListing | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
filesAPI
|
||||
.list(path)
|
||||
.then(setData)
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'加载失败'
|
||||
setError(msg)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [path])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
const enter = (e: FileEntry) => {
|
||||
if (e.is_dir) setPath(e.path)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-bold text-white">文件浏览器</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
只允许访问已配置的根目录(媒体库 + data + cache)。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className="neon-button !px-3 !py-1 !text-xs"
|
||||
onClick={() => setPath('')}
|
||||
title="返回根列表"
|
||||
>
|
||||
<Home size={14} /> 根
|
||||
</button>
|
||||
{data?.parent && (
|
||||
<button
|
||||
className="neon-button !px-3 !py-1 !text-xs"
|
||||
onClick={() => setPath(data.parent ?? '')}
|
||||
>
|
||||
<ChevronUp size={14} /> 上一级
|
||||
</button>
|
||||
)}
|
||||
{data?.path && (
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-1 font-mono text-xs text-slate-300">
|
||||
{data.path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{error && <div className="glass-panel !border-red-400/40 text-red-400">{error}</div>}
|
||||
|
||||
{!loading && data && !data.entries && data.roots && (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.roots.map((r) => (
|
||||
<button
|
||||
key={r.path}
|
||||
onClick={() => setPath(r.path)}
|
||||
className="glass-panel flex items-center gap-3 text-left transition hover:border-primary-400/40"
|
||||
>
|
||||
<FolderOpen size={20} className="text-primary-400" />
|
||||
<div>
|
||||
<p className="font-mono text-sm text-white">{r.label}</p>
|
||||
<p className="font-mono text-xs text-slate-400">{r.path}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data?.entries && data.entries.length > 0 && (
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>大小</th>
|
||||
<th>修改时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.entries.map((e) => (
|
||||
<tr
|
||||
key={e.path}
|
||||
className="cursor-pointer border-t border-white/5 transition hover:bg-white/5"
|
||||
onClick={() => enter(e)}
|
||||
title={e.path}
|
||||
>
|
||||
<td className="flex items-center gap-2 py-2 text-white">
|
||||
{e.is_dir ? (
|
||||
<Folder size={16} className="text-primary-400" />
|
||||
) : (
|
||||
<FileVideo size={16} className="text-slate-400" />
|
||||
)}
|
||||
{e.name}
|
||||
</td>
|
||||
<td className="text-slate-300">{e.is_dir ? '—' : fmtBytes(e.size)}</td>
|
||||
<td className="text-slate-500">
|
||||
{new Date(e.modified * 1000).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data?.entries && data.entries.length === 0 && (
|
||||
<p className="text-slate-400">空目录。</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Clock, Play } from 'lucide-react'
|
||||
|
||||
import { schedulerAPI, type JobStatus } from '../api/scheduler'
|
||||
|
||||
export function SchedulerPage() {
|
||||
const [jobs, setJobs] = useState<JobStatus[]>([])
|
||||
const [running, setRunning] = useState<string>('')
|
||||
|
||||
const refresh = () => schedulerAPI.status().then(setJobs)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
const id = window.setInterval(refresh, 5_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const runNow = async (name: string) => {
|
||||
setRunning(name)
|
||||
try {
|
||||
await schedulerAPI.run(name)
|
||||
toast.success(`${name} 已运行`)
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'运行失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setRunning('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Clock className="h-6 w-6 text-primary-400" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">定时任务</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
后端周期性任务(媒体库扫描、转码缓存清理、回收站自动清理),每 5 秒刷新状态。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2">任务</th>
|
||||
<th>间隔</th>
|
||||
<th>上次运行</th>
|
||||
<th>错误</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<tr key={j.name} className="border-t border-white/5">
|
||||
<td className="py-2 font-mono text-white">{j.name}</td>
|
||||
<td className="text-slate-300">{j.interval}</td>
|
||||
<td className="text-slate-400">
|
||||
{j.last_run && new Date(j.last_run).getFullYear() > 2000
|
||||
? new Date(j.last_run).toLocaleString()
|
||||
: '尚未运行'}
|
||||
</td>
|
||||
<td className="text-red-400">{j.last_err || '—'}</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => runNow(j.name)}
|
||||
disabled={running === j.name}
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
>
|
||||
<Play size={12} className="inline" /> 立即运行
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Database, HardDrive, PieChart } from 'lucide-react'
|
||||
|
||||
import { storageAPI, type StorageBreakdown } from '../api/storage'
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(2)} ${u[i]}`
|
||||
}
|
||||
|
||||
function fmtHours(seconds: number): string {
|
||||
if (!seconds) return '—'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
return `${h.toLocaleString()} h`
|
||||
}
|
||||
|
||||
// StoragePage shows disk usage broken down by library and by container.
|
||||
export function StoragePage() {
|
||||
const [data, setData] = useState<StorageBreakdown | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
storageAPI
|
||||
.breakdown()
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="text-slate-500">加载中…</p>
|
||||
if (!data) return <p className="text-slate-500">无法获取存储数据</p>
|
||||
|
||||
const totalBytes = data.total_bytes || 1
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex items-center gap-3">
|
||||
<HardDrive className="h-6 w-6 text-primary-400" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">存储</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
按媒体库和容器格式统计的磁盘占用,数据来自数据库快照(无须实时扫描磁盘)。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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}`} />
|
||||
<Tile icon={<HardDrive size={20} />} label="累计时长" value={fmtHours(data.total_seconds)} />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-white">按媒体库</h2>
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>类型</th>
|
||||
<th>媒体数</th>
|
||||
<th>占用</th>
|
||||
<th>占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.by_library.map((l) => {
|
||||
const pct = (l.total_bytes / totalBytes) * 100
|
||||
return (
|
||||
<tr key={l.library_id} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{l.name}</td>
|
||||
<td className="text-slate-300">{l.type}</td>
|
||||
<td className="text-slate-300">{l.media_count}</td>
|
||||
<td className="text-slate-300">{fmtBytes(l.total_bytes)}</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 w-24 overflow-hidden rounded bg-white/10">
|
||||
<div
|
||||
className="h-full bg-primary-400"
|
||||
style={{ width: `${pct.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-white">按容器格式</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
{data.by_container.map((c) => (
|
||||
<div
|
||||
key={c.container}
|
||||
className="glass-panel flex items-center justify-between !p-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-slate-500">{c.container}</p>
|
||||
<p className="font-display text-lg font-semibold text-white">{c.count} 项</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300">{fmtBytes(c.bytes)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Tile({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-panel flex items-center gap-3 !p-4">
|
||||
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-primary-400">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-slate-500">{label}</p>
|
||||
<p className="font-display text-lg font-semibold text-white">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user