diff --git a/internal/handler/backup.go b/internal/handler/backup.go new file mode 100644 index 0000000..c9c72c0 --- /dev/null +++ b/internal/handler/backup.go @@ -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"}) + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 68fad04..c48537b 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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)) diff --git a/internal/handler/notify.go b/internal/handler/notify.go new file mode 100644 index 0000000..e1928bf --- /dev/null +++ b/internal/handler/notify.go @@ -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"}) + } +} diff --git a/internal/handler/organizer.go b/internal/handler/organizer.go new file mode 100644 index 0000000..d4c7156 --- /dev/null +++ b/internal/handler/organizer.go @@ -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) + } +} diff --git a/internal/service/backup.go b/internal/service/backup.go new file mode 100644 index 0000000..881202c --- /dev/null +++ b/internal/service/backup.go @@ -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 +} diff --git a/internal/service/douban.go b/internal/service/douban.go new file mode 100644 index 0000000..f482dc7 --- /dev/null +++ b/internal/service/douban.go @@ -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) + } +} diff --git a/internal/service/notifier.go b/internal/service/notifier.go new file mode 100644 index 0000000..0c5f086 --- /dev/null +++ b/internal/service/notifier.go @@ -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("%s\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("&", "&", "<", "<", ">", ">") + return r.Replace(s) +} diff --git a/internal/service/organizer.go b/internal/service/organizer.go new file mode 100644 index 0000000..20be901 --- /dev/null +++ b/internal/service/organizer.go @@ -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)) +} diff --git a/internal/service/service.go b/internal/service/service.go index c0b5b3d..7528896 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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, } diff --git a/web/src/App.tsx b/web/src/App.tsx index 0cd0256..c5cbd53 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = () =>

加载中…

@@ -92,6 +98,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } label="搜索" /> } label="收藏" /> } label="播放列表" /> + } label="观看历史" /> + } label="海报墙" />
媒体库 diff --git a/web/src/pages/PosterWallPage.tsx b/web/src/pages/PosterWallPage.tsx new file mode 100644 index 0000000..9d5a9b9 --- /dev/null +++ b/web/src/pages/PosterWallPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + mediaAPI + .search('', 200) + .then((d) => setItems(d.items)) + .finally(() => setLoading(false)) + }, []) + + return ( +
+
+ +
+

海报墙

+

+ 全部媒体的海报展示(最多 200 项)。 +

+
+
+ + {loading &&

加载中…

} + {!loading && items.length === 0 && ( +

暂无媒体。请先添加媒体库并扫描。

+ )} +
+ {items.map((m) => ( + + ))} +
+
+ ) +} diff --git a/web/src/pages/WatchHistoryPage.tsx b/web/src/pages/WatchHistoryPage.tsx new file mode 100644 index 0000000..45848d2 --- /dev/null +++ b/web/src/pages/WatchHistoryPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + playbackAPI + .recentHistory() + .then(setItems) + .finally(() => setLoading(false)) + }, []) + + return ( +
+
+ +

观看历史

+
+ + {loading &&

加载中…

} + {!loading && items.length === 0 && ( +

暂无观看记录。

+ )} + +
+ {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 ( +
+
+ {m.poster_url ? ( + {m.title} + ) : null} +
+
+ + {m.title} + +
+ {fmtDuration(h.position_ms)} / {fmtDuration(h.duration_ms)} + {new Date(h.watched_at).toLocaleString()} + {h.completed && ( + + 已看完 + + )} +
+
+
+
+
+ + 继续 + +
+ ) + })} +
+
+ ) +}