feat: bootstrap MediaStationGo (Go + React rewrite of MediaStation)

Adopt the cropflre/nowen-video tech stack and rebuild the project from
scratch:

  - Backend: Go 1.25 + Gin + GORM + SQLite (WAL) + JWT + WebSocket hub.
    Layered packages config / database / model / repository / service /
    middleware / handler. Default admin (admin/admin123) seeded on first
    run; /api routes for auth, libraries, media, stream and admin
    panels. WebSocket scan-progress events at /api/ws.
  - Frontend: React 18 + Vite 5 + Tailwind 3.4 + Zustand + axios +
    react-router 6 + lucide-react + framer-motion + hls.js. Pages for
    Login / Home / Library / Search / MediaDetail / Player / Admin
    (Library, Users, Settings tabs).
  - Distribution: multi-arch Dockerfile (frontend -> backend -> Alpine
    runtime), docker-compose.yml, GitHub Actions for CI and GHCR
    publish, Makefile, env-prefixed config (MEDIASTATION_*).
  - Docs: README, CONTRIBUTING, .env.example, config.example.yaml.

Backend builds, vets and tests pass. Frontend builds via tsc -b && vite
build (250 kB JS / 16 kB CSS, gzipped 84 / 4 kB).
This commit is contained in:
Kiro
2026-05-14 15:26:29 +00:00
commit d5cf5fb4b2
60 changed files with 7592 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Trash2 } from 'lucide-react'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, User } from '../types'
// Single-page admin console. We split the surface into three panels:
// • Libraries — create / delete / scan
// • Users — list / delete (cannot delete self)
// • Settings — generic key/value store viewer
//
// Each panel manages its own loading / refresh state.
export function AdminPage() {
const [tab, setTab] = useState<'library' | 'users' | 'settings'>('library')
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">管理后台</h1>
<div className="flex gap-2 border-b border-white/10">
{(['library', 'users', 'settings'] as const).map((k) => (
<button
key={k}
onClick={() => setTab(k)}
className={
'border-b-2 px-4 py-2 text-sm transition ' +
(tab === k
? 'border-primary-400 text-primary-400'
: 'border-transparent text-slate-400 hover:text-white')
}
>
{k === 'library' ? '媒体库' : k === 'users' ? '用户' : '设置'}
</button>
))}
</div>
{tab === 'library' && <LibraryPanel />}
{tab === 'users' && <UsersPanel />}
{tab === 'settings' && <SettingsPanel />}
</div>
)
}
function LibraryPanel() {
const [libs, setLibs] = useState<Library[]>([])
const [name, setName] = useState('')
const [path, setPath] = useState('')
const [type, setType] = useState('movie')
const refresh = () => libraryAPI.list().then(setLibs)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await libraryAPI.create(name, path, type)
toast.success('媒体库已创建')
setName('')
setPath('')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'创建失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
<input
required
className="input-base"
placeholder="名称"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
required
className="input-base md:col-span-2"
placeholder="服务器路径,如 /media/movies"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="anime">动漫</option>
<option value="music">音乐</option>
</select>
<button type="submit" className="neon-button md:col-span-4">
新建媒体库
</button>
</form>
<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 className="text-right">操作</th>
</tr>
</thead>
<tbody>
{libs.map((l) => (
<tr key={l.id} className="border-t border-white/5">
<td className="py-2 text-white">{l.name}</td>
<td className="text-slate-300">{l.path}</td>
<td className="text-slate-300">{l.type}</td>
<td className="space-x-2 py-2 text-right">
<button
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
onClick={async () => {
const r = await libraryAPI.scan(l.id)
toast.success(`扫描完成,新增 ${r.added}`)
}}
>
扫描
</button>
<button
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`确定删除「${l.name}」?`)) return
await libraryAPI.remove(l.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function UsersPanel() {
const [users, setUsers] = useState<User[]>([])
const refresh = () => adminAPI.listUsers().then(setUsers)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
return (
<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 className="text-right">操作</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-white/5">
<td className="py-2 text-white">{u.username}</td>
<td className="text-slate-300">{u.role}</td>
<td className="text-slate-400">
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}
</td>
<td className="py-2 text-right">
<button
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`确定删除「${u.username}」?`)) return
await adminAPI.deleteUser(u.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function SettingsPanel() {
const [items, setItems] = useState<{ key: string; value: string }[]>([])
const [k, setK] = useState('')
const [v, setV] = useState('')
const refresh = () => adminAPI.listSettings().then(setItems)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const onSave = async (e: FormEvent) => {
e.preventDefault()
await adminAPI.updateSetting(k, v)
toast.success('已保存')
setK('')
setV('')
await refresh()
}
return (
<div className="space-y-4">
<form onSubmit={onSave} className="glass-panel grid gap-3 md:grid-cols-3">
<input
required
className="input-base"
placeholder="键 (如 tmdb_api_key)"
value={k}
onChange={(e) => setK(e.target.value)}
/>
<input
required
className="input-base"
placeholder="值"
value={v}
onChange={(e) => setV(e.target.value)}
/>
<button type="submit" className="neon-button">
保存
</button>
</form>
<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>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.key} className="border-t border-white/5">
<td className="py-2 text-white">{it.key}</td>
<td className="text-slate-300">{it.value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react'
import { mediaAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
// Landing page: shows the most recently added media items across every
// library. Once the scraper / continue-watching subsystems land we will
// fold in additional rows here.
export function HomePage() {
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
mediaAPI
.search('', 24)
.then((d) => setItems(d.items))
.finally(() => setLoading(false))
}, [])
return (
<div className="space-y-8">
<header>
<h1 className="font-display text-3xl font-bold text-white">最近添加</h1>
<p className="text-sm text-slate-400">最新被扫描入库的媒体内容</p>
</header>
{loading && <p className="text-slate-500">加载中…</p>}
{!loading && items.length === 0 && (
<div className="glass-panel">
<p className="text-slate-300">
还没有任何媒体。前往 <span className="text-primary-400">管理后台</span>{' '}
创建媒体库,然后触发一次扫描。
</p>
</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>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
import type { Media } from '../types'
import { MediaCard } from '../components/MediaCard'
import { useAuthStore } from '../stores/auth'
// Browse a single library. Admins also get a "scan" button that triggers
// a fresh scan run on the backend.
export function LibraryPage() {
const { id = '' } = useParams()
const role = useAuthStore((s) => s.user?.role)
const [items, setItems] = useState<Media[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [scanning, setScanning] = useState(false)
useEffect(() => {
if (!id) return
setLoading(true)
libraryAPI
.listMedia(id)
.then((d) => {
setItems(d.items)
setTotal(d.total)
})
.finally(() => setLoading(false))
}, [id])
const handleScan = async () => {
setScanning(true)
try {
const r = await libraryAPI.scan(id)
toast.success(`扫描完成:新增 ${r.added} 个文件`)
const d = await libraryAPI.listMedia(id)
setItems(d.items)
setTotal(d.total)
} catch {
toast.error('扫描失败')
} finally {
setScanning(false)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="font-display text-3xl font-bold text-white">
媒体库 <span className="text-slate-500">({total})</span>
</h1>
{role === 'admin' && (
<button onClick={handleScan} disabled={scanning} className="neon-button">
{scanning ? '扫描中…' : '立即扫描'}
</button>
)}
</div>
{loading && <p className="text-slate-500">加载中…</p>}
<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>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { FormEvent, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import { Film } from 'lucide-react'
import { authAPI } from '../api/auth'
import { useAuthStore } from '../stores/auth'
// Single-form login screen. The first install seeds admin/admin123 — we
// surface that hint when no JWT exists yet.
export function LoginPage() {
const navigate = useNavigate()
const setSession = useAuthStore((s) => s.setSession)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const data = await authAPI.login(username, password)
setSession(data.token, data.user)
toast.success(`欢迎回来,${data.user.username}`)
navigate('/')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'登录失败,请检查用户名与密码'
toast.error(msg)
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-full items-center justify-center px-4 py-10">
<form onSubmit={handleSubmit} className="glass-panel w-full max-w-md">
<div className="mb-8 flex flex-col items-center gap-2">
<Film className="h-10 w-10 text-primary-400" />
<h1 className="font-display text-2xl font-bold tracking-wide text-white">
MediaStationGo
</h1>
<p className="text-sm text-slate-400">登录到你的家庭媒体中心</p>
</div>
<label className="mb-4 block">
<span className="mb-1 block text-sm text-slate-300">用户名</span>
<input
className="input-base"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label className="mb-6 block">
<span className="mb-1 block text-sm text-slate-300">密码</span>
<input
type="password"
className="input-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<button
type="submit"
disabled={loading}
className="neon-button w-full justify-center"
>
{loading ? '登录中…' : '登录'}
</button>
<p className="mt-6 text-center text-xs text-slate-500">
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
</p>
</form>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Play } from 'lucide-react'
import { mediaAPI } from '../api/library'
import type { Media } from '../types'
function fmtDuration(sec: number): string {
if (!sec || sec <= 0) return '—'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function fmtSize(bytes: number): string {
if (!bytes || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(2)} ${units[i]}`
}
// Detail screen for a single media item: poster, summary and a Play CTA.
export function MediaDetailPage() {
const { id = '' } = useParams()
const [media, setMedia] = useState<Media | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id) return
setLoading(true)
mediaAPI
.get(id)
.then(setMedia)
.finally(() => setLoading(false))
}, [id])
if (loading) return <p className="text-slate-500">加载中…</p>
if (!media) return <p className="text-slate-500">媒体不存在</p>
return (
<div className="space-y-8">
<div className="flex flex-col gap-6 md:flex-row">
<div className="aspect-[2/3] w-48 shrink-0 overflow-hidden rounded-xl bg-surface-800">
{media.poster_url ? (
<img src={media.poster_url} alt={media.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-slate-600">
无海报
</div>
)}
</div>
<div className="flex-1 space-y-4">
<h1 className="font-display text-3xl font-bold text-white">{media.title}</h1>
{media.year > 0 && <p className="text-slate-400">{media.year}</p>}
{media.overview && <p className="text-slate-300">{media.overview}</p>}
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
{media.video_codec && <Badge>{media.video_codec.toUpperCase()}</Badge>}
{media.width > 0 && (
<Badge>
{media.width}×{media.height}
</Badge>
)}
<Badge>{fmtDuration(media.duration_sec)}</Badge>
<Badge>{fmtSize(media.size_bytes)}</Badge>
{media.container && <Badge>{media.container.toUpperCase()}</Badge>}
</div>
<Link to={`/play/${media.id}`} className="neon-button">
<Play size={18} /> 播放
</Link>
</div>
</div>
</div>
)
}
function Badge({ children }: { children: React.ReactNode }) {
return (
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5">{children}</span>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { mediaAPI } from '../api/library'
import { streamURL } from '../api/client'
import type { Media } from '../types'
// Fullscreen, dark-themed video page. We currently use direct play via
// /api/stream/:id — the future task is wiring HLS.js for transcoded streams.
export function PlayerPage() {
const { id = '' } = useParams()
const navigate = useNavigate()
const ref = useRef<HTMLVideoElement>(null)
const [media, setMedia] = useState<Media | null>(null)
useEffect(() => {
if (!id) return
mediaAPI.get(id).then(setMedia)
}, [id])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') navigate(-1)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [navigate])
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-white backdrop-blur transition hover:bg-black/80"
>
<ArrowLeft size={16} /> 返回
</button>
<div className="flex flex-1 items-center justify-center">
{media ? (
<video
ref={ref}
src={streamURL(media.id)}
controls
autoPlay
className="max-h-screen w-full max-w-[1600px] bg-black"
/>
) : (
<p className="text-slate-500">加载中…</p>
)}
</div>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { ChangeEvent, useEffect, useState } from 'react'
import { mediaAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
// Search-as-you-type with a 300ms debounce.
export function SearchPage() {
const [q, setQ] = useState('')
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
const t = setTimeout(() => {
setLoading(true)
mediaAPI
.search(q, 60)
.then((d) => setItems(d.items))
.finally(() => setLoading(false))
}, 300)
return () => clearTimeout(t)
}, [q])
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">搜索</h1>
<input
autoFocus
className="input-base"
placeholder="按标题搜索…"
value={q}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
/>
{loading && <p className="text-slate-500">搜索中…</p>}
<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>
</div>
)
}