From d3683536560b2f5daa2b5aba75929c209a6b74c5 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:24:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=88=AE=E5=89=8A=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E6=B7=BB=E5=8A=A0=E4=BF=9D=E5=AD=98=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改刮削逻辑,添加保存元数据功能 --- internal/service/scraper.go | 1 + internal/service/scraper_tmdb_details.go | 1 + .../service/scraper_write_metadata_files.go | 127 ++++++++++++++++++ .../scraper_write_metadata_files_test.go | 89 ++++++++++++ web/src/components/layoutNavigation.ts | 8 +- 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 internal/service/scraper_write_metadata_files.go create mode 100644 internal/service/scraper_write_metadata_files_test.go diff --git a/internal/service/scraper.go b/internal/service/scraper.go index 509ef22..c60a7e4 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -195,6 +195,7 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m } if !(options.DeferEpisodeDetails && m != nil && m.EpisodeNum > 0) { s.writeMediaNFOAfterScrape(ctx, m, lib) + s.writeMediaArtworkFilesAfterScrape(ctx, m, lib) } s.invalidateMediaCache(ctx) s.hub.Publish("scrape", map[string]any{ diff --git a/internal/service/scraper_tmdb_details.go b/internal/service/scraper_tmdb_details.go index 60724e6..16a4484 100644 --- a/internal/service/scraper_tmdb_details.go +++ b/internal/service/scraper_tmdb_details.go @@ -141,6 +141,7 @@ func (s *ScraperService) enrichDeferredEpisodeDetails(ctx context.Context, rows } if s.fetchAndSaveTMDbEpisodeDetails(ctx, media, media.TMDbID, media.Year, options) { s.writeMediaNFOAfterScrape(ctx, media, lib) + s.writeMediaArtworkFilesAfterScrape(ctx, media, lib) s.invalidateMediaCache(ctx) } if i < len(rows)-1 { diff --git a/internal/service/scraper_write_metadata_files.go b/internal/service/scraper_write_metadata_files.go new file mode 100644 index 0000000..f14ab7d --- /dev/null +++ b/internal/service/scraper_write_metadata_files.go @@ -0,0 +1,127 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "strings" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// writeMediaArtworkFilesAfterScrape downloads the scraped poster/backdrop into +// the media folder as Jellyfin/Emby sidecars (-poster.jpg / +// -backdrop.jpg), scoped by the media file's base name so multiple movies +// sharing one directory never overwrite each other's artwork. +// +// Cloud media (cloud:// paths) are skipped entirely — the media folder is not +// writable for cloud mounts, and sidecars would be meaningless. Mirrors the +// local/cloud gating used by writeMediaNFOAfterScrape. +func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context, m *model.Media, lib *model.Library) { + if s == nil || s.images == nil || m == nil { + return + } + cloudMedia := isCloudMediaPath(m.Path) || (lib != nil && isCloudMediaPath(lib.Path)) + if cloudMedia { + return + } + // Reload the media from the DB so we use the freshly-saved poster/backdrop + // URLs rather than the stale in-memory values (the caller updates the row + // before invoking this). + refreshed, err := s.repo.Media.FindByID(ctx, m.ID) + if err != nil || refreshed == nil { + return + } + dir := filepath.Dir(resolveMappedDestinationPath(refreshed.Path)) + if dir == "" || dir == "." { + return + } + // Scope sidecar names by the media file's base name (e.g. A.mp4 -> A-poster.jpg) + // so that multiple movies sharing one directory (A.mp4 + B.mp4) never clash. + base := strings.TrimSuffix(filepath.Base(refreshed.Path), filepath.Ext(refreshed.Path)) + if base == "" || base == "." { + return + } + if refreshed.PosterURL != "" { + s.downloadArtworkToPath(ctx, dir, base+"-poster", refreshed.PosterURL) + } + if refreshed.BackdropURL != "" { + s.downloadArtworkToPath(ctx, dir, base+"-backdrop", refreshed.BackdropURL) + } +} + +// downloadArtworkToPath fetches an artwork URL via the image proxy cache and +// writes it under dir/.. It is best-effort: any failure is logged +// and returns without error so it never breaks scraping. +func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) { + if !isHTTPish(raw) { + return + } + data, ctype, err := s.images.Fetch(ctx, raw) + if err != nil || len(data) == 0 { + s.log.Warn("scrape artwork download failed", + zap.String("name", name), + zap.String("url", raw), + zap.Error(err)) + return + } + if !isImageContentType(ctype) || isTransparentPlaceholderData(data) { + return + } + s.writeArtworkDataToPath(dir, name, ctype, data) +} + +// writeArtworkDataToPath writes in-memory artwork bytes to dir/. +// using a temp file + rename so readers never observe a partial file. Returns +// the destination path, or "" if the write failed. +func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []byte) string { + if len(data) == 0 { + return "" + } + if err := os.MkdirAll(dir, 0o755); err != nil { + s.log.Warn("scrape artwork mkdir failed", zap.String("dir", dir), zap.Error(err)) + return "" + } + dst := filepath.Join(dir, name+imageExtForContentType(ctype)) + tmp, err := os.CreateTemp(dir, "img-*.tmp") + if err != nil { + s.log.Warn("scrape artwork temp create failed", zap.String("dir", dir), zap.Error(err)) + return "" + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + 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 { + _ = os.Remove(tmp.Name()) + s.log.Warn("scrape artwork rename failed", zap.String("dst", dst), zap.Error(err)) + return "" + } + s.log.Debug("scrape artwork written", zap.String("dst", dst)) + return dst +} + +// imageExtForContentType maps a detected image MIME type to a file extension. +// Unknown image types fall back to a generic ".img" so we never write an empty +// extension that could confuse media players. +func imageExtForContentType(ctype string) string { + switch strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0])) { + case "image/jpeg", "image/pjpeg": + return ".jpg" + case "image/png", "image/x-png": + return ".png" + case "image/webp": + return ".webp" + case "image/gif": + return ".gif" + case "image/avif": + return ".avif" + default: + return ".img" + } +} diff --git a/internal/service/scraper_write_metadata_files_test.go b/internal/service/scraper_write_metadata_files_test.go new file mode 100644 index 0000000..07522a0 --- /dev/null +++ b/internal/service/scraper_write_metadata_files_test.go @@ -0,0 +1,89 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// TestWriteArtworkDataToPathWritesJellyfinSidecar verifies that in-memory +// artwork bytes are written as a Jellyfin/Emby sidecar (poster.jpg) in the +// media directory using the scraped image MIME type. +func TestWriteArtworkDataToPathWritesJellyfinSidecar(t *testing.T) { + scraper := &ScraperService{log: zap.NewNop()} + mediaDir := t.TempDir() + + dst := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG) + if dst == "" { + t.Fatal("expected a written destination path") + } + if filepath.Base(dst) != "poster.jpg" { + t.Fatalf("base = %q, want poster.jpg", filepath.Base(dst)) + } + data, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("poster.jpg not readable: %v", err) + } + if string(data) != string(testJPEG) { + t.Fatal("poster.jpg content mismatch") + } +} + +// TestImageExtForContentType verifies the MIME -> extension mapping used to +// name Jellyfin sidecar files. +func TestImageExtForContentType(t *testing.T) { + cases := map[string]string{ + "image/jpeg": ".jpg", + "image/pjpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "image/avif": ".avif", + "image/jpeg; charset=binary": ".jpg", + "application/octet-stream": ".img", + } + for in, want := range cases { + if got := imageExtForContentType(in); got != want { + t.Errorf("imageExtForContentType(%q) = %q, want %q", in, got, want) + } + } +} + +// TestWriteMediaArtworkFilesAfterScrapeSkipsCloud verifies that cloud media +// never has artwork written anywhere. +func TestWriteMediaArtworkFilesAfterScrapeSkipsCloud(t *testing.T) { + scraper := &ScraperService{log: zap.NewNop()} + cloud := &model.Media{ + Path: "cloud://aliyun/openlist/Some.Movie.mkv", + PosterURL: "https://image.tmdb.org/t/p/poster.jpg", + BackdropURL: "https://image.tmdb.org/t/p/backdrop.jpg", + } + scraper.writeMediaArtworkFilesAfterScrape(t.Context(), cloud, nil) + // No panic and no files written; the method should return early. +} + +// TestSameDirectoryMediaUsesBaseScopedSidecars verifies that two movies sharing +// one directory produce distinct, base-scoped sidecar names (A-poster.jpg vs +// B-poster.jpg) so they never overwrite each other. +func TestSameDirectoryMediaUsesBaseScopedSidecars(t *testing.T) { + mediaPath := filepath.Join(t.TempDir(), "Movie Folder", "A.mp4") + if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil { + t.Fatal(err) + } + scraper := &ScraperService{log: zap.NewNop()} + + // Simulate the base-scoped naming used by writeMediaArtworkFilesAfterScrape. + base := "A" + dir := filepath.Dir(mediaPath) + dst := scraper.writeArtworkDataToPath(dir, base+"-poster", "image/jpeg", testJPEG) + if dst == "" { + t.Fatal("expected a written destination") + } + if filepath.Base(dst) != "A-poster.jpg" { + t.Fatalf("base = %q, want A-poster.jpg", filepath.Base(dst)) + } +} diff --git a/web/src/components/layoutNavigation.ts b/web/src/components/layoutNavigation.ts index c683c86..fc7ed36 100644 --- a/web/src/components/layoutNavigation.ts +++ b/web/src/components/layoutNavigation.ts @@ -1,9 +1,11 @@ import type { LucideIcon } from 'lucide-react' import { + Activity, Cast, Clock, CloudDownload, Compass, + FileText, Globe, HardDrive, Heart, @@ -71,21 +73,23 @@ export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [ id: 'downloads', label: '下载与订阅', icon: CloudDownload, - activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites'], + activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites', '/tasks'], items: [ { to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' }, { to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' }, { to: '/sites', label: '站点管理', icon: Globe, permission: 'can_manage_sites' }, + { to: '/tasks', label: '实时任务', icon: Activity }, ], }, { id: 'tools', label: '文件与自动化', icon: HardDrive, - activePaths: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/tasks', '/scheduler', '/recycle', '/stats'], + activePaths: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/scheduler', '/recycle', '/stats'], adminOnly: true, items: [ { to: '/storage', label: '存储与文件', icon: HardDrive }, + { to: '/strm', label: 'STRM 管理', icon: FileText }, ], }, {