This commit is contained in:
truewhile
2026-09-01 18:55:05 +08:00
parent 5c9e7fcaa6
commit db64a6c093
49 changed files with 1140 additions and 162 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

+1
View File
@@ -43,6 +43,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.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mmtl")
+3
View File
@@ -44,6 +44,9 @@ func (c *Config) normalize() error {
if c.Cache.CacheDir == "" {
c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache")
}
if c.Cache.ImagesMaxSizeMB < 0 {
c.Cache.ImagesMaxSizeMB = 0
}
if c.Cache.RedisPrefix == "" {
c.Cache.RedisPrefix = "mmtl"
}
+1
View File
@@ -116,6 +116,7 @@ type LoggingConfig struct {
// CacheConfig 控制磁盘转码/刮削缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
+3
View File
@@ -64,6 +64,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
svc.Transcoder.StopAll()
}
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
}
c.Status(http.StatusNoContent)
}
}
+24
View File
@@ -195,4 +195,28 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
type reorderEmbyMountsReq struct {
IDs []string `json:"ids" binding:"required"`
}
// reorderEmbyMountsHandler 批量重排挂载媒体库顺序。
func reorderEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req reorderEmbyMountsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
return
}
if err := svc.EmbyRemote.ReorderMounts(c.Request.Context(), req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
@@ -0,0 +1,65 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/database"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestReorderEmbyMountsHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := t.Context()
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
_ = repos.EmbyMount.Create(ctx, m1)
_ = repos.EmbyMount.Create(ctx, m2)
svc := &service.Container{
Repo: repos,
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
}
router := gin.New()
router.PUT("/admin/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
body, _ := json.Marshal(map[string]any{
"ids": []string{m2.ID, m1.ID},
})
req := httptest.NewRequest(http.MethodPut, "/admin/emby/mounts/reorder", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
list, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 || list[0].ID != m2.ID || list[1].ID != m1.ID {
t.Fatalf("expected order [m2, m1], got [m%s, m%s]", list[0].ID, list[1].ID)
}
}
+5 -4
View File
@@ -49,10 +49,11 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc))
admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc))
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc))
admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc))
admin.GET("/strm/accounts", listStrmAccountsHandler(svc))
admin.POST("/strm/accounts", createStrmAccountHandler(svc))
+1
View File
@@ -14,6 +14,7 @@ type EmbyMount struct {
RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余)
CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ...
Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」)
SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前
ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理
Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
}
+38 -4
View File
@@ -15,7 +15,14 @@ type EmbyMountRepository struct{ db *gorm.DB }
func (r *EmbyMountRepository) Create(ctx context.Context, m *model.EmbyMount) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(m).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if m != nil && m.SortOrder == 0 {
var maxSort int
_ = tx.Model(&model.EmbyMount{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
m.SortOrder = maxSort + 1
}
return tx.Create(m).Error
})
})
}
@@ -27,7 +34,17 @@ func (r *EmbyMountRepository) CreateInBatches(ctx context.Context, mounts []*mod
batchSize = 50
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).CreateInBatches(mounts, batchSize).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var maxSort int
_ = tx.Model(&model.EmbyMount{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
for _, m := range mounts {
if m != nil && m.SortOrder == 0 {
maxSort++
m.SortOrder = maxSort
}
}
return tx.CreateInBatches(mounts, batchSize).Error
})
})
}
@@ -45,16 +62,33 @@ func (r *EmbyMountRepository) FindByID(ctx context.Context, id string) (*model.E
func (r *EmbyMountRepository) List(ctx context.Context) ([]model.EmbyMount, error) {
var rows []model.EmbyMount
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error
err := r.db.WithContext(ctx).Order("sort_order asc, created_at asc").Find(&rows).Error
return rows, err
}
func (r *EmbyMountRepository) ListByAccountID(ctx context.Context, accountID string) ([]model.EmbyMount, error) {
var rows []model.EmbyMount
err := r.db.WithContext(ctx).Where("account_id = ?", accountID).Order("created_at asc").Find(&rows).Error
err := r.db.WithContext(ctx).Where("account_id = ?", accountID).Order("sort_order asc, created_at asc").Find(&rows).Error
return rows, err
}
func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i, id := range ids {
if err := tx.Model(&model.EmbyMount{}).Where("id = ?", id).
Update("sort_order", i).Error; err != nil {
return err
}
}
return nil
})
})
}
func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
+74
View File
@@ -0,0 +1,74 @@
package repository
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/database"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestEmbyMountSortOrderAndReorder(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
ctx := t.Context()
// 1. Create mounts and verify auto-assigned sort_order
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
m3 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-3", Name: "Mount 3"}
if err := repos.EmbyMount.Create(ctx, m1); err != nil {
t.Fatalf("create m1: %v", err)
}
if err := repos.EmbyMount.Create(ctx, m2); err != nil {
t.Fatalf("create m2: %v", err)
}
if err := repos.EmbyMount.Create(ctx, m3); err != nil {
t.Fatalf("create m3: %v", err)
}
if m1.SortOrder >= m2.SortOrder || m2.SortOrder >= m3.SortOrder {
t.Fatalf("expected ascending sort order on create: m1=%d, m2=%d, m3=%d",
m1.SortOrder, m2.SortOrder, m3.SortOrder)
}
// 2. Query list and verify initial order
list, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatalf("list mounts: %v", err)
}
if len(list) != 3 || list[0].ID != m1.ID || list[1].ID != m2.ID || list[2].ID != m3.ID {
t.Fatalf("unexpected list order: %+v", list)
}
// 3. Reorder: m3, m1, m2
if err := repos.EmbyMount.SetSortOrder(ctx, []string{m3.ID, m1.ID, m2.ID}); err != nil {
t.Fatalf("SetSortOrder failed: %v", err)
}
// 4. Query list again and verify updated order
reordered, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatalf("list mounts after reorder: %v", err)
}
if len(reordered) != 3 {
t.Fatalf("expected 3 mounts, got %d", len(reordered))
}
if reordered[0].ID != m3.ID || reordered[1].ID != m1.ID || reordered[2].ID != m2.ID {
t.Fatalf("expected order [m3, m1, m2], got: %s, %s, %s",
reordered[0].ID, reordered[1].ID, reordered[2].ID)
}
if reordered[0].SortOrder != 0 || reordered[1].SortOrder != 1 || reordered[2].SortOrder != 2 {
t.Fatalf("unexpected sort orders: %d, %d, %d",
reordered[0].SortOrder, reordered[1].SortOrder, reordered[2].SortOrder)
}
}
+97
View File
@@ -4,8 +4,11 @@
package service
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
@@ -42,3 +45,97 @@ func walkAndPrune(root string, cutoff time.Time) error {
}
return nil
}
// PruneImageCacheResult holds stats from an image cache prune operation.
type PruneImageCacheResult struct {
TotalFilesBefore int
TotalBytesBefore int64
DeletedFiles int
FreedBytes int64
RemainingBytes int64
}
type imageCacheFileEntry struct {
path string
size int64
modTime time.Time
}
// PruneImageCache scans imagesDir for cached image files. If the total disk usage
// exceeds maxSizeBytes, it removes files starting from the oldest (by ModTime)
// until disk usage falls to or below targetSizeBytes (80% of maxSizeBytes).
//
// In-flight temporary files (*.tmp) are skipped to avoid corrupting concurrent writes.
// Empty subdirectories left behind are best-effort removed.
func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResult, error) {
var result PruneImageCacheResult
if imagesDir == "" || maxSizeBytes <= 0 {
return result, nil
}
if _, err := os.Stat(imagesDir); err != nil {
return result, nil
}
var (
dirs []string
entries []imageCacheFileEntry
)
_ = filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if path != imagesDir {
dirs = append(dirs, path)
}
return nil
}
// Skip temporary files created during image download.
name := info.Name()
if strings.HasSuffix(name, ".tmp") || strings.HasPrefix(name, "img-") && strings.Contains(name, ".tmp") {
return nil
}
size := info.Size()
result.TotalFilesBefore++
result.TotalBytesBefore += size
entries = append(entries, imageCacheFileEntry{
path: path,
size: size,
modTime: info.ModTime(),
})
return nil
})
result.RemainingBytes = result.TotalBytesBefore
if result.TotalBytesBefore <= maxSizeBytes {
return result, nil
}
// High/Low watermark: prune down to 80% of max size to leave headroom
// and prevent disk thrashing on consecutive writes.
targetSizeBytes := maxSizeBytes * 80 / 100
sort.Slice(entries, func(i, j int) bool {
return entries[i].modTime.Before(entries[j].modTime)
})
for _, entry := range entries {
if result.RemainingBytes <= targetSizeBytes {
break
}
if err := os.Remove(entry.path); err == nil || errors.Is(err, os.ErrNotExist) {
result.DeletedFiles++
result.FreedBytes += entry.size
result.RemainingBytes -= entry.size
}
}
// Clean up emptied subdirectories from deepest to shallowest.
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
return result, nil
}
+162
View File
@@ -0,0 +1,162 @@
package service
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
)
func TestPruneImageCache_UnderLimit(t *testing.T) {
dir := t.TempDir()
file1 := filepath.Join(dir, "img1")
file2 := filepath.Join(dir, "img2")
if err := os.WriteFile(file1, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file2, make([]byte, 200), 0o600); err != nil {
t.Fatal(err)
}
// Max limit is 500 bytes, total is 300 bytes -> no prune
res, err := PruneImageCache(dir, 500)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.DeletedFiles != 0 {
t.Fatalf("expected 0 deleted files, got %d", res.DeletedFiles)
}
if res.TotalFilesBefore != 2 || res.TotalBytesBefore != 300 || res.RemainingBytes != 300 {
t.Fatalf("unexpected stats: %+v", res)
}
}
func TestPruneImageCache_OverLimitLRU(t *testing.T) {
dir := t.TempDir()
now := time.Now()
// Create 4 files of 100 bytes each, with distinct mtime
fOldest := filepath.Join(dir, "oldest")
fMidOld := filepath.Join(dir, "mid_old")
fMidNew := filepath.Join(dir, "mid_new")
fNewest := filepath.Join(dir, "newest")
for _, f := range []string{fOldest, fMidOld, fMidNew, fNewest} {
if err := os.WriteFile(f, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
}
_ = os.Chtimes(fOldest, now.Add(-4*time.Hour), now.Add(-4*time.Hour))
_ = os.Chtimes(fMidOld, now.Add(-3*time.Hour), now.Add(-3*time.Hour))
_ = os.Chtimes(fMidNew, now.Add(-2*time.Hour), now.Add(-2*time.Hour))
_ = os.Chtimes(fNewest, now.Add(-1*time.Hour), now.Add(-1*time.Hour))
// Total = 400 bytes. Max limit = 300 bytes.
// Target = 300 * 80 / 100 = 240 bytes.
// Deleting oldest (100) brings total to 300 (> 240).
// Deleting mid_old (100) brings total to 200 (<= 240).
// Total deleted = 2 files (200 bytes), remaining = 200 bytes.
res, err := PruneImageCache(dir, 300)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.DeletedFiles != 2 {
t.Fatalf("expected 2 deleted files, got %d", res.DeletedFiles)
}
if res.FreedBytes != 200 {
t.Fatalf("expected 200 freed bytes, got %d", res.FreedBytes)
}
if res.RemainingBytes != 200 {
t.Fatalf("expected 200 remaining bytes, got %d", res.RemainingBytes)
}
// Verify oldest and mid_old were deleted, mid_new and newest still exist
if _, err := os.Stat(fOldest); !os.IsNotExist(err) {
t.Fatalf("expected oldest file to be deleted, got err=%v", err)
}
if _, err := os.Stat(fMidOld); !os.IsNotExist(err) {
t.Fatalf("expected mid_old file to be deleted, got err=%v", err)
}
if _, err := os.Stat(fMidNew); err != nil {
t.Fatalf("expected mid_new file to exist, got err=%v", err)
}
if _, err := os.Stat(fNewest); err != nil {
t.Fatalf("expected newest file to exist, got err=%v", err)
}
}
func TestPruneImageCache_SkipsTmpFiles(t *testing.T) {
dir := t.TempDir()
fTmp := filepath.Join(dir, "img-123.tmp")
fImg := filepath.Join(dir, "cached_img")
if err := os.WriteFile(fTmp, make([]byte, 500), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fImg, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
// Limit is 200 bytes. fTmp (500) is ignored, only fImg (100) is counted <= 200.
res, err := PruneImageCache(dir, 200)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.DeletedFiles != 0 {
t.Fatalf("expected 0 deleted files, got %d", res.DeletedFiles)
}
if _, err := os.Stat(fTmp); err != nil {
t.Fatalf("expected tmp file to remain untouched, got %v", err)
}
}
func TestPruneImageCache_ZeroOrNegativeLimit(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "img")
if err := os.WriteFile(f, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
res, err := PruneImageCache(dir, 0)
if err != nil || res.DeletedFiles != 0 {
t.Fatalf("expected no-op for 0 limit, got %+v, err=%v", res, err)
}
res, err = PruneImageCache(dir, -10)
if err != nil || res.DeletedFiles != 0 {
t.Fatalf("expected no-op for negative limit, got %+v, err=%v", res, err)
}
}
func TestSchedulerJobCleanImageCache(t *testing.T) {
cacheRoot := t.TempDir()
imagesDir := filepath.Join(cacheRoot, "images")
if err := os.MkdirAll(imagesDir, 0o750); err != nil {
t.Fatal(err)
}
f := filepath.Join(imagesDir, "old_poster")
if err := os.WriteFile(f, make([]byte, 2*1024*1024), 0o600); err != nil {
t.Fatal(err)
}
scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, cacheRoot)
// Set limit to 1MB; our file is 2MB -> should be pruned
scheduler.SetImagesMaxSizeMBProvider(func() int {
return 1
})
if err := scheduler.jobCleanImageCache(context.Background()); err != nil {
t.Fatalf("jobCleanImageCache failed: %v", err)
}
if _, err := os.Stat(f); !os.IsNotExist(err) {
t.Fatalf("expected file to be pruned, got err=%v", err)
}
}
+103 -1
View File
@@ -343,4 +343,106 @@ func TestDanmakuSameBase(t *testing.T) {
require.Equal(t, int64(99999), resManual.EpisodeID)
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
require.Contains(t, resManual.Raw, "手动搜索弹幕")
}
}
// Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。
func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) {
content := bytes.Repeat([]byte("emby-remote-video-bytes-9876543210"), 300)
sum := md5.Sum(content)
wantHash := hex.EncodeToString(sum[:])
var gotRange string
var rangeHits int
rangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rangeHits++
gotRange = r.Header.Get("Range")
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(content)
}))
t.Cleanup(rangeSrv.Close)
var seen string
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":2001,"animeTitle":"芙莉莲","episodeTitle":"第1话"}]}`,
`<?xml version="1.0"?><i><d p="1.2,1,16777215,user1">Emby远程弹幕命中</d></i>`,
&seen)
overrideDanmakuOfficialBase(t, official.URL)
remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-456")
svc := newDanmakuTestService(t)
svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) {
require.Equal(t, remoteMediaID, encodedID)
return &model.Media{
Base: model.Base{ID: remoteMediaID},
Title: "葬送的芙莉莲",
EpisodeTitle: "第1话",
EpisodeNum: 1,
Path: "/mnt/emby/anime/Frieren/S01E01.mkv",
SizeBytes: int64(len(content)),
DurationSec: 1400,
}, rangeSrv.URL, nil
})
ctx := context.Background()
res, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "hash", res.MatchMode)
require.Equal(t, int64(25484), res.EpisodeID)
require.Equal(t, "芙莉莲", res.AnimeTitle)
require.Contains(t, res.Raw, "Emby远程弹幕命中")
require.Contains(t, gotRange, "bytes=0-")
require.Contains(t, seen, `"fileHash":"`+wantHash+`"`)
require.Contains(t, seen, `"fileName":"`+url.QueryEscape("S01E01")+`"`)
require.Equal(t, 1, rangeHits)
// 第二次拉取验证 hashCache 命中,不重复请求 rangeSrv
res2, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.Equal(t, "hash", res2.MatchMode)
require.Equal(t, 1, rangeHits)
}
// Emby 远程直链拉取失败时(如网络异常),能平滑降级走番剧原名/标题关键词搜索。
func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// 文件名搜索 ep01 时无结果,模拟文件名未匹配
if r.URL.Query().Get("anime") == "ep01" {
fmt.Fprint(w, `{"hasMore":false,"animes":[]}`)
return
}
// 降级到番剧名搜索命中
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":3001,"animeTitle":"降级搜索番剧","episodes":[{"episodeId":7799,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/7799", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.8,1,16777215,user1">降级搜索弹幕</d></i>`)
})
official := httptest.NewServer(mux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-789")
svc := newDanmakuTestService(t)
// 返回一个不存在的流服务地址模拟 Range 拉取失败
svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) {
return &model.Media{
Base: model.Base{ID: remoteMediaID},
Title: "降级搜索番剧",
EpisodeNum: 1,
Path: "/mnt/emby/anime/fallback/ep01.mkv",
DurationSec: 1200,
}, "http://127.0.0.1:1/invalid-stream", nil
})
ctx := context.Background()
res, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "search", res.MatchMode)
require.Equal(t, int64(7799), res.EpisodeID)
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
require.Contains(t, res.Raw, "降级搜索弹幕")
}
+101 -8
View File
@@ -94,6 +94,10 @@ type DanmakuEpisode struct {
EpisodeTitle string `json:"episodeTitle"`
}
// DanmakuRemoteMediaResolver resolves an Emby remote pseudo-ID (e.g. embyremote~mount~id)
// into a memory model.Media and a direct stream URL.
type DanmakuRemoteMediaResolver func(ctx context.Context, encodedID string) (*model.Media, string, error)
// DanmakuService fetches danmaku for a media item through the dandanplay
// protocol: match by 16MB-prefix hash, then search for an episode id by the
// video's name, then fetch the comment library XML. The React player parses
@@ -108,6 +112,10 @@ type DanmakuService struct {
// StrmService.ResolvePlay; nil means strm sources are skipped.
strmResolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)
// remoteResolve resolves an Emby remote pseudo-ID into *model.Media and
// direct stream URL for range hashing.
remoteResolve DanmakuRemoteMediaResolver
hashCacheMu sync.Mutex
hashCache map[string]string // stamp → 16MB-prefix MD5
}
@@ -136,6 +144,14 @@ func (s *DanmakuService) SetStrmResolver(resolve func(ctx context.Context, provi
}
}
// SetRemoteMediaResolver wires the resolver used to fetch metadata and direct
// stream URLs for Emby remote mounted media.
func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolver) {
if s != nil {
s.remoteResolve = resolve
}
}
// Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{
@@ -220,13 +236,17 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
target := ""
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
if hash, ok := s.mediaHash(ctx, media); ok {
fileSize := media.SizeBytes
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") {
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
}
matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec)
matchName := danmakuMatchFileName(media.Path)
if matchName == "" {
matchName = term.name
}
matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec)
if err != nil {
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
} else if len(matches) > 0 {
@@ -311,6 +331,29 @@ type danmakuSearchTerms struct {
// (movies / unknown) is left empty so the search does not filter by episode.
func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, *model.Media, error) {
var term danmakuSearchTerms
if IsEmbyRemoteID(mediaID) {
if s == nil || s.remoteResolve == nil {
return term, nil, errors.New("remote emby resolver unavailable")
}
m, _, err := s.remoteResolve(ctx, mediaID)
if err != nil || m == nil {
if err != nil {
return term, nil, err
}
return term, nil, errors.New("media not found")
}
if name := strings.TrimSpace(m.OriginalName); name != "" {
term.name = name
} else if name := strings.TrimSpace(m.Title); name != "" {
term.name = name
} else {
term.name = danmakuMatchFileName(m.Path)
}
if m.EpisodeNum > 0 {
term.episode = strconv.Itoa(m.EpisodeNum)
}
return term, m, nil
}
if s == nil || s.repo == nil || s.repo.Media == nil {
return term, nil, errors.New("media repository unavailable")
}
@@ -484,7 +527,14 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) {
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
func danmakuMatchFileName(path string) string {
base := filepath.Base(path)
if path == "" {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx >= 0 {
clean = clean[idx+1:]
}
base := filepath.Base(clean)
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
@@ -493,14 +543,20 @@ func danmakuMatchFileName(path string) string {
base = strings.TrimSuffix(base, filepath.Ext(base))
}
}
return base
return strings.TrimSpace(base)
}
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
// video). Local videos are hashed straight from disk; .strm indirections are
// resolved (local path / direct link) and only the 16MB prefix is downloaded.
// video). Local videos are hashed straight from disk; .strm indirections and
// remote Emby streams are range-fetched and only the 16MB prefix is downloaded.
func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (string, bool) {
if media == nil || media.Path == "" {
if media == nil {
return "", false
}
if IsEmbyRemoteID(media.ID) {
return s.hashEmbyRemote(ctx, media)
}
if media.Path == "" {
return "", false
}
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
@@ -517,6 +573,43 @@ func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (str
return s.hashLocalFile(media.Path)
}
// hashEmbyRemote computes the 16MB-prefix MD5 of a remote Emby stream via HTTP Range.
func (s *DanmakuService) hashEmbyRemote(ctx context.Context, media *model.Media) (string, bool) {
if media == nil || media.ID == "" {
return "", false
}
if h, ok := s.hashCacheGet("e|" + media.ID); ok {
return h, true
}
if s.remoteResolve == nil {
return "", false
}
_, streamURL, err := s.remoteResolve(ctx, media.ID)
if err != nil || strings.TrimSpace(streamURL) == "" {
if err != nil {
s.log.Warn("danmaku emby stream url resolve failed, hash layer skipped",
zap.String("media_id", media.ID), zap.Error(err))
}
return "", false
}
body, err := s.openRangeBody(ctx, streamURL, nil)
if err != nil || body == nil {
if err != nil {
s.log.Warn("danmaku emby range fetch failed, hash layer skipped",
zap.String("media_id", media.ID), zap.Error(err))
}
return "", false
}
defer body.Close()
h := md5.New()
if _, err := io.Copy(h, io.LimitReader(body, danmakuHashPrefixBytes)); err != nil {
return "", false
}
hash := hex.EncodeToString(h.Sum(nil))
s.hashCachePut("e|"+media.ID, hash)
return hash, true
}
// hashLocalFile computes the MD5 of the first 16MB of a local video, cached
// by path+size+mtime so repeated danmaku loads skip the disk read.
func (s *DanmakuService) hashLocalFile(path string) (string, bool) {
+12
View File
@@ -231,6 +231,18 @@ func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error {
return err
}
// ReorderMounts 批量重排挂载媒体库顺序。
func (r *EmbyRemoteService) ReorderMounts(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
if err := r.repo.EmbyMount.SetSortOrder(ctx, ids); err != nil {
return err
}
r.invalidateRemoteMediaCache(ctx)
return nil
}
// FullMountAccount 把账号的全部远程媒体库(View)挂载进来(幂等,已存在跳过)。
func (r *EmbyRemoteService) FullMountAccount(ctx context.Context, acct *model.StrmAccount, proxyPlayDefault bool) (int, error) {
views, err := r.RemoteViews(ctx, acct)
+53 -32
View File
@@ -36,23 +36,28 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar
if err != nil || len(mounts) == 0 {
return nil, err
}
// 按账号分组,每账号拉一次 Views 做匹配。
byAccount := map[string][]*model.EmbyMount{}
type accountData struct {
acct *model.StrmAccount
cfg *EmbyRemoteConfig
viewByName map[string]map[string]any
}
acctData := map[string]*accountData{}
for i := range mounts {
m := mounts[i]
m := &mounts[i]
if !m.Enabled {
continue
}
byAccount[m.AccountID] = append(byAccount[m.AccountID], &mounts[i])
}
out := make([]RemoteLibraryView, 0, len(mounts))
for accountID, accountMounts := range byAccount {
acct := r.AccountByID(ctx, accountID)
if _, ok := acctData[m.AccountID]; ok {
continue
}
acct := r.AccountByID(ctx, m.AccountID)
if acct == nil {
acctData[m.AccountID] = nil
continue
}
cfg, cfgErr := r.configOf(acct)
if cfgErr != nil {
acctData[m.AccountID] = nil
continue
}
views, viewErr := r.RemoteViews(ctx, acct)
@@ -61,31 +66,47 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar
r.log.Warn("web remote emby views failed",
zap.String("account", acct.Name), zap.Error(viewErr))
}
acctData[m.AccountID] = nil
continue
}
viewByName := map[string]map[string]any{}
for _, v := range views {
viewByName[remoteItemString(v, "Id")] = v
}
for _, mount := range accountMounts {
v, ok := viewByName[mount.RemoteViewID]
if !ok {
continue // 远程已删除该媒体库
}
lib := r.mapRemoteMountToLibrary(mount, acct, cfg, v)
if lib == nil {
continue
}
out = append(out, RemoteLibraryView{
Library: *lib,
MountID: mount.ID,
AccountID: acct.ID,
RemoteID: mount.RemoteViewID,
CollectionType: mount.CollectionType,
AccountName: acct.Name,
})
acctData[m.AccountID] = &accountData{
acct: acct,
cfg: cfg,
viewByName: viewByName,
}
}
out := make([]RemoteLibraryView, 0, len(mounts))
for i := range mounts {
m := &mounts[i]
if !m.Enabled {
continue
}
data := acctData[m.AccountID]
if data == nil || data.viewByName == nil {
continue
}
v, ok := data.viewByName[m.RemoteViewID]
if !ok {
continue // 远程已删除该媒体库
}
lib := r.mapRemoteMountToLibrary(m, data.acct, data.cfg, v)
if lib == nil {
continue
}
out = append(out, RemoteLibraryView{
Library: *lib,
MountID: m.ID,
AccountID: data.acct.ID,
RemoteID: m.RemoteViewID,
CollectionType: m.CollectionType,
AccountName: data.acct.Name,
})
}
return out, nil
}
@@ -125,13 +146,13 @@ func (r *EmbyRemoteService) mapRemoteMountToLibrary(mount *model.EmbyMount, acct
case "music":
libType = "music"
}
lib := &model.Library{
Base: model.Base{ID: EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)},
Name: name,
Type: libType,
Enabled: true,
SortOrder: 1000, // 远程库排在本地库之后
}
lib := &model.Library{
Base: model.Base{ID: EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)},
Name: name,
Type: libType,
Enabled: true,
SortOrder: 1000 + mount.SortOrder, // 远程库排在本地库之后,且保持挂载库排序
}
// 远程媒体库封面只有真实存在图片标签才下发。
if remoteItemHasImageTag(item, "Primary") {
lib.CoverURL = r.remoteItemImageURL(cfg, mount.RemoteViewID, "Primary")
+10
View File
@@ -84,3 +84,13 @@ func (p *ImageProxy) libraryRoots() []string {
p.libRootsAt = time.Now()
return p.libRootsCache
}
// Prune removes oldest cached images until disk usage is within the configured limit.
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
return PruneImageCacheResult{}, nil
}
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
return PruneImageCache(p.cacheDir, maxBytes)
}
+12 -5
View File
@@ -106,11 +106,18 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
cfg.App.SSLCert = value
case "https.key":
cfg.App.SSLKey = value
case "https.cert_path":
cfg.App.SSLCertPath = strings.TrimSpace(value)
case "https.key_path":
cfg.App.SSLKeyPath = strings.TrimSpace(value)
}
case "https.cert_path":
cfg.App.SSLCertPath = strings.TrimSpace(value)
case "https.key_path":
cfg.App.SSLKeyPath = strings.TrimSpace(value)
case "cache.images_max_size_mb":
if n, err := strconv.Atoi(value); err == nil {
if n < 0 {
n = 0
}
cfg.Cache.ImagesMaxSizeMB = n
}
}
}
// ParseBoolSetting is the exported variant of parseBoolSetting for handlers
+18
View File
@@ -41,6 +41,8 @@ type SchedulerService struct {
cacheDir string
now func() time.Time
imagesMaxSizeMBProvider func() int
mu sync.Mutex
stopCh chan struct{}
jobs []*scheduledJob
@@ -59,6 +61,17 @@ func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService
s.organizePipeline = pipeline
}
func (s *SchedulerService) SetImagesMaxSizeMBProvider(fn func() int) {
s.imagesMaxSizeMBProvider = fn
}
func (s *SchedulerService) imagesMaxSizeMB() int {
if s.imagesMaxSizeMBProvider != nil {
return s.imagesMaxSizeMBProvider()
}
return 0
}
// scheduledJob is one recurring task.
type scheduledJob struct {
name string
@@ -120,6 +133,11 @@ func (s *SchedulerService) Start(ctx context.Context) {
interval: 24 * time.Hour,
run: s.jobPurgeRecycleBin,
},
{
name: "image_cache_cleanup",
interval: 1 * time.Hour,
run: s.jobCleanImageCache,
},
}
for _, j := range s.jobs {
initialDelay := 15 * time.Second
+30
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"path/filepath"
"strconv"
"strings"
"time"
@@ -202,3 +203,32 @@ func isMissingTableErr(err error) bool {
}
return err == gorm.ErrInvalidDB
}
// jobCleanImageCache prunes image proxy cache files when disk usage exceeds the configured limit.
func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
if s.cacheDir == "" {
return nil
}
maxMB := s.imagesMaxSizeMB()
if maxMB <= 0 {
return nil
}
imagesDir := filepath.Join(s.cacheDir, "images")
maxSizeBytes := int64(maxMB) * 1024 * 1024
res, err := PruneImageCache(imagesDir, maxSizeBytes)
if err != nil {
if s.log != nil {
s.log.Warn("scheduled image cache cleanup failed", zap.Error(err))
}
return err
}
if res.DeletedFiles > 0 && s.log != nil {
s.log.Info("scheduled image cache cleanup completed",
zap.Int("deleted_files", res.DeletedFiles),
zap.Int64("freed_bytes", res.FreedBytes),
zap.Int64("remaining_bytes", res.RemainingBytes),
)
}
return nil
}
+30
View File
@@ -2,11 +2,13 @@ package service
import (
"context"
"errors"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
)
@@ -117,6 +119,28 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
if b.c.EmbyRemote != nil {
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
if !ok {
return nil, "", errors.New("invalid emby remote id")
}
mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
if err != nil {
return nil, "", err
}
return nil, "", errors.New("emby mount or account not found")
}
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil || m == nil {
return nil, "", err
}
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
return m, streamURL, nil
})
}
}
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
@@ -131,6 +155,12 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
)
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
b.c.Scheduler.SetImagesMaxSizeMBProvider(func() int {
if b.cfg == nil {
return 0
}
return b.cfg.Cache.ImagesMaxSizeMB
})
}
func (b *serviceContainerBuilder) initIdentityServices() {
BIN
View File
Binary file not shown.
+2
View File
@@ -8,6 +8,8 @@ export const embyAPI = {
updateMount: (id: string, payload: { name?: string; proxy_play?: boolean; enabled?: boolean }) =>
api.put<EmbyMount>(`/admin/emby/mounts/${id}`, payload).then((r) => r.data),
deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data),
reorderMounts: (ids: string[]) =>
api.put<{ ok: boolean }>('/admin/emby/mounts/reorder', { ids }).then((r) => r.data),
listAccountViews: (accountId: string) =>
api.get<RemoteEmbyView[]>(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data),
fullMountAccount: (accountId: string, proxy: boolean) =>
+2 -2
View File
@@ -39,8 +39,8 @@ export function APIConfigsPanel() {
)}
{!loading && (
<div className="glass-panel overflow-hidden">
<table className="w-full text-left text-sm">
<div className="glass-panel table-scroll">
<table className="min-w-[640px] w-full text-left text-sm">
<thead className="border-b border-gray-200 text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="px-4 py-3">服务</th>
+1 -1
View File
@@ -53,7 +53,7 @@ export function Layout() {
const isPlayPage = location.pathname.startsWith('/play')
return (
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
<LayoutSidebars
sidebar={sidebar}
isAdmin={permissions.isAdmin}
+10 -8
View File
@@ -43,7 +43,7 @@ export function LayoutHeader({
hideSearch,
}: LayoutHeaderProps) {
return (
<header className="relative flex h-20 shrink-0 items-center justify-between gap-4 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
<header className="relative z-30 flex h-16 shrink-0 items-center justify-between gap-2 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-3 backdrop-blur-md sm:h-20 sm:gap-4 sm:px-4 md:px-8">
{/* Left: Mobile Menu button or Brand Logo */}
<div className="flex items-center gap-3 shrink-0">
<button
@@ -71,7 +71,7 @@ export function LayoutHeader({
</div>
{/* Middle: Search Box */}
<div className="flex-1 max-w-xl mx-auto">
<div className="flex min-w-0 flex-1 max-w-xl mx-auto">
{!hideSearch && <LayoutHeaderSearch />}
</div>
@@ -155,8 +155,8 @@ function LayoutHeaderSearch() {
<div ref={containerRef} className="relative w-full">
<div className="relative flex items-center">
<Search
size={16}
className="absolute left-3.5 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500"
size={15}
className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]"
/>
<input
type="text"
@@ -169,8 +169,8 @@ function LayoutHeaderSearch() {
if (results.length > 0) setIsOpen(true)
}}
onKeyDown={handleKeyDown}
placeholder="搜索电影、剧集、演员…"
className="w-full h-10 pl-10 pr-9 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
placeholder="搜索媒体…"
className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
/>
{loading ? (
<LoaderCircle size={15} className="absolute right-3.5 text-brand-500 animate-spin" />
@@ -288,8 +288,10 @@ function LayoutHeaderActions({
}: LayoutHeaderActionsProps) {
return (
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
<div className="hidden items-center gap-3 sm:flex md:gap-4">
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
<span className="h-6 w-px bg-[var(--app-border)]" />
</div>
<LayoutUserMenu
user={user}
isOpen={isProfileOpen}
+23 -22
View File
@@ -324,7 +324,7 @@ export function PlayerControls({
onMouseLeave={() => setControlsHovered(false)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2.5 text-white">
<div className="flex flex-wrap items-center gap-2 text-white sm:flex-nowrap sm:gap-2.5">
{/* 上一集 */}
<button
onClick={onPrevEpisode}
@@ -362,24 +362,25 @@ export function PlayerControls({
<SkipForward size={18} />
</button>
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={displayTime}
onMouseDown={handleSeekStart}
onTouchStart={handleSeekStart}
onChange={(e) => handleSeekChange(Number(e.target.value))}
onMouseUp={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
onTouchEnd={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
className="min-w-0 flex-1 cursor-pointer accent-rose-500"
aria-label="播放进度"
/>
<span className="shrink-0 font-mono text-xs tabular-nums text-white/85">
{formatTime(displayTime)} / {formatTime(duration)}
</span>
<div className="order-2 flex min-w-0 basis-full items-center gap-2 sm:order-none sm:basis-auto sm:flex-1">
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={displayTime}
onMouseDown={handleSeekStart}
onTouchStart={handleSeekStart}
onChange={(e) => handleSeekChange(Number(e.target.value))}
onMouseUp={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
onTouchEnd={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
className="min-w-0 flex-1 cursor-pointer accent-rose-500"
aria-label="播放进度"
/>
<span className="shrink-0 font-mono text-[10px] tabular-nums text-white/85 sm:text-xs">
{formatTime(displayTime)} / {formatTime(duration)}
</span>
</div>
{pipSupported && (
<button
@@ -456,7 +457,7 @@ export function PlayerControls({
title={hasPlaylist ? '选集列表' : '当前无更多剧集'}
>
<ListVideo size={15} />
选集
<span className="hidden sm:inline">选集</span>
</button>
)}
@@ -472,7 +473,7 @@ export function PlayerControls({
title="弹幕设置"
>
<MessageSquareText size={15} />
弹幕
<span className="hidden sm:inline">弹幕</span>
{danmakuEnabled && <span className="h-1.5 w-1.5 rounded-full bg-lime-400" />}
</button>
@@ -490,7 +491,7 @@ export function PlayerControls({
step={0.05}
value={muted ? 0 : volume}
onChange={(e) => changeVolume(Number(e.target.value))}
className="w-16 accent-rose-500"
className="hidden w-16 accent-rose-500 sm:block"
aria-label="音量"
/>
+1 -1
View File
@@ -115,7 +115,7 @@ export function PlayerDanmakuPanel({
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
<div
onClick={(e) => e.stopPropagation()}
className="absolute right-4 top-16 z-30 w-80 rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md"
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-auto sm:w-80"
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold">
+1 -1
View File
@@ -89,7 +89,7 @@ export function PlayerPlaylistPanel({
return (
<div
onClick={(e) => e.stopPropagation()}
className="absolute right-4 top-16 bottom-20 z-30 flex w-80 sm:w-96 flex-col rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md overflow-hidden"
className="absolute inset-x-3 top-12 bottom-3 z-30 flex flex-col overflow-hidden rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-20 sm:w-96"
>
{/* 头部 */}
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
+5 -5
View File
@@ -22,17 +22,17 @@ export function openManageLibrariesDialog(): Promise<void> {
function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 sm:p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
className="flex h-[calc(100dvh-1rem)] sm:h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl sm:rounded-3xl border border-white/70 bg-white shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
<h3 className="font-display text-lg font-bold text-ink-600">管理媒体库</h3>
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 sm:px-6 sm:py-4">
<h3 className="font-display text-base sm:text-lg font-bold text-ink-600">管理媒体库</h3>
<button
type="button"
onClick={onClose}
@@ -42,7 +42,7 @@ function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
<AdminLibraryPanel />
</div>
</div>
+3 -2
View File
@@ -277,10 +277,11 @@ body {
.badge-neutral { @apply badge bg-gray-100 text-gray-600 border border-gray-200/50; }
/* ── Page Headings ── */
.page-heading { @apply font-display text-3xl font-extrabold tracking-tight text-gray-900; }
.page-heading { @apply font-display text-2xl font-extrabold tracking-tight text-gray-900 sm:text-3xl; }
.page-subtitle { @apply mt-1 text-sm text-gray-500; }
/* ── Data Tables Redesign ── */
/* ── Mobile Data Tables ── */
.table-scroll { @apply overflow-x-auto overscroll-x-contain; -webkit-overflow-scrolling: touch; }
.data-table { @apply w-full text-left text-sm border-collapse; }
.data-table thead { @apply border-b border-gray-200 bg-gray-50/70; }
.data-table th { @apply px-5 py-4 text-xs font-bold uppercase tracking-wider text-gray-500 whitespace-nowrap; }
+2 -2
View File
@@ -42,8 +42,8 @@ export function AdminUsersTable({
onDeleteUser,
}: AdminUsersTableProps) {
return (
<div className="glass-panel overflow-x-auto">
<table className="w-full text-left text-sm">
<div className="glass-panel table-scroll">
<table className="min-w-[900px] w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">用户名</th>
+132 -3
View File
@@ -2,8 +2,11 @@
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import {
ArrowDown,
ArrowUp,
Check,
Globe,
GripVertical,
Loader2,
Pencil,
Plus,
@@ -107,7 +110,7 @@ function AccountDialog({
disabled={!!existing}
/>
</label>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">用户名</span>
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
@@ -301,6 +304,89 @@ export function EmbyMountPage() {
const [accountDialog, setAccountDialog] = useState<StrmAccount | null | 'new'>(null)
const [mountDialogFor, setMountDialogFor] = useState<StrmAccount | null>(null)
const [testingID, setTestingID] = useState<string | null>(null)
const [draggingId, setDraggingId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
const [reordering, setReordering] = useState(false)
const applyReorder = async (nextMounts: EmbyMount[]) => {
const prevMounts = mounts
setMounts(nextMounts)
setReordering(true)
try {
await embyAPI.reorderMounts(nextMounts.map((m) => m.id))
toast.success('媒体库顺序已更新')
} catch (err) {
setMounts(prevMounts)
toast.error(apiErrorMessage(err))
} finally {
setReordering(false)
}
}
const moveMount = (m: EmbyMount, direction: 'up' | 'down') => {
const acctMounts = mounts.filter((item) => item.account_id === m.account_id)
const idx = acctMounts.findIndex((item) => item.id === m.id)
if (idx < 0) return
if (direction === 'up' && idx === 0) return
if (direction === 'down' && idx === acctMounts.length - 1) return
const targetIdx = direction === 'up' ? idx - 1 : idx + 1
const newAcctMounts = [...acctMounts]
const [moved] = newAcctMounts.splice(idx, 1)
newAcctMounts.splice(targetIdx, 0, moved)
let acctPointer = 0
const nextMounts = mounts.map((item) => {
if (item.account_id === m.account_id) {
return newAcctMounts[acctPointer++]
}
return item
})
applyReorder(nextMounts)
}
const handleDragStart = (e: React.DragEvent, id: string) => {
e.dataTransfer.effectAllowed = 'move'
setDraggingId(id)
}
const handleDragOver = (e: React.DragEvent, overId: string) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragOverId !== overId) {
setDragOverId(overId)
}
}
const handleDrop = (overId: string, accountId: string) => {
if (!draggingId || draggingId === overId) {
setDraggingId(null)
setDragOverId(null)
return
}
const acctMounts = mounts.filter((item) => item.account_id === accountId)
const fromIdx = acctMounts.findIndex((item) => item.id === draggingId)
const toIdx = acctMounts.findIndex((item) => item.id === overId)
if (fromIdx < 0 || toIdx < 0) {
setDraggingId(null)
setDragOverId(null)
return
}
const newAcctMounts = [...acctMounts]
const [moved] = newAcctMounts.splice(fromIdx, 1)
newAcctMounts.splice(toIdx, 0, moved)
let acctPointer = 0
const nextMounts = mounts.map((item) => {
if (item.account_id === accountId) {
return newAcctMounts[acctPointer++]
}
return item
})
setDraggingId(null)
setDragOverId(null)
applyReorder(nextMounts)
}
const load = useCallback(async () => {
const [accts, mts] = await Promise.all([
@@ -454,8 +540,33 @@ export function EmbyMountPage() {
</p>
) : (
<div className="space-y-2">
{acctMounts.map((m) => (
<div key={m.id} className={`flex items-center gap-3 rounded-xl border px-4 py-3 ${m.enabled ? 'border-sand-200 bg-gray-50/60' : 'border-gray-100 bg-gray-50 opacity-60'}`}>
{acctMounts.map((m, index) => (
<div
key={m.id}
onDragOver={(e) => handleDragOver(e, m.id)}
onDrop={() => handleDrop(m.id, acct.id)}
className={`flex items-center gap-3 rounded-xl border px-3 py-3 transition-colors ${
draggingId === m.id
? 'border-dashed border-brand-400 bg-brand-50/20 opacity-50'
: dragOverId === m.id
? 'border-brand-400 bg-brand-50/30'
: m.enabled
? 'border-sand-200 bg-gray-50/60'
: 'border-gray-100 bg-gray-50 opacity-60'
}`}
>
<div
draggable
onDragStart={(e) => handleDragStart(e, m.id)}
onDragEnd={() => {
setDraggingId(null)
setDragOverId(null)
}}
className="shrink-0 cursor-grab active:cursor-grabbing text-sand-400 hover:text-ink-600 p-0.5"
title="按住拖拽调整顺序"
>
<GripVertical size={16} />
</div>
<Tv size={16} className="shrink-0 text-brand-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-ink-600">
@@ -466,6 +577,24 @@ export function EmbyMountPage() {
{m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}
</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => moveMount(m, 'up')}
disabled={index === 0 || reordering}
className="rounded-lg border border-gray-200 p-1.5 text-ink-100 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
title="上移"
>
<ArrowUp size={13} />
</button>
<button
onClick={() => moveMount(m, 'down')}
disabled={index === acctMounts.length - 1 || reordering}
className="rounded-lg border border-gray-200 p-1.5 text-ink-100 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
title="下移"
>
<ArrowDown size={13} />
</button>
</div>
<button
onClick={() => toggleMountProxy(m, !m.proxy_play)}
disabled={!m.enabled}
+2 -2
View File
@@ -26,8 +26,8 @@ export function FileEntriesTable({
onChoose,
}: FileEntriesTableProps) {
return (
<div className="glass-panel overflow-x-auto">
<table className="w-full text-left text-sm">
<div className="glass-panel table-scroll">
<table className="min-w-[700px] w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="w-10 py-2">
+41 -26
View File
@@ -48,36 +48,44 @@ export function LibrariesHeader({
onManageLibraries: () => void
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between sm:gap-4">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1>
<p className="mt-1 text-sm text-ink-50">
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">媒体库</h1>
<p className="mt-1 text-xs text-ink-50 sm:text-sm">
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
{repairMsg && <span className="text-xs text-ink-50">{repairMsg}</span>}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
<EpisodeArtworkToggle
checked={repairEpisodeArtwork}
onChange={onRepairEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-10"
className="h-9 sm:h-10 text-xs sm:text-sm"
/>
<button
type="button"
onClick={onRepairRescrape}
disabled={repairing}
className="btn-outline disabled:cursor-not-allowed disabled:opacity-60"
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
>
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<Link to="/scraper/queue" className="btn-outline inline-flex items-center gap-1.5" title="查看正在进行的刮削任务与进度">
<Link
to="/scraper/queue"
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
title="查看正在进行的刮削任务与进度"
>
<Sparkles size={14} className="text-brand-500" />
<span>刮削队列</span>
</Link>
<button type="button" onClick={onManageLibraries} className="btn-outline">
<button
type="button"
onClick={onManageLibraries}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
>
管理媒体库
</button>
</div>
@@ -102,7 +110,7 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
<p className="text-sm text-ink-50">按目录进入完整媒体库;下方每个目录也会直接展示最新内容。</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
{previews.map((preview, index) => (
<motion.div
key={preview.library.id}
@@ -142,9 +150,9 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
return (
<Link
to={`/library/${library.id}`}
className="group flex overflow-hidden rounded-3xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
className="group flex overflow-hidden rounded-2xl sm:rounded-3xl border border-sand-200 bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
>
<div className={`grid h-24 w-36 shrink-0 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
<div className={`grid h-20 w-24 sm:h-24 sm:w-36 shrink-0 gap-1 overflow-hidden rounded-xl sm:rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
@@ -159,25 +167,32 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
))
) : (
<div className="col-span-2 flex h-full items-center justify-center text-brand-500">
{TYPE_ICONS[library.type] ?? <FolderOpen size={34} />}
{TYPE_ICONS[library.type] ?? <FolderOpen size={28} className="sm:h-8 sm:w-8" />}
</div>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col justify-between px-4 py-1">
<div className="flex min-w-0 flex-1 flex-col justify-between px-3 sm:px-4 py-0.5 sm:py-1">
<div>
<div className="mb-1 inline-flex rounded-full bg-sand-100 px-2 py-0.5 text-[10px] font-bold text-sand-600">
{TYPE_LABELS[library.type] ?? library.type}
<div className="mb-1 flex items-center justify-between gap-1.5">
<span className="inline-flex rounded-full bg-sand-100 px-2 py-0.5 text-[10px] font-bold text-sand-600">
{TYPE_LABELS[library.type] ?? library.type}
</span>
<span className="text-[11px] font-semibold text-sand-500 sm:hidden">
{preview.total.toLocaleString()} 个条目
</span>
</div>
<h2 className="truncate font-display text-xl font-black text-ink-600 group-hover:text-brand-600">
<h2 className="line-clamp-2 font-display text-sm font-bold leading-tight text-ink-600 group-hover:text-brand-600 sm:truncate sm:text-xl sm:font-black">
{library.name}
</h2>
<p className="mt-1 line-clamp-1 break-all text-xs text-ink-50" title={library.path}>
<p className="mt-0.5 line-clamp-1 break-all text-[11px] text-ink-50 sm:mt-1 sm:text-xs" title={library.path}>
{displayPath}
</p>
</div>
<div className="flex items-center justify-between text-xs font-bold">
<span className="text-sand-600">{preview.total.toLocaleString()} 个条目</span>
<span className="text-brand-600">浏览全部</span>
<div className="mt-1 flex items-center justify-between text-xs font-bold sm:mt-0">
<span className="hidden text-sand-600 sm:inline">{preview.total.toLocaleString()} 个条目</span>
<span className="ml-auto inline-flex items-center gap-0.5 text-[11px] text-brand-600 sm:text-xs">
浏览全部 <ArrowRight size={12} />
</span>
</div>
</div>
</Link>
@@ -190,19 +205,19 @@ function LibraryShelf({ preview }: { preview: LibraryPreview }) {
const displayPath = libraryDisplayPath(library.path)
return (
<section className="rounded-[1.7rem] border border-sand-200 bg-white/75 p-4 shadow-card">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<section className="rounded-2xl sm:rounded-[1.7rem] border border-sand-200 bg-white/75 p-3.5 sm:p-4 shadow-card">
<div className="mb-3 sm:mb-4 flex flex-wrap items-end justify-between gap-2 sm:gap-3">
<div className="min-w-0">
<div className="mb-1 inline-flex items-center gap-2 rounded-full bg-brand-50 px-2.5 py-1 text-[11px] font-bold text-brand-700">
<div className="mb-1 inline-flex items-center gap-1.5 sm:gap-2 rounded-full bg-brand-50 px-2 sm:px-2.5 py-0.5 sm:py-1 text-[10px] sm:text-[11px] font-bold text-brand-700">
{TYPE_ICONS[library.type] ?? <LibraryIcon size={14} />}
{TYPE_LABELS[library.type] ?? library.type}
</div>
<h2 className="truncate font-display text-2xl font-black text-ink-600">{library.name}</h2>
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">{library.name}</h2>
<p className="mt-1 line-clamp-1 break-all text-xs text-ink-50">
<span title={library.path}>{displayPath}</span> · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部
</p>
</div>
<Link to={`/library/${library.id}`} className="btn-outline shrink-0">
<Link to={`/library/${library.id}`} className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm shrink-0">
浏览全部
<ArrowRight size={14} />
</Link>
+16 -7
View File
@@ -46,13 +46,13 @@ export function LibraryPageHeader({
const displayPath = library ? libraryDisplayPath(library.path) : ''
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">
{library?.name ?? '媒体库'}
<span className="text-sand-500"> ({itemCount})</span>
</h1>
{library && <p className="text-sm text-ink-50" title={library.path}>{library.type} · {displayPath}</p>}
{library && <p className="text-xs text-ink-50 sm:text-sm" title={library.path}>{library.type} · {displayPath}</p>}
{loadingAllText && <p className="mt-1 text-xs text-sand-500">{loadingAllText}</p>}
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
</div>
@@ -69,18 +69,27 @@ export function LibraryPageHeader({
checked={scrapeEpisodeArtwork}
onChange={onScrapeEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-9"
className="h-9 text-xs sm:text-sm"
/>
<button onClick={onScan} disabled={scanning} className="btn-outline">
<button
onClick={onScan}
disabled={scanning}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
>
{scanning ? '扫描中…' : '扫描媒体库'}
</button>
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
<button
onClick={onScrape}
disabled={scraping}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
title="对整个媒体库执行刮削元数据"
>
{scraping ? '刮削中…' : '整库刮削元数据'}
</button>
<button
onClick={onRepairRescrape}
disabled={repairing}
className="btn-outline"
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
>
{repairing ? '修复中…' : '修复+重刮整库'}
+2 -2
View File
@@ -83,8 +83,8 @@ export function LibraryStorageStats() {
<section className="space-y-3">
<h3 className="font-display text-lg font-semibold text-ink-600">按媒体库</h3>
<div className="glass-panel overflow-x-auto !p-3">
<table className="w-full text-left text-sm">
<div className="glass-panel table-scroll !p-3">
<table className="min-w-[500px] w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
+2 -2
View File
@@ -171,8 +171,8 @@ export function ManualOrganizePanel({
function ManualOrganizePreviewTable({ items }: { items: PreviewItem[] }) {
return (
<div className="max-h-72 overflow-auto rounded-xl border border-gray-200 bg-white/70">
<table className="w-full text-left text-xs">
<div className="table-scroll max-h-72 overflow-y-auto rounded-xl border border-gray-200 bg-white/70">
<table className="min-w-[700px] w-full text-left text-xs">
<thead className="sticky top-0 bg-white text-sand-500">
<tr>
<th className="px-3 py-2">动作</th>
+1 -1
View File
@@ -72,7 +72,7 @@ export function ProfileFormModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="glass-panel max-h-[calc(100dvh-2rem)] w-full max-w-lg overflow-y-auto !p-4 sm:!p-6">
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
{editing ? '编辑 Profile' : '创建 Profile'}
</h2>
+1 -1
View File
@@ -88,7 +88,7 @@ export function ProfilePreferenceFields({
}) {
return (
<>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="首选字幕">
<select
className="input-base"
+3 -3
View File
@@ -450,7 +450,7 @@ export function ScraperQueuePage() {
</div>
{selectedIds.size > 0 && (
<div className="flex items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-1.5 text-xs animate-in fade-in zoom-in-95">
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-2 text-xs animate-in fade-in zoom-in-95">
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
<button
@@ -507,8 +507,8 @@ export function ScraperQueuePage() {
: `「${FILTERS.find((f) => f.key === filter)?.label}」状态下暂无任务`}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<div className="table-scroll">
<table className="min-w-[960px] w-full text-left text-sm">
<thead className="border-b border-gray-200/80 bg-gray-50/50 text-[11px] font-bold uppercase tracking-wider text-sand-500">
<tr>
<th className="w-10 px-3 py-3 text-center">
+28 -7
View File
@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useRef, useState } from 'react'
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import {
ChevronRight,
@@ -48,7 +48,7 @@ function DialogShell({
role="dialog"
aria-modal="true"
className={
'flex max-h-[88vh] w-full flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl ' +
'flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl ' +
(wide ? 'max-w-3xl' : 'max-w-lg')
}
onClick={(event) => event.stopPropagation()}
@@ -67,7 +67,7 @@ function DialogShell({
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">{children}</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">{children}</div>
</div>
</div>
)
@@ -192,7 +192,7 @@ export function StrmAccountDialog({
<form onSubmit={submit} className="space-y-5">
{/* 提供方选择 */}
{!existing && (
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{PROVIDER_OPTIONS.map((option) => {
const Icon =
option.provider === 'cloud115'
@@ -435,10 +435,20 @@ function Strm115AuthPanel({
})()
}, [])
const filteredApps = (sources?.built_in ?? []).filter(
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
const filteredApps = useMemo(
() =>
(sources?.built_in ?? []).filter(
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
),
[appKeyword, sources],
)
useEffect(() => {
if (filteredApps.length > 0 && !filteredApps.some((source) => source.app_id === appID)) {
setAppID(filteredApps[0].app_id)
}
}, [appID, filteredApps])
const buildStartPayload = () => {
switch (authSource) {
case 'built_in_appid':
@@ -453,6 +463,11 @@ function Strm115AuthPanel({
}
const startAuth = async () => {
if (authSource === 'built_in_appid' && filteredApps.length === 0) {
toast.error('未找到匹配的官方应用')
return
}
setStarting(true)
setAuthStatus('')
try {
@@ -552,6 +567,7 @@ function Strm115AuthPanel({
</option>
))}
</select>
{filteredApps.length === 0 && <p className="text-xs text-sand-500">未找到匹配的官方应用</p>}
</Field>
</div>
)}
@@ -577,7 +593,12 @@ function Strm115AuthPanel({
</Field>
)}
<button type="button" onClick={startAuth} disabled={starting} className="neon-button disabled:opacity-50">
<button
type="button"
onClick={startAuth}
disabled={starting || (authSource === 'built_in_appid' && filteredApps.length === 0)}
className="neon-button disabled:opacity-50"
>
{starting ? <Loader2 size={16} className="animate-spin" /> : <QrCode size={16} />}
{authSource === 'built_in_appid' || authSource === 'custom_appid' ? '获取登录二维码' : '获取授权链接'}
</button>
+2 -2
View File
@@ -502,8 +502,8 @@ function RecordSection({ records, onDeleted }: { records: StrmSyncRecord[]; onDe
{records.length === 0 ? (
<p className="rounded-xl bg-gray-50 px-4 py-6 text-center text-sm text-sand-500">还没有同步记录</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<div className="table-scroll">
<table className="min-w-[980px] w-full text-left text-sm">
<thead className="border-b border-gray-200 text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="px-3 py-2">时间</th>
+3 -3
View File
@@ -470,7 +470,7 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
{/* Selected Batch Toolbar */}
{selectedIds.size > 0 && (
<div className="flex items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-1.5 text-xs animate-in fade-in zoom-in-95">
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-2 text-xs animate-in fade-in zoom-in-95">
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
<button
@@ -529,8 +529,8 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
: `「${FILTERS.find((f) => f.key === filter)?.label}」状态下暂无任务`}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<div className="table-scroll">
<table className="min-w-[900px] w-full text-left text-sm">
<thead className="border-b border-gray-200/80 bg-gray-50/50 text-[11px] font-bold uppercase tracking-wider text-sand-500">
<tr>
<th className="w-10 px-3 py-3 text-center">
+5 -5
View File
@@ -97,7 +97,7 @@ export function WatchHistoryPage() {
return (
<div
key={h.id}
className="glass-panel flex items-center gap-4 !p-3"
className="glass-panel flex flex-col gap-3 !p-3 sm:flex-row sm:items-center sm:gap-4"
>
<div className="h-16 w-12 shrink-0 overflow-hidden rounded-lg bg-surface-900">
{m.poster_url ? (
@@ -109,14 +109,14 @@ export function WatchHistoryPage() {
/>
) : null}
</div>
<div className="flex-1 space-y-1">
<div className="min-w-0 flex-1 space-y-1">
<Link
to={`/media/${m.id}`}
className="font-medium text-ink-600 transition hover:text-brand-500"
className="block truncate font-medium text-ink-600 transition hover:text-brand-500"
>
{m.title}
</Link>
<div className="flex items-center gap-3 text-xs text-ink-50">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-50">
<span>{fmtDuration(h.position_ms)} / {fmtDuration(h.duration_ms)}</span>
<span>{new Date(h.watched_at).toLocaleString()}</span>
{h.completed && (
@@ -132,7 +132,7 @@ export function WatchHistoryPage() {
/>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex w-full items-center gap-2 sm:w-auto sm:shrink-0">
<Link
to={`/play/${m.id}`}
className="neon-button !px-3 !py-1 !text-xs"
+8
View File
@@ -99,6 +99,14 @@ export const generalSettingsGroup: SettingGroup = {
hint: 'NAS 建议 1;用于扫描、整理洗版和手动探测,避免同时启动多个 ffprobe 进程',
defaultValue: '1',
},
{
key: 'cache.images_max_size_mb',
label: '图片缓存上限 (MB)',
type: 'number',
hint: '海报、剧照等图片代理缓存目录 (cache/images) 的最大占用空间。超过上限时自动按修改时间清理最旧的文件。设为 0 表示不限制,默认 500MB',
defaultValue: '500',
placeholder: '500',
},
{
key: 'https.enabled',
label: '启用 HTTPS',
+1
View File
@@ -6,6 +6,7 @@ export interface EmbyMount {
remote_view_name: string
collection_type: string
name: string
sort_order?: number
proxy_play: boolean
enabled: boolean
created_at: string