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 },
],
},
{