diff --git a/README.md b/README.md index e1a01d1..d233c14 100644 --- a/README.md +++ b/README.md @@ -472,12 +472,10 @@ docker compose up -d | 类型 | 推荐路径 | | --- | --- | -| 电影 | `/media/Movies` | -| 剧集 | `/media/TV` | -| 动漫 | `/media/Anime` | -| 综艺 | `/media/Variety` | +| 电影媒体库 | `/media/电影` | +| 剧集/动漫/综艺媒体库 | `/media/电视剧` | | 成人内容 | `/media/Adult` | -| 下载入库 | `/downloads/Movies`、`/downloads/TV` 等 | +| 下载根目录 | `/downloads` | #### NAS 绝对路径写法 @@ -500,7 +498,7 @@ MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads ``` -容器内路径仍然填写 `/media` 和 `/downloads`,例如媒体库添加 `/media/Movies`,下载器保存路径填写 `/downloads/Movies`。 +容器内媒体库建议添加 `/media/电影` 和 `/media/电视剧` 两个根目录;整理后会自动进入 `/media/电影/动画电影`、`/media/电视剧/国产剧` 等分类目录。下载器保存根目录填写 `/downloads`,订阅下载会自动落到 `/downloads/动画电影`、`/downloads/国产剧` 等分类目录。 ### 下载器路径怎么填 @@ -514,13 +512,24 @@ MediaStationGo 容器:/downloads qBittorrent 容器:/downloads ``` -订阅保存路径可填写: +订阅保存根目录建议填写: ```text -/downloads/Movies -/downloads/TV -/downloads/Anime -/downloads/Variety +/downloads +``` + +启用智能分类后,订阅或站点搜索下载会根据媒体类别自动进入: + +```text +/downloads/动画电影 +/downloads/华语电影 +/downloads/外语电影 +/downloads/国产剧 +/downloads/国漫 +/downloads/日番 +/downloads/欧美剧 +/downloads/日韩剧 +/downloads/综艺 ``` --- @@ -775,6 +784,63 @@ tvshow.nfo episode.nfo ``` +### 智能分类目录规则 + +MediaStationGo 的智能分类分为两个阶段: + +1. 下载阶段:根据订阅类型、搜索结果分类和标题特征,把任务保存到下载器根目录下的分类子目录。 +2. 整理阶段:用户可选择手动整理,或开启自动整理;整理时先进入媒体库一级目录,再进入二级分类目录。 + +推荐宿主机目录: + +```text +/vol1/1000/qBittorrent/downloads +/vol1/1000/Docker/moviepilot-v2/media/电影 +/vol1/1000/Docker/moviepilot-v2/media/电视剧 +``` + +对应容器内目录: + +```text +/downloads +/media/电影 +/media/电视剧 +``` + +下载器智能分类示例: + +```text +/downloads/动画电影 +/downloads/国产剧 +/downloads/国漫 +/downloads/华语电影 +/downloads/日番 +/downloads/外语电影 +/downloads/综艺 +``` + +整理后媒体库示例: + +```text +/media/电视剧/国产剧/剧名 (2026)/Season 01/剧名 - S01E01 - 第 1 集.mkv +/media/电视剧/国漫/动画名 (2026)/Season 01/动画名 - S01E01 - 第 1 集.mkv +/media/电视剧/欧美剧/剧名 (2026)/Season 01/剧名 - S01E01 - 第 1 集.mkv +/media/电视剧/日番/番剧名 (2026)/Season 01/番剧名 - S01E01 - 第 1 集.mkv +/media/电视剧/日韩剧/剧名 (2026)/Season 01/剧名 - S01E01 - 第 1 集.mkv +/media/电视剧/综艺/综艺名 (2026)/Season 2026/综艺名 - S2026E01 - 第 1 集.mp4 +/media/电影/动画电影/电影名 (2026)/电影名 (2026) - 1080p.mkv +/media/电影/华语电影/电影名 (2026)/电影名 (2026) - 1080p.mkv +/media/电影/外语电影/电影名 (2026)/电影名 (2026) - 1080p.mkv +``` + +如果媒体库根目录直接设置为 `/media`,整理器会在启用智能分类时自动补上 `电影/` 或 `电视剧/` 一级目录;如果你已经把媒体库设置为 `/media/电影` 或 `/media/电视剧`,则不会重复追加一级目录。 + +自动整理与手动整理是两件事: + +- `organizer.smart_classify`:只控制是否使用智能分类目录。 +- `organizer.auto_after_download` / `organize.auto`:控制下载完成后是否自动整理。 +- 未开启自动整理时,可以在「整理与维护」页面手动整理媒体库或单个媒体。 + ### 整理与刮削命名模板 整理规则建议按媒体类型拆分。剧集、动漫、综艺都属于连续剧集类,应保留剧名、年份、季目录、季集号和分集标题;电影类则保留片名、年份、分段和视频规格。 diff --git a/README_EN.md b/README_EN.md index c771318..bacc1a4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -341,12 +341,10 @@ then add libraries in the web UI using container paths: | Type | Recommended path | | --- | --- | -| Movies | `/media/Movies` | -| TV | `/media/TV` | -| Anime | `/media/Anime` | -| Variety | `/media/Variety` | +| Movie library | `/media/电影` | +| TV / anime / variety library | `/media/电视剧` | | Adult | `/media/Adult` | -| Downloaded media | `/downloads/Movies`, `/downloads/TV`, etc. | +| Download root | `/downloads` | #### NAS Absolute Path Syntax @@ -369,7 +367,7 @@ MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads ``` -Inside MediaStationGo, still use container paths such as `/media/Movies` for libraries and `/downloads/Movies` for download targets. +Inside MediaStationGo, add `/media/电影` and `/media/电视剧` as media library roots. Organized files will land in category folders such as `/media/电影/动画电影` and `/media/电视剧/国产剧`. Use `/downloads` as the download root; subscriptions will save to folders such as `/downloads/动画电影` and `/downloads/国产剧`. ### Download Client Paths @@ -383,13 +381,24 @@ MediaStationGo container: /downloads qBittorrent container: /downloads ``` -Subscription save paths can then be: +Recommended subscription save root: ```text -/downloads/Movies -/downloads/TV -/downloads/Anime -/downloads/Variety +/downloads +``` + +With smart classification enabled, subscription and site-search downloads are saved to category folders such as: + +```text +/downloads/动画电影 +/downloads/华语电影 +/downloads/外语电影 +/downloads/国产剧 +/downloads/国漫 +/downloads/日番 +/downloads/欧美剧 +/downloads/日韩剧 +/downloads/综艺 ``` --- @@ -643,6 +652,63 @@ tvshow.nfo episode.nfo ``` +### Smart Classification Directory Rules + +MediaStationGo separates download classification from final media organization: + +1. Download stage: subscriptions and site-search downloads are saved under the downloader root by category. +2. Organization stage: manual or automatic organization moves files into the media library root, then into type and category folders. + +Recommended host directories: + +```text +/vol1/1000/qBittorrent/downloads +/vol1/1000/Docker/moviepilot-v2/media/电影 +/vol1/1000/Docker/moviepilot-v2/media/电视剧 +``` + +Container paths: + +```text +/downloads +/media/电影 +/media/电视剧 +``` + +Download classification examples: + +```text +/downloads/动画电影 +/downloads/国产剧 +/downloads/国漫 +/downloads/华语电影 +/downloads/日番 +/downloads/外语电影 +/downloads/综艺 +``` + +Organized media examples: + +```text +/media/电视剧/国产剧/Show Name (2026)/Season 01/Show Name - S01E01 - 第 1 集.mkv +/media/电视剧/国漫/Anime Name (2026)/Season 01/Anime Name - S01E01 - 第 1 集.mkv +/media/电视剧/欧美剧/Show Name (2026)/Season 01/Show Name - S01E01 - 第 1 集.mkv +/media/电视剧/日番/Anime Name (2026)/Season 01/Anime Name - S01E01 - 第 1 集.mkv +/media/电视剧/日韩剧/Show Name (2026)/Season 01/Show Name - S01E01 - 第 1 集.mkv +/media/电视剧/综艺/Variety Name (2026)/Season 2026/Variety Name - S2026E01 - 第 1 集.mp4 +/media/电影/动画电影/Movie Name (2026)/Movie Name (2026) - 1080p.mkv +/media/电影/华语电影/Movie Name (2026)/Movie Name (2026) - 1080p.mkv +/media/电影/外语电影/Movie Name (2026)/Movie Name (2026) - 1080p.mkv +``` + +If the library root is set directly to `/media`, the organizer automatically adds the `电影/` or `电视剧/` type folder when smart classification is enabled. If the library root is already `/media/电影` or `/media/电视剧`, it will not add the type folder again. + +Automatic and manual organization are separate switches: + +- `organizer.smart_classify`: controls smart category folders only. +- `organizer.auto_after_download` / `organize.auto`: controls whether completed downloads are organized automatically. +- If auto organization is disabled, use the Tools page to organize a library or a single media item manually. + ### Organization and Scraping Naming Templates Use separate organization templates by media type. TV shows, anime, and variety shows should keep title, year, season folder, season/episode number, and episode title. Movies should keep title, year, part marker, and video format. diff --git a/config.example.yaml b/config.example.yaml index f909438..5f989d3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -97,6 +97,7 @@ flaresolverr: # 整理配置(智能分类) organizer: smart_classify: false # 是否启用智能分类整理 + auto_after_download: false # 下载完成后是否自动整理;关闭时可在「整理与维护」手动整理 categories: chinese_movie: "华语电影" animation_movie: "动画电影" diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 1ea3cc1..07ac743 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -74,7 +74,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { "key": "organize", "label": "整理 & 刮削", "items": []gin.H{ - {"key": "organize.auto", "type": "toggle"}, + {"key": "organizer.auto_after_download", "type": "toggle"}, {"key": "organizer.smart_classify", "type": "toggle"}, {"key": "organize.movie_format", "type": "text"}, {"key": "organize.tv_format", "type": "text"}, diff --git a/internal/service/downloads.go b/internal/service/downloads.go index 8d2ec61..1dce4d2 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -491,13 +491,18 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, sa if d.organizer == nil || savePath == "" { return } - // 仅当显式开启 organizer.auto_after_download 时才在下载完成后整理。 + // 仅当显式开启 organizer.auto_after_download / organize.auto 时才在下载完成后整理。 // 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关, // 让操作员只想启用"分类子目录"就被动触发了文件 move。 autoOrganize := false if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil { autoOrganize = v == "true" || v == "1" || v == "on" } + if !autoOrganize { + if v, err := d.repo.Setting.Get(ctx, "organize.auto"); err == nil { + autoOrganize = v == "true" || v == "1" || v == "on" + } + } if !autoOrganize { d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash)) return diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index 51de8a7..4cf5783 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -219,9 +219,6 @@ func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, s if !s.isSmartClassifyEnabled(ctx) || category == "" { return base } - if mediaType != "" { - return filepath.Join(base, mediaType, sanitizeFilename(category)) - } return filepath.Join(base, sanitizeFilename(category)) } diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go index b60f78d..d301546 100644 --- a/internal/service/media_classifier_test.go +++ b/internal/service/media_classifier_test.go @@ -91,7 +91,7 @@ func TestSubscriptionResolveClassifiedSavePath(t *testing.T) { t.Fatalf("classification = %q/%q, want tv/综艺", mediaType, category) } got := svc.resolveSubscriptionSavePath(t.Context(), sub, mediaType, category) - want := filepath.Join("D:", "Downloads", "tv", "综艺") + want := filepath.Join("D:", "Downloads", "综艺") if got != want { t.Fatalf("save path = %q, want %q", got, want) } diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 9d51b04..84d467b 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -59,7 +59,7 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s if err != nil || lib == nil { return "", errors.New("library not found") } - if lib.Type == "tv" || lib.Type == "anime" { + if isSeriesLibraryType(lib.Type) { if err := o.refreshEpisodeIdentity(m, lib); err != nil { return "", err } @@ -74,13 +74,14 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s category := o.SmartClassify(ctx, m) var dst string - if lib.Type == "tv" || lib.Type == "anime" { + if isSeriesLibraryType(lib.Type) { // TV: {lib.Path}/[分类]/{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, category, title, season) + root := o.organizeRoot(lib.Path, lib.Type, category) + dir := filepath.Join(root, category, title, season) if category == "" { - dir = filepath.Join(lib.Path, title, season) + dir = filepath.Join(root, title, season) } dst = filepath.Join(dir, fmt.Sprintf("%s - %s%s", title, epTag, ext)) } else { @@ -89,9 +90,10 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s if m.Year > 0 { folder = fmt.Sprintf("%s (%d)", title, m.Year) } - dir := filepath.Join(lib.Path, category, folder) + root := o.organizeRoot(lib.Path, lib.Type, category) + dir := filepath.Join(root, category, folder) if category == "" { - dir = filepath.Join(lib.Path, folder) + dir = filepath.Join(root, folder) } dst = filepath.Join(dir, folder+ext) } @@ -268,6 +270,55 @@ func sanitizeFilename(s string) string { return strings.TrimSpace(r.Replace(s)) } +func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string) string { + if strings.TrimSpace(category) == "" { + return libraryPath + } + typeDir := mediaTypeRootDir(mediaType) + if typeDir == "" || pathAlreadyEndsWith(libraryPath, typeDir) { + return libraryPath + } + if isGenericMediaRoot(libraryPath) { + return filepath.Join(libraryPath, typeDir) + } + return libraryPath +} + +func mediaTypeRootDir(mediaType string) string { + switch normalizeMediaType(mediaType, "", "") { + case "movie": + return "电影" + case "tv", "anime", "variety": + return "电视剧" + default: + return "" + } +} + +func isGenericMediaRoot(path string) bool { + base := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Clean(path)))) + switch base { + case "media", "medias", "library", "libraries", "organized", "整理": + return true + default: + return false + } +} + +func pathAlreadyEndsWith(path, suffix string) bool { + base := strings.TrimSpace(filepath.Base(filepath.Clean(path))) + return strings.EqualFold(base, suffix) +} + +func isSeriesLibraryType(mediaType string) bool { + switch normalizeMediaType(mediaType, "", "") { + case "tv", "anime", "variety": + return true + default: + return false + } +} + // isSmartClassifyEnabled checks if smart classify is enabled. // It first checks the database setting, then falls back to config.yaml. func (o *OrganizerService) isSmartClassifyEnabled(ctx context.Context) bool { diff --git a/internal/service/organizer_test.go b/internal/service/organizer_test.go index 7d1c183..74713ea 100644 --- a/internal/service/organizer_test.go +++ b/internal/service/organizer_test.go @@ -121,3 +121,58 @@ func TestOrganizeMediaUsesEpisodeNFOSeason(t *testing.T) { t.Fatalf("dst = %q, want %q", dst, want) } } + +func TestOrganizeMediaAddsMoviePilotStyleTypeRootForGenericMediaRoot(t *testing.T) { + root := t.TempDir() + mediaRoot := 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: mediaRoot, 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(mediaRoot, "电视剧", "国产剧", "Some Show", "Season 01", "Some Show - S01E02.mkv") + if dst != want { + t.Fatalf("dst = %q, want %q", dst, want) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("organized file missing: %v", err) + } +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index e0aa66c..7cb7bb6 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -91,7 +91,7 @@ const GROUPS: SettingGroup[] = [ description: '媒体文件命名 + 自动刮削 + 整理目标', items: [ { - key: 'organize.auto', + key: 'organizer.auto_after_download', label: '入库时自动整理', type: 'toggle', }, diff --git a/web/src/pages/SubscriptionsPage.tsx b/web/src/pages/SubscriptionsPage.tsx index 444a7df..eab3b28 100644 --- a/web/src/pages/SubscriptionsPage.tsx +++ b/web/src/pages/SubscriptionsPage.tsx @@ -116,7 +116,7 @@ export function SubscriptionsPage() {
- 定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按媒体类型和二级分类写入下载目录。 + 定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按二级分类写入下载目录。
- 将媒体文件按 媒体库/年份/标题 (年份)/标题.ext{' '}
+ 将媒体文件按 媒体库/电影或电视剧/分类/标题/Season{' '}
的规范布局移动并重命名,确保已先完成刮削。