diff --git a/config.example.yaml b/config.example.yaml index c9161f6..bf80046 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -93,3 +93,17 @@ flaresolverr: url: "http://localhost:8191" # FlareSolverr 服务地址 session: "mediastation" # 会话名称(可选,保持 cookie 持久化) timeout: 60 # 请求超时(秒) + +# 整理配置(智能分类) +organizer: + smart_classify: false # 是否启用智能分类整理 + categories: + chinese_movie: "华语电影" + foreign_movie: "外语电影" + euus_movie: "欧美电影" + jk_movie: "日韩电影" + domestic_tv: "国产剧" + euus_tv: "欧美剧" + jk_tv: "日韩剧" + jp_anime: "日番" + cn_anime: "国漫" diff --git a/internal/config/config.go b/internal/config/config.go index e7ffcc5..fe4f7e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,7 @@ type Config struct { AI AIConfig `mapstructure:"ai"` FlareSolverr FlareSolverrConfig `mapstructure:"flaresolverr"` ApiConfig ApiConfigConfig `mapstructure:"api_config"` + Organizer OrganizerConfig `mapstructure:"organizer"` } // ApiConfigConfig API 配置相关设置。 @@ -130,6 +131,12 @@ type AIConfig struct { MaxConcurrent int `mapstructure:"max_concurrent"` } +// OrganizerConfig 配置媒体文件智能分类整理。 +type OrganizerConfig struct { + SmartClassify bool `mapstructure:"smart_classify"` + Categories map[string]string `mapstructure:"categories"` +} + // FlareSolverrConfig 配置 FlareSolverr 服务(用于绕过 Cloudflare/WAF)。 type FlareSolverrConfig struct { Enabled bool `mapstructure:"enabled"` @@ -226,6 +233,17 @@ func setDefaults(v *viper.Viper) { v.SetDefault("flaresolverr.session", "mediastation") v.SetDefault("flaresolverr.timeout", 60) + v.SetDefault("organizer.smart_classify", false) + v.SetDefault("organizer.categories.chinese_movie", "华语电影") + v.SetDefault("organizer.categories.foreign_movie", "外语电影") + v.SetDefault("organizer.categories.euus_movie", "欧美电影") + v.SetDefault("organizer.categories.jk_movie", "日韩电影") + v.SetDefault("organizer.categories.domestic_tv", "国产剧") + v.SetDefault("organizer.categories.euus_tv", "欧美剧") + v.SetDefault("organizer.categories.jk_tv", "日韩剧") + v.SetDefault("organizer.categories.jp_anime", "日番") + v.SetDefault("organizer.categories.cn_anime", "国漫") + v.SetDefault("transcoder.encoder", "") v.SetDefault("transcoder.preset", "veryfast") v.SetDefault("transcoder.video_bitrate", "1500k") diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 7067249..1ea3cc1 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -75,6 +75,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { "label": "整理 & 刮削", "items": []gin.H{ {"key": "organize.auto", "type": "toggle"}, + {"key": "organizer.smart_classify", "type": "toggle"}, {"key": "organize.movie_format", "type": "text"}, {"key": "organize.tv_format", "type": "text"}, {"key": "organize.anime_format", "type": "text"}, diff --git a/internal/model/model.go b/internal/model/model.go index 69b4c36..54de927 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -79,6 +79,9 @@ type Media struct { ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"` TMDbID int `json:"tmdb_id"` BangumiID int `json:"bangumi_id"` + Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en" + Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US" + Genres string `gorm:"size:255" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation" NSFW bool `gorm:"default:false" json:"nsfw"` // STRMURL is the indirection target for .strm files: when present the diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 20be901..5396f42 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -49,6 +49,7 @@ type OrganizeResult struct { // OrganizeMedia moves a single media file into the target library directory. // It auto-detects whether the media is a movie or TV episode based on the // parsed season/episode numbers and builds the destination path accordingly. +// When smart classify is enabled, it adds a category subfolder (e.g., "华语电影"). func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (string, error) { m, err := o.repo.Media.FindByID(ctx, mediaID) if err != nil || m == nil { @@ -64,20 +65,29 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s title = "Unknown" } + // Determine category folder (if smart classify is enabled) + category := o.SmartClassify(ctx, m) + var dst string if lib.Type == "tv" || lib.Type == "anime" { - // TV: {Title}/Season XX/{Title} - SxxExx.ext + // 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, title, season) + dir := filepath.Join(lib.Path, category, title, season) + if category == "" { + dir = filepath.Join(lib.Path, title, season) + } dst = filepath.Join(dir, fmt.Sprintf("%s - %s%s", title, epTag, ext)) } else { - // Movie: {Title} ({Year})/{Title} ({Year}).ext + // Movie: {lib.Path}/[分类]/{Title} ({Year})/{Title} ({Year}).ext folder := title if m.Year > 0 { folder = fmt.Sprintf("%s (%d)", title, m.Year) } - dir := filepath.Join(lib.Path, folder) + dir := filepath.Join(lib.Path, category, folder) + if category == "" { + dir = filepath.Join(lib.Path, folder) + } dst = filepath.Join(dir, folder+ext) } @@ -107,6 +117,7 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s zap.String("media", m.ID), zap.String("from", m.Path), zap.String("to", dst), + zap.String("category", category), ) return dst, nil } @@ -161,3 +172,202 @@ func sanitizeFilename(s string) string { ) return strings.TrimSpace(r.Replace(s)) } + +// 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 { + // Try database first + if o.repo != nil && o.repo.Setting != nil { + val, err := o.repo.Setting.Get(ctx, "organizer.smart_classify") + if err == nil && val != "" { + return val == "true" || val == "1" || val == "on" + } + } + // Fallback to config.yaml + return o.cfg.Organizer.SmartClassify +} + +// SmartClassify determines the subcategory folder based on media metadata. +// It returns the category folder name (e.g., "华语电影", "欧美剧", "日番"). +// Returns empty string if smart classify is disabled or metadata is insufficient. +func (o *OrganizerService) SmartClassify(ctx context.Context, m *model.Media) string { + // Check if smart classify is enabled (from database first, then config) + smartClassify := o.isSmartClassifyEnabled(ctx) + if !smartClassify { + return "" + } + + // Fetch fresh metadata from DB (languages, countries, genres may have been updated by scraper) + if m.Languages == "" && m.Countries == "" && m.Genres == "" { + // Try to reload from DB + fresh, err := o.repo.Media.FindByID(ctx, m.ID) + if err == nil && fresh != nil { + m = fresh + } + } + + // Parse metadata fields (comma-separated) + languages := parseCommaList(m.Languages) + countries := parseCommaList(m.Countries) + genres := parseCommaList(m.Genres) + + // Determine media type from library + lib, err := o.repo.Library.FindByID(ctx, m.LibraryID) + if err != nil || lib == nil { + return "" + } + + categories := o.cfg.Organizer.Categories + if categories == nil { + categories = make(map[string]string) + } + + // Helper closures + isChinese := func() bool { + for _, lang := range languages { + if lang == "zh" || lang == "zh-CN" || lang == "zh-TW" { + return true + } + } + for _, c := range countries { + if c == "CN" || c == "TW" || c == "HK" { + return true + } + } + return false + } + + isEastAsian := func() bool { + for _, c := range countries { + if c == "JP" || c == "KR" { + return true + } + } + for _, lang := range languages { + if lang == "ja" || lang == "ko" { + return true + } + } + return false + } + + isWestern := func() bool { + westernCountries := []string{"US", "GB", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK", "FI", "ES", "IT", "PT", "AT", "CH", "BE"} + for _, c := range countries { + for _, wc := range westernCountries { + if c == wc { + return true + } + } + } + return false + } + + // Classification logic + switch lib.Type { + case "movie": + // Use genres to help classify (e.g., Animation might be anime) + isAnimation := false + for _, g := range genres { + if g == "Animation" { + isAnimation = true + break + } + } + + if isChinese() { + if name, ok := categories["chinese_movie"]; ok && name != "" { + return name + } + return "华语电影" + } + if isEastAsian() || (isAnimation && isEastAsian()) { + if name, ok := categories["jk_movie"]; ok && name != "" { + return name + } + return "日韩电影" + } + if isWestern() { + if name, ok := categories["euus_movie"]; ok && name != "" { + return name + } + return "欧美电影" + } + // Fallback: foreign movie + if name, ok := categories["foreign_movie"]; ok && name != "" { + return name + } + return "外语电影" + + case "tv", "anime": + if lib.Type == "anime" || contains(genres, "Animation") { + // Anime classification + if isEastAsian() { + // Check if it's Japanese + for _, c := range countries { + if c == "JP" { + if name, ok := categories["jp_anime"]; ok && name != "" { + return name + } + return "日番" + } + } + } + // Chinese anime + if isChinese() { + if name, ok := categories["cn_anime"]; ok && name != "" { + return name + } + return "国漫" + } + } + // TV classification + if isChinese() { + if name, ok := categories["domestic_tv"]; ok && name != "" { + return name + } + return "国产剧" + } + if isEastAsian() { + if name, ok := categories["jk_tv"]; ok && name != "" { + return name + } + return "日韩剧" + } + if isWestern() { + if name, ok := categories["euus_tv"]; ok && name != "" { + return name + } + return "欧美剧" + } + return "剧集" + } + + return "" +} + +// parseCommaList splits a comma-separated string into a slice of trimmed strings. +func parseCommaList(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +// contains checks if a string slice contains a specific string. +func contains(slice []string, s string) bool { + for _, v := range slice { + if v == s { + return true + } + } + return false +} diff --git a/internal/service/scraper.go b/internal/service/scraper.go index 24592b1..d3dae15 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -154,6 +154,34 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { if match.BangumiID > 0 { updates["bangumi_id"] = match.BangumiID } + + // Fetch extended metadata (languages, countries, genres) from TMDb + if match.TMDbID > 0 && s.tmdb != nil && s.tmdb.Enabled() { + mediaType := s.determineMediaType(lib, match) + details, err := s.tmdb.GetDetails(ctx, match.TMDbID, mediaType) + if err != nil { + s.log.Warn("failed to get details from tmdb", + zap.Int("tmdb_id", match.TMDbID), + zap.String("type", mediaType), + zap.Error(err)) + } else if details != nil { + if len(details.Languages) > 0 { + updates["languages"] = strings.Join(details.Languages, ",") + } + if len(details.Countries) > 0 { + updates["countries"] = strings.Join(details.Countries, ",") + } + if len(details.Genres) > 0 { + updates["genres"] = strings.Join(details.Genres, ",") + } + s.log.Debug("enrich: saved extended metadata", + zap.String("media_id", m.ID), + zap.Strings("languages", details.Languages), + zap.Strings("countries", details.Countries), + zap.Strings("genres", details.Genres)) + } + } + if err := s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID). Updates(updates).Error; err != nil { return err @@ -242,3 +270,19 @@ func (s *ScraperService) AnyEnabled() bool { } return false } + +// determineMediaType returns "tv" for TV shows and "movie" for movies. +// It uses the library type as the primary signal. +func (s *ScraperService) determineMediaType(lib *model.Library, match *Match) string { + if lib != nil { + switch lib.Type { + case "tv", "anime": + return "tv" + } + } + // Fallback: if Bangumi ID is present, treat as TV/anime + if match != nil && match.BangumiID > 0 { + return "tv" + } + return "movie" +} diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index 7f4fd79..dc0c716 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -80,14 +80,22 @@ func (t *TMDbProvider) Enabled() bool { func (t *TMDbProvider) resolveAPIKey(ctx context.Context) string { // Check config first (fast path) if t.cfg.Secrets.TMDbAPIKey != "" { + t.log.Debug("tmdb: using API key from config file") return t.cfg.Secrets.TMDbAPIKey } // Fall back to database if t.apiConfig != nil { resolved, err := t.apiConfig.Resolve(ctx, "tmdb") - if err == nil && resolved.APIKey != "" { + if err != nil { + t.log.Warn("tmdb: failed to resolve API key from database", zap.Error(err)) + } else if resolved.APIKey == "" { + t.log.Warn("tmdb: API key is empty in database") + } else { + t.log.Debug("tmdb: using API key from database") return resolved.APIKey } + } else { + t.log.Warn("tmdb: apiConfig is nil, cannot resolve API key from database") } return "" } @@ -113,14 +121,17 @@ func (t *TMDbProvider) resolveBaseURL(ctx context.Context) string { // across providers; provider-specific IDs sit side-by-side so the scraper // orchestrator can write them all into a single update. type Match struct { - TMDbID int `json:"tmdb_id"` - BangumiID int `json:"bangumi_id"` - Title string `json:"title"` - Overview string `json:"overview"` - PosterURL string `json:"poster_url"` - BackdropURL string `json:"backdrop_url"` - Year int `json:"year"` - Rating float32 `json:"rating"` + TMDbID int `json:"tmdb_id"` + BangumiID int `json:"bangumi_id"` + Title string `json:"title"` + Overview string `json:"overview"` + PosterURL string `json:"poster_url"` + BackdropURL string `json:"backdrop_url"` + Year int `json:"year"` + Rating float32 `json:"rating"` + Languages []string `json:"languages,omitempty"` + Countries []string `json:"countries,omitempty"` + Genres []string `json:"genres,omitempty"` } // SearchMovie issues `/search/movie` and returns the best match, or nil @@ -201,3 +212,134 @@ func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error { } return json.NewDecoder(resp.Body).Decode(out) } + +// TMDbDetails holds extended metadata from the /movie/{id} or /tv/{id} endpoints. +type TMDbDetails struct { + Languages []string `json:"languages"` + Countries []string `json:"countries"` + Genres []string `json:"genres"` +} + +// GetDetails fetches extended metadata for a TMDb ID. +// It calls /movie/{id} or /tv/{id} with append_to_response=genres +// and extracts languages, production countries, and genres. +// mediaType should be "movie" or "tv". +func (t *TMDbProvider) GetDetails(ctx context.Context, tmdbID int, mediaType string) (*TMDbDetails, error) { + apiKey := t.resolveAPIKey(ctx) + if apiKey == "" { + return nil, fmt.Errorf("tmdb: no API key available") + } + base := t.resolveBaseURL(ctx) + + path := "/movie/" + fmt.Sprint(tmdbID) + if mediaType == "tv" { + path = "/tv/" + fmt.Sprint(tmdbID) + } + + q := url.Values{} + q.Set("api_key", apiKey) + q.Set("language", "zh-CN") + q.Set("append_to_response", "genres") + u := base + path + "?" + q.Encode() + + // Response structs for /movie/{id} and /tv/{id} + type genre struct { + Name string `json:"name"` + } + type movieResult struct { + OriginalLanguage string `json:"original_language"` + ProductionCountries []struct { + Iso3166_1 string `json:"iso_3166_1"` + } `json:"production_countries"` + SpokenLanguages []struct { + Iso639_1 string `json:"iso_639_1"` + } `json:"spoken_languages"` + Genres []genre `json:"genres"` + } + type tvResult struct { + OriginCountry []string `json:"origin_country"` + SpokenLanguages []struct { + Iso639_1 string `json:"iso_639_1"` + } `json:"spoken_languages"` + Genres []genre `json:"genres"` + } + + var ( + languages []string + countries []string + genres []string + ) + + if mediaType == "tv" { + var r tvResult + if err := t.getJSON(ctx, u, &r); err != nil { + return nil, err + } + // Spoken languages + for _, l := range r.SpokenLanguages { + languages = append(languages, l.Iso639_1) + } + // Origin countries + countries = append(countries, r.OriginCountry...) + // Genres + for _, g := range r.Genres { + genres = append(genres, g.Name) + } + } else { + var r movieResult + if err := t.getJSON(ctx, u, &r); err != nil { + return nil, err + } + // Original language + if r.OriginalLanguage != "" { + languages = append(languages, r.OriginalLanguage) + } + // Spoken languages + for _, l := range r.SpokenLanguages { + languages = append(languages, l.Iso639_1) + } + // Production countries + for _, c := range r.ProductionCountries { + countries = append(countries, c.Iso3166_1) + } + // Genres + for _, g := range r.Genres { + genres = append(genres, g.Name) + } + } + + // Deduplicate + languages = deduplicate(languages) + countries = deduplicate(countries) + genres = deduplicate(genres) + + t.log.Debug("tmdb: getDetails", + zap.Int("tmdb_id", tmdbID), + zap.String("type", mediaType), + zap.Strings("languages", languages), + zap.Strings("countries", countries), + zap.Strings("genres", genres), + ) + + return &TMDbDetails{ + Languages: languages, + Countries: countries, + Genres: genres, + }, nil +} + +// deduplicate removes duplicates from a string slice. +func deduplicate(s []string) []string { + if len(s) == 0 { + return s + } + seen := make(map[string]bool, len(s)) + out := make([]string, 0, len(s)) + for _, v := range s { + if !seen[v] { + seen[v] = true + out = append(out, v) + } + } + return out +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 19d9cfd..f825d2b 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -95,6 +95,12 @@ const GROUPS: SettingGroup[] = [ label: '入库时自动整理', type: 'toggle', }, + { + key: 'organizer.smart_classify', + label: '启用智能分类', + type: 'toggle', + hint: '根据元数据(语言/国家/类型)自动分类到子目录(如:华语电影、欧美剧、日番)', + }, { key: 'organize.target_dir', label: '整理目标目录',