mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 23:36:37 +08:00
优化
This commit is contained in:
@@ -22,18 +22,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
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]|$)`)
|
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._-]|$)`)
|
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||||
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\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*[\[\(._-]|$)`)
|
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*季`)
|
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._-]|$)`)
|
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
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|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
|
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 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||||
)
|
)
|
||||||
@@ -93,6 +94,27 @@ func ParseEpisode(path string) (season, episode int) {
|
|||||||
return 0, 0
|
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 {
|
type episodeRef struct {
|
||||||
Season int
|
Season int
|
||||||
Episode int
|
Episode int
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,10 +39,19 @@ func (r ManualScrapeRequest) EpisodeArtworkOption() *bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ScraperService) ApplyManualMatch(ctx context.Context, mediaID string, req ManualScrapeRequest) (*model.Media, error) {
|
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) {
|
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)
|
media, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||||
if err != nil || media == nil {
|
if err != nil || media == nil {
|
||||||
return nil, errors.New("media not found")
|
return nil, errors.New("media not found")
|
||||||
@@ -55,12 +64,32 @@ func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaI
|
|||||||
if strings.TrimSpace(match.Title) == "" {
|
if strings.TrimSpace(match.Title) == "" {
|
||||||
return nil, errors.New("manual match title required")
|
return nil, errors.New("manual match title required")
|
||||||
}
|
}
|
||||||
|
rebuildManualScrapeIdentity(media, lib, match)
|
||||||
if err := s.applyProviderMatchWithOptions(ctx, media, lib, match, options); err != nil {
|
if err := s.applyProviderMatchWithOptions(ctx, media, lib, match, options); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.repo.Media.FindByID(ctx, mediaID)
|
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) {
|
func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrapeRequest) (*Match, error) {
|
||||||
source := strings.ToLower(strings.TrimSpace(req.Source))
|
source := strings.ToLower(strings.TrimSpace(req.Source))
|
||||||
mediaType := normalizeMediaType(req.MediaType, req.Title, "")
|
mediaType := normalizeMediaType(req.MediaType, req.Title, "")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *model.Media, lib *model.Library, match *Match, options ScrapeOptions) error {
|
||||||
posterCandidate := match.PosterURL
|
posterCandidate := match.PosterURL
|
||||||
backdropCandidate := match.BackdropURL
|
backdropCandidate := match.BackdropURL
|
||||||
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", m.PosterURL, posterCandidate)
|
currentPoster := m.PosterURL
|
||||||
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", m.BackdropURL, backdropCandidate)
|
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{
|
updates := map[string]any{
|
||||||
"title": match.Title,
|
"title": match.Title,
|
||||||
"overview": match.Overview,
|
"overview": match.Overview,
|
||||||
@@ -142,6 +156,12 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
|||||||
updates["languages"] = strings.Join(match.Languages, ",")
|
updates["languages"] = strings.Join(match.Languages, ",")
|
||||||
updates["nsfw"] = match.NSFW
|
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 != "" {
|
if match.ReleaseDate != "" {
|
||||||
updates["release_date"] = match.ReleaseDate
|
updates["release_date"] = match.ReleaseDate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type ScrapeOptions struct {
|
|||||||
EpisodeArtwork *bool
|
EpisodeArtwork *bool
|
||||||
DeferEpisodeDetails bool
|
DeferEpisodeDetails bool
|
||||||
ForceRematch bool
|
ForceRematch bool
|
||||||
|
RebuildIdentity bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o ScrapeOptions) episodeArtworkEnabled() bool {
|
func (o ScrapeOptions) episodeArtworkEnabled() bool {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -24,6 +25,10 @@ type ScraperService struct {
|
|||||||
hub *Hub
|
hub *Hub
|
||||||
cache *RuntimeCacheService
|
cache *RuntimeCacheService
|
||||||
images *ImageProxy
|
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.
|
// NewScraperService is the constructor.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
|
|||||||
if base == "" || base == "." {
|
if base == "" || base == "." {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isAdult := IsAdultMediaPathOrMetadata(refreshed.Path, refreshed.LibraryID, refreshed.NSFW) || IsAdultArtworkURL(refreshed.PosterURL)
|
isAdult := shouldCropAdultPoster(refreshed, lib)
|
||||||
if refreshed.PosterURL != "" {
|
if refreshed.PosterURL != "" {
|
||||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult)
|
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)
|
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
|
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
|
||||||
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
|
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
|
||||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) {
|
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))
|
s.log.Warn("scrape artwork write failed", zap.String("dst", dst), zap.Error(err))
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
_ = tmp.Close()
|
if err := tmp.Close(); err != nil {
|
||||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
|
||||||
_ = os.Remove(tmp.Name())
|
_ = 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 ""
|
return ""
|
||||||
}
|
}
|
||||||
s.log.Debug("scrape artwork written", zap.String("dst", dst))
|
s.log.Debug("scrape artwork written", zap.String("dst", dst))
|
||||||
|
|||||||
@@ -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
|
// TestImageExtForContentType verifies the MIME -> extension mapping used to
|
||||||
// name Jellyfin sidecar files.
|
// name Jellyfin sidecar files.
|
||||||
func TestImageExtForContentType(t *testing.T) {
|
func TestImageExtForContentType(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user