mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dab9a1073a | |||
| bd3d4ad357 | |||
| bd5bfdcc10 | |||
| 73dc37fef8 | |||
| f1b7d99a33 | |||
| 638dae0c91 | |||
| 791722aec6 | |||
| 1c1da494b7 | |||
| 97b3a7bd7f | |||
| 543615a2a8 | |||
| 2b7828e1fe | |||
| 0b1fe52e24 | |||
| 2fc2c17b05 | |||
| be96acb035 | |||
| 6a72e7ab82 | |||
| 4ebd477ca2 | |||
| 0320d956ed | |||
| 95395c479a | |||
| eba7904c99 | |||
| 1171fe9464 | |||
| 06b96102c8 | |||
| 077458df31 |
+1
-1
@@ -7,7 +7,7 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反
|
||||
我们优先支持以下版本和部署方式的安全修复:
|
||||
|
||||
- 当前 `main` 分支。
|
||||
- 最新发布镜像:`ghcr.io/shukebta/mmtl:latest`。
|
||||
- 最新发布镜像:`ghcr.io/truewhile/mmtl:latest`。
|
||||
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
|
||||
|
||||
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
|
||||
|
||||
@@ -20,11 +20,7 @@
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
# 镜像二选一:
|
||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
||||
# image: shukbet/mmtl:latest
|
||||
image: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
@@ -101,7 +97,7 @@ services:
|
||||
MMTL_SEARCH_OPENSEARCH_URL: http://opensearch:9200
|
||||
MMTL_SEARCH_INDEX: mmtl_media
|
||||
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
# 默认推荐在网页里使用容器路径 /media。
|
||||
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# Docker Hub 备用:
|
||||
# image: shukbet/mmtl:latest
|
||||
image: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
container_name: mmtl
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -17,11 +17,7 @@
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
# 镜像二选一:
|
||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
||||
# image: shukbet/mmtl:latest
|
||||
image: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
@@ -92,7 +88,7 @@ services:
|
||||
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
|
||||
MMTL_CACHE_CACHE_DIR: /cache
|
||||
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
|
||||
MMTL_MEDIA_DIR: /media
|
||||
|
||||
+2
-6
@@ -16,11 +16,7 @@
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
# 镜像二选一:
|
||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
||||
# image: shukbet/mmtl:latest
|
||||
image: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
@@ -112,7 +108,7 @@ services:
|
||||
MMTL_CACHE_CACHE_DIR: /cache
|
||||
|
||||
# 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
# 路径换算配置。
|
||||
# 默认推荐在网页里使用容器路径 /media。
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -30,6 +31,9 @@ type strmAccountView struct {
|
||||
ProviderLabel string `json:"provider_label"`
|
||||
// ProxyPlay 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理(编辑回显用)。
|
||||
ProxyPlay *bool `json:"proxy_play,omitempty"`
|
||||
// EmbyLines 仅远程 Emby 挂载账号返回:多线路配置(不含凭据)。
|
||||
EmbyLines []service.EmbyRemoteLine `json:"emby_lines,omitempty"`
|
||||
EmbyActiveLine int `json:"emby_active_line,omitempty"`
|
||||
}
|
||||
|
||||
func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []strmAccountView {
|
||||
@@ -45,6 +49,10 @@ func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []st
|
||||
if proxyPlay, err := svc.EmbyRemote.ProxyPlayOf(&a); err == nil {
|
||||
view.ProxyPlay = &proxyPlay
|
||||
}
|
||||
if lines, activeLine, err := svc.EmbyRemote.LinesOf(&a); err == nil {
|
||||
view.EmbyLines = lines
|
||||
view.EmbyActiveLine = activeLine
|
||||
}
|
||||
}
|
||||
out = append(out, view)
|
||||
}
|
||||
@@ -117,13 +125,33 @@ func deleteStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
acct := svc.Strm.TestStrmAccount(c.Request.Context(), c.Param("id"))
|
||||
if acct == nil {
|
||||
id := c.Param("id")
|
||||
acct, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), id)
|
||||
if err != nil || acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
|
||||
return
|
||||
}
|
||||
views := strmAccountViews(svc, []model.StrmAccount{*acct})
|
||||
c.JSON(http.StatusOK, views[0])
|
||||
now := time.Now()
|
||||
acct.LastTestAt = &now
|
||||
if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil {
|
||||
if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil {
|
||||
acct.LastTestResult = err.Error()
|
||||
acct.LastTestOK = false
|
||||
} else {
|
||||
acct.LastTestResult = "ok"
|
||||
acct.LastTestOK = true
|
||||
}
|
||||
} else {
|
||||
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])
|
||||
return
|
||||
}
|
||||
_ = svc.Repo.StrmAccount.Update(c.Request.Context(), acct)
|
||||
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+159
-14
@@ -56,7 +56,9 @@ func (t *embyRemoteTransport) RoundTrip(req *http.Request) (*http.Response, erro
|
||||
|
||||
// EmbyRemoteConfig 是一个远程 Emby 账号的解密配置。
|
||||
type EmbyRemoteConfig struct {
|
||||
BaseURL string // http://host:8096(无需 /emby 后缀)
|
||||
BaseURL string // 当前生效线路地址(http://host:8096,无需 /emby 后缀)
|
||||
Lines []EmbyRemoteLine // 全部线路,按优先级排列
|
||||
ActiveLine int // 当前生效线路下标
|
||||
Username string
|
||||
Password string
|
||||
Token string // api_key(手动填写或自动认证获得)
|
||||
@@ -322,8 +324,14 @@ func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig
|
||||
return nil, fmt.Errorf("decode emby account config: %w", err)
|
||||
}
|
||||
}
|
||||
lines, activeLine, err := ParseEmbyRemoteLines(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &EmbyRemoteConfig{
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(raw["url"]), "/"),
|
||||
BaseURL: lines[activeLine].URL,
|
||||
Lines: lines,
|
||||
ActiveLine: activeLine,
|
||||
Username: strings.TrimSpace(raw["username"]),
|
||||
Password: r.crypto.Decrypt(raw["password"]),
|
||||
Token: firstNonEmptyStr(r.crypto.Decrypt(raw["api_key"]), r.crypto.Decrypt(raw["token"])),
|
||||
@@ -333,9 +341,6 @@ func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig
|
||||
if cfg.BaseURL == "" {
|
||||
return nil, errors.New("缺少 Emby 地址")
|
||||
}
|
||||
if !strings.HasPrefix(cfg.BaseURL, "http://") && !strings.HasPrefix(cfg.BaseURL, "https://") {
|
||||
return nil, errors.New("Emby 地址必须以 http:// 或 https:// 开头")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -366,6 +371,29 @@ func (r *EmbyRemoteService) ensureToken(ctx context.Context, acct *model.StrmAcc
|
||||
if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" {
|
||||
return errors.New("缺少 Emby 凭据:请填写 api_key 或 用户名/密码")
|
||||
}
|
||||
var lastErr error
|
||||
for _, lineIdx := range r.lineOrder(cfg) {
|
||||
lineCfg := r.withLine(cfg, lineIdx)
|
||||
err := r.ensureTokenOnLine(ctx, acct, lineCfg)
|
||||
if err == nil {
|
||||
cfg.Token = lineCfg.Token
|
||||
cfg.RemoteUserID = lineCfg.RemoteUserID
|
||||
cfg.BaseURL = lineCfg.BaseURL
|
||||
r.adoptWorkingLine(ctx, acct, cfg, lineIdx)
|
||||
return r.persistToken(ctx, acct, cfg)
|
||||
}
|
||||
lastErr = err
|
||||
if !isEmbyLineFailoverError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("Emby 线路认证失败")
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) ensureTokenOnLine(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) error {
|
||||
body, _ := json.Marshal(map[string]string{"Username": cfg.Username, "Pw": cfg.Password})
|
||||
endpoint := r.embyBase(cfg) + "/Users/AuthenticateByName"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
|
||||
@@ -399,7 +427,7 @@ func (r *EmbyRemoteService) ensureToken(ctx context.Context, acct *model.StrmAcc
|
||||
if login.User.Id != "" {
|
||||
cfg.RemoteUserID = login.User.Id
|
||||
}
|
||||
return r.persistToken(ctx, acct, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistToken 把认证得到的 token / user id 加密写回账号配置(下次请求免登录)。
|
||||
@@ -425,12 +453,36 @@ func (r *EmbyRemoteService) persistToken(ctx context.Context, acct *model.StrmAc
|
||||
}
|
||||
|
||||
// doGet 向远程 Emby 发起带 api_key 的 GET,把响应 JSON 解码到 out。
|
||||
// 401 时自动重认证一次再重试(凭据过期场景)。
|
||||
// 401 时自动重认证一次再重试(凭据过期场景)。连接失败时按线路优先级自动切换。
|
||||
func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, path string, q url.Values, out any) error {
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
var lastErr error
|
||||
for _, lineIdx := range r.lineOrder(cfg) {
|
||||
lineCfg := r.withLine(cfg, lineIdx)
|
||||
err := r.doGetOnLine(ctx, acct, cfg, lineCfg, path, q, out)
|
||||
if err == nil {
|
||||
r.adoptWorkingLine(ctx, acct, cfg, lineIdx)
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isEmbyLineFailoverError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("远程 Emby 请求失败")
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAccount, master *EmbyRemoteConfig, cfg *EmbyRemoteConfig, path string, q url.Values, out any) error {
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if strings.TrimSpace(cfg.Token) == "" {
|
||||
if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
master.Token = cfg.Token
|
||||
master.RemoteUserID = cfg.RemoteUserID
|
||||
}
|
||||
endpoint := r.embyBase(cfg) + path
|
||||
if q != nil {
|
||||
endpoint += "?" + q.Encode()
|
||||
@@ -452,8 +504,8 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount,
|
||||
return readErr
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
|
||||
// token 失效:清空后重认证重试一次。
|
||||
cfg.Token = ""
|
||||
master.Token = ""
|
||||
if acct != nil {
|
||||
raw := map[string]string{}
|
||||
_ = json.Unmarshal([]byte(acct.Config), &raw)
|
||||
@@ -783,9 +835,39 @@ func (r *EmbyRemoteService) ProxyVideoStream(ctx context.Context, w http.Respons
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return err
|
||||
var lastErr error
|
||||
for _, lineIdx := range r.lineOrder(cfg) {
|
||||
lineCfg := r.withLine(cfg, lineIdx)
|
||||
lineCfg.Token = cfg.Token
|
||||
lineCfg.RemoteUserID = cfg.RemoteUserID
|
||||
if strings.TrimSpace(lineCfg.Token) == "" {
|
||||
if err := r.ensureToken(ctx, acct, lineCfg); err != nil {
|
||||
lastErr = err
|
||||
if isEmbyLineFailoverError(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
cfg.Token = lineCfg.Token
|
||||
cfg.RemoteUserID = lineCfg.RemoteUserID
|
||||
}
|
||||
err := r.proxyVideoStreamOnLine(ctx, w, req, lineCfg, remoteID)
|
||||
if err == nil {
|
||||
r.adoptWorkingLine(ctx, acct, cfg, lineIdx)
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isEmbyLineFailoverError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("远程 Emby 视频流请求失败")
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.ResponseWriter, req *http.Request, cfg *EmbyRemoteConfig, remoteID string) error {
|
||||
endpoint := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/stream"
|
||||
q := url.Values{}
|
||||
if mediaSourceID := strings.TrimSpace(req.URL.Query().Get("MediaSourceId")); mediaSourceID != "" {
|
||||
@@ -837,9 +919,39 @@ func (r *EmbyRemoteService) ProxySubtitle(ctx context.Context, w http.ResponseWr
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return err
|
||||
var lastErr error
|
||||
for _, lineIdx := range r.lineOrder(cfg) {
|
||||
lineCfg := r.withLine(cfg, lineIdx)
|
||||
lineCfg.Token = cfg.Token
|
||||
lineCfg.RemoteUserID = cfg.RemoteUserID
|
||||
if strings.TrimSpace(lineCfg.Token) == "" {
|
||||
if err := r.ensureToken(ctx, acct, lineCfg); err != nil {
|
||||
lastErr = err
|
||||
if isEmbyLineFailoverError(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
cfg.Token = lineCfg.Token
|
||||
cfg.RemoteUserID = lineCfg.RemoteUserID
|
||||
}
|
||||
err := r.proxySubtitleOnLine(ctx, w, lineCfg, remoteID, index)
|
||||
if err == nil {
|
||||
r.adoptWorkingLine(ctx, acct, cfg, lineIdx)
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isEmbyLineFailoverError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("远程 Emby 字幕流请求失败")
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) proxySubtitleOnLine(ctx context.Context, w http.ResponseWriter, cfg *EmbyRemoteConfig, remoteID, index string) error {
|
||||
endpoint := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/Subtitles/" + url.PathEscape(index) + "/Stream"
|
||||
endpoint += "?api_key=" + url.QueryEscape(cfg.Token)
|
||||
upstream, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
@@ -902,6 +1014,39 @@ func (r *EmbyRemoteService) ProxySetFavorite(ctx context.Context, acct *model.St
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) doMutate(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, method, path string) error {
|
||||
var lastErr error
|
||||
for _, lineIdx := range r.lineOrder(cfg) {
|
||||
lineCfg := r.withLine(cfg, lineIdx)
|
||||
lineCfg.Token = cfg.Token
|
||||
lineCfg.RemoteUserID = cfg.RemoteUserID
|
||||
if strings.TrimSpace(lineCfg.Token) == "" {
|
||||
if err := r.ensureToken(ctx, acct, lineCfg); err != nil {
|
||||
lastErr = err
|
||||
if isEmbyLineFailoverError(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
cfg.Token = lineCfg.Token
|
||||
cfg.RemoteUserID = lineCfg.RemoteUserID
|
||||
}
|
||||
err := r.doMutateOnLine(ctx, lineCfg, method, path)
|
||||
if err == nil {
|
||||
r.adoptWorkingLine(ctx, acct, cfg, lineIdx)
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isEmbyLineFailoverError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("远程 Emby 状态同步失败")
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) doMutateOnLine(ctx context.Context, cfg *EmbyRemoteConfig, method, path string) error {
|
||||
endpoint := r.embyBase(cfg) + path + "?api_key=" + url.QueryEscape(cfg.Token)
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
// EmbyRemoteLine 表示同一 Emby 服务器的一条接入线路(内网/外网/CDN 等)。
|
||||
type EmbyRemoteLine struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func normalizeEmbyRemoteURL(raw string) string {
|
||||
u := strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
return ""
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// NormalizeEmbyRemoteLines 去掉空 URL,并规范每条线路地址。
|
||||
func NormalizeEmbyRemoteLines(lines []EmbyRemoteLine) []EmbyRemoteLine {
|
||||
out := make([]EmbyRemoteLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
u := normalizeEmbyRemoteURL(line.URL)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, EmbyRemoteLine{
|
||||
Name: strings.TrimSpace(line.Name),
|
||||
URL: u,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseEmbyRemoteLines 从账号配置 JSON 中解析线路列表。
|
||||
// 兼容旧版单字段 url;urls 为 JSON 数组字符串。
|
||||
func ParseEmbyRemoteLines(raw map[string]string) ([]EmbyRemoteLine, int, error) {
|
||||
active := 0
|
||||
if v := strings.TrimSpace(raw["active_line"]); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
active = n
|
||||
}
|
||||
}
|
||||
if urlsJSON := strings.TrimSpace(raw["urls"]); urlsJSON != "" {
|
||||
var lines []EmbyRemoteLine
|
||||
if err := json.Unmarshal([]byte(urlsJSON), &lines); err != nil {
|
||||
return nil, 0, fmt.Errorf("解析 Emby 线路列表: %w", err)
|
||||
}
|
||||
lines = NormalizeEmbyRemoteLines(lines)
|
||||
if len(lines) == 0 {
|
||||
return nil, 0, errors.New("至少配置一条 Emby 线路")
|
||||
}
|
||||
if active >= len(lines) {
|
||||
active = 0
|
||||
}
|
||||
return lines, active, nil
|
||||
}
|
||||
if u := normalizeEmbyRemoteURL(raw["url"]); u != "" {
|
||||
return []EmbyRemoteLine{{URL: u}}, 0, nil
|
||||
}
|
||||
return nil, 0, errors.New("缺少 Emby 地址")
|
||||
}
|
||||
|
||||
// EncodeEmbyRemoteLines 序列化线路列表,并返回兼容旧版的 primary URL。
|
||||
func EncodeEmbyRemoteLines(lines []EmbyRemoteLine) (urlsJSON string, primaryURL string, err error) {
|
||||
lines = NormalizeEmbyRemoteLines(lines)
|
||||
if len(lines) == 0 {
|
||||
return "", "", errors.New("至少配置一条 Emby 线路")
|
||||
}
|
||||
data, err := json.Marshal(lines)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return string(data), lines[0].URL, nil
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) withLine(cfg *EmbyRemoteConfig, index int) *EmbyRemoteConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *cfg
|
||||
if index >= 0 && index < len(cfg.Lines) {
|
||||
copy.BaseURL = normalizeEmbyRemoteURL(cfg.Lines[index].URL)
|
||||
copy.ActiveLine = index
|
||||
}
|
||||
return ©
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) lineOrder(cfg *EmbyRemoteConfig) []int {
|
||||
if cfg == nil || len(cfg.Lines) == 0 {
|
||||
return []int{0}
|
||||
}
|
||||
if len(cfg.Lines) == 1 {
|
||||
return []int{0}
|
||||
}
|
||||
active := cfg.ActiveLine
|
||||
if active < 0 || active >= len(cfg.Lines) {
|
||||
active = 0
|
||||
}
|
||||
order := []int{active}
|
||||
for i := range cfg.Lines {
|
||||
if i != active {
|
||||
order = append(order, i)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func isEmbyLineFailoverError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
if strings.Contains(msg, "登录失败") ||
|
||||
strings.Contains(msg, "未返回 accesstoken") ||
|
||||
strings.Contains(msg, "缺少 emby 凭据") ||
|
||||
strings.Contains(msg, "认证重试失败") {
|
||||
return false
|
||||
}
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(msg, "连接远程 emby") ||
|
||||
strings.Contains(msg, "请求远程 emby 失败") ||
|
||||
strings.Contains(msg, "dial tcp") ||
|
||||
strings.Contains(msg, "connection refused") ||
|
||||
strings.Contains(msg, "no such host") ||
|
||||
strings.Contains(msg, "timeout") ||
|
||||
strings.Contains(msg, "i/o timeout")
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) persistActiveLine(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, lineIndex int) error {
|
||||
if acct == nil || cfg == nil || lineIndex < 0 || lineIndex >= len(cfg.Lines) {
|
||||
return nil
|
||||
}
|
||||
raw := map[string]string{}
|
||||
if strings.TrimSpace(acct.Config) != "" {
|
||||
_ = json.Unmarshal([]byte(acct.Config), &raw)
|
||||
}
|
||||
raw["active_line"] = strconv.Itoa(lineIndex)
|
||||
raw["url"] = cfg.Lines[lineIndex].URL
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct.Config = string(data)
|
||||
cfg.ActiveLine = lineIndex
|
||||
cfg.BaseURL = normalizeEmbyRemoteURL(cfg.Lines[lineIndex].URL)
|
||||
return r.repo.StrmAccount.Update(ctx, acct)
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) adoptWorkingLine(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, lineIndex int) {
|
||||
if cfg == nil || lineIndex < 0 || lineIndex >= len(cfg.Lines) {
|
||||
return
|
||||
}
|
||||
if lineIndex == cfg.ActiveLine {
|
||||
return
|
||||
}
|
||||
_ = r.persistActiveLine(ctx, acct, cfg, lineIndex)
|
||||
}
|
||||
|
||||
// LinesOf 返回账号已配置的线路(供管理界面回显,不含敏感信息)。
|
||||
func (r *EmbyRemoteService) LinesOf(acct *model.StrmAccount) ([]EmbyRemoteLine, int, error) {
|
||||
raw := map[string]string{}
|
||||
if acct != nil && strings.TrimSpace(acct.Config) != "" {
|
||||
if err := json.Unmarshal([]byte(acct.Config), &raw); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
return ParseEmbyRemoteLines(raw)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseEmbyRemoteLinesLegacyURL(t *testing.T) {
|
||||
lines, active, err := ParseEmbyRemoteLines(map[string]string{
|
||||
"url": "http://192.168.1.10:8096/",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseEmbyRemoteLines() error = %v", err)
|
||||
}
|
||||
if active != 0 || len(lines) != 1 || lines[0].URL != "http://192.168.1.10:8096" {
|
||||
t.Fatalf("unexpected lines: %#v active=%d", lines, active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmbyRemoteLinesMulti(t *testing.T) {
|
||||
lines, active, err := ParseEmbyRemoteLines(map[string]string{
|
||||
"urls": `[{"name":"内网","url":"http://10.0.0.8:8096"},{"name":"外网","url":"https://emby.example.com"}]`,
|
||||
"active_line": "1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseEmbyRemoteLines() error = %v", err)
|
||||
}
|
||||
if active != 1 || len(lines) != 2 {
|
||||
t.Fatalf("unexpected lines: %#v active=%d", lines, active)
|
||||
}
|
||||
if lines[1].Name != "外网" || lines[1].URL != "https://emby.example.com" {
|
||||
t.Fatalf("unexpected second line: %#v", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeEmbyRemoteLines(t *testing.T) {
|
||||
jsonText, primary, err := EncodeEmbyRemoteLines([]EmbyRemoteLine{
|
||||
{Name: "内网", URL: "http://10.0.0.8:8096/"},
|
||||
{Name: "外网", URL: "https://emby.example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeEmbyRemoteLines() error = %v", err)
|
||||
}
|
||||
if primary != "http://10.0.0.8:8096" {
|
||||
t.Fatalf("primary = %q", primary)
|
||||
}
|
||||
lines, active, err := ParseEmbyRemoteLines(map[string]string{"urls": jsonText})
|
||||
if err != nil || active != 0 || len(lines) != 2 {
|
||||
t.Fatalf("roundtrip failed: %#v active=%d err=%v", lines, active, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyRemoteServiceLineOrder(t *testing.T) {
|
||||
svc := &EmbyRemoteService{}
|
||||
cfg := &EmbyRemoteConfig{
|
||||
Lines: []EmbyRemoteLine{
|
||||
{URL: "http://a"},
|
||||
{URL: "http://b"},
|
||||
{URL: "http://c"},
|
||||
},
|
||||
ActiveLine: 1,
|
||||
}
|
||||
order := svc.lineOrder(cfg)
|
||||
if len(order) != 3 || order[0] != 1 || order[1] != 0 || order[2] != 2 {
|
||||
t.Fatalf("unexpected order: %#v", order)
|
||||
}
|
||||
}
|
||||
@@ -273,8 +273,9 @@ func HasStrmAccountCredential(acct *model.StrmAccount) bool {
|
||||
case model.StrmProviderOpenList:
|
||||
return strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"password"`)
|
||||
case model.StrmProviderEmbyRemote:
|
||||
// 远程 Emby:接入地址 + (自动认证凭据 或 手动 api_key) 即视为已配置。
|
||||
return strings.Contains(acct.Config, `"url"`) &&
|
||||
// 远程 Emby:接入线路 + (自动认证凭据 或 手动 api_key) 即视为已配置。
|
||||
hasURL := strings.Contains(acct.Config, `"url"`) || strings.Contains(acct.Config, `"urls"`)
|
||||
return hasURL &&
|
||||
(strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"api_key"`) ||
|
||||
(strings.Contains(acct.Config, `"username"`) && strings.Contains(acct.Config, `"password"`)))
|
||||
default:
|
||||
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
SystemUpdateCommandSettingKey = "system.update.command"
|
||||
SystemUpdateComposeDirSettingKey = "system.update.compose_dir"
|
||||
|
||||
DefaultSystemUpdateImage = "ghcr.io/shukebta/mmtl:latest"
|
||||
DefaultSystemUpdateImage = "ghcr.io/truewhile/mmtl:latest"
|
||||
DefaultSystemUpdateWatchtowerImage = "containrrr/watchtower:latest"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
func TestRenderSystemUpdateCommand(t *testing.T) {
|
||||
status := SystemUpdateStatus{
|
||||
Image: "ghcr.io/shukebta/mmtl:latest",
|
||||
Image: "ghcr.io/truewhile/mmtl:latest",
|
||||
WatchtowerImage: "containrrr/watchtower:latest",
|
||||
ContainerID: "abc123def456",
|
||||
ContainerName: "mmtl",
|
||||
@@ -28,7 +28,7 @@ func TestRenderSystemUpdateCommand(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"containrrr/watchtower:latest",
|
||||
"mmtl",
|
||||
"ghcr.io/shukebta/mmtl:latest",
|
||||
"ghcr.io/truewhile/mmtl:latest",
|
||||
"abc123def456",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -65,7 +65,7 @@ func TestComposeTargetInDirMatchesMMTLCompose(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(`
|
||||
services:
|
||||
mmtl:
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
image: ghcr.io/truewhile/mmtl:latest
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,15 +7,16 @@ import {
|
||||
LayoutSidebars,
|
||||
LayoutWorkspace,
|
||||
} from './LayoutSections'
|
||||
import { MobileBottomNav } from './MobileBottomNav'
|
||||
import { isAdminEntrySearch, isPlayerRoute, shouldShowMobileBottomNav } from './layoutNavigation'
|
||||
import { useLayoutPermissions } from './useLayoutPermissions'
|
||||
import { useLayoutProfiles } from './useLayoutProfiles'
|
||||
import { useLayoutSidebar } from './useLayoutSidebar'
|
||||
import { useThemeMode } from './useThemeMode'
|
||||
|
||||
function isMediaView(pathname: string, search: string): boolean {
|
||||
const params = new URLSearchParams(search)
|
||||
// 从设置/管理后台菜单进入(携带 from=admin 或 from=settings 或 manage=1)时展示左侧栏
|
||||
if (params.get('from') === 'admin' || params.get('from') === 'settings' || params.get('manage') === '1') {
|
||||
// 从设置/管理后台菜单进入时展示左侧栏
|
||||
if (isAdminEntrySearch(search)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -24,7 +25,7 @@ function isMediaView(pathname: string, search: string): boolean {
|
||||
pathname === '/libraries' ||
|
||||
pathname.startsWith('/library') ||
|
||||
pathname.startsWith('/media') ||
|
||||
pathname.startsWith('/play') ||
|
||||
isPlayerRoute(pathname) ||
|
||||
pathname === '/favourites' ||
|
||||
pathname === '/playlists' ||
|
||||
pathname.startsWith('/playlist') ||
|
||||
@@ -50,7 +51,8 @@ export function Layout() {
|
||||
|
||||
const showSidebar = !isMediaView(location.pathname, location.search)
|
||||
const hideSearch = location.pathname.startsWith('/settings')
|
||||
const isPlayPage = location.pathname.startsWith('/play')
|
||||
const isPlayPage = isPlayerRoute(location.pathname)
|
||||
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname)
|
||||
|
||||
return (
|
||||
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
@@ -59,6 +61,7 @@ export function Layout() {
|
||||
isAdmin={permissions.isAdmin}
|
||||
can={permissions.can}
|
||||
showSidebar={showSidebar}
|
||||
sidebarVariant={showSidebar ? 'admin' : 'media'}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
{!isPlayPage && (
|
||||
@@ -72,9 +75,13 @@ export function Layout() {
|
||||
onLogout={closeProfileAndLogout}
|
||||
showSidebar={showSidebar}
|
||||
hideSearch={hideSearch}
|
||||
pathname={location.pathname}
|
||||
/>
|
||||
)}
|
||||
<LayoutWorkspace routeKey={location.pathname} />
|
||||
<LayoutWorkspace routeKey={location.pathname} showMobileBottomNav={showMobileBottomNav} />
|
||||
{showMobileBottomNav && (
|
||||
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import type { Media, PlayProfile, User } from '../types'
|
||||
import { resolveHeaderBack } from './layoutNavigation'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
import type { useLayoutProfiles } from './useLayoutProfiles'
|
||||
@@ -29,6 +30,7 @@ type LayoutHeaderProps = {
|
||||
onLogout: () => void
|
||||
showSidebar?: boolean
|
||||
hideSearch?: boolean
|
||||
pathname?: string
|
||||
}
|
||||
|
||||
export function LayoutHeader({
|
||||
@@ -41,11 +43,26 @@ export function LayoutHeader({
|
||||
onLogout,
|
||||
showSidebar,
|
||||
hideSearch,
|
||||
pathname = '',
|
||||
}: LayoutHeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
const headerBack = resolveHeaderBack(pathname)
|
||||
|
||||
return (
|
||||
<header className="relative z-30 flex h-16 shrink-0 items-center justify-between gap-2 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-3 backdrop-blur-md sm:h-20 sm:gap-4 sm:px-4 md:px-8">
|
||||
{/* Left: Mobile Menu button or Brand Logo */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* Left: back / menu / brand */}
|
||||
<div className="flex items-center gap-2 shrink-0 sm:gap-3">
|
||||
{headerBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(headerBack.to)}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--app-border)] px-2.5 py-2 text-xs font-semibold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] lg:hidden"
|
||||
title={headerBack.label}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="max-w-[4.5rem] truncate sm:max-w-none">{headerBack.label}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
onClick={onOpenMobileDrawer}
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
|
||||
@@ -53,12 +70,7 @@ export function LayoutHeader({
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${
|
||||
showSidebar ? 'lg:hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<Link to="/" className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${showSidebar ? 'lg:hidden' : 'hidden sm:flex'}`}>
|
||||
<img
|
||||
src="/brand/logo-192.png"
|
||||
alt="MMTL"
|
||||
@@ -303,6 +315,8 @@ function LayoutHeaderActions({
|
||||
onUseDefaultProfile={onUseDefaultProfile}
|
||||
onSwitchProfile={onSwitchProfile}
|
||||
onLogout={onLogout}
|
||||
themeMode={themeMode}
|
||||
onThemeChange={onThemeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||
import { isPlayerRoute } from './layoutNavigation'
|
||||
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||
|
||||
@@ -25,10 +26,12 @@ type LayoutSidebarsProps = Omit<
|
||||
> & {
|
||||
sidebar: LayoutSidebarState
|
||||
showSidebar: boolean
|
||||
sidebarVariant?: 'media' | 'admin'
|
||||
}
|
||||
|
||||
type LayoutWorkspaceProps = {
|
||||
routeKey: string
|
||||
showMobileBottomNav?: boolean
|
||||
}
|
||||
|
||||
export { LayoutHeader } from './LayoutHeaderSections'
|
||||
@@ -37,7 +40,7 @@ export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarP
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'hidden lg:flex flex-col h-full shrink-0 transition-all duration-300 ease-out',
|
||||
'hidden lg:flex min-h-0 h-full shrink-0 flex-col transition-all duration-300 ease-out',
|
||||
isSidebarOpen ? 'w-64' : 'w-20',
|
||||
)}
|
||||
>
|
||||
@@ -63,7 +66,7 @@ export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileS
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
|
||||
className="relative flex w-64 max-w-xs flex-col h-full z-10 shadow-xl"
|
||||
className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
@@ -78,6 +81,7 @@ export function LayoutSidebars({
|
||||
isAdmin,
|
||||
can,
|
||||
showSidebar,
|
||||
sidebarVariant = 'admin',
|
||||
}: LayoutSidebarsProps) {
|
||||
const content = (
|
||||
<LayoutSidebarContent
|
||||
@@ -87,6 +91,7 @@ export function LayoutSidebars({
|
||||
can={can}
|
||||
onToggleSidebar={sidebar.toggleSidebar}
|
||||
onCloseMobileDrawer={() => sidebar.setIsMobileDrawerOpen(false)}
|
||||
variant={sidebarVariant}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -116,8 +121,12 @@ export function LayoutSidebars({
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
||||
if (routeKey.startsWith('/play')) {
|
||||
export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: LayoutWorkspaceProps) {
|
||||
const bottomPad = showMobileBottomNav
|
||||
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
||||
: ''
|
||||
|
||||
if (isPlayerRoute(routeKey)) {
|
||||
return (
|
||||
<main className="flex flex-1 h-full w-full overflow-hidden">
|
||||
<RouteErrorBoundary>
|
||||
@@ -128,7 +137,7 @@ export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
|
||||
<main className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowLeft, Menu, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { LAYOUT_NAV_ITEMS, type LayoutNavItem } from './layoutNavigation'
|
||||
|
||||
import {
|
||||
LAYOUT_NAV_ITEMS,
|
||||
type LayoutNavItem,
|
||||
} from './layoutNavigation'
|
||||
import { SidebarLink } from './LayoutSidebarNav'
|
||||
|
||||
export type LayoutSidebarContentProps = {
|
||||
@@ -12,6 +16,7 @@ export type LayoutSidebarContentProps = {
|
||||
can: (key: string) => boolean
|
||||
onToggleSidebar: () => void
|
||||
onCloseMobileDrawer: () => void
|
||||
variant?: 'media' | 'admin'
|
||||
}
|
||||
|
||||
export function LayoutSidebarContent({
|
||||
@@ -21,18 +26,24 @@ export function LayoutSidebarContent({
|
||||
can,
|
||||
onToggleSidebar,
|
||||
onCloseMobileDrawer,
|
||||
variant = 'admin',
|
||||
}: LayoutSidebarContentProps) {
|
||||
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
|
||||
const visibleItems = visibleSidebarItems({ isAdmin, can })
|
||||
const adminItems = visibleSidebarItems({ isAdmin, can, items: LAYOUT_NAV_ITEMS })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||
<div className="flex h-full min-h-0 flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||
<LayoutSidebarHeader
|
||||
sidebarExpanded={sidebarExpanded}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onCloseMobileDrawer={onCloseMobileDrawer}
|
||||
/>
|
||||
<LayoutSidebarNav items={visibleItems} sidebarExpanded={sidebarExpanded} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-hide">
|
||||
{/* 窄屏媒体浏览抽屉不展示 MEDIA_NAV_ITEMS:底部导航与用户菜单已覆盖 */}
|
||||
{variant !== 'media' || adminItems.length > 0 ? (
|
||||
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} />
|
||||
) : null}
|
||||
</div>
|
||||
<LayoutSidebarHomeBack sidebarExpanded={sidebarExpanded} />
|
||||
</div>
|
||||
)
|
||||
@@ -41,8 +52,9 @@ export function LayoutSidebarContent({
|
||||
function visibleSidebarItems({
|
||||
isAdmin,
|
||||
can,
|
||||
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'>): LayoutNavItem[] {
|
||||
return LAYOUT_NAV_ITEMS.filter(
|
||||
items,
|
||||
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'> & { items: LayoutNavItem[] }): LayoutNavItem[] {
|
||||
return items.filter(
|
||||
(item) => (!item.adminOnly || isAdmin) && (!item.permission || can(item.permission)),
|
||||
)
|
||||
}
|
||||
@@ -57,7 +69,7 @@ function LayoutSidebarHeader({
|
||||
onCloseMobileDrawer: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-20 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<div className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/brand/logo-192.png"
|
||||
@@ -92,7 +104,7 @@ function LayoutSidebarNav({
|
||||
sidebarExpanded: boolean
|
||||
}) {
|
||||
return (
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-1 scrollbar-hide">
|
||||
<nav className="px-4 py-5 space-y-1">
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon
|
||||
return (
|
||||
@@ -112,7 +124,7 @@ function LayoutSidebarNav({
|
||||
|
||||
function LayoutSidebarHomeBack({ sidebarExpanded }: { sidebarExpanded: boolean }) {
|
||||
return (
|
||||
<div className="border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<div className="shrink-0 border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<Link
|
||||
to="/"
|
||||
className={clsx(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { isSidebarLinkActive } from './layoutNavigation'
|
||||
|
||||
type SidebarGroupProps = {
|
||||
id: string
|
||||
icon: ReactNode
|
||||
@@ -79,44 +81,41 @@ type SidebarLinkProps = {
|
||||
}
|
||||
|
||||
export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
|
||||
const location = useLocation()
|
||||
const isActive = isSidebarLinkActive(to, location.pathname, location.search, end)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
<Link
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'relative flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 group',
|
||||
child && 'py-2.5 text-[13px]',
|
||||
isActive
|
||||
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
|
||||
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={clsx(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center transition-transform duration-300 group-hover:scale-110',
|
||||
isActive ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={clsx(
|
||||
'relative flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 group',
|
||||
child && 'py-2.5 text-[13px]',
|
||||
isActive
|
||||
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
|
||||
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
)}
|
||||
</NavLink>
|
||||
>
|
||||
<span className={clsx(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center transition-transform duration-300 group-hover:scale-110',
|
||||
isActive ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { PlayProfile } from '../types'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import type { ThemeMode } from './useThemeMode'
|
||||
|
||||
type MenuPosition = {
|
||||
top: number
|
||||
right: number
|
||||
}
|
||||
|
||||
type LayoutUser = {
|
||||
username?: string
|
||||
@@ -22,6 +30,8 @@ type LayoutUserMenuProps = {
|
||||
onUseDefaultProfile: () => void
|
||||
onSwitchProfile: (profile: PlayProfile) => void
|
||||
onLogout: () => void
|
||||
themeMode?: ThemeMode
|
||||
onThemeChange?: (mode: ThemeMode) => void
|
||||
}
|
||||
|
||||
export function LayoutUserMenu({
|
||||
@@ -35,74 +45,95 @@ export function LayoutUserMenu({
|
||||
onUseDefaultProfile,
|
||||
onSwitchProfile,
|
||||
onLogout,
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
}: LayoutUserMenuProps) {
|
||||
const location = useLocation()
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const lastLocationRef = useRef(`${location.pathname}${location.search}`)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const onCloseRef = useRef(onClose)
|
||||
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)
|
||||
|
||||
onCloseRef.current = onClose
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current
|
||||
if (!trigger) return
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
setMenuPosition({
|
||||
top: rect.bottom + 12,
|
||||
right: Math.max(8, window.innerWidth - rect.right),
|
||||
})
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
setMenuPosition(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
updateMenuPosition()
|
||||
window.addEventListener('resize', updateMenuPosition)
|
||||
window.addEventListener('scroll', updateMenuPosition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateMenuPosition)
|
||||
window.removeEventListener('scroll', updateMenuPosition, true)
|
||||
}
|
||||
}, [isOpen, updateMenuPosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const root = rootRef.current
|
||||
const target = event.target
|
||||
if (!root || !(target instanceof Node) || root.contains(target)) return
|
||||
onClose()
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
if (event.key === 'Escape') onCloseRef.current()
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const nextLocation = `${location.pathname}${location.search}`
|
||||
if (lastLocationRef.current === nextLocation) return
|
||||
lastLocationRef.current = nextLocation
|
||||
if (isOpen) onClose()
|
||||
}, [isOpen, location.pathname, location.search, onClose])
|
||||
onCloseRef.current()
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative" data-testid="layout-user-menu">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="menu"
|
||||
className="flex items-center gap-2.5 rounded-full border border-[var(--app-border)] p-1 pr-3 transition-all hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#111827] to-[#1f2937] font-display text-xs font-bold text-white shadow-sm">
|
||||
{user?.username?.slice(0, 2).toUpperCase() || 'US'}
|
||||
</div>
|
||||
<div className="hidden text-left md:block">
|
||||
<p className="text-xs font-bold leading-none text-[var(--app-text)]">{user?.username}</p>
|
||||
<p className="mt-0.5 text-[9px] font-bold uppercase leading-none tracking-wider text-[var(--app-muted)]">
|
||||
{activeProfile ? `Profile: ${activeProfile.name}` : user?.role}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-[var(--app-muted)]" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
const menuPortal = isOpen && menuPosition && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="layout-user-menu-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="fixed inset-0 z-[120]"
|
||||
aria-hidden="true"
|
||||
onPointerDown={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
key="layout-user-menu-panel"
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
role="menu"
|
||||
className="absolute right-0 z-50 mt-3 w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
|
||||
style={{ top: menuPosition.top, right: menuPosition.right }}
|
||||
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onClick={onClose} />
|
||||
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onClick={onClose} />
|
||||
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onClick={onClose} />
|
||||
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onClick={onClose} />
|
||||
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onClick={onClose} />
|
||||
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
|
||||
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onNavigate={onClose} />
|
||||
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onNavigate={onClose} />
|
||||
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onNavigate={onClose} />
|
||||
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onNavigate={onClose} />
|
||||
{themeMode && onThemeChange ? (
|
||||
<div className="px-3 py-2 sm:hidden">
|
||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
主题
|
||||
</p>
|
||||
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
@@ -128,18 +159,50 @@ export function LayoutUserMenu({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<UserMenuLink to="/play-profiles" icon={<UserCog size={16} />} label="管理观影 Profile" onClick={onClose} />
|
||||
<UserMenuLink
|
||||
to="/play-profiles"
|
||||
icon={<UserCog size={16} />}
|
||||
label="管理观影 Profile"
|
||||
onNavigate={onClose}
|
||||
/>
|
||||
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
||||
<button
|
||||
onClick={onLogout}
|
||||
onClick={() => {
|
||||
onClose()
|
||||
onLogout()
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-red-500 transition-colors hover:bg-[var(--app-danger-soft)]"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>安全登出系统</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="relative z-[122]" data-testid="layout-user-menu">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="menu"
|
||||
className="relative z-[122] flex items-center gap-2.5 rounded-full border border-[var(--app-border)] p-1 pr-3 transition-all hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#111827] to-[#1f2937] font-display text-xs font-bold text-white shadow-sm">
|
||||
{user?.username?.slice(0, 2).toUpperCase() || 'US'}
|
||||
</div>
|
||||
<div className="hidden text-left md:block">
|
||||
<p className="text-xs font-bold leading-none text-[var(--app-text)]">{user?.username}</p>
|
||||
<p className="mt-0.5 text-[9px] font-bold uppercase leading-none tracking-wider text-[var(--app-muted)]">
|
||||
{activeProfile ? `Profile: ${activeProfile.name}` : user?.role}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-[var(--app-muted)]" />
|
||||
</button>
|
||||
{menuPortal}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -148,17 +211,17 @@ function UserMenuLink({
|
||||
to,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
onNavigate,
|
||||
}: {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
icon: ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
onClick={onNavigate}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Heart } from 'lucide-react'
|
||||
|
||||
type MediaFavouriteButtonProps = {
|
||||
favourite: boolean
|
||||
onToggle: () => void
|
||||
disabled?: boolean
|
||||
variant?: 'compact' | 'inline'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MediaFavouriteButton({
|
||||
favourite,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
variant = 'inline',
|
||||
className = '',
|
||||
}: MediaFavouriteButtonProps) {
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={favourite ? '取消收藏' : '加入收藏'}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
className={
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 shadow-sm backdrop-blur transition hover:bg-red-50 disabled:opacity-50 ' +
|
||||
(favourite ? 'text-red-500' : 'text-gray-700 hover:text-red-500') +
|
||||
(className ? ` ${className}` : '')
|
||||
}
|
||||
>
|
||||
<Heart size={13} fill={favourite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={
|
||||
'btn-outline gap-2 ' +
|
||||
(favourite
|
||||
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
|
||||
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50') +
|
||||
(className ? ` ${className}` : '')
|
||||
}
|
||||
>
|
||||
<Heart size={14} fill={favourite ? 'currentColor' : 'none'} />
|
||||
<span>{favourite ? '取消收藏' : '加入收藏'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import { Menu } from 'lucide-react'
|
||||
|
||||
import { MOBILE_BOTTOM_NAV_ITEMS } from './layoutNavigation'
|
||||
|
||||
type MobileBottomNavProps = {
|
||||
onOpenMenu: () => void
|
||||
}
|
||||
|
||||
export function MobileBottomNav({ onOpenMenu }: MobileBottomNavProps) {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--app-border)] bg-[var(--app-panel)]/95 backdrop-blur-md lg:hidden"
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
aria-label="主导航"
|
||||
>
|
||||
<div className="mx-auto flex h-14 max-w-lg items-stretch justify-around px-1">
|
||||
{MOBILE_BOTTOM_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = item.match
|
||||
? item.match(location.pathname)
|
||||
: item.end
|
||||
? location.pathname === item.to
|
||||
: location.pathname === item.to || location.pathname.startsWith(`${item.to}/`)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={clsx(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1 text-[10px] font-semibold transition-colors',
|
||||
active ? 'text-brand-600' : 'text-[var(--app-muted)] hover:text-[var(--app-text)]',
|
||||
)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={active ? 2.4 : 2} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMenu}
|
||||
className="flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1 text-[10px] font-semibold text-[var(--app-muted)] transition-colors hover:text-[var(--app-text)]"
|
||||
aria-label="打开更多菜单"
|
||||
>
|
||||
<Menu size={18} />
|
||||
<span>更多</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type PageBackButtonProps = {
|
||||
to?: string
|
||||
label?: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function PageBackButton({
|
||||
to,
|
||||
label = '返回',
|
||||
onClick,
|
||||
className,
|
||||
compact = false,
|
||||
}: PageBackButtonProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
return
|
||||
}
|
||||
if (to) {
|
||||
navigate(to)
|
||||
return
|
||||
}
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2 text-sm font-semibold text-[var(--app-subtle)] shadow-sm transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
compact && 'px-2.5 py-1.5 text-xs',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ArrowLeft size={compact ? 15 : 16} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PageBackButton } from './PageBackButton'
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string
|
||||
description?: string
|
||||
backTo?: string
|
||||
backLabel?: string
|
||||
onBack?: () => void
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
backTo,
|
||||
backLabel,
|
||||
onBack,
|
||||
actions,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
const showBack = Boolean(backTo || onBack)
|
||||
|
||||
return (
|
||||
<header className={className ?? 'space-y-3'}>
|
||||
{showBack && <PageBackButton to={backTo} label={backLabel} onClick={onBack} />}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">{title}</h1>
|
||||
{description ? <p className="mt-0.5 text-xs text-ink-50 sm:text-sm">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Clock,
|
||||
FileOutput,
|
||||
FolderOpen,
|
||||
Heart,
|
||||
Home,
|
||||
Library,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
Settings,
|
||||
Tv,
|
||||
User,
|
||||
@@ -19,6 +23,20 @@ export type LayoutNavItem = {
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
export type HeaderBackTarget = {
|
||||
to: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type MobileBottomNavItem = {
|
||||
to: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
end?: boolean
|
||||
match?: (pathname: string) => boolean
|
||||
}
|
||||
|
||||
/** Desktop admin sidebar and admin mobile drawer. */
|
||||
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/profile', label: '个人资料', icon: User },
|
||||
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
|
||||
@@ -26,8 +44,136 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||
// Emby 挂载(设置区):远程 Emby 媒体库挂载
|
||||
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
|
||||
// STRM 管理(设置区):网盘目录生成 strm 与元数据同步
|
||||
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
|
||||
]
|
||||
]
|
||||
|
||||
/** Media browsing links for the mobile drawer when browsing libraries. */
|
||||
export const MEDIA_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/', label: '首页', icon: Home, end: true },
|
||||
{ to: '/libraries', label: '媒体库', icon: Library },
|
||||
{ to: '/favourites', label: '我的收藏', icon: Heart },
|
||||
{ to: '/playlists', label: '播放列表', icon: ListMusic },
|
||||
{ to: '/history', label: '观看历史', icon: Clock },
|
||||
]
|
||||
|
||||
export const MOBILE_BOTTOM_NAV_ITEMS: MobileBottomNavItem[] = [
|
||||
{ to: '/', label: '首页', icon: Home, end: true },
|
||||
{
|
||||
to: '/libraries',
|
||||
label: '媒体库',
|
||||
icon: Library,
|
||||
match: (p) => p === '/libraries' || p.startsWith('/library'),
|
||||
},
|
||||
{ to: '/favourites', label: '收藏', icon: Heart },
|
||||
{
|
||||
to: '/playlists',
|
||||
label: '列表',
|
||||
icon: ListMusic,
|
||||
match: (p) => p === '/playlists' || p.startsWith('/playlist'),
|
||||
},
|
||||
]
|
||||
|
||||
const ROOT_MEDIA_PATHS = new Set(['/', '/libraries', '/favourites', '/playlists', '/history'])
|
||||
|
||||
/** True only for the fullscreen player route (`/play` or `/play/:id`), not `/playlists`. */
|
||||
export function isPlayerRoute(pathname: string): boolean {
|
||||
return pathname === '/play' || pathname.startsWith('/play/')
|
||||
}
|
||||
|
||||
export function isRootMediaPath(pathname: string): boolean {
|
||||
return ROOT_MEDIA_PATHS.has(pathname)
|
||||
}
|
||||
|
||||
/** Admin-entry query used to open the management sidebar on library pages. */
|
||||
export function isAdminEntrySearch(search: string): boolean {
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
return (
|
||||
params.get('from') === 'admin' ||
|
||||
params.get('from') === 'settings' ||
|
||||
params.get('manage') === '1'
|
||||
)
|
||||
}
|
||||
|
||||
export function isLibraryPath(pathname: string): boolean {
|
||||
return pathname === '/libraries' || pathname.startsWith('/library')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar active state must honor query strings. NavLink only matches pathname,
|
||||
* so `/libraries` and `/libraries?from=admin` would otherwise both highlight.
|
||||
*/
|
||||
export function isSidebarLinkActive(
|
||||
to: string,
|
||||
pathname: string,
|
||||
search: string,
|
||||
end?: boolean,
|
||||
): boolean {
|
||||
const [toPath, toQuery = ''] = to.split('?')
|
||||
const toIsAdminLibrary = toPath === '/libraries' && isAdminEntrySearch(toQuery)
|
||||
const toIsMediaLibrary = toPath === '/libraries' && !toIsAdminLibrary
|
||||
|
||||
if (toIsAdminLibrary) {
|
||||
return isLibraryPath(pathname) && isAdminEntrySearch(search)
|
||||
}
|
||||
if (toIsMediaLibrary) {
|
||||
return isLibraryPath(pathname) && !isAdminEntrySearch(search)
|
||||
}
|
||||
|
||||
return end ? pathname === toPath : pathname === toPath || pathname.startsWith(`${toPath}/`)
|
||||
}
|
||||
|
||||
export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
||||
if (pathname.startsWith('/library/')) {
|
||||
return { to: '/libraries', label: '媒体库' }
|
||||
}
|
||||
if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
|
||||
return { to: '/', label: '首页' }
|
||||
}
|
||||
if (pathname.startsWith('/playlist/')) {
|
||||
return { to: '/playlists', label: '播放列表' }
|
||||
}
|
||||
if (pathname.startsWith('/media/')) {
|
||||
return null
|
||||
}
|
||||
if (pathname === '/scraper/queue') {
|
||||
return { to: '/queue', label: '任务队列' }
|
||||
}
|
||||
if (pathname === '/strm/downloads' || pathname === '/strm/uploads') {
|
||||
return { to: '/strm', label: 'STRM 管理' }
|
||||
}
|
||||
if (
|
||||
pathname === '/profile' ||
|
||||
pathname === '/dlna' ||
|
||||
pathname === '/play-profiles' ||
|
||||
pathname === '/poster-wall'
|
||||
) {
|
||||
return { to: '/', label: '首页' }
|
||||
}
|
||||
if (
|
||||
pathname === '/settings' ||
|
||||
pathname === '/admin' ||
|
||||
pathname === '/files' ||
|
||||
pathname === '/queue' ||
|
||||
pathname === '/emby-mount' ||
|
||||
pathname === '/strm'
|
||||
) {
|
||||
return { to: '/', label: '首页' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function shouldShowMobileBottomNav(pathname: string): boolean {
|
||||
if (isPlayerRoute(pathname)) return false
|
||||
return (
|
||||
pathname === '/' ||
|
||||
pathname === '/libraries' ||
|
||||
pathname.startsWith('/library') ||
|
||||
pathname.startsWith('/media') ||
|
||||
pathname === '/favourites' ||
|
||||
pathname === '/playlists' ||
|
||||
pathname.startsWith('/playlist') ||
|
||||
pathname === '/history' ||
|
||||
pathname === '/poster-wall'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { playbackAPI } from '../api/playback'
|
||||
|
||||
export function useFavourites() {
|
||||
const [favouriteIDs, setFavouriteIDs] = useState<Set<string>>(() => new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
playbackAPI
|
||||
.listFavourites()
|
||||
.then((items) => {
|
||||
if (!cancelled) {
|
||||
setFavouriteIDs(new Set((items ?? []).map((item) => item.id)))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) toast.error('加载收藏状态失败')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isFavourite = useCallback((mediaID: string) => favouriteIDs.has(mediaID), [favouriteIDs])
|
||||
|
||||
const toggleFavourite = useCallback(async (mediaID: string) => {
|
||||
try {
|
||||
const state = await playbackAPI.toggleFavourite(mediaID)
|
||||
setFavouriteIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (state) next.add(mediaID)
|
||||
else next.delete(mediaID)
|
||||
return next
|
||||
})
|
||||
toast.success(state ? '已加入我的收藏' : '已取消收藏')
|
||||
return state
|
||||
} catch {
|
||||
toast.error('收藏操作失败')
|
||||
return favouriteIDs.has(mediaID)
|
||||
}
|
||||
}, [favouriteIDs])
|
||||
|
||||
return { isFavourite, toggleFavourite, loading }
|
||||
}
|
||||
@@ -18,7 +18,6 @@ type AutoOrganizeSettingsPanelProps = {
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
running: boolean
|
||||
moveKeepsSeeding: boolean
|
||||
onRefresh: () => void
|
||||
onSave: () => void
|
||||
onRunNow: () => void
|
||||
@@ -40,7 +39,6 @@ export function AutoOrganizeSettingsPanel({
|
||||
loading,
|
||||
saving,
|
||||
running,
|
||||
moveKeepsSeeding,
|
||||
onRefresh,
|
||||
onSave,
|
||||
onRunNow,
|
||||
@@ -108,7 +106,6 @@ export function AutoOrganizeSettingsPanel({
|
||||
<AutoOrganizeBasicTab
|
||||
config={config}
|
||||
currentDir={currentDir}
|
||||
moveKeepsSeeding={moveKeepsSeeding}
|
||||
onConfigChange={onConfigChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -13,11 +13,9 @@ type AutoOrganizeTabProps = {
|
||||
export function AutoOrganizeBasicTab({
|
||||
config,
|
||||
currentDir,
|
||||
moveKeepsSeeding,
|
||||
onConfigChange,
|
||||
}: AutoOrganizeTabProps & {
|
||||
currentDir: string
|
||||
moveKeepsSeeding: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -68,7 +66,7 @@ export function AutoOrganizeBasicTab({
|
||||
onChange={(event) => onConfigChange('transferMode', event.target.value)}
|
||||
>
|
||||
<option value="hardlink">硬链接</option>
|
||||
<option value="move">移动(关闭保种才会移动)</option>
|
||||
<option value="move">移动</option>
|
||||
<option value="copy">复制</option>
|
||||
<option value="symlink">软链接</option>
|
||||
</select>
|
||||
@@ -85,20 +83,10 @@ export function AutoOrganizeBasicTab({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{moveKeepsSeeding && (
|
||||
<div className="rounded-xl border border-orange-300 bg-orange-50 px-3 py-2 text-xs text-orange-700">
|
||||
当前同时选择了“移动”和“保种”。为避免 qB 做种源文件被删除,后端会实际使用硬链接;Docker / NAS
|
||||
多挂载或不同子卷下可能报 invalid cross-device link。需要真正移动时请关闭“保种”,需要保种但硬链接失败时请选择“复制”。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<BooleanSetting config={config} settingKey="enabled" label="整理源目录定时自动整理" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="afterDownload" label="qB 下载完成后自动整理" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="downloadSmartClassify" label="下载器智能分类" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="smartClassify" label="智能分类到子库" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="autoAddLibrary" label="自动注册目的地媒体库" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="keepSeeding" label="保种" onConfigChange={onConfigChange} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,12 @@ import type { StrmAccount } from '../types/strm'
|
||||
import type { EmbyMount, RemoteEmbyView } from '../types/emby'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import {
|
||||
defaultEmbyRemoteLines,
|
||||
encodeEmbyRemoteConfigLines,
|
||||
normalizeEmbyRemoteLines,
|
||||
type EmbyRemoteLine,
|
||||
} from '../utils/embyRemoteLines'
|
||||
|
||||
const inputCls = 'input-base w-full'
|
||||
|
||||
@@ -40,21 +46,50 @@ function AccountDialog({
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(existing?.name ?? '')
|
||||
const [url, setUrl] = useState('')
|
||||
const [lines, setLines] = useState<EmbyRemoteLine[]>(() => defaultEmbyRemoteLines(existing?.emby_lines))
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [enabled, setEnabled] = useState(existing?.enabled ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const canSave = () => url !== '' && (token !== '' || password !== '')
|
||||
const normalizedLines = normalizeEmbyRemoteLines(lines)
|
||||
const canSave = () => normalizedLines.length > 0 && (token !== '' || password !== '' || !!existing?.has_credential)
|
||||
|
||||
const updateLine = (index: number, patch: Partial<EmbyRemoteLine>) => {
|
||||
setLines((prev) => prev.map((line, i) => (i === index ? { ...line, ...patch } : line)))
|
||||
}
|
||||
|
||||
const addLine = () => {
|
||||
setLines((prev) => [...prev, { name: `线路 ${prev.length + 1}`, url: '' }])
|
||||
}
|
||||
|
||||
const removeLine = (index: number) => {
|
||||
setLines((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)))
|
||||
}
|
||||
|
||||
const moveLine = (index: number, direction: 'up' | 'down') => {
|
||||
setLines((prev) => {
|
||||
const target = direction === 'up' ? index - 1 : index + 1
|
||||
if (target < 0 || target >= prev.length) return prev
|
||||
const next = [...prev]
|
||||
const [moved] = next.splice(index, 1)
|
||||
next.splice(target, 0, moved)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const readyLines = normalizeEmbyRemoteLines(lines)
|
||||
if (readyLines.length === 0) {
|
||||
toast.error('请至少填写一条有效的 Emby 线路地址')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const config: Record<string, string> = {
|
||||
...(url ? { url } : {}),
|
||||
...encodeEmbyRemoteConfigLines(readyLines),
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password } : {}),
|
||||
...(token ? { token } : {}),
|
||||
@@ -69,7 +104,7 @@ function AccountDialog({
|
||||
toast.success('Emby 账号已更新')
|
||||
} else {
|
||||
if (!canSave()) {
|
||||
toast.error('请填写 Emby 地址与密码(或 API Key)')
|
||||
toast.error('请填写线路地址与密码(或 API Key)')
|
||||
return
|
||||
}
|
||||
await strmAPI.createAccount({ name: name || 'Emby 服务器', provider: 'emby_remote', config })
|
||||
@@ -85,11 +120,11 @@ function AccountDialog({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-md rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-display text-xl font-bold text-ink-600">{existing ? '编辑 Emby 账号' : '添加 Emby 账号'}</h3>
|
||||
<p className="text-xs text-sand-500">连接远程 Emby 服务器(Jellyfin 暂不支持)</p>
|
||||
<p className="text-xs text-sand-500">可配置内网、外网等多条线路,连接失败时按顺序自动切换</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100">
|
||||
<X size={18} />
|
||||
@@ -100,16 +135,48 @@ function AccountDialog({
|
||||
<span className="mb-1 block font-semibold text-ink-600">账号名称</span>
|
||||
<input className={inputCls} value={name} placeholder="例如:家庭影院" onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-semibold text-ink-600">Emby 服务地址</span>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={url}
|
||||
placeholder="http://192.168.1.10:8096"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={!!existing}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-ink-600">接入线路</span>
|
||||
<button type="button" onClick={addLine} className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-semibold text-ink-100 hover:bg-gray-50">
|
||||
<Plus size={12} />
|
||||
添加线路
|
||||
</button>
|
||||
</div>
|
||||
{lines.map((line, index) => (
|
||||
<div key={`line-${index}`} className="space-y-2 rounded-2xl border border-sand-200 bg-gray-50/70 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-sand-500">
|
||||
线路 {index + 1}
|
||||
{existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" onClick={() => moveLine(index, 'up')} disabled={index === 0} className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30">
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button type="button" onClick={() => moveLine(index, 'down')} disabled={index === lines.length - 1} className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30">
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
<button type="button" onClick={() => removeLine(index)} disabled={lines.length <= 1} className="rounded-lg border border-gray-200 p-1 text-red-500 hover:bg-red-50 disabled:opacity-30">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={line.name}
|
||||
placeholder="线路名称,如:内网 / 外网 / IPv6"
|
||||
onChange={(e) => updateLine(index, { name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={line.url}
|
||||
placeholder="http://192.168.1.10:8096 或 https://emby.example.com"
|
||||
onChange={(e) => updateLine(index, { url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-semibold text-ink-600">用户名</span>
|
||||
@@ -478,7 +545,7 @@ export function EmbyMountPage() {
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
添加远程 Emby 服务器后,按需挂载其媒体库到本项目;播放默认直连原 Emby,可对单个媒体库开启本机代理
|
||||
添加远程 Emby 服务器后,按需挂载其媒体库到本项目;可为同一服务器配置多条线路并自动切换
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setAccountDialog('new')} className="neon-button">
|
||||
@@ -512,6 +579,9 @@ export function EmbyMountPage() {
|
||||
<p className="font-display text-lg font-bold text-ink-600">{acct.name}</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{acct.has_credential ? '凭据已配置' : '待补全凭据'}
|
||||
{acct.emby_lines && acct.emby_lines.length > 0
|
||||
? ` · ${acct.emby_lines.length} 条线路`
|
||||
: ''}
|
||||
{acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ChevronUp, Home } from 'lucide-react'
|
||||
import { filesAPI, type FileEntry, type FileListing } from '../api/files'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library } from '../types'
|
||||
import { settingOn } from './autoOrganizeModel'
|
||||
import { AutoOrganizeSettingsPanel } from './AutoOrganizeSettingsPanel'
|
||||
import { FileBrowserRoots } from './FileBrowserRoots'
|
||||
import { FileEntriesTable } from './FileEntriesTable'
|
||||
@@ -36,7 +35,6 @@ export function FileManagerPage() {
|
||||
() => libraries.filter((library) => !isCloudLibraryPath(library.path)),
|
||||
[libraries],
|
||||
)
|
||||
const autoMoveKeepsSeeding = autoOrganize.moveKeepsSeeding
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true)
|
||||
@@ -68,7 +66,6 @@ export function FileManagerPage() {
|
||||
selectedPath: fileOperations.selected?.path,
|
||||
selectedPaths: fileOperations.selectedPaths,
|
||||
scrapeAfter,
|
||||
keepSeeding: settingOn(autoOrganize.config.keepSeeding),
|
||||
onScrapeAfterChange: setScrapeAfter,
|
||||
onClearSelected: () => fileOperations.setSelectedPaths([]),
|
||||
refresh,
|
||||
@@ -102,7 +99,6 @@ export function FileManagerPage() {
|
||||
loading={autoOrganize.loading}
|
||||
saving={autoOrganize.saving}
|
||||
running={autoOrganize.running}
|
||||
moveKeepsSeeding={autoMoveKeepsSeeding}
|
||||
onRefresh={autoOrganize.refresh}
|
||||
onSave={() => void autoOrganize.save()}
|
||||
onRunNow={autoOrganize.runNow}
|
||||
@@ -119,7 +115,6 @@ export function FileManagerPage() {
|
||||
organizeDestPath={manualOrganize.organizeDestPath}
|
||||
organizeMediaType={manualOrganize.organizeMediaType}
|
||||
organizeTransferMode={manualOrganize.organizeTransferMode}
|
||||
manualMoveKeepsSeeding={manualOrganize.manualMoveKeepsSeeding}
|
||||
scanAfter={manualOrganize.scanAfter}
|
||||
scrapeAfter={scrapeAfter}
|
||||
organizeReady={manualOrganize.organizeReady}
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
LibrariesHeader,
|
||||
} from './LibrariesPageSections'
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
import { readPinnedLibraryIds, sortLibraryPreviews, togglePinnedLibraryId } from '../utils/pinnedLibraries'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||
const [pinnedIds, setPinnedIds] = useState<string[]>(() => readPinnedLibraryIds())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
||||
@@ -57,6 +59,12 @@ export function LibrariesPage() {
|
||||
loadLibraries().catch(() => undefined)
|
||||
}, [loadLibraries])
|
||||
|
||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
||||
|
||||
const handleTogglePin = useCallback((libraryId: string) => {
|
||||
setPinnedIds(togglePinnedLibraryId(libraryId))
|
||||
}, [])
|
||||
|
||||
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
|
||||
|
||||
if (loading) {
|
||||
@@ -79,7 +87,7 @@ export function LibrariesPage() {
|
||||
{previews.length === 0 ? (
|
||||
<LibrariesEmptyState />
|
||||
) : (
|
||||
<LibrariesContent previews={previews} />
|
||||
<LibrariesContent previews={sortedPreviews} pinnedIds={pinnedIds} onTogglePin={handleTogglePin} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
@@ -9,6 +9,7 @@ import { MediaCard } from '../components/MediaCard'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { libraryDisplayPath } from './libraryDisplayModel'
|
||||
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
|
||||
import { isLibraryPinned } from '../utils/pinnedLibraries'
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={18} />,
|
||||
@@ -102,13 +103,26 @@ export function LibrariesEmptyState() {
|
||||
)
|
||||
}
|
||||
|
||||
export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
export function LibrariesContent({
|
||||
previews,
|
||||
pinnedIds,
|
||||
onTogglePin,
|
||||
}: {
|
||||
previews: LibraryPreview[]
|
||||
pinnedIds: string[]
|
||||
onTogglePin: (libraryId: string) => void
|
||||
}) {
|
||||
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
|
||||
<p className="text-sm text-ink-50">按目录进入完整媒体库;下方每个目录也会直接展示最新内容。</p>
|
||||
<p className="text-sm text-ink-50">
|
||||
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
|
||||
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
|
||||
{previews.map((preview, index) => (
|
||||
@@ -118,7 +132,11 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<LibraryEntryCard preview={preview} />
|
||||
<LibraryEntryCard
|
||||
preview={preview}
|
||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||
onTogglePin={() => onTogglePin(preview.library.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
@@ -132,7 +150,10 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<LibraryShelf preview={preview} />
|
||||
<LibraryShelf
|
||||
preview={preview}
|
||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</section>
|
||||
@@ -140,7 +161,15 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
function LibraryEntryCard({
|
||||
preview,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
}: {
|
||||
preview: LibraryPreview
|
||||
pinned: boolean
|
||||
onTogglePin: () => void
|
||||
}) {
|
||||
const library = preview.library
|
||||
const artwork = library.cover_url
|
||||
? [{ src: library.cover_url, version: library.updated_at }]
|
||||
@@ -148,10 +177,35 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
const displayPath = libraryDisplayPath(library.path)
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/library/${library.id}`}
|
||||
className="group flex overflow-hidden rounded-2xl sm:rounded-3xl border border-sand-200 bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
|
||||
<div
|
||||
className={
|
||||
'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"
|
||||
>
|
||||
<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) => (
|
||||
@@ -173,7 +227,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
</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">
|
||||
<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>
|
||||
@@ -183,6 +237,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
</div>
|
||||
<h2 className="line-clamp-2 font-display text-sm font-bold leading-tight text-ink-600 group-hover:text-brand-600 sm:truncate sm:text-xl sm:font-black">
|
||||
{library.name}
|
||||
{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}
|
||||
@@ -195,11 +250,12 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryShelf({ preview }: { preview: LibraryPreview }) {
|
||||
function LibraryShelf({ preview, pinned }: { preview: LibraryPreview; pinned?: boolean }) {
|
||||
const library = preview.library
|
||||
const cards = preview.cards.slice(0, 10)
|
||||
const displayPath = libraryDisplayPath(library.path)
|
||||
@@ -212,7 +268,10 @@ function LibraryShelf({ preview }: { preview: LibraryPreview }) {
|
||||
{TYPE_ICONS[library.type] ?? <LibraryIcon size={14} />}
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</div>
|
||||
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">{library.name}</h2>
|
||||
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">
|
||||
{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>
|
||||
|
||||
@@ -11,7 +11,7 @@ type LibraryMediaSectionsProps = {
|
||||
seriesCards: SeriesCard[]
|
||||
selectedSeries: SeriesCard | null
|
||||
loading: boolean
|
||||
movieActions: (media: Media) => ReactNode
|
||||
cardActions: (media: Media) => ReactNode
|
||||
onSeriesClick: (series: SeriesCard) => void
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function LibraryMediaSections({
|
||||
seriesCards,
|
||||
selectedSeries,
|
||||
loading,
|
||||
movieActions,
|
||||
cardActions,
|
||||
onSeriesClick,
|
||||
}: LibraryMediaSectionsProps) {
|
||||
return (
|
||||
@@ -29,7 +29,7 @@ export function LibraryMediaSections({
|
||||
{!isSeries && items.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{items.map((media) => (
|
||||
<MediaCard key={media.id} media={media} actions={movieActions(media)} />
|
||||
<MediaCard key={media.id} media={media} actions={cardActions(media)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -45,6 +45,7 @@ export function LibraryMediaSections({
|
||||
key={series.key}
|
||||
media={series.rep}
|
||||
count={series.count}
|
||||
actions={cardActions(series.rep)}
|
||||
onClick={() => onSeriesClick(series)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
|
||||
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
type SortOrder,
|
||||
} from '../utils/mediaSort'
|
||||
import { LibraryPageDialogs } from './LibraryPageDialogs'
|
||||
import { PageBackButton } from '../components/PageBackButton'
|
||||
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
import { LibraryPageHeader } from './LibraryPageHeader'
|
||||
import { LibraryMediaSections } from './LibraryMediaSections'
|
||||
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
|
||||
@@ -20,12 +22,17 @@ import { useLibraryData } from './useLibraryData'
|
||||
import { useLibraryScanStatus } from './useLibraryScanStatus'
|
||||
import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
|
||||
import { useLibraryAdminActions } from './useLibraryAdminActions'
|
||||
import { usePermission } from '../hooks/usePermission'
|
||||
import { useFavourites } from '../hooks/useFavourites'
|
||||
|
||||
export function LibraryPage() {
|
||||
const { id = '' } = useParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const location = useLocation()
|
||||
const role = useAuthStore((s) => s.user?.role)
|
||||
const canFavorite = usePermission('can_favorite')
|
||||
const { isFavourite, toggleFavourite } = useFavourites()
|
||||
const [favouriteBusyID, setFavouriteBusyID] = useState('')
|
||||
|
||||
const [scrapeDialogOpen, setScrapeDialogOpen] = useState(false)
|
||||
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
|
||||
@@ -168,6 +175,39 @@ export function LibraryPage() {
|
||||
setManualMovie,
|
||||
})
|
||||
|
||||
const handleToggleFavourite = async (mediaID: string) => {
|
||||
if (!canFavorite || favouriteBusyID) return
|
||||
setFavouriteBusyID(mediaID)
|
||||
try {
|
||||
await toggleFavourite(mediaID)
|
||||
} finally {
|
||||
setFavouriteBusyID('')
|
||||
}
|
||||
}
|
||||
|
||||
const cardActions = (media: Media): ReactNode => {
|
||||
const actions: ReactNode[] = []
|
||||
if (canFavorite) {
|
||||
actions.push(
|
||||
<MediaFavouriteButton
|
||||
key="favourite"
|
||||
variant="compact"
|
||||
favourite={isFavourite(media.id)}
|
||||
disabled={favouriteBusyID === media.id}
|
||||
onToggle={() => {
|
||||
void handleToggleFavourite(media.id)
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
const adminActions = movieActions(media)
|
||||
if (adminActions) {
|
||||
actions.push(<Fragment key="admin-actions">{adminActions}</Fragment>)
|
||||
}
|
||||
if (actions.length === 0) return undefined
|
||||
return <>{actions}</>
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
@@ -181,6 +221,12 @@ export function LibraryPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!selectedSeries && (
|
||||
<div className="hidden lg:block">
|
||||
<PageBackButton to="/libraries" label="全部媒体库" compact />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedSeries && (
|
||||
<LibraryPageHeader
|
||||
library={library}
|
||||
@@ -209,7 +255,7 @@ export function LibraryPage() {
|
||||
seriesCards={sortedSeriesCards}
|
||||
selectedSeries={selectedSeries}
|
||||
loading={loading}
|
||||
movieActions={movieActions}
|
||||
cardActions={cardActions}
|
||||
onSeriesClick={handleSeriesClick}
|
||||
/>
|
||||
|
||||
@@ -222,6 +268,13 @@ export function LibraryPage() {
|
||||
loadingEpisodes={loadingSeriesEpisodes}
|
||||
playbackFrom={`${location.pathname}${location.search}`}
|
||||
isAdmin={role === 'admin'}
|
||||
canFavorite={canFavorite}
|
||||
favourite={selectedSeries ? isFavourite(selectedSeries.rep.id) : false}
|
||||
favouriteBusy={!!selectedSeries && favouriteBusyID === selectedSeries.rep.id}
|
||||
onToggleFavourite={() => {
|
||||
if (!selectedSeries) return
|
||||
void handleToggleFavourite(selectedSeries.rep.id)
|
||||
}}
|
||||
seriesToolBusy={seriesToolBusy}
|
||||
onBack={clearSelectedSeries}
|
||||
onSmartScrape={handleSeriesSmartScrape}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search,
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
@@ -13,6 +14,10 @@ type LibrarySeriesDetailHeaderProps = {
|
||||
allEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
canFavorite: boolean
|
||||
favourite: boolean
|
||||
favouriteBusy: boolean
|
||||
onToggleFavourite: () => void
|
||||
seriesToolBusy: string
|
||||
onBack: () => void
|
||||
onSmartScrape: () => void
|
||||
@@ -30,6 +35,10 @@ export function LibrarySeriesDetailHeader({
|
||||
allEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
canFavorite,
|
||||
favourite,
|
||||
favouriteBusy,
|
||||
onToggleFavourite,
|
||||
seriesToolBusy,
|
||||
onBack,
|
||||
onSmartScrape,
|
||||
@@ -75,15 +84,24 @@ export function LibrarySeriesDetailHeader({
|
||||
{series.rep.overview || '暂无简介'}
|
||||
</p>
|
||||
|
||||
{firstEpisode && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{firstEpisode && (
|
||||
<>
|
||||
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
|
||||
</>
|
||||
)}
|
||||
{canFavorite && (
|
||||
<MediaFavouriteButton
|
||||
favourite={favourite}
|
||||
disabled={favouriteBusy}
|
||||
onToggle={onToggleFavourite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && allEpisodes.length > 0 && !isRemoteEmbyID(series.rep.id) && (
|
||||
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
|
||||
|
||||
@@ -19,6 +19,10 @@ type LibrarySeriesDetailSectionProps = {
|
||||
loadingEpisodes: boolean
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
canFavorite: boolean
|
||||
favourite: boolean
|
||||
favouriteBusy: boolean
|
||||
onToggleFavourite: () => void
|
||||
seriesToolBusy: string
|
||||
onBack: () => void
|
||||
onSmartScrape: () => void
|
||||
@@ -40,6 +44,10 @@ export function LibrarySeriesDetailSection({
|
||||
loadingEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
canFavorite,
|
||||
favourite,
|
||||
favouriteBusy,
|
||||
onToggleFavourite,
|
||||
seriesToolBusy,
|
||||
onBack,
|
||||
onSmartScrape,
|
||||
@@ -66,6 +74,10 @@ export function LibrarySeriesDetailSection({
|
||||
allEpisodes={allEpisodes}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
canFavorite={canFavorite}
|
||||
favourite={favourite}
|
||||
favouriteBusy={favouriteBusy}
|
||||
onToggleFavourite={onToggleFavourite}
|
||||
seriesToolBusy={seriesToolBusy}
|
||||
onBack={onBack}
|
||||
onSmartScrape={onSmartScrape}
|
||||
|
||||
@@ -15,7 +15,6 @@ type ManualOrganizePanelProps = {
|
||||
organizeDestPath: string
|
||||
organizeMediaType: string
|
||||
organizeTransferMode: string
|
||||
manualMoveKeepsSeeding: boolean
|
||||
scanAfter: boolean
|
||||
scrapeAfter: boolean
|
||||
organizeReady: boolean
|
||||
@@ -40,7 +39,6 @@ export function ManualOrganizePanel({
|
||||
organizeDestPath,
|
||||
organizeMediaType,
|
||||
organizeTransferMode,
|
||||
manualMoveKeepsSeeding,
|
||||
scanAfter,
|
||||
scrapeAfter,
|
||||
organizeReady,
|
||||
@@ -119,19 +117,13 @@ export function ManualOrganizePanel({
|
||||
<span className="text-xs text-ink-50">整理方式</span>
|
||||
<select className="input-base w-full" value={organizeTransferMode} onChange={(event) => onTransferModeChange(event.target.value)}>
|
||||
<option value="hardlink">硬链接</option>
|
||||
<option value="move">移动(关闭保种才会移动)</option>
|
||||
<option value="move">移动</option>
|
||||
<option value="copy">复制</option>
|
||||
<option value="symlink">软链接</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{manualMoveKeepsSeeding && (
|
||||
<div className="rounded-xl border border-orange-300 bg-orange-50 px-3 py-2 text-xs text-orange-700">
|
||||
“保种”已开启,选择“移动”时后端会改用硬链接以保留下载源。要执行真正移动,请先在上方自动整理设置里关闭“保种”并保存。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||
<input type="checkbox" checked={scanAfter} onChange={(event) => onScanAfterChange(event.target.checked)} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { PageBackButton } from '../components/PageBackButton'
|
||||
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
@@ -63,8 +65,11 @@ export function MediaDetailLoading() {
|
||||
|
||||
export function MediaDetailMissing() {
|
||||
return (
|
||||
<div className="text-center py-24 bg-white rounded-2xl border border-gray-200">
|
||||
<p className="text-gray-500">媒体资源已被移除或不存在</p>
|
||||
<div className="space-y-4 py-12 text-center">
|
||||
<PageBackButton label="返回上一页" className="mx-auto" />
|
||||
<div className="rounded-2xl border border-gray-200 bg-white py-16">
|
||||
<p className="text-gray-500">媒体资源已被移除或不存在</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { playbackAPI, type PlaylistDetail } from '../api/playback'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
|
||||
export function PlaylistDetailPage() {
|
||||
const { id = '' } = useParams()
|
||||
@@ -24,10 +25,12 @@ export function PlaylistDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">{detail.playlist.name}</h1>
|
||||
<p className="text-sm text-ink-50">{detail.items.length} 部影片</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
backTo="/playlists"
|
||||
backLabel="播放列表"
|
||||
title={detail.playlist.name}
|
||||
description={`${detail.items.length} 部影片`}
|
||||
/>
|
||||
|
||||
{detail.items.length === 0 && (
|
||||
<p className="text-ink-50">暂无内容,前往媒体详情页通过「加入播放列表」添加。</p>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ListPlus, Trash2 } from 'lucide-react'
|
||||
import { ChevronRight, ListMusic, ListPlus, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import type { Playlist } from '../types'
|
||||
|
||||
// Landing page for playlists. Lists every playlist owned by the current
|
||||
// user and lets them create / delete one.
|
||||
export function PlaylistsPage() {
|
||||
const [items, setItems] = useState<Playlist[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const refresh = () =>
|
||||
playbackAPI
|
||||
@@ -26,67 +26,124 @@ export function PlaylistsPage() {
|
||||
|
||||
const onCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
await playbackAPI.createPlaylist(name)
|
||||
await playbackAPI.createPlaylist(trimmed)
|
||||
toast.success('已创建')
|
||||
setName('')
|
||||
await refresh()
|
||||
} catch {
|
||||
toast.error('创建失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = !loading && items.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">播放列表</h1>
|
||||
<PageHeader
|
||||
title="播放列表"
|
||||
description="整理你想稍后观看的影片,随时继续播放"
|
||||
/>
|
||||
|
||||
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<form
|
||||
onSubmit={onCreate}
|
||||
className="glass-panel flex flex-col gap-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
className="input-base min-w-0 flex-1"
|
||||
placeholder="新播放列表名称"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={creating}
|
||||
/>
|
||||
<button type="submit" className="neon-button">
|
||||
<ListPlus size={16} /> 新建
|
||||
<button type="submit" className="neon-button shrink-0" disabled={creating}>
|
||||
<ListPlus size={16} />
|
||||
{creating ? '创建中…' : '新建'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">还没有任何播放列表。</p>}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-ink-50">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="glass-panel flex items-center justify-between !p-4"
|
||||
>
|
||||
<Link
|
||||
to={`/playlist/${p.id}`}
|
||||
className="font-medium text-ink-600 transition hover:text-brand-500"
|
||||
>
|
||||
{p.name}
|
||||
{p.is_public && (
|
||||
<span className="ml-2 rounded-lg border border-primary-400/40 px-1.5 py-0.5 text-xs text-brand-500">
|
||||
public
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!(await confirmAction({ title: '删除播放列表', message: `删除「${p.name}」?`, confirmText: '删除' }))) return
|
||||
await playbackAPI.deletePlaylist(p.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
{isEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-4 p-10 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-400/10 text-brand-500">
|
||||
<ListMusic size={28} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-lg font-medium text-ink-100">还没有任何播放列表</p>
|
||||
<p className="text-sm text-sand-500">
|
||||
在上方输入名称创建列表,或在媒体详情页点击「加入播放列表」
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="glass-panel group flex items-center gap-3 !p-4 transition hover:border-primary-400/30"
|
||||
>
|
||||
<Link
|
||||
to={`/playlist/${p.id}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary-400/10 text-brand-500 transition group-hover:bg-primary-400/20">
|
||||
<ListMusic size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-ink-600 transition group-hover:text-brand-500">
|
||||
{p.name}
|
||||
</p>
|
||||
{p.is_public && (
|
||||
<span className="mt-0.5 inline-block rounded-md border border-primary-400/40 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-brand-500">
|
||||
公开
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className="shrink-0 text-sand-500 transition group-hover:text-brand-500"
|
||||
/>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (
|
||||
!(await confirmAction({
|
||||
title: '删除播放列表',
|
||||
message: `删除「${p.name}」?`,
|
||||
confirmText: '删除',
|
||||
}))
|
||||
) {
|
||||
return
|
||||
}
|
||||
await playbackAPI.deletePlaylist(p.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
className="shrink-0 rounded-lg border border-red-400/40 p-2 text-red-400 transition hover:bg-red-400/10"
|
||||
title="删除播放列表"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function ScraperQueuePage() {
|
||||
export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
const [snapshot, setSnapshot] = useState<ScrapeQueueSnapshot | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | ScrapeTaskStatus>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -251,7 +251,7 @@ export function ScraperQueuePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. Header */}
|
||||
{!embedded && (
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
|
||||
@@ -385,6 +385,7 @@ export function ScraperQueuePage() {
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* 2. Status Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Plus,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Tv,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -23,6 +27,12 @@ import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from
|
||||
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
||||
import {
|
||||
defaultEmbyRemoteLines,
|
||||
encodeEmbyRemoteConfigLines,
|
||||
normalizeEmbyRemoteLines,
|
||||
type EmbyRemoteLine,
|
||||
} from '../utils/embyRemoteLines'
|
||||
|
||||
// ─── 弹框外壳 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -106,6 +116,9 @@ export function StrmAccountDialog({
|
||||
const [name, setName] = useState(existing?.name ?? '')
|
||||
const [enabled, setEnabled] = useState(existing?.enabled ?? true)
|
||||
const [url, setUrl] = useState('')
|
||||
const [embyLines, setEmbyLines] = useState<EmbyRemoteLine[]>(() =>
|
||||
defaultEmbyRemoteLines(existing?.provider === 'emby_remote' ? existing?.emby_lines : undefined),
|
||||
)
|
||||
const [server, setServer] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -128,14 +141,16 @@ export function StrmAccountDialog({
|
||||
...(password ? { password } : {}),
|
||||
...(token ? { token } : {}),
|
||||
}
|
||||
case 'emby_remote':
|
||||
case 'emby_remote': {
|
||||
const lineConfig = encodeEmbyRemoteConfigLines(embyLines)
|
||||
return {
|
||||
...(url ? { url } : {}),
|
||||
...lineConfig,
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password } : {}),
|
||||
...(token ? { token } : {}),
|
||||
proxy_play: proxyPlay ? 'true' : 'false',
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
@@ -148,7 +163,7 @@ export function StrmAccountDialog({
|
||||
case 'openlist':
|
||||
return server !== '' && (token !== '' || password !== '')
|
||||
case 'emby_remote':
|
||||
return url !== '' && (token !== '' || password !== '')
|
||||
return normalizeEmbyRemoteLines(embyLines).length > 0 && (token !== '' || password !== '')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -255,9 +270,92 @@ export function StrmAccountDialog({
|
||||
)}
|
||||
|
||||
{provider === 'emby_remote' && (
|
||||
<Field label="Emby 服务地址" hint="远程 Emby 服务器地址,如 http://192.168.1.10:8096">
|
||||
<input className={inputCls} value={url} placeholder="http://host:8096" onChange={(e) => setUrl(e.target.value)} />
|
||||
</Field>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-ink-100">接入线路</p>
|
||||
<p className="text-xs text-sand-500">可配置内网、外网等多条线路,连接失败时按顺序自动切换</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmbyLines((prev) => [...prev, { name: `线路 ${prev.length + 1}`, url: '' }])}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-semibold text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
<Plus size={12} />
|
||||
添加线路
|
||||
</button>
|
||||
</div>
|
||||
{embyLines.map((line, index) => (
|
||||
<div key={`emby-line-${index}`} className="space-y-2 rounded-2xl border border-sand-200 bg-gray-50/70 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-sand-500">
|
||||
线路 {index + 1}
|
||||
{existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmbyLines((prev) => {
|
||||
const target = index - 1
|
||||
if (target < 0) return prev
|
||||
const next = [...prev]
|
||||
const [moved] = next.splice(index, 1)
|
||||
next.splice(target, 0, moved)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
disabled={index === 0}
|
||||
className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmbyLines((prev) => {
|
||||
const target = index + 1
|
||||
if (target >= prev.length) return prev
|
||||
const next = [...prev]
|
||||
const [moved] = next.splice(index, 1)
|
||||
next.splice(target, 0, moved)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
disabled={index === embyLines.length - 1}
|
||||
className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmbyLines((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)))}
|
||||
disabled={embyLines.length <= 1}
|
||||
className="rounded-lg border border-gray-200 p-1 text-red-500 hover:bg-red-50 disabled:opacity-30"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={line.name}
|
||||
placeholder="线路名称,如:内网 / 外网 / IPv6"
|
||||
onChange={(e) =>
|
||||
setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, name: e.target.value } : item)))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={line.url}
|
||||
placeholder="http://192.168.1.10:8096 或 https://emby.example.com"
|
||||
onChange={(e) =>
|
||||
setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, url: e.target.value } : item)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(provider === 'clouddrive2' || provider === 'emby_remote') && (
|
||||
|
||||
@@ -55,7 +55,13 @@ function getFileIcon(filename: string): ReactNode {
|
||||
return <File size={15} className="text-gray-400 shrink-0" />
|
||||
}
|
||||
|
||||
export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
|
||||
export function StrmQueuePanel({
|
||||
kind,
|
||||
embedded = false,
|
||||
}: {
|
||||
kind: 'download' | 'upload'
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const [snapshot, setSnapshot] = useState<StrmQueueSnapshot | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | StrmTaskStatus>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -242,7 +248,7 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. Header */}
|
||||
{!embedded && (
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
|
||||
@@ -403,6 +409,7 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* 2. Interactive Status Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
|
||||
@@ -115,9 +115,9 @@ export function TaskQueuePage() {
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
{type === 'scrape' && <ScraperQueuePage />}
|
||||
{type === 'download' && <StrmQueuePanel kind="download" />}
|
||||
{type === 'upload' && <StrmQueuePanel kind="upload" />}
|
||||
{type === 'scrape' && <ScraperQueuePage embedded />}
|
||||
{type === 'download' && <StrmQueuePanel kind="download" embedded />}
|
||||
{type === 'upload' && <StrmQueuePanel kind="upload" embedded />}
|
||||
{type === 'all' && (
|
||||
<div className="space-y-10">
|
||||
<section>
|
||||
@@ -125,21 +125,21 @@ export function TaskQueuePage() {
|
||||
<Sparkles size={18} className="text-brand-500" />
|
||||
刮削队列
|
||||
</h2>
|
||||
<ScraperQueuePage />
|
||||
<ScraperQueuePage embedded />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
|
||||
<Download size={18} className="text-brand-500" />
|
||||
下载队列
|
||||
</h2>
|
||||
<StrmQueuePanel kind="download" />
|
||||
<StrmQueuePanel kind="download" embedded />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
|
||||
<Upload size={18} className="text-brand-500" />
|
||||
上传队列
|
||||
</h2>
|
||||
<StrmQueuePanel kind="upload" />
|
||||
<StrmQueuePanel kind="upload" embedded />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,16 +2,13 @@ import type { Setting } from '../types'
|
||||
|
||||
export type AutoOrganizeConfig = {
|
||||
enabled: string
|
||||
afterDownload: string
|
||||
scrapeAfter: string
|
||||
downloadSmartClassify: string
|
||||
smartClassify: string
|
||||
autoAddLibrary: string
|
||||
sourceDir: string
|
||||
targetDir: string
|
||||
transferMode: string
|
||||
intervalSeconds: string
|
||||
keepSeeding: string
|
||||
movieFormat: string
|
||||
tvFormat: string
|
||||
animeFormat: string
|
||||
@@ -24,16 +21,13 @@ export type AutoOrganizeConfig = {
|
||||
|
||||
export const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
|
||||
enabled: 'false',
|
||||
afterDownload: 'false',
|
||||
scrapeAfter: 'true',
|
||||
downloadSmartClassify: 'true',
|
||||
smartClassify: 'true',
|
||||
autoAddLibrary: 'true',
|
||||
sourceDir: '',
|
||||
targetDir: '',
|
||||
transferMode: 'hardlink',
|
||||
intervalSeconds: '300',
|
||||
keepSeeding: 'true',
|
||||
movieFormat: '{title} ({year})/{title} ({year})',
|
||||
tvFormat: '{title} ({year})/Season {season:02}/{title} S{season:02}E{episode:02}',
|
||||
animeFormat: '{title}/Season {season:02}/{title} S{season:02}E{episode:02}',
|
||||
@@ -46,16 +40,13 @@ export const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
|
||||
|
||||
export const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = {
|
||||
enabled: 'organize.auto',
|
||||
afterDownload: 'organizer.auto_after_download',
|
||||
scrapeAfter: 'organize.scrape_after',
|
||||
downloadSmartClassify: 'downloads.smart_classify',
|
||||
smartClassify: 'organizer.smart_classify',
|
||||
autoAddLibrary: 'organize.auto_add_library',
|
||||
sourceDir: 'organize.source_dir',
|
||||
targetDir: 'organize.target_dir',
|
||||
transferMode: 'organize.transfer_mode',
|
||||
intervalSeconds: 'organize.interval_seconds',
|
||||
keepSeeding: 'organize.keep_seeding',
|
||||
movieFormat: 'organize.movie_format',
|
||||
tvFormat: 'organize.tv_format',
|
||||
animeFormat: 'organize.anime_format',
|
||||
@@ -72,16 +63,13 @@ export function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig {
|
||||
const idx = settingIndex(rows)
|
||||
return {
|
||||
enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled,
|
||||
afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload,
|
||||
scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter,
|
||||
downloadSmartClassify: idx[AUTO_ORGANIZE_KEYS.downloadSmartClassify] ?? AUTO_ORGANIZE_DEFAULTS.downloadSmartClassify,
|
||||
smartClassify: idx[AUTO_ORGANIZE_KEYS.smartClassify] ?? AUTO_ORGANIZE_DEFAULTS.smartClassify,
|
||||
autoAddLibrary: idx[AUTO_ORGANIZE_KEYS.autoAddLibrary] ?? AUTO_ORGANIZE_DEFAULTS.autoAddLibrary,
|
||||
sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir,
|
||||
targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir,
|
||||
transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode,
|
||||
intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds,
|
||||
keepSeeding: idx[AUTO_ORGANIZE_KEYS.keepSeeding] ?? AUTO_ORGANIZE_DEFAULTS.keepSeeding,
|
||||
movieFormat: idx[AUTO_ORGANIZE_KEYS.movieFormat] ?? AUTO_ORGANIZE_DEFAULTS.movieFormat,
|
||||
tvFormat: idx[AUTO_ORGANIZE_KEYS.tvFormat] ?? AUTO_ORGANIZE_DEFAULTS.tvFormat,
|
||||
animeFormat: idx[AUTO_ORGANIZE_KEYS.animeFormat] ?? AUTO_ORGANIZE_DEFAULTS.animeFormat,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
@@ -79,11 +79,6 @@ export function useAutoOrganizeSettings({ onScrapeAfterChange }: UseAutoOrganize
|
||||
}
|
||||
}, [dirty, save])
|
||||
|
||||
const moveKeepsSeeding = useMemo(
|
||||
() => config.transferMode === 'move' && settingOn(config.keepSeeding),
|
||||
[config.keepSeeding, config.transferMode],
|
||||
)
|
||||
|
||||
return {
|
||||
config,
|
||||
dirty,
|
||||
@@ -91,7 +86,6 @@ export function useAutoOrganizeSettings({ onScrapeAfterChange }: UseAutoOrganize
|
||||
running,
|
||||
loading,
|
||||
activeTab,
|
||||
moveKeepsSeeding,
|
||||
refresh,
|
||||
save,
|
||||
runNow,
|
||||
|
||||
@@ -17,7 +17,6 @@ type UseManualOrganizeOptions = {
|
||||
selectedPath?: string
|
||||
selectedPaths: string[]
|
||||
scrapeAfter: boolean
|
||||
keepSeeding: boolean
|
||||
onScrapeAfterChange: (value: boolean) => void
|
||||
onClearSelected: () => void
|
||||
refresh: () => void
|
||||
@@ -29,7 +28,6 @@ export function useManualOrganize({
|
||||
selectedPath,
|
||||
selectedPaths,
|
||||
scrapeAfter,
|
||||
keepSeeding,
|
||||
onScrapeAfterChange,
|
||||
onClearSelected,
|
||||
refresh,
|
||||
@@ -59,7 +57,6 @@ export function useManualOrganize({
|
||||
)
|
||||
const organizeSource = organizeSources.length === 1 ? organizeSources[0] : `${organizeSources.length} 个已选项目`
|
||||
const organizeReady = organizeSources.length > 0 && Boolean(organizeDestPath.trim())
|
||||
const manualMoveKeepsSeeding = organizeTransferMode === 'move' && keepSeeding
|
||||
|
||||
const runManualOrganize = async (dryRun: boolean) => {
|
||||
if (!organizeReady) {
|
||||
@@ -124,7 +121,6 @@ export function useManualOrganize({
|
||||
previewItems,
|
||||
organizeSource,
|
||||
organizeReady,
|
||||
manualMoveKeepsSeeding,
|
||||
setOrganizeLibraryID,
|
||||
setOrganizeDestPath,
|
||||
setOrganizeTransferMode,
|
||||
|
||||
@@ -9,6 +9,11 @@ export const STRM_PROVIDER_LABELS: Record<StrmProvider, string> = {
|
||||
emby_remote: 'Emby 远程挂载',
|
||||
}
|
||||
|
||||
export type EmbyRemoteLine = {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface StrmAccount {
|
||||
id: string
|
||||
name: string
|
||||
@@ -23,6 +28,10 @@ export interface StrmAccount {
|
||||
provider_label: string
|
||||
/** 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理 */
|
||||
proxy_play?: boolean
|
||||
/** 仅远程 Emby 挂载账号返回:多线路配置 */
|
||||
emby_lines?: EmbyRemoteLine[]
|
||||
/** 当前优先使用的线路下标 */
|
||||
emby_active_line?: number
|
||||
}
|
||||
|
||||
export interface StrmAccountInput {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export type EmbyRemoteLine = {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function normalizeEmbyRemoteLines(lines: EmbyRemoteLine[]): EmbyRemoteLine[] {
|
||||
return lines
|
||||
.map((line) => ({
|
||||
name: line.name.trim(),
|
||||
url: line.url.trim().replace(/\/+$/, ''),
|
||||
}))
|
||||
.filter((line) => line.url.startsWith('http://') || line.url.startsWith('https://'))
|
||||
}
|
||||
|
||||
export function encodeEmbyRemoteConfigLines(lines: EmbyRemoteLine[]): Record<string, string> {
|
||||
const normalized = normalizeEmbyRemoteLines(lines)
|
||||
if (normalized.length === 0) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
urls: JSON.stringify(normalized),
|
||||
url: normalized[0].url,
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultEmbyRemoteLines(existing?: EmbyRemoteLine[]): EmbyRemoteLine[] {
|
||||
if (existing && existing.length > 0) {
|
||||
return existing.map((line) => ({ name: line.name ?? '', url: line.url ?? '' }))
|
||||
}
|
||||
return [{ name: '线路 1', url: '' }]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const STORAGE_KEY = 'mmtl_pinned_libraries'
|
||||
|
||||
function parsePinnedIds(raw: string | null): string[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((id): id is string => typeof id === 'string' && id.trim() !== '')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function readPinnedLibraryIds(): string[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
return parsePinnedIds(window.localStorage.getItem(STORAGE_KEY))
|
||||
}
|
||||
|
||||
export function writePinnedLibraryIds(ids: string[]): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
|
||||
}
|
||||
|
||||
export function togglePinnedLibraryId(id: string): string[] {
|
||||
const current = readPinnedLibraryIds()
|
||||
const index = current.indexOf(id)
|
||||
const next = index >= 0 ? current.filter((item) => item !== id) : [...current, id]
|
||||
writePinnedLibraryIds(next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
|
||||
return pinnedIds.includes(id)
|
||||
}
|
||||
|
||||
export function sortLibraryPreviews<T extends { library: { id: string } }>(items: T[], pinnedIds: string[]): T[] {
|
||||
if (pinnedIds.length === 0) return items
|
||||
const rank = new Map(pinnedIds.map((id, index) => [id, index]))
|
||||
return [...items].sort((a, b) => {
|
||||
const aRank = rank.get(a.library.id)
|
||||
const bRank = rank.get(b.library.id)
|
||||
const aPinned = aRank !== undefined
|
||||
const bPinned = bRank !== undefined
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
||||
if (aPinned && bPinned) return aRank - bRank
|
||||
return 0
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user