diff --git a/internal/service/adult_scraper.go b/internal/service/adult_scraper.go index f7f1b45..416f871 100644 --- a/internal/service/adult_scraper.go +++ b/internal/service/adult_scraper.go @@ -31,7 +31,10 @@ var ( var adultExcludedPrefixes = map[string]struct{}{ "AC": {}, "AAC": {}, "AVC": {}, "BD": {}, "CD": {}, "DDP": {}, "DTS": {}, "FHD": {}, "HD": {}, "HEVC": {}, "HDR": {}, "MP": {}, "SD": {}, "UHD": {}, - "WEB": {}, "X264": {}, "X265": {}, + "WEB": {}, "X264": {}, "X265": {}, "H264": {}, "H265": {}, "AV1": {}, + "SEASON": {}, "EPISODE": {}, "EP": {}, "SP": {}, "OVA": {}, "OAD": {}, + "TMDB": {}, "TMDBID": {}, "DOUBAN": {}, "BANGUMI": {}, "BGM": {}, "TVDB": {}, "THETVDB": {}, + "PART": {}, "VOL": {}, "DISC": {}, "DISK": {}, "TRACK": {}, "CHAPTER": {}, "SAMPLE": {}, "TRAILER": {}, } var defaultAdultBases = []string{ @@ -87,11 +90,12 @@ func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error) } continue } - if match != nil { - match.OriginalName = code - match.NSFW = true - return match, nil - } + if match != nil { + match.OriginalName = code + match.Title = FormatAdultTitle(code, match.Title) + match.NSFW = true + return match, nil + } } return nil, lastErr } diff --git a/internal/service/adult_scraper_code.go b/internal/service/adult_scraper_code.go index a05db53..9c3927f 100644 --- a/internal/service/adult_scraper_code.go +++ b/internal/service/adult_scraper_code.go @@ -39,3 +39,57 @@ func normalizeAdultCode(input string) string { } return "" } + +// CleanAdultTitle 去除标题中冗余的前缀番号、方括号、分隔符,提取纯标题内容 +func CleanAdultTitle(code, rawTitle string) string { + rawTitle = strings.TrimSpace(rawTitle) + code = normalizeAdultCode(code) + if rawTitle == "" { + return "" + } + clean := rawTitle + for { + trimmed := strings.TrimSpace(clean) + if trimmed == "" { + return "" + } + if code != "" { + upper := strings.ToUpper(trimmed) + upperCode := strings.ToUpper(code) + if strings.HasPrefix(upper, upperCode) { + clean = strings.TrimSpace(trimmed[len(upperCode):]) + clean = strings.TrimLeft(clean, "-_ ::]】") + continue + } + } + if strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "【") { + idx := strings.IndexAny(trimmed, "]】") + if idx > 0 { + bracketContent := normalizeAdultCode(trimmed[1:idx]) + if bracketContent != "" && (code == "" || bracketContent == code) { + clean = strings.TrimSpace(trimmed[idx+len("]"):]) + clean = strings.TrimLeft(clean, "-_ ::") + continue + } + } + } + break + } + return strings.TrimSpace(clean) +} + +// FormatAdultTitle 格式化番号标题:把番号放在最前面,如 "IPZZ-090-具体标题" 或 "IPZZ-090" +func FormatAdultTitle(code, rawTitle string) string { + code = normalizeAdultCode(code) + if code == "" { + code = normalizeAdultCode(rawTitle) + } + clean := CleanAdultTitle(code, rawTitle) + if code == "" { + return clean + } + if clean == "" || strings.EqualFold(clean, code) { + return code + } + return code + "-" + clean +} diff --git a/internal/service/adult_scraper_parse.go b/internal/service/adult_scraper_parse.go index f72edbe..25f1ff2 100644 --- a/internal/service/adult_scraper_parse.go +++ b/internal/service/adult_scraper_parse.go @@ -9,14 +9,17 @@ import ( ) func parseAdultDetailHTML(body, code, source, detailURL string) *Match { + code = normalizeAdultCode(code) match := &Match{ OriginalName: code, MediaType: "adult", NSFW: true, Genres: []string{"Adult", source}, } - if title := firstAdultTitle(body, code); title != "" { - match.Title = title + rawTitle := firstAdultTitle(body, code) + match.Title = FormatAdultTitle(code, rawTitle) + if match.Title == "" { + match.Title = code } if match.Title == "" { return nil @@ -48,7 +51,10 @@ func firstAdultTitle(body, code string) string { if title == "" { continue } - title = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(title, code), strings.ToUpper(code))) + clean := CleanAdultTitle(code, title) + if clean != "" { + return clean + } if title != "" { return title } diff --git a/internal/service/adult_scraper_test.go b/internal/service/adult_scraper_test.go index d8c700e..bde64d7 100644 --- a/internal/service/adult_scraper_test.go +++ b/internal/service/adult_scraper_test.go @@ -36,13 +36,13 @@ func TestParseAdultDetailHTML(t *testing.T) {
日期 2024-05-01
` - got := parseAdultDetailHTML(html, "SSIS-001", "javdb", "https://javdb.com/v/abc") - if got == nil { - t.Fatal("parseAdultDetailHTML returned nil") - } - if got.Title != "测试标题" || got.OriginalName != "SSIS-001" || !got.NSFW { - t.Fatalf("unexpected metadata: %+v", got) - } + got := parseAdultDetailHTML(html, "SSIS-001", "javdb", "https://javdb.com/v/abc") + if got == nil { + t.Fatal("parseAdultDetailHTML returned nil") + } + if got.Title != "SSIS-001-测试标题" || got.OriginalName != "SSIS-001" || !got.NSFW { + t.Fatalf("unexpected metadata: %+v", got) + } if got.PosterURL != "https://javdb.com/covers/ssis001.jpg" || got.BackdropURL != "https://javdb.com/samples/1.jpg" { t.Fatalf("unexpected artwork: %+v", got) } @@ -98,9 +98,9 @@ func TestAdultProviderUsesConfiguredMultipleSources(t *testing.T) { if err != nil { t.Fatal(err) } - if match == nil || match.Title != "多源命中标题" || match.OriginalName != "SSIS-001" || !match.NSFW { - t.Fatalf("multi-source adult match = %+v", match) - } + if match == nil || match.Title != "SSIS-001-多源命中标题" || match.OriginalName != "SSIS-001" || !match.NSFW { + t.Fatalf("multi-source adult match = %+v", match) + } } func TestAdultSourceKindRecognizesJavBusMirrors(t *testing.T) { diff --git a/internal/service/nfo.go b/internal/service/nfo.go index fed6e80..3d0ee65 100644 --- a/internal/service/nfo.go +++ b/internal/service/nfo.go @@ -152,9 +152,20 @@ func WriteMediaNFO(m *model.Media) (string, error) { Language: splitNFOList(m.Languages), } } else { + title := m.Title + original := m.OriginalName + adultCode := normalizeAdultCode(firstText(m.OriginalName, AdultCodeFromMediaPath(m.Path), m.Title)) + if adultCode != "" || m.NSFW { + if adultCode != "" { + title = FormatAdultTitle(adultCode, m.Title) + if original == "" || strings.EqualFold(original, m.Title) { + original = adultCode + } + } + } doc = movieNFO{ - Title: m.Title, - Original: m.OriginalName, + Title: title, + Original: original, Year: m.Year, Plot: m.Overview, Rating: m.Rating, diff --git a/internal/service/nfo_test.go b/internal/service/nfo_test.go index 6270b2c..67e385b 100644 --- a/internal/service/nfo_test.go +++ b/internal/service/nfo_test.go @@ -68,3 +68,35 @@ func TestWriteMediaNFOUsesEpisodeTitleForEpisodeDetails(t *testing.T) { t.Fatalf("episode nfo did not keep episode/show titles separate:\n%s", text) } } + +func TestWriteMediaNFOAdultTitleWithCode(t *testing.T) { + root := t.TempDir() + mediaPath := filepath.Join(root, "成人", "IPZZ-090-测试成人影片", "IPZZ-090.mp4") + if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mediaPath, []byte("media"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := WriteMediaNFO(&model.Media{ + Title: "测试成人影片", + OriginalName: "IPZZ-090", + Path: mediaPath, + NSFW: true, + }) + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(got) + if err != nil { + t.Fatal(err) + } + text := string(body) + if !strings.Contains(text, "IPZZ-090-测试成人影片") { + t.Fatalf("expected title with code at front in adult nfo, got:\n%s", text) + } + if !strings.Contains(text, "IPZZ-090") { + t.Fatalf("expected originaltitle with code, got:\n%s", text) + } +} diff --git a/internal/service/organizer_scrape_providers_test.go b/internal/service/organizer_scrape_providers_test.go index a977791..cabc471 100644 --- a/internal/service/organizer_scrape_providers_test.go +++ b/internal/service/organizer_scrape_providers_test.go @@ -63,13 +63,13 @@ func TestOrganizeDirectoryUsesAdultMetadataBeforeRename(t *testing.T) { 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) - } + if res.Items[0].Title != "SSIS-001-整理成人标题" || res.Items[0].MediaType != "adult" { + t.Fatalf("adult organize item = %+v", res.Items[0]) + } + wantSuffix := filepath.Join("成人", "SSIS-001-整理成人标题", "SSIS-001-整理成人标题.mkv") + if !strings.Contains(res.Items[0].Target, wantSuffix) { + t.Fatalf("adult target = %q, want suffix %q", res.Items[0].Target, wantSuffix) + } } func TestOrganizeDirectoryUsesBangumiForAnimeRename(t *testing.T) { diff --git a/internal/service/organizer_target.go b/internal/service/organizer_target.go index 065d4fc..f2b9c5d 100644 --- a/internal/service/organizer_target.go +++ b/internal/service/organizer_target.go @@ -31,9 +31,22 @@ func (o *OrganizerService) buildOrganizeTargetPath(ctx context.Context, in organ if root == "" || root == "." { return organizeTargetPath{}, fmt.Errorf("organize target root required") } + isAdult := normalizeOrganizeMediaType(in.MediaType) == "adult" || normalizeOrganizeCategoryKey(in.Category) == normalizeOrganizeCategoryKey("成人") + var adultCode string + if isAdult { + adultCode = normalizeAdultCode(firstText(AdultCodeFromMediaPath(in.Source), normalizeAdultCode(in.Title))) + } + title := sanitizeFilename(strings.TrimSpace(in.Title)) + if isAdult && adultCode != "" { + title = sanitizeFilename(FormatAdultTitle(adultCode, title)) + } if title == "" { - title = "Unknown" + if adultCode != "" { + title = adultCode + } else { + title = "Unknown" + } } ext := strings.TrimSpace(in.Ext) if ext != "" && !strings.HasPrefix(ext, ".") { @@ -56,10 +69,11 @@ func (o *OrganizerService) buildOrganizeTargetPath(ctx context.Context, in organ template := strings.TrimSpace(o.organizeNamingFormat(ctx, in.MediaType, in.Series)) var rel string if template == "" { - rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series) + rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series, isAdult) } else { rel = renderOrganizeNamingTemplate(template, organizeNamingData{ Title: title, + Code: adultCode, Year: in.Year, Season: in.Season, Episode: in.Episode, @@ -73,7 +87,7 @@ func (o *OrganizerService) buildOrganizeTargetPath(ctx context.Context, in organ }) rel = cleanOrganizeRelativePath(rel) if rel == "" { - rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series) + rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series, isAdult) } if ext != "" && !strings.EqualFold(filepath.Ext(rel), ext) { rel += ext @@ -98,6 +112,10 @@ func (o *OrganizerService) organizeNamingFormat(ctx context.Context, mediaType s } else { key = "organize.tv_format" } + } else if normalizeOrganizeMediaType(mediaType) == "adult" { + if value, err := o.repo.Setting.Get(ctx, "organize.adult_format"); err == nil && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } } value, err := o.repo.Setting.Get(ctx, key) if err != nil { @@ -106,7 +124,7 @@ func (o *OrganizerService) organizeNamingFormat(ctx context.Context, mediaType s return strings.TrimSpace(value) } -func defaultOrganizeRelativePath(title, ext string, year, season, episode int, series bool) string { +func defaultOrganizeRelativePath(title, ext string, year, season, episode int, series bool, isAdult ...bool) string { if series { if season < 0 { season = 1 @@ -117,6 +135,9 @@ func defaultOrganizeRelativePath(title, ext string, year, season, episode int, s episodeTag := fmt.Sprintf("S%02dE%02d", season, episode) return filepath.Join(title, fmt.Sprintf("Season %02d", season), fmt.Sprintf("%s - %s%s", title, episodeTag, ext)) } + if len(isAdult) > 0 && isAdult[0] { + return filepath.Join(title, title+ext) + } folder := title if year > 0 { folder = fmt.Sprintf("%s (%d)", title, year) diff --git a/internal/service/organizer_target_template.go b/internal/service/organizer_target_template.go index 7dce142..7452c65 100644 --- a/internal/service/organizer_target_template.go +++ b/internal/service/organizer_target_template.go @@ -10,6 +10,7 @@ import ( type organizeNamingData struct { Title string + Code string Year int Season int Episode int @@ -73,9 +74,11 @@ func renderOrganizeConditionals(template string, data organizeNamingData) string func organizeTemplateTruthy(name string, data organizeNamingData) bool { switch strings.ToLower(strings.TrimSpace(name)) { - case "title", "name": - return data.Title != "" - case "year": + case "title", "name": + return data.Title != "" + case "code", "num", "adult_code": + return data.Code != "" + case "year": return data.Year > 0 case "season": return data.Season >= 0 && data.Episode > 0 @@ -102,9 +105,11 @@ func organizeTemplateTruthy(name string, data organizeNamingData) bool { func organizeTemplateValue(name, format string, data organizeNamingData, fallback string) string { switch strings.ToLower(strings.TrimSpace(name)) { - case "title", "name": - return data.Title - case "year": + case "title", "name": + return data.Title + case "code", "num", "adult_code": + return data.Code + case "year": if data.Year <= 0 { return "" }