Files
MeBox/web/src/pages/StoragePage.tsx
T
Kiro 4b747c74ca 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.
2026-05-15 10:58:32 +00:00

142 lines
4.8 KiB
TypeScript

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>
)
}