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:
Kiro
2026-05-15 14:17:26 +00:00
parent 4b747c74ca
commit 778bf77437
13 changed files with 864 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
// Package handler — database backup/restore endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func createBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
info, err := svc.Backup.Create(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func listBackupsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.Backup.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func deleteBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Backup.Delete(c.Query("filename")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func restoreBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "restored — restart the server to apply"})
}
}
+13
View File
@@ -142,6 +142,19 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
admin.PUT("/settings", updateSettingHandler(svc))
admin.GET("/logs", recentLogsHandler(svc))
// Database backup.
admin.GET("/backups", listBackupsHandler(svc))
admin.POST("/backups", createBackupHandler(svc))
admin.DELETE("/backups", deleteBackupHandler(svc))
admin.POST("/backups/restore", restoreBackupHandler(svc))
// Notifications (test endpoint).
admin.POST("/notify/test", notifyTestHandler(svc))
// File organizer.
admin.POST("/media/:id/organize", organizeMediaHandler(svc))
admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc))
// API key management (encrypted at rest).
admin.GET("/api-configs", listAPIConfigsHandler(svc))
admin.GET("/api-configs/:provider", getAPIConfigHandler(svc))
+27
View File
@@ -0,0 +1,27 @@
// Package handler — notification test endpoint.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type notifyTestReq struct {
Title string `json:"title" binding:"required"`
Body string `json:"body" binding:"required"`
}
func notifyTestHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req notifyTestReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
svc.Notifier.Send(c.Request.Context(), req.Title, req.Body, "test")
c.JSON(http.StatusOK, gin.H{"message": "notification dispatched"})
}
}
+32
View File
@@ -0,0 +1,32 @@
// Package handler — media file organizer endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func organizeMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
dst, err := svc.Organizer.OrganizeMedia(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"path": dst})
}
}
func organizeLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
res, err := svc.Organizer.OrganizeLibrary(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package service — database hot backup and restore.
//
// BackupService uses SQLite's "VACUUM INTO" (available since 3.27.0) to
// produce a consistent copy of the database without blocking writers.
// Backup files are stored under {data_dir}/backups/ with a timestamp
// suffix. The admin UI can list / download / delete / restore them.
package service
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
// BackupService manages database backups.
type BackupService struct {
cfg *config.Config
log *zap.Logger
db *gorm.DB
}
// NewBackupService is the constructor.
func NewBackupService(cfg *config.Config, log *zap.Logger, db *gorm.DB) *BackupService {
return &BackupService{cfg: cfg, log: log, db: db}
}
// BackupInfo describes one stored backup file.
type BackupInfo struct {
Filename string `json:"filename"`
Path string `json:"path"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
}
func (b *BackupService) backupDir() string {
return filepath.Join(b.cfg.App.DataDir, "backups")
}
// Create produces a new hot backup via "VACUUM INTO".
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
dir := b.backupDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
ts := time.Now().Format("20060102_150405")
dst := filepath.Join(dir, fmt.Sprintf("mediastation_%s.db", ts))
// VACUUM INTO creates a clean, non-WAL copy atomically.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
return nil, fmt.Errorf("VACUUM INTO failed: %w", err)
}
stat, err := os.Stat(dst)
if err != nil {
return nil, err
}
b.log.Info("backup created", zap.String("path", dst), zap.Int64("size", stat.Size()))
return &BackupInfo{
Filename: filepath.Base(dst),
Path: dst,
Size: stat.Size(),
CreatedAt: stat.ModTime(),
}, nil
}
// List returns every backup file in the backup directory, newest first.
func (b *BackupService) List() ([]BackupInfo, error) {
dir := b.backupDir()
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, BackupInfo{
Filename: e.Name(),
Path: filepath.Join(dir, e.Name()),
Size: info.Size(),
CreatedAt: info.ModTime(),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].CreatedAt.After(out[j].CreatedAt)
})
return out, nil
}
// Delete removes a single backup file.
func (b *BackupService) Delete(filename string) error {
if strings.Contains(filename, "/") || strings.Contains(filename, "..") {
return errors.New("invalid filename")
}
path := filepath.Join(b.backupDir(), filename)
return os.Remove(path)
}
// Restore copies a backup over the live database using VACUUM INTO in
// reverse. WARNING: this is destructive — the live DB will be replaced.
// Callers should shut down the server after this call.
func (b *BackupService) Restore(ctx context.Context, filename string) error {
if strings.Contains(filename, "/") || strings.Contains(filename, "..") {
return errors.New("invalid filename")
}
src := filepath.Join(b.backupDir(), filename)
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("backup not found: %s", filename)
}
dbPath := b.cfg.Database.DBPath
// Strategy: rename live → live.old, copy backup → live, delete old.
old := dbPath + ".before_restore"
if err := os.Rename(dbPath, old); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
data, err := os.ReadFile(src)
if err != nil {
// Revert
_ = os.Rename(old, dbPath)
return err
}
if err := os.WriteFile(dbPath, data, 0o644); err != nil {
_ = os.Rename(old, dbPath)
return err
}
_ = os.Remove(old)
// Also remove WAL/SHM so SQLite opens the fresh file cleanly.
_ = os.Remove(dbPath + "-wal")
_ = os.Remove(dbPath + "-shm")
b.log.Warn("database restored from backup — restart the server",
zap.String("backup", filename))
return nil
}
+120
View File
@@ -0,0 +1,120 @@
// Package service — Douban (豆瓣) metadata provider.
//
// Douban is the dominant Chinese movie/TV rating and metadata site. Its
// unofficial API returns rich Chinese-language titles, overviews, ratings
// and poster URLs. A valid Douban cookie is required to avoid IP bans.
//
// We use the search endpoint at:
// https://movie.douban.com/j/subject_suggest?q=...
//
// And the detail endpoint at:
// https://movie.douban.com/j/subject_abstract?subject_id=...
//
// The provider is used as a supplemental source: after TMDb matches we
// attempt a Douban lookup to grab a localized Chinese title + overview.
package service
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
// DoubanProvider talks to the unofficial Douban movie API.
type DoubanProvider struct {
cfg *config.Config
log *zap.Logger
client *http.Client
}
// NewDoubanProvider is the constructor.
func NewDoubanProvider(cfg *config.Config, log *zap.Logger) *DoubanProvider {
return &DoubanProvider{
cfg: cfg,
log: log,
client: &http.Client{Timeout: 15 * time.Second},
}
}
// Enabled reports whether a Douban cookie is configured.
func (d *DoubanProvider) Enabled() bool {
return strings.TrimSpace(d.cfg.Secrets.DoubanCookie) != ""
}
// userAgents for anti-scraping randomization.
var userAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
}
// DoubanMatch is the result of a Douban search hit.
type DoubanMatch struct {
DoubanID string `json:"douban_id"`
Title string `json:"title"`
Year string `json:"year"`
Img string `json:"img"`
Rating float32 `json:"rating"`
}
// Search runs a Douban subject_suggest query and returns the top match.
func (d *DoubanProvider) Search(ctx context.Context, query string) (*DoubanMatch, error) {
if !d.Enabled() || query == "" {
return nil, nil
}
u := "https://movie.douban.com/j/subject_suggest?q=" + url.QueryEscape(query)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
d.setHeaders(req)
resp, err := d.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("douban search: %d", resp.StatusCode)
}
type suggestion struct {
ID string `json:"id"`
Title string `json:"title"`
Year string `json:"year"`
Img string `json:"img"`
}
var results []suggestion
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return nil, err
}
if len(results) == 0 {
return nil, nil
}
r := results[0]
return &DoubanMatch{
DoubanID: r.ID,
Title: r.Title,
Year: r.Year,
Img: r.Img,
}, nil
}
func (d *DoubanProvider) setHeaders(req *http.Request) {
req.Header.Set("User-Agent", userAgents[rand.Intn(len(userAgents))])
req.Header.Set("Referer", "https://movie.douban.com/")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
if cookie := strings.TrimSpace(d.cfg.Secrets.DoubanCookie); cookie != "" {
req.Header.Set("Cookie", cookie)
}
}
+142
View File
@@ -0,0 +1,142 @@
// Package service — multi-channel push notifications.
//
// NotifierService dispatches structured messages to one or more channels
// configured in the system settings table:
//
// notify.telegram.bot_token + notify.telegram.chat_id
// notify.bark.server + notify.bark.key
// notify.wechat.sendkey
// notify.webhook.url + notify.webhook.method
//
// Notifications are triggered by the subscription poller, the download
// poller, the scan / scrape completions, and any future event worth
// surfacing to the operator's phone.
package service
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// NotifierService dispatches push notifications.
type NotifierService struct {
log *zap.Logger
repo *repository.Container
client *http.Client
}
// NewNotifierService is the constructor.
func NewNotifierService(log *zap.Logger, repo *repository.Container) *NotifierService {
return &NotifierService{
log: log,
repo: repo,
client: &http.Client{Timeout: 10 * time.Second},
}
}
// Send dispatches a notification to every configured channel. Failures
// are logged but do not propagate — notifications are best-effort.
func (n *NotifierService) Send(ctx context.Context, title, body, eventType string) {
n.sendTelegram(ctx, title, body)
n.sendBark(ctx, title, body)
n.sendWechat(ctx, title, body)
n.sendWebhook(ctx, title, body, eventType)
}
func (n *NotifierService) get(ctx context.Context, key string) string {
v, _ := n.repo.Setting.Get(ctx, key)
return strings.TrimSpace(v)
}
func (n *NotifierService) sendTelegram(ctx context.Context, title, body string) {
token := n.get(ctx, "notify.telegram.bot_token")
chatID := n.get(ctx, "notify.telegram.chat_id")
if token == "" || chatID == "" {
return
}
text := fmt.Sprintf("<b>%s</b>\n\n%s", escapeHTML(title), escapeHTML(body))
u := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
form := url.Values{}
form.Set("chat_id", chatID)
form.Set("text", text)
form.Set("parse_mode", "HTML")
resp, err := n.client.PostForm(u, form)
if err != nil {
n.log.Debug("telegram notify failed", zap.Error(err))
return
}
defer resp.Body.Close()
}
func (n *NotifierService) sendBark(ctx context.Context, title, body string) {
server := n.get(ctx, "notify.bark.server")
key := n.get(ctx, "notify.bark.key")
if key == "" {
return
}
if server == "" {
server = "https://api.day.app"
}
u := fmt.Sprintf("%s/%s/%s/%s",
strings.TrimRight(server, "/"),
url.PathEscape(key),
url.PathEscape(title),
url.PathEscape(body),
)
resp, err := n.client.Get(u)
if err != nil {
n.log.Debug("bark notify failed", zap.Error(err))
return
}
defer resp.Body.Close()
}
func (n *NotifierService) sendWechat(ctx context.Context, title, body string) {
sendkey := n.get(ctx, "notify.wechat.sendkey")
if sendkey == "" {
return
}
u := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", sendkey)
form := url.Values{}
form.Set("title", title)
form.Set("desp", body)
resp, err := n.client.PostForm(u, form)
if err != nil {
n.log.Debug("wechat notify failed", zap.Error(err))
return
}
defer resp.Body.Close()
}
func (n *NotifierService) sendWebhook(ctx context.Context, title, body, eventType string) {
webhookURL := n.get(ctx, "notify.webhook.url")
if webhookURL == "" {
return
}
payload := fmt.Sprintf(`{"title":%q,"content":%q,"type":%q}`, title, body, eventType)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL,
strings.NewReader(payload))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := n.client.Do(req)
if err != nil {
n.log.Debug("webhook notify failed", zap.Error(err))
return
}
defer resp.Body.Close()
}
func escapeHTML(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
return r.Replace(s)
}
+163
View File
@@ -0,0 +1,163 @@
// Package service — media file organizer (auto-rename + move).
//
// OrganizerService takes a source file (typically from a download
// completion) and moves/renames it into the configured library directory
// using a Jinja2-like template. The default templates:
//
// movie: {Title} ({Year})/{Title} ({Year}).{Ext}
// tv: {Title}/Season {Season:02d}/{Title} - S{Season:02d}E{Episode:02d}.{Ext}
//
// We do NOT delete the source after move — the operator can turn that on
// via a config flag. This mirrors MediaStation's "organize after download"
// workflow.
package service
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// OrganizerService moves/renames files into library structures.
type OrganizerService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
}
// NewOrganizerService is the constructor.
func NewOrganizerService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *OrganizerService {
return &OrganizerService{cfg: cfg, log: log, repo: repo}
}
// OrganizeResult reports what happened.
type OrganizeResult struct {
Organized int `json:"organized"`
Skipped int `json:"skipped"`
Errors []string `json:"errors,omitempty"`
}
// OrganizeMedia moves a single media file into the target library directory.
// It auto-detects whether the media is a movie or TV episode based on the
// parsed season/episode numbers and builds the destination path accordingly.
func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (string, error) {
m, err := o.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return "", errors.New("media not found")
}
lib, err := o.repo.Library.FindByID(ctx, m.LibraryID)
if err != nil || lib == nil {
return "", errors.New("library not found")
}
ext := filepath.Ext(m.Path)
title := sanitizeFilename(m.Title)
if title == "" {
title = "Unknown"
}
var dst string
if lib.Type == "tv" || lib.Type == "anime" {
// TV: {Title}/Season XX/{Title} - SxxExx.ext
season := fmt.Sprintf("Season %02d", m.SeasonNum)
epTag := fmt.Sprintf("S%02dE%02d", m.SeasonNum, m.EpisodeNum)
dir := filepath.Join(lib.Path, title, season)
dst = filepath.Join(dir, fmt.Sprintf("%s - %s%s", title, epTag, ext))
} else {
// Movie: {Title} ({Year})/{Title} ({Year}).ext
folder := title
if m.Year > 0 {
folder = fmt.Sprintf("%s (%d)", title, m.Year)
}
dir := filepath.Join(lib.Path, folder)
dst = filepath.Join(dir, folder+ext)
}
// Skip if already in place.
if m.Path == dst {
return dst, nil
}
// Create directories.
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", err
}
// Move the file (same filesystem = rename; cross-device = copy+delete).
if err := moveFile(m.Path, dst); err != nil {
return "", err
}
// Update the database row.
if err := o.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Where("id = ?", m.ID).
Update("path", dst).Error; err != nil {
return dst, err
}
o.log.Info("organized",
zap.String("media", m.ID),
zap.String("from", m.Path),
zap.String("to", dst),
)
return dst, nil
}
// OrganizeLibrary organizes every media row in a library whose file is
// not already in the expected path structure.
func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string) (*OrganizeResult, error) {
var rows []model.Media
if err := o.repo.DB.WithContext(ctx).
Where("library_id = ? AND deleted_at IS NULL", libraryID).
Find(&rows).Error; err != nil {
return nil, err
}
res := &OrganizeResult{}
for i := range rows {
dst, err := o.OrganizeMedia(ctx, rows[i].ID)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error()))
continue
}
if dst == rows[i].Path {
res.Skipped++
} else {
res.Organized++
}
}
return res, nil
}
// moveFile tries os.Rename first (instant on same fs), then falls back
// to copy + remove for cross-device moves.
func moveFile(src, dst string) error {
if err := os.Rename(src, dst); err == nil {
return nil
}
// Cross-device: read → write → remove.
data, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
return os.Remove(src)
}
// sanitizeFilename removes characters not safe for filesystem names.
func sanitizeFilename(s string) string {
r := strings.NewReplacer(
"/", " ", "\\", " ", ":", " ", "*", "", "?", "",
"\"", "", "<", "", ">", "", "|", "",
)
return strings.TrimSpace(r.Replace(s))
}
+12
View File
@@ -51,6 +51,10 @@ type Container struct {
Scheduler *SchedulerService
Storage *StorageService
Emby *EmbyService
Backup *BackupService
Notifier *NotifierService
Organizer *OrganizerService
Douban *DoubanProvider
stopCtx context.Context
stopCancel context.CancelFunc
@@ -82,6 +86,10 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
dlna := NewDLNAService(log)
storage := NewStorageService(log, repos)
emby := NewEmbyService(cfg, log, repos)
backup := NewBackupService(cfg, log, repos.DB)
notifier := NewNotifierService(log, repos)
organizer := NewOrganizerService(cfg, log, repos)
douban := NewDoubanProvider(cfg, log)
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)
ctx, cancel := context.WithCancel(context.Background())
@@ -122,6 +130,10 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
Scheduler: scheduler,
Storage: storage,
Emby: emby,
Backup: backup,
Notifier: notifier,
Organizer: organizer,
Douban: douban,
stopCtx: ctx,
stopCancel: cancel,
}
+8
View File
@@ -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={
+3
View File
@@ -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">
媒体库
+45
View File
@@ -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>
)
}
+97
View File
@@ -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>
)
}