mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
fix: avoid repeated category organization
This commit is contained in:
@@ -287,7 +287,7 @@ vim docker-compose.yml
|
||||
#
|
||||
# 镜像版本:
|
||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.6
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7
|
||||
#
|
||||
# 路径映射总览:
|
||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||
@@ -470,7 +470,7 @@ docker compose up -d
|
||||
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.6
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7
|
||||
MEDIASTATION_HTTP_PORT=18080
|
||||
MEDIASTATION_MEDIA_DIR=/mnt/nas/media
|
||||
MEDIASTATION_DOWNLOAD_DIR=/mnt/nas/downloads
|
||||
@@ -650,26 +650,26 @@ cd MediaStationGo
|
||||
|
||||
| 平台 | 包名示例 |
|
||||
| --- | --- |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.6-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.6-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.6-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.6-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.6-darwin-arm64.tar.gz` |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.7-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.7-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.7-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.7-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.7-darwin-arm64.tar.gz` |
|
||||
|
||||
部署步骤:
|
||||
|
||||
```bash
|
||||
# Linux 示例
|
||||
tar -xzf MediaStationGo-v0.0.6-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.6-linux-amd64
|
||||
tar -xzf MediaStationGo-v0.0.7-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.7-linux-amd64
|
||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
Expand-Archive .\MediaStationGo-v0.0.6-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.6-windows-amd64
|
||||
Expand-Archive .\MediaStationGo-v0.0.7-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.7-windows-amd64
|
||||
$env:MEDIASTATION_APP_PORT = "18080"
|
||||
.\mediastation-go.exe
|
||||
```
|
||||
|
||||
+10
-10
@@ -320,7 +320,7 @@ For production, pin a specific release tag instead of using `latest`:
|
||||
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.6
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7
|
||||
MEDIASTATION_HTTP_PORT=18080
|
||||
MEDIASTATION_MEDIA_DIR=/mnt/nas/media
|
||||
MEDIASTATION_DOWNLOAD_DIR=/mnt/nas/downloads
|
||||
@@ -500,25 +500,25 @@ Each release provides multi-platform archives:
|
||||
|
||||
| Platform | Package example |
|
||||
| --- | --- |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.6-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.6-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.6-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.6-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.6-darwin-arm64.tar.gz` |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.7-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.7-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.7-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.7-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.7-darwin-arm64.tar.gz` |
|
||||
|
||||
Linux example:
|
||||
|
||||
```bash
|
||||
tar -xzf MediaStationGo-v0.0.6-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.6-linux-amd64
|
||||
tar -xzf MediaStationGo-v0.0.7-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.7-linux-amd64
|
||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||
```
|
||||
|
||||
Windows example:
|
||||
|
||||
```powershell
|
||||
Expand-Archive .\MediaStationGo-v0.0.6-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.6-windows-amd64
|
||||
Expand-Archive .\MediaStationGo-v0.0.7-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.7-windows-amd64
|
||||
$env:MEDIASTATION_APP_PORT = "18080"
|
||||
.\mediastation-go.exe
|
||||
```
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# 镜像版本:
|
||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.6
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7
|
||||
#
|
||||
# 路径映射总览:
|
||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
// 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.
|
||||
// Organizing moves the source file. On the same filesystem this is an
|
||||
// os.Rename; across filesystems it falls back to streaming copy + source
|
||||
// removal, so operators should keep downloads and media on the same volume
|
||||
// when they want instant moves and no temporary duplicate disk usage.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -79,10 +81,7 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
|
||||
season := fmt.Sprintf("Season %02d", m.SeasonNum)
|
||||
epTag := fmt.Sprintf("S%02dE%02d", m.SeasonNum, m.EpisodeNum)
|
||||
root := o.organizeRoot(lib.Path, lib.Type, category)
|
||||
dir := filepath.Join(root, category, title, season)
|
||||
if category == "" {
|
||||
dir = filepath.Join(root, title, season)
|
||||
}
|
||||
dir := filepath.Join(categoryRoot(root, category), title, season)
|
||||
dst = filepath.Join(dir, fmt.Sprintf("%s - %s%s", title, epTag, ext))
|
||||
} else {
|
||||
// Movie: {lib.Path}/[分类]/{Title} ({Year})/{Title} ({Year}).ext
|
||||
@@ -91,10 +90,7 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
|
||||
folder = fmt.Sprintf("%s (%d)", title, m.Year)
|
||||
}
|
||||
root := o.organizeRoot(lib.Path, lib.Type, category)
|
||||
dir := filepath.Join(root, category, folder)
|
||||
if category == "" {
|
||||
dir = filepath.Join(root, folder)
|
||||
}
|
||||
dir := filepath.Join(categoryRoot(root, category), folder)
|
||||
dst = filepath.Join(dir, folder+ext)
|
||||
}
|
||||
|
||||
@@ -219,17 +215,20 @@ 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)
|
||||
// Cross-device: stream copy → remove. This can temporarily consume the
|
||||
// destination file size while copying, but the source is removed after the
|
||||
// copy succeeds.
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
// O_EXCL 保证不会覆盖已存在的目标。
|
||||
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, werr := f.Write(data); werr != nil {
|
||||
if _, werr := io.Copy(f, in); werr != nil {
|
||||
f.Close()
|
||||
os.Remove(dst)
|
||||
return werr
|
||||
@@ -284,6 +283,13 @@ func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string)
|
||||
return libraryPath
|
||||
}
|
||||
|
||||
func categoryRoot(root, category string) string {
|
||||
if strings.TrimSpace(category) == "" || pathAlreadyEndsWith(root, category) {
|
||||
return root
|
||||
}
|
||||
return filepath.Join(root, category)
|
||||
}
|
||||
|
||||
func mediaTypeRootDir(mediaType string) string {
|
||||
switch normalizeMediaType(mediaType, "", "") {
|
||||
case "movie":
|
||||
|
||||
@@ -176,3 +176,59 @@ func TestOrganizeMediaAddsMoviePilotStyleTypeRootForGenericMediaRoot(t *testing.
|
||||
t.Fatalf("organized file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizeMediaDoesNotRepeatCategoryWhenLibraryIsCategoryRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
libraryRoot := filepath.Join(root, "media", "电视剧", "国产剧")
|
||||
sourceDir := filepath.Join(root, "downloads", "国产剧")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := filepath.Join(sourceDir, "Some Show S01E02.mkv")
|
||||
if err := os.WriteFile(source, []byte("episode"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), "organizer.smart_classify", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := model.Library{Name: "国产剧", Path: libraryRoot, Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "Some Show",
|
||||
Path: source,
|
||||
Container: "mkv",
|
||||
Countries: "CN",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||
dst, err := organizer.OrganizeMedia(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(libraryRoot, "Some Show", "Season 01", "Some Show - S01E02.mkv")
|
||||
if dst != want {
|
||||
t.Fatalf("dst = %q, want %q", dst, want)
|
||||
}
|
||||
repeated := filepath.Join(libraryRoot, "国产剧", "Some Show", "Season 01", "Some Show - S01E02.mkv")
|
||||
if _, err := os.Stat(repeated); !os.IsNotExist(err) {
|
||||
t.Fatalf("repeated category path exists or stat failed unexpectedly: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user