feat: align smart media classification paths

This commit is contained in:
ShukeBta
2026-05-29 02:08:38 +08:00
parent 4f8831c8fd
commit 00b5418fb0
12 changed files with 278 additions and 37 deletions
+77 -11
View File
@@ -472,12 +472,10 @@ docker compose up -d
| 类型 | 推荐路径 | | 类型 | 推荐路径 |
| --- | --- | | --- | --- |
| 电影 | `/media/Movies` | | 电影媒体库 | `/media/电影` |
| 剧集 | `/media/TV` | | 剧集/动漫/综艺媒体库 | `/media/电视剧` |
| 动漫 | `/media/Anime` |
| 综艺 | `/media/Variety` |
| 成人内容 | `/media/Adult` | | 成人内容 | `/media/Adult` |
| 下载入库 | `/downloads/Movies`、`/downloads/TV` 等 | | 下载根目录 | `/downloads` |
#### NAS 绝对路径写法 #### NAS 绝对路径写法
@@ -500,7 +498,7 @@ MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads 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 qBittorrent 容器:/downloads
``` ```
订阅保存路径可填写: 订阅保存根目录建议填写:
```text ```text
/downloads/Movies /downloads
/downloads/TV ```
/downloads/Anime
/downloads/Variety 启用智能分类后,订阅或站点搜索下载会根据媒体类别自动进入:
```text
/downloads/动画电影
/downloads/华语电影
/downloads/外语电影
/downloads/国产剧
/downloads/国漫
/downloads/日番
/downloads/欧美剧
/downloads/日韩剧
/downloads/综艺
``` ```
--- ---
@@ -775,6 +784,63 @@ tvshow.nfo
episode.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`:控制下载完成后是否自动整理。
- 未开启自动整理时,可以在「整理与维护」页面手动整理媒体库或单个媒体。
### 整理与刮削命名模板 ### 整理与刮削命名模板
整理规则建议按媒体类型拆分。剧集、动漫、综艺都属于连续剧集类,应保留剧名、年份、季目录、季集号和分集标题;电影类则保留片名、年份、分段和视频规格。 整理规则建议按媒体类型拆分。剧集、动漫、综艺都属于连续剧集类,应保留剧名、年份、季目录、季集号和分集标题;电影类则保留片名、年份、分段和视频规格。
+77 -11
View File
@@ -341,12 +341,10 @@ then add libraries in the web UI using container paths:
| Type | Recommended path | | Type | Recommended path |
| --- | --- | | --- | --- |
| Movies | `/media/Movies` | | Movie library | `/media/电影` |
| TV | `/media/TV` | | TV / anime / variety library | `/media/电视剧` |
| Anime | `/media/Anime` |
| Variety | `/media/Variety` |
| Adult | `/media/Adult` | | Adult | `/media/Adult` |
| Downloaded media | `/downloads/Movies`, `/downloads/TV`, etc. | | Download root | `/downloads` |
#### NAS Absolute Path Syntax #### NAS Absolute Path Syntax
@@ -369,7 +367,7 @@ MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads 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 ### Download Client Paths
@@ -383,13 +381,24 @@ MediaStationGo container: /downloads
qBittorrent container: /downloads qBittorrent container: /downloads
``` ```
Subscription save paths can then be: Recommended subscription save root:
```text ```text
/downloads/Movies /downloads
/downloads/TV ```
/downloads/Anime
/downloads/Variety 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 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 ### 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. 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.
+1
View File
@@ -97,6 +97,7 @@ flaresolverr:
# 整理配置(智能分类) # 整理配置(智能分类)
organizer: organizer:
smart_classify: false # 是否启用智能分类整理 smart_classify: false # 是否启用智能分类整理
auto_after_download: false # 下载完成后是否自动整理;关闭时可在「整理与维护」手动整理
categories: categories:
chinese_movie: "华语电影" chinese_movie: "华语电影"
animation_movie: "动画电影" animation_movie: "动画电影"
+1 -1
View File
@@ -74,7 +74,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
"key": "organize", "key": "organize",
"label": "整理 & 刮削", "label": "整理 & 刮削",
"items": []gin.H{ "items": []gin.H{
{"key": "organize.auto", "type": "toggle"}, {"key": "organizer.auto_after_download", "type": "toggle"},
{"key": "organizer.smart_classify", "type": "toggle"}, {"key": "organizer.smart_classify", "type": "toggle"},
{"key": "organize.movie_format", "type": "text"}, {"key": "organize.movie_format", "type": "text"},
{"key": "organize.tv_format", "type": "text"}, {"key": "organize.tv_format", "type": "text"},
+6 -1
View File
@@ -491,13 +491,18 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, sa
if d.organizer == nil || savePath == "" { if d.organizer == nil || savePath == "" {
return return
} }
// 仅当显式开启 organizer.auto_after_download 时才在下载完成后整理。 // 仅当显式开启 organizer.auto_after_download / organize.auto 时才在下载完成后整理。
// 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关, // 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关,
// 让操作员只想启用"分类子目录"就被动触发了文件 move。 // 让操作员只想启用"分类子目录"就被动触发了文件 move。
autoOrganize := false autoOrganize := false
if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil { if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil {
autoOrganize = v == "true" || v == "1" || v == "on" 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 { if !autoOrganize {
d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash)) d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash))
return return
-3
View File
@@ -219,9 +219,6 @@ func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, s
if !s.isSmartClassifyEnabled(ctx) || category == "" { if !s.isSmartClassifyEnabled(ctx) || category == "" {
return base return base
} }
if mediaType != "" {
return filepath.Join(base, mediaType, sanitizeFilename(category))
}
return filepath.Join(base, sanitizeFilename(category)) return filepath.Join(base, sanitizeFilename(category))
} }
+1 -1
View File
@@ -91,7 +91,7 @@ func TestSubscriptionResolveClassifiedSavePath(t *testing.T) {
t.Fatalf("classification = %q/%q, want tv/综艺", mediaType, category) t.Fatalf("classification = %q/%q, want tv/综艺", mediaType, category)
} }
got := svc.resolveSubscriptionSavePath(t.Context(), sub, mediaType, category) got := svc.resolveSubscriptionSavePath(t.Context(), sub, mediaType, category)
want := filepath.Join("D:", "Downloads", "tv", "综艺") want := filepath.Join("D:", "Downloads", "综艺")
if got != want { if got != want {
t.Fatalf("save path = %q, want %q", got, want) t.Fatalf("save path = %q, want %q", got, want)
} }
+57 -6
View File
@@ -59,7 +59,7 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
if err != nil || lib == nil { if err != nil || lib == nil {
return "", errors.New("library not found") 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 { if err := o.refreshEpisodeIdentity(m, lib); err != nil {
return "", err return "", err
} }
@@ -74,13 +74,14 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
category := o.SmartClassify(ctx, m) category := o.SmartClassify(ctx, m)
var dst string var dst string
if lib.Type == "tv" || lib.Type == "anime" { if isSeriesLibraryType(lib.Type) {
// TV: {lib.Path}/[分类]/{Title}/Season XX/{Title} - SxxExx.ext // TV: {lib.Path}/[分类]/{Title}/Season XX/{Title} - SxxExx.ext
season := fmt.Sprintf("Season %02d", m.SeasonNum) season := fmt.Sprintf("Season %02d", m.SeasonNum)
epTag := fmt.Sprintf("S%02dE%02d", m.SeasonNum, m.EpisodeNum) 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 == "" { 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)) dst = filepath.Join(dir, fmt.Sprintf("%s - %s%s", title, epTag, ext))
} else { } else {
@@ -89,9 +90,10 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
if m.Year > 0 { if m.Year > 0 {
folder = fmt.Sprintf("%s (%d)", title, m.Year) 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 == "" { if category == "" {
dir = filepath.Join(lib.Path, folder) dir = filepath.Join(root, folder)
} }
dst = filepath.Join(dir, folder+ext) dst = filepath.Join(dir, folder+ext)
} }
@@ -268,6 +270,55 @@ func sanitizeFilename(s string) string {
return strings.TrimSpace(r.Replace(s)) 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. // isSmartClassifyEnabled checks if smart classify is enabled.
// It first checks the database setting, then falls back to config.yaml. // It first checks the database setting, then falls back to config.yaml.
func (o *OrganizerService) isSmartClassifyEnabled(ctx context.Context) bool { func (o *OrganizerService) isSmartClassifyEnabled(ctx context.Context) bool {
+55
View File
@@ -121,3 +121,58 @@ func TestOrganizeMediaUsesEpisodeNFOSeason(t *testing.T) {
t.Fatalf("dst = %q, want %q", dst, want) 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)
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ const GROUPS: SettingGroup[] = [
description: '媒体文件命名 + 自动刮削 + 整理目标', description: '媒体文件命名 + 自动刮削 + 整理目标',
items: [ items: [
{ {
key: 'organize.auto', key: 'organizer.auto_after_download',
label: '入库时自动整理', label: '入库时自动整理',
type: 'toggle', type: 'toggle',
}, },
+1 -1
View File
@@ -116,7 +116,7 @@ export function SubscriptionsPage() {
<div className="space-y-6"> <div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1> <h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
<p className="text-sm text-ink-50"> <p className="text-sm text-ink-50">
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按媒体类型和二级分类写入下载目录。 定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按二级分类写入下载目录。
</p> </p>
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-4"> <form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-4">
+1 -1
View File
@@ -135,7 +135,7 @@ function OrganizePanel() {
<h2 className="font-display text-lg font-semibold text-ink-600">整理 &amp; 重命名</h2> <h2 className="font-display text-lg font-semibold text-ink-600">整理 &amp; 重命名</h2>
</div> </div>
<p className="text-xs text-sand-500"> <p className="text-xs text-sand-500">
将媒体文件按 <code className="rounded-lg bg-gray-50 px-1">媒体库/年份/标题 (年份)/标题.ext</code>{' '} 将媒体文件按 <code className="rounded-lg bg-gray-50 px-1">媒体库/电影或电视剧/分类/标题/Season</code>{' '}
的规范布局移动并重命名,确保已先完成刮削。 的规范布局移动并重命名,确保已先完成刮削。
</p> </p>