mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 22:36:37 +08:00
Fix media scraping rename and compose pulls
This commit is contained in:
@@ -242,6 +242,8 @@ docker compose pull mediastation-go
|
|||||||
docker compose up -d --no-deps mediastation-go
|
docker compose up -d --no-deps mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
|
不要执行裸 `docker compose pull` 做日常更新。PostgreSQL / Redis 是数据与缓存基础组件,compose 已设置为 `pull_policy: never`;需要升级它们时,请先备份 `./postgres`,再手动修改镜像版本并单独拉取。
|
||||||
|
|
||||||
第二档和第三档更新时继续带上叠加文件:
|
第二档和第三档更新时继续带上叠加文件:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
#
|
#
|
||||||
# 快速开始:
|
# 快速开始:
|
||||||
# 1. 按需修改下方 volumes 的宿主机路径。
|
# 1. 按需修改下方 volumes 的宿主机路径。
|
||||||
# 2. docker compose pull
|
# 2. docker compose pull mediastation-go
|
||||||
# 3. docker compose up -d
|
# 3. docker compose up -d
|
||||||
# 4. 浏览器打开 http://<服务器IP>:18080
|
# 4. 浏览器打开 http://<服务器IP>:18080
|
||||||
#
|
#
|
||||||
@@ -201,6 +201,9 @@ services:
|
|||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
# 数据库基础镜像不参与日常应用更新,避免误拉新版 PostgreSQL。
|
||||||
|
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
|
||||||
|
pull_policy: never
|
||||||
container_name: mediastation-postgres
|
container_name: mediastation-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
# Redis 只做热缓存,日常更新 MediaStationGo 时不需要重新拉取。
|
||||||
|
# 如需升级 Redis,请先确认兼容性后再手动调整镜像版本并拉取。
|
||||||
|
pull_policy: never
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
- redis-server
|
- redis-server
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ services:
|
|||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
# 数据库基础镜像不参与日常应用更新,避免误拉新版 PostgreSQL。
|
||||||
|
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
|
||||||
|
pull_policy: never
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mediastation
|
POSTGRES_DB: mediastation
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
//
|
//
|
||||||
// Endpoints used:
|
// Endpoints used:
|
||||||
//
|
//
|
||||||
// GET /v3/movies/{tmdb_id} (artwork keyed by TMDb id)
|
// GET /v3/movies/{tmdb_id} (artwork keyed by TMDb id)
|
||||||
// GET /v3/tv/{thetvdb_id} (artwork keyed by TheTVDB id)
|
// GET /v3/tv/{thetvdb_id} (artwork keyed by TheTVDB id)
|
||||||
//
|
//
|
||||||
// The provider is enabled iff secrets.fanart_tv_api_key is non-empty.
|
// The provider is enabled iff secrets.fanart_tv_api_key is non-empty.
|
||||||
package service
|
package service
|
||||||
@@ -88,6 +88,46 @@ func (f *FanartProvider) MovieArtwork(ctx context.Context, tmdbID int) (*Artwork
|
|||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TVArtwork looks up artwork for a TheTVDB series id.
|
||||||
|
func (f *FanartProvider) TVArtwork(ctx context.Context, theTVDBID string) (*Artwork, error) {
|
||||||
|
if !f.Enabled() || theTVDBID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
type entry struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
}
|
||||||
|
type page struct {
|
||||||
|
TVPoster []entry `json:"tvposter"`
|
||||||
|
ShowBack []entry `json:"showbackground"`
|
||||||
|
HDLogo []entry `json:"hdtvlogo"`
|
||||||
|
ClearLogo []entry `json:"clearlogo"`
|
||||||
|
TVThumb []entry `json:"tvthumb"`
|
||||||
|
}
|
||||||
|
u := fmt.Sprintf("https://webservice.fanart.tv/v3/tv/%s?api_key=%s",
|
||||||
|
theTVDBID, f.cfg.Secrets.FanartAPIKey)
|
||||||
|
var p page
|
||||||
|
if err := f.getJSON(ctx, u, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a := &Artwork{}
|
||||||
|
if len(p.TVPoster) > 0 {
|
||||||
|
a.Poster = p.TVPoster[0].URL
|
||||||
|
}
|
||||||
|
if len(p.ShowBack) > 0 {
|
||||||
|
a.Backdrop = p.ShowBack[0].URL
|
||||||
|
}
|
||||||
|
if len(p.HDLogo) > 0 {
|
||||||
|
a.Logo = p.HDLogo[0].URL
|
||||||
|
} else if len(p.ClearLogo) > 0 {
|
||||||
|
a.Logo = p.ClearLogo[0].URL
|
||||||
|
}
|
||||||
|
if len(p.TVThumb) > 0 {
|
||||||
|
a.Thumb = p.TVThumb[0].URL
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *FanartProvider) getJSON(ctx context.Context, u string, out any) error {
|
func (f *FanartProvider) getJSON(ctx context.Context, u string, out any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ type nfoDocument struct {
|
|||||||
OriginalTitle string `xml:"originaltitle"`
|
OriginalTitle string `xml:"originaltitle"`
|
||||||
SortTitle string `xml:"sorttitle"`
|
SortTitle string `xml:"sorttitle"`
|
||||||
Num string `xml:"num"`
|
Num string `xml:"num"`
|
||||||
Year int `xml:"year"`
|
Year nfoInt `xml:"year"`
|
||||||
Premiered string `xml:"premiered"`
|
Premiered string `xml:"premiered"`
|
||||||
ReleaseDate string `xml:"releasedate"`
|
ReleaseDate string `xml:"releasedate"`
|
||||||
Release string `xml:"release"`
|
Release string `xml:"release"`
|
||||||
@@ -77,15 +77,15 @@ type nfoDocument struct {
|
|||||||
Plot string `xml:"plot"`
|
Plot string `xml:"plot"`
|
||||||
Outline string `xml:"outline"`
|
Outline string `xml:"outline"`
|
||||||
OriginalPlot string `xml:"originalplot"`
|
OriginalPlot string `xml:"originalplot"`
|
||||||
Rating float32 `xml:"rating"`
|
Rating nfoFloat `xml:"rating"`
|
||||||
Poster string `xml:"poster"`
|
Poster string `xml:"poster"`
|
||||||
Thumbs []nfoThumb `xml:"thumb"`
|
Thumbs []nfoThumb `xml:"thumb"`
|
||||||
Fanart nfoFanart `xml:"fanart"`
|
Fanart nfoFanart `xml:"fanart"`
|
||||||
Art nfoArt `xml:"art"`
|
Art nfoArt `xml:"art"`
|
||||||
TMDbID int `xml:"tmdbid"`
|
TMDbID nfoInt `xml:"tmdbid"`
|
||||||
UniqueIDs []nfoUniqueID `xml:"uniqueid"`
|
UniqueIDs []nfoUniqueID `xml:"uniqueid"`
|
||||||
Season int `xml:"season"`
|
Season nfoInt `xml:"season"`
|
||||||
Episode int `xml:"episode"`
|
Episode nfoInt `xml:"episode"`
|
||||||
Genres []string `xml:"genre"`
|
Genres []string `xml:"genre"`
|
||||||
Tags []string `xml:"tag"`
|
Tags []string `xml:"tag"`
|
||||||
Countries []string `xml:"country"`
|
Countries []string `xml:"country"`
|
||||||
@@ -98,6 +98,46 @@ type nfoDocument struct {
|
|||||||
Actors []nfoActor `xml:"actor"`
|
Actors []nfoActor `xml:"actor"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type nfoInt int
|
||||||
|
|
||||||
|
func (n *nfoInt) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||||
|
var raw string
|
||||||
|
if err := d.DecodeElement(&raw, &start); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw = cleanXMLText(raw)
|
||||||
|
if raw == "" || strings.EqualFold(raw, "none") || strings.EqualFold(raw, "null") || strings.EqualFold(raw, "nan") {
|
||||||
|
*n = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(raw); err == nil {
|
||||||
|
*n = nfoInt(v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||||
|
*n = nfoInt(int(f))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type nfoFloat float32
|
||||||
|
|
||||||
|
func (n *nfoFloat) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||||
|
var raw string
|
||||||
|
if err := d.DecodeElement(&raw, &start); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw = cleanXMLText(raw)
|
||||||
|
if raw == "" || strings.EqualFold(raw, "none") || strings.EqualFold(raw, "null") || strings.EqualFold(raw, "nan") {
|
||||||
|
*n = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseFloat(raw, 32); err == nil {
|
||||||
|
*n = nfoFloat(v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type nfoActor struct {
|
type nfoActor struct {
|
||||||
Name string `xml:"name"`
|
Name string `xml:"name"`
|
||||||
Role string `xml:"role"`
|
Role string `xml:"role"`
|
||||||
@@ -253,16 +293,16 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe
|
|||||||
Title: cleanXMLText(doc.Title),
|
Title: cleanXMLText(doc.Title),
|
||||||
OriginalName: cleanXMLText(doc.OriginalTitle),
|
OriginalName: cleanXMLText(doc.OriginalTitle),
|
||||||
AdultCode: normalizeAdultCode(doc.Num),
|
AdultCode: normalizeAdultCode(doc.Num),
|
||||||
Year: doc.Year,
|
Year: int(doc.Year),
|
||||||
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
|
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
|
||||||
Rating: doc.Rating,
|
Rating: float32(doc.Rating),
|
||||||
PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...),
|
PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...),
|
||||||
BackdropURL: firstRemoteURL(baseDir, nfoBackdropValues(doc)...),
|
BackdropURL: firstRemoteURL(baseDir, nfoBackdropValues(doc)...),
|
||||||
TMDbID: doc.TMDbID,
|
TMDbID: int(doc.TMDbID),
|
||||||
DoubanID: externalIDFromUniqueIDs(doc.UniqueIDs, "douban"),
|
DoubanID: externalIDFromUniqueIDs(doc.UniqueIDs, "douban"),
|
||||||
TheTVDBID: externalIDFromUniqueIDs(doc.UniqueIDs, "thetvdb", "tvdb"),
|
TheTVDBID: externalIDFromUniqueIDs(doc.UniqueIDs, "thetvdb", "tvdb"),
|
||||||
SeasonNum: doc.Season,
|
SeasonNum: int(doc.Season),
|
||||||
EpisodeNum: doc.Episode,
|
EpisodeNum: int(doc.Episode),
|
||||||
Genres: joinNFOValues(adultAwareGenres(doc)),
|
Genres: joinNFOValues(adultAwareGenres(doc)),
|
||||||
Countries: joinNFOValues(doc.Countries),
|
Countries: joinNFOValues(doc.Countries),
|
||||||
Languages: joinNFOValues(doc.Languages),
|
Languages: joinNFOValues(doc.Languages),
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ func TestReadLocalEpisodeMetadataMergesShowAndEpisode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadLocalEpisodeMetadataIgnoresNoneNumericFields(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
showDir := filepath.Join(root, "链锯人 总集篇 (2025)")
|
||||||
|
seasonDir := filepath.Join(showDir, "Season 1")
|
||||||
|
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mediaPath := filepath.Join(seasonDir, "链锯人 总集篇 - S01E01.mkv")
|
||||||
|
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`<tvshow><title>链锯人 总集篇</title><year>2025</year></tvshow>`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>第 1 集</title><season>None</season><episode>None</episode><rating>None</rating></episodedetails>`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got == nil || got.Title != "链锯人 总集篇" || got.Year != 2025 {
|
||||||
|
t.Fatalf("unexpected metadata: %+v", got)
|
||||||
|
}
|
||||||
|
if got.SeasonNum != 0 || got.EpisodeNum != 0 || got.Rating != 0 {
|
||||||
|
t.Fatalf("None numeric fields should parse as zero, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
|
func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
showDir := filepath.Join(root, "哈哈哈哈哈")
|
showDir := filepath.Join(root, "哈哈哈哈哈")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -159,6 +160,49 @@ func TestOrganizeDirectoryHonorsMoviePilotStyleNamingFormat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryUsesSeriesFolderWhenFileTitleIsOnlyReleaseTags(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
src := filepath.Join(root, "downloads", "链锯人 总集篇 (2025)", "Season 1", "2025.2160p.120fps.WEB-DL.H265.10bit.DTS5.1.S01E01.mkv")
|
||||||
|
writeOrgFile(t, src, "episode")
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
template := "{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if video_format %} - {{video_format}}{% endif %}{{fileExt}}"
|
||||||
|
if err := repos.Setting.Set(t.Context(), "organize.tv_format", template); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repos.Setting.Set(t.Context(), "organize.anime_format", template); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
if _, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: src,
|
||||||
|
DestPath: dest,
|
||||||
|
MediaType: "anime",
|
||||||
|
MediaCategory: "日番",
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("organize anime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := filepath.Join(dest, "电视剧", "日番", "链锯人 总集篇 (2025)", "Season 1", "链锯人 总集篇 - S01E01 - 2160p.120fps.WEB-DL.H265.10bit.DTS5.1.mkv")
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("organize should use series folder title and keep video_format, want %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeNamingTemplateKeepsVideoFormatToken(t *testing.T) {
|
||||||
|
rel := renderOrganizeNamingTemplate("{title} - {episode_tag} - {video_format}{fileExt}", organizeNamingData{
|
||||||
|
Title: "链锯人 总集篇",
|
||||||
|
EpisodeTag: "S01E01",
|
||||||
|
VideoFormat: extractOrganizeReleaseTag("2025.2160p.120fps.WEB-DL.H265.10bit.DTS5.1.mkv"),
|
||||||
|
FileExt: ".mkv",
|
||||||
|
})
|
||||||
|
if !strings.Contains(rel, "2160p.120fps.WEB-DL.H265.10bit.DTS5.1") {
|
||||||
|
t.Fatalf("video_format was lost from rendered name: %q", rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrganizePipelineUsesSameNamingFormat(t *testing.T) {
|
func TestOrganizePipelineUsesSameNamingFormat(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
src := filepath.Join(root, "downloads", "Pipeline.Show.S01E02.2026.mkv")
|
src := filepath.Join(root, "downloads", "Pipeline.Show.S01E02.2026.mkv")
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
|
|||||||
MediaType: lib.Type,
|
MediaType: lib.Type,
|
||||||
Category: category,
|
Category: category,
|
||||||
Title: title,
|
Title: title,
|
||||||
|
Source: m.Path,
|
||||||
Ext: ext,
|
Ext: ext,
|
||||||
Year: m.Year,
|
Year: m.Year,
|
||||||
Season: m.SeasonNum,
|
Season: m.SeasonNum,
|
||||||
@@ -172,6 +173,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
|
|||||||
MediaType: lib.Type,
|
MediaType: lib.Type,
|
||||||
Category: category,
|
Category: category,
|
||||||
Title: title,
|
Title: title,
|
||||||
|
Source: m.Path,
|
||||||
Ext: ext,
|
Ext: ext,
|
||||||
Year: m.Year,
|
Year: m.Year,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -242,9 +242,17 @@ type organizeDirectoryLayout struct {
|
|||||||
// into destRoot, applying dedup + 洗版.
|
// into destRoot, applying dedup + 洗版.
|
||||||
func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, mediaTypeOverride, mediaCategoryOverride string, dryRun bool, allowReplaceExisting bool, metadataCache map[string]*Match, res *OrganizeResult) error {
|
func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, mediaTypeOverride, mediaCategoryOverride string, dryRun bool, allowReplaceExisting bool, metadataCache map[string]*Match, res *OrganizeResult) error {
|
||||||
ext := filepath.Ext(src)
|
ext := filepath.Ext(src)
|
||||||
|
season, episode := ParseEpisode(src)
|
||||||
title, year := CleanQuery(src)
|
title, year := CleanQuery(src)
|
||||||
if title == "" {
|
if organizeWeakFileTitle(title) {
|
||||||
title = strings.TrimSuffix(filepath.Base(src), ext)
|
if folderTitle, folderYear := organizeTitleFromParentFolder(src, sourceRoot, season > 0 || episode > 0); folderTitle != "" {
|
||||||
|
title = folderTitle
|
||||||
|
if year <= 0 {
|
||||||
|
year = folderYear
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title = strings.TrimSuffix(filepath.Base(src), ext)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// CleanQuery lowercases the parsed title; title-case it so organized output
|
// CleanQuery lowercases the parsed title; title-case it so organized output
|
||||||
// matches typical library casing (and stays consistent for dedup).
|
// matches typical library casing (and stays consistent for dedup).
|
||||||
@@ -253,7 +261,6 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
title = "Unknown"
|
title = "Unknown"
|
||||||
}
|
}
|
||||||
season, episode := ParseEpisode(src)
|
|
||||||
pathLayout := o.inferOrganizeDirectoryLayout(src, sourceRoot)
|
pathLayout := o.inferOrganizeDirectoryLayout(src, sourceRoot)
|
||||||
layout := pathLayout
|
layout := pathLayout
|
||||||
forcedType := normalizeOrganizeMediaType(mediaTypeOverride)
|
forcedType := normalizeOrganizeMediaType(mediaTypeOverride)
|
||||||
@@ -320,6 +327,7 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
|
|||||||
MediaType: layout.MediaType,
|
MediaType: layout.MediaType,
|
||||||
Category: layout.Category,
|
Category: layout.Category,
|
||||||
Title: title,
|
Title: title,
|
||||||
|
Source: src,
|
||||||
Ext: ext,
|
Ext: ext,
|
||||||
Year: year,
|
Year: year,
|
||||||
Season: season,
|
Season: season,
|
||||||
@@ -425,6 +433,21 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func organizeTitleFromParentFolder(src, sourceRoot string, seriesLike bool) (string, int) {
|
||||||
|
if !seriesLike {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
raw := seriesFolderTitle(src, sourceRoot)
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
title, year := CleanQuery(raw)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
return title, year
|
||||||
|
}
|
||||||
|
|
||||||
func shouldSkipOrganizeSourceVideo(path, sourceRoot string) (bool, string) {
|
func shouldSkipOrganizeSourceVideo(path, sourceRoot string) (bool, string) {
|
||||||
cleanPath := filepath.Clean(path)
|
cleanPath := filepath.Clean(path)
|
||||||
cleanRoot := filepath.Clean(sourceRoot)
|
cleanRoot := filepath.Clean(sourceRoot)
|
||||||
@@ -473,6 +496,31 @@ func normalizeOrganizeMediaType(mediaType string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func organizeWeakFileTitle(title string) bool {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if title == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
fields := strings.Fields(strings.ToLower(title))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
meaningful := 0
|
||||||
|
for _, field := range fields {
|
||||||
|
if _, ok := noiseTokenSet[field]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := releaseBoundaryTokenSet[field]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(field) == 4 && strings.HasPrefix(field, "20") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meaningful++
|
||||||
|
}
|
||||||
|
return meaningful == 0
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OrganizerService) inferMediaTypeForSourceFile(src, title string, season, episode int) string {
|
func (o *OrganizerService) inferMediaTypeForSourceFile(src, title string, season, episode int) string {
|
||||||
if season > 0 || episode > 0 {
|
if season > 0 || episode > 0 {
|
||||||
return "tv"
|
return "tv"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type organizeTargetInput struct {
|
|||||||
MediaType string
|
MediaType string
|
||||||
Category string
|
Category string
|
||||||
Title string
|
Title string
|
||||||
|
Source string
|
||||||
Ext string
|
Ext string
|
||||||
Year int
|
Year int
|
||||||
Season int
|
Season int
|
||||||
@@ -60,15 +61,17 @@ func (o *OrganizerService) buildOrganizeTargetPath(ctx context.Context, in organ
|
|||||||
rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series)
|
rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series)
|
||||||
} else {
|
} else {
|
||||||
rel = renderOrganizeNamingTemplate(template, organizeNamingData{
|
rel = renderOrganizeNamingTemplate(template, organizeNamingData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Year: in.Year,
|
Year: in.Year,
|
||||||
Season: in.Season,
|
Season: in.Season,
|
||||||
Episode: in.Episode,
|
Episode: in.Episode,
|
||||||
Ext: strings.TrimPrefix(ext, "."),
|
Ext: strings.TrimPrefix(ext, "."),
|
||||||
FileExt: ext,
|
FileExt: ext,
|
||||||
Category: sanitizeFilename(in.Category),
|
Category: sanitizeFilename(in.Category),
|
||||||
MediaType: normalizeOrganizeMediaType(in.MediaType),
|
MediaType: normalizeOrganizeMediaType(in.MediaType),
|
||||||
EpisodeTag: episodeTag,
|
EpisodeTag: episodeTag,
|
||||||
|
VideoFormat: extractOrganizeReleaseTag(in.Source),
|
||||||
|
Part: extractOrganizeReleaseTag(in.Source),
|
||||||
})
|
})
|
||||||
rel = cleanOrganizeRelativePath(rel)
|
rel = cleanOrganizeRelativePath(rel)
|
||||||
if rel == "" {
|
if rel == "" {
|
||||||
@@ -124,15 +127,17 @@ func defaultOrganizeRelativePath(title, ext string, year, season, episode int, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
type organizeNamingData struct {
|
type organizeNamingData struct {
|
||||||
Title string
|
Title string
|
||||||
Year int
|
Year int
|
||||||
Season int
|
Season int
|
||||||
Episode int
|
Episode int
|
||||||
Ext string
|
Ext string
|
||||||
FileExt string
|
FileExt string
|
||||||
Category string
|
Category string
|
||||||
MediaType string
|
MediaType string
|
||||||
EpisodeTag string
|
EpisodeTag string
|
||||||
|
VideoFormat string
|
||||||
|
Part string
|
||||||
}
|
}
|
||||||
|
|
||||||
var organizeTemplateTokenRE = regexp.MustCompile(`\{([A-Za-z_]+)(?::([^}]+))?\}`)
|
var organizeTemplateTokenRE = regexp.MustCompile(`\{([A-Za-z_]+)(?::([^}]+))?\}`)
|
||||||
@@ -204,8 +209,10 @@ func organizeTemplateTruthy(name string, data organizeNamingData) bool {
|
|||||||
return data.MediaType != ""
|
return data.MediaType != ""
|
||||||
case "episode_tag", "episodetag", "season_episode":
|
case "episode_tag", "episodetag", "season_episode":
|
||||||
return data.EpisodeTag != ""
|
return data.EpisodeTag != ""
|
||||||
case "part", "videoformat", "video_format":
|
case "videoformat", "video_format":
|
||||||
return false
|
return data.VideoFormat != ""
|
||||||
|
case "part":
|
||||||
|
return data.Part != ""
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -234,13 +241,52 @@ func organizeTemplateValue(name, format string, data organizeNamingData, fallbac
|
|||||||
return data.MediaType
|
return data.MediaType
|
||||||
case "episode_tag", "episodetag", "season_episode":
|
case "episode_tag", "episodetag", "season_episode":
|
||||||
return data.EpisodeTag
|
return data.EpisodeTag
|
||||||
case "part", "videoformat", "video_format":
|
case "videoformat", "video_format":
|
||||||
return ""
|
return data.VideoFormat
|
||||||
|
case "part":
|
||||||
|
return data.Part
|
||||||
default:
|
default:
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var organizeReleaseTailRE = regexp.MustCompile(`(?i)(?:^|[.\s_\-\[\(])((?:2160p|1080p|720p|480p|4k|uhd|fhd|web[\s._-]*dl|web[\s._-]*rip|bluray|blu[\s._-]*ray|bdrip|hdtv|remux|x26[45]|h[\s._-]*26[45]|hevc|avc|10bit|8bit|hdr10?\+?|dovi|dv|120fps|60fps|dts|ddp?|eac3|aac|flac|truehd|atmos).*)$`)
|
||||||
|
|
||||||
|
func extractOrganizeReleaseTag(source string) string {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(strings.TrimSpace(source)), filepath.Ext(source))
|
||||||
|
if base == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
matches := organizeReleaseTailRE.FindStringSubmatch(base)
|
||||||
|
if len(matches) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value := strings.Trim(matches[1], " ._-[]()")
|
||||||
|
value = patSEnE.ReplaceAllString(value, " ")
|
||||||
|
value = patNxE.ReplaceAllString(value, " ")
|
||||||
|
value = patEP.ReplaceAllString(value, " ")
|
||||||
|
value = patCN.ReplaceAllString(value, " ")
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case ' ', '_', '[', ']', '(', ')', '{', '}':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.Trim(part, ".-")
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sanitizeFilename(strings.Join(out, "."))
|
||||||
|
}
|
||||||
|
|
||||||
func formatOrganizeNumber(value int, format string, fallback int) string {
|
func formatOrganizeNumber(value int, format string, fallback int) string {
|
||||||
if value <= 0 {
|
if value <= 0 {
|
||||||
value = fallback
|
value = fallback
|
||||||
|
|||||||
@@ -267,6 +267,11 @@ func (s *SchedulerService) loopWithInitialDelay(ctx context.Context, j *schedule
|
|||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
}
|
}
|
||||||
if err := s.runOnce(ctx, j); err != nil {
|
if err := s.runOnce(ctx, j); err != nil {
|
||||||
|
if errors.Is(err, ErrSchedulerJobAlreadyRunning) {
|
||||||
|
s.log.Debug("scheduled job skipped; previous run still active", zap.String("name", j.name))
|
||||||
|
delay = j.interval
|
||||||
|
continue
|
||||||
|
}
|
||||||
s.log.Warn("scheduled job failed",
|
s.log.Warn("scheduled job failed",
|
||||||
zap.String("name", j.name), zap.Error(err))
|
zap.String("name", j.name), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-49
@@ -1,14 +1,8 @@
|
|||||||
// Package service — scraper orchestrator.
|
// Package service — scraper orchestrator.
|
||||||
//
|
//
|
||||||
// ScraperService takes a Media row and tries to enrich it with metadata
|
// ScraperService takes a Media row and tries to enrich it with metadata from
|
||||||
// from one or more providers. Selection is driven by the library type:
|
// local NFO first, then TMDb -> Douban -> Bangumi -> TheTVDB. Fanart.tv is
|
||||||
//
|
// artwork-only and upgrades poster/backdrop after a metadata match.
|
||||||
// library.type == "anime" -> Bangumi (fallback: TMDb)
|
|
||||||
// library.type == "tv" -> TheTVDB (fallback: TMDb)
|
|
||||||
// default -> TMDb
|
|
||||||
//
|
|
||||||
// After the primary match we optionally upgrade poster / backdrop with
|
|
||||||
// Fanart.tv when an API key is configured.
|
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -282,22 +276,43 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
|||||||
zap.String("library_type", lib.Type))
|
zap.String("library_type", lib.Type))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Optional Fanart upgrade.
|
s.applyFanartArtwork(ctx, match)
|
||||||
if s.fanart != nil && s.fanart.Enabled() && match.TMDbID > 0 {
|
|
||||||
if a, err := s.fanart.MovieArtwork(ctx, match.TMDbID); err == nil && a != nil {
|
|
||||||
if a.Poster != "" {
|
|
||||||
match.PosterURL = a.Poster
|
|
||||||
}
|
|
||||||
if a.Backdrop != "" {
|
|
||||||
match.BackdropURL = a.Backdrop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mergeLocalMetadataIntoMatch(match, local)
|
mergeLocalMetadataIntoMatch(match, local)
|
||||||
|
|
||||||
return s.applyProviderMatch(ctx, m, lib, match)
|
return s.applyProviderMatch(ctx, m, lib, match)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ScraperService) applyFanartArtwork(ctx context.Context, match *Match) {
|
||||||
|
if s == nil || s.fanart == nil || !s.fanart.Enabled() || match == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apply := func(a *Artwork) {
|
||||||
|
if a == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.Poster != "" {
|
||||||
|
match.PosterURL = a.Poster
|
||||||
|
}
|
||||||
|
if a.Backdrop != "" {
|
||||||
|
match.BackdropURL = a.Backdrop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match.TMDbID > 0 {
|
||||||
|
if a, err := s.fanart.MovieArtwork(ctx, match.TMDbID); err == nil {
|
||||||
|
apply(a)
|
||||||
|
} else {
|
||||||
|
s.log.Debug("fanart movie artwork failed", zap.Int("tmdb_id", match.TMDbID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(match.TheTVDBID) != "" {
|
||||||
|
if a, err := s.fanart.TVArtwork(ctx, strings.TrimSpace(match.TheTVDBID)); err == nil {
|
||||||
|
apply(a)
|
||||||
|
} else {
|
||||||
|
s.log.Debug("fanart tv artwork failed", zap.String("thetvdb_id", match.TheTVDBID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mediaYearHint(m *model.Media) int {
|
func mediaYearHint(m *model.Media) int {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -695,33 +710,14 @@ func librarySupportsSeasons(lib *model.Library) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookup runs the provider chain. When the library is missing we fall
|
// lookup runs the provider chain after local NFO has been considered:
|
||||||
// back to TMDb only.
|
// TMDb -> Douban -> Bangumi -> TheTVDB. Douban and Bangumi do not require API
|
||||||
//
|
// keys; providers that are unavailable or return an error are skipped.
|
||||||
// 库类型决定首选 provider:
|
|
||||||
//
|
|
||||||
// anime -> Bangumi -> TMDb /search/tv -> TMDb /search/movie
|
|
||||||
// tv -> TMDb /search/tv -> TMDb /search/movie -> TheTVDB
|
|
||||||
// movie -> TMDb /search/movie
|
|
||||||
// (空) -> TMDb /search/movie
|
|
||||||
//
|
|
||||||
// 任何 provider 错误都不会中止链式查询;只要返回 nil/err,就继续走下一个
|
|
||||||
// provider。这避免了 Bangumi token 未配置时 anime 库整体失败的问题。
|
|
||||||
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match {
|
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match {
|
||||||
kind := ""
|
kind := ""
|
||||||
if lib != nil {
|
if lib != nil {
|
||||||
kind = lib.Type
|
kind = lib.Type
|
||||||
}
|
}
|
||||||
switch kind {
|
|
||||||
case "anime":
|
|
||||||
if s.bangumi != nil && s.bangumi.Enabled() {
|
|
||||||
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil {
|
|
||||||
return m
|
|
||||||
} else if err != nil {
|
|
||||||
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.tmdb != nil && s.tmdb.Enabled() {
|
if s.tmdb != nil && s.tmdb.Enabled() {
|
||||||
// anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。
|
// anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。
|
||||||
if kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows" {
|
if kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows" {
|
||||||
@@ -737,13 +733,6 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
|
|||||||
s.log.Debug("tmdb movie search failed", zap.String("query", query), zap.Error(err))
|
s.log.Debug("tmdb movie search failed", zap.String("query", query), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() {
|
|
||||||
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil {
|
|
||||||
return m
|
|
||||||
} else if err != nil {
|
|
||||||
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.douban != nil && s.douban.Enabled() {
|
if s.douban != nil && s.douban.Enabled() {
|
||||||
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil {
|
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil {
|
||||||
return m
|
return m
|
||||||
@@ -751,6 +740,20 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
|
|||||||
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
|
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.bangumi != nil && s.bangumi.Enabled() {
|
||||||
|
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil {
|
||||||
|
return m
|
||||||
|
} else if err != nil {
|
||||||
|
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() {
|
||||||
|
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil {
|
||||||
|
return m
|
||||||
|
} else if err != nil {
|
||||||
|
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user