mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b2b592003 | |||
| 0c4668c156 | |||
| 474513f18a | |||
| 0810bfb234 |
@@ -44,7 +44,7 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("logging.max_backups", 10)
|
||||
|
||||
v.SetDefault("cache.cache_dir", "./cache")
|
||||
v.SetDefault("cache.images_max_size_mb", 500)
|
||||
v.SetDefault("cache.images_max_size_mb", 2000)
|
||||
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
|
||||
v.SetDefault("cache.cleanup_interval_min", 60)
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
|
||||
@@ -38,6 +38,12 @@ type ImageProxy struct {
|
||||
mu sync.Mutex
|
||||
fetchGroup singleflight.Group
|
||||
|
||||
// directClient bypasses HTTP_PROXY / OS proxy settings. It is built once and
|
||||
// shared: rebuilding the transport on every fetch discarded all keep-alive
|
||||
// connections, so every burst of poster requests paid a fresh TCP (and TLS)
|
||||
// handshake per image.
|
||||
directClient *http.Client
|
||||
|
||||
// resizeSem bounds concurrent decode/resize jobs. Emby TV clients request
|
||||
// poster grids in bursts; letting every request decode a source image at
|
||||
// once causes CPU and memory spikes that make the whole UI feel sluggish.
|
||||
@@ -113,6 +119,7 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
}
|
||||
|
||||
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
|
||||
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: NewInternalTransport()}
|
||||
return proxy
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ func (p *ImageProxy) remoteImageCachePaths(raw string) (string, string, string,
|
||||
}
|
||||
|
||||
func (p *ImageProxy) remoteImageCachePathsForValidated(raw string) (string, string, string) {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
sum := sha256.Sum256([]byte(imageCacheKeyURL(raw)))
|
||||
key := hex.EncodeToString(sum[:])
|
||||
cachePath := filepath.Join(p.cacheDir, key)
|
||||
return key, cachePath, cachePath + ".fail"
|
||||
|
||||
@@ -87,7 +87,10 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
return err
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
key, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
|
||||
// 已配置的远程 Emby 挂载:把尺寸直接转发给远端生成缩略图,缓存键也用
|
||||
// 带尺寸的地址,这样不同尺寸各自缓存互不覆盖。
|
||||
fetchURL := p.upstreamImageFetchURL(raw, opts)
|
||||
key, cachePath, failPath := p.remoteImageCachePathsForValidated(fetchURL)
|
||||
forceRefresh := r.URL.Query().Get("refresh") != ""
|
||||
p.removeUnusableImageCache(cachePath, failPath)
|
||||
if !forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
|
||||
@@ -95,7 +98,7 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
}
|
||||
// No negative caching: a previously failed fetch is retried on every
|
||||
// subsequent request, so the image recovers as soon as upstream does.
|
||||
data, ctype, contentLength, err := p.fetchAndCacheRemoteImageShared(ctx, raw, host, cachePath, failPath)
|
||||
data, ctype, contentLength, err := p.fetchAndCacheRemoteImageShared(ctx, fetchURL, host, cachePath, failPath)
|
||||
if err != nil {
|
||||
if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
|
||||
return nil
|
||||
@@ -179,7 +182,7 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
|
||||
return nil, "", "", errImageProxyRequestSetup
|
||||
}
|
||||
var lastErr error
|
||||
for _, candidate := range p.remoteImageFetchClients() {
|
||||
for _, candidate := range p.remoteImageFetchClients(host) {
|
||||
data, ctype, contentLength, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate)
|
||||
if err == nil {
|
||||
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
|
||||
@@ -263,9 +266,11 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err
|
||||
return data, ctype, err
|
||||
}
|
||||
|
||||
// writeImageCache atomically writes the fetched original. The global mutex is
|
||||
// deliberately not held: os.CreateTemp already yields a unique name and
|
||||
// os.Rename is atomic, so the lock only serialized multi-megabyte disk writes
|
||||
// and made one poster's write block every other image in flight.
|
||||
func (p *ImageProxy) writeImageCache(cachePath, failPath, pattern string, data []byte) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
tmp, tmpErr := os.CreateTemp(p.cacheDir, pattern)
|
||||
if tmpErr != nil {
|
||||
return
|
||||
|
||||
@@ -18,23 +18,32 @@ type remoteImageFetchClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p *ImageProxy) remoteImageFetchClients() []remoteImageFetchClient {
|
||||
func (p *ImageProxy) remoteImageFetchClients(host string) []remoteImageFetchClient {
|
||||
client := p.client
|
||||
if client == nil {
|
||||
client = NewExternalHTTPClient(30 * time.Second)
|
||||
}
|
||||
clients := []remoteImageFetchClient{{name: "default", client: client}}
|
||||
if _, ok := client.Transport.(*http.Transport); ok {
|
||||
timeout := client.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
clients = append(clients, remoteImageFetchClient{
|
||||
name: "direct",
|
||||
client: &http.Client{Timeout: timeout, Transport: NewInternalTransport()},
|
||||
})
|
||||
if _, ok := client.Transport.(*http.Transport); !ok {
|
||||
return []remoteImageFetchClient{{name: "default", client: client}}
|
||||
}
|
||||
return clients
|
||||
timeout := client.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
direct := p.directClient
|
||||
if direct == nil {
|
||||
direct = &http.Client{Timeout: timeout, Transport: NewInternalTransport()}
|
||||
}
|
||||
directCandidate := remoteImageFetchClient{name: "direct", client: direct}
|
||||
defaultCandidate := remoteImageFetchClient{name: "default", client: client}
|
||||
// Hosts the user configured themselves — remote Emby mounts and their image
|
||||
// endpoints — are reached over the LAN or a dedicated line. Routing those
|
||||
// through the OS/env proxy first costs a failed attempt before every single
|
||||
// image, so try the direct client first for them.
|
||||
if p.isAllowedRemoteHost(host) {
|
||||
return []remoteImageFetchClient{directCandidate, defaultCandidate}
|
||||
}
|
||||
return []remoteImageFetchClient{defaultCandidate, directCandidate}
|
||||
}
|
||||
|
||||
func (p *ImageProxy) canUseExternalImageFallback() bool {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// imageCacheKeyURL normalizes an upstream image URL before it is hashed into a
|
||||
// disk cache key. Credential query parameters are dropped: a remote Emby token
|
||||
// is per-account, so removing it cannot make two different images collide, but
|
||||
// it stops a token rotation from invalidating every cached poster at once.
|
||||
func imageCacheKeyURL(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" {
|
||||
return raw
|
||||
}
|
||||
q := u.Query()
|
||||
dropped := false
|
||||
for key := range q {
|
||||
switch strings.ToLower(key) {
|
||||
case "api_key", "apikey", "x-emby-token", "x-mediabrowser-token":
|
||||
q.Del(key)
|
||||
dropped = true
|
||||
}
|
||||
}
|
||||
if !dropped {
|
||||
return raw
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// upstreamImageFetchURL forwards the client's thumbnail request to a configured
|
||||
// remote Emby mount. The remote server can produce the thumbnail itself from
|
||||
// maxWidth/maxHeight/quality, so the proxy transfers a few dozen KB instead of
|
||||
// the full-size original and skips the local decode+scale entirely (measured at
|
||||
// ~400ms for a single 2892×4096 poster).
|
||||
//
|
||||
// Other upstreams (TMDb, Douban, adult sites, ...) do not honour these
|
||||
// parameters, so their originals are still fetched and scaled locally.
|
||||
func (p *ImageProxy) upstreamImageFetchURL(raw string, opts imageResizeOptions) string {
|
||||
if p == nil || !opts.active() {
|
||||
return raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return raw
|
||||
}
|
||||
if !p.isAllowedRemoteHost(u.Hostname()) {
|
||||
return raw
|
||||
}
|
||||
q := u.Query()
|
||||
if opts.MaxWidth > 0 {
|
||||
q.Set("maxWidth", strconv.Itoa(opts.MaxWidth))
|
||||
}
|
||||
if opts.MaxHeight > 0 {
|
||||
q.Set("maxHeight", strconv.Itoa(opts.MaxHeight))
|
||||
}
|
||||
q.Set("quality", strconv.Itoa(opts.encodingQuality()))
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func newUpstreamImageProxy(t *testing.T, allowedHosts ...string) *ImageProxy {
|
||||
t.Helper()
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
if len(allowedHosts) > 0 {
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string { return allowedHosts })
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
func TestUpstreamImageFetchURLForwardsResizeToConfiguredMount(t *testing.T) {
|
||||
proxy := newUpstreamImageProxy(t, "192.168.1.50:8096")
|
||||
raw := "http://192.168.1.50:8096/emby/Items/abc/Images/Primary?api_key=secret"
|
||||
|
||||
got := proxy.upstreamImageFetchURL(raw, imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 82})
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("maxWidth") != "480" || q.Get("maxHeight") != "600" || q.Get("quality") != "82" {
|
||||
t.Fatalf("forwarded query = %q, want maxWidth=480 maxHeight=600 quality=82", u.RawQuery)
|
||||
}
|
||||
if q.Get("api_key") != "secret" {
|
||||
t.Fatalf("api_key = %q, want the credential preserved", q.Get("api_key"))
|
||||
}
|
||||
if u.Path != "/emby/Items/abc/Images/Primary" {
|
||||
t.Fatalf("path = %q, want the item image path unchanged", u.Path)
|
||||
}
|
||||
|
||||
// The default quality is applied when the client does not send one.
|
||||
got = proxy.upstreamImageFetchURL(raw, imageResizeOptions{MaxWidth: 160})
|
||||
if !strings.Contains(got, "quality="+strconv.Itoa(imageResizeDefaultQuality)) {
|
||||
t.Fatalf("url = %q, want the default encoding quality forwarded", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamImageFetchURLLeavesOtherUpstreamsAlone(t *testing.T) {
|
||||
proxy := newUpstreamImageProxy(t, "192.168.1.50:8096")
|
||||
opts := imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 82}
|
||||
|
||||
// Not a configured mount: TMDb/Douban do not honour these parameters, so the
|
||||
// proxy must keep fetching their originals and resize locally.
|
||||
tmdb := "https://image.tmdb.org/t/p/original/poster.jpg"
|
||||
if got := proxy.upstreamImageFetchURL(tmdb, opts); got != tmdb {
|
||||
t.Fatalf("url = %q, want %q unchanged", got, tmdb)
|
||||
}
|
||||
// Configured mount but no size requested: keep the full original.
|
||||
mount := "http://192.168.1.50:8096/emby/Items/abc/Images/Primary?api_key=secret"
|
||||
if got := proxy.upstreamImageFetchURL(mount, imageResizeOptions{}); got != mount {
|
||||
t.Fatalf("url = %q, want %q unchanged", got, mount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteImageCacheKeyIgnoresCredentialRotation(t *testing.T) {
|
||||
proxy := newUpstreamImageProxy(t)
|
||||
first := "http://192.168.1.50:8096/emby/Items/abc/Images/Primary?api_key=old-token"
|
||||
second := "http://192.168.1.50:8096/emby/Items/abc/Images/Primary?api_key=new-token"
|
||||
|
||||
_, firstPath, _ := proxy.remoteImageCachePathsForValidated(first)
|
||||
_, secondPath, _ := proxy.remoteImageCachePathsForValidated(second)
|
||||
if firstPath != secondPath {
|
||||
t.Fatalf("token rotation changed the cache key: %q vs %q", firstPath, secondPath)
|
||||
}
|
||||
|
||||
other := "http://192.168.1.50:8096/emby/Items/zzz/Images/Primary?api_key=new-token"
|
||||
_, otherPath, _ := proxy.remoteImageCachePathsForValidated(other)
|
||||
if otherPath == secondPath {
|
||||
t.Fatal("different items must not share a cache key")
|
||||
}
|
||||
|
||||
// A generic `token` parameter can be part of a signed URL's identity, so it
|
||||
// must stay in the key. Only credential-ish names are dropped.
|
||||
signed := "http://cdn.example.com/img/1.jpg?token=aaa"
|
||||
signedOther := "http://cdn.example.com/img/1.jpg?token=bbb"
|
||||
_, signedPath, _ := proxy.remoteImageCachePathsForValidated(signed)
|
||||
_, signedOtherPath, _ := proxy.remoteImageCachePathsForValidated(signedOther)
|
||||
if signedPath == signedOtherPath {
|
||||
t.Fatal("generic token query parameters must remain part of the cache key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteImageFetchClientsPreferDirectForConfiguredMount(t *testing.T) {
|
||||
proxy := newUpstreamImageProxy(t, "192.168.1.50:8096")
|
||||
|
||||
mountClients := proxy.remoteImageFetchClients("192.168.1.50:8096")
|
||||
if len(mountClients) != 2 || mountClients[0].name != "direct" || mountClients[1].name != "default" {
|
||||
t.Fatalf("mount client order = %+v, want direct first", clientNames(mountClients))
|
||||
}
|
||||
if mountClients[0].client != proxy.directClient {
|
||||
t.Fatal("direct fetches must reuse the shared no-proxy client")
|
||||
}
|
||||
|
||||
cdnClients := proxy.remoteImageFetchClients("image.tmdb.org")
|
||||
if len(cdnClients) != 2 || cdnClients[0].name != "default" || cdnClients[1].name != "direct" {
|
||||
t.Fatalf("cdn client order = %+v, want default first", clientNames(cdnClients))
|
||||
}
|
||||
}
|
||||
|
||||
func clientNames(clients []remoteImageFetchClient) []string {
|
||||
out := make([]string, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
out = append(out, c.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestServeRemoteImageForwardsResizeAndCachesPerSize is the regression test for
|
||||
// sluggish artwork on mounted Emby libraries: MeBox used to download the remote
|
||||
// original and scale it locally for every requested size. The size must now be
|
||||
// forwarded to the mount, and each size must get its own cache entry.
|
||||
func TestServeRemoteImageForwardsResizeAndCachesPerSize(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
queries := []url.Values{}
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
queries = append(queries, r.URL.Query())
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=secret"
|
||||
|
||||
serve := func(t *testing.T, query string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/img?"+query, nil)
|
||||
if err := proxy.Serve(t.Context(), rec, req, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
rec := serve(t, "maxWidth=480&maxHeight=600&quality=82")
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("body = %x, want the upstream image", got)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
first := append([]url.Values(nil), queries...)
|
||||
mu.Unlock()
|
||||
if len(first) != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", len(first))
|
||||
}
|
||||
if first[0].Get("maxWidth") != "480" || first[0].Get("maxHeight") != "600" || first[0].Get("quality") != "82" {
|
||||
t.Fatalf("upstream query = %q, want the client's thumbnail request forwarded", first[0].Encode())
|
||||
}
|
||||
|
||||
// Same size again: served from the disk cache, no second upstream call.
|
||||
serve(t, "maxWidth=480&maxHeight=600&quality=82")
|
||||
mu.Lock()
|
||||
if len(queries) != 1 {
|
||||
mu.Unlock()
|
||||
t.Fatalf("upstream calls = %d, want 1 after a cache hit", len(queries))
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// A different size must be its own cache entry, not a re-use of the 480px file.
|
||||
serve(t, "maxWidth=160&quality=60")
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("upstream calls = %d, want 2 for two distinct sizes", len(queries))
|
||||
}
|
||||
if got := queries[1].Get("maxWidth"); got != "160" {
|
||||
t.Fatalf("second upstream maxWidth = %q, want 160", got)
|
||||
}
|
||||
if got := queries[1].Get("quality"); got != "60" {
|
||||
t.Fatalf("second upstream quality = %q, want 60", got)
|
||||
}
|
||||
}
|
||||
@@ -344,14 +344,14 @@ func isCompactWebImage(header []byte) bool {
|
||||
}
|
||||
|
||||
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
|
||||
// 与 writeImageCache 一样不再持有全局锁:临时文件名唯一、rename 原子,
|
||||
// 持锁只会把缩略图的写盘和原图的写盘串成一条队。
|
||||
func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) {
|
||||
dir := filepath.Dir(cachePath)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
p.warn("imageproxy: resize cache mkdir failed", err)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
tmp, err := os.CreateTemp(dir, "resized-*.tmp")
|
||||
if err != nil {
|
||||
p.warn("imageproxy: resize cache temp failed", err)
|
||||
|
||||
@@ -89,6 +89,7 @@ export function Layout() {
|
||||
)}
|
||||
<LayoutWorkspace
|
||||
routeKey={location.pathname}
|
||||
scrollKey={`${location.pathname}${location.search}`}
|
||||
userKey={user?.id}
|
||||
showMobileBottomNav={showMobileBottomNav}
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,8 @@ type LayoutSidebarsProps = Omit<
|
||||
|
||||
type LayoutWorkspaceProps = {
|
||||
routeKey: string
|
||||
/** 滚动记忆的 key:含 query,让同一路径下的不同视图(如剧集面板)各记一份位置。 */
|
||||
scrollKey?: string
|
||||
userKey?: string
|
||||
showMobileBottomNav?: boolean
|
||||
}
|
||||
@@ -113,10 +115,11 @@ export function LayoutSidebars({
|
||||
|
||||
export function LayoutWorkspace({
|
||||
routeKey,
|
||||
scrollKey,
|
||||
userKey = 'anonymous',
|
||||
showMobileBottomNav = false,
|
||||
}: LayoutWorkspaceProps) {
|
||||
useScrollMemory(routeKey, userKey)
|
||||
useScrollMemory(scrollKey ?? routeKey, userKey)
|
||||
|
||||
const bottomPad = showMobileBottomNav
|
||||
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
||||
|
||||
@@ -10,13 +10,14 @@ const ACTION_OVERLAY_CLASS =
|
||||
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
|
||||
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
|
||||
export const MediaCard = memo(function MediaCard({
|
||||
media, progress, count, rating, linkTo, onClick, actions, renderActions, compact,
|
||||
media, progress, count, rating, linkTo, linkState, onClick, actions, renderActions, compact,
|
||||
}: {
|
||||
media: Media
|
||||
progress?: number
|
||||
count?: number
|
||||
rating?: number
|
||||
linkTo?: string
|
||||
linkState?: { from?: string }
|
||||
onClick?: () => void
|
||||
actions?: ReactNode
|
||||
renderActions?: (media: Media) => ReactNode
|
||||
@@ -169,7 +170,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
if (actionContent) {
|
||||
return (
|
||||
<div className="group relative block">
|
||||
<Link to={href} className="block">
|
||||
<Link to={href} state={linkState} className="block">
|
||||
{card}
|
||||
</Link>
|
||||
<div className={ACTION_OVERLAY_CLASS}>
|
||||
@@ -180,7 +181,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} className="group block">
|
||||
<Link to={href} state={linkState} className="group block">
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { shouldPersistScrollSample, shouldRememberScroll } from './useScrollMemory.ts'
|
||||
import {
|
||||
isJumpIntentKey,
|
||||
isScrollIntentKey,
|
||||
shouldPersistClampedSample,
|
||||
shouldPersistScrollSample,
|
||||
shouldRememberScroll,
|
||||
} from './useScrollMemory.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`useScrollMemory: ${name}`)
|
||||
@@ -7,6 +13,7 @@ function check(name: string, condition: boolean) {
|
||||
check('home remembers scroll', shouldRememberScroll('/'))
|
||||
check('library list remembers scroll', shouldRememberScroll('/libraries'))
|
||||
check('library detail remembers scroll', shouldRememberScroll('/library/lib-1'))
|
||||
check('library series panel remembers scroll', shouldRememberScroll('/library/lib-1'))
|
||||
check('media detail remembers scroll', shouldRememberScroll('/media/media-1'))
|
||||
check('settings does not remember scroll', !shouldRememberScroll('/settings'))
|
||||
check('player does not remember scroll', !shouldRememberScroll('/play/media-1'))
|
||||
@@ -48,4 +55,48 @@ check(
|
||||
}),
|
||||
)
|
||||
|
||||
// 恢复途中内容还没长出来(滚不动)或被钳在矮内容底部时,scrollTop 是浏览器钳制
|
||||
// 出来的值,不能写回存储,否则一次误触就把记忆位置清成 0。
|
||||
check(
|
||||
'clamped sample while list is still loading is ignored',
|
||||
!shouldPersistClampedSample({ current: 0, maxScroll: 0, saved: 1800 }),
|
||||
)
|
||||
check(
|
||||
'clamped sample at the bottom of a shrunken list is ignored',
|
||||
!shouldPersistClampedSample({ current: 1200, maxScroll: 1200, saved: 1800 }),
|
||||
)
|
||||
check(
|
||||
'sample at the bottom of a completely scrolled list stays persistable',
|
||||
shouldPersistClampedSample({ current: 1800, maxScroll: 1800, saved: 1800 }),
|
||||
)
|
||||
check(
|
||||
'reachable scroll position is persisted',
|
||||
shouldPersistClampedSample({ current: 900, maxScroll: 1200, saved: 1800 }),
|
||||
)
|
||||
check(
|
||||
'reaching the remembered target is persisted',
|
||||
shouldPersistClampedSample({ current: 1800, maxScroll: 2600, saved: 1800 }),
|
||||
)
|
||||
check(
|
||||
'deeper than the remembered target is persisted',
|
||||
shouldPersistClampedSample({ current: 3000, maxScroll: 4000, saved: 1800 }),
|
||||
)
|
||||
check(
|
||||
'intentional scroll to top of a tall list is persisted',
|
||||
shouldPersistClampedSample({ current: 0, maxScroll: 4000, saved: 1800 }),
|
||||
)
|
||||
check(
|
||||
'unscrollable page without memory cannot persist',
|
||||
!shouldPersistClampedSample({ current: 0, maxScroll: 0, saved: 0 }),
|
||||
)
|
||||
|
||||
check('page down counts as scroll intent', isScrollIntentKey('PageDown'))
|
||||
check('space counts as scroll intent', isScrollIntentKey(' '))
|
||||
check('typing does not count as scroll intent', !isScrollIntentKey('a'))
|
||||
check('shortcut does not count as scroll intent', !isScrollIntentKey('Meta'))
|
||||
check('page down allows a long jump', isJumpIntentKey('PageDown'))
|
||||
check('home allows a long jump', isJumpIntentKey('Home'))
|
||||
check('arrow key keeps smooth-only intent', !isJumpIntentKey('ArrowDown'))
|
||||
check('space keeps smooth-only intent', !isJumpIntentKey(' '))
|
||||
|
||||
console.log('useScrollMemory.test.ts ok')
|
||||
|
||||
@@ -3,6 +3,36 @@ import { useLayoutEffect } from 'react'
|
||||
const SCROLL_STORAGE_PREFIX = 'mebox.scroll.'
|
||||
const scrollPositions = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* 用户滚动意图的有效期:滚轮 / 触摸 / 翻页键之后这段时间内的 scroll 事件才算
|
||||
* “用户自己滚出来的位置”。连续滚动(含惯性)期间每个 scroll 事件都会续期,
|
||||
* 所以正常滑动不会中途过期,而恢复回填 / 虚拟列表量高这类程序化位移会被排除。
|
||||
*/
|
||||
const USER_SCROLL_INTENT_TTL_MS = 800
|
||||
|
||||
/** 平滑滚动(滚轮/触摸/方向键)单次采样允许的最大“向上跳”距离。 */
|
||||
function maxSmoothJumpUp(viewportHeight: number): number {
|
||||
return Math.max(viewportHeight * 2, 1200)
|
||||
}
|
||||
|
||||
/** 会滚动容器的按键。 */
|
||||
export function isScrollIntentKey(key: string): boolean {
|
||||
return (
|
||||
key === 'PageDown' ||
|
||||
key === 'PageUp' ||
|
||||
key === 'Home' ||
|
||||
key === 'End' ||
|
||||
key === 'ArrowDown' ||
|
||||
key === 'ArrowUp' ||
|
||||
key === ' '
|
||||
)
|
||||
}
|
||||
|
||||
/** 一次按键可能直接跳很远(首页/末页/翻页),这些不算异常向上跳。 */
|
||||
export function isJumpIntentKey(key: string): boolean {
|
||||
return key === 'PageDown' || key === 'PageUp' || key === 'Home' || key === 'End'
|
||||
}
|
||||
|
||||
function readScrollPosition(key: string): number {
|
||||
const cached = scrollPositions.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
@@ -56,21 +86,54 @@ export function shouldPersistScrollSample(input: {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一次滚动采样是不是“用户真正选中的位置”。
|
||||
*
|
||||
* 内容还在加载(容器根本滚不动)或被钳在“变矮内容”的底部时,scrollTop 是浏览器
|
||||
* 钳制出来的值:恢复途中任何一次滚轮 / 触摸 / 按键都会读到这个值,写回存储就会把
|
||||
* 记忆位置抹成 0(表现为返回媒体库时永远停在顶部,且之后再也不会记录)。
|
||||
*/
|
||||
export function shouldPersistClampedSample(input: {
|
||||
current: number
|
||||
maxScroll: number
|
||||
saved: number
|
||||
}): boolean {
|
||||
const { current, maxScroll, saved } = input
|
||||
if (maxScroll <= 0) {
|
||||
return false
|
||||
}
|
||||
if (current >= maxScroll && current < saved) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度,
|
||||
* 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。
|
||||
*
|
||||
* 恢复还没完成时,容器里的 scrollTop 往往是浏览器钳制出来的值(内容还在加载
|
||||
* 占位,或被钳在变矮内容的底部)。这种采样既不写回存储,也不当成用户“接管”,
|
||||
* 否则一次误触的滚轮 / 触摸就会把记忆位置清成 0。
|
||||
*
|
||||
* 只有带“用户滚动意图”(滚轮 / 触摸 / 翻页键 / 拖滚动条)的 scroll 事件才会写
|
||||
* 存储:恢复回填、虚拟列表挂载量高、浏览器钳制造成的位移都不算用户选择的位置。
|
||||
*
|
||||
* routeKey 允许包含 query:媒体库详情页的剧集面板(`/library/:id?series=...`)
|
||||
* 与网格视图是两块不同内容,各自记一份位置,互不覆盖。
|
||||
*/
|
||||
export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
||||
export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
|
||||
useLayoutEffect(() => {
|
||||
const el = document.getElementById('app-main-scroll')
|
||||
if (!el) return
|
||||
|
||||
const pathname = routeKey.split('?')[0]
|
||||
if (!shouldRememberScroll(pathname)) {
|
||||
el.scrollTop = 0
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${userKey}:${pathname}`
|
||||
const key = `${userKey}:${routeKey}`
|
||||
const saved = readScrollPosition(key)
|
||||
let restoring = saved > 0
|
||||
let observer: ResizeObserver | null = null
|
||||
@@ -79,6 +142,36 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
||||
let restorePumpUntil = 0
|
||||
let lastSaved = saved
|
||||
let lastHeight = el.scrollHeight
|
||||
// 最近一次“用户主动滚动”的时间戳与类型(滚轮 / 触摸 / 翻页键 / 拖滚动条)。
|
||||
// 只有用户自己滚出来的位置才写回存储:恢复逻辑、虚拟列表挂载量高、浏览器
|
||||
// 钳制造成的 scrollTop 变化都不代表用户想要的位置,写进去就会把记忆抹掉。
|
||||
let userScrollUntil = 0
|
||||
let userScrollAllowsJump = false
|
||||
const markUserScroll = (allowsJump = false) => {
|
||||
const now = performance.now()
|
||||
// 上一段意图已过期:新的一段从“只允许小幅向上跳”开始。
|
||||
if (userScrollUntil <= now) {
|
||||
userScrollAllowsJump = false
|
||||
}
|
||||
userScrollUntil = now + USER_SCROLL_INTENT_TTL_MS
|
||||
if (allowsJump) {
|
||||
userScrollAllowsJump = true
|
||||
}
|
||||
}
|
||||
const userScrolling = () => userScrollUntil > performance.now()
|
||||
|
||||
const currentMaxScroll = () => Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
|
||||
// 只有“用户真正能滚到”的位置才算有效采样:内容还在加载占位(滚不动)或
|
||||
// 被钳在变矮内容的底部时,scrollTop 都是浏览器钳制出来的值。
|
||||
const sampleIsTrustworthy = (current: number) =>
|
||||
shouldPersistClampedSample({ current, maxScroll: currentMaxScroll(), saved })
|
||||
|
||||
const adoptScrollPosition = (current: number) => {
|
||||
lastSaved = current
|
||||
lastHeight = el.scrollHeight
|
||||
writeScrollPosition(key, current)
|
||||
}
|
||||
|
||||
const stopRestore = () => {
|
||||
if (restoreFrame) {
|
||||
@@ -97,14 +190,18 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
||||
if (!restoring) return
|
||||
restoring = false
|
||||
stopRestore()
|
||||
lastSaved = Math.round(el.scrollTop)
|
||||
lastHeight = el.scrollHeight
|
||||
writeScrollPosition(key, lastSaved)
|
||||
const current = Math.round(el.scrollTop)
|
||||
if (!sampleIsTrustworthy(current)) {
|
||||
// 目标一直不可达(内容仍比记忆位置矮 / 还在加载):保留原记忆值,
|
||||
// 不要把钳制出来的位置写回去。
|
||||
return
|
||||
}
|
||||
adoptScrollPosition(current)
|
||||
}
|
||||
|
||||
const tryRestore = () => {
|
||||
if (!restoring) return
|
||||
const max = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const max = currentMaxScroll()
|
||||
const target = Math.min(saved, max)
|
||||
if (target > 0 && Math.abs(el.scrollTop - target) > 1) {
|
||||
el.scrollTop = target
|
||||
@@ -125,8 +222,30 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
||||
}
|
||||
|
||||
const saveNow = () => {
|
||||
if (restoring) return
|
||||
const current = Math.round(el.scrollTop)
|
||||
const userDriven = userScrolling()
|
||||
// 惯性/连续滚动期间持续续期,避免长距离滑动中途被判定为“非用户滚动”。
|
||||
if (userDriven) markUserScroll(userScrollAllowsJump)
|
||||
// 内容还没长回来时读到的 scrollTop 不是用户选的位置,既不能写存储,
|
||||
// 也不能当成“用户接管”。
|
||||
if (!sampleIsTrustworthy(current)) return
|
||||
// 一瞬间大幅向上跳(恢复回填、虚拟列表挂载量高、浏览器钳制)只有按键翻页 /
|
||||
// 拖滚动条这类意图才可能是用户行为,否则直接丢弃。
|
||||
const upJump = lastSaved > current ? lastSaved - current : 0
|
||||
if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) {
|
||||
return
|
||||
}
|
||||
if (restoring) {
|
||||
if (!userDriven || Math.abs(current - saved) <= 1) return
|
||||
// 恢复途中用户真的滚到了别处:交还控制权并记录这个位置,避免恢复
|
||||
// 逻辑继续和用户抢滚动条。
|
||||
restoring = false
|
||||
stopRestore()
|
||||
adoptScrollPosition(current)
|
||||
return
|
||||
}
|
||||
// 非用户滚动(恢复逻辑回填、虚拟列表挂载/量高、浏览器钳制)不写存储。
|
||||
if (!userDriven) return
|
||||
const height = el.scrollHeight
|
||||
if (
|
||||
!shouldPersistScrollSample({
|
||||
@@ -136,7 +255,6 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
||||
lastHeight,
|
||||
})
|
||||
) {
|
||||
lastHeight = height
|
||||
return
|
||||
}
|
||||
lastHeight = height
|
||||
@@ -145,13 +263,13 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
||||
writeScrollPosition(key, current)
|
||||
}
|
||||
|
||||
const cancelRestore = () => {
|
||||
if (!restoring) return
|
||||
restoring = false
|
||||
stopRestore()
|
||||
lastSaved = Math.round(el.scrollTop)
|
||||
lastHeight = el.scrollHeight
|
||||
writeScrollPosition(key, lastSaved)
|
||||
// 用户滚动意图:滚轮 / 触摸 = 平滑滚动;翻页键 / 拖滚动条 = 允许直接跳很远。
|
||||
const onScrollIntent = () => {
|
||||
markUserScroll(false)
|
||||
}
|
||||
|
||||
const onJumpIntent = () => {
|
||||
markUserScroll(true)
|
||||
}
|
||||
|
||||
if (saved <= 0) {
|
||||
@@ -171,42 +289,37 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
// 点在容器本身 = 拖滚动条或在空白处按下:允许大幅跳转。
|
||||
if (event.target === el) {
|
||||
cancelRestore()
|
||||
onJumpIntent()
|
||||
}
|
||||
saveNow()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === 'PageDown' ||
|
||||
event.key === 'PageUp' ||
|
||||
event.key === 'Home' ||
|
||||
event.key === 'End' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === ' '
|
||||
) {
|
||||
cancelRestore()
|
||||
if (isJumpIntentKey(event.key)) {
|
||||
onJumpIntent()
|
||||
} else if (isScrollIntentKey(event.key)) {
|
||||
onScrollIntent()
|
||||
}
|
||||
saveNow()
|
||||
}
|
||||
|
||||
el.addEventListener('scroll', saveNow, { passive: true })
|
||||
el.addEventListener('pointerdown', onPointerDown, true)
|
||||
window.addEventListener('wheel', cancelRestore, { passive: true, capture: true })
|
||||
window.addEventListener('touchstart', cancelRestore, { passive: true, capture: true })
|
||||
window.addEventListener('wheel', onScrollIntent, { passive: true, capture: true })
|
||||
window.addEventListener('touchstart', onScrollIntent, { passive: true, capture: true })
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', saveNow)
|
||||
el.removeEventListener('pointerdown', onPointerDown, true)
|
||||
window.removeEventListener('wheel', cancelRestore, true)
|
||||
window.removeEventListener('touchstart', cancelRestore, true)
|
||||
window.removeEventListener('wheel', onScrollIntent, true)
|
||||
window.removeEventListener('touchstart', onScrollIntent, true)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
stopRestore()
|
||||
// 清理时写入最后一次有效位置,避免依赖已被钳制的 el.scrollTop。
|
||||
writeScrollPosition(key, lastSaved)
|
||||
}
|
||||
}, [pathname, userKey])
|
||||
}, [routeKey, userKey])
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function FavouritesPage() {
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
<MediaCard key={m.id} media={m} linkState={{ from: '/favourites' }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function MediaDetailBackButton({ onBack }: { onBack: () => void }) {
|
||||
className="btn-ghost gap-2 bg-white/80 shadow-sm backdrop-blur hover:bg-white"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>返回媒体库</span>
|
||||
<span>返回</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -127,6 +127,7 @@ export function WatchHistoryPage() {
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Link
|
||||
to={`/media/${m.id}`}
|
||||
state={{ from: '/history' }}
|
||||
className="block truncate font-medium text-ink-600 transition hover:text-brand-500"
|
||||
>
|
||||
{m.title}
|
||||
@@ -150,6 +151,7 @@ export function WatchHistoryPage() {
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:shrink-0">
|
||||
<Link
|
||||
to={`/play/${m.id}`}
|
||||
state={{ from: '/history' }}
|
||||
className="neon-button !px-3 !py-1 !text-xs"
|
||||
>
|
||||
<Play size={12} /> 继续
|
||||
|
||||
@@ -111,9 +111,9 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
key: 'cache.images_max_size_mb',
|
||||
label: '图片缓存上限 (MB)',
|
||||
type: 'number',
|
||||
hint: '海报、剧照等图片代理缓存目录 (cache/images) 的最大占用空间。超过上限时自动按修改时间清理最旧的文件。设为 0 表示不限制,默认 500MB',
|
||||
defaultValue: '500',
|
||||
placeholder: '500',
|
||||
hint: '海报、剧照等图片代理缓存目录 (cache/images) 的最大占用空间。超过上限时自动按修改时间清理最旧的文件;上限过小会导致旧图被淘汰后又要重新从上游/挂载的 Emby 下载。设为 0 表示不限制,默认 2000MB',
|
||||
defaultValue: '2000',
|
||||
placeholder: '2000',
|
||||
},
|
||||
{
|
||||
key: 'https.enabled',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
import { useLocation, type NavigateFunction } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { api } from '../api/client'
|
||||
@@ -11,6 +11,8 @@ import type { Media } from '../types'
|
||||
import { seasonSortOrder } from '../utils/groupSeries'
|
||||
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||
|
||||
type MediaDetailLocationState = { from?: string } | null
|
||||
|
||||
interface MediaDetailPageStateParams {
|
||||
id: string
|
||||
navigate: NavigateFunction
|
||||
@@ -29,6 +31,7 @@ interface MediaDetailActionsParams {
|
||||
media: Media | null
|
||||
scrapeEpisodeArtwork: boolean
|
||||
navigate: NavigateFunction
|
||||
backFrom: string | undefined
|
||||
refresh: () => Promise<void>
|
||||
setFavourite: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
@@ -54,10 +57,13 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
setEpisodes,
|
||||
setLoadingEpisodes,
|
||||
})
|
||||
const location = useLocation()
|
||||
const backFrom = (location.state as MediaDetailLocationState)?.from
|
||||
const actions = useMediaDetailActions({
|
||||
media,
|
||||
scrapeEpisodeArtwork,
|
||||
navigate,
|
||||
backFrom,
|
||||
refresh,
|
||||
setFavourite,
|
||||
})
|
||||
@@ -173,10 +179,14 @@ function useMediaDetailActions({
|
||||
media,
|
||||
scrapeEpisodeArtwork,
|
||||
navigate,
|
||||
backFrom,
|
||||
refresh,
|
||||
setFavourite,
|
||||
}: MediaDetailActionsParams) {
|
||||
const goBack = useCallback(() => goBackFromMediaDetail(media, navigate), [media, navigate])
|
||||
const goBack = useCallback(
|
||||
() => goBackFromMediaDetail(media, navigate, backFrom),
|
||||
[backFrom, media, navigate],
|
||||
)
|
||||
const toggleFavourite = useCallback(
|
||||
() => toggleMediaFavourite(media, setFavourite),
|
||||
[media, setFavourite],
|
||||
@@ -188,13 +198,34 @@ function useMediaDetailActions({
|
||||
const reprobe = useCallback(() => reprobeMedia(media, refresh), [media, refresh])
|
||||
const exportNFO = useCallback(() => exportMediaNFO(media), [media])
|
||||
const deleteMedia = useCallback(
|
||||
() => deleteMediaFromLibrary(media, navigate),
|
||||
[media, navigate],
|
||||
() => deleteMediaFromLibrary(media, navigate, backFrom),
|
||||
[backFrom, media, navigate],
|
||||
)
|
||||
return { goBack, toggleFavourite, rescrape, reprobe, exportNFO, deleteMedia }
|
||||
}
|
||||
|
||||
function goBackFromMediaDetail(media: Media | null, navigate: NavigateFunction, replace = false): void {
|
||||
function canNavigateHistoryBack(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const idx = (window.history.state as { idx?: number } | null)?.idx
|
||||
return typeof idx === 'number' ? idx > 0 : window.history.length > 1
|
||||
}
|
||||
|
||||
function goBackFromMediaDetail(
|
||||
media: Media | null,
|
||||
navigate: NavigateFunction,
|
||||
from: string | undefined,
|
||||
replace = false,
|
||||
): void {
|
||||
// Prefer the explicit return path (e.g. Favourites → Detail), then browser
|
||||
// history, and only fall back to the owning library for direct/deep links.
|
||||
if (typeof from === 'string' && from.length > 0) {
|
||||
navigate(from, replace ? { replace: true } : undefined)
|
||||
return
|
||||
}
|
||||
if (canNavigateHistoryBack()) {
|
||||
navigate(-1)
|
||||
return
|
||||
}
|
||||
if (!media) {
|
||||
navigate('/libraries')
|
||||
return
|
||||
@@ -259,7 +290,11 @@ async function exportMediaNFO(media: Media | null): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMediaFromLibrary(media: Media | null, navigate: NavigateFunction): Promise<void> {
|
||||
async function deleteMediaFromLibrary(
|
||||
media: Media | null,
|
||||
navigate: NavigateFunction,
|
||||
from: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!media) return
|
||||
const result = await confirmActionResult({
|
||||
title: '删除媒体',
|
||||
@@ -275,7 +310,7 @@ async function deleteMediaFromLibrary(media: Media | null, navigate: NavigateFun
|
||||
return
|
||||
}
|
||||
toast.success(result.checked ? '已删除媒体及本地文件' : '已从媒体库删除')
|
||||
goBackFromMediaDetail(media, navigate, true)
|
||||
goBackFromMediaDetail(media, navigate, from, true)
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
|
||||
Reference in New Issue
Block a user