mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 21:36: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) {
|
if !(options.DeferEpisodeDetails && m != nil && m.EpisodeNum > 0) {
|
||||||
s.writeMediaNFOAfterScrape(ctx, m, lib)
|
s.writeMediaNFOAfterScrape(ctx, m, lib)
|
||||||
|
s.writeMediaArtworkFilesAfterScrape(ctx, m, lib)
|
||||||
}
|
}
|
||||||
s.invalidateMediaCache(ctx)
|
s.invalidateMediaCache(ctx)
|
||||||
s.hub.Publish("scrape", map[string]any{
|
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) {
|
if s.fetchAndSaveTMDbEpisodeDetails(ctx, media, media.TMDbID, media.Year, options) {
|
||||||
s.writeMediaNFOAfterScrape(ctx, media, lib)
|
s.writeMediaNFOAfterScrape(ctx, media, lib)
|
||||||
|
s.writeMediaArtworkFilesAfterScrape(ctx, media, lib)
|
||||||
s.invalidateMediaCache(ctx)
|
s.invalidateMediaCache(ctx)
|
||||||
}
|
}
|
||||||
if i < len(rows)-1 {
|
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 type { LucideIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
Cast,
|
Cast,
|
||||||
Clock,
|
Clock,
|
||||||
CloudDownload,
|
CloudDownload,
|
||||||
Compass,
|
Compass,
|
||||||
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Heart,
|
Heart,
|
||||||
@@ -71,21 +73,23 @@ export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
|
|||||||
id: 'downloads',
|
id: 'downloads',
|
||||||
label: '下载与订阅',
|
label: '下载与订阅',
|
||||||
icon: CloudDownload,
|
icon: CloudDownload,
|
||||||
activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites'],
|
activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites', '/tasks'],
|
||||||
items: [
|
items: [
|
||||||
{ to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' },
|
{ to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' },
|
||||||
{ to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' },
|
{ to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' },
|
||||||
{ to: '/sites', label: '站点管理', icon: Globe, permission: 'can_manage_sites' },
|
{ to: '/sites', label: '站点管理', icon: Globe, permission: 'can_manage_sites' },
|
||||||
|
{ to: '/tasks', label: '实时任务', icon: Activity },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tools',
|
id: 'tools',
|
||||||
label: '文件与自动化',
|
label: '文件与自动化',
|
||||||
icon: HardDrive,
|
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,
|
adminOnly: true,
|
||||||
items: [
|
items: [
|
||||||
{ to: '/storage', label: '存储与文件', icon: HardDrive },
|
{ to: '/storage', label: '存储与文件', icon: HardDrive },
|
||||||
|
{ to: '/strm', label: 'STRM 管理', icon: FileText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user