mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 20:46:37 +08:00
feat: backup, notifier, organizer, douban, watch-history, poster-wall
Complete the port of the remaining MediaStation features:
Backend services:
- service/backup.go: SQLite hot backup via VACUUM INTO, list, delete,
restore. Backup files stored under {data_dir}/backups/ with timestamps.
- service/notifier.go: Multi-channel push notifications (Telegram /
Bark / WeChat Server酱 / Webhook). Configuration via settings table
keys notify.telegram.*, notify.bark.*, etc.
- service/organizer.go: Auto-rename + move media files into library
directory structure. Movie: {Title} ({Year})/{Title} ({Year}).ext,
TV: {Title}/Season XX/{Title} - SxxExx.ext. Both per-media and
per-library batch endpoints.
- service/douban.go: Douban (豆瓣) metadata provider using the
unofficial subject_suggest API. Returns Chinese titles + posters.
Requires douban_cookie in secrets config.
Handlers:
- handler/backup.go: GET/POST/DELETE /admin/backups, POST restore.
- handler/organizer.go: POST /admin/media/:id/organize,
POST /admin/libraries/:id/organize.
- handler/notify.go: POST /admin/notify/test.
Frontend pages:
- WatchHistoryPage.tsx: full paginated history with progress bars,
resume buttons, poster thumbnails and timestamps.
- PosterWallPage.tsx: dense 8-column poster grid of all media
(matches the original MediaStation PosterWallView.vue).
Sidebar additions: 观看历史 + 海报墙 links in the main nav group.
Verified: go build, go vet, go test all pass; tsc -b && vite build
emits 30 lazy chunks; main bundle 254 KB / 85 KB gzipped.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user