mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c086b01381 | |||
| f196e5621f |
@@ -195,7 +195,8 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
grouped := service.GroupEpisodeVersionsForDisplay(items)
|
||||
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +236,7 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
grouped := service.GroupEpisodeVersionsForDisplay(items)
|
||||
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
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, "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,25 @@ func GroupMediaVersions(items []model.Media) []MediaItem {
|
||||
return groupMediaVersions(items)
|
||||
}
|
||||
|
||||
// GroupEpisodeVersionsForDisplay folds encoding/container variants into one
|
||||
// episode while preserving season/episode ordering for episode-list APIs.
|
||||
func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
|
||||
grouped := groupMediaVersions(items)
|
||||
if grouped == nil {
|
||||
return []MediaItem{}
|
||||
}
|
||||
sort.SliceStable(grouped, func(i, j int) bool {
|
||||
if grouped[i].SeasonNum != grouped[j].SeasonNum {
|
||||
return grouped[i].SeasonNum < grouped[j].SeasonNum
|
||||
}
|
||||
if grouped[i].EpisodeNum != grouped[j].EpisodeNum {
|
||||
return grouped[i].EpisodeNum < grouped[j].EpisodeNum
|
||||
}
|
||||
return grouped[i].CreatedAt.Before(grouped[j].CreatedAt)
|
||||
})
|
||||
return grouped
|
||||
}
|
||||
|
||||
func mediaVersionGroupKey(m model.Media) string {
|
||||
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||
if IsEmbyRemoteID(m.ID) {
|
||||
|
||||
@@ -49,6 +49,28 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupEpisodeVersionsForDisplayMergesAndKeepsEpisodeOrder(t *testing.T) {
|
||||
rows := []model.Media{
|
||||
{Base: model.Base{ID: "ep2"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 2, TMDbID: 99, Path: "/show/s01e02.mkv"},
|
||||
{Base: model.Base{ID: "ep1-small"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99, Path: "/show/s01e01.mp4", SizeBytes: 100},
|
||||
{Base: model.Base{ID: "ep1-large"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99, Path: "/show/s01e01.mkv", SizeBytes: 200},
|
||||
}
|
||||
|
||||
grouped := GroupEpisodeVersionsForDisplay(rows)
|
||||
if len(grouped) != 2 {
|
||||
t.Fatalf("grouped len = %d, want 2: %#v", len(grouped), grouped)
|
||||
}
|
||||
if grouped[0].EpisodeNum != 1 || len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("episode 1 was not merged first: %#v", grouped)
|
||||
}
|
||||
if grouped[0].ID != "ep1-large" {
|
||||
t.Fatalf("primary version = %q, want ep1-large", grouped[0].ID)
|
||||
}
|
||||
if grouped[1].EpisodeNum != 2 {
|
||||
t.Fatalf("second item episode = %d, want 2", grouped[1].EpisodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
|
||||
hd := model.Media{
|
||||
LibraryID: "movies",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ type ScrapeOptions struct {
|
||||
EpisodeArtwork *bool
|
||||
DeferEpisodeDetails bool
|
||||
ForceRematch bool
|
||||
RebuildIdentity bool
|
||||
}
|
||||
|
||||
func (o ScrapeOptions) episodeArtworkEnabled() bool {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/<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) {
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -65,6 +65,7 @@ export function LibrarySeriesEpisodes({
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{visibleEpisodes.map((ep) => {
|
||||
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
||||
const versionCount = ep.versions?.length ?? 1
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
@@ -102,6 +103,7 @@ export function LibrarySeriesEpisodes({
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
{versionCount > 1 ? ` · ${versionCount} 版本` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user