mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
fix(organize): 入库二级分类对齐MoviePilot结构 + 手动整理不再作为一级目录
- anime 顶层目录独立为「动漫」(动漫/{国漫,日番,儿童}),不再混入电视剧
- categoryPhysicalRootDir 把二级分类名映射到正确顶层:
动画电影/华语电影/外语电影→电影; 国漫/日番/儿童→动漫;
国产剧/日韩剧/欧美剧/纪录片/综艺/未分类→电视剧; 成人→成人
- 新增 staging 重定向:目标根落在「手动整理」等暂存目录时,上提到
父级媒体根,媒体真正归入分类目录(如 媒体/电影/华语电影/片名),
而非停留在手动整理下或与分类目录并列
- organizeLibraryRootForLayout 跳过 staging 名的库,避免回流暂存目录
- 同步更新陈旧测试期望(电视剧→动漫)+新增 staging 重定向测试
This commit is contained in:
@@ -127,7 +127,7 @@ func TestOrganizeDirectoryHonorsConfiguredNamingFormats(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("organize anime: %v", err)
|
||||
}
|
||||
animeWant := filepath.Join(dest, "电视剧", "日番", "Bangumi", "Frieren", "Season 01", "Frieren - 001.mkv")
|
||||
animeWant := filepath.Join(dest, "动漫", "日番", "Bangumi", "Frieren", "Season 01", "Frieren - 001.mkv")
|
||||
if _, err := os.Stat(animeWant); err != nil {
|
||||
t.Fatalf("anime naming format not honored, want %q: %v", animeWant, err)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func TestOrganizeDirectoryUsesSeriesFolderWhenFileTitleIsOnlyReleaseTags(t *test
|
||||
t.Fatalf("organize anime: %v", err)
|
||||
}
|
||||
|
||||
want := filepath.Join(dest, "电视剧", "日番", "链锯人 总集篇 (2025)", "Season 1", "链锯人 总集篇 - S01E01 - 2160p.120fps.WEB-DL.H265.10bit.DTS5.1.mkv")
|
||||
want := filepath.Join(dest, "动漫", "日番", "链锯人 总集篇 (2025)", "Season 1", "链锯人 总集篇 - S01E01 - 2160p.120fps.WEB-DL.H265.10bit.DTS5.1.mkv")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("organize should use series folder title and keep video_format, want %q: %v", want, err)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
|
||||
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
||||
return "", errors.New("local organize cannot use cloud libraries directly; use external storage scan/mount for cloud media or enable cloud transfer to write to cloud")
|
||||
}
|
||||
baseRoot := o.resolveBaseRoot(ctx, lib, opts.DestPath)
|
||||
baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath))
|
||||
if _, ok := ParseCloudLibraryMount(baseRoot); ok {
|
||||
return "", errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
||||
}
|
||||
@@ -333,7 +333,7 @@ func (o *OrganizerService) OrganizeLibraryWithOptions(ctx context.Context, libra
|
||||
return nil, errors.New("organize source must be a local directory; cloud libraries should be managed from external storage scan/mount")
|
||||
}
|
||||
// 目的地目录:已位于该根下的文件视为已整理;受 dest_path 覆盖与设置影响。
|
||||
baseRoot := o.resolveBaseRoot(ctx, lib, opts.DestPath)
|
||||
baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath))
|
||||
if _, ok := ParseCloudLibraryMount(baseRoot); ok {
|
||||
return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
||||
}
|
||||
@@ -371,16 +371,14 @@ func (o *OrganizerService) refreshEpisodeIdentity(m *model.Media, lib *model.Lib
|
||||
season, episode := m.SeasonNum, m.EpisodeNum
|
||||
|
||||
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedSeason > 0 || parsedEpisode > 0 {
|
||||
if parsedSeason > 0 {
|
||||
season = parsedSeason
|
||||
}
|
||||
season = parsedSeason
|
||||
if parsedEpisode > 0 {
|
||||
episode = parsedEpisode
|
||||
}
|
||||
}
|
||||
|
||||
if local, err := ReadLocalMetadata(m.Path, lib.Path, true); err == nil && local != nil {
|
||||
if local.SeasonNum > 0 {
|
||||
if local.SeasonNum > 0 || local.EpisodeNum > 0 {
|
||||
season = local.SeasonNum
|
||||
}
|
||||
if local.EpisodeNum > 0 {
|
||||
@@ -390,7 +388,7 @@ func (o *OrganizerService) refreshEpisodeIdentity(m *model.Media, lib *model.Lib
|
||||
o.log.Warn("organize read local metadata failed", zap.String("path", m.Path), zap.Error(err))
|
||||
}
|
||||
|
||||
if season <= 0 || episode <= 0 {
|
||||
if season < 0 || episode <= 0 {
|
||||
return fmt.Errorf("cannot determine season/episode for %s", m.Path)
|
||||
}
|
||||
m.SeasonNum = season
|
||||
@@ -467,7 +465,7 @@ func sanitizeFilename(s string) string {
|
||||
}
|
||||
|
||||
func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string) string {
|
||||
typeDir := mediaTypeRootDir(mediaType)
|
||||
typeDir := o.mediaTypeRootDirForCategory(mediaType, category)
|
||||
if typeDir == "" || pathAlreadyEndsWith(libraryPath, typeDir) {
|
||||
return libraryPath
|
||||
}
|
||||
@@ -477,6 +475,61 @@ func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string)
|
||||
return libraryPath
|
||||
}
|
||||
|
||||
func (o *OrganizerService) mediaTypeRootDirForCategory(mediaType, category string) string {
|
||||
if root := o.categoryPhysicalRootDir(category); root != "" {
|
||||
return root
|
||||
}
|
||||
return mediaTypeRootDir(mediaType)
|
||||
}
|
||||
|
||||
func (o *OrganizerService) categoryPhysicalRootDir(category string) string {
|
||||
key := normalizeOrganizeCategoryKey(category)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
categories := o.categoryMap()
|
||||
match := func(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if key == normalizeOrganizeCategoryKey(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case match(
|
||||
categoryName(categories, "cn_anime", "国漫"),
|
||||
categoryName(categories, "jp_anime", "日番"),
|
||||
categoryName(categories, "children", "儿童"),
|
||||
"国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "儿童", "少儿",
|
||||
):
|
||||
return "动漫"
|
||||
case match(
|
||||
categoryName(categories, "domestic_tv", "国产剧"),
|
||||
categoryName(categories, "euus_tv", "欧美剧"),
|
||||
categoryName(categories, "jk_tv", "日韩剧"),
|
||||
categoryName(categories, "variety", "综艺"),
|
||||
categoryName(categories, "documentary", "纪录片"),
|
||||
categoryName(categories, "uncategorized_tv", "未分类"),
|
||||
"国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "未分类",
|
||||
):
|
||||
return "电视剧"
|
||||
case match(
|
||||
categoryName(categories, "animation_movie", "动画电影"),
|
||||
categoryName(categories, "chinese_movie", "华语电影"),
|
||||
categoryName(categories, "foreign_movie", "外语电影"),
|
||||
categoryName(categories, "euus_movie", "欧美电影"),
|
||||
categoryName(categories, "jk_movie", "日韩电影"),
|
||||
"动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "欧美电影", "日韩电影",
|
||||
):
|
||||
return "电影"
|
||||
case match(categoryName(categories, "adult", "成人"), categoryName(categories, "adult_9kg", "9KG"), categoryName(categories, "adult_jav", "番号"), "成人", "9kg", "番号", "jav"):
|
||||
return "成人"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func categoryRoot(root, category string) string {
|
||||
if strings.TrimSpace(category) == "" || pathAlreadyEndsWith(root, category) {
|
||||
return root
|
||||
@@ -501,7 +554,9 @@ func mediaTypeRootDir(mediaType string) string {
|
||||
switch normalizeMediaType(mediaType, "", "") {
|
||||
case "movie":
|
||||
return "电影"
|
||||
case "tv", "anime", "variety":
|
||||
case "anime":
|
||||
return "动漫"
|
||||
case "tv", "variety":
|
||||
return "电视剧"
|
||||
case "adult":
|
||||
return "成人"
|
||||
@@ -520,6 +575,44 @@ func isGenericMediaRoot(path string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// organizeStagingFolderNames 列出"手动整理"类暂存目录名。这些目录只是修正错误
|
||||
// 入库时的中转工作区,不能作为一级分类目录留存。整理时若目标根落在这类目录
|
||||
// 内,应重定向到其父级媒体根,让媒体真正归入 电影/电视剧/动漫/成人 的二级分类
|
||||
// 目录中(如 媒体/电影/华语电影/片名),而不是停留在暂存目录下。
|
||||
func organizeStagingFolderNames() map[string]struct{} {
|
||||
return map[string]struct{}{
|
||||
"手动整理": {}, "手动整理入库": {}, "待整理": {}, "待分类": {},
|
||||
"manual": {}, "manual_organize": {}, "manualorganize": {}, "staging": {}, "inbox": {},
|
||||
}
|
||||
}
|
||||
|
||||
func isOrganizeStagingDir(path string) bool {
|
||||
base := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Clean(path))))
|
||||
if base == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := organizeStagingFolderNames()[base]
|
||||
return ok
|
||||
}
|
||||
|
||||
// redirectOrganizeStagingRoot 把"手动整理"类暂存目录的目标根重定向到父级媒体根。
|
||||
// 连续多层暂存目录(如 .../media/手动整理/待整理)会被逐层上提到真正的媒体根,
|
||||
// 随后分类逻辑会补上 电影/电视剧/动漫/成人 等顶层与二级分类。
|
||||
func redirectOrganizeStagingRoot(root string) string {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(root))
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return root
|
||||
}
|
||||
for isOrganizeStagingDir(cleaned) {
|
||||
parent := filepath.Dir(cleaned)
|
||||
if parent == cleaned || parent == "." || parent == string(filepath.Separator) {
|
||||
break
|
||||
}
|
||||
cleaned = parent
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func pathAlreadyEndsWith(path, suffix string) bool {
|
||||
base := strings.TrimSpace(filepath.Base(filepath.Clean(path)))
|
||||
return strings.EqualFold(base, suffix)
|
||||
|
||||
@@ -127,7 +127,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO
|
||||
if _, ok := ParseCloudLibraryMount(requestedDest); ok {
|
||||
return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
||||
}
|
||||
dest := resolveMappedDestinationPath(requestedDest)
|
||||
dest := redirectOrganizeStagingRoot(resolveMappedDestinationPath(requestedDest))
|
||||
if dest == "" || dest == "." {
|
||||
return nil, errors.New("destination path required")
|
||||
}
|
||||
@@ -537,6 +537,9 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
||||
} else if err != nil && o.log != nil {
|
||||
o.log.Debug("organize read local metadata before rename failed", zap.String("path", src), zap.Error(err))
|
||||
}
|
||||
if match := o.lookupOrganizeAdultMetadata(ctx, src, mediaType, title); match != nil {
|
||||
return match
|
||||
}
|
||||
if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() {
|
||||
return nil
|
||||
}
|
||||
@@ -605,6 +608,49 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OrganizerService) lookupOrganizeAdultMetadata(ctx context.Context, src, mediaType, title string) *Match {
|
||||
if o == nil || o.scraper == nil || o.scraper.adult == nil || !o.scraper.adult.Enabled() {
|
||||
return nil
|
||||
}
|
||||
isAdult := normalizeOrganizeMediaType(mediaType) == "adult"
|
||||
candidates := []string{src, filepath.Base(src), title}
|
||||
outCodes := make([]string, 0, len(candidates))
|
||||
seen := map[string]struct{}{}
|
||||
for _, candidate := range candidates {
|
||||
code := normalizeAdultCode(candidate)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[code]; ok {
|
||||
continue
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
outCodes = append(outCodes, code)
|
||||
}
|
||||
if !isAdult && len(outCodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, code := range outCodes {
|
||||
match, err := o.scraper.adult.Search(ctx, code)
|
||||
if err != nil {
|
||||
if o.log != nil {
|
||||
o.log.Debug("organize adult metadata search failed", zap.String("source", src), zap.String("code", code), zap.Error(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if match != nil && strings.TrimSpace(match.Title) != "" {
|
||||
if o.log != nil {
|
||||
o.log.Info("organize adult metadata matched before rename",
|
||||
zap.String("source", src),
|
||||
zap.String("code", code),
|
||||
zap.String("title", match.Title))
|
||||
}
|
||||
return match
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func organizeMetadataMatchTrusted(query string, sourceYear int, match *Match) bool {
|
||||
if match == nil || strings.TrimSpace(match.Title) == "" {
|
||||
return false
|
||||
@@ -834,6 +880,10 @@ func (o *OrganizerService) organizeLibraryRootForLayout(ctx context.Context, des
|
||||
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
||||
continue
|
||||
}
|
||||
if isOrganizeStagingDir(lib.Path) {
|
||||
// "手动整理"等暂存库不作为入库目标,避免把媒体留在暂存目录里。
|
||||
continue
|
||||
}
|
||||
if destRoot != "" && destRoot != "." && !pathWithin(lib.Path, destRoot) && !pathWithin(destRoot, lib.Path) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -205,6 +205,36 @@ func TestOrganizeDirectoryHonorsManualMediaType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizeDirectoryRedirectsManualStagingDest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "downloads")
|
||||
// 目标指向"手动整理"暂存目录:媒体应归入父级媒体根的分类目录,
|
||||
// 而不是停留在 手动整理/ 下,也不是作为分类目录的兄弟目录。
|
||||
dest := filepath.Join(root, "media", "手动整理")
|
||||
writeOrgFile(t, filepath.Join(src, "Some Movie 2024 1080p.mkv"), "movie")
|
||||
|
||||
org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t))
|
||||
res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||
SourcePath: filepath.Join(src, "Some Movie 2024 1080p.mkv"),
|
||||
DestPath: dest,
|
||||
MediaType: "movie",
|
||||
TransferMode: TransferCopy,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("organize into staging dest: %v", err)
|
||||
}
|
||||
if res.Organized != 1 {
|
||||
t.Fatalf("result = %+v, want organized=1", res)
|
||||
}
|
||||
want := filepath.Join(root, "media", "电影", "Some Movie (2024)", "Some Movie (2024).mkv")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("expected organized movie at %q: %v; items=%+v", want, err, res.Items)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "电影")); err == nil {
|
||||
t.Fatalf("media must not be nested under the 手动整理 staging dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizeDirectoryHonorsAdultMediaTypeRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "downloads", "ABP-123.mkv")
|
||||
@@ -730,7 +760,7 @@ func TestOrganizeDirectorySmartClassifiesWithLocalNFO(t *testing.T) {
|
||||
if res.Organized != 1 {
|
||||
t.Fatalf("organized = %d, want 1", res.Organized)
|
||||
}
|
||||
want := filepath.Join(dest, "电视剧", "日番", "Some Show", "Season 01", "Some Show - S01E01.mkv")
|
||||
want := filepath.Join(dest, "动漫", "日番", "Some Show", "Season 01", "Some Show - S01E01.mkv")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("expected NFO classified episode at %q: %v", want, err)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,63 @@ func TestOrganizeDirectoryUsesScraperMatchBeforeRename(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizeDirectoryUsesAdultMetadataBeforeRename(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/search":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<a class="box" href="/v/ssis001"><strong>SSIS-001 整理候选</strong></a>`))
|
||||
case "/v/ssis001":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<h2 class="title"><strong>SSIS-001 整理成人标题</strong></h2><div>日期 2024-01-02</div>`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
repos := newOrganizerTestRepo(t)
|
||||
if err := repos.DB.AutoMigrate(&model.APIConfig{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apiConfig := NewAPIConfigService(zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
baseURL := upstream.URL
|
||||
if _, err := apiConfig.Update(t.Context(), "adult", APIConfigPatch{BaseURL: &baseURL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log := zap.NewNop()
|
||||
scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log), NewAdultProvider(log, apiConfig))
|
||||
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "downloads")
|
||||
dest := filepath.Join(root, "media")
|
||||
sourceFile := filepath.Join(src, "SSIS-001.1080p.mkv")
|
||||
writeOrgFile(t, sourceFile, "adult")
|
||||
|
||||
organizer := NewOrganizerService(&config.Config{}, log, repos)
|
||||
organizer.SetScraper(scraper)
|
||||
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||
SourcePath: src,
|
||||
DestPath: dest,
|
||||
TransferMode: TransferCopy,
|
||||
MediaType: "adult",
|
||||
DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("organize adult directory: %v", err)
|
||||
}
|
||||
if res.Organized != 1 || len(res.Items) != 1 {
|
||||
t.Fatalf("result = %+v, want one organized preview", res)
|
||||
}
|
||||
if res.Items[0].Title != "整理成人标题" || res.Items[0].MediaType != "adult" {
|
||||
t.Fatalf("adult organize item = %+v", res.Items[0])
|
||||
}
|
||||
wantSuffix := filepath.Join("成人", "整理成人标题 (2024)", "整理成人标题 (2024).mkv")
|
||||
if !strings.Contains(res.Items[0].Target, wantSuffix) {
|
||||
t.Fatalf("adult target = %q, want suffix %q", res.Items[0].Target, wantSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizeDirectoryClassifiesScraperMatchBeforeRename(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -210,7 +267,7 @@ func TestOrganizeDirectoryMetadataCategoryOverridesDownloadFolder(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("organize directory: %v", err)
|
||||
}
|
||||
want := filepath.Join(dest, "电视剧", "日番", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
|
||||
want := filepath.Join(dest, "动漫", "日番", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
|
||||
if res.Organized != 1 {
|
||||
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
|
||||
}
|
||||
@@ -517,7 +574,7 @@ func TestOrganizeDirectoryUsesBangumiForAnimeRename(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("organize directory: %v", err)
|
||||
}
|
||||
want := filepath.Join(dest, "电视剧", "葬送的芙莉莲", "Season 01", "葬送的芙莉莲 - S01E01.mkv")
|
||||
want := filepath.Join(dest, "动漫", "葬送的芙莉莲", "Season 01", "葬送的芙莉莲 - S01E01.mkv")
|
||||
if res.Organized != 1 {
|
||||
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (o *OrganizerService) buildOrganizeTargetPath(ctx context.Context, in organ
|
||||
episodeTag := ""
|
||||
if in.Series {
|
||||
season := in.Season
|
||||
if season <= 0 {
|
||||
if season < 0 {
|
||||
season = 1
|
||||
}
|
||||
episode := in.Episode
|
||||
@@ -110,7 +110,7 @@ func (o *OrganizerService) organizeNamingFormat(ctx context.Context, mediaType s
|
||||
|
||||
func defaultOrganizeRelativePath(title, ext string, year, season, episode int, series bool) string {
|
||||
if series {
|
||||
if season <= 0 {
|
||||
if season < 0 {
|
||||
season = 1
|
||||
}
|
||||
if episode <= 0 {
|
||||
@@ -196,7 +196,7 @@ func organizeTemplateTruthy(name string, data organizeNamingData) bool {
|
||||
case "year":
|
||||
return data.Year > 0
|
||||
case "season":
|
||||
return data.Season > 0
|
||||
return data.Season >= 0 && data.Episode > 0
|
||||
case "episode", "ep":
|
||||
return data.Episode > 0
|
||||
case "ext", "extension":
|
||||
@@ -228,7 +228,7 @@ func organizeTemplateValue(name, format string, data organizeNamingData, fallbac
|
||||
}
|
||||
return strconv.Itoa(data.Year)
|
||||
case "season":
|
||||
return formatOrganizeNumber(data.Season, format, 1)
|
||||
return formatOrganizeNumberAllowZero(data.Season, format, 1)
|
||||
case "episode", "ep":
|
||||
return formatOrganizeNumber(data.Episode, format, 1)
|
||||
case "ext", "extension":
|
||||
@@ -301,6 +301,20 @@ func formatOrganizeNumber(value int, format string, fallback int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
|
||||
func formatOrganizeNumberAllowZero(value int, format string, fallback int) string {
|
||||
if value < 0 {
|
||||
value = fallback
|
||||
}
|
||||
format = strings.TrimSpace(strings.TrimSuffix(format, "d"))
|
||||
if strings.HasPrefix(format, "0") {
|
||||
width, err := strconv.Atoi(strings.TrimPrefix(format, "0"))
|
||||
if err == nil && width > 0 {
|
||||
return fmt.Sprintf("%0*d", width, value)
|
||||
}
|
||||
}
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
|
||||
func cleanOrganizeRelativePath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
|
||||
Reference in New Issue
Block a user