mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 19:46:38 +08:00
778bf77437
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.
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
// 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))
|
|
}
|