修改刮削逻辑,添加保存元数据功能

修改刮削逻辑,添加保存元数据功能
This commit is contained in:
truewhile
2026-08-20 11:24:40 +08:00
parent 1dc9086bb4
commit d368353656
5 changed files with 224 additions and 2 deletions
+1
View File
@@ -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{
+1
View File
@@ -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 {
@@ -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 (<base>-poster.jpg /
// <base>-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/<name>.<ext>. 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/<name>.<ext>
// 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"
}
}
@@ -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))
}
}
+6 -2
View File
@@ -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 },
],
},
{