mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +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:
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&", "<", "<", ">", ">")
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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