diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index c0b28fa..a4988e8 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -22,18 +22,19 @@ import ( ) var ( - patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`) - patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`) - patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`) - patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) - patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`) - patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) - patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) - patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`) - patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`) - patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`) - patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`) - patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`) + patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`) + patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`) + patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`) + patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) + patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`) + patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) + patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) + patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`) + patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`) + patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`) + patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`) + patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`) + patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`) // patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。 patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`) ) @@ -93,6 +94,27 @@ func ParseEpisode(path string) (season, episode int) { return 0, 0 } +// onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention +// to provider-style specials. For example, S01E00 becomes S00E01 and S02E00 +// becomes S00E02. Normal episodes retain their parsed identity. +func onlineEpisodeIdentityFromPath(path string) (season, episode int) { + season, episode = ParseEpisode(path) + if episode != 0 { + return season, episode + } + name := mediaSidecarBase(path) + if name == "" { + name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } + if m := patSeasonEpisodeZero.FindStringSubmatch(name); len(m) == 2 { + specialEpisode := mustAtoi(m[1]) + if specialEpisode > 0 { + return 0, specialEpisode + } + } + return season, episode +} + type episodeRef struct { Season int Episode int diff --git a/internal/service/episode_parser_test.go b/internal/service/episode_parser_test.go index 546754d..288003d 100644 --- a/internal/service/episode_parser_test.go +++ b/internal/service/episode_parser_test.go @@ -68,3 +68,23 @@ func TestEpisodeRefsFromTitleParsesRanges(t *testing.T) { } } } + +func TestOnlineEpisodeIdentityFromPathMapsAnimeEpisodeZeroToSpecials(t *testing.T) { + cases := []struct { + path string + wantSeason int + wantEpisode int + }{ + {`动漫/路人女主/Season 1/S01E00 - 爱与青春的杀必死回.mkv`, 0, 1}, + {`动漫/路人女主/Season 2/S02E00 - 恋爱与纯情的杀必死回.mkv`, 0, 2}, + {`动漫/路人女主/Season 2/S02E03 - 初稿与二稿.mkv`, 2, 3}, + {`动漫/路人女主/Specials/S00E04.mkv`, 0, 4}, + } + for _, tc := range cases { + season, episode := onlineEpisodeIdentityFromPath(tc.path) + if season != tc.wantSeason || episode != tc.wantEpisode { + t.Errorf("onlineEpisodeIdentityFromPath(%q) = (%d, %d), want (%d, %d)", + tc.path, season, episode, tc.wantSeason, tc.wantEpisode) + } + } +} diff --git a/internal/service/manual_scrape.go b/internal/service/manual_scrape.go index 59fc5f3..6eb9077 100644 --- a/internal/service/manual_scrape.go +++ b/internal/service/manual_scrape.go @@ -39,10 +39,19 @@ func (r ManualScrapeRequest) EpisodeArtworkOption() *bool { } func (s *ScraperService) ApplyManualMatch(ctx context.Context, mediaID string, req ManualScrapeRequest) (*model.Media, error) { - return s.ApplyManualMatchWithOptions(ctx, mediaID, req, ScrapeOptions{EpisodeArtwork: req.EpisodeArtworkOption()}) + return s.ApplyManualMatchWithOptions(ctx, mediaID, req, ScrapeOptions{ + EpisodeArtwork: req.EpisodeArtworkOption(), + ForceRematch: true, + RebuildIdentity: true, + }) } func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaID string, req ManualScrapeRequest, options ScrapeOptions) (*model.Media, error) { + // A user-triggered match is authoritative. Do not let metadata written by a + // previous scrape influence the new result. + options.ForceRematch = true + options.RebuildIdentity = true + media, err := s.repo.Media.FindByID(ctx, mediaID) if err != nil || media == nil { return nil, errors.New("media not found") @@ -55,12 +64,32 @@ func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaI if strings.TrimSpace(match.Title) == "" { return nil, errors.New("manual match title required") } + rebuildManualScrapeIdentity(media, lib, match) if err := s.applyProviderMatchWithOptions(ctx, media, lib, match, options); err != nil { return nil, err } return s.repo.Media.FindByID(ctx, mediaID) } +func rebuildManualScrapeIdentity(media *model.Media, lib *model.Library, match *Match) { + if media == nil || match == nil { + return + } + mediaType := normalizeOrganizeMediaType(match.MediaType) + if mediaType == "" && librarySupportsSeasons(lib) { + mediaType = "tv" + } + switch mediaType { + case "tv", "anime", "variety": + media.SeasonNum, media.EpisodeNum = onlineEpisodeIdentityFromPath(media.Path) + media.EpisodeTitle = "" + case "movie", "adult": + media.SeasonNum = 0 + media.EpisodeNum = 0 + media.EpisodeTitle = "" + } +} + func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrapeRequest) (*Match, error) { source := strings.ToLower(strings.TrimSpace(req.Source)) mediaType := normalizeMediaType(req.MediaType, req.Title, "") diff --git a/internal/service/manual_scrape_test.go b/internal/service/manual_scrape_test.go index 355b8ac..1da72f5 100644 --- a/internal/service/manual_scrape_test.go +++ b/internal/service/manual_scrape_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "path/filepath" "strings" "sync/atomic" "testing" @@ -627,3 +628,90 @@ func TestApplyManualMovieMatchClearsEpisodeMarkers(t *testing.T) { t.Fatalf("stale external IDs were not cleared for manual movie fallback: tmdb=%d thetvdb=%q", got.TMDbID, got.TheTVDBID) } } + +func TestApplyManualTVMatchRebuildsEpisodeIdentityFromPath(t *testing.T) { + var requestedEpisodePath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/tv/69367": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 69367, + "name": "路人女主的养成方法", + "original_name": "冴えない彼女の育てかた", + "overview": "整剧简介", + "poster_path": "/show.jpg", + "backdrop_path": "/show-backdrop.jpg", + "first_air_date": "2015-01-09", + "vote_average": 7.0, + }) + case "/tv/69367/season/0/episode/1": + requestedEpisodePath = r.URL.Path + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "爱与青春的杀必死回", + "overview": "特别篇简介", + "still_path": "/special.jpg", + "air_date": "2015-01-07", + "vote_average": 7.5, + "runtime": 24, + }) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + cfg := &config.Config{} + cfg.Secrets.TMDbAPIKey = "test-key" + cfg.Secrets.TMDbAPIProxy = upstream.URL + log := zap.NewNop() + scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) + + root := t.TempDir() + lib := model.Library{Name: "动漫", Path: root, Type: "anime", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: lib.ID, + Title: "旧标题", + Path: filepath.Join(root, "路人女主的养成方法 (2015)", "Season 1", "S01E00 - 爱与青春的杀必死回.strm"), + SeasonNum: 1, + EpisodeNum: 1, + EpisodeTitle: "错误百出的序曲", + SeriesID: "stale-series", + TMDbID: 111, + BangumiID: 222, + ScrapeStatus: "matched", + } + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + got, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{ + Source: "tmdb", + MediaType: "tv", + Title: "路人女主的养成方法", + TMDbID: 69367, + }) + if err != nil { + t.Fatal(err) + } + if requestedEpisodePath != "/tv/69367/season/0/episode/1" { + t.Fatalf("episode details path = %q, want season-zero special", requestedEpisodePath) + } + if got.SeasonNum != 0 || got.EpisodeNum != 1 || got.EpisodeTitle != "爱与青春的杀必死回" { + t.Fatalf("rebuilt episode identity = S%02dE%02d %q", got.SeasonNum, got.EpisodeNum, got.EpisodeTitle) + } + if got.SeriesID != "" || got.TMDbID != 69367 || got.BangumiID != 0 { + t.Fatalf("stale scrape identity survived manual rematch: %#v", got) + } +} diff --git a/internal/service/scraper.go b/internal/service/scraper.go index dc1922f..d0b1058 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -119,8 +119,22 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media, func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *model.Media, lib *model.Library, match *Match, options ScrapeOptions) error { posterCandidate := match.PosterURL backdropCandidate := match.BackdropURL - posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", m.PosterURL, posterCandidate) - backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", m.BackdropURL, backdropCandidate) + currentPoster := m.PosterURL + currentBackdrop := m.BackdropURL + if options.RebuildIdentity { + currentPoster = "" + currentBackdrop = "" + } + posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", currentPoster, posterCandidate) + backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", currentBackdrop, backdropCandidate) + if options.RebuildIdentity { + if strings.TrimSpace(m.PosterURL) != posterURL { + removePoster = m.PosterURL + } + if strings.TrimSpace(m.BackdropURL) != backdropURL { + removeBackdrop = m.BackdropURL + } + } updates := map[string]any{ "title": match.Title, "overview": match.Overview, @@ -142,6 +156,12 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m updates["languages"] = strings.Join(match.Languages, ",") updates["nsfw"] = match.NSFW } + if options.RebuildIdentity { + updates["season_num"] = m.SeasonNum + updates["episode_num"] = m.EpisodeNum + updates["episode_title"] = "" + updates["series_id"] = "" + } if match.ReleaseDate != "" { updates["release_date"] = match.ReleaseDate } diff --git a/internal/service/scraper_options.go b/internal/service/scraper_options.go index d69fe82..c597d55 100644 --- a/internal/service/scraper_options.go +++ b/internal/service/scraper_options.go @@ -7,6 +7,7 @@ type ScrapeOptions struct { EpisodeArtwork *bool DeferEpisodeDetails bool ForceRematch bool + RebuildIdentity bool } func (o ScrapeOptions) episodeArtworkEnabled() bool { diff --git a/internal/service/scraper_service.go b/internal/service/scraper_service.go index bfb99ae..91b77a8 100644 --- a/internal/service/scraper_service.go +++ b/internal/service/scraper_service.go @@ -1,6 +1,7 @@ package service import ( + "sync" "time" "go.uber.org/zap" @@ -24,6 +25,10 @@ type ScraperService struct { hub *Hub cache *RuntimeCacheService images *ImageProxy + + // Serializes final sidecar replacement. Windows cannot rename over an + // existing file, and concurrent scrapes can target the same sidecar. + artworkWriteMu sync.Mutex } // NewScraperService is the constructor. diff --git a/internal/service/scraper_write_metadata_files.go b/internal/service/scraper_write_metadata_files.go index 70a5143..7ed92ca 100644 --- a/internal/service/scraper_write_metadata_files.go +++ b/internal/service/scraper_write_metadata_files.go @@ -44,7 +44,7 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context, if base == "" || base == "." { return } - isAdult := IsAdultMediaPathOrMetadata(refreshed.Path, refreshed.LibraryID, refreshed.NSFW) || IsAdultArtworkURL(refreshed.PosterURL) + isAdult := shouldCropAdultPoster(refreshed, lib) if refreshed.PosterURL != "" { s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult) } @@ -60,6 +60,21 @@ func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, r s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false) } +// shouldCropAdultPoster keeps adult-cover handling independent of which +// metadata provider won. A code-numbered title may match TMDb first, so the +// provider's NSFW flag or artwork host alone is not sufficient. +func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool { + if media == nil { + return false + } + mediaType := "" + if lib != nil { + mediaType = lib.Type + } + return IsAdultMediaPathOrMetadata(media.Path, mediaType, media.NSFW) || + IsAdultArtworkURL(media.PosterURL) +} + // downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and // writes it under dir/.. For adult posters, it crops the right half of the cover. func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) { @@ -110,10 +125,23 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data [] s.log.Warn("scrape artwork write failed", zap.String("dst", dst), zap.Error(err)) return "" } - _ = tmp.Close() - if err := os.Rename(tmp.Name(), dst); err != nil { + if err := tmp.Close(); err != nil { _ = os.Remove(tmp.Name()) - s.log.Warn("scrape artwork rename failed", zap.String("dst", dst), zap.Error(err)) + s.log.Warn("scrape artwork close failed", zap.String("dst", dst), zap.Error(err)) + return "" + } + + // On Windows os.Rename does not replace dst. Serialize remove+rename so two + // concurrent scrapes cannot leave the previous uncropped DVD cover behind. + s.artworkWriteMu.Lock() + err = os.Remove(dst) + if err == nil || os.IsNotExist(err) { + err = os.Rename(tmp.Name(), dst) + } + s.artworkWriteMu.Unlock() + if err != nil { + _ = os.Remove(tmp.Name()) + s.log.Warn("scrape artwork replace failed", zap.String("dst", dst), zap.Error(err)) return "" } s.log.Debug("scrape artwork written", zap.String("dst", dst)) diff --git a/internal/service/scraper_write_metadata_files_test.go b/internal/service/scraper_write_metadata_files_test.go index 98fa2b8..10a39d5 100644 --- a/internal/service/scraper_write_metadata_files_test.go +++ b/internal/service/scraper_write_metadata_files_test.go @@ -33,6 +33,47 @@ func TestWriteArtworkDataToPathWritesJellyfinSidecar(t *testing.T) { } } +func TestWriteArtworkDataToPathReplacesExistingSidecar(t *testing.T) { + scraper := &ScraperService{log: zap.NewNop()} + mediaDir := t.TempDir() + dst := filepath.Join(mediaDir, "poster.jpg") + if err := os.WriteFile(dst, []byte("old DVD cover"), 0o644); err != nil { + t.Fatal(err) + } + + if got := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG); got != dst { + t.Fatalf("destination = %q, want %q", got, dst) + } + data, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if string(data) != string(testJPEG) { + t.Fatal("existing sidecar was not replaced") + } +} + +func TestShouldCropAdultPosterWhenTMDbArtworkMatchedCodePath(t *testing.T) { + media := &model.Media{ + Path: filepath.Join(t.TempDir(), "IPX-235.mp4"), + Title: "TMDb matched title", + PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", + } + if !shouldCropAdultPoster(media, &model.Library{Type: "movie"}) { + t.Fatal("code-numbered media should retain adult crop handling after a TMDb match") + } +} + +func TestShouldCropAdultPosterUsesLibraryType(t *testing.T) { + media := &model.Media{ + Path: filepath.Join(t.TempDir(), "renamed.mp4"), + PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", + } + if !shouldCropAdultPoster(media, &model.Library{Type: "adult"}) { + t.Fatal("adult library media should be cropped regardless of provider") + } +} + // TestImageExtForContentType verifies the MIME -> extension mapping used to // name Jellyfin sidecar files. func TestImageExtForContentType(t *testing.T) {