Compare commits

..

22 Commits

Author SHA1 Message Date
truewhile 5faa6975d0 优化标签 2026-09-16 13:50:06 +08:00
truewhile cbfae0252e t添加标签功能 2026-09-16 12:30:34 +08:00
truewhile 57ee482146 优化 2026-09-15 23:13:31 +08:00
truewhile a9b3110b2c 优化 2026-09-15 22:05:34 +08:00
truewhile ff86c2f597 yb显示 2026-09-15 18:50:50 +08:00
truewhile a5731fa963 优化显示 2026-09-15 18:26:20 +08:00
truewhile 531e45f677 优化 2026-09-15 16:48:42 +08:00
truewhile cd3f028f0b 修复bug 2026-09-15 16:12:59 +08:00
truewhile bd7b7a722b Merge pull request #34 from truewhile/cursor/hide-viewer-admin-ui
Cursor/hide viewer admin UI
2026-09-15 15:23:58 +08:00
truewhile 83deffcf45 修复 2026-09-15 15:21:43 +08:00
truewhile c74f6be250 Hide scrape and library admin controls from non-admin users.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 15:20:09 +08:00
truewhile c911a57467 bug处理 2026-09-15 09:58:47 +08:00
truewhile 1dbc3f8fe7 Merge branch 'main' of https://github.com/truewhile/MeBox 2026-09-15 00:00:54 +08:00
truewhile a563ee11d6 优化,添加滚动记忆 2026-09-15 00:00:35 +08:00
truewhile 215ebf6942 Merge pull request #33 from truewhile/cursor/fix-library-scrape-dialog-position
优化
2026-09-14 22:55:16 +08:00
truewhile e4c0e85eaa 优化 2026-09-14 22:54:09 +08:00
truewhile 60f05129c9 优化 2026-09-14 21:56:47 +08:00
truewhile fa4c90d47a bug处理 2026-09-14 21:40:13 +08:00
truewhile 1ec8303fec bug处理 2026-09-14 21:16:34 +08:00
truewhile b28c0d9247 添加115云端解码功能 2026-09-14 17:57:51 +08:00
truewhile 91c6d9dfd9 处理token刷新失败bug 2026-09-14 16:47:44 +08:00
truewhile 95d8126d65 优化windows包,优化windos可执行文件功能 2026-09-14 15:48:59 +08:00
110 changed files with 7716 additions and 1073 deletions
+2
View File
@@ -0,0 +1,2 @@
'Get-Content' is not recognized as an internal or external command,
operable program or batch file.
+5 -1
View File
@@ -196,8 +196,12 @@ jobs:
path: web/dist
- name: Build binary
run: |
LDFLAGS="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
LDFLAGS="$LDFLAGS -H=windowsgui"
fi
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" \
go build -trimpath -ldflags="$LDFLAGS" \
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
- name: Package
run: |
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
- name: Build linux/arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v4
+8
View File
@@ -225,6 +225,14 @@ npm --prefix web run dev # http://127.0.0.1:3000
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
Windows 本地打包:
```powershell
.\scripts\build-windows.ps1 -Version dev
```
Windows 可执行程序使用项目 Logo,不显示控制台窗口;启动后会常驻系统托盘。托盘菜单可打开 MeBox、切换开机自启、查看日志、重启或退出。
---
## 鸣谢
+8
View File
@@ -199,6 +199,14 @@ npm --prefix web run dev
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
Build the Windows executable locally:
```powershell
.\scripts\build-windows.ps1 -Version dev
```
The Windows executable uses the project logo and runs without a console window. It stays in the notification area, with menu actions for opening MeBox, toggling auto-start, viewing logs, restarting, and exiting.
---
## Acknowledgements
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"context"
"fmt"
"os"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// application owns the long-running MeBox server and all resources created
// during startup. Platform entry points decide how the application is
// controlled: a console signal loop or a Windows notification-area icon.
type application struct {
cfg *config.Config
logger *zap.Logger
embyCompatLogger *zap.Logger
serverManager *serverManager
services *service.Container
closeMu sync.Mutex
closeFuncs []func()
shutdown sync.Once
shutdownErr error
}
func newApplication() (*application, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("config load failed: %w", err)
}
logger, closeLogger, err := newLoggerWithCloser(cfg)
if err != nil {
return nil, fmt.Errorf("logger init failed: %w", err)
}
app := &application{
cfg: cfg,
logger: logger,
closeFuncs: []func(){closeLogger},
}
if err := app.start(); err != nil {
app.closeLoggers()
return nil, err
}
return app, nil
}
func (a *application) start() error {
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(a.cfg)
if err != nil {
a.logger.Error("Emby compatibility logger init failed", zap.Error(err))
return fmt.Errorf("Emby compatibility logger init failed: %w", err)
}
a.embyCompatLogger = embyCompatLogger
a.addCloser(closeEmbyCompatLogger)
appVersion := effectiveVersion(version)
a.logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", a.cfg.App.Port),
zap.String("data_dir", a.cfg.App.DataDir),
zap.String("emby_compat_log", embyCompatLogPath(a.cfg)),
)
for _, dir := range []string{a.cfg.App.DataDir, a.cfg.Cache.CacheDir} {
if err := os.MkdirAll(dir, 0o750); err != nil {
a.logger.Error("create dir failed", zap.String("dir", dir), zap.Error(err))
return fmt.Errorf("create dir %s failed: %w", dir, err)
}
}
db, err := database.Open(a.cfg, a.logger)
if err != nil {
a.logger.Error("database open failed", zap.Error(err))
return fmt.Errorf("database open failed: %w", err)
}
if err := waitForDatabase(db, a.logger); err != nil {
a.logger.Error("database not ready", zap.Error(err))
return fmt.Errorf("database not ready: %w", err)
}
if err := database.AutoMigrate(db); err != nil {
a.logger.Error("auto-migrate failed", zap.Error(err))
return fmt.Errorf("auto-migrate failed: %w", err)
}
if err := database.MigrateSQLiteToCurrentIfNeeded(a.cfg, db, a.logger); err != nil {
a.logger.Error("sqlite to postgres migration failed", zap.Error(err))
return fmt.Errorf("sqlite to postgres migration failed: %w", err)
}
repos := repository.New(db)
service.ApplyRuntimeSettings(context.Background(), a.cfg, repos, a.logger)
applyCPUThreadLimit(a.cfg, a.logger)
a.services = service.NewWithVersion(a.cfg, a.logger, repos, appVersion)
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
if cleaned, err := a.services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
a.logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
} else if cleaned > 0 {
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
a.logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(a.cfg, a.logger, a.embyCompatLogger, a.services)
a.serverManager = newServerManager(a.cfg, a.logger, router)
a.services.ReloadHTTPServer = a.serverManager.Reload
if err := a.serverManager.Start(); err != nil {
a.logger.Error("listen failed", zap.Error(err))
return fmt.Errorf("listen failed: %w", err)
}
go func() {
scheme := "http"
if a.cfg.App.HTTPSEnabled {
scheme = "https"
}
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
a.logger.Info("server public endpoint",
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, a.cfg.App.Port)),
)
}
}()
helper.Go(a.logger, "services.boot", a.services.Boot)
return nil
}
// Shutdown is idempotent and safe to call from both the tray handler and the
// systray exit callback.
func (a *application) Shutdown() error {
a.shutdown.Do(func() {
a.logger.Info("shutdown requested")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
if a.serverManager != nil {
if err := a.serverManager.Shutdown(ctx); err != nil {
a.shutdownErr = err
a.logger.Error("graceful shutdown failed", zap.Error(err))
}
}
cancel()
if a.services != nil {
a.services.Close()
}
a.logger.Info("MeBox stopped")
_ = a.logger.Sync()
if a.embyCompatLogger != nil {
_ = a.embyCompatLogger.Sync()
}
a.closeLoggers()
})
return a.shutdownErr
}
func (a *application) addCloser(fn func()) {
if fn == nil {
return
}
a.closeMu.Lock()
a.closeFuncs = append(a.closeFuncs, fn)
a.closeMu.Unlock()
}
func (a *application) closeLoggers() {
a.closeMu.Lock()
closers := append([]func(){}, a.closeFuncs...)
a.closeFuncs = nil
a.closeMu.Unlock()
for i := len(closers) - 1; i >= 0; i-- {
closers[i]()
}
}
func (a *application) localURL() string {
config.RuntimeMu.RLock()
scheme := "http"
if a.cfg.App.HTTPSEnabled {
scheme = "https"
}
port := a.cfg.App.Port
config.RuntimeMu.RUnlock()
return fmt.Sprintf("%s://127.0.0.1:%d", scheme, port)
}
+1 -114
View File
@@ -11,21 +11,8 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// version is overwritten at build time via -ldflags="-X main.version=...".
@@ -46,105 +33,5 @@ func effectiveVersion(buildVersion string) string {
}
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config load failed: %v\n", err)
os.Exit(1)
}
logger, err := newLogger(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err)
os.Exit(1)
}
defer func() { _ = logger.Sync() }()
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(cfg)
if err != nil {
logger.Fatal("Emby compatibility logger init failed", zap.Error(err))
}
defer func() {
_ = embyCompatLogger.Sync()
closeEmbyCompatLogger()
}()
appVersion := effectiveVersion(version)
logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", cfg.App.Port),
zap.String("data_dir", cfg.App.DataDir),
zap.String("emby_compat_log", embyCompatLogPath(cfg)),
)
// Ensure data / cache / web dirs exist.
for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} {
if err := os.MkdirAll(d, 0o750); err != nil {
logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err))
}
}
db, err := database.Open(cfg, logger)
if err != nil {
logger.Fatal("database open failed", zap.Error(err))
}
if err := waitForDatabase(db, logger); err != nil {
logger.Fatal("database not ready", zap.Error(err))
}
if err := database.AutoMigrate(db); err != nil {
logger.Fatal("auto-migrate failed", zap.Error(err))
}
if err := database.MigrateSQLiteToCurrentIfNeeded(cfg, db, logger); err != nil {
logger.Fatal("sqlite to postgres migration failed", zap.Error(err))
}
repos := repository.New(db)
service.ApplyRuntimeSettings(context.Background(), cfg, repos, logger)
applyCPUThreadLimit(cfg, logger)
services := service.NewWithVersion(cfg, logger, repos, appVersion)
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
} else if cleaned > 0 {
logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(cfg, logger, embyCompatLogger, services)
serverMgr := newServerManager(cfg, logger, router)
services.ReloadHTTPServer = serverMgr.Reload
if err := serverMgr.Start(); err != nil {
logger.Fatal("listen failed", zap.Error(err))
}
go func() {
scheme := "http"
if cfg.App.HTTPSEnabled {
scheme = "https"
}
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
logger.Info("server public endpoint",
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, cfg.App.Port)),
)
}
}()
helper.Go(logger, "services.boot", services.Boot)
// Graceful shutdown.
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
logger.Info("shutdown requested")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := serverMgr.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", zap.Error(err))
}
services.Close()
logger.Info("MeBox stopped")
runProgram()
}
+33
View File
@@ -0,0 +1,33 @@
//go:build !windows
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func runProgram() {
app, err := newApplication()
if err != nil {
reportError("MeBox 启动失败", err)
os.Exit(1)
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
if err := app.Shutdown(); err != nil {
reportError("MeBox 退出失败", err)
}
}
func reportError(title string, err error) {
if err == nil {
return
}
fmt.Fprintf(os.Stderr, "%s: %v\n", title, err)
}
+330
View File
@@ -0,0 +1,330 @@
//go:build windows
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"fyne.io/systray"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"github.com/truewhile/MeBox/internal/brand"
)
const (
runRegistryKey = `Software\Microsoft\Windows\CurrentVersion\Run`
runRegistryValue = "MeBox"
singleInstanceName = `Local\MeBox-Server`
)
var errAlreadyRunning = errors.New("MeBox is already running")
func runProgram() {
if err := prepareWorkingDirectory(); err != nil {
reportError("MeBox 启动失败", fmt.Errorf("切换工作目录失败: %w", err))
return
}
instance, err := acquireSingleInstance()
if errors.Is(err, errAlreadyRunning) {
reportError("MeBox", errors.New("MeBox 已在运行,请查看右下角托盘图标"))
return
}
if err != nil {
reportError("MeBox 启动失败", fmt.Errorf("创建单实例锁失败: %w", err))
return
}
app, err := newApplication()
if err != nil {
_ = instance.Close()
reportError("MeBox 启动失败", err)
return
}
controller := &trayController{app: app}
systray.Run(controller.onReady, controller.onExit)
// Release the mutex before starting the replacement process. The new
// process must be able to acquire it immediately after the old one exits.
_ = instance.Close()
if !controller.readyClosed.Load() {
_ = app.Shutdown()
reportError("MeBox 启动失败", errors.New("系统托盘初始化失败"))
return
}
if controller.restartRequested.Load() {
if err := launchSelf(); err != nil {
reportError("MeBox 重启失败", err)
}
}
}
type trayController struct {
app *application
readyClosed atomic.Bool
restartRequested atomic.Bool
shutdownStarted atomic.Bool
}
func (c *trayController) onReady() {
defer func() {
c.readyClosed.Store(true)
}()
systray.SetIcon(brand.Icon)
systray.SetTooltip("MeBox")
mOpen := systray.AddMenuItem("打开 MeBox", "在浏览器中打开 MeBox")
mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 后自动启动 MeBox", autoStartEnabled())
mLogs := systray.AddMenuItem("查看日志", "打开 MeBox 应用日志")
systray.AddSeparator()
mRestart := systray.AddMenuItem("重启 MeBox", "重启 MeBox 服务")
mQuit := systray.AddMenuItem("退出 MeBox", "停止服务并退出")
initialAutoStart := mAutoStart.Checked()
go func() {
for {
select {
case <-mOpen.ClickedCh:
if err := openURL(c.app.localURL()); err != nil {
reportError("MeBox", fmt.Errorf("打开 MeBox 失败: %w", err))
}
case <-mAutoStart.ClickedCh:
enable := !mAutoStart.Checked()
if err := setAutoStart(enable); err != nil {
if initialAutoStart {
mAutoStart.Check()
} else {
mAutoStart.Uncheck()
}
reportError("MeBox", fmt.Errorf("更新开机自启设置失败: %w", err))
continue
}
if enable {
mAutoStart.Check()
} else {
mAutoStart.Uncheck()
}
initialAutoStart = enable
case <-mLogs.ClickedCh:
if err := c.app.openLog(); err != nil {
reportError("MeBox", fmt.Errorf("打开日志失败: %w", err))
}
case <-mRestart.ClickedCh:
c.restart()
case <-mQuit.ClickedCh:
c.quit()
}
}
}()
}
func (c *trayController) onExit() {
_ = c.app.Shutdown()
}
func (c *trayController) quit() {
if !c.shutdownStarted.CompareAndSwap(false, true) {
return
}
go func() {
_ = c.app.Shutdown()
systray.Quit()
}()
}
func (c *trayController) restart() {
if !c.shutdownStarted.CompareAndSwap(false, true) {
return
}
c.restartRequested.Store(true)
go func() {
_ = c.app.Shutdown()
systray.Quit()
}()
}
func (a *application) openLog() error {
appLog, _, _ := logFilePaths(a.cfg)
if appLog != "" {
if _, err := os.Stat(appLog); err == nil {
return openPath(appLog)
}
_ = os.MkdirAll(filepath.Dir(appLog), 0o750)
return openPath(filepath.Dir(appLog))
}
logDir := filepath.Join(a.cfg.App.DataDir, "logs")
if err := os.MkdirAll(logDir, 0o750); err != nil {
return err
}
return openPath(logDir)
}
func prepareWorkingDirectory() error {
exe, err := os.Executable()
if err != nil {
return err
}
exeDir := filepath.Dir(exe)
cwd, err := os.Getwd()
if err != nil {
return err
}
if samePath(exeDir, cwd) || looksLikeProjectDirectory(cwd) {
return nil
}
return os.Chdir(exeDir)
}
func looksLikeProjectDirectory(dir string) bool {
for _, name := range []string{"go.mod", "config.yaml", "data", filepath.Join("web", "dist")} {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return true
}
}
return false
}
func samePath(left, right string) bool {
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
type singleInstance struct {
handle windows.Handle
}
func acquireSingleInstance() (*singleInstance, error) {
name, err := windows.UTF16PtrFromString(singleInstanceName)
if err != nil {
return nil, err
}
handle, err := windows.CreateMutex(nil, false, name)
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
if handle != 0 {
_ = windows.CloseHandle(handle)
}
return nil, errAlreadyRunning
}
if err != nil {
return nil, err
}
return &singleInstance{handle: handle}, nil
}
func (s *singleInstance) Close() error {
if s == nil || s.handle == 0 {
return nil
}
err := windows.CloseHandle(s.handle)
s.handle = 0
return err
}
func launchSelf() error {
exe, err := os.Executable()
if err != nil {
return err
}
cwd, err := os.Getwd()
if err != nil {
return err
}
cmd := exec.Command(exe, os.Args[1:]...)
cmd.Dir = cwd
cmd.Env = os.Environ()
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS,
}
if err := cmd.Start(); err != nil {
return err
}
return cmd.Process.Release()
}
func autoStartEnabled() bool {
key, err := registry.OpenKey(registry.CURRENT_USER, runRegistryKey, registry.QUERY_VALUE)
if err != nil {
return false
}
defer key.Close()
value, _, err := key.GetStringValue(runRegistryValue)
if err != nil {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(strings.Trim(value, `"`)), filepath.Clean(exe))
}
func setAutoStart(enabled bool) error {
key, _, err := registry.CreateKey(registry.CURRENT_USER, runRegistryKey, registry.SET_VALUE)
if err != nil {
return err
}
defer key.Close()
if !enabled {
if err := key.DeleteValue(runRegistryValue); err != nil && !errors.Is(err, registry.ErrNotExist) {
return err
}
return nil
}
exe, err := os.Executable()
if err != nil {
return err
}
return key.SetStringValue(runRegistryValue, syscall.EscapeArg(filepath.Clean(exe)))
}
func openURL(url string) error {
return shellOpen(url)
}
func openPath(path string) error {
return shellOpen(path)
}
func shellOpen(target string) error {
targetPtr, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
verbPtr, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
return windows.ShellExecute(0, verbPtr, targetPtr, nil, nil, 1)
}
func reportError(title string, err error) {
if err == nil {
return
}
text, textErr := windows.UTF16PtrFromString(title + "\r\n\r\n" + err.Error())
if textErr != nil {
return
}
caption, captionErr := windows.UTF16PtrFromString("MeBox")
if captionErr != nil {
return
}
_, _ = windows.MessageBox(0, text, caption, windows.MB_OK|windows.MB_ICONERROR|windows.MB_SETFOREGROUND)
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package main
// Regenerate the linked Windows resources after changing the project logo or
// manifest:
//
// go generate ./cmd/server
//
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch amd64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_amd64.syso
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch arm64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_arm64.syso
Binary file not shown.
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
version="1.0.0.0"
processorArchitecture="*"
name="MeBox"
type="win32"
/>
<description>MeBox media server</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
+2
View File
@@ -3,6 +3,7 @@ module github.com/truewhile/MeBox
go 1.25.0
require (
fyne.io/systray v1.12.2
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-contrib/gzip v1.2.6
@@ -48,6 +49,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+4
View File
@@ -1,3 +1,5 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -56,6 +58,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+10
View File
@@ -0,0 +1,10 @@
// Package brand contains the embedded MeBox application artwork.
package brand
import _ "embed"
// Icon is the Windows ICO used by both the executable resource and the
// notification-area icon.
//
//go:embed logo.ico
var Icon []byte
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+148
View File
@@ -0,0 +1,148 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func mediaPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
quality, _ := strconv.Atoi(c.Query("quality"))
info, err := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, quality)
if err != nil {
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func mediaTranscodeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
var req struct {
Definition int `json:"definition"`
}
_ = c.ShouldBindJSON(&req)
info, err := svc.Cloud115.StartTranscode(c.Request.Context(), m.ID, req.Definition)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func cloud115HLSMasterHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
definition, _ := strconv.Atoi(c.Query("definition"))
err = svc.Cloud115.HLSProxy().ServeMaster(c.Request.Context(), c.Writer, c.Request, m.ID, definition)
if err == nil {
return
}
if c.Writer.Written() {
return
}
if errors.Is(err, service.ErrCloud115TranscodePending) {
retryAfter := 5
message := "115 正在转码,请稍候"
var info *service.PlaybackInfo
if latest, infoErr := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, definition); infoErr == nil && latest != nil {
info = latest
if latest.Transcode.Message != "" {
message = latest.Transcode.Message
}
if latest.Transcode.RetryAfterSec > 0 {
retryAfter = latest.Transcode.RetryAfterSec
}
}
c.JSON(http.StatusConflict, gin.H{
"code": "transcode_pending",
"message": message,
"retry_after_sec": retryAfter,
"playback": info,
})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
mediaID := c.Query("media_id")
if mediaID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
return
}
if !enforceScopedPlaybackToken(c, mediaID) {
return
}
err := svc.Cloud115.HLSProxy().ServeChild(
c.Request.Context(),
c.Writer,
c.Request,
c.Param("session"),
c.Param("key"),
)
if err == nil || c.Writer.Written() {
return
}
switch {
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"})
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
}
+27 -4
View File
@@ -112,7 +112,8 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
t.Fatalf("status = %d, want 200", w.Code)
}
var cfg struct {
MergeSources bool `json:"merge_sources"`
MergeSources bool `json:"merge_sources"`
PlaybackRate float64 `json:"playback_rate"`
}
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
@@ -120,6 +121,9 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
if cfg.MergeSources {
t.Fatal("default merge preference should be false")
}
if cfg.PlaybackRate != 1 {
t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate)
}
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
t.Fatalf("set: %v", err)
@@ -137,7 +141,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
svc := newDanmakuSettingsService(t)
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
updateDanmakuSettingsHandler(svc)(c)
@@ -154,7 +158,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
t.Fatalf("unexpected render config: %+v", cfg)
}
if !cfg.MergeSources || cfg.Volume != 0.35 {
if !cfg.MergeSources || cfg.Volume != 0.35 || cfg.PlaybackRate != 1.5 {
t.Fatalf("unexpected user preferences: %+v", cfg)
}
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
@@ -165,11 +169,30 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" {
t.Fatalf("preferences not persisted: %+v", user)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"playback_rate":4}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read user: %v", err)
}
if user.PlayerPlaybackRate != 1 {
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
svc := newDanmakuSettingsService(t)
+7
View File
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
return ""
}
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
// 否则多用户会串到同一个 UserId 上。
func embyEffectiveUserID(c *gin.Context) string {
return embyUserID(c)
}
const embyCompatSessionTTL = 30 * time.Minute
type embyCompatSession struct {
+7 -34
View File
@@ -13,13 +13,7 @@ import (
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
splitOpt := func(s string) []string {
if s == "" {
return nil
@@ -73,10 +67,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -106,13 +97,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
if err != nil {
@@ -126,13 +111,7 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
if err != nil {
@@ -147,13 +126,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc != nil && svc.Emby != nil {
uid := firstQueryValue(c, "UserId", "userId")
if uid == "" {
uid = c.Param("userId")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -209,7 +182,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
UserID: embyEffectiveUserID(c),
ParentID: c.Param("id"),
Limit: 500,
}
@@ -230,7 +203,7 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
parentID = c.Param("id")
}
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
UserID: embyEffectiveUserID(c),
ParentID: parentID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
+2 -8
View File
@@ -13,10 +13,7 @@ import (
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -47,10 +44,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
}
return
}
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
ctx := c.Request.Context()
// The official-format route carries a :format suffix (Stream.ass /
// Stream.vtt); prefer it for the Content-Type when present, otherwise
+2 -8
View File
@@ -92,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
@@ -116,10 +113,7 @@ func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
+1 -4
View File
@@ -10,10 +10,7 @@ import (
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.Views(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+40
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
@@ -13,6 +14,45 @@ type pinnedLibrariesReq struct {
LibraryIDs []string `json:"library_ids"`
}
type libraryTagsReq struct {
Tags []model.LibraryTagSet `json:"tags"`
}
func getLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
tags, err := svc.Profile.GetLibraryTags(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tags == nil {
tags = []model.LibraryTagSet{}
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
}
func setLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req libraryTagsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
tags, err := svc.Profile.SetLibraryTags(c.Request.Context(), uid.(string), req.Tags)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tags == nil {
tags = []model.LibraryTagSet{}
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
}
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
@@ -12,6 +12,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
authed.PATCH("/me", updateProfileHandler(svc))
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
authed.GET("/me/library-tags", getLibraryTagsHandler(svc))
authed.PUT("/me/library-tags", setLibraryTagsHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.POST("/me/logout", logoutHandler(svc))
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
@@ -44,6 +46,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/media/:id", getMediaHandler(svc))
authed.GET("/media/:id/episodes", listMediaEpisodesHandler(svc))
authed.GET("/media/:id/playback", mediaPlaybackInfoHandler(svc))
authed.POST("/media/:id/transcode", mediaTranscodeHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
@@ -65,6 +69,10 @@ func registerAuthedPlaybackAndProxyRoutes(authed *gin.RouterGroup, svc *service.
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
authed.DELETE("/hls/:id", stopTranscodeHandler(svc))
authed.GET("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc))
authed.HEAD("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc))
authed.GET("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
authed.HEAD("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
authed.GET("/img", imageProxyHandler(svc))
}
@@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
"GET /api/me",
"GET /api/me/pinned-libraries",
"PUT /api/me/pinned-libraries",
"GET /api/me/library-tags",
"PUT /api/me/library-tags",
"GET /api/auth/permissions",
"GET /api/libraries",
"GET /api/media",
+1 -1
View File
@@ -89,7 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
cards = sortRemoteSeriesCards(cards, sortSpec, history)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
+23
View File
@@ -0,0 +1,23 @@
package handler
import (
"slices"
"time"
"github.com/truewhile/MeBox/internal/service"
)
// sortRemoteSeriesCards keeps the DateLastContentAdded order returned by the
// remote Emby server when LastAddedAt is not exposed in the Series DTO.
func sortRemoteSeriesCards(cards []service.SeriesCard, spec service.MediaSortSpec, history map[string]time.Time) []service.SeriesCard {
if spec.Field != "updated_at" {
return service.SortSeriesCards(cards, spec.Field, spec.Order, history)
}
out := append([]service.SeriesCard(nil), cards...)
// RemoteSeriesCards always fetches DateLastContentAdded in descending order.
if spec.Order == "asc" {
slices.Reverse(out)
}
return service.SortSeriesCards(out, spec.Field, spec.Order, history)
}
+21
View File
@@ -0,0 +1,21 @@
package handler
import (
"testing"
"github.com/truewhile/MeBox/internal/service"
)
func TestSortRemoteSeriesCardsUpdatedAtUsesRemoteOrder(t *testing.T) {
cards := []service.SeriesCard{{Key: "newest"}, {Key: "middle"}, {Key: "oldest"}}
desc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "desc"}, nil)
if desc[0].Key != "newest" || desc[1].Key != "middle" || desc[2].Key != "oldest" {
t.Fatalf("descending order = %q, %q, %q", desc[0].Key, desc[1].Key, desc[2].Key)
}
asc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "asc"}, nil)
if asc[0].Key != "oldest" || asc[1].Key != "middle" || asc[2].Key != "newest" {
t.Fatalf("ascending order = %q, %q, %q", asc[0].Key, asc[1].Key, asc[2].Key)
}
}
+20 -10
View File
@@ -138,25 +138,35 @@ func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
return
}
now := time.Now()
acct.LastTestAt = &now
if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil {
result := ""
ok := false
if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil {
acct.LastTestResult = err.Error()
acct.LastTestOK = false
result = err.Error()
} else {
acct.LastTestResult = "ok"
acct.LastTestOK = true
result = "ok"
ok = true
}
} else {
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
acct.LastTestAt = &now
acct.LastTestResult = result
acct.LastTestOK = ok
if err := svc.Repo.StrmAccount.UpdateTestResult(c.Request.Context(), acct.ID, now, result, ok); err != nil {
// 写库失败时仍返回本地测试结果;不要回退到整行 Update,
// 那会覆盖 TestConnection 期间可能刷新的账号配置。
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
return
}
if fresh, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), acct.ID); err == nil && fresh != nil {
acct = fresh
}
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
return
}
_ = svc.Repo.StrmAccount.Update(c.Request.Context(), acct)
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
return
}
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
}
}
+32 -40
View File
@@ -92,13 +92,14 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
var rows []model.PlaybackHistory
if err := svc.Repo.DB.
Where("user_id = ? AND completed = ?", toString(uid), false).
Where("user_id = ? AND completed = ?", userID, false).
Order("watched_at desc").
Limit(limit).
Find(&rows).Error; err != nil {
@@ -116,61 +117,52 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
}
mIdx := make(map[string]model.Media, len(media))
for _, m := range media {
if !mediaVisibleForRequest(c, svc, &m) {
continue
}
mIdx[m.ID] = m
}
out := make([]gin.H, 0, len(rows))
staleIDs := make([]string, 0)
for _, r := range rows {
m, ok := mIdx[r.MediaID]
if !ok {
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
if mediaVisibleForRequest(c, svc, rm) {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
}
continue
}
}
}
fallback := fallbackHistoryMedia(r.MediaID)
if fallback != nil {
if ok {
if mediaVisibleForRequest(c, svc, &m) {
out = append(out, gin.H{
"history": r,
"media": *fallback,
"media": m,
})
}
continue
}
out = append(out, gin.H{
"history": r,
"media": m,
})
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID)
if resolveErr == nil && mount != nil && acct != nil {
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID)
if detailErr == nil && remoteMedia != nil {
if mediaVisibleForRequest(c, svc, remoteMedia) {
out = append(out, gin.H{
"history": r,
"media": *remoteMedia,
})
}
continue
}
}
}
// 媒体记录已不存在。继续返回占位卡只会让用户点击后遇到 404,
// 因此清理这条失效播放记录,不再占用继续观看列表。
staleIDs = append(staleIDs, r.MediaID)
}
if len(staleIDs) > 0 {
_ = svc.Repo.DB.WithContext(c.Request.Context()).Unscoped().
Where("user_id = ? AND media_id IN ?", userID, staleIDs).
Delete(&model.PlaybackHistory{}).Error
}
c.JSON(http.StatusOK, out)
}
}
func fallbackHistoryMedia(mediaID string) *model.Media {
if mediaID == "" {
return nil
}
title := "媒体"
if service.IsEmbyRemoteID(mediaID) {
title = "远程媒体"
}
return &model.Media{
Base: model.Base{ID: mediaID},
Title: title,
}
}
// historyDeleteHandler removes one or all history rows for the caller.
//
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
+105
View File
@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
valid := model.Media{
Base: model.Base{ID: "media-valid"},
Title: "有效影片",
Path: "/media/valid.mkv",
}
if err := db.Create(&valid).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-valid"},
UserID: "user-1",
MediaID: valid.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: now.Add(-time.Minute),
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-stale"},
UserID: "user-1",
MediaID: "media-deleted",
PositionMs: 60_000,
DurationMs: 120_000,
WatchedAt: now,
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history/continue", historyContinueHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/watch-history/continue?limit=10", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var body []struct {
History model.PlaybackHistory `json:"history"`
Media model.Media `json:"media"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != 1 {
t.Fatalf("continue watching rows = %d, want 1: %s", len(body), rec.Body.String())
}
if body[0].Media.ID != valid.ID || body[0].History.ID != "history-valid" {
t.Fatalf("unexpected row: %#v", body[0])
}
var staleCount int64
if err := db.Unscoped().Model(&model.PlaybackHistory{}).
Where("user_id = ? AND media_id = ?", "user-1", "media-deleted").
Count(&staleCount).Error; err != nil {
t.Fatal(err)
}
if staleCount != 0 {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
+2
View File
@@ -125,6 +125,8 @@ func externalPlaybackTokenAllowedPath(c *gin.Context, mediaID string) bool {
return len(segments) >= 3 &&
strings.EqualFold(segments[2], "play") &&
strings.TrimSpace(c.Query("media_id")) == mediaID
case "cloud115":
return strings.TrimSpace(c.Query("media_id")) == mediaID
}
}
return false
+118
View File
@@ -0,0 +1,118 @@
package model
import (
"encoding/json"
"strings"
)
// MaxLibraryTags 是单个用户可创建的标签数量上限,避免恶意写入过大的 JSON。
const MaxLibraryTags = 50
// MaxLibraryTagNameLen 是单个标签名的最大字符长度(按 rune 计数)。
const MaxLibraryTagNameLen = 24
// LibraryTagSet 是用户自定义的媒体库标签分组。
// LibraryIDs 保存该标签下媒体库的 ID(含远程 Emby 挂载库的 embyremote~ 形式),
// 顺序即媒体库在该标签内的展示顺序。
type LibraryTagSet struct {
Name string `json:"name"`
LibraryIDs []string `json:"library_ids"`
}
// DecodeLibraryTags 解析 LibraryTags 字段,忽略损坏的数据。
func (u *User) DecodeLibraryTags() []LibraryTagSet {
if u == nil || strings.TrimSpace(u.LibraryTags) == "" {
return nil
}
var tags []LibraryTagSet
if err := json.Unmarshal([]byte(u.LibraryTags), &tags); err != nil {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
for _, tag := range NormalizeLibraryTags(tags) {
out = append(out, tag)
}
if len(out) == 0 {
return nil
}
return out
}
// EncodeLibraryTags 把标签集合序列化为可写入 LibraryTags 字段的 JSON 字符串。
// 空集合序列化为空字符串,便于用零值表达"没有标签"。
func EncodeLibraryTags(tags []LibraryTagSet) (string, error) {
if len(tags) == 0 {
return "", nil
}
raw, err := json.Marshal(tags)
if err != nil {
return "", err
}
return string(raw), nil
}
// NormalizeLibraryTags 清洗标签集合:去空白、丢弃空名标签、去掉标签名与
// 标签内媒体库 ID 的重复项,并把标签名重复的项合并。保持传入顺序。
func NormalizeLibraryTags(tags []LibraryTagSet) []LibraryTagSet {
if len(tags) == 0 {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
indexByName := make(map[string]int, len(tags))
for _, tag := range tags {
name := TruncateLibraryTagName(tag.Name)
if name == "" {
continue
}
if len(out) >= MaxLibraryTags {
break
}
key := strings.ToLower(name)
pos, exists := indexByName[key]
if !exists {
if len(out) >= MaxLibraryTags {
break
}
out = append(out, LibraryTagSet{Name: name, LibraryIDs: []string{}})
pos = len(out) - 1
indexByName[key] = pos
}
seen := make(map[string]struct{}, len(out[pos].LibraryIDs))
for _, id := range out[pos].LibraryIDs {
seen[id] = struct{}{}
}
for _, id := range tag.LibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out[pos].LibraryIDs = append(out[pos].LibraryIDs, trimmed)
}
}
if len(out) == 0 {
return nil
}
for i := range out {
if out[i].LibraryIDs == nil {
out[i].LibraryIDs = []string{}
}
}
return out
}
// TruncateLibraryTagName 去掉首尾空白并按 rune 截断到长度上限。
func TruncateLibraryTagName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
runes := []rune(name)
if len(runes) > MaxLibraryTagNameLen {
runes = runes[:MaxLibraryTagNameLen]
}
return strings.TrimSpace(string(runes))
}
+79
View File
@@ -0,0 +1,79 @@
package model
import "testing"
func TestNormalizeLibraryTagsMergesDuplicatesAndTrims(t *testing.T) {
tags := []LibraryTagSet{
{Name: " 动画 ", LibraryIDs: []string{" a ", "", "b", "a"}},
{Name: "动画", LibraryIDs: []string{"c", "b"}},
{Name: " ", LibraryIDs: []string{"x"}},
{Name: "电影", LibraryIDs: nil},
}
got := NormalizeLibraryTags(tags)
if len(got) != 2 {
t.Fatalf("NormalizeLibraryTags len = %d, want 2 (%#v)", len(got), got)
}
if got[0].Name != "动画" {
t.Fatalf("first tag name = %q, want 动画", got[0].Name)
}
want := []string{"a", "b", "c"}
if len(got[0].LibraryIDs) != len(want) {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
for i := range want {
if got[0].LibraryIDs[i] != want[i] {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
}
if got[1].Name != "电影" || got[1].LibraryIDs == nil {
t.Fatalf("empty tag should survive with an empty non-nil id list: %#v", got[1])
}
}
func TestNormalizeLibraryTagsCapsCountAndNameLength(t *testing.T) {
long := make([]rune, MaxLibraryTagNameLen+10)
for i := range long {
long[i] = 'x'
}
got := NormalizeLibraryTags([]LibraryTagSet{{Name: string(long)}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if runes := []rune(got[0].Name); len(runes) != MaxLibraryTagNameLen {
t.Fatalf("name length = %d, want %d", len(runes), MaxLibraryTagNameLen)
}
many := make([]LibraryTagSet, 0, MaxLibraryTags+5)
for i := 0; i < MaxLibraryTags+5; i++ {
many = append(many, LibraryTagSet{Name: string(rune('a' + i%26)) + "-" + string(rune('a'+i/26))})
}
if capped := NormalizeLibraryTags(many); len(capped) > MaxLibraryTags {
t.Fatalf("capped len = %d, want <= %d", len(capped), MaxLibraryTags)
}
}
func TestEncodeDecodeLibraryTagsRoundTrip(t *testing.T) {
user := &User{}
if encoded, err := EncodeLibraryTags(nil); err != nil || encoded != "" {
t.Fatalf("EncodeLibraryTags(nil) = %q, %v; want \"\", nil", encoded, err)
}
raw, err := EncodeLibraryTags([]LibraryTagSet{{Name: "动画", LibraryIDs: []string{"lib-1"}}})
if err != nil {
t.Fatalf("EncodeLibraryTags: %v", err)
}
user.LibraryTags = raw
decoded := user.DecodeLibraryTags()
if len(decoded) != 1 || decoded[0].Name != "动画" || len(decoded[0].LibraryIDs) != 1 || decoded[0].LibraryIDs[0] != "lib-1" {
t.Fatalf("DecodeLibraryTags = %#v", decoded)
}
user.LibraryTags = "{not json"
if decoded := user.DecodeLibraryTags(); decoded != nil {
t.Fatalf("corrupt payload should decode to nil, got %#v", decoded)
}
user.PopulateComputedFields()
if user.LibraryTagList != nil {
t.Fatalf("PopulateComputedFields should mirror DecodeLibraryTags, got %#v", user.LibraryTagList)
}
}
+7
View File
@@ -28,11 +28,17 @@ type User struct {
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
// LibraryTags 存储用户自定义的媒体库标签分组(JSON 字符串),
// 形如 [{"name":"动画","library_ids":["lib-1","lib-2"]}]。标签属于用户本人,
// 用于在媒体库页把同一标签下的媒体库聚合到一起。
LibraryTags string `gorm:"type:text" json:"-"`
LibraryTagList []LibraryTagSet `gorm:"-" json:"library_tags,omitempty"`
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
@@ -102,4 +108,5 @@ func (u *User) PopulateComputedFields() {
}
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
u.LibraryTagList = u.DecodeLibraryTags()
}
+15
View File
@@ -57,6 +57,21 @@ func (r *StrmAccountRepository) Update(ctx context.Context, a *model.StrmAccount
})
}
// UpdateTestResult updates only connectivity-test metadata. Callers that touch
// account credentials must not use Update with a snapshot read before Ping:
// a 115 token refresh can persist new tokens while Ping is running, and writing
// the stale snapshot back would revoke the freshly rotated credentials.
func (r *StrmAccountRepository) UpdateTestResult(ctx context.Context, id string, at time.Time, result string, ok bool) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmAccount{}).Where("id = ?", id).Updates(map[string]any{
"last_test_at": at,
"last_test_result": result,
"last_test_ok": ok,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmAccount{}).Error
+186
View File
@@ -0,0 +1,186 @@
// 115 开放平台视频播放/转码 API。
//
// 播放接口返回的是 115 云端转码后的 HLS(m3u8)地址,不是原文件
// downurl;未完成转码时接口会返回 state=false,可再调用 video_push
// 请求加速转码。
package cloud115
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
)
// FlexString 兼容 115 接口同字段在不同设备端返回字符串或数字的情况。
type FlexString string
func (s *FlexString) UnmarshalJSON(data []byte) error {
raw := bytes.TrimSpace(data)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
*s = ""
return nil
}
var str string
if err := json.Unmarshal(raw, &str); err == nil {
*s = FlexString(str)
return nil
}
var num json.Number
if err := json.Unmarshal(raw, &num); err == nil {
*s = FlexString(num.String())
return nil
}
return fmt.Errorf("115: 无法解析字符串/数字字段 %s", string(raw))
}
func (s FlexString) String() string {
return string(s)
}
func (s FlexString) Int64() int64 {
v, _ := strconv.ParseInt(strings.TrimSpace(string(s)), 10, 64)
return v
}
// MultiTrack 是 115 返回的一条音轨信息。
type MultiTrack struct {
Title string `json:"title"`
IsSelected string `json:"is_selected"`
SyncTime string `json:"sync_time"`
}
// MultiTrackList 兼容 115 接口把 multitrack_list 返回成对象(键为音轨序号)
// 或数组的两种形态。
type MultiTrackList []MultiTrack
func (l *MultiTrackList) UnmarshalJSON(data []byte) error {
raw := bytes.TrimSpace(data)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) || bytes.Equal(raw, []byte("{}")) {
*l = nil
return nil
}
if raw[0] == '[' {
var arr []MultiTrack
if err := json.Unmarshal(raw, &arr); err != nil {
return err
}
*l = arr
return nil
}
if raw[0] == '{' {
var m map[string]MultiTrack
if err := json.Unmarshal(raw, &m); err != nil {
return err
}
keys := make([]int, 0, len(m))
for key := range m {
idx, err := strconv.Atoi(strings.TrimSpace(key))
if err != nil {
continue
}
keys = append(keys, idx)
}
sort.Ints(keys)
out := make([]MultiTrack, 0, len(keys))
for _, idx := range keys {
out = append(out, m[strconv.Itoa(idx)])
}
*l = out
return nil
}
return fmt.Errorf("115: multitrack_list 既不是对象也不是数组")
}
// VideoURLItem 是 115 返回的一条清晰度播放地址。
type VideoURLItem struct {
URL string `json:"url"`
Height int `json:"height"`
Width int `json:"width"`
Definition int `json:"definition"`
Title string `json:"title"`
DefinitionN int `json:"definition_n"`
}
// VideoPlayData 是 /open/video/play 的 data 字段。
type VideoPlayData struct {
FileID string `json:"file_id"`
ParentID string `json:"parent_id"`
FileName string `json:"file_name"`
FileSize FlexString `json:"file_size"`
FileSha1 string `json:"file_sha1"`
FileType string `json:"file_type"`
IsPrivate FlexString `json:"is_private"`
PlayLong FlexString `json:"play_long"`
UserDef int `json:"user_def"`
UserRotate int `json:"user_rotate"`
UserTurn int `json:"user_turn"`
MultitrackList MultiTrackList `json:"multitrack_list"`
DefinitionList map[string]string `json:"definition_list"`
DefinitionListNew map[string]string `json:"definition_list_new"`
VideoURL []VideoURLItem `json:"video_url"`
VideoPushState *bool `json:"video_push_state"`
}
// GetVideoPlayInfo 调用 115 视频在线播放接口。
//
// 注意:state=false(例如视频尚未转码)时 doAuthJSON 会返回 error,但仍会
// 返回已解析的 RespBase/VideoPlayData。调用方需要同时检查两者:有 data 时
// 可用于判断需要等待还是触发加速转码。
func (c *OpenClient) GetVideoPlayInfo(ctx context.Context, pickCode, ua string) (*RespBase, *VideoPlayData, error) {
pickCode = strings.TrimSpace(pickCode)
if pickCode == "" {
return nil, nil, fmt.Errorf("115: pick_code 为空")
}
resp, err := c.doAuthJSONWithUA(
ctx,
http.MethodGet,
ProAPIBase+"/open/video/play",
map[string]string{"pick_code": pickCode},
1,
ua,
)
if resp == nil {
return nil, nil, err
}
data := &VideoPlayData{}
if len(resp.Data) > 0 {
trimmed := bytes.TrimSpace(resp.Data)
if !bytes.Equal(trimmed, []byte("null")) {
if decodeErr := json.Unmarshal(trimmed, data); decodeErr != nil && err == nil {
err = fmt.Errorf("115: 解析视频播放信息失败:%w", decodeErr)
}
}
}
return resp, data, err
}
// SubmitVideoPush 提交 115 加速转码请求。
//
// op 支持 vip_push(按 VIP 等级加速)和 pay_push(消耗枫币)。调用方必须
// 明确选择 op,默认空值按 vip_push 处理,避免误消费枫币。
func (c *OpenClient) SubmitVideoPush(ctx context.Context, pickCode, op string) error {
pickCode = strings.TrimSpace(pickCode)
if pickCode == "" {
return fmt.Errorf("115: pick_code 为空")
}
op = strings.TrimSpace(op)
if op == "" {
op = "vip_push"
}
_, err := c.doAuthJSON(
ctx,
http.MethodPost,
ProAPIBase+"/open/video/video_push",
map[string]string{
"pick_code": pickCode,
"op": op,
},
1,
)
return err
}
+361
View File
@@ -0,0 +1,361 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
var (
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
)
const (
cloud115HLSSessionTTL = 30 * time.Minute
cloud115HLSMaxSessions = 256
cloud115HLSMaxManifest = 8 << 20
)
type cloud115HLSSession struct {
ID string
MediaID string
Definition int
CreatedAt time.Time
ExpiresAt time.Time
mu sync.Mutex
entries map[string]string
next int
}
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
//
// 浏览器不能直接请求 115 的 m3u8:master/variant/分片的 CORS 只允许
// https://115.com,且 master 还是 HTTP。代理在服务端拉取并重写播放列表,
// 分片按 Range 流式转发。
type Cloud115HLSProxy struct {
service *Cloud115PlaybackService
client *http.Client
mu sync.Mutex
sessions map[string]*cloud115HLSSession
}
func newCloud115HLSProxy(service *Cloud115PlaybackService) *Cloud115HLSProxy {
return &Cloud115HLSProxy{
service: service,
client: &http.Client{
Timeout: 0,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 6 {
return errors.New("stopped after 6 redirects")
}
return nil
},
},
sessions: make(map[string]*cloud115HLSSession),
}
}
// ServeMaster 解析指定清晰度并返回重写后的 master.m3u8。
func (p *Cloud115HLSProxy) ServeMaster(ctx context.Context, w http.ResponseWriter, r *http.Request, mediaID string, definition int) error {
if p == nil || p.service == nil {
return ErrCloud115NotApplicable
}
upstream, _, err := p.service.ResolveCloud115URL(ctx, mediaID, definition)
if err != nil {
return err
}
session := p.newSession(mediaID, definition)
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
resp, err := p.fetchUpstream(fetchCtx, upstream, "")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("115 云端播放列表返回 HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest))
if err != nil {
return err
}
baseURL := upstream
if resp.Request != nil && resp.Request.URL != nil {
baseURL = resp.Request.URL.String()
}
rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery)
p.storeSession(session)
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
if r.Method != http.MethodHead {
_, err = io.WriteString(w, rewritten)
}
return err
}
// ServeChild 代理 variant/分片;variant 播放列表会继续重写为同源地址。
func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter, r *http.Request, sessionID, key string) error {
session, upstream, ok := p.lookup(sessionID, key)
if !ok {
return ErrCloud115HLSSessionNotFound
}
resp, err := p.fetchUpstream(ctx, upstream, r.Header.Get("Range"))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusGone {
p.deleteSession(sessionID)
return ErrCloud115HLSUpstreamExpired
}
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
isPlaylist := strings.Contains(contentType, "mpegurl") ||
strings.Contains(contentType, "application/vnd.apple.mpegurl")
if !isPlaylist {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
return readErr
}
if strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") {
isPlaylist = true
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
} else {
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
}
}
copyUpstreamHeaders(w, resp, isPlaylist)
if isPlaylist {
body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest))
if err != nil {
return err
}
baseURL := upstream
if resp.Request != nil && resp.Request.URL != nil {
baseURL = resp.Request.URL.String()
}
rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery)
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
w.WriteHeader(resp.StatusCode)
if r.Method != http.MethodHead {
_, err = io.WriteString(w, rewritten)
}
return err
}
w.WriteHeader(resp.StatusCode)
if r.Method != http.MethodHead {
_, err = io.Copy(w, resp.Body)
}
return err
}
func (p *Cloud115HLSProxy) newSession(mediaID string, definition int) *cloud115HLSSession {
now := time.Now()
return &cloud115HLSSession{
ID: cloud115.RandomString(24),
MediaID: mediaID,
Definition: definition,
CreatedAt: now,
ExpiresAt: now.Add(cloud115HLSSessionTTL),
entries: make(map[string]string),
}
}
func (p *Cloud115HLSProxy) storeSession(session *cloud115HLSSession) {
if p == nil || session == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
if len(p.sessions) >= cloud115HLSMaxSessions {
for id, existing := range p.sessions {
if now.After(existing.ExpiresAt) {
delete(p.sessions, id)
}
}
}
if len(p.sessions) >= cloud115HLSMaxSessions {
for id := range p.sessions {
delete(p.sessions, id)
break
}
}
session.ExpiresAt = now.Add(cloud115HLSSessionTTL)
p.sessions[session.ID] = session
}
func (p *Cloud115HLSProxy) lookup(sessionID, key string) (*cloud115HLSSession, string, bool) {
if p == nil {
return nil, "", false
}
p.mu.Lock()
session := p.sessions[sessionID]
if session != nil && time.Now().After(session.ExpiresAt) {
delete(p.sessions, sessionID)
session = nil
}
p.mu.Unlock()
if session == nil {
return nil, "", false
}
session.mu.Lock()
upstream, ok := session.entries[key]
session.mu.Unlock()
return session, upstream, ok
}
func (p *Cloud115HLSProxy) deleteSession(sessionID string) {
if p == nil {
return
}
p.mu.Lock()
delete(p.sessions, sessionID)
p.mu.Unlock()
}
func (p *Cloud115HLSProxy) fetchUpstream(ctx context.Context, rawURL, rangeHeader string) (*http.Response, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed == nil || !isAllowed115UpstreamHost(parsed.Hostname()) {
return nil, fmt.Errorf("115 hls upstream host not allowed: %s", rawURL)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", cloud115.DefaultUA)
if strings.TrimSpace(rangeHeader) != "" {
req.Header.Set("Range", rangeHeader)
}
return p.client.Do(req)
}
func isAllowed115UpstreamHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
for _, suffix := range []string{".115.com", ".115cdn.com", ".115cdn.net"} {
if strings.HasSuffix(host, suffix) {
return true
}
}
return host == "115.com" || host == "115cdn.com" || host == "115cdn.net"
}
func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, baseURL, rawQuery string) string {
if session == nil {
return text
}
lines := strings.SplitAfter(text, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#") {
if strings.HasPrefix(trimmed, "#EXT-X-MEDIA:") ||
strings.HasPrefix(trimmed, "#EXT-X-KEY:") ||
strings.HasPrefix(trimmed, "#EXT-X-MAP:") {
lines[i] = replaceManifestURI(line, func(uri string) string {
return p.proxyURL(session, resolveManifestURL(baseURL, uri), rawQuery)
})
}
continue
}
lines[i] = p.proxyURL(session, resolveManifestURL(baseURL, trimmed), rawQuery) + lineEnding(line)
}
return strings.Join(lines, "")
}
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
session.mu.Lock()
session.next++
key := "e" + strconv.Itoa(session.next)
session.entries[key] = upstream
mediaID := session.MediaID
session.mu.Unlock()
query := childProxyQuery(rawQuery, mediaID)
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
}
func childProxyQuery(rawQuery, mediaID string) string {
values, _ := url.ParseQuery(rawQuery)
keep := url.Values{}
for _, key := range []string{"token", "api_key", "apiKey", "ApiKey", "profile_id", "profile_pin_token"} {
if value := strings.TrimSpace(values.Get(key)); value != "" {
keep.Set(key, value)
}
}
if strings.TrimSpace(mediaID) != "" {
keep.Set("media_id", mediaID)
}
return keep.Encode()
}
func replaceManifestURI(line string, replace func(string) string) string {
const marker = `URI="`
idx := strings.Index(line, marker)
if idx < 0 {
return line
}
start := idx + len(marker)
end := strings.Index(line[start:], `"`)
if end < 0 {
return line
}
end += start
return line[:start] + replace(line[start:end]) + line[end:]
}
func resolveManifestURL(baseURL, raw string) string {
base, baseErr := url.Parse(strings.TrimSpace(baseURL))
ref, refErr := url.Parse(strings.TrimSpace(raw))
if baseErr != nil || refErr != nil || base == nil || ref == nil {
return raw
}
return base.ResolveReference(ref).String()
}
func lineEnding(line string) string {
if strings.HasSuffix(line, "\r\n") {
return "\r\n"
}
if strings.HasSuffix(line, "\n") {
return "\n"
}
return ""
}
func copyUpstreamHeaders(w http.ResponseWriter, resp *http.Response, playlist bool) {
for _, key := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
if value := resp.Header.Get(key); value != "" {
w.Header().Set(key, value)
}
}
if playlist {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
} else if w.Header().Get("Cache-Control") == "" {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
}
+151
View File
@@ -0,0 +1,151 @@
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{},
}
manifest := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1800000,RESOLUTION=1280x720\nhttps://cpats01.115.com/a.m3u8?u=1&se=2\n"
rewritten := proxy.rewriteManifest(session, manifest, "http://videoplay.115.com/m3u8/pc", "token=t&media_id=media-1")
if strings.Contains(rewritten, "cpats01.115.com") {
t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten)
}
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") {
t.Fatalf("proxy URL missing: %s", rewritten)
}
if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") {
t.Fatalf("auth/media query missing: %s", rewritten)
}
if len(session.entries) != 1 {
t.Fatalf("session entries = %d, want 1", len(session.entries))
}
}
func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{},
}
manifest := `#EXTM3U
#EXT-X-KEY:METHOD=AES-128,URI="https://cpats01.115.com/key?k=1"
#EXTINF:10.0,
https://cpats01.115.com/seg.ts?x=1
`
rewritten := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "token=t&media_id=media-1")
if strings.Contains(rewritten, "cpats01.115.com") {
t.Fatalf("upstream URL leaked into rewritten key manifest: %s", rewritten)
}
if len(session.entries) != 2 {
t.Fatalf("session entries = %d, want 2", len(session.entries))
}
}
func TestIsAllowed115UpstreamHost(t *testing.T) {
for _, host := range []string{"videoplay.115.com", "cpats01.115.com", "cdn.115cdn.net"} {
if !isAllowed115UpstreamHost(host) {
t.Fatalf("host %q should be allowed", host)
}
}
for _, host := range []string{"", "evil.example.com", "115.com.evil.example.com"} {
if isAllowed115UpstreamHost(host) {
t.Fatalf("host %q should be rejected", host)
}
}
}
func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
proxy := &Cloud115HLSProxy{
sessions: map[string]*cloud115HLSSession{},
}
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := "#EXTM3U\n#EXT-X-TARGETDURATION:10\nhttps://cpats01.115.com/seg.ts?x=1\n"
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/vnd.apple.mpegurl"},
},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
rec := httptest.NewRecorder()
if err := proxy.ServeChild(context.Background(), rec, req, session.ID, "e1"); err != nil {
t.Fatalf("ServeChild: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") {
t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "cpats01.115.com") {
t.Fatalf("upstream URL leaked in variant: %s", rec.Body.String())
}
}
func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) {
payload := []byte("segment-bytes")
proxy := &Cloud115HLSProxy{
sessions: map[string]*cloud115HLSSession{},
}
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusPartialContent,
Header: http.Header{
"Content-Type": []string{"video/mp2t"},
"Content-Range": []string{"bytes 0-12/100"},
"Content-Length": []string{strconv.Itoa(len(payload))},
},
Body: io.NopCloser(bytes.NewReader(payload)),
Request: req,
}, nil
})}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
req.Header.Set("Range", "bytes=0-12")
rec := httptest.NewRecorder()
if err := proxy.ServeChild(context.Background(), rec, req, session.ID, "e1"); err != nil {
t.Fatalf("ServeChild: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if rec.Header().Get("Content-Range") != "bytes 0-12/100" {
t.Fatalf("content-range = %q", rec.Header().Get("Content-Range"))
}
if !bytes.Equal(rec.Body.Bytes(), payload) {
t.Fatalf("payload = %q, want %q", rec.Body.Bytes(), payload)
}
}
+411
View File
@@ -0,0 +1,411 @@
package service
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
var (
ErrCloud115TranscodePending = errors.New("115 cloud transcode pending")
ErrCloud115NotApplicable = errors.New("115 cloud playback not applicable")
)
// Cloud115TranscodeState 描述当前请求清晰度的云端转码状态。
type Cloud115TranscodeState struct {
State string `json:"state"` // idle / ready / transcoding / unavailable
Definition string `json:"definition,omitempty"`
Message string `json:"message,omitempty"`
RetryAfterSec int `json:"retry_after_sec,omitempty"`
StartedAt int64 `json:"started_at,omitempty"`
}
// PlaybackInfo 是播放器加载时获取的统一播放能力描述。
type PlaybackInfo struct {
MediaID string `json:"media_id"`
Provider string `json:"provider"`
Fallback []string `json:"fallback"`
DefaultQuality string `json:"default_quality"`
CloudQualities []PlaybackQuality `json:"cloud_qualities,omitempty"`
LocalQualities []PlaybackQuality `json:"local_qualities"`
Transcode Cloud115TranscodeState `json:"transcode"`
}
type cloud115PushAttempt struct {
at time.Time
success bool
message string
}
// Cloud115PlaybackService 负责 115 云端播放、清晰度列表和云端转码触发。
// 实际的 HLS 代理在 Cloud115HLSProxy 中。
type Cloud115PlaybackService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
strm *StrmService
mu sync.Mutex
pushState map[string]cloud115PushAttempt
proxy *Cloud115HLSProxy
}
func NewCloud115PlaybackService(cfg *config.Config, log *zap.Logger, repo *repository.Container, strm *StrmService) *Cloud115PlaybackService {
svc := &Cloud115PlaybackService{
cfg: cfg,
log: log,
repo: repo,
strm: strm,
pushState: make(map[string]cloud115PushAttempt),
}
svc.proxy = newCloud115HLSProxy(svc)
return svc
}
// HLSProxy 返回 115 云 HLS 反向代理。
func (s *Cloud115PlaybackService) HLSProxy() *Cloud115HLSProxy {
if s == nil {
return nil
}
return s.proxy
}
// PlaybackInfo 返回媒体在 direct/115-cloud/local 三种模式下的能力描述。
//
// requestedDefinition 为空或 0 时默认选择 1080P;该档位不可用时返回
// transcoding 状态(并只触发一次加速转码,避免轮询风暴)。
func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID string, requestedDefinition int) (*PlaybackInfo, error) {
if s == nil || s.repo == nil || s.repo.Media == nil {
return nil, ErrMediaNotFound
}
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
return nil, ErrMediaNotFound
}
provider := MediaPlaybackProvider(m)
localQualities := LocalQualityOptions(m)
localDefault := DefaultLocalHLSQualityID(m)
info := &PlaybackInfo{
MediaID: m.ID,
Provider: provider,
DefaultQuality: localDefault,
LocalQualities: localQualities,
Transcode: Cloud115TranscodeState{State: "idle"},
}
if provider != model.StrmProvider115 {
info.Fallback = []string{"direct", "local_hls"}
return info, nil
}
account, pickCode, client, err := s.resolve115Target(ctx, m)
if err != nil {
info.Fallback = []string{"direct", "local_hls"}
info.Transcode = Cloud115TranscodeState{State: "unavailable", Message: err.Error()}
return info, nil
}
resp, data, playErr := client.GetVideoPlayInfo(ctx, pickCode, cloud115.DefaultUA)
if cloud115PlayInfoFatal(resp, playErr) {
info.Fallback = []string{"direct", "local_hls"}
info.Transcode = Cloud115TranscodeState{
State: "unavailable",
Message: cloud115PlayErrorMessage(playErr, resp),
}
return info, nil
}
cloudQualities := Cloud115QualityOptions(m, data)
info.CloudQualities = cloudQualities
info.Fallback = []string{"direct", "cloud_hls", "local_hls"}
info.DefaultQuality = DefaultCloud115Quality(cloudQualities)
requested := strings.TrimSpace(strconv.Itoa(requestedDefinition))
if requestedDefinition <= 0 || requested == "0" {
requested = info.DefaultQuality
}
selected, ok := findPlaybackQuality(cloudQualities, requested)
if !ok {
info.Transcode = Cloud115TranscodeState{
State: "unavailable",
Definition: requested,
Message: "115 不支持该清晰度",
}
return info, nil
}
if selected.Source == "original" {
info.Transcode = Cloud115TranscodeState{State: "ready", Definition: selected.ID}
return info, nil
}
if selected.Available {
info.Transcode = Cloud115TranscodeState{State: "ready", Definition: selected.ID}
return info, nil
}
info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client)
return info, nil
}
// StartTranscode 显式触发某个清晰度的云端转码,返回最新状态。
func (s *Cloud115PlaybackService) StartTranscode(ctx context.Context, mediaID string, definition int) (*PlaybackInfo, error) {
return s.PlaybackInfo(ctx, mediaID, definition)
}
// ResolveCloud115URL 返回指定清晰度的 115 云端 m3u8 地址。
// 档位尚未转码完成时返回 ErrCloud115TranscodePending。
func (s *Cloud115PlaybackService) ResolveCloud115URL(ctx context.Context, mediaID string, definition int) (string, *cloud115.VideoPlayData, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return "", nil, err
}
if m == nil {
return "", nil, ErrMediaNotFound
}
if MediaPlaybackProvider(m) != model.StrmProvider115 {
return "", nil, ErrCloud115NotApplicable
}
_, pickCode, client, err := s.resolve115Target(ctx, m)
if err != nil {
return "", nil, err
}
resp, data, playErr := client.GetVideoPlayInfo(ctx, pickCode, cloud115.DefaultUA)
if cloud115PlayInfoFatal(resp, playErr) {
return "", data, fmt.Errorf("115 云端播放不可用:%s", cloud115PlayErrorMessage(playErr, resp))
}
requested := definition
if requested <= 0 {
requested = 4
}
item := findVideoURL(data, requested)
if item == nil || strings.TrimSpace(item.URL) == "" {
return "", data, ErrCloud115TranscodePending
}
return strings.TrimSpace(item.URL), data, nil
}
func (s *Cloud115PlaybackService) ensureCloudTranscode(
ctx context.Context,
accountID, pickCode string,
quality PlaybackQuality,
client *cloud115.OpenClient,
) Cloud115TranscodeState {
key := accountID + ":" + pickCode
now := time.Now()
s.mu.Lock()
if last, ok := s.pushState[key]; ok {
ttl := 2 * time.Minute
if last.success {
ttl = 3 * time.Hour
}
if now.Sub(last.at) < ttl {
s.mu.Unlock()
return Cloud115TranscodeState{
State: "transcoding",
Definition: quality.ID,
Message: last.message,
RetryAfterSec: 5,
StartedAt: last.at.Unix(),
}
}
}
s.mu.Unlock()
err := client.SubmitVideoPush(ctx, pickCode, "vip_push")
attempt := cloud115PushAttempt{at: now, success: err == nil}
if err != nil {
attempt.message = fmt.Sprintf("115 云端转码排队中(加速请求未成功:%s)", cloud115PlayErrorMessage(err, nil))
} else {
attempt.message = fmt.Sprintf("已触发 115 云端转码,正在等待 %s…", quality.Label)
}
s.mu.Lock()
s.pushState[key] = attempt
s.mu.Unlock()
return Cloud115TranscodeState{
State: "transcoding",
Definition: quality.ID,
Message: attempt.message,
RetryAfterSec: 5,
StartedAt: now.Unix(),
}
}
func (s *Cloud115PlaybackService) resolve115Target(ctx context.Context, m *model.Media) (*model.StrmAccount, string, *cloud115.OpenClient, error) {
if s == nil || s.strm == nil || s.repo == nil || s.repo.StrmAccount == nil {
return nil, "", nil, ErrCloud115NotApplicable
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
parsed, err := readLocalSTRMTarget(m.Path)
if err != nil {
return nil, "", nil, err
}
raw = strings.TrimSpace(parsed)
}
if raw == "" {
return nil, "", nil, fmt.Errorf("缺少 STRM 播放目标")
}
u, err := url.Parse(raw)
if err != nil {
return nil, "", nil, err
}
if !strings.Contains(strings.ToLower(u.Path), "/api/strm/play/cloud115/") &&
!strings.Contains(strings.ToLower(u.Path), "/api/cloud/play/cloud115") {
return nil, "", nil, ErrCloud115NotApplicable
}
accountID := strings.TrimSpace(u.Query().Get("acct"))
if accountID == "" {
return nil, "", nil, fmt.Errorf("缺少 115 账号 ID")
}
pickCode := strings.TrimSpace(u.Query().Get("pickcode"))
if pickCode == "" {
return nil, "", nil, fmt.Errorf("缺少 115 pickcode")
}
account, err := s.repo.StrmAccount.FindByID(ctx, accountID)
if err != nil {
return nil, "", nil, err
}
if account == nil || !account.Enabled {
return nil, "", nil, fmt.Errorf("115 账号不存在或已禁用")
}
if account.Provider != model.StrmProvider115 {
return nil, "", nil, ErrCloud115NotApplicable
}
provider, err := s.strm.providerFor(ctx, account)
if err != nil {
return nil, "", nil, err
}
openProvider, ok := provider.(cloud.OpenAPI115Provider)
if !ok || openProvider.OpenClient() == nil {
return nil, "", nil, ErrCloud115NotApplicable
}
return account, pickCode, openProvider.OpenClient(), nil
}
// MediaPlaybackProvider 返回媒体行的播放提供方(用于前端降级顺序)。
func MediaPlaybackProvider(m *model.Media) string {
if m == nil {
return model.StrmProviderLocal
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
if target, err := readLocalSTRMTarget(m.Path); err == nil {
raw = strings.TrimSpace(target)
}
}
if raw != "" {
if u, err := url.Parse(raw); err == nil {
path := strings.ToLower(u.Path)
if idx := strings.Index(path, "/api/strm/play/"); idx >= 0 {
rest := strings.Trim(path[idx+len("/api/strm/play/"):], "/")
if provider, _, ok := strings.Cut(rest, "/"); ok && provider != "" {
return provider
}
}
if idx := strings.Index(path, "/api/cloud/play/"); idx >= 0 {
provider := strings.Trim(path[idx+len("/api/cloud/play/"):], "/")
if provider != "" {
return provider
}
}
}
}
if IsStrmMediaRow(m) {
return "strm"
}
return model.StrmProviderLocal
}
func findPlaybackQuality(options []PlaybackQuality, id string) (PlaybackQuality, bool) {
id = strings.TrimSpace(id)
for _, option := range options {
if option.ID == id {
return option, true
}
}
return PlaybackQuality{}, false
}
func findVideoURL(data *cloud115.VideoPlayData, definition int) *cloud115.VideoURLItem {
if data == nil || definition <= 0 {
return nil
}
for i := range data.VideoURL {
item := &data.VideoURL[i]
if item.DefinitionN == definition || item.Definition == definition {
return item
}
}
return nil
}
func cloud115PlayInfoFatal(resp *cloud115.RespBase, err error) bool {
if resp == nil {
return true
}
if bool(resp.State) {
return false
}
switch resp.Code {
case cloud115.AccessTokenAuthFail,
cloud115.AccessTokenExpiryCode,
cloud115.AccessAuthInvalid,
cloud115.AccessTokenFormatInvalid,
cloud115.RefreshTokenInvalid,
cloud115.TokenRefreshFail,
cloud115.RequestMaxLimitCode,
cloud115.RequestRateLimitCode:
return true
}
// 非鉴权/限流错误一律当作“尚未转码完成”处理,继续轮询。
_ = err
return false
}
func cloud115PlayErrorMessage(err error, resp *cloud115.RespBase) string {
if err != nil {
var apiErr *cloud115.OpenAPIError
if errors.As(err, &apiErr) {
switch apiErr.Code {
case cloud115.AccessTokenAuthFail,
cloud115.AccessTokenExpiryCode,
cloud115.AccessAuthInvalid,
cloud115.AccessTokenFormatInvalid,
cloud115.RefreshTokenInvalid:
return "115 授权已过期,请重新授权账号"
}
if strings.TrimSpace(apiErr.Message) != "" {
return apiErr.Message
}
}
return err.Error()
}
if resp != nil {
if msg := strings.TrimSpace(resp.Message); msg != "" {
return msg
}
if msg := strings.TrimSpace(resp.Error); msg != "" {
return msg
}
}
return "115 云端播放不可用"
}
+203
View File
@@ -0,0 +1,203 @@
package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) {
var playCalls int32
var pushCalls int32
var ready int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/open/video/play":
atomic.AddInt32(&playCalls, 1)
if atomic.LoadInt32(&ready) == 0 {
_ = json.NewEncoder(w).Encode(map[string]any{
"state": false,
"code": 0,
"message": "转码中",
"data": map[string]any{},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"state": true,
"code": 0,
"message": "",
"data": map[string]any{
"file_name": "test.mkv",
"definition_list_new": map[string]string{
"4": "1080P",
},
"video_url": []map[string]any{
{
"url": "http://cdn.example/master.m3u8",
"definition": 4,
"definition_n": 4,
"width": 1920,
"height": 1080,
"title": "1080P",
},
},
},
})
case "/open/video/video_push":
atomic.AddInt32(&pushCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
oldBase := cloud115.ProAPIBase
cloud115.ProAPIBase = server.URL
defer func() { cloud115.ProAPIBase = oldBase }()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.Media{})
repos := repository.New(db)
strm := NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop()))
ctx := context.Background()
account, err := strm.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at",
"refresh_token": "rt",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
media := &model.Media{
Base: model.Base{ID: "media-1"},
Title: "test",
Path: "/media/test.strm",
Container: "strm",
Height: 1080,
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + account.ID + "&pickcode=pc",
}
if err := db.Create(media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
info, err := svc.PlaybackInfo(ctx, media.ID, 0)
if err != nil {
t.Fatalf("first playback info: %v", err)
}
if info.Provider != model.StrmProvider115 || info.DefaultQuality != "4" {
t.Fatalf("unexpected playback info: %#v", info)
}
if info.Transcode.State != "transcoding" {
t.Fatalf("transcode state = %q, want transcoding", info.Transcode.State)
}
if got := atomic.LoadInt32(&pushCalls); got != 1 {
t.Fatalf("push calls = %d, want 1", got)
}
if _, err := svc.PlaybackInfo(ctx, media.ID, 0); err != nil {
t.Fatalf("second playback info: %v", err)
}
if got := atomic.LoadInt32(&pushCalls); got != 1 {
t.Fatalf("push calls after polling = %d, want 1", got)
}
atomic.StoreInt32(&ready, 1)
info, err = svc.PlaybackInfo(ctx, media.ID, 0)
if err != nil {
t.Fatalf("ready playback info: %v", err)
}
if info.Transcode.State != "ready" {
t.Fatalf("transcode state = %q, want ready", info.Transcode.State)
}
url, _, err := svc.ResolveCloud115URL(ctx, media.ID, 4)
if err != nil {
t.Fatalf("resolve cloud url: %v", err)
}
if url != "http://cdn.example/master.m3u8" {
t.Fatalf("url = %q", url)
}
if got := atomic.LoadInt32(&playCalls); got < 3 {
t.Fatalf("play calls = %d, want >= 3", got)
}
}
func TestCloud115PlaybackInfoTranscodePendingBeforeReady(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/open/video/play":
_ = json.NewEncoder(w).Encode(map[string]any{"state": false, "code": 0, "message": "转码中", "data": map[string]any{}})
case "/open/video/video_push":
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
}
}))
defer server.Close()
oldBase := cloud115.ProAPIBase
cloud115.ProAPIBase = server.URL
defer func() { cloud115.ProAPIBase = oldBase }()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.Media{})
repos := repository.New(db)
strm := NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop()))
ctx := context.Background()
account, err := strm.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at",
"refresh_token": "rt",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
media := &model.Media{
Base: model.Base{ID: "media-2"},
Title: "test",
Path: "/media/test2.strm",
Container: "strm",
Height: 1080,
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + account.ID + "&pickcode=pc2",
}
if err := db.Create(media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
if _, _, err := svc.ResolveCloud115URL(ctx, media.ID, 4); !errors.Is(err, ErrCloud115TranscodePending) {
t.Fatalf("resolve pending err = %v, want ErrCloud115TranscodePending", err)
}
}
func TestMediaPlaybackProvider(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"115", "/api/strm/play/cloud115/video.mkv?acct=1&pickcode=pc", model.StrmProvider115},
{"openlist", "/api/strm/play/openlist/video.mkv?acct=1&ref=/a.mkv", model.StrmProviderOpenList},
{"local", "/media/local.mkv", model.StrmProviderLocal},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
media := &model.Media{Path: tc.raw}
if tc.name != "local" {
media.STRMURL = tc.raw
}
got := MediaPlaybackProvider(media)
if got != tc.want {
t.Fatalf("provider = %q, want %q", got, tc.want)
}
})
}
}
+38 -5
View File
@@ -49,6 +49,12 @@ const (
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
const DanmakuDefaultSource = "https://api.dandanplay.net"
const (
minPlayerPlaybackRate = 0.5
maxPlayerPlaybackRate = 3.0
defaultPlayerPlaybackRate = 1.0
)
// danmakuOfficialBase is where identification (/api/v2/match) and the
// comment/search fallback always go, regardless of the configured source.
// A package var (not a const) so tests can point it at a local server.
@@ -63,6 +69,8 @@ type DanmakuRenderConfig struct {
FontSize string `json:"font_size"`
Area string `json:"area"`
Volume float64 `json:"volume"`
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
PlaybackRate float64 `json:"playback_rate"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
MergeSources bool `json:"merge_sources"`
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
@@ -89,6 +97,7 @@ type DanmakuSettingsPatch struct {
Area *float64 `json:"area"`
MergeSources *bool `json:"merge_sources"`
Volume *float64 `json:"volume"`
PlaybackRate *float64 `json:"playback_rate"`
}
type danmakuUserContextKey struct{}
@@ -345,10 +354,11 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
// Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
}
if s == nil || s.repo == nil || s.repo.Setting == nil {
return cfg
@@ -399,7 +409,14 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if user == nil {
return DanmakuRenderConfig{Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1}
return DanmakuRenderConfig{
Enabled: true,
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
}
}
opacity := user.DanmakuOpacity
if opacity < 0.1 || opacity > 1 {
@@ -417,6 +434,10 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if volume < 0 || volume > 1 {
volume = 1
}
playbackRate := user.PlayerPlaybackRate
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
playbackRate = defaultPlayerPlaybackRate
}
return DanmakuRenderConfig{
Enabled: user.DanmakuEnabled,
Source: strings.TrimSpace(user.DanmakuSource),
@@ -425,6 +446,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
FontSize: strconv.Itoa(fontSize),
Area: strconv.FormatFloat(area, 'f', -1, 64),
Volume: volume,
PlaybackRate: playbackRate,
MergeSources: user.DanmakuMergeSources,
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
}
@@ -495,6 +517,17 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
}
updates["player_volume"] = *patch.Volume
}
if patch.PlaybackRate != nil {
if *patch.PlaybackRate < minPlayerPlaybackRate || *patch.PlaybackRate > maxPlayerPlaybackRate {
return DanmakuRenderConfig{}, fmt.Errorf(
"%w: playback_rate must be between %.2f and %.2f",
ErrInvalidDanmakuSettings,
minPlayerPlaybackRate,
maxPlayerPlaybackRate,
)
}
updates["player_playback_rate"] = *patch.PlaybackRate
}
if len(updates) == 0 {
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
}
+2
View File
@@ -422,6 +422,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
user.DanmakuFontSize = 30
user.DanmakuArea = 0.7
user.PlayerVolume = 0.42
user.PlayerPlaybackRate = 1.25
require.NoError(t, svc.repo.User.Create(ctx, &user))
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
@@ -430,6 +431,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
require.Contains(t, res.Raw, "弹幕A")
require.Equal(t, srv.URL(), res.Source)
require.Equal(t, 0.42, res.Volume)
require.Equal(t, 1.25, res.PlaybackRate)
require.Equal(t, "0.65", res.Opacity)
require.Equal(t, "30", res.FontSize)
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
+4
View File
@@ -205,6 +205,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if containsEmbyFilter(p.Filters, "IsFavorite") {
return e.favoriteItems(ctx, p)
}
// 继续观看必须走 MeBox 本地 PlaybackHistory,不能转发到远程共用账号。
if containsEmbyFilter(p.Filters, "IsResumable") {
return e.resumableItems(ctx, p)
}
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
+17 -11
View File
@@ -38,14 +38,6 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite {
userData, _ := out["UserData"].(map[string]any)
if userData == nil {
userData = map[string]any{}
out["UserData"] = userData
}
userData["IsFavorite"] = true
}
return out, nil
}
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
@@ -218,7 +210,7 @@ func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID stri
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return embyLatestCacheValue{Items: []map[string]any{}}, nil
}
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
out, err := e.remote.RemoteLatestForDisplay(ctx, mount, acct, remoteParent, limit)
if err != nil {
return embyLatestCacheValue{}, err
}
@@ -466,12 +458,26 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
mediaIDs := make([]string, 0, len(hist))
localIDs := make([]string, 0, len(hist))
for _, h := range hist {
mediaIDs = append(mediaIDs, h.MediaID)
if !IsEmbyRemoteID(h.MediaID) {
localIDs = append(localIDs, h.MediaID)
}
}
favSet := map[string]bool{}
if len(mediaIDs) > 0 {
var favs []model.Favorite
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id IN ?", p.UserID, mediaIDs).
Find(&favs).Error; err != nil {
return nil, err
}
for _, fav := range favs {
favSet[fav.MediaID] = true
}
}
byID := map[string]*model.Media{}
if len(localIDs) > 0 {
var medias []model.Media
@@ -498,7 +504,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue
}
localTotal++
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, favSet[h.MediaID], h.PositionMs, false)})
continue
}
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
@@ -559,7 +565,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
item["UserData"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
slots[i].item = item
}
}
+174
View File
@@ -860,6 +860,180 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
return out, nil
}
// RemoteLatestForDisplay 返回可直接展示的最新媒体条目。剧集库优先取 Series;
// 远程 Latest 只返回 Episode 时,按 SeriesId 归并,避免调用方拿到单集 ID
// 后无法在 Series 卡片列表中找到对应条目。
func (r *EmbyRemoteService) RemoteLatestForDisplay(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
if remoteCollectionLooksEpisodic(mount.CollectionType) {
return r.RemoteLatestSeries(ctx, mount, acct, remoteViewID, limit)
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
if remoteItemsContainEpisodes(items) {
return remoteSeriesItemsFromLatest(mount, items, limit), nil
}
return items, nil
}
// RemoteLatestSeries 拉取剧集库最近更新的 Series。部分 Emby 服务不支持
// DateLastContentAdded 或过滤 Series,此时回退到 Latest 并把 Episode 归并到
// 对应 Series。
//
// 使用 Recursive=true 并跳过 anime/ 等中间容器,与 RemoteSeriesCards /
// Emby 客户端列剧集方式一致。
func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "true")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
// 多取一些以便滤掉中间容器后仍够 limit。
q.Set("Limit", strconv.Itoa(limit*4))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
filtered := make([]map[string]any, 0, limit)
for _, it := range body.Items {
name := remoteItemString(it, "Name")
path := remoteItemString(it, "Path")
if remoteSeriesItemLooksLikeContainer(name, path) {
continue
}
filtered = append(filtered, it)
if len(filtered) >= limit {
break
}
}
if len(filtered) > 0 {
RewriteEmbyRemoteIDs(filtered, mount.ID)
return filtered, nil
}
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
return remoteSeriesItemsFromLatest(mount, items, limit), nil
}
func remoteCollectionLooksEpisodic(collectionType string) bool {
switch strings.ToLower(strings.TrimSpace(collectionType)) {
case "tvshows", "tv":
return true
default:
return false
}
}
func remoteItemsContainEpisodes(items []map[string]any) bool {
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") {
return true
}
}
return false
}
func remoteSeriesItemsFromLatest(mount *model.EmbyMount, items []map[string]any, limit int) []map[string]any {
if limit <= 0 || limit > 100 {
limit = 20
}
out := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
if len(out) >= limit {
break
}
if !strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") {
id := strings.TrimSpace(remoteItemString(item, "Id"))
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, item)
continue
}
seriesID := remoteItemSeriesID(item, mount)
if seriesID == "" {
continue
}
if _, exists := seen[seriesID]; exists {
continue
}
seen[seriesID] = struct{}{}
out = append(out, remoteSeriesPayloadFromEpisode(mount, item, seriesID))
}
return out
}
func remoteItemSeriesID(item map[string]any, mount *model.EmbyMount) string {
seriesID := strings.TrimSpace(remoteItemString(item, "SeriesId"))
if seriesID == "" {
return ""
}
if _, rawID, ok := DecodeEmbyRemoteID(seriesID); ok {
if mount == nil {
return rawID
}
return EncodeEmbyRemoteID(mount.ID, rawID)
}
if mount == nil {
return seriesID
}
return EncodeEmbyRemoteID(mount.ID, seriesID)
}
func remoteSeriesPayloadFromEpisode(mount *model.EmbyMount, episode map[string]any, seriesID string) map[string]any {
name := strings.TrimSpace(remoteItemString(episode, "SeriesName"))
if name == "" {
name = strings.TrimSpace(remoteItemString(episode, "Name"))
}
out := map[string]any{
"Id": seriesID,
"Name": name,
"Type": "Series",
}
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
out["ParentId"] = EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
}
if tag := strings.TrimSpace(remoteItemString(episode, "SeriesPrimaryImageTag")); tag != "" {
out["ImageTags"] = map[string]any{"Primary": tag}
}
year := remoteItemInt(episode, "SeriesProductionYear")
if year == 0 {
year = remoteItemInt(episode, "SeriesYear")
}
if year == 0 {
year = remoteItemInt(episode, "ProductionYear")
}
if year > 0 {
out["ProductionYear"] = year
}
for _, key := range []string{"Genres", "ProviderIds", "DateLastMediaAdded", "CommunityRating", "CriticRating", "OfficialRating"} {
if value, ok := episode[key]; ok {
out[key] = value
}
}
return out
}
// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
+74 -80
View File
@@ -180,31 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
seriesID = rid
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
year := remoteItemInt(item, "ProductionYear")
if year == 0 {
year = remoteItemInt(item, "Year")
}
if year == 0 {
year = remoteItemInt(item, "SeriesProductionYear")
}
if year == 0 {
year = remoteItemInt(item, "SeriesYear")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: year,
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
year := remoteItemInt(item, "ProductionYear")
if year == 0 {
year = remoteItemInt(item, "Year")
}
if year == 0 {
year = remoteItemInt(item, "SeriesProductionYear")
}
if year == 0 {
year = remoteItemInt(item, "SeriesYear")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: year,
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
media.CreatedAt = date
media.UpdatedAt = date
@@ -509,19 +509,24 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
return items, total, nil
}
// remoteSeriesPageSize 是远程剧集分页拉取的每页条数:Fields 带 Overview/Path
// 等重字段,单页 1000 条时载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断
// 直接解析失败,因此按 200 条翻页拉全量。
const remoteSeriesPageSize = 200
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
//
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次
// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方
// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时
// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。
// 与 Emby 客户端一致:IncludeItemTypes=Series + Recursive=true,按
// DateLastContentAdded 倒序分页拉全库剧集。多媒体根下的 anime/ 等中间容器
// 若偶发出现在结果里则过滤掉;LastAddedAt 仅在远程提供 DateLastMediaAdded
// 时填充。
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID)
// v3:Recursive=true(对齐 Emby 客户端),与旧直属/下探缓存区分。
cacheKey := r.remoteCacheKey("series-cards-v3", acct.ID, mount.ID, remoteViewID)
var cached []SeriesCard
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
return cached, nil
@@ -529,19 +534,17 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("Recursive", "true")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
// 每页 200:Fields 带全量重字段(Overview/MediaStreams 等)时单页 1000
// 条的载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断直接解析失败。
q.Set("Limit", "200")
q.Set("Limit", strconv.Itoa(remoteSeriesPageSize))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
}
cards := make([]SeriesCard, 0)
for startIndex := 0; ; startIndex += 200 {
for startIndex := 0; ; startIndex += remoteSeriesPageSize {
q.Set("StartIndex", strconv.Itoa(startIndex))
body.Items = nil
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
@@ -551,9 +554,13 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
break
}
for _, it := range body.Items {
name := remoteItemString(it, "Name")
path := remoteItemString(it, "Path")
if remoteSeriesItemLooksLikeContainer(name, path) {
continue
}
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
@@ -565,9 +572,16 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
lastAdded = &date
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
cards = append(cards, SeriesCard{
Key: m.ID,
Rep: m,
LinkMedia: m,
Count: count,
IsSeries: true,
LastAddedAt: lastAdded,
})
}
if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < 1000 {
if int64(startIndex+len(body.Items)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize {
break
}
}
@@ -577,6 +591,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
return cards, nil
}
func remoteSeriesItemLooksLikeContainer(name, path string) bool {
if isEmbyGenericContainer(name) {
return true
}
base := pathBaseSlash(strings.TrimRight(strings.ReplaceAll(path, "\\", "/"), "/"))
return base != "" && isEmbyGenericContainer(base)
}
// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
@@ -589,52 +611,14 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
return cached, nil
}
// 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。
// 避免 Emby /Items/Latest 默认返回无年份/无系列海报的单集(Episode)。
if mount != nil && (mount.CollectionType == "tvshows" || mount.CollectionType == "tv") {
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
q.Set("Limit", strconv.Itoa(limit))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
cards := make([]SeriesCard, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
if count == 0 {
count = 1
}
var lastAdded *time.Time
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
lastAdded = &date
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
}
return cards, nil
}
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
items, err := r.RemoteLatestForDisplay(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
cards := make([]SeriesCard, 0, len(items))
for _, it := range items {
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
isSeries := strings.EqualFold(strings.TrimSpace(remoteItemString(it, "Type")), "Series")
var lastAdded *time.Time
if !m.UpdatedAt.IsZero() {
t := m.UpdatedAt
@@ -647,7 +631,17 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
if count == 0 {
count = 1
}
cards = append(cards, SeriesCard{
Key: m.ID,
Rep: m,
LinkMedia: m,
Count: count,
IsSeries: isSeries,
LastAddedAt: lastAdded,
})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
+448 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
@@ -120,10 +121,15 @@ func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) {
}
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
q := r.URL.Query()
if q.Get("ParentId") != "view-1" || q.Get("IncludeItemTypes") != "Series" {
if q.Get("IncludeItemTypes") != "Series" || q.Get("ParentId") != "view-1" {
w.WriteHeader(http.StatusBadRequest)
return
}
if q.Get("Recursive") != "true" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("expected Recursive=true"))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
@@ -230,6 +236,11 @@ func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) {
return
}
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
q := r.URL.Query()
if q.Get("ParentId") != "view-2" || q.Get("Recursive") != "true" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
@@ -465,6 +476,9 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
if cards[0].Count != 12 {
t.Fatalf("expected Count 12, got %d", cards[0].Count)
}
if !cards[0].IsSeries {
t.Fatalf("expected remote latest card to be marked as series")
}
if cards[0].Rep.PosterURL == "" {
t.Fatalf("expected PosterURL not empty")
}
@@ -476,6 +490,439 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
}
}
func TestRemoteLatestCardsTvShowsGroupsEpisodeFallbackBySeries(t *testing.T) {
var seriesQueryCalled atomic.Bool
var latestCalled atomic.Bool
var requestedSeriesType atomic.Value
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch strings.TrimPrefix(r.URL.Path, "/emby") {
case "/Users/remote-user/Items":
seriesQueryCalled.Store(true)
requestedSeriesType.Store(r.URL.Query().Get("IncludeItemTypes"))
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
case "/Users/remote-user/Items/Latest":
latestCalled.Store(true)
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "episode-2",
"Name": "第二集",
"Type": "Episode",
"SeriesId": "series-b",
"SeriesName": "剧集 B",
"SeriesProductionYear": 2025,
"SeriesPrimaryImageTag": "poster-b",
},
{
"Id": "episode-1",
"Name": "第一集",
"Type": "Episode",
"SeriesId": "series-a",
"SeriesName": "剧集 A",
"SeriesProductionYear": 2024,
},
{
"Id": "episode-1b",
"Name": "第一集下",
"Type": "Episode",
"SeriesId": "series-a",
"SeriesName": "剧集 A",
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-tv-fallback"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-tv-fallback"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "剧集库",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10)
if err != nil {
t.Fatalf("RemoteLatestCards failed: %v", err)
}
if !seriesQueryCalled.Load() || !latestCalled.Load() {
t.Fatalf("expected both Series query and Latest fallback, series=%v latest=%v", seriesQueryCalled.Load(), latestCalled.Load())
}
if got, _ := requestedSeriesType.Load().(string); got != "Series" {
t.Fatalf("series query IncludeItemTypes = %q, want Series", got)
}
if len(cards) != 2 {
t.Fatalf("expected 2 deduplicated series cards, got %d: %#v", len(cards), cards)
}
wantKeys := []string{
EncodeEmbyRemoteID(mount.ID, "series-b"),
EncodeEmbyRemoteID(mount.ID, "series-a"),
}
for i, want := range wantKeys {
if cards[i].Key != want {
t.Fatalf("card[%d].Key = %q, want %q", i, cards[i].Key, want)
}
if !cards[i].IsSeries {
t.Fatalf("card[%d] should be marked as series", i)
}
}
}
func TestEmbyLatestItemsRemoteTvShowsUseSeriesIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch strings.TrimPrefix(r.URL.Path, "/emby") {
case "/Users/remote-user/Items":
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
case "/Users/remote-user/Items/Latest":
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "episode-10",
"Name": "第十集",
"Type": "Episode",
"SeriesId": "series-10",
"SeriesName": "远程剧集",
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
db := newServiceTestDB(t,
&model.Setting{},
&model.User{},
&model.Favorite{},
&model.PlaybackHistory{},
&model.StrmAccount{},
&model.EmbyMount{},
)
repos := repository.New(db)
remoteSvc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
svc := NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetEmbyRemote(remoteSvc)
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-emby-latest"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-emby-latest"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "剧集库",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
items, err := svc.LatestItems(t.Context(), "user-1", EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID), 10)
if err != nil {
t.Fatalf("LatestItems failed: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 series item, got %d: %#v", len(items), items)
}
if items[0]["Type"] != "Series" {
t.Fatalf("latest item Type = %#v, want Series", items[0]["Type"])
}
wantID := EncodeEmbyRemoteID(mount.ID, "series-10")
if items[0]["Id"] != wantID {
t.Fatalf("latest item Id = %#v, want %q", items[0]["Id"], wantID)
}
}
// 远程剧集库分页拉全量:超过一页时必须继续翻页,否则第 201 条之后的剧集
// 既不会出现在媒体库列表里,首页深链过来的 ?series= 也无从命中。
func TestRemoteSeriesCardsFetchAllPages(t *testing.T) {
const totalSeries = remoteSeriesPageSize*2 + 50
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
query := r.URL.Query()
if query.Get("Recursive") != "true" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("expected Recursive=true"))
return
}
startIndex, _ := strconv.Atoi(query.Get("StartIndex"))
limit, _ := strconv.Atoi(query.Get("Limit"))
requests.Add(1)
items := make([]map[string]any, 0, limit)
for i := startIndex; i < totalSeries && len(items) < limit; i++ {
items = append(items, map[string]any{
"Id": "series-" + strconv.Itoa(i),
"Name": "剧集 " + strconv.Itoa(i),
"Type": "Series",
"RecursiveItemCount": 12,
})
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": items,
"TotalRecordCount": totalSeries,
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-series-pages"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-series-pages"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "电视剧",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if len(cards) != totalSeries {
t.Fatalf("expected all %d series, got %d", totalSeries, len(cards))
}
if got := requests.Load(); got != 3 {
t.Fatalf("expected 3 paged requests, got %d", got)
}
lastKey := EncodeEmbyRemoteID(mount.ID, "series-"+strconv.Itoa(totalSeries-1))
if cards[len(cards)-1].Key != lastKey {
t.Fatalf("last card key = %q, want %q", cards[len(cards)-1].Key, lastKey)
}
}
// 与 Emby 客户端一致:Recursive=true 拉全库 Series,并过滤 anime 等中间容器。
func TestRemoteSeriesCardsRecursiveFiltersAnimeContainers(t *testing.T) {
var sawRecursive atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if q.Get("ParentId") != "view-2023" || q.Get("IncludeItemTypes") != "Series" {
http.NotFound(w, r)
return
}
if q.Get("Recursive") == "true" {
sawRecursive.Store(true)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 5,
"Items": []map[string]any{
{"Id": "c0", "Name": "anime", "Type": "Series", "Path": "https://cdn/0/anime/", "RecursiveItemCount": 3086},
{"Id": "c1", "Name": "anime", "Type": "Series", "Path": "https://cdn/1/anime/", "RecursiveItemCount": 567},
{"Id": "s-a", "Name": "数码宝贝 BEATBREAK", "Type": "Series", "Path": "https://cdn/0/anime/digimon", "RecursiveItemCount": 24},
{"Id": "s-b", "Name": "活死喵之夜", "Type": "Series", "Path": "https://cdn/0/anime/nyaight", "RecursiveItemCount": 12},
{"Id": "s-c", "Name": "药屋少女的呢喃", "Type": "Series", "Path": "https://cdn/1/anime/kusuriya", "RecursiveItemCount": 51},
},
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-recursive"},
Name: "nijigem",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-recursive"},
AccountID: acct.ID,
RemoteViewID: "view-2023",
RemoteViewName: "2023前 动漫",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if !sawRecursive.Load() {
t.Fatal("expected Recursive=true on remote Items request")
}
if len(cards) != 3 {
t.Fatalf("cards = %d, want 3 real series after filtering anime containers", len(cards))
}
for _, card := range cards {
if strings.EqualFold(card.Rep.Title, "anime") {
t.Fatalf("container title %q should have been filtered", card.Rep.Title)
}
}
}
// 首页「最新条目」卡片 key 必须与媒体库剧集列表的 key 一致,否则点击后
// 媒体库页找不到目标剧集,只能退回整库列表。
func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) {
var seriesQueryCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
http.NotFound(w, r)
return
}
seriesQueryCalls.Add(1)
parentID := r.URL.Query().Get("ParentId")
if parentID == "series-latest" {
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{
"Id": "series-latest",
"Name": "刚刚更新",
"Type": "Series",
"RecursiveItemCount": 24,
},
},
"TotalRecordCount": 1,
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-latest-key"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-latest-key"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "电视剧",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
latestCards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10)
if err != nil {
t.Fatalf("RemoteLatestCards failed: %v", err)
}
if len(latestCards) != 1 || !latestCards[0].IsSeries {
t.Fatalf("expected 1 series latest card, got %#v", latestCards)
}
seriesCards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if seriesQueryCalls.Load() < 2 {
t.Fatalf("expected both latest and series list queries, got %d", seriesQueryCalls.Load())
}
found := false
for _, card := range seriesCards {
if card.Key == latestCards[0].Key {
found = true
break
}
}
if !found {
t.Fatalf("latest card key %q not present in series list keys %#v", latestCards[0].Key, seriesCards)
}
}
func TestRemoteLatestFields(t *testing.T) {
var requestedFields string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+76 -42
View File
@@ -11,8 +11,8 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入
// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。
// SetFavorite 把 mediaID 标为 userID 的收藏。只写入 MeBox 本地 favourites 表,
// 按 user_id 隔离;挂载远程 Emby 共用账号,不能再透传收藏以免串用户。
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
return err
@@ -22,15 +22,8 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f
}
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
// 远程 Emby 条目直接透传到对应服务器(本地不落库)。
// 远程挂载条目同样只落本地 PlaybackHistory,按 MeBox 用户隔离。
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
if e.remote != nil && IsEmbyRemoteID(mediaID) {
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
if err := e.ProxyRemoteSetPlayed(ctx, acctID, remoteID, played); err != nil {
return err
}
return nil
}
if !played {
err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
@@ -40,15 +33,20 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
}
return err
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
dur := int64(0)
if IsEmbyRemoteID(mediaID) {
dur = remoteItemDurationMs(ctx, e, mediaID)
} else {
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
}
dur = int64(m.DurationSec) * 1000
}
dur := int64(m.DurationSec) * 1000
if dur <= 0 {
dur = 1
}
err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: dur,
@@ -62,6 +60,36 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
return err
}
func remoteItemDurationMs(ctx context.Context, e *EmbyService, mediaID string) int64 {
if e == nil || e.remote == nil || !IsEmbyRemoteID(mediaID) {
return 0
}
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
return 0
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || item == nil {
return 0
}
switch ticks := item["RunTimeTicks"].(type) {
case float64:
if ticks > 0 {
return int64(ticks) / 10_000
}
case int64:
if ticks > 0 {
return ticks / 10_000
}
case int:
if ticks > 0 {
return int64(ticks) / 10_000
}
}
return 0
}
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
// 不携带 PlaySessionId 的旧调用仍保持兼容。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
@@ -138,8 +166,9 @@ func embyPlaySessionStartedAtMs(playSessionID string) int64 {
return value
}
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
// and favourite state to remote Emby payloads.
// mergeRemoteUserData overlays the current MeBox user's locally recorded
// playback and favourite state onto remote Emby payloads. Remote mounts share
// one upstream Emby account, so upstream UserData must never leak across MeBox users.
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
if strings.TrimSpace(userID) == "" || payload == nil {
return nil
@@ -178,18 +207,7 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
}
for _, item := range items {
id, _ := item["Id"].(string)
userData, _ := item["UserData"].(map[string]any)
if h := byMediaID[id]; h != nil {
item["UserData"] = mergedRemoteUserData(userData, h)
userData, _ = item["UserData"].(map[string]any)
}
if favSet[id] {
if userData == nil {
userData = map[string]any{}
item["UserData"] = userData
}
userData["IsFavorite"] = true
}
item["UserData"] = applyMeBoxUserData(item["UserData"], byMediaID[id], favSet[id])
}
return nil
}
@@ -221,12 +239,37 @@ func remoteItemMaps(payload any) []map[string]any {
}
func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]any {
favorite := false
if existing, ok := raw.(map[string]any); ok {
if v, ok := existing["IsFavorite"].(bool); ok {
favorite = v
}
}
return applyMeBoxUserData(raw, history, favorite)
}
// applyMeBoxUserData rebuilds UserData for a remote item using only MeBox-local
// per-user state. Shared upstream Emby favourite/progress fields are discarded.
func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool) map[string]any {
userData := map[string]any{}
if existing, ok := raw.(map[string]any); ok {
for key, value := range existing {
userData[key] = value
switch key {
case "IsFavorite", "PlaybackPositionTicks", "Played", "PlayedPercentage", "PlayCount", "LastPlayedDate":
continue
default:
userData[key] = value
}
}
}
userData["IsFavorite"] = favorite
if history == nil {
userData["PlaybackPositionTicks"] = int64(0)
userData["Played"] = false
userData["PlayedPercentage"] = float64(0)
userData["PlayCount"] = 0
return userData
}
duration := history.DurationMs
position := history.PositionMs
percentage := float64(0)
@@ -237,18 +280,9 @@ func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]an
userData["Played"] = history.Completed
userData["PlayedPercentage"] = percentage
if history.Completed {
playCount := 0
switch value := userData["PlayCount"].(type) {
case int:
playCount = value
case int64:
playCount = int(value)
case float64:
playCount = int(value)
}
if playCount < 1 {
userData["PlayCount"] = 1
}
userData["PlayCount"] = 1
} else {
userData["PlayCount"] = 0
}
return userData
}
+97 -4
View File
@@ -18,16 +18,18 @@ func TestMergedRemoteUserData(t *testing.T) {
preserve any
}{
{
name: "in-progress preserves remote fields",
name: "in-progress preserves non-user remote fields only",
raw: map[string]any{
"PlayCount": 2,
"Custom": "remote-value",
"PlayCount": 2,
"IsFavorite": true,
"PlaybackPositionTicks": int64(999),
"Custom": "remote-value",
},
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
position: 250_000_000,
played: false,
percent: 25,
count: 2,
count: 0,
preserve: "remote-value",
},
{
@@ -59,10 +61,47 @@ func TestMergedRemoteUserData(t *testing.T) {
if tt.preserve != nil && out["Custom"] != tt.preserve {
t.Fatalf("Custom = %#v, want %#v", out["Custom"], tt.preserve)
}
if out["IsFavorite"] != false && out["IsFavorite"] != true {
t.Fatalf("IsFavorite missing: %#v", out)
}
})
}
}
func TestApplyMeBoxUserDataClearsSharedRemoteState(t *testing.T) {
out := applyMeBoxUserData(map[string]any{
"IsFavorite": true,
"PlaybackPositionTicks": int64(42_000_000),
"Played": true,
"PlayedPercentage": 80.0,
"PlayCount": 3,
"Key": "keep",
}, nil, false)
if out["IsFavorite"] != false {
t.Fatalf("IsFavorite = %#v, want false", out["IsFavorite"])
}
if out["PlaybackPositionTicks"] != int64(0) {
t.Fatalf("PlaybackPositionTicks = %#v, want 0", out["PlaybackPositionTicks"])
}
if out["Played"] != false {
t.Fatalf("Played = %#v, want false", out["Played"])
}
if out["PlayedPercentage"] != float64(0) {
t.Fatalf("PlayedPercentage = %#v, want 0", out["PlayedPercentage"])
}
if out["PlayCount"] != 0 {
t.Fatalf("PlayCount = %#v, want 0", out["PlayCount"])
}
if out["Key"] != "keep" {
t.Fatalf("Key = %#v, want keep", out["Key"])
}
fav := applyMeBoxUserData(map[string]any{"IsFavorite": false}, nil, true)
if fav["IsFavorite"] != true {
t.Fatalf("favorite overlay IsFavorite = %#v, want true", fav["IsFavorite"])
}
}
func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
payload := map[string]any{
@@ -108,3 +147,57 @@ func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
t.Fatalf("expected 95%% progress to be completed")
}
}
func TestMarkPlayedStoresRemoteItemLocallyPerUser(t *testing.T) {
svc := newTestEmbyService(t)
remoteID := EncodeEmbyRemoteID("mount-test", "item-played")
alice := &model.User{Username: "alice_played", Role: "user", Tier: "free", IsActive: true}
bob := &model.User{Username: "bob_played", Role: "user", Tier: "free", IsActive: true}
if err := svc.repo.User.Create(t.Context(), alice); err != nil {
t.Fatalf("create alice: %v", err)
}
if err := svc.repo.User.Create(t.Context(), bob); err != nil {
t.Fatalf("create bob: %v", err)
}
if err := svc.MarkPlayed(t.Context(), alice.ID, remoteID, true); err != nil {
t.Fatalf("mark played: %v", err)
}
var aliceRows, bobRows int64
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", alice.ID, remoteID).Count(&aliceRows)
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", bob.ID, remoteID).Count(&bobRows)
if aliceRows != 1 {
t.Fatalf("alice history rows = %d, want 1", aliceRows)
}
if bobRows != 0 {
t.Fatalf("bob should not see alice remote played state, rows=%d", bobRows)
}
payload := map[string]any{
"Id": remoteID,
"UserData": map[string]any{
"IsFavorite": true,
"PlaybackPositionTicks": int64(50_000_000),
"Played": true,
},
}
if err := svc.mergeRemoteUserData(t.Context(), bob.ID, payload); err != nil {
t.Fatalf("merge for bob: %v", err)
}
bobData := payload["UserData"].(map[string]any)
if bobData["IsFavorite"] != false {
t.Fatalf("bob IsFavorite leaked: %#v", bobData)
}
if bobData["Played"] != false || bobData["PlaybackPositionTicks"] != int64(0) {
t.Fatalf("bob playback leaked: %#v", bobData)
}
if err := svc.mergeRemoteUserData(t.Context(), alice.ID, payload); err != nil {
t.Fatalf("merge for alice: %v", err)
}
aliceData := payload["UserData"].(map[string]any)
if aliceData["Played"] != true {
t.Fatalf("alice Played = %#v, want true", aliceData["Played"])
}
}
+5 -27
View File
@@ -10,21 +10,14 @@ import (
"github.com/truewhile/MeBox/internal/repository"
)
// SyncUserFavorite keeps favourite state aligned across the local favourites table
// and the upstream remote Emby server for mounted items.
func SyncUserFavorite(ctx context.Context, repo *repository.Container, remote *EmbyRemoteService, userID, mediaID string, favorite bool) error {
// SyncUserFavorite keeps favourite state in the local favourites table, keyed
// by MeBox user_id. Remote Emby mounts share one upstream account, so favourite
// state is intentionally not proxied upstream.
func SyncUserFavorite(ctx context.Context, repo *repository.Container, _ *EmbyRemoteService, userID, mediaID string, favorite bool) error {
if repo == nil || userID == "" || mediaID == "" {
return errors.New("missing favourite sync inputs")
}
if err := setLocalFavorite(ctx, repo, userID, mediaID, favorite); err != nil {
return err
}
if favorite || IsEmbyRemoteID(mediaID) {
if err := proxyRemoteFavorite(ctx, remote, mediaID, favorite); err != nil {
return err
}
}
return nil
return setLocalFavorite(ctx, repo, userID, mediaID, favorite)
}
// IsUserFavorite reports whether the user has favourited mediaID locally.
@@ -57,18 +50,3 @@ func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, m
Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.Favorite{}).Error
}
func proxyRemoteFavorite(ctx context.Context, remote *EmbyRemoteService, mediaID string, favorite bool) error {
if remote == nil || !IsEmbyRemoteID(mediaID) {
return nil
}
mountID, remoteItemID, ok := DecodeEmbyRemoteID(mediaID)
if !ok {
return nil
}
_, acct, err := remote.ResolveMount(ctx, mountID)
if err != nil {
return err
}
return remote.ProxySetFavorite(ctx, acct, remoteItemID, favorite)
}
+3
View File
@@ -33,6 +33,7 @@ type SeriesCard struct {
Rep model.Media `json:"rep"`
LinkMedia model.Media `json:"linkMedia"`
Count int `json:"count"`
IsSeries bool `json:"is_series,omitempty"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
@@ -45,6 +46,7 @@ type SeriesCardView struct {
Rep model.Media `json:"rep"`
LinkLibraryID string `json:"linkLibraryId,omitempty"`
Count int `json:"count"`
IsSeries bool `json:"is_series,omitempty"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
@@ -59,6 +61,7 @@ func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView {
Rep: card.Rep,
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
Count: card.Count,
IsSeries: card.IsSeries,
LastAddedAt: card.LastAddedAt,
}
}
+4 -2
View File
@@ -70,7 +70,9 @@ func SortSeriesCards(cards []SeriesCard, field, order string, history map[string
}
sort.SliceStable(out, func(i, j int) bool {
cmp := compareSeriesByField(out[i], out[j], spec, history)
if cmp == 0 {
// updated_at 表示最后一集添加日期。远程卡片可能没有该字段,此时
// 保持调用方给定的顺序,不要用 ID 再次打乱。
if cmp == 0 && spec.Field != "updated_at" {
cmp = compareID(out[i].Key, out[j].Key)
}
return cmp < 0
@@ -173,7 +175,7 @@ func seriesUpdatedTime(card SeriesCard) (time.Time, bool) {
if card.LastAddedAt != nil && !card.LastAddedAt.IsZero() {
return *card.LastAddedAt, true
}
return mediaUpdatedTime(card.Rep)
return time.Time{}, false
}
func seriesLastPlayed(card SeriesCard, history map[string]time.Time) (time.Time, bool) {
+43
View File
@@ -2,6 +2,7 @@ package service
import (
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
)
@@ -40,3 +41,45 @@ func TestSortSeriesCardsTitleAsc(t *testing.T) {
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
}
}
func TestSortSeriesCardsUpdatedAtDoesNotFallbackToMediaDate(t *testing.T) {
olderMediaDate := time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)
newerMediaDate := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
cards := []SeriesCard{
{
Key: "remote-first",
Rep: model.Media{Base: model.Base{
ID: "remote-first",
CreatedAt: olderMediaDate,
UpdatedAt: olderMediaDate,
}},
},
{
Key: "remote-second",
Rep: model.Media{Base: model.Base{
ID: "remote-second",
CreatedAt: newerMediaDate,
UpdatedAt: newerMediaDate,
}},
},
}
got := SortSeriesCards(cards, "updated_at", "desc", nil)
if got[0].Key != "remote-first" || got[1].Key != "remote-second" {
t.Fatalf("series order = %q, %q, want remote-provided order", got[0].Key, got[1].Key)
}
}
func TestSortSeriesCardsUpdatedAtSortsKnownLastEpisodeDates(t *testing.T) {
older := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
newer := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
cards := []SeriesCard{
{Key: "older", LastAddedAt: &older},
{Key: "newer", LastAddedAt: &newer},
}
got := SortSeriesCards(cards, "updated_at", "desc", nil)
if got[0].Key != "newer" || got[1].Key != "older" {
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
}
}
+277
View File
@@ -0,0 +1,277 @@
package service
import (
"regexp"
"sort"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// PlaybackQuality 是返回给播放器的统一画质选项。
//
// Source:
// - cloud: 115 云端转码清晰度
// - original: 115 原文件(直连,可能仍是 MKV/HEVC/PGS)
// - local: MeBox 本地 HLS 重新编码清晰度
type PlaybackQuality struct {
ID string `json:"id"`
Label string `json:"label"`
Height int `json:"height,omitempty"`
Source string `json:"source"`
Available bool `json:"available"`
RequiresTranscode bool `json:"requires_transcode,omitempty"`
RequiresVIP bool `json:"requires_vip,omitempty"`
Note string `json:"note,omitempty"`
}
type cloud115QualitySpec struct {
ID string
Label string
Height int
VIP bool
}
var cloud115QualityLadder = []cloud115QualitySpec{
{ID: "1", Label: "标清", Height: 480},
{ID: "2", Label: "高清", Height: 540},
{ID: "3", Label: "超清", Height: 720},
{ID: "4", Label: "1080P", Height: 1080},
{ID: "5", Label: "4K", Height: 2160, VIP: true},
}
var resolutionTokenRE = regexp.MustCompile(`(?i)(\d{3,4})p`)
func mediaSourceHeight(m *model.Media) int {
if m == nil {
return 0
}
if m.Height > 0 {
return m.Height
}
for _, text := range []string{m.OriginalName, m.Path, m.STRMURL} {
if h := parseHeightToken(text); h > 0 {
return h
}
}
return 0
}
func parseHeightToken(text string) int {
text = strings.TrimSpace(text)
if text == "" {
return 0
}
upper := strings.ToUpper(text)
if strings.Contains(upper, "4K") || strings.Contains(upper, "2160P") {
return 2160
}
for _, match := range resolutionTokenRE.FindAllStringSubmatch(text, -1) {
if len(match) < 2 {
continue
}
height, err := strconv.Atoi(match[1])
if err != nil || height < 240 || height > 4320 {
continue
}
return height
}
return 0
}
func cloud115AvailableDefinitions(data *cloud115.VideoPlayData) map[string]bool {
available := map[string]bool{}
if data == nil {
return available
}
for key := range data.DefinitionListNew {
key = strings.TrimSpace(key)
if key != "" {
available[key] = true
}
}
for key := range data.DefinitionList {
key = strings.TrimSpace(key)
if key != "" {
available[key] = true
}
}
for _, item := range data.VideoURL {
id := item.DefinitionN
if id <= 0 {
id = item.Definition
}
if id > 0 {
available[strconv.Itoa(id)] = true
}
}
return available
}
func cloud115SourceHeight(m *model.Media, data *cloud115.VideoPlayData) int {
if height := mediaSourceHeight(m); height > 0 {
return height
}
maxHeight := 0
if data != nil {
for _, item := range data.VideoURL {
if item.Height > maxHeight {
maxHeight = item.Height
}
}
}
if maxHeight > 0 {
return maxHeight
}
return 1080
}
// Cloud115QualityOptions 返回 115 已转码和“按源分辨率可转码”的清晰度。
// 未出现在 definition_list_new/video_url 中的档位会标记为需要云端转码。
func Cloud115QualityOptions(m *model.Media, data *cloud115.VideoPlayData) []PlaybackQuality {
sourceHeight := cloud115SourceHeight(m, data)
available := cloud115AvailableDefinitions(data)
out := make([]PlaybackQuality, 0, len(cloud115QualityLadder)+1)
out = append(out, PlaybackQuality{
ID: "100",
Label: "原画",
Height: sourceHeight,
Source: "original",
Available: true,
Note: "播放原始文件,可能仍是 MKV/HEVC/PGS",
})
for _, spec := range cloud115QualityLadder {
if sourceHeight > 0 && spec.Height > sourceHeight && !available[spec.ID] {
continue
}
label := spec.Label
if data != nil && strings.TrimSpace(data.DefinitionListNew[spec.ID]) != "" {
label = strings.TrimSpace(data.DefinitionListNew[spec.ID])
}
isAvailable := available[spec.ID]
item := PlaybackQuality{
ID: spec.ID,
Label: label,
Height: spec.Height,
Source: "cloud",
Available: isAvailable,
RequiresTranscode: !isAvailable,
RequiresVIP: spec.VIP,
}
if !isAvailable {
item.Note = "需要 115 云端转码"
}
if spec.VIP {
if item.Note != "" {
item.Note += ","
}
item.Note += "需要年费 VIP"
}
out = append(out, item)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Source != out[j].Source {
return out[i].Source == "original"
}
if out[i].Height != out[j].Height {
return out[i].Height > out[j].Height
}
return out[i].ID < out[j].ID
})
return out
}
// DefaultCloud115Quality 默认优先 1080P;没有 1080P 时退回最高的云端档位。
func DefaultCloud115Quality(options []PlaybackQuality) string {
for _, option := range options {
if option.ID == "4" && option.Source == "cloud" {
return option.ID
}
}
best := ""
bestHeight := -1
for _, option := range options {
if option.Source != "cloud" {
continue
}
if option.Height > bestHeight {
best = option.ID
bestHeight = option.Height
}
}
if best != "" {
return best
}
return "4"
}
// LocalQualityOptions 返回 MeBox 本地 HLS 可用的画质档位。
func LocalQualityOptions(m *model.Media) []PlaybackQuality {
sourceHeight := mediaSourceHeight(m)
presets := []PlaybackQuality{
{ID: "source", Label: "原画", Height: sourceHeight, Source: "local", Available: true, Note: "本地 HLS,保持源分辨率"},
{ID: "1080", Label: "1080P", Height: 1080, Source: "local", Available: true},
{ID: "720", Label: "720P", Height: 720, Source: "local", Available: true},
{ID: "480", Label: "480P", Height: 480, Source: "local", Available: true},
}
out := make([]PlaybackQuality, 0, len(presets))
for _, preset := range presets {
if preset.ID != "source" && sourceHeight > 0 && preset.Height > sourceHeight {
continue
}
out = append(out, preset)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].ID == "source" {
return true
}
if out[j].ID == "source" {
return false
}
return out[i].Height > out[j].Height
})
return out
}
// LocalHLSQuality 是传给 ffmpeg 的本地 HLS 画质参数。
type LocalHLSQuality struct {
ID string
Label string
Height int
VideoBitrate string
MaxRate string
BufSize string
}
var localHLSQualities = map[string]LocalHLSQuality{
"source": {ID: "source", Label: "原画", Height: 0, VideoBitrate: "8000k", MaxRate: "10000k", BufSize: "12000k"},
"1080": {ID: "1080", Label: "1080P", Height: 1080, VideoBitrate: "4000k", MaxRate: "4500k", BufSize: "6000k"},
"720": {ID: "720", Label: "720P", Height: 720, VideoBitrate: "2500k", MaxRate: "2800k", BufSize: "4000k"},
"480": {ID: "480", Label: "480P", Height: 480, VideoBitrate: "1200k", MaxRate: "1400k", BufSize: "2000k"},
}
func LocalHLSQualityByID(id string) (LocalHLSQuality, bool) {
quality, ok := localHLSQualities[strings.TrimSpace(id)]
return quality, ok
}
func DefaultLocalHLSQualityID(m *model.Media) string {
sourceHeight := mediaSourceHeight(m)
switch {
case sourceHeight >= 1080:
return "1080"
case sourceHeight >= 720:
return "720"
case sourceHeight >= 480:
return "480"
case sourceHeight > 0:
return "source"
default:
return "720"
}
}
+109
View File
@@ -0,0 +1,109 @@
package service
import (
"strings"
"testing"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
func TestCloud115QualityOptionsDefault1080AndTranscode(t *testing.T) {
media := &model.Media{Height: 1080}
data := &cloud115.VideoPlayData{
DefinitionListNew: map[string]string{"3": "超清"},
VideoURL: []cloud115.VideoURLItem{
{Definition: 3, DefinitionN: 3, Height: 720, Width: 1280, Title: "超清"},
},
}
options := Cloud115QualityOptions(media, data)
if got := DefaultCloud115Quality(options); got != "4" {
t.Fatalf("default quality = %q, want 4", got)
}
quality4, ok := findPlaybackQuality(options, "4")
if !ok {
t.Fatal("1080P option missing")
}
if quality4.Available || !quality4.RequiresTranscode {
t.Fatalf("1080P should require transcode: %#v", quality4)
}
quality3, ok := findPlaybackQuality(options, "3")
if !ok || !quality3.Available || quality3.RequiresTranscode {
t.Fatalf("超清 should be available: %#v", quality3)
}
if quality4K, ok := findPlaybackQuality(options, "5"); ok {
t.Fatalf("1080P source should not list 4K: %#v", quality4K)
}
}
func TestCloud115QualityOptionsListsAvailableOnlyOnce(t *testing.T) {
media := &model.Media{Height: 2160}
data := &cloud115.VideoPlayData{
DefinitionListNew: map[string]string{"4": "1080P", "5": "4K"},
VideoURL: []cloud115.VideoURLItem{
{Definition: 4, DefinitionN: 4, Height: 1080},
{Definition: 5, DefinitionN: 5, Height: 2160},
},
}
options := Cloud115QualityOptions(media, data)
for _, id := range []string{"4", "5"} {
quality, ok := findPlaybackQuality(options, id)
if !ok || !quality.Available {
t.Fatalf("quality %s should be available: %#v", id, quality)
}
}
quality5, _ := findPlaybackQuality(options, "5")
if !quality5.RequiresVIP {
t.Fatalf("4K should be marked VIP: %#v", quality5)
}
}
func TestLocalQualityOptionsFollowSourceHeight(t *testing.T) {
media := &model.Media{Height: 720}
options := LocalQualityOptions(media)
if _, ok := findPlaybackQuality(options, "1080"); ok {
t.Fatal("720p source should not list 1080P")
}
for _, id := range []string{"source", "720", "480"} {
if _, ok := findPlaybackQuality(options, id); !ok {
t.Fatalf("local quality %s missing", id)
}
}
if got := DefaultLocalHLSQualityID(media); got != "720" {
t.Fatalf("default local quality = %q, want 720", got)
}
}
func TestLocalHLSQualityFFmpegArgs(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
cfg.Transcoder.Realtime = false
cfg.Transcoder.HardwareAccel = false
quality480, _ := LocalHLSQualityByID("480")
args := buildFFmpegArgsForInput(cfg, transcodeInput{Source: "/x.mkv", Quality: &quality480}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined := strings.Join(args, " ")
if !strings.Contains(joined, "scale=-2:min(480\\,ih)") {
t.Fatalf("480P scale filter missing: %s", joined)
}
if !strings.Contains(joined, "-b:v 1200k") {
t.Fatalf("480P bitrate missing: %s", joined)
}
cfg.Transcoder.HardwareAccel = true
cfg.Transcoder.Encoder = "nvenc"
qualitySource, _ := LocalHLSQualityByID("source")
args = buildFFmpegArgsForInput(cfg, transcodeInput{Source: "/x.mkv", Quality: &qualitySource}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined = strings.Join(args, " ")
if strings.Contains(joined, "h264_nvenc") {
t.Fatalf("source quality should not use hardware scale path: %s", joined)
}
if strings.Contains(joined, "scale=-2:min(") {
t.Fatalf("source quality should not downscale: %s", joined)
}
if !strings.Contains(joined, "libx264") {
t.Fatalf("source quality should use software x264: %s", joined)
}
}
+79
View File
@@ -144,6 +144,85 @@ func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string,
return normalized, nil
}
// GetLibraryTags returns the user's library tag groups, filtered to libraries
// the user can still access. Empty tags are kept so an editor does not lose a
// tag that was just created.
func (p *ProfileService) GetLibraryTags(ctx context.Context, userID string) ([]model.LibraryTagSet, error) {
user, err := p.repo.User.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("user not found")
}
tags := model.NormalizeLibraryTags(user.DecodeLibraryTags())
if len(tags) == 0 {
return []model.LibraryTagSet{}, nil
}
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
if err != nil {
return nil, err
}
for i := range tags {
tags[i].LibraryIDs = filterPinnedLibraryIDs(tags[i].LibraryIDs, accessible)
if tags[i].LibraryIDs == nil {
tags[i].LibraryIDs = []string{}
}
}
return tags, nil
}
// SetLibraryTags persists the user's library tag groups after dropping
// inaccessible libraries. A library belongs to at most one tag: the first tag
// that lists it wins, so the saved state always matches the tab UI.
func (p *ProfileService) SetLibraryTags(ctx context.Context, userID string, tags []model.LibraryTagSet) ([]model.LibraryTagSet, error) {
if userID == "" {
return nil, errors.New("missing user id")
}
user, err := p.repo.User.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("user not found")
}
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
if err != nil {
return nil, err
}
normalized := model.NormalizeLibraryTags(tags)
if len(normalized) > model.MaxLibraryTags {
normalized = normalized[:model.MaxLibraryTags]
}
claimed := make(map[string]struct{})
for i := range normalized {
filtered := make([]string, 0, len(normalized[i].LibraryIDs))
for _, id := range filterPinnedLibraryIDs(normalized[i].LibraryIDs, accessible) {
if _, taken := claimed[id]; taken {
continue
}
claimed[id] = struct{}{}
filtered = append(filtered, id)
}
normalized[i].LibraryIDs = filtered
}
raw, err := model.EncodeLibraryTags(normalized)
if err != nil {
return nil, err
}
if err := p.repo.User.UpdateFields(ctx, userID, map[string]any{
"library_tags": raw,
}); err != nil {
return nil, err
}
if normalized == nil {
normalized = []model.LibraryTagSet{}
}
return normalized, nil
}
func (p *ProfileService) accessibleLibraryIDSet(ctx context.Context, visibility MediaVisibility) (map[string]struct{}, error) {
libs, err := p.repo.Library.List(ctx)
if err != nil {
@@ -0,0 +1,150 @@
package service
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"go.uber.org/zap"
)
func TestProfileLibraryTagsFiltersInaccessibleAndKeepsOneTagPerLibrary(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewProfileService(zap.NewNop(), repos)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
libA := &model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
libB := &model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true}
libHidden := &model.Library{Name: "Adult", Path: "/media/adult", Type: "movie", Enabled: true}
for _, lib := range []*model.Library{libA, libB, libHidden} {
if err := repos.Library.Create(t.Context(), lib); err != nil {
t.Fatal(err)
}
}
if err := repos.User.UpdateFields(t.Context(), user.ID, map[string]any{
"allowed_library_ids": `["` + libA.ID + `","` + libB.ID + `"]`,
}); err != nil {
t.Fatal(err)
}
got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{
{Name: " 热门 ", LibraryIDs: []string{libA.ID, libHidden.ID, "missing", libA.ID}},
{Name: "热门", LibraryIDs: []string{libB.ID, libA.ID}},
{Name: "", LibraryIDs: []string{libB.ID}},
})
if err != nil {
t.Fatalf("SetLibraryTags: %v", err)
}
if len(got) != 1 {
t.Fatalf("SetLibraryTags returned %d tags, want 1 (%#v)", len(got), got)
}
if got[0].Name != "热门" {
t.Fatalf("tag name = %q, want 热门", got[0].Name)
}
// libA 已在第一个标签里占位,第二个标签里的 libA 应被丢弃。
want := []string{libA.ID, libB.ID}
if len(got[0].LibraryIDs) != len(want) {
t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want)
}
for i := range want {
if got[0].LibraryIDs[i] != want[i] {
t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want)
}
}
loaded, err := svc.GetLibraryTags(t.Context(), user.ID)
if err != nil {
t.Fatalf("GetLibraryTags: %v", err)
}
if len(loaded) != 1 || loaded[0].Name != "热门" || len(loaded[0].LibraryIDs) != len(want) {
t.Fatalf("GetLibraryTags = %#v, want one tag with %v", loaded, want)
}
}
func TestProfileLibraryTagsKeepsEmptyTagAndMountedEmby(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewProfileService(zap.NewNop(), repos)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
mount := &model.EmbyMount{
AccountID: "acct-1",
RemoteViewID: "view-42",
RemoteViewName: "Remote Movies",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatal(err)
}
remoteID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{
{Name: "远程", LibraryIDs: []string{remoteID, "embyremote~missing~view"}},
{Name: "以后再用", LibraryIDs: []string{}},
})
if err != nil {
t.Fatalf("SetLibraryTags: %v", err)
}
if len(got) != 2 {
t.Fatalf("SetLibraryTags = %#v, want 2 tags", got)
}
if len(got[0].LibraryIDs) != 1 || got[0].LibraryIDs[0] != remoteID {
t.Fatalf("remote tag ids = %v, want [%s]", got[0].LibraryIDs, remoteID)
}
if got[1].Name != "以后再用" || len(got[1].LibraryIDs) != 0 {
t.Fatalf("empty tag should survive as-is, got %#v", got[1])
}
loaded, err := svc.GetLibraryTags(t.Context(), user.ID)
if err != nil {
t.Fatalf("GetLibraryTags: %v", err)
}
if len(loaded) != 2 || len(loaded[1].LibraryIDs) != 0 {
t.Fatalf("GetLibraryTags = %#v, want the empty tag preserved", loaded)
}
}
func TestProfileLibraryTagsEmptyStateReturnsEmptySlice(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewProfileService(zap.NewNop(), repos)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
if tags, err := svc.GetLibraryTags(t.Context(), user.ID); err != nil || tags == nil || len(tags) != 0 {
t.Fatalf("GetLibraryTags = %#v, %v; want empty non-nil slice", tags, err)
}
if tags, err := svc.SetLibraryTags(t.Context(), user.ID, nil); err != nil || tags == nil || len(tags) != 0 {
t.Fatalf("SetLibraryTags(nil) = %#v, %v; want empty non-nil slice", tags, err)
}
}
+1
View File
@@ -64,6 +64,7 @@ type Container struct {
RecognitionWords *RecognitionWordsService
Danmaku *DanmakuService
Strm *StrmService
Cloud115 *Cloud115PlaybackService
Database *DatabaseAdminService
FFTools *FFmpegToolsService
+8 -7
View File
@@ -28,13 +28,13 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
cfg: cfg,
log: log,
repos: repos,
version: normalizeSystemUpdateVersion(version),
c: &Container{
Version: normalizeSystemUpdateVersion(version),
Cfg: cfg,
Log: log,
Repo: repos,
},
version: normalizeSystemUpdateVersion(version),
c: &Container{
Version: normalizeSystemUpdateVersion(version),
Cfg: cfg,
Log: log,
Repo: repos,
},
}
builder.startRealtimeServices()
builder.initProviderServices()
@@ -119,6 +119,7 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Profile = NewProfileService(b.log, b.repos)
b.c.Audit = NewAuditService(b.log, b.repos)
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
b.c.Cloud115 = NewCloud115PlaybackService(b.cfg, b.log, b.repos, b.c.Strm)
// ffmpeg/ffprobe 一键下载安装(data/tools/ffmpeg/)。
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
+16 -1
View File
@@ -26,7 +26,8 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
startSec := parseHLSStartSec(r)
seekGen := parseHLSSeekGen(r)
subtitleStream := parseHLSSubtitleStream(r)
if _, err := s.transcoder.EnsureJobFromSubtitle(r.Context(), mediaID, startSec, seekGen, subtitleStream); err != nil {
quality := parseHLSQuality(r)
if _, err := s.transcoder.EnsureJobFromSubtitleQuality(r.Context(), mediaID, startSec, seekGen, subtitleStream, quality); err != nil {
return err
}
s.transcoder.TouchJob(mediaID)
@@ -76,6 +77,20 @@ func parseHLSSubtitleStream(r *http.Request) int {
return v
}
func parseHLSQuality(r *http.Request) string {
if r == nil {
return ""
}
quality := strings.TrimSpace(r.URL.Query().Get("quality"))
if quality == "" {
return ""
}
if _, ok := LocalHLSQualityByID(quality); !ok {
return ""
}
return quality
}
func parseHLSStartSec(r *http.Request) float64 {
if r == nil {
return 0
+71
View File
@@ -10,6 +10,7 @@ import (
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
@@ -156,6 +157,76 @@ func TestPersist115TokensKeepsSharedClient(t *testing.T) {
}
}
// TestTestStrmAccountKeepsTokensRefreshedDuringPing 回归测试:
// Ping 期间 115 客户端可能刷新并持久化 token。账号测试只能写 last_test_*
// 字段,不能再用请求开始时读取的旧 Config 整包覆盖,否则新 token 会被旧值
// 覆盖,最终导致账号在下次重启后失效。
func TestTestStrmAccountKeepsTokensRefreshedDuringPing(t *testing.T) {
svc := testStrmService(t)
ctx := context.Background()
acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at-old",
"refresh_token": "rt-old",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
svc.providerMu.Lock()
svc.provider115Cache[acct.ID] = &refreshOnPing115Provider{svc: svc, accountID: acct.ID}
svc.providerMu.Unlock()
got := svc.TestStrmAccount(ctx, acct.ID)
if got == nil {
t.Fatal("TestStrmAccount returned nil")
}
if !got.LastTestOK || got.LastTestResult != "ok" {
t.Fatalf("test result = (%v, %q), want ok", got.LastTestOK, got.LastTestResult)
}
gotCfg, err := svc.strmAccountConfig(got)
if err != nil {
t.Fatalf("decode returned config: %v", err)
}
if gotCfg["access_token"] != "at-new" || gotCfg["refresh_token"] != "rt-new" {
t.Fatalf("returned account lost refreshed tokens: %#v", gotCfg)
}
fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID)
if err != nil || fresh == nil {
t.Fatalf("reload account: %v", err)
}
freshCfg, err := svc.strmAccountConfig(fresh)
if err != nil {
t.Fatalf("decode persisted config: %v", err)
}
if freshCfg["access_token"] != "at-new" || freshCfg["refresh_token"] != "rt-new" {
t.Fatalf("persisted account lost refreshed tokens: %#v", freshCfg)
}
}
// refreshOnPing115Provider 模拟真实 115 客户端在 Ping 内完成 token 轮转并
// 通过持久化回调写回新 token 的行为。
type refreshOnPing115Provider struct {
svc *StrmService
accountID string
}
func (p *refreshOnPing115Provider) Type() string { return model.StrmProvider115 }
func (p *refreshOnPing115Provider) Ping(context.Context) error {
p.svc.persist115Tokens(p.accountID, "at-new", "rt-new")
return nil
}
func (p *refreshOnPing115Provider) List(context.Context, string) ([]cloud.FileEntry, error) {
return nil, nil
}
func (p *refreshOnPing115Provider) Resolve(context.Context, string) (*cloud.DirectLink, error) {
return nil, nil
}
func TestDeleteStrmAccountCascadesEmbyMounts(t *testing.T) {
ctx := context.Background()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{})
+26 -8
View File
@@ -449,19 +449,37 @@ func (s *StrmService) TestStrmAccount(ctx context.Context, id string) *model.Str
return nil
}
now := time.Now()
acct.LastTestAt = &now
result := ""
ok := false
provider, err := s.providerFor(ctx, acct)
if err != nil {
acct.LastTestResult = err.Error()
acct.LastTestOK = false
result = err.Error()
} else if err := provider.Ping(ctx); err != nil {
acct.LastTestResult = err.Error()
acct.LastTestOK = false
result = err.Error()
} else {
acct.LastTestResult = "ok"
acct.LastTestOK = true
result = "ok"
ok = true
}
_ = s.repo.StrmAccount.Update(ctx, acct)
// Ping 期间 115 客户端可能刷新 access/refresh token,并通过
// OnTokenRefreshed 持久化新配置。这里只写测试结果字段,不能把请求开始时
// 读取的旧 acct.Config 整包写回,否则会把刚轮转的 token 覆盖失效。
updateErr := s.repo.StrmAccount.UpdateTestResult(ctx, id, now, result, ok)
if updateErr != nil && s.log != nil {
s.log.Warn("update strm account test result failed", zap.String("account_id", id), zap.Error(updateErr))
}
// 重新读取,确保返回给前端的账号配置已经是 Ping 期间刷新后的版本。
if fresh, err := s.repo.StrmAccount.FindByID(ctx, id); err == nil && fresh != nil {
if updateErr != nil {
// 写库失败时仍让本次响应展示刚完成测试的结果。
fresh.LastTestAt = &now
fresh.LastTestResult = result
fresh.LastTestOK = ok
}
return fresh
}
acct.LastTestAt = &now
acct.LastTestResult = result
acct.LastTestOK = ok
return acct
}
+32 -8
View File
@@ -47,8 +47,8 @@ type TranscoderService struct {
repo *repository.Container
hub *Hub
mu sync.Mutex
jobs map[string]*hlsJob
mu sync.Mutex
jobs map[string]*hlsJob
// startGates serializes EnsureJobFrom / StopJob per media so concurrent
// playlist hits cannot spawn multiple ffmpeg writers into one HLS dir.
startGates sync.Map // mediaID -> *sync.Mutex
@@ -74,6 +74,8 @@ type hlsJob struct {
// subtitleStream is an absolute ffmpeg stream index to burn into the
// picture, or -1 when no bitmap subtitle is selected.
subtitleStream int
// quality is the requested local HLS quality profile ("" = global config).
quality string
// done is closed when the ffmpeg goroutine fully exits (after process death).
done chan struct{}
}
@@ -130,12 +132,27 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
// EnsureJobFromSubtitle is EnsureJobFrom with an optional bitmap subtitle
// stream. Selecting or closing a burned subtitle creates a new HLS generation.
func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int) (string, error) {
return t.EnsureJobFromSubtitleQuality(ctx, mediaID, startSec, seekGen, subtitleStream, "")
}
// EnsureJobFromSubtitleQuality is EnsureJobFromSubtitle with an explicit local
// HLS quality profile. Empty qualityID keeps the global transcoder config.
func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int, qualityID string) (string, error) {
if !t.cfg.Transcoder.Enabled {
return "", ErrTranscodeDisabled
}
if startSec < 0 {
startSec = 0
}
var quality *LocalHLSQuality
if requested := strings.TrimSpace(qualityID); requested != "" {
profile, ok := LocalHLSQualityByID(requested)
if !ok {
return "", fmt.Errorf("未知的 HLS 画质:%s", requested)
}
quality = &profile
qualityID = profile.ID
}
m, err := t.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return "", err
@@ -150,12 +167,12 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
t.mu.Lock()
if existing, ok := t.jobs[mediaID]; ok {
if sameHLSConfiguration(existing, startSec, subtitleStream) {
if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
@@ -175,6 +192,7 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
if subtitleStream >= 0 {
input.SubtitleStream = &subtitleStream
}
input.Quality = quality
t.maybeFillDuration(ctx, m, input)
if _, err := t.resolveFFmpegPath(); err != nil {
return "", err
@@ -189,12 +207,12 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
t.mu.Lock()
if existing, ok := t.jobs[mediaID]; ok {
if sameHLSConfiguration(existing, startSec, subtitleStream) {
if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
@@ -224,6 +242,7 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
startSec: startSec,
seekGen: seekGen,
subtitleStream: subtitleStream,
quality: qualityID,
done: make(chan struct{}),
}
t.jobs[mediaID] = job
@@ -250,10 +269,14 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool
}
func shouldReplaceHLSJobConfiguration(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int) bool {
return shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, "")
}
func shouldReplaceHLSJobConfigurationQuality(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int, quality string) bool {
if existing == nil {
return true
}
if sameHLSConfiguration(existing, startSec, subtitleStream) {
if sameHLSConfiguration(existing, startSec, subtitleStream, quality) {
return false
}
return newerHLSGenerationMayReplace(existing, seekGen)
@@ -270,9 +293,10 @@ func newerHLSGenerationMayReplace(existing *hlsJob, seekGen int64) bool {
return true
}
func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int) bool {
func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int, quality string) bool {
return existing != nil &&
existing.subtitleStream == subtitleStream &&
existing.quality == quality &&
sameHLSStart(existing.startSec, startSec)
}
+40 -10
View File
@@ -15,6 +15,7 @@ type transcodeInput struct {
Headers map[string]string
StartSec float64
SubtitleStream *int
Quality *LocalHLSQuality
}
type ffmpegArgSettings struct {
@@ -28,6 +29,7 @@ type ffmpegArgSettings struct {
realtime bool
threads int
vaapiDevice string
noScale bool
}
type ffmpegVideoPlan struct {
@@ -45,7 +47,11 @@ func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []st
}
func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string {
settings := ffmpegArgSettingsFromConfig(cfg)
settings := ffmpegArgSettingsFromConfig(cfg, input.Quality)
if settings.noScale {
// 原画档位不做硬件缩放的兼容处理,直接走软件编码,保持源分辨率。
settings.encoder = ""
}
video := ffmpegVideoPlanForSettings(settings)
if input.SubtitleStream != nil {
// Bitmap subtitles must be composited in software. Keeping CUDA/QSV/
@@ -64,7 +70,7 @@ func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist,
return args
}
func ffmpegArgSettingsFromConfig(cfg *config.Config) ffmpegArgSettings {
func ffmpegArgSettingsFromConfig(cfg *config.Config, quality *LocalHLSQuality) ffmpegArgSettings {
settings := ffmpegArgSettings{
bitrate: ffmpegDefaultString(cfg.Transcoder.VideoBitrate, "1500k"),
maxrate: ffmpegDefaultString(cfg.Transcoder.MaxRate, "1800k"),
@@ -79,7 +85,23 @@ func ffmpegArgSettingsFromConfig(cfg *config.Config) ffmpegArgSettings {
if cfg.Transcoder.HardwareAccel {
settings.encoder = normalizedHardwareEncoder(cfg.Transcoder.Encoder)
}
if settings.height <= 0 {
if quality != nil {
if strings.TrimSpace(quality.VideoBitrate) != "" {
settings.bitrate = strings.TrimSpace(quality.VideoBitrate)
}
if strings.TrimSpace(quality.MaxRate) != "" {
settings.maxrate = strings.TrimSpace(quality.MaxRate)
}
if strings.TrimSpace(quality.BufSize) != "" {
settings.bufsize = strings.TrimSpace(quality.BufSize)
}
if quality.Height > 0 {
settings.height = quality.Height
} else {
settings.noScale = true
}
}
if settings.height <= 0 && !settings.noScale {
settings.height = 720
}
if settings.segmentSeconds <= 0 {
@@ -118,8 +140,12 @@ func ffmpegVideoPlanForSettings(settings ffmpegArgSettings) ffmpegVideoPlan {
codec: "h264_vaapi",
}
default:
filter := ""
if !settings.noScale {
filter = fmt.Sprintf("scale=-2:min(%d\\,ih)", settings.height)
}
return ffmpegVideoPlan{
filter: fmt.Sprintf("scale=-2:min(%d\\,ih)", settings.height),
filter: filter,
codec: "libx264",
preset: settings.preset,
}
@@ -149,14 +175,18 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe
}
args = append(args, "-i", input.Source)
if input.SubtitleStream != nil {
filter := fmt.Sprintf(
"[0:v:0][0:%d]overlay=0:0:eof_action=pass,scale=-2:min(%d\\,ih)[v]",
*input.SubtitleStream,
settings.height,
)
filter := fmt.Sprintf("[0:v:0][0:%d]overlay=0:0:eof_action=pass", *input.SubtitleStream)
if !settings.noScale {
filter += fmt.Sprintf(",scale=-2:min(%d\\,ih)", settings.height)
}
filter += "[v]"
args = append(args, "-filter_complex", filter, "-map", "[v]", "-map", "0:a:0?", "-c:v", video.codec)
} else {
args = append(args, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
args = append(args, "-map", "0:v:0?", "-map", "0:a:0?")
if video.filter != "" {
args = append(args, "-vf", video.filter)
}
args = append(args, "-c:v", video.codec)
}
if settings.threads > 0 && video.codec == "libx264" {
args = append(args, "-threads", strconv.Itoa(settings.threads))
+42
View File
@@ -0,0 +1,42 @@
param(
[string]$Output = "dist\mebox-windows-amd64.exe",
[string]$Version = "dev",
[switch]$SkipFrontend
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
Push-Location $root
try {
if (-not $SkipFrontend) {
Push-Location web
try {
npm ci
npm run build
}
finally {
Pop-Location
}
}
if (-not (Test-Path "web\dist\index.html")) {
throw "web\dist\index.html is missing. Run without -SkipFrontend or build the frontend first."
}
$outputDir = Split-Path -Parent $Output
if ($outputDir) {
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
}
$env:CGO_ENABLED = "0"
$env:GOOS = "windows"
$env:GOARCH = "amd64"
go build -trimpath `
-ldflags="-s -w -H=windowsgui -X main.version=$Version" `
-o $Output `
./cmd/server
Write-Host "Built $Output"
}
finally {
Pop-Location
}
+12 -2
View File
@@ -147,7 +147,7 @@ export function streamURL(mediaId: string): string {
// hlsURL returns the m3u8 playlist URL fed into hls.js.
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number): string {
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number, quality?: string): string {
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
// Always send start= (including 0) so the server can tell an intentional
// restart-from-head apart from a missing query on a stale refresh.
@@ -159,7 +159,17 @@ export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number):
subtitleStream !== undefined && subtitleStream >= 0
? `&subtitle=${encodeURIComponent(String(subtitleStream))}`
: ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${bust}`
const qualityQuery = quality ? `&quality=${encodeURIComponent(quality)}` : ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${qualityQuery}${bust}`
}
// cloudHlsURL returns the MeBox-proxied 115 cloud HLS master playlist URL.
// A browser cannot fetch the 115 CDN directly because its CORS policy only
// allows https://115.com; the backend rewrites all child URLs to this
// same-origin proxy.
export function cloudHlsURL(mediaId: string, definition: string): string {
const quality = definition ? `&definition=${encodeURIComponent(definition)}` : ''
return `/api/cloud115/media/${encodeURIComponent(mediaId)}/master.m3u8?${tokenQuery()}${profileQuery()}${quality}&media_id=${encodeURIComponent(mediaId)}`
}
// Stop an on-demand HLS job. keepalive makes the request survive page
+2
View File
@@ -71,6 +71,7 @@ export interface DanmakuConfig {
font_size: string
area: string
volume: number
playback_rate: number
/** Per-user preference: merge the same episode's multiple sources. */
merge_sources: boolean
}
@@ -87,6 +88,7 @@ export interface DanmakuSettingsPatch {
area?: number
merge_sources?: boolean
volume?: number
playback_rate?: number
}
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
+13 -1
View File
@@ -1,5 +1,5 @@
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
import type { Library, LibraryRoot, Media, ScanResult } from '../types'
import type { Library, LibraryRoot, Media, PlaybackInfo, ScanResult } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
export interface MediaPage {
@@ -233,6 +233,18 @@ export const mediaAPI = {
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
playbackInfo: (id: string, quality?: number) =>
api
.get<PlaybackInfo>(`/media/${id}/playback`, {
params: quality && quality > 0 ? { quality } : undefined,
})
.then((r) => r.data),
startCloudTranscode: (id: string, definition: number) =>
api
.post<PlaybackInfo>(`/media/${id}/transcode`, { definition })
.then((r) => r.data),
getEpisodes: (id: string) =>
api.get<{ items: Media[]; total: number }>(`/media/${id}/episodes`).then((r) => r.data),
+7 -1
View File
@@ -1,5 +1,5 @@
import { api } from './client'
import type { User } from '../types'
import type { LibraryTagSet, User } from '../types'
export const profileAPI = {
get: () => api.get<User>('/me').then((r) => r.data),
@@ -21,6 +21,12 @@ export const profileAPI = {
setPinnedLibraries: (libraryIds: string[]) =>
api.put<{ library_ids: string[] }>('/me/pinned-libraries', { library_ids: libraryIds }).then((r) => r.data.library_ids ?? []),
getLibraryTags: () =>
api.get<{ tags: LibraryTagSet[] | null }>('/me/library-tags').then((r) => r.data.tags ?? []),
setLibraryTags: (tags: LibraryTagSet[]) =>
api.put<{ tags: LibraryTagSet[] | null }>('/me/library-tags', { tags }).then((r) => r.data.tags ?? []),
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
api.patch<User>(`/admin/users/${id}/role`, { role }).then((r) => r.data),
}
+2
View File
@@ -37,6 +37,7 @@ export function GlobalEvents() {
}
}
if (topic === 'scrape' && p.finished) {
if (role !== 'admin') return
const processed = Number(p.processed ?? 0)
const matched = Number(p.matched ?? 0)
const failed = Number(p.failed ?? 0)
@@ -51,6 +52,7 @@ export function GlobalEvents() {
}
}
if (topic === 'subscription') {
if (role !== 'admin') return
const queued = (p.queued as number | undefined) ?? 0
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
}
+5 -1
View File
@@ -87,7 +87,11 @@ export function Layout() {
pathname={location.pathname}
/>
)}
<LayoutWorkspace routeKey={location.pathname} showMobileBottomNav={showMobileBottomNav} />
<LayoutWorkspace
routeKey={location.pathname}
userKey={user?.id}
showMobileBottomNav={showMobileBottomNav}
/>
{showMobileBottomNav && (
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
)}
+9 -1
View File
@@ -4,6 +4,7 @@ import clsx from 'clsx'
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
import { isPlayerRoute } from './layoutNavigation'
import { RouteErrorBoundary } from './RouteErrorBoundary'
import { useScrollMemory } from '../hooks/useScrollMemory'
import type { useLayoutSidebar } from './useLayoutSidebar'
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
@@ -30,6 +31,7 @@ type LayoutSidebarsProps = Omit<
type LayoutWorkspaceProps = {
routeKey: string
userKey?: string
showMobileBottomNav?: boolean
}
@@ -109,7 +111,13 @@ export function LayoutSidebars({
)
}
export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: LayoutWorkspaceProps) {
export function LayoutWorkspace({
routeKey,
userKey = 'anonymous',
showMobileBottomNav = false,
}: LayoutWorkspaceProps) {
useScrollMemory(routeKey, userKey)
const bottomPad = showMobileBottomNav
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
: ''
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Layers, Plus, Settings2, X } from 'lucide-react'
import { MAX_LIBRARY_TAGS } from '../utils/libraryTags'
import type { LibraryTagTab } from '../utils/libraryTags'
/**
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
*/
export function LibraryTagBar({
tabs,
selectedTagId,
onSelect,
onCreate,
onManage,
manageLabel = '管理标签',
showCreate = true,
busy = false,
}: {
tabs: LibraryTagTab[]
selectedTagId: string
onSelect: (tagId: string) => void
onCreate?: (name: string) => void
onManage?: () => void
manageLabel?: string
showCreate?: boolean
busy?: boolean
}) {
const [creating, setCreating] = useState(false)
const [draft, setDraft] = useState('')
const inputRef = useRef<HTMLInputElement | null>(null)
// 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。
const submittedRef = useRef(false)
useEffect(() => {
if (creating) {
submittedRef.current = false
inputRef.current?.focus()
}
}, [creating])
const hasTags = useMemo(() => tabs.some((tab) => !tab.isAll), [tabs])
const submit = () => {
if (submittedRef.current) return
submittedRef.current = true
const name = draft.trim()
setDraft('')
setCreating(false)
if (!name) return
onCreate?.(name)
}
const cancel = () => {
submittedRef.current = true
setDraft('')
setCreating(false)
}
return (
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--app-border)] pb-3">
<div className="flex flex-wrap items-center gap-1.5">
{tabs.map((tab) => {
const active = tab.id === selectedTagId
return (
<button
key={tab.id}
type="button"
onClick={() => onSelect(tab.id)}
aria-pressed={active}
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
active
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
}`}
title={tab.isAll ? '显示全部媒体库' : `只看「${tab.name}」标签下的媒体库`}
>
{tab.isAll && <Layers size={13} />}
<span className="max-w-[8rem] truncate">{tab.name}</span>
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
{tab.count}
</span>
</button>
)
})}
</div>
{(showCreate || onManage) && (
<div className="flex flex-wrap items-center gap-1.5">
{showCreate && onCreate && (creating || !hasTags) && (
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1">
<input
ref={inputRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
submit()
}
if (event.key === 'Escape') {
event.preventDefault()
setDraft('')
setCreating(false)
}
}}
onBlur={() => submit()}
maxLength={24}
placeholder="新标签名"
className="h-6 w-24 bg-transparent text-xs font-semibold text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
title={`最多 24 个字,当前最多 ${MAX_LIBRARY_TAGS} 个标签`}
/>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={submit}
disabled={busy || !draft.trim()}
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)] disabled:opacity-40"
title="创建标签"
>
<Plus size={13} />
</button>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={cancel}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="取消"
>
<X size={13} />
</button>
</div>
)}
{showCreate && onCreate && !creating && hasTags && (
<button
type="button"
onClick={() => setCreating(true)}
disabled={busy || tabs.length - 1 >= MAX_LIBRARY_TAGS}
className="inline-flex items-center gap-1 rounded-xl border border-dashed border-[var(--app-border)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-muted)] transition-colors hover:border-brand-500/50 hover:text-brand-500 disabled:opacity-40"
title="新建标签"
>
<Plus size={13} />
<span>新建标签</span>
</button>
)}
{onManage && (
<button
type="button"
onClick={onManage}
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500"
title="重命名 / 删除标签,调整标签下的媒体库"
>
<Settings2 size={13} />
<span>{manageLabel}</span>
</button>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,488 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { confirmAction } from './confirmAction'
import type { Library } from '../types'
import {
MAX_LIBRARY_TAGS,
filterLibrariesForTagging,
normalizeTagName,
tagNameError,
type LibraryTag,
} from '../utils/libraryTags'
export type ManageLibraryTagsDialogProps = {
tags: LibraryTag[]
libraries: Library[]
saving: boolean
onCreate: (name: string) => Promise<string>
onRename: (from: string, to: string) => Promise<void>
onRemove: (name: string) => Promise<void>
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
onAssign: (libraryId: string, tagName: string) => Promise<void>
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
onAssignBatch: (libraryIds: string[], tagName: string) => Promise<void>
onClose: () => void
}
/** 「未分类」在筛选/批量目标里的哨兵值,与标签名不会冲突。 */
const UNTAGGED = '__untagged__'
/**
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把媒体库归入
* 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。
*
* 右侧支持搜索、按当前标签筛选、多选后一次性批量归类,避免逐条点击下拉框。
*/
export function ManageLibraryTagsDialogView({
tags,
libraries,
saving,
onCreate,
onRename,
onRemove,
onAssign,
onAssignBatch,
onClose,
}: ManageLibraryTagsDialogProps) {
const [draftName, setDraftName] = useState('')
const [renaming, setRenaming] = useState('')
const [renameDraft, setRenameDraft] = useState('')
const [busyLibrary, setBusyLibrary] = useState('')
const [keyword, setKeyword] = useState('')
const [tagFilter, setTagFilter] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [batchTag, setBatchTag] = useState('')
const [applying, setApplying] = useState(false)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
const tagByLibrary = useMemo(() => {
const map = new Map<string, string>()
for (const tag of tags) {
for (const id of tag.library_ids) {
if (!map.has(id)) map.set(id, tag.name)
}
}
return map
}, [tags])
// 标签被删除/重命名后,批量目标与筛选值可能已经失效,落回默认项。
useEffect(() => {
if (batchTag && batchTag !== UNTAGGED && !tags.some((tag) => tag.name === batchTag)) {
setBatchTag('')
}
if (tagFilter && tagFilter !== UNTAGGED && !tags.some((tag) => tag.name === tagFilter)) {
setTagFilter('')
}
}, [tags, batchTag, tagFilter])
// 媒体库被删除后清掉已选项,避免批量应用时带着不存在的 ID。
useEffect(() => {
const available = new Set(libraries.map((lib) => lib.id))
setSelectedIds((prev) => {
const next = prev.filter((id) => available.has(id))
return next.length === prev.length ? prev : next
})
}, [libraries])
const visibleLibraries = useMemo(() => {
const searched = filterLibrariesForTagging(libraries, keyword)
if (!tagFilter) return searched
return searched.filter((lib) => {
const current = tagByLibrary.get(lib.id) ?? ''
return tagFilter === UNTAGGED ? current === '' : current === tagFilter
})
}, [libraries, keyword, tagFilter, tagByLibrary])
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const visibleSelectedCount = visibleLibraries.reduce(
(sum, lib) => sum + (selectedSet.has(lib.id) ? 1 : 0),
0,
)
const allVisibleSelected = visibleLibraries.length > 0 && visibleSelectedCount === visibleLibraries.length
const toggleLibrary = useCallback((libraryId: string) => {
setSelectedIds((prev) =>
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId],
)
}, [])
const toggleVisible = useCallback(() => {
const visibleIds = visibleLibraries.map((lib) => lib.id)
setSelectedIds((prev) => {
const current = new Set(prev)
const everySelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id))
if (everySelected) return prev.filter((id) => !visibleIds.includes(id))
for (const id of visibleIds) current.add(id)
return Array.from(current)
})
}, [visibleLibraries])
const handleCreate = async () => {
const error = tagNameError(draftName, tags)
if (error) {
toast.error(error)
return
}
if (tags.length >= MAX_LIBRARY_TAGS) {
toast.error(`最多 ${MAX_LIBRARY_TAGS} 个标签`)
return
}
const created = await onCreate(normalizeTagName(draftName))
if (created) {
setDraftName('')
toast.success(`标签「${created}」已创建`)
} else {
toast.error('标签创建失败')
}
}
const handleRename = async (from: string) => {
const error = tagNameError(renameDraft, tags, from)
if (error) {
toast.error(error)
return
}
await onRename(from, renameDraft)
setRenaming('')
setRenameDraft('')
}
const handleRemove = async (tag: LibraryTag) => {
const confirmed = await confirmAction({
title: '删除标签',
message: `确定删除标签「${tag.name}」?\n媒体库本身不会被删除,只会回到「未分类」。`,
confirmText: '删除',
})
if (!confirmed) return
await onRemove(tag.name)
}
const handleAssign = async (library: Library, tagName: string) => {
setBusyLibrary(library.id)
try {
await onAssign(library.id, tagName)
} finally {
setBusyLibrary('')
}
}
const handleApplyBatch = async () => {
if (selectedIds.length === 0 || applying) return
setApplying(true)
try {
await onAssignBatch(selectedIds, batchTag)
const count = selectedIds.length
toast.success(
batchTag ? `已将 ${count} 个媒体库归入「${batchTag}」` : `已将 ${count} 个媒体库移出标签`,
)
setSelectedIds([])
} finally {
setApplying(false)
}
}
const busy = saving || applying
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 backdrop-blur-sm sm:p-4"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="flex h-[calc(100dvh-1rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-[var(--app-panel)] shadow-2xl sm:h-[82vh] sm:rounded-3xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-4 py-3 sm:px-6 sm:py-4">
<div className="flex items-center gap-2">
<Tag size={18} className="text-brand-500" />
<h3 className="font-display text-base font-bold text-[var(--app-text)] sm:text-lg">管理标签</h3>
{busy && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="关闭"
>
<X size={20} />
</button>
</div>
<div className="grid flex-1 grid-cols-1 gap-4 overflow-hidden p-3 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
<section className="flex min-h-0 flex-col gap-3">
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">标签</h4>
<div className="flex items-center gap-1.5">
<input
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void handleCreate()
}
}}
maxLength={24}
placeholder="新建标签名"
className="input-base !py-2 text-sm"
/>
<button
type="button"
onClick={() => void handleCreate()}
disabled={busy || !draftName.trim()}
className="btn-outline shrink-0 !px-3 !py-2 text-xs"
title="创建标签"
>
<Plus size={14} />
新建
</button>
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
{tags.length === 0 ? (
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
还没有标签。先创建一个,再把媒体库归入其中。
</p>
) : (
tags.map((tag) => (
<div
key={tag.name}
className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2"
>
{renaming === tag.name ? (
<>
<input
autoFocus
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void handleRename(tag.name)
}
if (event.key === 'Escape') {
setRenaming('')
setRenameDraft('')
}
}}
maxLength={24}
className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
/>
<button
type="button"
onClick={() => void handleRename(tag.name)}
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
title="保存名称"
>
<Check size={14} />
</button>
<button
type="button"
onClick={() => {
setRenaming('')
setRenameDraft('')
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="取消"
>
<X size={14} />
</button>
</>
) : (
<>
<button
type="button"
onClick={() => setTagFilter(tag.name)}
className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500"
title={`只看「${tag.name}」标签下的媒体库`}
>
{tag.name}
</button>
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
{tag.library_ids.length}
</span>
<button
type="button"
onClick={() => {
setRenaming(tag.name)
setRenameDraft(tag.name)
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
title="重命名"
>
<Pencil size={13} />
</button>
<button
type="button"
onClick={() => void handleRemove(tag)}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
title="删除标签"
>
<Trash2 size={13} />
</button>
</>
)}
</div>
))
)}
</div>
</section>
<section className="flex min-h-0 flex-col gap-3 border-t border-[var(--app-border)] pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
媒体库归类({libraries.length})
</h4>
<span className="text-xs text-[var(--app-muted)]">勾选多个后可一次性设置标签</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索名称 / 路径 / 类型"
className="input-base h-9 min-w-[10rem] flex-1 !py-0 text-xs"
/>
<select
value={tagFilter}
onChange={(event) => setTagFilter(event.target.value)}
className="input-base h-9 w-32 shrink-0 !py-0 text-xs"
title="筛选媒体库"
>
<option value="">全部标签</option>
<option value={UNTAGGED}>未分类</option>
{tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
</option>
))}
</select>
</div>
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2">
<button
type="button"
onClick={toggleVisible}
disabled={visibleLibraries.length === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500 disabled:opacity-40"
title="全选 / 取消全选当前列表中的媒体库"
>
<Check size={13} />
{allVisibleSelected ? '取消全选' : '全选'}
</button>
<span className="text-xs font-semibold text-[var(--app-subtle)]">
已选 {selectedIds.length} 个
</span>
<select
value={batchTag}
onChange={(event) => setBatchTag(event.target.value)}
className="input-base ml-auto h-8 w-36 shrink-0 !py-0 text-xs"
title="选择要批量设置的标签"
>
<option value="">未分类(移出标签)</option>
{tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
</option>
))}
</select>
<button
type="button"
onClick={() => void handleApplyBatch()}
disabled={busy || selectedIds.length === 0}
className="btn-outline shrink-0 !px-3 !py-1.5 text-xs"
title="把选中媒体库的标签一次性设为左侧所选值"
>
{applying && <Loader2 size={13} className="animate-spin" />}
应用到已选
</button>
{selectedIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedIds([])}
disabled={applying}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="清空选择"
>
<X size={13} />
</button>
)}
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
{visibleLibraries.length === 0 ? (
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
{libraries.length === 0 ? '暂无可归类的媒体库。' : '没有符合当前筛选条件的媒体库。'}
</p>
) : (
visibleLibraries.map((library) => {
const current = tagByLibrary.get(library.id) ?? ''
const checked = selectedSet.has(library.id)
return (
<div
key={library.id}
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
checked
? 'border-brand-500/50 bg-brand-500/5'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleLibrary(library.id)}
disabled={applying}
className="h-4 w-4 shrink-0 accent-brand-400"
title="勾选后可批量设置标签"
/>
<span
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
title={library.path || library.name}
>
{library.name}
</span>
{busyLibrary === library.id && (
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
)}
<select
value={current}
disabled={busy || busyLibrary === library.id}
onChange={(event) => void handleAssign(library, event.target.value)}
className="input-base h-8 w-32 shrink-0 !py-0 text-xs"
title="选择该媒体库所属标签"
>
<option value="">未分类</option>
{tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
</option>
))}
</select>
</div>
)
})
)}
</div>
</section>
</div>
<div className="flex items-center justify-between gap-3 border-t border-[var(--app-border)] px-4 py-3 sm:px-6">
<span className="text-xs text-[var(--app-muted)]">标签按用户保存;一个媒体库同时只属于一个标签。</span>
<button type="button" onClick={onClose} className="btn-outline !px-4 !py-2 text-sm">
完成
</button>
</div>
</div>
</div>
)
}
+13 -17
View File
@@ -4,6 +4,9 @@ import { Film, Play, Layers, Star } from 'lucide-react'
import { imageURL } from '../api/client'
import type { Media } from '../types'
const ACTION_OVERLAY_CLASS =
'absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-100 transition-opacity sm:pointer-events-none sm:opacity-0 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 sm:focus-within:pointer-events-auto sm:focus-within:opacity-100'
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
export const MediaCard = memo(function MediaCard({
@@ -79,25 +82,18 @@ export const MediaCard = memo(function MediaCard({
</div>
)}
{/* Episode count badge */}
{count !== undefined && count > 1 && (
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
<Layers size={10} className="text-[#c9954a]" />
<span>{count} 集</span>
{/* Episode count badge:统一放右下角(桌面端与移动端一致),与左上角评分彻底错开 */}
{(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
<span className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-lg border border-white/15 bg-[#111827]/90 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm">
<Layers size={10} className="shrink-0 text-[#c9954a]" />
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
</span>
)}
{count === undefined && versionCount > 1 && (
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
<Layers size={10} className="text-[#c9954a]" />
<span>{versionCount} 版本</span>
</span>
)}
) : null}
{/* Rating Badge */}
{displayRating > 0 && (
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm">
<Star size={10} fill="currentColor" />
<span className="absolute left-2 top-2 inline-flex items-center gap-0.5 rounded-lg border border-white/15 bg-[#111827]/90 px-1.5 py-0.5 text-[10px] font-bold text-[#c9954a] shadow-sm">
<Star size={10} fill="currentColor" className="shrink-0" />
<span>{displayRating.toFixed(1)}</span>
</span>
)}
@@ -160,7 +156,7 @@ export const MediaCard = memo(function MediaCard({
{card}
</button>
{actionContent && (
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<div className={ACTION_OVERLAY_CLASS}>
{actionContent}
</div>
)}
@@ -174,7 +170,7 @@ export const MediaCard = memo(function MediaCard({
<Link to={href} className="block">
{card}
</Link>
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<div className={ACTION_OVERLAY_CLASS}>
{actionContent}
</div>
</div>
+287 -17
View File
@@ -3,8 +3,12 @@ import { createPortal } from 'react-dom'
import {
Captions,
CaptionsOff,
Check,
FastForward,
Gauge,
ListVideo,
Loader2,
Lock,
Maximize,
MessageSquareText,
Minimize,
@@ -14,10 +18,18 @@ import {
Rewind,
SkipBack,
SkipForward,
Timer,
Volume2,
VolumeX,
} from 'lucide-react'
import type { SubtitleTrack } from '../api/subtitles'
import type { PlaybackQuality } from '../types'
import {
formatPlaybackRate,
normalizePlaybackRate,
PLAYBACK_RATE_OPTIONS,
stepPlaybackRate,
} from '../utils/playbackRate'
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
import {
SUBTITLE_POSITION_OPTIONS,
@@ -42,18 +54,28 @@ const SEEK_STEP_SEC = 10
const SEEK_REPEAT_MS = 160
const SEEK_APPLY_MS = 220
const SEEK_HINT_MS = 700
const PLAYBACK_RATE_REPEAT_MS = 140
const PLAYBACK_RATE_HINT_MS = 900
type SeekHint = {
dir: 'back' | 'forward'
seconds: number
}
function isEditableTarget(target: EventTarget | null): boolean {
function isPlayerSeekRange(target: EventTarget | null): boolean {
return target instanceof HTMLInputElement && target.dataset.playerSeekRange === 'true'
}
function shouldIgnorePlayerSeekShortcut(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
return Boolean(
target.closest('input, textarea, select, [contenteditable="true"]') ||
target.isContentEditable,
const control = target.closest<HTMLElement>(
'input, textarea, select, [contenteditable="true"]',
)
if (control instanceof HTMLInputElement) {
// 进度条需要继续走播放器的 10 秒快捷键;其它输入控件保留原生键盘行为。
return !isPlayerSeekRange(control)
}
return Boolean(control || target.isContentEditable)
}
type PlayerControlsProps = {
@@ -62,6 +84,10 @@ type PlayerControlsProps = {
volume?: number
onVolumeChange?: (volume: number) => void
onVolumeCommit?: (volume: number) => void
/** 用户级播放倍速,由播放页从数据库读取。 */
playbackRate?: number
onPlaybackRateChange?: (rate: number) => void
onPlaybackRateCommit?: (rate: number) => void
uiVisible: boolean
onUiVisibleChange: (visible: boolean) => void
subs: SubtitleTrack[]
@@ -86,6 +112,10 @@ type PlayerControlsProps = {
playlistOpen?: boolean
hasPlaylist?: boolean
onTogglePlaylist?: () => void
qualities?: PlaybackQuality[]
selectedQuality?: string
onSelectQuality?: (quality: PlaybackQuality) => void
showQuality?: boolean
/** Media metadata duration (seconds). Used when HLS only knows transcoded length. */
knownDuration?: number
/** Absolute source offset of the current HLS session (seconds). */
@@ -99,6 +129,9 @@ export function PlayerControls({
volume: volumeProp = 1,
onVolumeChange,
onVolumeCommit,
playbackRate: playbackRateProp = 1,
onPlaybackRateChange,
onPlaybackRateCommit,
uiVisible,
onUiVisibleChange,
subs,
@@ -122,6 +155,10 @@ export function PlayerControls({
playlistOpen = false,
hasPlaylist = false,
onTogglePlaylist,
qualities = [],
selectedQuality = '',
onSelectQuality,
showQuality = false,
knownDuration = 0,
streamOffset = 0,
onSeekAbsolute,
@@ -144,10 +181,16 @@ export function PlayerControls({
const [scrubValue, setScrubValue] = useState<number | null>(null)
const [subtitleMenuOpen, setSubtitleMenuOpen] = useState(false)
const subtitleMenuRef = useRef<HTMLDivElement | null>(null)
const [qualityMenuOpen, setQualityMenuOpen] = useState(false)
const qualityMenuRef = useRef<HTMLDivElement | null>(null)
const [speedMenuOpen, setSpeedMenuOpen] = useState(false)
const speedMenuRef = useRef<HTMLDivElement | null>(null)
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const controlsHoveredRef = useRef(false)
const isScrubbingRef = useRef(false)
const subtitleMenuOpenRef = useRef(false)
const qualityMenuOpenRef = useRef(false)
const speedMenuOpenRef = useRef(false)
const danmakuOpenRef = useRef(false)
const playlistOpenRef = useRef(false)
const pendingSeekRef = useRef<number | null>(null)
@@ -157,7 +200,11 @@ export function PlayerControls({
const seekApplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const seekHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSeekAtRef = useRef(0)
const playbackRateRef = useRef(normalizePlaybackRate(playbackRateProp))
const playbackRateHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastPlaybackRateAtRef = useRef(0)
const [seekHint, setSeekHint] = useState<SeekHint | null>(null)
const [playbackRateHint, setPlaybackRateHint] = useState<number | null>(null)
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
useEffect(() => {
@@ -172,6 +219,14 @@ export function PlayerControls({
subtitleMenuOpenRef.current = subtitleMenuOpen
}, [subtitleMenuOpen])
useEffect(() => {
qualityMenuOpenRef.current = qualityMenuOpen
}, [qualityMenuOpen])
useEffect(() => {
speedMenuOpenRef.current = speedMenuOpen
}, [speedMenuOpen])
useEffect(() => {
danmakuOpenRef.current = danmakuOpen
}, [danmakuOpen])
@@ -184,6 +239,10 @@ export function PlayerControls({
durationRef.current = duration
}, [duration])
useEffect(() => {
playbackRateRef.current = normalizePlaybackRate(playbackRateProp)
}, [playbackRateProp])
useEffect(() => {
setStageEl(container())
}, [container])
@@ -199,17 +258,33 @@ export function PlayerControls({
setMuted(next === 0)
}, [video, volumeProp])
// 点击控制栏外部时关闭字幕菜单
// 倍速由播放页按用户持久化;配置加载、切换剧集或切换播放源后同步到当前 video。
useEffect(() => {
if (!subtitleMenuOpen) return
const el = video()
if (!el) return
const next = normalizePlaybackRate(playbackRateProp)
if (Math.abs(el.playbackRate - next) > 0.001) {
el.playbackRate = next
}
}, [video, playbackRateProp])
// 点击控制栏外部时关闭字幕/画质/倍速菜单
useEffect(() => {
if (!subtitleMenuOpen && !qualityMenuOpen && !speedMenuOpen) return
const onDocClick = (e: MouseEvent) => {
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
setSubtitleMenuOpen(false)
}
if (qualityMenuRef.current && !qualityMenuRef.current.contains(e.target as Node)) {
setQualityMenuOpen(false)
}
if (speedMenuRef.current && !speedMenuRef.current.contains(e.target as Node)) {
setSpeedMenuOpen(false)
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [subtitleMenuOpen])
}, [subtitleMenuOpen, qualityMenuOpen, speedMenuOpen])
// 播放时 3 秒无操作自动隐藏控制栏;暂停/悬停/拖动进度条/打开菜单时保持显示。
// 监听挂在整个播放器舞台容器(data-player-stage)上,避免光标移到控制栏时因离开视频画面而误触发 mouseleave。
@@ -225,6 +300,8 @@ export function PlayerControls({
!controlsHoveredRef.current &&
!isScrubbingRef.current &&
!subtitleMenuOpenRef.current &&
!qualityMenuOpenRef.current &&
!speedMenuOpenRef.current &&
!danmakuOpenRef.current &&
!playlistOpenRef.current
) {
@@ -233,6 +310,8 @@ export function PlayerControls({
!controlsHoveredRef.current &&
!isScrubbingRef.current &&
!subtitleMenuOpenRef.current &&
!qualityMenuOpenRef.current &&
!speedMenuOpenRef.current &&
!danmakuOpenRef.current &&
!playlistOpenRef.current
) {
@@ -335,7 +414,7 @@ export function PlayerControls({
// 当悬停或菜单状态改变时,更新控制栏计时器
useEffect(() => {
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || speedMenuOpen || danmakuOpen || playlistOpen) {
onUiVisibleChange(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
} else {
@@ -345,7 +424,7 @@ export function PlayerControls({
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
}
}
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
}, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, speedMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
const togglePlay = () => {
const el = video()
@@ -354,6 +433,30 @@ export function PlayerControls({
else el.pause()
}
const changePlaybackRate = useCallback((next: number) => {
const normalized = normalizePlaybackRate(next)
playbackRateRef.current = normalized
onPlaybackRateChange?.(normalized)
setPlaybackRateHint(normalized)
if (playbackRateHintTimerRef.current) {
clearTimeout(playbackRateHintTimerRef.current)
}
playbackRateHintTimerRef.current = setTimeout(() => {
setPlaybackRateHint(null)
playbackRateHintTimerRef.current = null
}, PLAYBACK_RATE_HINT_MS)
}, [onPlaybackRateChange])
const commitPlaybackRate = useCallback(() => {
onPlaybackRateCommit?.(playbackRateRef.current)
}, [onPlaybackRateCommit])
const selectPlaybackRate = useCallback((next: number) => {
const normalized = normalizePlaybackRate(next)
changePlaybackRate(normalized)
onPlaybackRateCommit?.(normalized)
}, [changePlaybackRate, onPlaybackRateCommit])
const applyAbsoluteSeek = useCallback(
(absolute: number) => {
const el = video()
@@ -409,6 +512,7 @@ export function PlayerControls({
controlsHoveredRef.current ||
isScrubbingRef.current ||
subtitleMenuOpenRef.current ||
qualityMenuOpenRef.current ||
danmakuOpenRef.current ||
playlistOpenRef.current
) {
@@ -419,6 +523,7 @@ export function PlayerControls({
!controlsHoveredRef.current &&
!isScrubbingRef.current &&
!subtitleMenuOpenRef.current &&
!qualityMenuOpenRef.current &&
!danmakuOpenRef.current &&
!playlistOpenRef.current
) {
@@ -427,7 +532,7 @@ export function PlayerControls({
}, 3000)
}, [onUiVisibleChange, video])
const queueRelativeSeek = useCallback((delta: number) => {
const queueRelativeSeek = useCallback((delta: number, immediate: boolean) => {
const el = video()
if (!el) return
if (!seekBurstRef.current) {
@@ -462,6 +567,16 @@ export function PlayerControls({
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS)
if (immediate) {
if (seekApplyTimerRef.current) {
clearTimeout(seekApplyTimerRef.current)
seekApplyTimerRef.current = null
}
const burst = seekBurstRef.current
seekBurstRef.current = null
applySeekRef.current(burst.base + burst.delta)
return
}
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
seekApplyTimerRef.current = setTimeout(() => {
const burst = seekBurstRef.current
@@ -475,23 +590,47 @@ export function PlayerControls({
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
if (isEditableTarget(e.target)) return
if (shouldIgnorePlayerSeekShortcut(e.target)) return
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
const now = Date.now()
if (e.repeat && now - lastPlaybackRateAtRef.current < PLAYBACK_RATE_REPEAT_MS) return
lastPlaybackRateAtRef.current = now
changePlaybackRate(
stepPlaybackRate(playbackRateRef.current, e.key === 'ArrowUp' ? 1 : -1),
)
return
}
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const now = Date.now()
if (e.repeat && now - lastSeekAtRef.current < SEEK_REPEAT_MS) return
const immediate = !e.repeat && now - lastSeekAtRef.current >= SEEK_APPLY_MS
lastSeekAtRef.current = now
e.preventDefault()
queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC)
queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC, immediate)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [queueRelativeSeek])
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [changePlaybackRate, queueRelativeSeek])
useEffect(() => {
const onKeyUp = (e: KeyboardEvent) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
if (shouldIgnorePlayerSeekShortcut(e.target)) return
e.preventDefault()
commitPlaybackRate()
}
window.addEventListener('keyup', onKeyUp, true)
return () => window.removeEventListener('keyup', onKeyUp, true)
}, [commitPlaybackRate])
useEffect(() => {
return () => {
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
if (playbackRateHintTimerRef.current) clearTimeout(playbackRateHintTimerRef.current)
}
}, [])
@@ -549,6 +688,11 @@ export function PlayerControls({
(selectedSubtitle.delivery === 'webvtt' || selectedSubtitle.delivery === 'ass')
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
const selectedQualityLabel = qualities.find((quality) => quality.id === selectedQuality)?.label ?? ''
const qualityGroups = [
{ key: 'cloud', label: '115 云端', items: qualities.filter((quality) => quality.source !== 'local') },
{ key: 'local', label: '本地 HLS', items: qualities.filter((quality) => quality.source === 'local') },
].filter((group) => group.items.length > 0)
const seekOverlay = seekHint && stageEl
? createPortal(
@@ -580,9 +724,24 @@ export function PlayerControls({
)
: null
const playbackRateOverlay = playbackRateHint && stageEl
? createPortal(
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center">
<div className="flex items-center gap-2 rounded-full bg-black/65 px-5 py-3 text-white shadow-lg backdrop-blur-sm">
<Timer size={22} />
<span className="text-sm font-semibold tabular-nums">
{formatPlaybackRate(playbackRateHint)} 倍速
</span>
</div>
</div>,
stageEl,
)
: null
return (
<>
{seekOverlay}
{playbackRateOverlay}
<div
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-3 pt-14 transition-opacity duration-300 ${
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
@@ -633,6 +792,7 @@ export function PlayerControls({
<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"
data-player-seek-range="true"
min={0}
max={duration || 0}
step={0.1}
@@ -660,10 +820,120 @@ export function PlayerControls({
</button>
)}
{showQuality && qualities.length > 0 && onSelectQuality && (
<div className="relative" ref={qualityMenuRef}>
<button
onClick={() => {
setSubtitleMenuOpen(false)
setSpeedMenuOpen(false)
setQualityMenuOpen((v) => !v)
}}
className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15"
title="画质"
>
<Gauge size={18} className={qualityMenuOpen ? 'text-rose-400' : 'text-white/80'} />
<span className="hidden text-[10px] font-medium text-white/80 sm:inline">
{selectedQualityLabel || '画质'}
</span>
</button>
{qualityMenuOpen && (
<div className="absolute bottom-11 right-0 z-30 min-w-48 overflow-hidden rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
{qualityGroups.map((group) => (
<div key={group.key}>
<p className="px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/40">
{group.label}
</p>
{group.items.map((quality) => {
const current = quality.id === selectedQuality
return (
<button
key={`${quality.source}-${quality.id}`}
type="button"
onClick={() => {
onSelectQuality(quality)
setQualityMenuOpen(false)
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition ${
current
? 'text-rose-400 hover:bg-white/10'
: quality.requires_vip
? 'text-white/55 hover:bg-white/10'
: 'text-white/85 hover:bg-white/10'
}`}
title={quality.note || quality.label}
>
<span className="truncate">{quality.label}</span>
{quality.requires_transcode && (
<span className="ml-auto flex items-center gap-1 rounded bg-white/10 px-1.5 py-0.5 text-[9px] text-amber-300">
<Loader2 size={10} />
转码
</span>
)}
{quality.requires_vip && <Lock size={11} className="ml-auto text-amber-300" />}
{current && !quality.requires_transcode && (
<Check size={13} className="ml-auto text-rose-400" />
)}
</button>
)
})}
</div>
))}
</div>
)}
</div>
)}
<div className="relative" ref={speedMenuRef}>
<button
onClick={() => {
setSubtitleMenuOpen(false)
setQualityMenuOpen(false)
setSpeedMenuOpen((v) => !v)
}}
className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15"
title="播放速度(↑/↓ 调节)"
>
<Timer size={18} className={speedMenuOpen ? 'text-rose-400' : 'text-white/80'} />
<span className="text-[10px] font-semibold tabular-nums text-white/80">
{formatPlaybackRate(playbackRateProp)}
</span>
</button>
{speedMenuOpen && (
<div className="absolute bottom-11 right-0 z-30 min-w-36 overflow-hidden rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
<p className="px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/40">
播放速度
</p>
{PLAYBACK_RATE_OPTIONS.map((rate) => {
const current = normalizePlaybackRate(playbackRateProp) === rate
return (
<button
key={rate}
type="button"
onClick={() => {
selectPlaybackRate(rate)
setSpeedMenuOpen(false)
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition ${
current ? 'text-rose-400 hover:bg-white/10' : 'text-white/85 hover:bg-white/10'
}`}
>
<span className="tabular-nums">{formatPlaybackRate(rate)}</span>
{current && <Check size={13} className="ml-auto text-rose-400" />}
</button>
)
})}
</div>
)}
</div>
{subs.length > 0 && (
<div className="relative" ref={subtitleMenuRef}>
<button
onClick={() => setSubtitleMenuOpen((v) => !v)}
onClick={() => {
setQualityMenuOpen(false)
setSpeedMenuOpen(false)
setSubtitleMenuOpen((v) => !v)
}}
className="rounded-full p-1.5 transition hover:bg-white/15"
title="字幕"
>
@@ -852,4 +1122,4 @@ export function PlayerControls({
</div>
</>
)
}
}
+2 -3
View File
@@ -5,7 +5,6 @@ import type { User } from '../types'
export function useLayoutPermissions(user: User | null | undefined) {
const permissions = usePermissionStore((state) => state.permissions)
const isSuper = usePermissionStore((state) => state.isSuper)
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
@@ -17,8 +16,8 @@ export function useLayoutPermissions(user: User | null | undefined) {
const isAdmin = user?.role === 'admin'
const can = useCallback(
(key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true,
[isAdmin, isSuper, permissions],
(key: string) => isAdmin || (permissions ?? {})[key] === true,
[isAdmin, permissions],
)
return { can, isAdmin }
+8 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { useAuthStore } from '../stores/auth'
const SETTING_KEY = 'scrape.episode_images'
@@ -13,8 +14,11 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
const enabledRef = useRef(false)
const changedLocally = useRef(false)
const saveQueue = useRef(Promise.resolve())
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
useEffect(() => {
if (!isAdmin) return
let active = true
adminAPI.listSettings()
.then((settings) => {
@@ -32,9 +36,11 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
return () => {
active = false
}
}, [])
}, [isAdmin])
const setEnabled = useCallback((nextValue: SetStateAction<boolean>) => {
if (!isAdmin) return
const next = typeof nextValue === 'function' ? nextValue(enabledRef.current) : nextValue
if (enabledRef.current === next) return
changedLocally.current = true
@@ -47,7 +53,7 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
.catch(() => {
toast.error('保存每集图片设置失败')
})
}, [])
}, [isAdmin])
return [enabled, setEnabled]
}
+253
View File
@@ -0,0 +1,253 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import {
ALL_TAG_ID,
assignLibrariesToTag,
attachLibraryToTag,
dedupeLibraryTags,
loadLibraryTags,
normalizeLibraryTags,
normalizeTagName,
readCachedLibraryTags,
readSelectedTagId,
resolveSelectedTagId,
saveLibraryTags,
writeCachedLibraryTags,
writeSelectedTagId,
type LibraryTag,
} from '../utils/libraryTags'
export type UseLibraryTagsResult = {
/** 当前用户维护的标签(顺序即标签栏顺序)。 */
tags: LibraryTag[]
/** 标签数据是否仍在首次加载中。 */
loading: boolean
/** 是否有写入在途(可用于禁用重复点击)。 */
saving: boolean
/** 接口不可用(例如旧后端)时降级为本地标签。 */
loadError: boolean
/** 当前选中的标签栏:ALL_TAG_ID 表示「全部」。 */
selectedTagId: string
setSelectedTagId: (tagId: string) => void
createTag: (name: string) => Promise<string>
renameTag: (from: string, to: string) => Promise<void>
removeTag: (name: string) => Promise<void>
/** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
/** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */
assignLibrary: (libraryId: string, tagName: string) => Promise<void>
/** 批量把媒体库挂到标签下;tagName 为空表示批量移出所有标签。一次写入。 */
assignLibraries: (libraryIds: string[], tagName: string) => Promise<void>
}
/**
* 媒体库标签的用户级读写。
*
* 写入采用「先乐观更新、串行提交、失败回滚」:标签是整份替换(`PUT`),
* 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是
* 排队时的最新本地状态。接口不可用(旧后端/网络异常)时退回本地存储,
* 标签栏依旧可用,不会因为一次失败就让标签凭空消失。
*/
export function useLibraryTags(): UseLibraryTagsResult {
const [tags, setTags] = useState<LibraryTag[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [loadError, setLoadError] = useState(false)
const [selectedTagId, setSelectedTagIdState] = useState(() => readSelectedTagId())
const tagsRef = useRef<LibraryTag[]>([])
const selectedRef = useRef(selectedTagId)
const savedRef = useRef<LibraryTag[]>([])
const chainRef = useRef<Promise<unknown>>(Promise.resolve())
const pendingRef = useRef(0)
// 后端没有该接口(旧版本)时退化为仅本地存储,不再反复提示保存失败。
const localOnlyRef = useRef(false)
useEffect(() => {
tagsRef.current = tags
}, [tags])
useEffect(() => {
selectedRef.current = selectedTagId
}, [selectedTagId])
useEffect(() => {
let cancelled = false
setLoading(true)
loadLibraryTags()
.then((rows) => {
if (cancelled) return
tagsRef.current = rows
savedRef.current = rows
setTags(rows)
setLoadError(false)
})
.catch((err) => {
if (cancelled) return
// 旧后端没有 /me/library-tags:用本地缓存兜底,标签栏照常可用。
const cached = readCachedLibraryTags()
tagsRef.current = cached
savedRef.current = cached
setTags(cached)
setLoadError(true)
// 接口不存在时(旧后端)只做本地读写,避免每次编辑都弹一次保存失败。
localOnlyRef.current = isMissingEndpoint(err)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
// 选中的标签可能被删除,落回「全部」以免标签栏空白。
useEffect(() => {
const resolved = resolveSelectedTagId(tags, selectedTagId)
if (resolved !== selectedTagId) {
selectedRef.current = resolved
setSelectedTagIdState(resolved)
}
}, [tags, selectedTagId])
const setSelectedTagId = useCallback((tagId: string) => {
const next = tagId && tagId !== ALL_TAG_ID ? tagId : ALL_TAG_ID
selectedRef.current = next
writeSelectedTagId(next)
setSelectedTagIdState(next)
}, [])
const mutate = useCallback((updater: (current: LibraryTag[]) => LibraryTag[]) => {
const rollback = savedRef.current
const next = dedupeLibraryTags(normalizeLibraryTags(updater(tagsRef.current)))
tagsRef.current = next
setTags(next)
pendingRef.current += 1
setSaving(true)
const task = chainRef.current.then(async () => {
// 发送排队时的最新本地状态:链上更早的写入已经被后面的状态取代。
const snapshot = tagsRef.current
if (localOnlyRef.current) {
// 已知后端不支持标签接口:只落本地缓存,不再发请求。
if (tagsRef.current === snapshot) savedRef.current = snapshot
writeCachedLibraryTags(snapshot)
return
}
try {
const saved = await saveLibraryTags(snapshot)
savedRef.current = saved
setLoadError(false)
if (tagsRef.current === snapshot) {
tagsRef.current = saved
setTags(saved)
}
} catch (err) {
if (isMissingEndpoint(err)) {
// 旧后端不支持标签接口:本次修改保留在本地存储,标签栏照常可用。
localOnlyRef.current = true
savedRef.current = snapshot
writeCachedLibraryTags(snapshot)
setLoadError(true)
return
}
// 用最后一次成功保存的状态回滚,避免前端显示一份服务端并不存在的标签。
tagsRef.current = rollback
savedRef.current = rollback
setTags(rollback)
setLoadError(true)
toast.error('标签保存失败,请稍后重试')
}
})
chainRef.current = task.catch(() => undefined)
return task.finally(() => {
pendingRef.current = Math.max(0, pendingRef.current - 1)
if (pendingRef.current === 0) setSaving(false)
})
}, [])
const createTag = useCallback(
async (name: string) => {
const trimmed = normalizeTagName(name)
if (!trimmed) return ''
if (tagsRef.current.some((tag) => tag.name.toLowerCase() === trimmed.toLowerCase())) {
return trimmed
}
await mutate((current) => [...current, { name: trimmed, library_ids: [] }])
return tagsRef.current.some((tag) => tag.name === trimmed) ? trimmed : ''
},
[mutate],
)
const renameTag = useCallback(
async (from: string, to: string) => {
const trimmed = normalizeTagName(to)
if (!trimmed || trimmed === from) return
await mutate((current) =>
current.map((tag) => (tag.name === from ? { ...tag, name: trimmed } : tag)),
)
// 重命名后选中项要跟着换到新名字,否则标签栏会落回「全部」。
if (selectedRef.current === from) setSelectedTagId(trimmed)
},
[mutate, setSelectedTagId],
)
const removeTag = useCallback(
async (name: string) => {
await mutate((current) => current.filter((tag) => tag.name !== name))
if (selectedRef.current === name) setSelectedTagId(ALL_TAG_ID)
},
[mutate, setSelectedTagId],
)
const setTagLibraries = useCallback(
async (name: string, libraryIds: string[]) => {
await mutate((current) =>
current.map((tag) => (tag.name === name ? { ...tag, library_ids: libraryIds } : tag)),
)
},
[mutate],
)
const assignLibrary = useCallback(
async (libraryId: string, tagName: string) => {
await mutate((current) => {
if (tagName) return attachLibraryToTag(current, libraryId, tagName)
return current.map((tag) => ({
...tag,
library_ids: tag.library_ids.filter((id) => id !== libraryId),
}))
})
},
[mutate],
)
const assignLibraries = useCallback(
async (libraryIds: string[], tagName: string) => {
await mutate((current) => assignLibrariesToTag(current, libraryIds, tagName))
},
[mutate],
)
return {
tags,
loading,
saving,
loadError,
selectedTagId,
setSelectedTagId,
createTag,
renameTag,
removeTag,
setTagLibraries,
assignLibrary,
assignLibraries,
}
}
/** 判断错误是否说明后端没有这个接口(旧版本 / 反向代理未更新)。 */
function isMissingEndpoint(err: unknown): boolean {
const status = (err as { response?: { status?: number } })?.response?.status
return status === 404 || status === 405 || status === 501
}
@@ -0,0 +1,62 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import {
forgetListPosition,
normalizeListPosition,
readListPosition,
useRememberedListPosition,
writeListPosition,
} from './useListPositionMemory.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`useListPositionMemory: ${name}`)
}
check('unknown key falls back', readListPosition('unknown-key', 3) === 3)
check('missing value uses default fallback', readListPosition('unknown-key') === 1)
writeListPosition('demo', 4)
check('round trip keeps value', readListPosition('demo') === 4)
writeListPosition('demo', 9)
check('rewrite keeps latest value', readListPosition('demo') === 9)
forgetListPosition('demo')
check('forget resets to fallback', readListPosition('demo', 2) === 2)
check('normalize parses numeric string', normalizeListPosition('7', 1) === 7)
check('normalize floors decimals', normalizeListPosition(3.8, 1) === 3)
check('normalize rejects zero', normalizeListPosition(0, 2) === 2)
check('normalize rejects negatives', normalizeListPosition(-5, 2) === 2)
check('normalize rejects NaN input', normalizeListPosition('abc', 2) === 2)
check('normalize rejects infinity', normalizeListPosition(Number.POSITIVE_INFINITY, 2) === 2)
check('normalize clamps to max', normalizeListPosition(99, 1, 8) === 8)
check('normalize keeps value under max', normalizeListPosition(6, 1, 8) === 6)
// 详情页返回时列表组件会重新挂载:挂载即可读到上次页码,而不是先回第一页。
function PageProbe({ positionKey, max }: { positionKey: string; max?: number }) {
const [position] = useRememberedListPosition(positionKey, 1, max)
return createElement('span', null, String(position))
}
writeListPosition('grid:/libraries', 3)
check(
'remount restores remembered page',
renderToStaticMarkup(createElement(PageProbe, { positionKey: 'grid:/libraries' })) === '<span>3</span>',
)
check(
'remount restores independent key',
renderToStaticMarkup(createElement(PageProbe, { positionKey: 'grid:/' })) === '<span>1</span>',
)
writeListPosition('shelves', 40)
check(
'remount clamps to restore cap',
renderToStaticMarkup(createElement(PageProbe, { positionKey: 'shelves', max: 15 })) === '<span>15</span>',
)
check(
'live state can grow past restore cap',
readListPosition('shelves') === 40,
)
console.log('useListPositionMemory.test.ts ok')
+87
View File
@@ -0,0 +1,87 @@
import { useCallback, useEffect, useState } from 'react'
// 列表分页位置(媒体库入口当前页、媒体库已加载页数、货架解锁数量)需要在
// 进入详情页再返回后依然生效,所以用 sessionStorage 记录,并在内存里留一份
// 兜底缓存;同一标签页内刷新也仍然记得。
const LIST_POSITION_PREFIX = 'mebox.listpos.'
const positions = new Map<string, number>()
/**
* 详情页返回时最多自动补拉的分页数量:滚动到很深的用户重新进入列表时
* 依次补拉这些页,剩下的继续由底部哨兵按需加载,避免一次打太多请求。
*/
export const MAX_RESTORE_PAGES = 8
/** 把存储里的任意值收敛成 >= 1 的整数分页位置。 */
export function normalizeListPosition(
value: unknown,
fallback = 1,
max = Number.MAX_SAFE_INTEGER,
): number {
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric)) return fallback
const rounded = Math.floor(numeric)
if (rounded < 1) return fallback
return Math.min(rounded, max)
}
export function readListPosition(key: string, fallback = 1, max = Number.MAX_SAFE_INTEGER): number {
const cached = positions.get(key)
if (cached !== undefined) return Math.min(cached, max)
if (typeof window === 'undefined') return fallback
try {
const raw = window.sessionStorage.getItem(LIST_POSITION_PREFIX + key)
if (raw === null) return fallback
const value = normalizeListPosition(raw, fallback, max)
positions.set(key, value)
return value
} catch {
return fallback
}
}
export function writeListPosition(key: string, value: number): void {
const next = normalizeListPosition(value)
positions.set(key, next)
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(LIST_POSITION_PREFIX + key, String(next))
} catch {
// 私密模式等场景忽略存储失败,不影响分页。
}
}
export function forgetListPosition(key: string): void {
positions.delete(key)
if (typeof window === 'undefined') return
try {
window.sessionStorage.removeItem(LIST_POSITION_PREFIX + key)
} catch {
// 同上,存储不可用时无需处理。
}
}
/**
* 记住列表当前分页位置。key 需要在一份挂载生命周期内保持稳定:路由切换会
* 让列表重新挂载,因此每次挂载都能直接读到上次记录的位置(不会先闪回第一页)。
*/
export function useRememberedListPosition(
key: string,
fallback = 1,
max = Number.MAX_SAFE_INTEGER,
): [number, (next: number | ((prev: number) => number)) => void] {
const [position, setPosition] = useState(() => readListPosition(key, fallback, max))
useEffect(() => {
writeListPosition(key, position)
}, [key, position])
const remember = useCallback((next: number | ((prev: number) => number)) => {
setPosition((prev) => {
const value = typeof next === 'function' ? next(prev) : next
return normalizeListPosition(value, prev)
})
}, [])
return [position, remember]
}
+20 -22
View File
@@ -5,17 +5,17 @@ import { useAuthStore } from '../stores/auth'
/**
* usePermission hook - 检查用户是否拥有特定权限
*
*
* @param key - 权限键名
* @param options - 配置选项
* @param options.autoFetch - 是否在权限未加载时自动获取(默认 true)
* @returns boolean - 用户是否拥有该权限
*
*
* @example
* ```tsx
* function MyComponent() {
* const canEdit = usePermission('can_edit_media')
*
*
* if (canEdit) {
* return <EditButton />
* }
@@ -30,26 +30,24 @@ export function usePermission(
const { autoFetch = true } = options
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染
const hasPermission = usePermissionStore((s) => s.hasPermission)
const isSuper = usePermissionStore((s) => s.isSuper)
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
const tier = useAuthStore((state) => state.tier)
const role = useAuthStore((state) => state.user?.role)
const isAuthenticated = useAuthStore((state) => state.token !== null)
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin'
const isAdmin = role === 'admin'
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
useEffect(() => {
if (!hasSuperAccess && isAuthenticated && autoFetch) {
if (!isAdmin && isAuthenticated && autoFetch) {
const { permissions, isLoading } = usePermissionStore.getState()
if (Object.keys(permissions).length === 0 && !isLoading) {
fetchPermissions()
}
}
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated])
}, [autoFetch, fetchPermissions, isAdmin, isAuthenticated])
// 超级用户有所有权限
if (hasSuperAccess) {
// 管理员拥有全部能力;plus / is_super 不能用来展示刮削、整理、删除等管理入口
if (isAdmin) {
return true
}
@@ -63,18 +61,18 @@ export function usePermission(
/**
* usePermissions hook - 获取所有权限
*
*
* @returns 权限状态和检查函数
*
*
* @example
* ```tsx
* function MyComponent() {
* const { permissions, isSuper, check } = usePermissions()
*
*
* if (isSuper) {
* return <AdminPanel />
* }
*
*
* return (
* <div>
* {check('can_view_dashboard') && <Dashboard />}
@@ -87,15 +85,14 @@ export function usePermission(
export function usePermissions() {
// selector 订阅:只关注 permissions/isSuper/isLoading 变化
const permissions = usePermissionStore((s) => s.permissions)
const isSuper = usePermissionStore((s) => s.isSuper)
const isLoading = usePermissionStore((s) => s.isLoading)
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
const tier = useAuthStore((state) => state.tier)
const role = useAuthStore((state) => state.user?.role)
const isAuthenticated = useAuthStore((state) => state.token !== null)
const isAdmin = role === 'admin'
const check = (key: string): boolean => {
if (isSuper || tier === 'plus' || role === 'admin') {
if (isAdmin) {
return true
}
return permissions[key] === true
@@ -103,7 +100,8 @@ export function usePermissions() {
return {
permissions,
isSuper: isSuper || tier === 'plus' || role === 'admin',
// Keep the field name for callers, but only admins are treated as full-access.
isSuper: isAdmin,
isLoading,
check,
refetch: fetchPermissions,
@@ -113,10 +111,10 @@ export function usePermissions() {
/**
* usePermissionMany hook - 批量检查多个权限
*
*
* @param keys - 权限键数组
* @returns 每个权限的布尔值映射
*
*
* @example
* ```tsx
* function MyComponent() {
@@ -125,7 +123,7 @@ export function usePermissions() {
* 'can_manage_users',
* 'can_access_settings',
* ])
*
*
* return (
* <div>
* {perms['can_edit_media'] && <EditButton />}
@@ -137,7 +135,7 @@ export function usePermissions() {
*/
export function usePermissionMany(keys: string[]): Record<string, boolean> {
const { check } = usePermissions()
return keys.reduce((acc, key) => {
acc[key] = check(key)
return acc
+51
View File
@@ -0,0 +1,51 @@
import { shouldPersistScrollSample, shouldRememberScroll } from './useScrollMemory.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`useScrollMemory: ${name}`)
}
check('home remembers scroll', shouldRememberScroll('/'))
check('library list remembers scroll', shouldRememberScroll('/libraries'))
check('library detail 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'))
check(
'route transition collapse is ignored',
!shouldPersistScrollSample({
current: 0,
lastSaved: 1800,
height: 400,
lastHeight: 4200,
}),
)
check(
'intentional scroll to top is persisted',
shouldPersistScrollSample({
current: 0,
lastSaved: 1800,
height: 4200,
lastHeight: 4200,
}),
)
check(
'normal downward scroll is persisted',
shouldPersistScrollSample({
current: 1900,
lastSaved: 1800,
height: 4200,
lastHeight: 4200,
}),
)
check(
'content growth still allows persist',
shouldPersistScrollSample({
current: 1800,
lastSaved: 1800,
height: 5200,
lastHeight: 4200,
}),
)
console.log('useScrollMemory.test.ts ok')
+212
View File
@@ -0,0 +1,212 @@
import { useLayoutEffect } from 'react'
const SCROLL_STORAGE_PREFIX = 'mebox.scroll.'
const scrollPositions = new Map<string, number>()
function readScrollPosition(key: string): number {
const cached = scrollPositions.get(key)
if (cached !== undefined) return cached
if (typeof window === 'undefined') return 0
try {
const value = Number(window.sessionStorage.getItem(SCROLL_STORAGE_PREFIX + key))
if (Number.isFinite(value) && value >= 0) {
scrollPositions.set(key, value)
return value
}
} catch {
// sessionStorage 不可用时仍可使用内存缓存。
}
return 0
}
function writeScrollPosition(key: string, value: number): void {
const next = Math.max(0, Math.round(value))
scrollPositions.set(key, next)
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(SCROLL_STORAGE_PREFIX + key, String(next))
} catch {
// 私密模式等场景忽略存储失败,不影响页面滚动。
}
}
export function shouldRememberScroll(pathname: string): boolean {
return (
pathname === '/' ||
pathname === '/libraries' ||
pathname.startsWith('/library/') ||
pathname.startsWith('/media/')
)
}
/**
* 路由切换时 Outlet 内容变矮,浏览器会把共享滚动容器的 scrollTop 钳低,
* 并可能同步触发 scroll 事件。这种“假滚动”不能写入存储,否则返回时永远回到顶部。
*/
export function shouldPersistScrollSample(input: {
current: number
lastSaved: number
height: number
lastHeight: number
}): boolean {
const { current, lastSaved, height, lastHeight } = input
if (height + 1 < lastHeight && current < lastSaved) {
return false
}
return true
}
/**
* 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度,
* 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。
*/
export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
useLayoutEffect(() => {
const el = document.getElementById('app-main-scroll')
if (!el) return
if (!shouldRememberScroll(pathname)) {
el.scrollTop = 0
return
}
const key = `${userKey}:${pathname}`
const saved = readScrollPosition(key)
let restoring = saved > 0
let observer: ResizeObserver | null = null
let restoreTimer: number | null = null
let restoreFrame = 0
let restorePumpUntil = 0
let lastSaved = saved
let lastHeight = el.scrollHeight
const stopRestore = () => {
if (restoreFrame) {
window.cancelAnimationFrame(restoreFrame)
restoreFrame = 0
}
if (restoreTimer != null) {
window.clearTimeout(restoreTimer)
restoreTimer = null
}
observer?.disconnect()
observer = null
}
const finishRestore = () => {
if (!restoring) return
restoring = false
stopRestore()
lastSaved = Math.round(el.scrollTop)
lastHeight = el.scrollHeight
writeScrollPosition(key, lastSaved)
}
const tryRestore = () => {
if (!restoring) return
const max = Math.max(0, el.scrollHeight - el.clientHeight)
const target = Math.min(saved, max)
if (target > 0 && Math.abs(el.scrollTop - target) > 1) {
el.scrollTop = target
}
if (target >= saved - 1) {
finishRestore()
}
}
const pumpRestore = () => {
if (!restoring) return
tryRestore()
if (restoring && performance.now() < restorePumpUntil) {
restoreFrame = window.requestAnimationFrame(pumpRestore)
} else {
restoreFrame = 0
}
}
const saveNow = () => {
if (restoring) return
const current = Math.round(el.scrollTop)
const height = el.scrollHeight
if (
!shouldPersistScrollSample({
current,
lastSaved,
height,
lastHeight,
})
) {
lastHeight = height
return
}
lastHeight = height
if (current === lastSaved) return
lastSaved = current
writeScrollPosition(key, current)
}
const cancelRestore = () => {
if (!restoring) return
restoring = false
stopRestore()
lastSaved = Math.round(el.scrollTop)
lastHeight = el.scrollHeight
writeScrollPosition(key, lastSaved)
}
if (saved <= 0) {
el.scrollTop = 0
} else {
tryRestore()
if (restoring) {
const content = el.firstElementChild
if (typeof ResizeObserver !== 'undefined' && content instanceof HTMLElement) {
observer = new ResizeObserver(() => tryRestore())
observer.observe(content)
}
restorePumpUntil = performance.now() + 1000
restoreFrame = window.requestAnimationFrame(pumpRestore)
restoreTimer = window.setTimeout(finishRestore, 10000)
}
}
const onPointerDown = (event: PointerEvent) => {
if (event.target === el) {
cancelRestore()
}
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()
}
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('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('keydown', onKeyDown, true)
stopRestore()
// 清理时写入最后一次有效位置,避免依赖已被钳制的 el.scrollTop。
writeScrollPosition(key, lastSaved)
}
}, [pathname, userKey])
}
+2 -1
View File
@@ -430,7 +430,8 @@ video::cue(*) {
.animate-overlay-in { animation: overlay-in 0.15s ease-out both; }
.animate-drawer-in { animation: drawer-in 0.22s ease-out both; }
.animate-page-in { animation: page-in 0.25s ease-out both; }
/* 播放完成后不能保留 transform,否则长页面会成为 fixed 弹框的包含块。 */
.animate-page-in { animation: page-in 0.25s ease-out backwards; }
.animate-accordion-in { animation: accordion-in 0.18s ease-out both; }
.animate-fade-slide-in { animation: fade-slide-in 0.2s ease-out both; }
.animate-menu-in { animation: menu-in 0.15s ease-out both; }
+93 -49
View File
@@ -6,6 +6,10 @@ import type { HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
import { LibraryTagBar } from '../components/LibraryTagBar'
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
import { useLibraryTags } from '../hooks/useLibraryTags'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import { sortByPinnedIds } from '../utils/pinnedLibraries'
import { partitionPreviewIDs } from '../utils/remoteEmby'
@@ -30,6 +34,7 @@ export function HomePage() {
const [librariesLoading, setLibrariesLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(true)
const { pinnedIds } = usePinnedLibraries()
const libraryTags = useLibraryTags()
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
@@ -87,6 +92,19 @@ export function HomePage() {
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
// 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。
const effectiveTagId = libraryTags.selectedTagId
const tagTabs = useMemo(
() => buildLibraryTagTabs(sortedLibraries, libraryTags.tags),
[sortedLibraries, libraryTags.tags],
)
const taggedLibraries = useMemo(
() => filterLibrariesByTag(sortedLibraries, libraryTags.tags, effectiveTagId),
[sortedLibraries, libraryTags.tags, effectiveTagId],
)
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
@@ -151,49 +169,46 @@ export function HomePage() {
)
}, [])
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后再按批加载。
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后自行按批加载。
useEffect(() => {
if (sortedLibraries.length === 0) return
const carouselLibIds = sortedLibraries
if (taggedLibraries.length === 0) return
const carouselLibIds = taggedLibraries
.filter((l) => l.carousel_enabled === true)
.map((l) => l.id)
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
// 入口网格仍然是一次展示 20 个库;没有自定义封面的库必须在首屏
// 立即拉预览,否则会先出现占位图标再补图,体感反而更慢。
const topGridLibIds = sortedLibraries
.slice(0, 20)
.filter((l) => !l.cover_url)
.map((l) => l.id)
const topRowLibIds = taggedLibraries.slice(0, 3).map((l) => l.id)
const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
const shelfTargetSet = new Set(shelfTargets)
const gridTargets = topGridLibIds.filter((id) => !shelfTargetSet.has(id))
// 入口网格只需要 2 张封面,横向货架才需要 10 张。分开请求可以避免
// 为暂时不会出现的货架预取完整卡片窗口。
void fetchPreviews(shelfTargets, 10)
void fetchPreviews(gridTargets, 2)
}, [sortedLibraries, fetchPreviews])
}, [taggedLibraries, fetchPreviews])
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
const INITIAL_ROWS = 3
const STEP_ROWS = 2
const [visibleTargetCount, setVisibleTargetCount] = useState(INITIAL_ROWS)
const sentinelRef = useRef<HTMLDivElement | null>(null)
const sentinelRef = useRef<HTMLButtonElement | null>(null)
const userScrolledRef = useRef(false)
const revealMoreLibraries = useCallback(() => {
setVisibleTargetCount((prev) => {
if (prev >= taggedLibraries.length) return prev
return Math.min(prev + STEP_ROWS, taggedLibraries.length)
})
}, [taggedLibraries.length])
// 随 visibleTargetCount 增加,按需触发后续库的预览加载
useEffect(() => {
if (sortedLibraries.length === 0) return
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
if (taggedLibraries.length === 0) return
const currentTargets = taggedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
void fetchPreviews(currentTargets, 10)
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
}, [taggedLibraries, visibleTargetCount, fetchPreviews])
// 当前已拉取并确认有内容的媒体库行
const visibleLibraries = useMemo(() => {
return sortedLibraries
return taggedLibraries
.slice(0, visibleTargetCount)
.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0)
}, [sortedLibraries, visibleTargetCount, libraryData])
}, [taggedLibraries, visibleTargetCount, libraryData])
const hasMoreLibraries = visibleTargetCount < sortedLibraries.length
const hasMoreLibraries = visibleTargetCount < taggedLibraries.length
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库行)
useEffect(() => {
@@ -201,12 +216,11 @@ export function HomePage() {
if (!scrollParent) return
const handleCheckBottom = () => {
if (scrollParent.scrollTop <= 0) return
userScrolledRef.current = true
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
if (remaining < 600) {
setVisibleTargetCount((prev) => {
if (prev >= sortedLibraries.length) return prev
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
})
revealMoreLibraries()
}
}
@@ -218,11 +232,8 @@ export function HomePage() {
observer = new IntersectionObserver(
(entries) => {
const [entry] = entries
if (entry?.isIntersecting) {
setVisibleTargetCount((prev) => {
if (prev >= sortedLibraries.length) return prev
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
})
if (entry?.isIntersecting && userScrolledRef.current) {
revealMoreLibraries()
}
},
{
@@ -234,15 +245,13 @@ export function HomePage() {
observer.observe(sentinel)
}
handleCheckBottom()
return () => {
scrollParent.removeEventListener('scroll', handleCheckBottom)
if (observer) {
observer.disconnect()
}
}
}, [visibleLibraries.length, hasMoreLibraries, sortedLibraries.length])
}, [hasMoreLibraries, revealMoreLibraries, visibleLibraries.length])
// Quick lookup map for libraries
const libraryMap = useMemo(() => {
@@ -267,7 +276,7 @@ export function HomePage() {
// series-type libs (loaded via /series), so gate on cards instead.
const carouselItems = useMemo(() => {
const candidateMedia: Media[] = []
const effectiveSelectedIds = libraries
const effectiveSelectedIds = taggedLibraries
.filter((l) => l.carousel_enabled === true)
.map((l) => l.id)
@@ -284,7 +293,7 @@ export function HomePage() {
// Fallback to all loaded items with artwork
if (candidateMedia.length === 0) {
for (const lib of libraries) {
for (const lib of taggedLibraries) {
const data = libraryData[lib.id]
if (data) {
for (const card of data.cards) {
@@ -295,14 +304,13 @@ export function HomePage() {
}
return candidateMedia.slice(0, 10)
}, [libraries, libraryData])
}, [taggedLibraries, libraryData])
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
return <HomeLoadingState />
}
const empty =
libraries.length === 0 &&
history.length === 0
@@ -322,18 +330,49 @@ export function HomePage() {
{historyLoading && <ContinueWatchingSkeleton />}
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
{/* 3. 媒体库卡片区(每页展示 20 个媒体库) */}
{/* 3. 媒体库标签栏 + 卡片区(每页展示 20 个媒体库) */}
{sortedLibraries.length > 0 && (
<HomeLibrariesSection
libraries={sortedLibraries}
libraryData={libraryData}
libraryCounts={libraryCounts}
onNeedPreviews={fetchPreviews}
/>
<section className="space-y-6">
<LibraryTagBar
tabs={tagTabs}
selectedTagId={effectiveTagId}
onSelect={libraryTags.setSelectedTagId}
onCreate={(name) => {
void libraryTags.createTag(name)
}}
onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving}
/>
{libraryTagsOpen && (
<ManageLibraryTagsDialogView
tags={libraryTags.tags}
libraries={libraries}
saving={libraryTags.saving}
onCreate={libraryTags.createTag}
onRename={libraryTags.renameTag}
onRemove={libraryTags.removeTag}
onAssign={libraryTags.assignLibrary}
onAssignBatch={libraryTags.assignLibraries}
onClose={() => setLibraryTagsOpen(false)}
/>
)}
{taggedLibraries.length === 0 ? (
<p className="rounded-2xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-12 text-center text-sm text-[var(--app-muted)]">
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
</p>
) : (
<HomeLibrariesSection
libraries={taggedLibraries}
libraryData={libraryData}
libraryCounts={libraryCounts}
onNeedPreviews={fetchPreviews}
/>
)}
</section>
)}
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
{visibleLibraries.length > 0 && (
{(visibleLibraries.length > 0 || hasMoreLibraries) && (
<div className="space-y-10">
{visibleLibraries.map((lib) => {
const cards = libraryData[lib.id]?.cards || []
@@ -346,12 +385,17 @@ export function HomePage() {
)
})}
{hasMoreLibraries && (
<div ref={sentinelRef} className="flex h-10 w-full items-center justify-center py-2 opacity-60">
<button
ref={sentinelRef}
type="button"
onClick={revealMoreLibraries}
className="flex h-10 w-full items-center justify-center py-2 opacity-60 transition-opacity hover:opacity-100"
>
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
<span>加载更多媒体库…</span>
</div>
</div>
</button>
)}
</div>
)}
+136 -87
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { Link, useLocation } from 'react-router-dom'
import {
ArrowRight,
ChevronLeft,
@@ -9,6 +9,7 @@ import {
FolderOpen,
Library as LibraryIcon,
Music,
Pin,
Play,
PlayCircle,
Sparkles,
@@ -19,6 +20,7 @@ import {
import { imageURL } from '../api/client'
import { useInViewOnce } from '../hooks/useInViewOnce'
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
import { MediaCard } from '../components/MediaCard'
import type { HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
@@ -355,14 +357,28 @@ export function HomeLibrariesSection({
libraryData,
libraryCounts,
onNeedPreviews,
pinnedIds = [],
onTogglePin,
showAllLink = true,
title = '媒体库',
}: {
libraries: Library[]
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
libraryCounts: Record<string, number>
onNeedPreviews?: (ids: string[], limit?: number) => void
pinnedIds?: string[]
onTogglePin?: (libraryId: string) => void
showAllLink?: boolean
title?: string
}) {
const PAGE_SIZE = 20
const [currentPage, setCurrentPage] = useState(1)
// 分页位置按路由分别记忆:首页「媒体库」和 /libraries 的「媒体库入口」
// 各自保留自己的页码,进入媒体库详情再返回时不会掉回第一页。
const { pathname } = useLocation()
const [currentPage, setCurrentPage] = useRememberedListPosition(
`libraries-grid:${pathname}`,
1,
)
const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE))
const effectivePage = Math.min(currentPage, totalPages)
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
@@ -382,7 +398,7 @@ export function HomeLibrariesSection({
<div>
<div className="flex items-center gap-2">
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
媒体库
{title}
</h2>
{libraries.length > PAGE_SIZE && (
<span className="rounded-md border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[11px] font-semibold text-[var(--app-muted)]">
@@ -396,41 +412,45 @@ export function HomeLibrariesSection({
</div>
</div>
<div className="flex items-center gap-3">
{totalPages > 1 && (
<div className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1 text-xs">
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={effectivePage <= 1}
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
title="上一页"
>
<ChevronLeft size={14} />
</button>
<span className="font-mono text-xs font-semibold text-[var(--app-subtle)]">
{effectivePage} / {totalPages}
</span>
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={effectivePage >= totalPages}
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
title="下一页"
>
<ChevronRight size={14} />
</button>
</div>
)}
{(totalPages > 1 || showAllLink) && (
<div className="flex items-center gap-3">
{totalPages > 1 && (
<div className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1 text-xs">
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={effectivePage <= 1}
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
title="上一页"
>
<ChevronLeft size={14} />
</button>
<span className="font-mono text-xs font-semibold text-[var(--app-subtle)]">
{effectivePage} / {totalPages}
</span>
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={effectivePage >= totalPages}
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
title="下一页"
>
<ChevronRight size={14} />
</button>
</div>
)}
<Link
to="/libraries"
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
>
<span>全部媒体库</span>
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
{showAllLink && (
<Link
to="/libraries"
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
>
<span>全部媒体库</span>
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
</Link>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
@@ -440,6 +460,8 @@ export function HomeLibrariesSection({
library={lib}
count={libraryCounts[lib.id] ?? 0}
cards={libraryData?.[lib.id]?.cards ?? []}
pinned={pinnedIds.includes(lib.id)}
onTogglePin={onTogglePin ? () => onTogglePin(lib.id) : undefined}
onVisible={() => {
if (!lib.cover_url) {
queuePreview(lib.id)
@@ -452,73 +474,100 @@ export function HomeLibrariesSection({
)
}
function HomeLibraryCard({
export function HomeLibraryCard({
library,
count,
cards,
pinned = false,
onTogglePin,
onVisible,
}: {
library: Library
count: number
cards: SeriesCard[]
pinned?: boolean
onTogglePin?: () => void
onVisible: () => void
}) {
const ref = useInViewOnce<HTMLAnchorElement>(onVisible)
const ref = useInViewOnce<HTMLDivElement>(onVisible)
const artwork = getLibraryArtworks(library, cards)
return (
<Link
<div
ref={ref}
to={`/library/${library.id}`}
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
title={library.name}
className={`group relative h-full overflow-hidden rounded-2xl border bg-[var(--app-panel)] transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10 ${
pinned ? 'border-brand-500/60 ring-1 ring-brand-500/20' : 'border-[var(--app-border)]'
}`}
>
<div
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
}`}
<Link
to={`/library/${library.id}`}
className="flex h-full flex-col justify-between p-3"
title={library.name}
>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(
src,
version,
library.cover_url ? undefined : { maxWidth: 480, maxHeight: 320, quality: 78 },
)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(event) => {
event.currentTarget.style.visibility = 'hidden'
}}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-brand-500">
{TYPE_ICONS[library.type] || <FolderOpen size={28} />}
</div>
)}
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
{TYPE_LABELS[library.type] || '自定义'}
</div>
</div>
<div className="mt-3 flex flex-col justify-between">
<h3
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
title={library.name}
<div
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
}`}
>
{library.name}
</h3>
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
{count > 0 ? `${count} 部媒体` : '暂无条目'}
</p>
</div>
</Link>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(
src,
version,
library.cover_url ? undefined : { maxWidth: 480, maxHeight: 320, quality: 78 },
)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(event) => {
event.currentTarget.style.visibility = 'hidden'
}}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-brand-500">
{TYPE_ICONS[library.type] || <FolderOpen size={28} />}
</div>
)}
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
{TYPE_LABELS[library.type] || '自定义'}
</div>
</div>
<div className="mt-3 flex flex-col justify-between">
<h3
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
title={library.name}
>
{library.name}
</h3>
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
{count > 0 ? `${count} 部媒体` : '暂无条目'}
</p>
</div>
</Link>
{onTogglePin && (
<button
type="button"
onClick={onTogglePin}
className={`absolute left-2 top-2 z-10 rounded-lg border p-1.5 shadow-sm backdrop-blur-md transition-all duration-200 opacity-100 sm:pointer-events-none sm:opacity-0 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:focus-visible:pointer-events-auto sm:focus-visible:opacity-100 ${
pinned
? 'border-brand-400/50 bg-brand-500 text-white'
: 'border-white/20 bg-black/60 text-white hover:bg-black/75'
}`}
title={pinned ? '取消置顶' : '置顶媒体库'}
aria-label={pinned ? '取消置顶' : '置顶媒体库'}
aria-pressed={pinned}
>
<Pin size={13} className={pinned ? 'fill-current' : ''} />
</button>
)}
</div>
)
}
+75 -6
View File
@@ -2,9 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { libraryAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { LibraryTagBar } from '../components/LibraryTagBar'
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
import { useLibraryTags } from '../hooks/useLibraryTags'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import { useAuthStore } from '../stores/auth'
import {
LibrariesContent,
LibrariesEmptyState,
@@ -14,13 +18,17 @@ import type { LibraryPreview } from './librariesPageModel'
import type { Library } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
import type { LibraryTag } from '../utils/libraryTags'
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
import { partitionPreviewIDs } from '../utils/remoteEmby'
export function LibrariesPage() {
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
const libraryTags = useLibraryTags()
const [loading, setLoading] = useState(true)
const [repairing, setRepairing] = useState(false)
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
@@ -103,7 +111,7 @@ export function LibrariesPage() {
}, [])
async function handleRepairRescrape() {
if (repairing) return
if (!isAdmin || repairing) return
setRepairing(true)
setRepairMsg('')
try {
@@ -117,6 +125,7 @@ export function LibrariesPage() {
}
const handleManageLibraries = async () => {
if (!isAdmin) return
await openManageLibrariesDialog()
await loadLibraries({ force: true })
}
@@ -143,7 +152,20 @@ export function LibrariesPage() {
void togglePin(libraryId)
}, [togglePin])
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
const effectiveTagId = libraryTags.selectedTagId
const tagTabs = useMemo(
() => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags),
[sortedPreviews, libraryTags.tags],
)
const taggedPreviews = useMemo(
() => filterPreviewsByTag(sortedPreviews, libraryTags.tags, effectiveTagId),
[sortedPreviews, libraryTags.tags, effectiveTagId],
)
const taggedTotal = useMemo(
() => taggedPreviews.reduce((sum, preview) => sum + preview.total, 0),
[taggedPreviews],
)
if (loading || pinnedLoading) {
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
@@ -152,8 +174,9 @@ export function LibrariesPage() {
return (
<div className="space-y-8">
<LibrariesHeader
previewCount={previews.length}
total={total}
previewCount={taggedPreviews.length}
total={taggedTotal}
isAdmin={isAdmin}
repairMsg={repairMsg}
repairEpisodeArtwork={repairEpisodeArtwork}
repairing={repairing}
@@ -162,11 +185,40 @@ export function LibrariesPage() {
onManageLibraries={handleManageLibraries}
/>
<LibraryTagBar
tabs={tagTabs}
selectedTagId={effectiveTagId}
onSelect={libraryTags.setSelectedTagId}
onCreate={(name) => {
void libraryTags.createTag(name)
}}
onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving}
/>
{libraryTagsOpen && (
<ManageLibraryTagsDialogView
tags={libraryTags.tags}
libraries={libraries}
saving={libraryTags.saving}
onCreate={libraryTags.createTag}
onRename={libraryTags.renameTag}
onRemove={libraryTags.removeTag}
onAssign={libraryTags.assignLibrary}
onAssignBatch={libraryTags.assignLibraries}
onClose={() => setLibraryTagsOpen(false)}
/>
)}
{previews.length === 0 ? (
<LibrariesEmptyState />
<LibrariesEmptyState isAdmin={isAdmin} />
) : taggedPreviews.length === 0 ? (
<p className="rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-16 text-center text-sm text-[var(--app-muted)]">
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
</p>
) : (
<LibrariesContent
previews={sortedPreviews}
previews={taggedPreviews}
pinnedIds={pinnedIds}
onTogglePin={handleTogglePin}
onNeedPreviews={fetchPreviews}
@@ -175,3 +227,20 @@ export function LibrariesPage() {
</div>
)
}
/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/手动排序)。 */
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
if (!tagId || tagId === ALL_TAG_ID) return previews
const ordered = filterLibrariesByTag(
previews.map((preview) => ({ id: preview.library.id })),
tags,
tagId,
)
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
const out: LibraryPreview[] = []
for (const item of ordered) {
const preview = byId.get(item.id)
if (preview) out.push(preview)
}
return out
}
+120 -340
View File
@@ -1,37 +1,20 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Link } from 'react-router-dom'
import { ArrowRight, ChevronLeft, ChevronRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
import { imageURL } from '../api/client'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { MediaCard } from '../components/MediaCard'
import { useInViewOnce } from '../hooks/useInViewOnce'
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
import { seriesCardLink } from '../utils/groupSeries'
import { libraryDisplayPath } from './libraryDisplayModel'
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
import { isLibraryPinned } from '../utils/pinnedLibraries'
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
import type { LibraryPreview } from './librariesPageModel'
const TYPE_ICONS: Record<string, ReactNode> = {
movie: <Film size={18} />,
tv: <Tv size={18} />,
anime: <PlayCircle size={18} />,
variety: <Tv size={18} />,
music: <Music size={18} />,
adult: <Film size={18} />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '剧集',
anime: '动漫',
variety: '综艺',
music: '音乐',
adult: '成人',
}
// 返回媒体库列表时最多一次性解锁的货架数量:滚动到很深的位置后回来
// 仍能落到原处,同时避免一次渲染过多货架。上限之外的继续按需解锁。
const MAX_RESTORED_SHELVES = 15
export function LibrariesHeader({
previewCount,
total,
isAdmin,
repairMsg,
repairEpisodeArtwork,
repairing,
@@ -41,6 +24,7 @@ export function LibrariesHeader({
}: {
previewCount: number
total: number
isAdmin: boolean
repairMsg: string
repairEpisodeArtwork: boolean
repairing: boolean
@@ -56,49 +40,53 @@ export function LibrariesHeader({
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
</p>
</div>
<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-9 sm:h-10 text-xs sm:text-sm"
/>
<button
type="button"
onClick={onRepairRescrape}
disabled={repairing}
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 !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 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
>
管理媒体库
</button>
</div>
{isAdmin && (
<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-9 sm:h-10 text-xs sm:text-sm"
/>
<button
type="button"
onClick={onRepairRescrape}
disabled={repairing}
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 !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 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
>
管理媒体库
</button>
</div>
)}
</div>
)
}
export function LibrariesEmptyState() {
export function LibrariesEmptyState({ isAdmin = false }: { isAdmin?: boolean }) {
return (
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-sand-200 bg-white py-24 text-center">
<LibraryIcon className="mb-4 h-12 w-12 text-gray-400" />
<p className="text-sm text-ink-50">暂无媒体库,请到管理后台添加目录。</p>
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] py-24 text-center">
<LibraryIcon className="mb-4 h-12 w-12 text-[var(--app-muted)]" />
<p className="text-sm text-[var(--app-muted)]">
{isAdmin ? '暂无媒体库,请到管理后台添加目录。' : '暂无可用媒体库。'}
</p>
</div>
)
}
@@ -114,33 +102,40 @@ export function LibrariesContent({
onTogglePin: (libraryId: string) => void
onNeedPreviews?: (ids: string[], limit?: number) => void
}) {
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
// 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架,
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。
// 下方媒体库货架继续按需解锁:首屏先展示前 3 个,滚动接近底部再加载 2 个。
const INITIAL_SHELVES = 3
const STEP_SHELVES = 2
const [visibleCount, setVisibleCount] = useState(INITIAL_SHELVES)
const sentinelRef = useRef<HTMLDivElement | null>(null)
// 货架解锁数量同样跨路由记忆:进入媒体库详情再返回不会从头解锁。
const [visibleCount, setVisibleCount] = useRememberedListPosition(
'libraries-shelves',
INITIAL_SHELVES,
MAX_RESTORED_SHELVES,
)
const sentinelRef = useRef<HTMLButtonElement | null>(null)
const userScrolledRef = useRef(false)
const revealMoreShelves = useCallback(() => {
setVisibleCount((prev) => {
if (prev >= previews.length) return prev
return Math.min(prev + STEP_SHELVES, previews.length)
})
}, [previews.length, setVisibleCount])
useEffect(() => {
const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id)
const currentTargets = previews.slice(0, visibleCount).map((preview) => preview.library.id)
onNeedPreviews?.(currentTargets, 10)
}, [previews, visibleCount, onNeedPreviews])
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架)
useEffect(() => {
const scrollParent = document.getElementById('app-main-scroll')
if (!scrollParent) return
const handleCheckBottom = () => {
if (scrollParent.scrollTop <= 0) return
userScrolledRef.current = true
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
if (remaining < 600) {
setVisibleCount((prev) => {
if (prev >= previews.length) return prev
return Math.min(prev + STEP_SHELVES, previews.length)
})
revealMoreShelves()
}
}
@@ -152,11 +147,8 @@ export function LibrariesContent({
observer = new IntersectionObserver(
(entries) => {
const [entry] = entries
if (entry?.isIntersecting) {
setVisibleCount((prev) => {
if (prev >= previews.length) return prev
return Math.min(prev + STEP_SHELVES, previews.length)
})
if (entry?.isIntersecting && userScrolledRef.current) {
revealMoreShelves()
}
},
{
@@ -168,286 +160,74 @@ export function LibrariesContent({
observer.observe(sentinel)
}
handleCheckBottom()
return () => {
scrollParent.removeEventListener('scroll', handleCheckBottom)
if (observer) {
observer.disconnect()
}
if (observer) observer.disconnect()
}
}, [visibleCount, previews.length])
const visiblePreviews = useMemo(() => {
return previews.slice(0, visibleCount)
}, [previews, visibleCount])
const ENTRY_PAGE_SIZE = 20
const [entryPage, setEntryPage] = useState(1)
const totalEntryPages = Math.max(1, Math.ceil(previews.length / ENTRY_PAGE_SIZE))
const effectiveEntryPage = Math.min(entryPage, totalEntryPages)
const pagedPreviews = useMemo<LibraryPreview[]>(() => {
const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE
return previews.slice(start, start + ENTRY_PAGE_SIZE)
}, [previews, effectiveEntryPage])
// 入口网格首屏展示前 20 个库;没有自定义封面的库需要立即拉预览,否则卡片会先渲染占位图标再补图。
useEffect(() => {
const ids = pagedPreviews.filter((preview) => !preview.library.cover_url).map((preview) => preview.library.id)
if (ids.length > 0) {
onNeedPreviews?.(ids, 2)
}
}, [pagedPreviews, onNeedPreviews])
}, [revealMoreShelves, visibleCount, previews.length])
const visiblePreviews = useMemo(
() => previews.slice(0, visibleCount),
[previews, visibleCount],
)
const libraries = useMemo(() => previews.map((preview) => preview.library), [previews])
const libraryData = useMemo(
() =>
Object.fromEntries(
previews.map((preview) => [
preview.library.id,
{ cards: preview.cards, items: [], total: preview.total },
]),
),
[previews],
)
const libraryCounts = useMemo(
() =>
Object.fromEntries(
previews.map((preview) => [preview.library.id, preview.total]),
),
[previews],
)
return (
<>
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
{previews.length > ENTRY_PAGE_SIZE && (
<span className="rounded-md border border-sand-200 bg-sand-50 px-2 py-0.5 text-xs font-semibold text-sand-700">
共 {previews.length} 个 · 每页 {ENTRY_PAGE_SIZE} 个
</span>
)}
</div>
<p className="text-sm text-ink-50">
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
</p>
</div>
{totalEntryPages > 1 && (
<div className="flex items-center gap-2 rounded-xl border border-sand-200 bg-white px-2.5 py-1 text-xs shadow-sm">
<button
type="button"
onClick={() => setEntryPage((p) => Math.max(1, p - 1))}
disabled={effectiveEntryPage <= 1}
className="rounded-lg p-1 text-ink-50 hover:bg-sand-100 hover:text-ink-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-50 transition-colors"
title="上一页"
>
<ChevronLeft size={14} />
</button>
<span className="font-mono text-xs font-semibold text-ink-600">
{effectiveEntryPage} / {totalEntryPages}
</span>
<button
type="button"
onClick={() => setEntryPage((p) => Math.min(totalEntryPages, p + 1))}
disabled={effectiveEntryPage >= totalEntryPages}
className="rounded-lg p-1 text-ink-50 hover:bg-sand-100 hover:text-ink-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-50 transition-colors"
title="下一页"
>
<ChevronRight size={14} />
</button>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
{pagedPreviews.map((preview) => (
<div
key={preview.library.id}
className="animate-page-in"
>
<LibraryEntryCard
preview={preview}
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
onTogglePin={() => onTogglePin(preview.library.id)}
onVisible={() => {
if (!preview.library.cover_url) {
queuePreview(preview.library.id)
}
}}
/>
</div>
))}
</div>
</section>
<HomeLibrariesSection
libraries={libraries}
libraryData={libraryData}
libraryCounts={libraryCounts}
onNeedPreviews={onNeedPreviews}
pinnedIds={pinnedIds}
onTogglePin={onTogglePin}
showAllLink={false}
title="媒体库入口"
/>
{visiblePreviews.length > 0 && (
<section className="space-y-6">
<section className="space-y-10">
{visiblePreviews.map((preview) => (
<div
key={preview.library.id}
className="animate-page-in"
>
<LibraryShelf
preview={preview}
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
<div key={preview.library.id} className="animate-page-in">
<HomeLibraryRowSection
library={preview.library}
cards={preview.cards}
/>
</div>
))}
{visibleCount < previews.length && (
<div ref={sentinelRef} className="flex h-10 w-full items-center justify-center py-2 opacity-60">
<button
ref={sentinelRef}
type="button"
onClick={revealMoreShelves}
className="flex h-10 w-full items-center justify-center py-2 opacity-60 transition-opacity hover:opacity-100"
>
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
<span>加载更多媒体库货架…</span>
</div>
</div>
</button>
)}
</section>
)}
</>
)
}
function LibraryEntryCard({
preview,
pinned,
onTogglePin,
onVisible,
}: {
preview: LibraryPreview
pinned: boolean
onTogglePin: () => void
onVisible?: () => void
}) {
const library = preview.library
const ref = useInViewOnce<HTMLDivElement>(onVisible ?? (() => {}))
const artwork = library.cover_url
? [{ src: library.cover_url, version: library.updated_at }]
: libraryArtworkItems(preview.cards)
const displayPath = libraryDisplayPath(library.path)
return (
<div
ref={ref}
className={
'group relative flex overflow-hidden rounded-2xl sm:rounded-3xl border bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:shadow-card-hover ' +
(pinned ? 'border-brand-300 ring-1 ring-brand-100' : 'border-sand-200 hover:border-brand-200')
}
>
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onTogglePin()
}}
className={
'absolute right-2 top-2 z-10 rounded-full border p-1.5 transition ' +
(pinned
? 'border-brand-200 bg-brand-50 text-brand-600 hover:bg-brand-100'
: 'border-sand-200 bg-white/95 text-sand-500 opacity-0 hover:bg-gray-50 hover:text-brand-600 group-hover:opacity-100')
}
title={pinned ? '取消置顶' : '置顶媒体库'}
aria-label={pinned ? '取消置顶' : '置顶媒体库'}
aria-pressed={pinned}
>
<Pin size={14} className={pinned ? 'fill-current' : ''} />
</button>
<Link
to={`/library/${library.id}`}
className="flex min-w-0 flex-1"
title={library.name}
>
<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
key={`${src}-${index}`}
src={imageURL(
src,
version,
library.cover_url ? undefined : { maxWidth: 400, maxHeight: 300, quality: 78 },
)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover"
onError={(event) => { event.currentTarget.style.visibility = 'hidden' }}
/>
))
) : (
<div className="col-span-2 flex h-full items-center justify-center text-brand-500">
{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-3 sm:px-4 py-0.5 sm:py-1">
<div>
<div className="mb-1 flex items-center justify-between gap-1.5 pr-8">
<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="line-clamp-2 break-words font-display text-sm font-bold leading-snug text-ink-600 group-hover:text-brand-600 sm:text-base lg:text-lg sm:font-black"
title={library.name}
>
{library.name}
{pinned ? <span className="ml-1.5 align-middle text-[10px] font-bold text-brand-600">置顶</span> : null}
</h2>
<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="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>
</div>
)
}
function LibraryShelf({ preview, pinned }: { preview: LibraryPreview; pinned?: boolean }) {
const library = preview.library
const cards = preview.cards.slice(0, 10)
const displayPath = libraryDisplayPath(library.path)
return (
<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-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="line-clamp-2 break-words font-display text-xl sm:text-2xl font-black text-ink-600"
title={library.name}
>
{library.name}
{pinned ? <span className="ml-2 align-middle text-xs font-bold text-brand-600">置顶</span> : null}
</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 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm shrink-0">
浏览全部
<ArrowRight size={14} />
</Link>
</div>
{cards.length > 0 ? (
<div className="flex gap-4 overflow-x-auto pb-2 pr-1">
{cards.map((card) => (
<div key={card.key} className="w-[9.5rem] shrink-0 lg:w-[10rem] 2xl:w-[10.5rem]">
<MediaCard
media={card.rep}
count={card.count}
linkTo={seriesCardLink(card)}
/>
</div>
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-sand-200 bg-white px-6 py-10 text-center text-sm text-ink-50">
该目录暂无可展示内容,扫描媒体库后会出现在这里。
</div>
)}
</section>
)
}
+2 -2
View File
@@ -30,7 +30,7 @@ export function LibraryMovieActions({
}
return (
<>
<div className="hidden flex-wrap justify-end gap-1 sm:flex">
<button title="智能刮削" disabled={busy} onClick={() => onSmartScrape(media)} className={buttonClass}>
<Sparkles size={13} />
</button>
@@ -46,6 +46,6 @@ export function LibraryMovieActions({
<button title="删除" disabled={busy} onClick={() => onDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
<Trash2 size={13} />
</button>
</>
</div>
)
}
+5 -1
View File
@@ -124,6 +124,7 @@ export function LibraryPage() {
})
const {
resolvingSeries,
selectedEpisodes,
visibleEpisodes,
selectedSeriesEpisodes,
@@ -132,6 +133,7 @@ export function LibraryPage() {
handleSeriesClick,
clearSelectedSeries,
} = useLibrarySeriesSelection({
libraryID: id,
items,
seriesEpisodeItems,
isSeriesLibrary,
@@ -209,7 +211,9 @@ export function LibraryPage() {
return <>{actions}</>
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
if (loading) {
// resolvingSeries:深链目标剧集不在已加载的一页卡片里,正在按 key
// 单独解析。此时先显示加载态,避免先渲染整个媒体库列表再跳走。
if (loading || resolvingSeries) {
return (
<div className="flex items-center justify-center py-32">
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 2 }} className="flex items-center gap-3">
+342 -13
View File
@@ -4,7 +4,7 @@ import type Hls from 'hls.js'
import toast from 'react-hot-toast'
import { mediaAPI, libraryAPI } from '../api/library'
import { hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
import { cloudHlsURL, hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
import {
danmakuAPI,
type DanmakuAnime,
@@ -17,8 +17,9 @@ import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { systemAPI } from '../api/system'
import { profileAPI } from '../api/profile'
import { useAuthStore } from '../stores/auth'
import type { Media } from '../types'
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { normalizePlaybackRate } from '../utils/playbackRate'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import {
normalizeSubtitleChineseMode,
@@ -109,8 +110,13 @@ export function PlayerPage() {
const [playerVolume, setPlayerVolume] = useState(() =>
normalizePlayerVolume(authUser?.player_volume),
)
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() =>
normalizePlaybackRate(authUser?.player_playback_rate),
)
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
const playerVolumeTouchedRef = useRef(false)
const playerPlaybackRateTouchedRef = useRef(false)
const playerPlaybackRateSaveSeqRef = useRef(0)
const subtitlePreferenceTouchedRef = useRef(false)
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
const [hlsUnavailable, setHlsUnavailable] = useState(false)
@@ -124,6 +130,16 @@ export function PlayerPage() {
const [initialSeekDone, setInitialSeekDone] = useState(false)
// HLS session source offset: playlist t=0 maps to this absolute second.
const [hlsStartSec, setHlsStartSec] = useState(0)
// 统一播放能力:115 提供 direct → cloud_hls → local_hls,其它源 direct → local_hls。
const [playbackInfo, setPlaybackInfo] = useState<PlaybackInfo | null>(null)
const [hlsSource, setHlsSource] = useState<'cloud' | 'local'>('local')
const [selectedQuality, setSelectedQuality] = useState('')
const [cloudWaiting, setCloudWaiting] = useState(false)
const [cloudWaitMessage, setCloudWaitMessage] = useState('')
const [cloudWaitStartedAt, setCloudWaitStartedAt] = useState(0)
const cloudPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const cloudRetryRef = useRef(5)
const pendingSeekRef = useRef<number | null>(null)
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false)
@@ -235,6 +251,10 @@ export function PlayerPage() {
if (!playerVolumeTouchedRef.current) {
setPlayerVolume(volume)
}
const playbackRate = normalizePlaybackRate(cfg.playback_rate)
if (!playerPlaybackRateTouchedRef.current) {
setPlayerPlaybackRate(playbackRate)
}
setDanmakuEnabled(cfg.enabled)
setDanmakuOpacity(Number(cfg.opacity) || 1)
setDanmakuFontSize(Number(cfg.font_size) || 24)
@@ -245,6 +265,10 @@ export function PlayerPage() {
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_volume: volume })
}
if (!playerPlaybackRateTouchedRef.current) {
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_playback_rate: playbackRate })
}
})
.catch(() => {
// 配置读取失败时保持本地默认值,不影响播放。
@@ -279,6 +303,34 @@ export function PlayerPage() {
[setAuthUser],
)
const changePlayerPlaybackRate = useCallback((next: number) => {
playerPlaybackRateTouchedRef.current = true
setPlayerPlaybackRate(normalizePlaybackRate(next))
}, [])
const commitPlayerPlaybackRate = useCallback(
(next: number) => {
playerPlaybackRateTouchedRef.current = true
const playbackRate = normalizePlaybackRate(next)
setPlayerPlaybackRate(playbackRate)
const saveSeq = ++playerPlaybackRateSaveSeqRef.current
void danmakuAPI
.updateSettings({ playback_rate: playbackRate })
.then((cfg) => {
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
const saved = normalizePlaybackRate(cfg.playback_rate)
setPlayerPlaybackRate(saved)
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_playback_rate: saved })
})
.catch(() => {
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
toast.error('倍速保存失败,请重试')
})
},
[setAuthUser],
)
const saveDanmakuAdvanced = useCallback(
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
const patch: DanmakuSettingsPatch = {
@@ -407,6 +459,17 @@ export function PlayerPage() {
setMedia(null)
setLoadError('')
setHlsStartSec(0)
setPlaybackInfo(null)
setHlsSource('local')
setSelectedQuality('')
setCloudWaiting(false)
setCloudWaitMessage('')
setCloudWaitStartedAt(0)
pendingSeekRef.current = null
if (cloudPollTimerRef.current) {
clearTimeout(cloudPollTimerRef.current)
cloudPollTimerRef.current = null
}
setDanmakuEpisodeId(null)
setDanmakuCandidates([])
setDanmakuAlternatives([])
@@ -478,6 +541,117 @@ export function PlayerPage() {
// Depend on media.id (not the media object): refreshing duration after
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
const mediaId = media?.id
const playbackProvider = playbackInfo?.provider
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
useEffect(() => {
if (!mediaId) return
let cancelled = false
mediaAPI
.playbackInfo(mediaId)
.then((info) => {
if (cancelled) return
setPlaybackInfo(info)
cloudRetryRef.current = Math.max(3, info.transcode.retry_after_sec || 5)
setSelectedQuality((prev) => {
if (prev && findPlaybackQualityById(info, prev)) return prev
return info.default_quality || info.local_qualities?.[0]?.id || ''
})
})
.catch(() => {
if (!cancelled) setPlaybackInfo(null)
})
return () => {
cancelled = true
}
}, [mediaId])
useEffect(() => {
cloudRetryRef.current = Math.max(3, playbackInfo?.transcode.retry_after_sec || 5)
}, [playbackInfo])
const switchToLocalHLS = useCallback(
(position = 0) => {
setHlsSource('local')
setCloudWaiting(false)
setSelectedQuality((prev) => {
if (playbackInfo?.local_qualities?.some((quality) => quality.id === prev)) return prev
return defaultLocalQualityId(playbackInfo)
})
if (position > 2) setHlsStartSec(position)
setPlaybackMode('hls')
},
[playbackInfo, setPlaybackMode],
)
const startCloudTranscode = useCallback(
async (definition: number) => {
if (!mediaId) return
try {
const info = await mediaAPI.startCloudTranscode(mediaId, definition)
setPlaybackInfo(info)
if (info.transcode.state === 'ready') {
setCloudWaiting(false)
setCloudWaitMessage('')
setHlsSource('cloud')
setPlaybackMode('hls')
return
}
setCloudWaiting(true)
setCloudWaitMessage(info.transcode.message || '115 正在转码…')
setCloudWaitStartedAt(Date.now())
} catch {
switchToLocalHLS()
toast.error('115 云端转码触发失败,已切换本地转码')
}
},
[mediaId, setPlaybackMode, switchToLocalHLS],
)
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
useEffect(() => {
if (!cloudWaiting || !mediaId) return
let cancelled = false
const poll = async () => {
if (cancelled) return
if (cloudWaitStartedAt > 0 && Date.now() - cloudWaitStartedAt > 10 * 60 * 1000) {
switchToLocalHLS()
toast.error('115 云端转码等待超时,已切换本地转码')
return
}
try {
const definition = Number(selectedQuality) || undefined
const info = await mediaAPI.playbackInfo(mediaId, definition)
if (cancelled) return
setPlaybackInfo(info)
cloudRetryRef.current = Math.max(3, info.transcode.retry_after_sec || 5)
if (info.transcode.state === 'ready') {
setCloudWaiting(false)
setCloudWaitMessage('')
setHlsSource('cloud')
setPlaybackMode('hls')
return
}
if (info.transcode.state === 'unavailable') {
switchToLocalHLS()
toast.error(info.transcode.message || '115 云端转码不可用,已切换本地转码')
return
}
setCloudWaitMessage(info.transcode.message || '115 正在转码…')
cloudPollTimerRef.current = setTimeout(poll, cloudRetryRef.current * 1000)
} catch {
cloudPollTimerRef.current = setTimeout(poll, 5000)
}
}
cloudPollTimerRef.current = setTimeout(poll, cloudRetryRef.current * 1000)
return () => {
cancelled = true
if (cloudPollTimerRef.current) {
clearTimeout(cloudPollTimerRef.current)
cloudPollTimerRef.current = null
}
}
}, [cloudWaiting, cloudWaitStartedAt, mediaId, selectedQuality, setPlaybackMode, switchToLocalHLS])
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
useEffect(() => {
@@ -524,7 +698,14 @@ export function PlayerPage() {
const video = ref.current
const durationSec = currentMedia.duration_sec || 0
if (mode === 'hls') {
const url = hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream)
if (hlsSource === 'cloud' && cloudWaiting) {
teardownHls()
return
}
const url =
hlsSource === 'cloud'
? cloudHlsURL(mediaId, selectedQuality)
: hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream, selectedQuality)
void import('hls.js').then(({ default: HlsCtor }) => {
if (cancelled || !ref.current) return
if (HlsCtor.isSupported()) {
@@ -545,6 +726,15 @@ export function PlayerPage() {
hls.loadSource(url)
hls.attachMedia(video)
hls.on(HlsCtor.Events.MANIFEST_PARSED, () => {
if (pendingSeekRef.current !== null) {
const target = pendingSeekRef.current
pendingSeekRef.current = null
try {
video.currentTime = target
} catch {
// ignore
}
}
void video.play().catch(() => undefined)
// .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。
if (durationSec > 0) return
@@ -558,7 +748,19 @@ export function PlayerPage() {
})
hls.on(HlsCtor.Events.ERROR, (_, data) => {
if (data.fatal) {
if (hlsSource === 'cloud') {
const position = ref.current?.currentTime || 0
setHlsUnavailable(false)
switchToLocalHLS(position)
toast.error('115 云端播放失败,切换本地转码')
return
}
setHlsUnavailable(true)
if (playbackProvider === 'cloud115') {
setPlayerError('115 云端和本地转码均不可用,请检查账号授权或稍后重试。')
toast.error('115 云端和本地转码均不可用')
return
}
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
toast.error('HLS 转码失败,尝试切换到直接播放')
setPlaybackMode('direct')
@@ -614,31 +816,36 @@ export function PlayerPage() {
}, [
activeBurnedSubtitleStream,
clearFallbackTimer,
cloudWaiting,
hlsSource,
hlsUnavailable,
hlsStartSec,
mediaId,
mode,
playbackProvider,
selectedQuality,
setPlaybackMode,
switchToLocalHLS,
teardownHls,
])
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
// survives route navigation while the component is being torn down.
useEffect(() => {
if (!mediaId || mode !== 'hls') return
if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return
return () => {
stopHLSJob(mediaId)
}
}, [mediaId, mode])
}, [hlsSource, mediaId, mode])
// React cleanup is not guaranteed when a tab/window closes. pagehide fires
// while the document can still dispatch a keepalive request.
useEffect(() => {
if (!mediaId || mode !== 'hls') return
if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return
const stopOnPageExit = () => stopHLSJob(mediaId)
window.addEventListener('pagehide', stopOnPageExit)
return () => window.removeEventListener('pagehide', stopOnPageExit)
}, [mediaId, mode])
}, [hlsSource, mediaId, mode])
// 自动拉取已有的播放进度并恢复播放位置
useEffect(() => {
@@ -944,6 +1151,11 @@ export function PlayerPage() {
}, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen])
const isDirectStream = isDirectStreamMedia(media)
const qualityOptions =
playbackInfo?.provider === 'cloud115'
? [...(playbackInfo.cloud_qualities ?? []), ...(playbackInfo.local_qualities ?? [])]
: (playbackInfo?.local_qualities ?? [])
const showQuality = mode !== 'direct' && qualityOptions.length > 0
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
useEffect(() => {
@@ -975,9 +1187,36 @@ export function PlayerPage() {
const next = mode === 'hls' ? 'direct' : 'hls'
if (next === 'hls') {
setHlsStartSec(0)
const preferred =
playbackInfo?.provider === 'cloud115'
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
: undefined
if (preferred?.source === 'cloud') {
setHlsSource('cloud')
if (preferred.available) {
setCloudWaiting(false)
} else {
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
}
} else {
switchToLocalHLS()
}
} else {
setCloudWaiting(false)
}
setPlaybackMode(next)
}, [isDirectStream, mode, setPlaybackMode])
}, [
isDirectStream,
mode,
playbackInfo,
selectedQuality,
setPlaybackMode,
startCloudTranscode,
switchToLocalHLS,
])
const handleSeekAbsolute = useCallback(
(absoluteSec: number) => {
@@ -1019,6 +1258,10 @@ export function PlayerPage() {
toast.error('图片字幕需要开启 HLS 转码后才能显示')
return
}
if (nextTrack?.delivery === 'burn') {
// PGS 等图片字幕必须走本地 FFmpeg 烧录,不能交给云端 HLS。
switchToLocalHLS(ref.current?.currentTime || 0)
}
const burnChanged =
oldTrack?.delivery === 'burn' || nextTrack?.delivery === 'burn'
if (burnChanged && mode === 'hls' && ref.current) {
@@ -1029,7 +1272,42 @@ export function PlayerPage() {
setHlsStartSec(ref.current?.currentTime || 0)
setPlaybackMode('hls')
}
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex, switchToLocalHLS])
const selectPlaybackQuality = useCallback(
(quality: PlaybackQuality) => {
const video = ref.current
const position = video?.currentTime || 0
if (quality.source === 'original') {
setCloudWaiting(false)
setHlsSource('local')
setPlaybackMode('direct')
return
}
if (quality.source === 'cloud') {
setSelectedQuality(quality.id)
setHlsSource('cloud')
if (quality.available) {
setCloudWaiting(false)
setCloudWaitMessage('')
if (position > 2) pendingSeekRef.current = position
setPlaybackMode('hls')
} else {
setCloudWaiting(true)
setCloudWaitMessage(quality.note || `正在等待 115 转码 ${quality.label}…`)
void startCloudTranscode(Number(quality.id) || 4)
}
return
}
setSelectedQuality(quality.id)
setHlsSource('local')
setCloudWaiting(false)
setCloudWaitMessage('')
if (position > 2) setHlsStartSec(position)
setPlaybackMode('hls')
},
[setPlaybackMode, startCloudTranscode],
)
const changeSubtitleChineseMode = useCallback(
(nextMode: SubtitleChineseMode) => {
@@ -1097,18 +1375,45 @@ export function PlayerPage() {
} else if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
} else if (playbackInfo?.provider === 'cloud115') {
const preferred =
findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
if (preferred?.source === 'cloud' && preferred.available) {
setCloudWaiting(false)
setHlsSource('cloud')
setPlaybackMode('hls')
} else if (preferred?.source === 'cloud') {
setHlsSource('cloud')
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
} else {
switchToLocalHLS(current?.currentTime || 0)
}
} else if (hlsUnavailable) {
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
setPlaybackMode('hls')
switchToLocalHLS(current?.currentTime || 0)
}
}
clearFallbackTimer()
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
}, [
clearFallbackTimer,
directOnly,
hlsUnavailable,
mediaId,
mode,
playbackInfo,
selectedQuality,
setPlaybackMode,
startCloudTranscode,
switchToLocalHLS,
])
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
setSubtitlePosition(nextPosition)
@@ -1161,6 +1466,9 @@ export function PlayerPage() {
playerVolume={playerVolume}
onPlayerVolumeChange={changePlayerVolume}
onPlayerVolumeCommit={commitPlayerVolume}
playerPlaybackRate={playerPlaybackRate}
onPlayerPlaybackRateChange={changePlayerPlaybackRate}
onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
danmakuEnabled={danmakuEnabled}
danmakuOpacity={danmakuOpacity}
danmakuFontSize={danmakuFontSize}
@@ -1183,8 +1491,14 @@ export function PlayerPage() {
hasPlaylist={playlistEpisodes.length > 0}
onTogglePlaylist={togglePlaylistOpen}
knownDuration={media?.duration_sec || 0}
streamOffset={mode === 'hls' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' ? handleSeekAbsolute : undefined}
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
qualities={qualityOptions}
selectedQuality={selectedQuality}
onSelectQuality={selectPlaybackQuality}
showQuality={showQuality}
waiting={cloudWaiting}
waitingMessage={cloudWaitMessage}
playlistPanel={
<PlayerPlaylistPanel
open={playlistOpen}
@@ -1248,6 +1562,21 @@ function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
}
function findPlaybackQualityById(info: PlaybackInfo | null, id: string): PlaybackQuality | undefined {
if (!info || !id) return undefined
return [...(info.cloud_qualities ?? []), ...(info.local_qualities ?? [])].find(
(quality) => quality.id === id,
)
}
function defaultLocalQualityId(info: PlaybackInfo | null): string {
const qualities = info?.local_qualities ?? []
for (const id of ['1080', '720', '480', 'source']) {
if (qualities.some((quality) => quality.id === id)) return id
}
return qualities[0]?.id ?? '720'
}
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
const normalized = normalizeEpisodeTitle(title)
if (!normalized) return true
+36 -1
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
import { Loader2 } from 'lucide-react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
import type { Media } from '../types'
import type { Media, PlaybackQuality } from '../types'
import {
loadSubtitleChineseConverter,
type SubtitleChineseMode,
@@ -150,6 +151,9 @@ type PlayerVideoStageProps = {
playerVolume: number
onPlayerVolumeChange: (volume: number) => void
onPlayerVolumeCommit: (volume: number) => void
playerPlaybackRate: number
onPlayerPlaybackRateChange: (rate: number) => void
onPlayerPlaybackRateCommit: (rate: number) => void
danmakuEnabled: boolean
danmakuOpacity: number
danmakuFontSize: number
@@ -178,6 +182,12 @@ type PlayerVideoStageProps = {
knownDuration?: number
streamOffset?: number
onSeekAbsolute?: (seconds: number) => boolean
qualities?: PlaybackQuality[]
selectedQuality?: string
onSelectQuality?: (quality: PlaybackQuality) => void
showQuality?: boolean
waiting?: boolean
waitingMessage?: string
}
export function PlayerVideoStage({
@@ -198,6 +208,9 @@ export function PlayerVideoStage({
playerVolume,
onPlayerVolumeChange,
onPlayerVolumeCommit,
playerPlaybackRate,
onPlayerPlaybackRateChange,
onPlayerPlaybackRateCommit,
danmakuEnabled,
danmakuOpacity,
danmakuFontSize,
@@ -224,6 +237,12 @@ export function PlayerVideoStage({
knownDuration,
streamOffset,
onSeekAbsolute,
qualities = [],
selectedQuality = '',
onSelectQuality,
showQuality = false,
waiting = false,
waitingMessage = '',
}: PlayerVideoStageProps) {
const stageRef = useRef<HTMLDivElement>(null)
const [videoRatio, setVideoRatio] = useState<number | null>(null)
@@ -640,6 +659,9 @@ export function PlayerVideoStage({
volume={playerVolume}
onVolumeChange={onPlayerVolumeChange}
onVolumeCommit={onPlayerVolumeCommit}
playbackRate={playerPlaybackRate}
onPlaybackRateChange={onPlayerPlaybackRateChange}
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
uiVisible={controlsVisible}
onUiVisibleChange={setControlsVisible}
subs={subs}
@@ -666,6 +688,10 @@ export function PlayerVideoStage({
knownDuration={knownDuration}
streamOffset={streamOffset}
onSeekAbsolute={onSeekAbsolute}
qualities={qualities}
selectedQuality={selectedQuality}
onSelectQuality={onSelectQuality}
showQuality={showQuality}
/>
{danmakuPanel}
{playlistPanel}
@@ -680,6 +706,15 @@ export function PlayerVideoStage({
{playerError}
</div>
) : null}
{waiting ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
<div className="max-w-md rounded-xl border border-white/15 bg-black/85 px-6 py-5 text-center text-white shadow-2xl">
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={28} />
<p className="text-sm font-medium">{waitingMessage || '115 正在转码…'}</p>
<p className="mt-1 text-xs text-white/60">转码完成后会自动播放</p>
</div>
</div>
) : null}
</div>
)
}
+100 -65
View File
@@ -6,6 +6,7 @@ import type { Library, Media } from '../types'
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
import type { SortField, SortOrder } from '../utils/mediaSort'
import { MAX_RESTORE_PAGES, readListPosition, writeListPosition } from '../hooks/useListPositionMemory'
export function useLibraryData(
libraryID: string,
@@ -43,6 +44,84 @@ export function useLibraryData(
const modeRef = useRef<'media' | 'series'>('media')
const moreInFlightRef = useRef(false)
// 分页位置按「媒体库 + 排序」记忆:详情页返回、甚至换完排序再切回来,
// 都能回到上次加载到的页数。
const positionKey = `library:${libraryID}:${sortField}:${sortOrder}`
// 拉取并追加下一页。滚动哨兵、按钮和首屏分页恢复共用这一条路径,
// 避免两套分页逻辑各自算页码。
const appendNextPage = useCallback(async (options?: { remember?: boolean }) => {
const lib = libraryRef.current
if (!lib || !hasMoreRef.current) return false
const seq = requestSeqRef.current
const page = nextPageRef.current
try {
if (modeRef.current === 'series') {
const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib), {
sort: sortField,
order: sortOrder,
})
if (seq !== requestSeqRef.current) return false
const pageItems = data.items ?? []
setServerSeriesCards((prev) => [...prev, ...pageItems])
loadedCountRef.current += pageItems.length
totalRef.current = data.total ?? totalRef.current
nextPageRef.current = page + 1
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
} else {
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib), {
sort: sortField,
order: sortOrder,
})
if (seq !== requestSeqRef.current) return false
const pageItems = data.items ?? []
setItems((prev) => [...prev, ...pageItems])
loadedCountRef.current += pageItems.length
totalRef.current = data.total ?? totalRef.current
nextPageRef.current = page + 1
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
}
setTotal(totalRef.current)
setHasMore(hasMoreRef.current)
if (options?.remember !== false) writeListPosition(positionKey, page)
return true
} catch {
if (seq === requestSeqRef.current) {
toast.error('媒体库加载失败')
hasMoreRef.current = false
setHasMore(false)
}
return false
}
}, [libraryID, positionKey, sortField, sortOrder])
const loadMore = useCallback(async (options?: { remember?: boolean }) => {
if (moreInFlightRef.current || !hasMoreRef.current) return
moreInFlightRef.current = true
setLoadingMore(true)
try {
await appendNextPage(options)
} finally {
moreInFlightRef.current = false
setLoadingMore(false)
}
}, [appendNextPage])
const loadAll = useCallback(async () => {
const seq = requestSeqRef.current
setLoadingMore(true)
try {
while (seq === requestSeqRef.current && hasMoreRef.current) {
// 一次拉全量(random 排序前的准备)不代表用户的分页位置,不写入记忆。
await loadMore({ remember: false })
if (seq !== requestSeqRef.current || !hasMoreRef.current) break
await yieldToBrowser()
}
} finally {
if (seq === requestSeqRef.current) setLoadingMore(false)
}
}, [loadMore])
useEffect(() => {
if (!libraryID) return
const seq = ++requestSeqRef.current
@@ -86,7 +165,7 @@ export function useLibraryData(
const seriesMode = isSeriesLibraryType(lib.type)
modeRef.current = seriesMode ? 'series' : 'media'
const pageSize = pageSizeFor(lib, seriesMode)
const pageSize = pageSizeFor(lib)
try {
if (seriesMode) {
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
@@ -106,6 +185,19 @@ export function useLibraryData(
setTotal(totalRef.current)
hasMoreRef.current = loadedCountRef.current < totalRef.current
setHasMore(hasMoreRef.current)
// 按记忆的分页位置把后续页补回来:从详情页返回时直接回到上次翻到的
// 位置,而不是只剩第一页。超出上限的部分继续交给底部哨兵按需加载。
const restoreTarget = Math.min(readListPosition(positionKey, 1), MAX_RESTORE_PAGES)
while (
!cancelled &&
seq === requestSeqRef.current &&
hasMoreRef.current &&
nextPageRef.current <= restoreTarget
) {
const advanced = await appendNextPage()
if (!advanced) break
}
} catch {
if (!cancelled && seq === requestSeqRef.current) {
toast.error('媒体库加载失败')
@@ -122,7 +214,7 @@ export function useLibraryData(
cancelled = true
requestSeqRef.current += 1
}
}, [libraryID, reloadTick, sortField, sortOrder])
}, [appendNextPage, libraryID, positionKey, reloadTick, sortField, sortOrder])
useEffect(() => {
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
@@ -146,65 +238,6 @@ export function useLibraryData(
return () => { cancelled = true }
}, [libraryID, isSeriesLibrary, selectedSeries])
const loadMore = useCallback(async () => {
const lib = libraryRef.current
if (!lib || moreInFlightRef.current || !hasMoreRef.current) return
const seq = requestSeqRef.current
moreInFlightRef.current = true
setLoadingMore(true)
try {
const page = nextPageRef.current
if (modeRef.current === 'series') {
const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib, true), {
sort: sortField,
order: sortOrder,
})
if (seq !== requestSeqRef.current) return
const pageItems = data.items ?? []
setServerSeriesCards((prev) => [...prev, ...pageItems])
loadedCountRef.current += pageItems.length
totalRef.current = data.total ?? totalRef.current
nextPageRef.current = page + 1
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
} else {
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib, false), {
sort: sortField,
order: sortOrder,
})
if (seq !== requestSeqRef.current) return
const pageItems = data.items ?? []
setItems((prev) => [...prev, ...pageItems])
loadedCountRef.current += pageItems.length
totalRef.current = data.total ?? totalRef.current
nextPageRef.current = page + 1
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
}
setTotal(totalRef.current)
setHasMore(hasMoreRef.current)
} catch {
toast.error('媒体库加载失败')
hasMoreRef.current = false
setHasMore(false)
} finally {
moreInFlightRef.current = false
setLoadingMore(false)
}
}, [libraryID, sortField, sortOrder])
const loadAll = useCallback(async () => {
const seq = requestSeqRef.current
setLoadingMore(true)
try {
while (seq === requestSeqRef.current && hasMoreRef.current) {
await loadMore()
if (seq !== requestSeqRef.current || !hasMoreRef.current) break
await yieldToBrowser()
}
} finally {
if (seq === requestSeqRef.current) setLoadingMore(false)
}
}, [loadMore])
const reloadCurrentLibrary = useCallback(() => {
setReloadTick((tick) => tick + 1)
}, [])
@@ -238,9 +271,11 @@ function isSeriesLibraryType(type?: string) {
return type === 'tv' || type === 'anime' || type === 'variety'
}
function pageSizeFor(lib: Library, seriesMode: boolean): number {
if (lib.is_remote_emby) return 100
return seriesMode ? 500 : 2000
function pageSizeFor(lib: Library): number {
// 首屏保持一屏多一点,后续统一由底部哨兵按页追加。此前沿用旧的
// 500/2000 批量值,会让中小型媒体库在第一次请求时就等于全量加载。
if (lib.is_remote_emby) return 50
return 60
}
function yieldToBrowser(): Promise<void> {
@@ -251,4 +286,4 @@ function yieldToBrowser(): Promise<void> {
setTimeout(resolve, 0)
}
})
}
}
+51 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type { Media } from '../types'
import {
@@ -9,6 +9,7 @@ import {
specialSectionForMedia,
type SeriesCard,
} from '../utils/groupSeries'
import { resolveSeriesCardByKey } from '../utils/seriesCardResolve'
type SeasonEpisodes = {
season: number
@@ -16,6 +17,7 @@ type SeasonEpisodes = {
}
type UseLibrarySeriesSelectionOptions = {
libraryID: string
items: Media[]
seriesEpisodeItems: Media[]
isSeriesLibrary: boolean
@@ -32,6 +34,7 @@ type UseLibrarySeriesSelectionOptions = {
}
export function useLibrarySeriesSelection({
libraryID,
items,
seriesEpisodeItems,
isSeriesLibrary,
@@ -46,6 +49,14 @@ export function useLibrarySeriesSelection({
setSelectedSeason,
onClearSeriesState,
}: UseLibrarySeriesSelectionOptions) {
// 深链(首页最新条目 / 海报墙 ?series=<key>)指向的剧集不在已加载的
// 一页卡片里时,需要单独按 key 解析;resolvingSeries 让页面在这段时间
// 显示加载态,而不是先闪一下整个媒体库列表。
const [resolvingSeries, setResolvingSeries] = useState(false)
const [pendingSeriesKey, setPendingSeriesKey] = useState('')
// 当前 URL 请求的剧集 key(非剧集库 / 加载中 / 没带参数时为空)。
const requestedSeriesKey = !loading && isSeries ? (searchParams.get('series') ?? '') : ''
const selectedEpisodes = useMemo(() => {
const sourceItems = isSeriesLibrary ? seriesEpisodeItems : items
if (!selectedSeries || sourceItems.length === 0) return []
@@ -92,20 +103,53 @@ export function useLibrarySeriesSelection({
useEffect(() => {
if (loading) return
if (!isSeries) {
setPendingSeriesKey('')
setSelectedSeries(null)
setSelectedSeason(null)
return
}
const key = searchParams.get('series')
if (!key) {
if (!requestedSeriesKey) {
setPendingSeriesKey('')
setSelectedSeries(null)
return
}
const next = seriesCards.find((card) => card.key === key)
setSelectedSeries(next ?? null)
}, [isSeries, loading, searchParams, seriesCards, setSelectedSeason, setSelectedSeries])
const next = seriesCards.find((card) => card.key === requestedSeriesKey)
if (next) {
setPendingSeriesKey('')
setSelectedSeries(next)
return
}
// 目标剧集尚未加载到(远程库分页 / 大库分页):登记待解析 key。
setPendingSeriesKey((prev) => (prev === requestedSeriesKey ? prev : requestedSeriesKey))
}, [isSeries, loading, requestedSeriesKey, seriesCards, setSelectedSeason, setSelectedSeries])
useEffect(() => {
// pendingSeriesKey 必须仍然是 URL 上请求的 key:切换媒体库/清空参数
// 时直接丢弃待解析状态,避免把别的库的剧集解析进来。
if (!pendingSeriesKey || pendingSeriesKey !== requestedSeriesKey) {
setResolvingSeries(false)
return
}
let cancelled = false
setResolvingSeries(true)
resolveSeriesCardByKey(libraryID, pendingSeriesKey)
.then((card) => {
if (cancelled) return
setResolvingSeries(false)
setSelectedSeries(card)
})
.catch(() => {
if (cancelled) return
setResolvingSeries(false)
setSelectedSeries(null)
})
return () => {
cancelled = true
}
}, [libraryID, pendingSeriesKey, requestedSeriesKey, setSelectedSeries])
useEffect(() => {
if (!selectedSeries || selectedEpisodes.length === 0) {
@@ -135,6 +179,7 @@ export function useLibrarySeriesSelection({
}
return {
resolvingSeries,
selectedEpisodes,
visibleEpisodes,
selectedSeriesEpisodes,
+3 -2
View File
@@ -53,8 +53,9 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
hasPermission: (key: string) => {
const state = get()
// Super user (admin or plus) has all permissions
if (state.isSuper) {
// Only admins implicitly have every capability in the UI.
// Plus / is_super must not reveal scrape/organize/delete controls.
if (state.role === 'admin') {
return true
}
return (state.permissions ?? {})[key] === true
+6
View File
@@ -1,3 +1,5 @@
import type { LibraryTagSet } from './library'
export interface User {
id: string
username: string
@@ -10,10 +12,14 @@ export interface User {
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
player_volume?: number
/** 本地缓存的播放倍速,服务端配置接口会刷新。 */
player_playback_rate?: number
force_password_reset: boolean
is_active: boolean
allowed_library_ids?: string[]
pinned_library_ids?: string[]
/** 当前用户维护的媒体库标签分组。 */
library_tags?: LibraryTagSet[]
is_default_admin?: boolean
is_protected?: boolean
realtime_online?: boolean

Some files were not shown because too many files have changed in this diff Show More