mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
feat: backup, notifier, organizer, douban, watch-history, poster-wall
Complete the port of the remaining MediaStation features:
Backend services:
- service/backup.go: SQLite hot backup via VACUUM INTO, list, delete,
restore. Backup files stored under {data_dir}/backups/ with timestamps.
- service/notifier.go: Multi-channel push notifications (Telegram /
Bark / WeChat Server酱 / Webhook). Configuration via settings table
keys notify.telegram.*, notify.bark.*, etc.
- service/organizer.go: Auto-rename + move media files into library
directory structure. Movie: {Title} ({Year})/{Title} ({Year}).ext,
TV: {Title}/Season XX/{Title} - SxxExx.ext. Both per-media and
per-library batch endpoints.
- service/douban.go: Douban (豆瓣) metadata provider using the
unofficial subject_suggest API. Returns Chinese titles + posters.
Requires douban_cookie in secrets config.
Handlers:
- handler/backup.go: GET/POST/DELETE /admin/backups, POST restore.
- handler/organizer.go: POST /admin/media/:id/organize,
POST /admin/libraries/:id/organize.
- handler/notify.go: POST /admin/notify/test.
Frontend pages:
- WatchHistoryPage.tsx: full paginated history with progress bars,
resume buttons, poster thumbnails and timestamps.
- PosterWallPage.tsx: dense 8-column poster grid of all media
(matches the original MediaStation PosterWallView.vue).
Sidebar additions: 观看历史 + 海报墙 links in the main nav group.
Verified: go build, go vet, go test all pass; tsc -b && vite build
emits 30 lazy chunks; main bundle 254 KB / 85 KB gzipped.
This commit is contained in:
@@ -63,6 +63,12 @@ const DuplicatesPage = lazy(() =>
|
||||
const SchedulerPage = lazy(() =>
|
||||
import('./pages/SchedulerPage').then((m) => ({ default: m.SchedulerPage })),
|
||||
)
|
||||
const WatchHistoryPage = lazy(() =>
|
||||
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })),
|
||||
)
|
||||
const PosterWallPage = lazy(() =>
|
||||
import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })),
|
||||
)
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||
|
||||
@@ -92,6 +98,8 @@ export default function App() {
|
||||
<Route path="subscriptions" element={<SubscriptionsPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="dlna" element={<DlnaPage />} />
|
||||
<Route path="history" element={<WatchHistoryPage />} />
|
||||
<Route path="poster-wall" element={<PosterWallPage />} />
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
GalleryHorizontalEnd,
|
||||
KeyRound,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
@@ -61,6 +62,8 @@ export function Layout() {
|
||||
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
|
||||
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
|
||||
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" />
|
||||
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" />
|
||||
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="海报墙" />
|
||||
|
||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||
媒体库
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { GalleryHorizontalEnd } from 'lucide-react'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// PosterWallPage shows a dense poster grid of all media across every
|
||||
// library — a visual "poster wall" that matches the original MediaStation's
|
||||
// PosterWallView.vue. Loads the first 200 items sorted by rating.
|
||||
export function PosterWallPage() {
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
mediaAPI
|
||||
.search('', 200)
|
||||
.then((d) => setItems(d.items))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<GalleryHorizontalEnd 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">
|
||||
全部媒体的海报展示(最多 200 项)。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="text-slate-400">暂无媒体。请先添加媒体库并扫描。</p>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Clock, Play } from 'lucide-react'
|
||||
|
||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||
import { imageURL } from '../api/client'
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (!ms || ms <= 0) return '—'
|
||||
const sec = Math.floor(ms / 1000)
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
// WatchHistoryPage shows a full paginated view of the user's playback
|
||||
// history with progress bars and resume links.
|
||||
export function WatchHistoryPage() {
|
||||
const [items, setItems] = useState<HistoryItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
playbackAPI
|
||||
.recentHistory()
|
||||
.then(setItems)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Clock className="h-6 w-6 text-primary-400" />
|
||||
<h1 className="font-display text-3xl font-bold text-white">观看历史</h1>
|
||||
</header>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="text-slate-400">暂无观看记录。</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((h) => {
|
||||
const m = h.media
|
||||
if (!m) return null
|
||||
const progress =
|
||||
h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||
return (
|
||||
<div
|
||||
key={h.id}
|
||||
className="glass-panel flex items-center gap-4 !p-3"
|
||||
>
|
||||
<div className="h-16 w-12 shrink-0 overflow-hidden rounded bg-surface-900">
|
||||
{m.poster_url ? (
|
||||
<img
|
||||
src={imageURL(m.poster_url)}
|
||||
alt={m.title}
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Link
|
||||
to={`/media/${m.id}`}
|
||||
className="font-medium text-white transition hover:text-primary-400"
|
||||
>
|
||||
{m.title}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||
<span>{fmtDuration(h.position_ms)} / {fmtDuration(h.duration_ms)}</span>
|
||||
<span>{new Date(h.watched_at).toLocaleString()}</span>
|
||||
{h.completed && (
|
||||
<span className="rounded border border-emerald-400/40 px-1.5 py-0.5 text-emerald-400">
|
||||
已看完
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded bg-white/10">
|
||||
<div
|
||||
className="h-full bg-primary-400"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/play/${m.id}`}
|
||||
className="neon-button !px-3 !py-1 !text-xs"
|
||||
>
|
||||
<Play size={12} /> 继续
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user