mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
修改刮削逻辑,添加保存元数据功能
修改刮削逻辑,添加保存元数据功能
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user