Compare commits

...

19 Commits

Author SHA1 Message Date
github-actions[bot] a50ceaf1a7 chore: bump version to 0.0.78 [skip ci] 2026-09-02 07:51:44 +00:00
truewhile bf49323789 feat(strm): auto-append remote directory tail to local output path (#16)
When adding or editing a cloud sync path, append the last segment of the
remote directory to the local output directory (e.g. /home/ubuntu/strm + 电影).
Remote changes replace the previous auto-appended tail; local browse and blur
commit the base path with the current remote tail.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 15:51:31 +08:00
github-actions[bot] 47118c8a53 chore: bump version to 0.0.77 [skip ci] 2026-09-02 07:16:02 +00:00
truewhile 67b85840dc perf(strm): 优化 OpenList 元数据下载速度 (#15)
* Add favourite buttons to library list and series detail pages

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites list for remote Emby mounted media

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Route favourites to library series detail when appropriate

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites link to library series for remote anime paths

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix remote Emby playback history and continue watching

- Preserve duration_ms when web player reports duration=0
- Add GET /playback/:id/resume for reliable per-item resume
- Switch home continue watching to /watch-history/continue API
- Set library_id on remote media for play-profile visibility
- Keep continue-watching rows when remote metadata hydration fails

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix mobile sort dropdown overflowing off-screen

Align dropdown to button left edge on small screens and cap width
to viewport so sort options stay fully visible on mobile.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Remove stale Windows binary backup files

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix STRM account edit form not echoing saved config

Return config_preview from account API for server/url/username and
secret flags. Merge partial config updates so empty password/token
fields do not wipe stored credentials.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* perf(strm): speed up OpenList metadata downloads with provider-aware concurrency

- Split download semaphore: 115 stays capped at 3 for WAF safety; OpenList/CloudDrive2
  WebDAV metadata uses strm.download_threads (default raised to 6, max 16)
- Scope 115 WAF cooldown to 115 tasks only so OpenList downloads are not paused
- Requeue claimed tasks back to pending when slot acquisition fails or 115 is cooling
- Add tests for separate semaphores and requeue behavior

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 15:15:49 +08:00
github-actions[bot] fbc33862fb chore: bump version to 0.0.76 [skip ci] 2026-09-02 06:14:43 +00:00
truewhile 04fc50a4b0 Add library favourite buttons and fix remote favourites list (#14)
* Add favourite buttons to library list and series detail pages

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites list for remote Emby mounted media

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Route favourites to library series detail when appropriate

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites link to library series for remote anime paths

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 14:14:32 +08:00
github-actions[bot] dab9a1073a chore: bump version to 0.0.75 [skip ci] 2026-09-02 05:48:38 +00:00
truewhile bd3d4ad357 Add favourite buttons to library list and series detail pages (#13)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 13:48:25 +08:00
github-actions[bot] bd5bfdcc10 chore: bump version to 0.0.74 [skip ci] 2026-09-02 03:43:54 +00:00
truewhile 73dc37fef8 Remove obsolete file manager organize options (#12)
Remove UI options for features that no longer exist:
- qB download auto-organize
- downloader smart classification
- seeding (keep seeding)

Also remove related warning banners and simplify move transfer labels.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:43:39 +08:00
github-actions[bot] f1b7d99a33 chore: bump version to 0.0.73 [skip ci] 2026-09-02 03:35:57 +00:00
truewhile 638dae0c91 fix(layout): hide duplicate media nav in mobile drawer (#11)
On narrow screens the media drawer duplicated bottom navigation and
user menu links (home, libraries, favourites, playlists, history).
Show only management links in the drawer when browsing media.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:35:44 +08:00
truewhile 791722aec6 feat(libraries): add pin-to-top for media library list (#10)
Allow users to pin frequently used libraries to the top of the
Libraries page. Pinned order is stored in localStorage and applied
to both entry cards and preview shelves.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:35:20 +08:00
github-actions[bot] 1c1da494b7 chore: bump version to 0.0.72 [skip ci] 2026-09-02 03:20:07 +00:00
truewhile 97b3a7bd7f feat(emby): support multiple connection lines for remote mount (#9)
Add account-level multi-line configuration for emby_remote mounts.
Lines are stored in strm_accounts.config as a urls JSON array with
backward compatibility for the legacy single url field. The backend
automatically fails over to the next line on connection errors and
persists the working active_line index.

Frontend: multi-line editor on Emby mount page and STRM account dialog
with add/remove/reorder, line names, and URL validation.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:19:56 +08:00
github-actions[bot] 543615a2a8 chore: bump version to 0.0.71 [skip ci] 2026-09-02 03:02:51 +00:00
truewhile 2b7828e1fe fix: close user menu on navigation and outside click (#8)
Portal the profile dropdown to document.body with a full-screen backdrop
so clicks outside reliably dismiss it above transformed page layers. Also
close the menu whenever the route changes.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:02:41 +08:00
github-actions[bot] 0b1fe52e24 chore: bump version to 0.0.70 [skip ci] 2026-09-02 02:49:11 +00:00
truewhile 2fc2c17b05 fix: prevent mobile sidebar nav items from stacking on short screens (#7)
Narrow drawers rendered media links in a flex-1 overflow region above a
non-shrinking admin block, so the browse items were clipped into a
compressed active pill. Scroll media and admin together, and distinguish
/libraries from /libraries?from=admin when highlighting.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 10:48:58 +08:00
56 changed files with 1960 additions and 372 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.69
0.0.78
+24
View File
@@ -82,6 +82,30 @@ func playbackProgressHandler(svc *service.Container) gin.HandlerFunc {
}
}
func playbackResumeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
row, err := svc.Playback.GetProgress(c.Request.Context(), toString(uid), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if row == nil {
c.JSON(http.StatusOK, gin.H{
"position_ms": 0,
"duration_ms": 0,
"completed": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"position_ms": row.PositionMs,
"duration_ms": row.DurationMs,
"completed": row.Completed,
})
}
}
// externalPlayersHandler returns the list of external player URI
// schemes the UI can offer the user. We lookup the media row to
// produce the per-player launch URL.
@@ -71,6 +71,7 @@ func registerAuthedFavoriteAndMediaActionRoutes(authed *gin.RouterGroup, svc *se
func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
+37 -4
View File
@@ -8,6 +8,7 @@ import (
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -28,8 +29,13 @@ type strmAccountView struct {
model.StrmAccount
HasCredential bool `json:"has_credential"`
ProviderLabel string `json:"provider_label"`
// ConfigPreview 非敏感配置字段,供编辑表单回显。
ConfigPreview service.StrmAccountConfigPreview `json:"config_preview,omitempty"`
// 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 {
@@ -41,10 +47,17 @@ func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []st
HasCredential: service.HasStrmAccountCredential(&a),
ProviderLabel: providerLabelOf(a.Provider),
}
if svc != nil && svc.Strm != nil {
view.ConfigPreview = svc.Strm.StrmAccountConfigPreviewOf(&a)
}
if a.Provider == model.StrmProviderEmbyRemote && svc != nil && svc.EmbyRemote != nil {
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 +130,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])
}
}
+27 -4
View File
@@ -129,14 +129,23 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
if mediaVisibleForRequest(c, svc, rm) {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
}
continue
}
}
}
fallback := fallbackHistoryMedia(r.MediaID)
if fallback != nil {
out = append(out, gin.H{
"history": r,
"media": *fallback,
})
}
continue
}
out = append(out, gin.H{
@@ -148,6 +157,20 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
}
}
func fallbackHistoryMedia(mediaID string) *model.Media {
if mediaID == "" {
return nil
}
title := "媒体"
if service.IsEmbyRemoteID(mediaID) {
title = "远程媒体"
}
return &model.Media{
Base: model.Base{ID: mediaID},
Title: title,
}
}
// historyDeleteHandler removes one or all history rows for the caller.
//
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
+3 -1
View File
@@ -26,7 +26,9 @@ func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory
return err
}
existing.PositionMs = h.PositionMs
existing.DurationMs = h.DurationMs
if h.DurationMs > 0 {
existing.DurationMs = h.DurationMs
}
existing.WatchedAt = h.WatchedAt
existing.Completed = h.Completed
return r.db.WithContext(ctx).Save(&existing).Error
+159 -14
View File
@@ -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 {
+185
View File
@@ -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 &copy
}
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)
}
}
+5
View File
@@ -300,6 +300,11 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
media.SeasonNum = 0
media.EpisodeNum = 0
}
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
media.DisplayLibraryID = libID
media.LibraryID = libID
}
return media
}
+87 -9
View File
@@ -9,9 +9,11 @@ package service
import (
"context"
"errors"
"sort"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
@@ -46,18 +48,61 @@ func (p *PlaybackService) RecordProgress(ctx context.Context, userID, mediaID st
if userID == "" || mediaID == "" {
return errors.New("missing user or media")
}
completed := duration > 0 && position >= duration-30_000
dur := p.resolvePlaybackDuration(ctx, userID, mediaID, duration)
completed := dur > 0 && position >= dur-30_000
h := &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: position,
DurationMs: duration,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
}
return p.repo.History.Upsert(ctx, h)
}
// GetProgress returns the saved resume row for one media item, or nil when absent.
func (p *PlaybackService) GetProgress(ctx context.Context, userID, mediaID string) (*model.PlaybackHistory, error) {
if userID == "" || mediaID == "" {
return nil, errors.New("missing user or media")
}
var row model.PlaybackHistory
err := p.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (p *PlaybackService) resolvePlaybackDuration(ctx context.Context, userID, mediaID string, duration int64) int64 {
if duration > 0 {
return duration
}
var existing model.PlaybackHistory
if err := p.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
First(&existing).Error; err == nil && existing.DurationMs > 0 {
return existing.DurationMs
}
if m, _ := p.repo.Media.FindByID(ctx, mediaID); m != nil && m.DurationSec > 0 {
return int64(m.DurationSec) * 1000
}
if p.remote != nil && IsEmbyRemoteID(mediaID) {
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
if mount, acct, _ := p.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil {
if rm, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID); err == nil && rm != nil && rm.DurationSec > 0 {
return int64(rm.DurationSec) * 1000
}
}
}
return duration
}
// HistoryItem joins the playback row with its media so the API consumer
// gets a fully-populated card without a second round-trip.
type HistoryItem struct {
@@ -126,14 +171,47 @@ func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([]
if len(favs) == 0 {
return nil, nil
}
ids := make([]string, len(favs))
for i, f := range favs {
ids[i] = f.MediaID
sort.Slice(favs, func(i, j int) bool {
return favs[i].CreatedAt.After(favs[j].CreatedAt)
})
localIDs := make([]string, 0, len(favs))
for _, fav := range favs {
if !IsEmbyRemoteID(fav.MediaID) {
localIDs = append(localIDs, fav.MediaID)
}
}
var out []model.Media
err = p.repo.DB.Where("id IN ?", ids).
Order("created_at desc").Find(&out).Error
return out, err
mediaByID := map[string]model.Media{}
if len(localIDs) > 0 {
var mediaRows []model.Media
if err := p.repo.DB.WithContext(ctx).Where("id IN ?", localIDs).Find(&mediaRows).Error; err != nil {
return nil, err
}
for _, media := range mediaRows {
mediaByID[media.ID] = media
}
}
out := make([]model.Media, 0, len(favs))
for _, fav := range favs {
if media, ok := mediaByID[fav.MediaID]; ok {
out = append(out, media)
continue
}
if p.remote != nil && IsEmbyRemoteID(fav.MediaID) {
mountID, remoteID, _ := DecodeEmbyRemoteID(fav.MediaID)
mount, acct, _ := p.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
continue
}
remoteMedia, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil || remoteMedia == nil {
continue
}
out = append(out, *remoteMedia)
}
}
return out, nil
}
// ─── Playlists ──────────────────────────────────────────────────────────────
+93
View File
@@ -0,0 +1,93 @@
package service
import (
"context"
"testing"
"time"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"go.uber.org/zap"
)
func TestRecordProgressPreservesDurationWhenMissing(t *testing.T) {
db := newServiceTestDB(t, &model.PlaybackHistory{}, &model.Media{})
repos := repository.New(db)
userID := "user-1"
mediaID := "local-movie-1"
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "hist-1"},
UserID: userID,
MediaID: mediaID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: time.Now(),
}).Error; err != nil {
t.Fatalf("seed history: %v", err)
}
svc := NewPlaybackService(zap.NewNop(), repos)
if err := svc.RecordProgress(context.Background(), userID, mediaID, 60_000, 0); err != nil {
t.Fatalf("RecordProgress: %v", err)
}
var hist model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", userID, mediaID).First(&hist).Error; err != nil {
t.Fatalf("find history: %v", err)
}
if hist.DurationMs != 120_000 {
t.Fatalf("expected duration preserved, got %d", hist.DurationMs)
}
if hist.PositionMs != 60_000 {
t.Fatalf("expected position updated, got %d", hist.PositionMs)
}
}
func TestGetProgressReturnsNilWhenMissing(t *testing.T) {
db := newServiceTestDB(t, &model.PlaybackHistory{})
repos := repository.New(db)
svc := NewPlaybackService(zap.NewNop(), repos)
row, err := svc.GetProgress(context.Background(), "user-1", "missing-media")
if err != nil {
t.Fatalf("GetProgress: %v", err)
}
if row != nil {
t.Fatalf("expected nil progress, got %#v", row)
}
}
func TestListFavouritesIncludesLocalAndRemoteIDs(t *testing.T) {
db := newServiceTestDB(t, &model.Favorite{}, &model.Media{})
repos := repository.New(db)
userID := "user-1"
local := model.Media{
Base: model.Base{ID: "local-movie-1"},
Title: "Local Movie",
}
if err := db.Create(&local).Error; err != nil {
t.Fatalf("create local media: %v", err)
}
remoteID := EncodeEmbyRemoteID("mount-1", "remote-series-1")
favs := []model.Favorite{
{Base: model.Base{CreatedAt: time.Now().Add(-time.Minute)}, UserID: userID, MediaID: remoteID},
{Base: model.Base{CreatedAt: time.Now()}, UserID: userID, MediaID: local.ID},
}
for i := range favs {
if err := db.Create(&favs[i]).Error; err != nil {
t.Fatalf("create favorite %d: %v", i, err)
}
}
svc := NewPlaybackService(zap.NewNop(), repos)
items, err := svc.ListFavourites(context.Background(), userID)
if err != nil {
t.Fatalf("ListFavourites: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected one hydrated local favourite without remote service, got %d: %#v", len(items), items)
}
if items[0].ID != local.ID {
t.Fatalf("expected local favourite first by created_at desc, got %#v", items[0])
}
}
+76
View File
@@ -0,0 +1,76 @@
package service
import (
"context"
"encoding/json"
"testing"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestStrmAccountConfigPreviewOf(t *testing.T) {
svc := testStrmService(t)
acct := &model.StrmAccount{
Provider: model.StrmProviderOpenList,
Config: mustJSON(map[string]string{
"server": "https://list.example.com",
"username": "alice",
"password": svc.crypto.Encrypt("secret"),
"token": svc.crypto.Encrypt("tok"),
}),
}
preview := svc.StrmAccountConfigPreviewOf(acct)
if preview.Server != "https://list.example.com" {
t.Fatalf("server = %q", preview.Server)
}
if preview.Username != "alice" {
t.Fatalf("username = %q", preview.Username)
}
if !preview.HasPassword || !preview.HasToken {
t.Fatalf("expected secret flags, got %#v", preview)
}
}
func TestUpdateStrmAccountMergesConfigWithoutClearingSecrets(t *testing.T) {
svc := testStrmService(t)
ctx := context.Background()
acct, err := svc.CreateStrmAccount(ctx, "openlist", model.StrmProviderOpenList, map[string]string{
"server": "https://list.example.com",
"username": "alice",
"password": "secret",
"token": "tok",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
updated, err := svc.UpdateStrmAccount(ctx, acct.ID, "openlist-renamed", nil, map[string]string{
"server": "https://list.example.com",
"username": "alice",
})
if err != nil {
t.Fatalf("update account: %v", err)
}
if updated.Name != "openlist-renamed" {
t.Fatalf("name = %q", updated.Name)
}
cfg, err := svc.strmAccountConfig(updated)
if err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg["password"] != "secret" {
t.Fatalf("password was cleared: %#v", cfg)
}
if cfg["token"] != "tok" {
t.Fatalf("token was cleared: %#v", cfg)
}
}
func mustJSON(v any) string {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(data)
}
+21 -15
View File
@@ -29,9 +29,8 @@ const (
// downloadWorker 下载队列 worker:认领 → 解析直链 → 下载 → 落盘。
//
// 采用「批量认领 + 全局并发限流」:一次认领数个任务,用 StrmService 上的全局信号量
// 限制整个进程「同时换直链+下载」的并发数(与 115 换链风控匹配,见 strmDownloadSemCap),
// 同时让下载充分并行。换链走全局令牌桶(QPS=3)兜底,下载走 CDN 不限速。
// 采用「批量认领 + 按网盘类型限流」:一次认领数个任务,115 与 WebDAV/OpenList
// 使用独立信号量(115 固定 3 防风控;OpenList/CloudDrive2 并发由 download_threads 控制)。
func (s *StrmService) downloadWorker(ctx context.Context) {
const claimBatch = 12 // 每次批量认领的任务数
for {
@@ -42,12 +41,6 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
return
default:
}
// 115 风控/限流熔断:冷却期间整体暂停,不给 WAF 续封机会
if left := s.wafCooldownLeft(); left > 0 {
s.log.Debug("下载队列冷却中", zap.Duration("remaining", left))
sleepContext(ctx, left)
continue
}
tasks, err := s.repo.StrmDownload.ClaimPendingDownload(ctx, claimBatch)
if err != nil {
s.log.Warn("claim strm download task failed", zap.Error(err))
@@ -58,24 +51,37 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
sleepContext(ctx, 2*time.Second)
continue
}
// 并发处理本批任务:每个任务先获取全局下载槽位,槽位内部执行换链+下载。
// 信号量与令牌桶双重限速,确保任意时刻并发换链请求不超过安全阈值。
var wg sync.WaitGroup
for i := range tasks {
wg.Add(1)
go func(i int) {
defer wg.Done()
if !s.acquireDownloadSlot(ctx) {
task := &tasks[i]
if task.Provider == model.StrmProvider115 && s.wafCooldownLeft() > 0 {
s.requeueDownloadTask(task)
return
}
defer s.releaseDownloadSlot()
s.processDownloadTask(ctx, &tasks[i])
if !s.acquireDownloadSlot(ctx, task.Provider) {
s.requeueDownloadTask(task)
return
}
defer s.releaseDownloadSlot(task.Provider)
s.processDownloadTask(ctx, task)
}(i)
}
wg.Wait()
}
}
// requeueDownloadTask 把已认领但未实际执行的任务退回 pending,避免长期停留在 running。
func (s *StrmService) requeueDownloadTask(task *model.StrmDownloadTask) {
task.Status = model.StrmTaskPending
task.StartedAt = nil
if err := s.repo.StrmDownload.Update(context.Background(), task); err != nil {
s.log.Warn("requeue strm download task failed", zap.Error(err), zap.String("id", task.ID))
}
}
func (s *StrmService) processDownloadTask(ctx context.Context, task *model.StrmDownloadTask) {
cleanPath := sanitizeLocalPath(task.LocalPath)
if cleanPath != "" && cleanPath != task.LocalPath {
@@ -648,7 +654,7 @@ func sleepContext(ctx context.Context, d time.Duration) {
// ─── 115 风控/限流熔断 ────────────────────────────────────────────────────────
// triggerWAFCooldown 检测到 115 风控/限流后触发全局冷却,冷却期间下载 worker 暂停。
// triggerWAFCooldown 检测到 115 风控/限流后触发冷却,仅影响 115 下载任务。
// 冷却时间取最大值,避免连续触发时缩短等待。
func (s *StrmService) triggerWAFCooldown() {
s.mu.Lock()
+60
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
@@ -104,3 +105,62 @@ func TestStrmUploadTasksClearAndRetry(t *testing.T) {
t.Fatalf("expected 2 remaining tasks, got %d", count)
}
}
func TestDownloadSemSeparateByProvider(t *testing.T) {
svc := NewStrmService(nil, zap.NewNop(), nil, nil)
svc.initDownloadSems(6)
if cap(svc.downloadSem115) != strm115DownloadSemCap {
t.Fatalf("115 sem cap = %d, want %d", cap(svc.downloadSem115), strm115DownloadSemCap)
}
if cap(svc.downloadSemDAV) != 6 {
t.Fatalf("dav sem cap = %d, want 6", cap(svc.downloadSemDAV))
}
ctx := context.Background()
for i := 0; i < strm115DownloadSemCap; i++ {
if !svc.acquireDownloadSlot(ctx, model.StrmProvider115) {
t.Fatalf("failed to acquire 115 slot %d", i)
}
}
// 115 槽位占满后,OpenList 仍应能独立获取槽位。
if !svc.acquireDownloadSlot(ctx, model.StrmProviderOpenList) {
t.Fatal("openlist slot should remain available while 115 slots are full")
}
for i := 0; i < 5; i++ {
if !svc.acquireDownloadSlot(ctx, model.StrmProviderOpenList) {
t.Fatalf("failed to acquire extra openlist slot %d", i)
}
}
timeoutCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
if svc.acquireDownloadSlot(timeoutCtx, model.StrmProviderOpenList) {
t.Fatal("expected openlist slots to be exhausted after 6 acquisitions")
}
}
func TestRequeueDownloadTask(t *testing.T) {
db := newServiceTestDB(t, &model.StrmDownloadTask{})
repos := repository.New(db)
svc := NewStrmService(nil, zap.NewNop(), repos, nil)
now := time.Now()
task := &model.StrmDownloadTask{
Base: model.Base{ID: "dl-running"},
Status: model.StrmTaskRunning,
Provider: model.StrmProviderOpenList,
StartedAt: &now,
}
if err := db.Create(task).Error; err != nil {
t.Fatalf("insert task: %v", err)
}
svc.requeueDownloadTask(task)
var got model.StrmDownloadTask
if err := db.First(&got, "id = ?", task.ID).Error; err != nil {
t.Fatalf("load task: %v", err)
}
if got.Status != model.StrmTaskPending || got.StartedAt != nil {
t.Fatalf("task not requeued: %+v", got)
}
}
+89 -39
View File
@@ -68,7 +68,7 @@ var StrmSettingDefs = map[string]struct {
StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端(需网盘支持写入)"},
StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"},
Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"},
StrmSettingDownloadThreads: {Default: "3", Label: "下载队列线程数", Kind: "number", Help: "元数据下载并发数"},
StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"},
StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"},
}
@@ -91,33 +91,51 @@ type StrmService struct {
oauthSessions map[string]*strm115AuthSession
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
downloadSemOnce sync.Once
}
// strmWAFCooldown 检测到 115 风控/限流后下载队列的全局冷却时长。
// strmWAFCooldown 检测到 115 风控/限流后 115 下载任务的全局冷却时长。
const strmWAFCooldown = 3 * time.Minute
// strmDownloadSemCap 全局同时进行「换直链+下载」的并发上限。
// strm115DownloadSemCap 115 同时进行「换直链+下载」的并发上限。
//
// 115 对换直链接口(/open/ufile/downurl)风控极严:过去把全局 QPS 提到 8 或让多
// worker 高并发换链,会瞬时撞上 WAF 返回 405 阻断页并触发 180 秒冷却,反而更慢。
// 因此用信号量把整个进程同时换直链的并发数压到 3,与令牌桶限速共同兜底:
// 宁可下载稍慢,也绝不触发风控。下载本身走 CDN 不限速。
const strmDownloadSemCap = 3
// 因此把 115 换直链并发压到 3,与令牌桶限速共同兜底;OpenList/CloudDrive2 元数据
// 走 WebDAV,不受此限制,并发由 strm.download_threads 控制。
const strm115DownloadSemCap = 3
// ensureDownloadSem 惰性初始化全局共享的下载并发信号量。
func (s *StrmService) ensureDownloadSem() {
// initDownloadSems 按设置初始化 115 与 WebDAV 两套下载并发信号量。
func (s *StrmService) initDownloadSems(davCap int) {
s.downloadSemOnce.Do(func() {
s.downloadSem = make(chan struct{}, strmDownloadSemCap)
if davCap < 1 {
davCap = 1
}
if davCap > 16 {
davCap = 16
}
s.downloadSem115 = make(chan struct{}, strm115DownloadSemCap)
s.downloadSemDAV = make(chan struct{}, davCap)
})
}
func (s *StrmService) downloadSemForProvider(provider string) chan struct{} {
if provider == model.StrmProvider115 {
return s.downloadSem115
}
return s.downloadSemDAV
}
// acquireDownloadSlot 获取一个下载并发槽位(等待/取消安全)。
func (s *StrmService) acquireDownloadSlot(ctx context.Context) bool {
s.ensureDownloadSem()
func (s *StrmService) acquireDownloadSlot(ctx context.Context, provider string) bool {
sem := s.downloadSemForProvider(provider)
if sem == nil {
return false
}
select {
case s.downloadSem <- struct{}{}:
case sem <- struct{}{}:
return true
case <-ctx.Done():
return false
@@ -125,11 +143,12 @@ func (s *StrmService) acquireDownloadSlot(ctx context.Context) bool {
}
// releaseDownloadSlot 释放一个下载并发槽位。
func (s *StrmService) releaseDownloadSlot() {
if s.downloadSem == nil {
func (s *StrmService) releaseDownloadSlot(provider string) {
sem := s.downloadSemForProvider(provider)
if sem == nil {
return
}
<-s.downloadSem
<-sem
}
// NewStrmService constructs the STRM service.
@@ -151,13 +170,14 @@ func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Conta
func (s *StrmService) Start(ctx context.Context) {
s.sync115RelayKey(ctx)
s.recoverInterruptedSyncs(ctx)
downloadThreads := s.strmIntSetting(ctx, StrmSettingDownloadThreads, 3)
downloadThreads := s.strmIntSetting(ctx, StrmSettingDownloadThreads, 6)
if downloadThreads < 1 {
downloadThreads = 1
}
if downloadThreads > 8 {
downloadThreads = 8
if downloadThreads > 16 {
downloadThreads = 16
}
s.initDownloadSems(downloadThreads)
uploadThreads := s.strmIntSetting(ctx, StrmSettingUploadThreads, 2)
if uploadThreads < 1 {
uploadThreads = 1
@@ -237,9 +257,9 @@ func (s *StrmService) strmAccountConfig(acct *model.StrmAccount) (map[string]str
return cfg, nil
}
// mergeEmbyRemoteConfig 对远程 Emby 账号配置做合并式更新:config 中出现的键
// 覆盖写入(敏感键按明文加密),未出现的键保留原密文;显式空字符串=清除。
func (s *StrmService) mergeEmbyRemoteConfig(existing string, config map[string]string) (string, error) {
// mergeStrmAccountConfig 对账号配置做合并式更新:config 中出现的键覆盖写入(敏感键按明文
// 加密),未出现的键保留原密文;显式空字符串=清除。
func (s *StrmService) mergeStrmAccountConfig(existing string, config map[string]string) (string, error) {
out := map[string]string{}
if strings.TrimSpace(existing) != "" {
if err := json.Unmarshal([]byte(existing), &out); err != nil {
@@ -264,6 +284,44 @@ func (s *StrmService) mergeEmbyRemoteConfig(existing string, config map[string]s
return string(data), nil
}
// StrmAccountConfigPreview 返回可安全回显给前端的非敏感配置字段。
type StrmAccountConfigPreview struct {
URL string `json:"url,omitempty"`
Server string `json:"server,omitempty"`
Username string `json:"username,omitempty"`
HasPassword bool `json:"has_password,omitempty"`
HasToken bool `json:"has_token,omitempty"`
HasAPIKey bool `json:"has_api_key,omitempty"`
}
// StrmAccountConfigPreviewOf 从账号配置提取可回显字段(不含密码/令牌明文)。
func (s *StrmService) StrmAccountConfigPreviewOf(acct *model.StrmAccount) StrmAccountConfigPreview {
var out StrmAccountConfigPreview
if acct == nil || strings.TrimSpace(acct.Config) == "" {
return out
}
raw := map[string]string{}
if err := json.Unmarshal([]byte(acct.Config), &raw); err != nil {
return out
}
out.URL = strings.TrimSpace(raw["url"])
out.Server = strings.TrimSpace(raw["server"])
out.Username = strings.TrimSpace(raw["username"])
for _, k := range []string{"password", "token", "api_key"} {
if v, ok := raw[k]; ok && strings.TrimSpace(v) != "" {
switch k {
case "password":
out.HasPassword = true
case "token":
out.HasToken = true
case "api_key":
out.HasAPIKey = true
}
}
}
return out
}
// HasStrmAccountCredential 报告账号是否已配置核心凭据(用于前端展示)。
func HasStrmAccountCredential(acct *model.StrmAccount) bool {
switch acct.Provider {
@@ -273,8 +331,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:
@@ -319,22 +378,13 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en
if enabled != nil {
acct.Enabled = *enabled
}
if len(config) > 0 {
var enc string
var err error
if acct.Provider == model.StrmProviderEmbyRemote {
// 远程 Emby 账号:合并式更新。config 中出现的键覆盖(敏感键按明文
// 加密写入),未出现的键保留原密文——避免编辑「代理开关」时把已
// 保存的地址与凭据清空。
enc, err = s.mergeEmbyRemoteConfig(acct.Config, config)
} else {
enc, err = s.strmAccountConfigJSON(config, true)
if len(config) > 0 {
enc, err := s.mergeStrmAccountConfig(acct.Config, config)
if err != nil {
return nil, err
}
acct.Config = enc
}
if err != nil {
return nil, err
}
acct.Config = enc
}
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
return nil, err
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5
View File
@@ -33,6 +33,11 @@ function publicOriginHeader() {
}
export const playbackAPI = {
getResume: (mediaId: string) =>
api
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`)
.then((r) => r.data),
recordProgress: (mediaId: string, positionMs: number, durationMs: number) =>
api
.post('/history', {
+3 -4
View File
@@ -8,16 +8,15 @@ import {
LayoutWorkspace,
} from './LayoutSections'
import { MobileBottomNav } from './MobileBottomNav'
import { isPlayerRoute, shouldShowMobileBottomNav } from './layoutNavigation'
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
}
+2 -2
View File
@@ -40,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',
)}
>
@@ -66,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>
+10 -41
View File
@@ -5,7 +5,6 @@ import clsx from 'clsx'
import {
LAYOUT_NAV_ITEMS,
MEDIA_NAV_ITEMS,
type LayoutNavItem,
} from './layoutNavigation'
import { SidebarLink } from './LayoutSidebarNav'
@@ -30,29 +29,21 @@ export function LayoutSidebarContent({
variant = 'admin',
}: LayoutSidebarContentProps) {
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
const mediaItems = visibleSidebarItems({ isAdmin, can, items: MEDIA_NAV_ITEMS })
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}
/>
{variant === 'media' ? (
<>
<LayoutSidebarNav items={mediaItems} sidebarExpanded={sidebarExpanded} grow />
{adminItems.length > 0 && (
<div className="border-t border-[var(--app-border)]">
<LayoutSidebarSectionLabel sidebarExpanded={sidebarExpanded} label="管理" />
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} />
</div>
)}
</>
) : (
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} grow />
)}
<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>
)
@@ -78,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"
@@ -105,37 +96,15 @@ function LayoutSidebarHeader({
)
}
function LayoutSidebarSectionLabel({
sidebarExpanded,
label,
}: {
sidebarExpanded: boolean
label: string
}) {
if (!sidebarExpanded) return null
return (
<p className="px-4 pt-4 pb-1 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
{label}
</p>
)
}
function LayoutSidebarNav({
items,
sidebarExpanded,
grow = false,
}: {
items: LayoutNavItem[]
sidebarExpanded: boolean
grow?: boolean
}) {
return (
<nav
className={clsx(
'overflow-y-auto px-4 py-5 space-y-1 scrollbar-hide',
grow && 'flex-1',
)}
>
<nav className="px-4 py-5 space-y-1">
{items.map((item) => {
const ItemIcon = item.icon
return (
@@ -155,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(
+36 -37
View File
@@ -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>
)
}
+103 -54
View File
@@ -1,4 +1,5 @@
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'
@@ -8,6 +9,11 @@ import type { PlayProfile } from '../types'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import type { ThemeMode } from './useThemeMode'
type MenuPosition = {
top: number
right: number
}
type LayoutUser = {
username?: string
role?: string
@@ -43,72 +49,83 @@ export function LayoutUserMenu({
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)]">
@@ -142,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>
)
}
@@ -162,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>
)
}
+1 -1
View File
@@ -84,7 +84,7 @@ export function MediaSortDropdown({
</button>
{isOpen && (
<div className="absolute right-0 z-50 mt-1.5 w-52 origin-top-right rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95">
<div className="absolute left-0 z-50 mt-1.5 w-52 max-w-[calc(100vw-2rem)] origin-top-left rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95 sm:left-auto sm:right-0 sm:origin-top-right">
<div className="px-2.5 py-1.5 text-[11px] font-bold text-sand-500 border-b border-sand-100">
排序方式
</div>
+38
View File
@@ -85,6 +85,44 @@ 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: '媒体库' }
+50
View File
@@ -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}
/>
)}
+1 -13
View File
@@ -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>
</>
)
+87 -17
View File
@@ -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>
+3 -2
View File
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast'
import { playbackAPI } from '../api/playback'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
import { favouriteMediaLink } from '../utils/mediaNavigation'
export function FavouritesPage() {
const [items, setItems] = useState<Media[]>([])
@@ -66,7 +67,7 @@ export function FavouritesPage() {
<div className="glass-panel flex flex-col items-center gap-3 p-10 text-center">
<p className="text-lg text-ink-100">还没有任何收藏</p>
<p className="text-sm text-sand-500">
点击媒体详情页的「收藏」按钮添加喜欢的内容
在媒体库或详情页点击「加入收藏」即可添加喜欢的内容
</p>
</div>
)}
@@ -74,7 +75,7 @@ export function FavouritesPage() {
{items.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
<MediaCard key={m.id} media={m} linkTo={favouriteMediaLink(m)} />
))}
</div>
)}
-5
View File
@@ -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}
+15 -5
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { libraryAPI, type LibraryWithPreview } from '../api/library'
import { playbackAPI, type HistoryItem } from '../api/playback'
import { historyAPI } from '../api/history'
import type { HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import {
@@ -33,15 +34,24 @@ export function HomePage() {
.list({ withPreview: true, previewLimit: 20 })
.then((rows) => asArray<LibraryWithPreview>(rows))
.catch(() => [] as LibraryWithPreview[]),
playbackAPI
.recentHistory()
.then((rows) => asArray<HistoryItem>(rows))
historyAPI
.continueWatching(12)
.then((rows) =>
rows.map(
(row): HistoryItem => ({
...row.history,
created_at: '',
updated_at: '',
media: row.media,
}),
),
)
.catch(() => [] as HistoryItem[]),
])
if (cancelled) return
setLibraries(libs)
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
setHistory(hist.filter((h) => h && !h.completed))
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
for (const lib of libs) {
+8 -3
View File
@@ -23,6 +23,7 @@ import type { HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import { seriesCardLink } from '../utils/groupSeries'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { getLibraryArtworks } from './librariesPageModel'
const TYPE_ICONS: Record<string, ReactNode> = {
@@ -575,8 +576,12 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] })
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{history.slice(0, 12).map((h) => {
const media = h.media
if (!media) return null
const media: Media = h.media ?? ({
id: h.media_id,
title: isRemoteEmbyID(h.media_id) ? '远程媒体' : '媒体',
poster_url: '',
updated_at: h.watched_at,
} as Media)
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
return (
<div key={h.id} className="w-64 sm:w-72 shrink-0">
@@ -592,7 +597,7 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] })
function ContinueCard({ media, progress }: { media: Media; progress: number }) {
return (
<Link
to={`/media/${media.id}`}
to={`/play/${media.id}`}
className="group flex items-center gap-3.5 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 shadow-sm transition-all duration-300 hover:border-brand-500/30 hover:bg-[var(--app-panel-soft)] hover:shadow-md"
>
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]">
+9 -1
View File
@@ -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>
)
+72 -13
View File
@@ -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>
+4 -3
View File
@@ -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)}
/>
))}
+48 -2
View File
@@ -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'
@@ -14,6 +14,7 @@ import {
} 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'
@@ -21,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)
@@ -169,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">
@@ -216,7 +255,7 @@ export function LibraryPage() {
seriesCards={sortedSeriesCards}
selectedSeries={selectedSeries}
loading={loading}
movieActions={movieActions}
cardActions={cardActions}
onSeriesClick={handleSeriesClick}
/>
@@ -229,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}
+27 -9
View File
@@ -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}
+1 -9
View File
@@ -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)} />
+5 -6
View File
@@ -1,14 +1,13 @@
import type { Media } from '../types'
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
import { mediaLibraryTarget } from '../utils/mediaNavigation'
export function mediaLibraryBackTarget(media: Media): string {
const seriesTarget = mediaLibraryTarget(media)
if (seriesTarget) return seriesTarget
const libraryID = media.display_library_id || media.library_id
if (!libraryID) return ''
if (!isEpisodeLike(media)) return `/library/${encodeURIComponent(libraryID)}`
const seriesKey = getSeriesKey(media)
const target = `/library/${encodeURIComponent(libraryID)}`
return seriesKey ? `${target}?series=${encodeURIComponent(seriesKey)}` : target
return `/library/${encodeURIComponent(libraryID)}`
}
export function mediaDetailScrapeMediaType(media: Media): string | undefined {
+4 -5
View File
@@ -262,11 +262,10 @@ export function PlayerPage() {
setResumePosition(0)
setInitialSeekDone(false)
playbackAPI
.recentHistory()
.then((items) => {
const found = items.find((h) => h.media_id === id)
if (found && found.position_ms > 2000 && !found.completed) {
setResumePosition(found.position_ms / 1000)
.getResume(id)
.then((progress) => {
if (progress.position_ms > 2000 && !progress.completed) {
setResumePosition(progress.position_ms / 1000)
}
})
.catch(() => undefined)
+223 -25
View File
@@ -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,13 @@ 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'
import { lastPathSegment, syncLocalPathWithRemote } from '../utils/strmPaths'
// ─── 弹框外壳 ────────────────────────────────────────────────────────────────
@@ -85,6 +96,26 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
const inputCls = 'input-base w-full'
const SECRET_PLACEHOLDER = '已配置,留空则不修改'
function accountDialogDefaults(existing: StrmAccount | null) {
const preview = existing?.config_preview
return {
provider: (existing?.provider ?? 'cloud115') as StrmProvider,
name: existing?.name ?? '',
enabled: existing?.enabled ?? true,
url: preview?.url ?? '',
server: preview?.server ?? '',
username: preview?.username ?? '',
password: '',
token: '',
proxyPlay: existing?.proxy_play ?? false,
embyLines: defaultEmbyRemoteLines(existing?.provider === 'emby_remote' ? existing?.emby_lines : undefined),
hasPassword: Boolean(preview?.has_password),
hasToken: Boolean(preview?.has_token || preview?.has_api_key),
}
}
// ─── 添加/编辑网盘账号 ────────────────────────────────────────────────────────
const PROVIDER_OPTIONS: { provider: StrmProvider; label: string; desc: string }[] = [
@@ -102,15 +133,19 @@ export function StrmAccountDialog({
onClose: () => void
onSaved: () => void
}) {
const [provider, setProvider] = useState<StrmProvider>(existing?.provider ?? 'cloud115')
const [name, setName] = useState(existing?.name ?? '')
const [enabled, setEnabled] = useState(existing?.enabled ?? true)
const [url, setUrl] = useState('')
const [server, setServer] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const [proxyPlay, setProxyPlay] = useState(existing?.proxy_play ?? false)
const defaults = accountDialogDefaults(existing)
const [provider, setProvider] = useState<StrmProvider>(defaults.provider)
const [name, setName] = useState(defaults.name)
const [enabled, setEnabled] = useState(defaults.enabled)
const [url, setUrl] = useState(defaults.url)
const [embyLines, setEmbyLines] = useState<EmbyRemoteLine[]>(defaults.embyLines)
const [server, setServer] = useState(defaults.server)
const [username, setUsername] = useState(defaults.username)
const [password, setPassword] = useState(defaults.password)
const [token, setToken] = useState(defaults.token)
const [proxyPlay, setProxyPlay] = useState(defaults.proxyPlay)
const [hasPassword, setHasPassword] = useState(defaults.hasPassword)
const [hasToken, setHasToken] = useState(defaults.hasToken)
const [saving, setSaving] = useState(false)
const buildConfig = (): Record<string, string> => {
@@ -128,14 +163,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 +185,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
}
@@ -163,7 +200,7 @@ export function StrmAccountDialog({
name,
provider: existing.provider,
enabled,
config: existing.has_credential && !Object.keys(buildConfig()).length ? {} : buildConfig(),
config: buildConfig(),
})
toast.success('网盘账号已更新')
} else {
@@ -255,9 +292,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') && (
@@ -266,7 +386,16 @@ export function StrmAccountDialog({
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
</Field>
<Field label="密码">
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<input
className={inputCls}
type="password"
value={password}
placeholder={existing && hasPassword ? SECRET_PLACEHOLDER : undefined}
onChange={(e) => {
setPassword(e.target.value)
if (e.target.value) setHasPassword(false)
}}
/>
</Field>
</div>
)}
@@ -277,20 +406,45 @@ export function StrmAccountDialog({
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
</Field>
<Field label="密码">
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<input
className={inputCls}
type="password"
value={password}
placeholder={existing && hasPassword ? SECRET_PLACEHOLDER : undefined}
onChange={(e) => {
setPassword(e.target.value)
if (e.target.value) setHasPassword(false)
}}
/>
</Field>
</div>
)}
{provider === 'emby_remote' && (
<Field label="API Key(可选)" hint="留空则用下方用户名/密码自动认证获取">
<input className={inputCls} value={token} placeholder="留空自动认证" onChange={(e) => setToken(e.target.value)} />
<input
className={inputCls}
value={token}
placeholder={existing && hasToken ? SECRET_PLACEHOLDER : '留空自动认证'}
onChange={(e) => {
setToken(e.target.value)
if (e.target.value) setHasToken(false)
}}
/>
</Field>
)}
{provider === 'openlist' && (
<Field label="Token(可选,优先于密码)">
<input className={inputCls} value={token} onChange={(e) => setToken(e.target.value)} />
<input
className={inputCls}
value={token}
placeholder={existing && hasToken ? SECRET_PLACEHOLDER : undefined}
onChange={(e) => {
setToken(e.target.value)
if (e.target.value) setHasToken(false)
}}
/>
</Field>
)}
@@ -760,10 +914,27 @@ export function StrmSyncPathDialog({
const [saving, setSaving] = useState(false)
const [browsing, setBrowsing] = useState(false)
const [browsingLocal, setBrowsingLocal] = useState<null | 'remote_path' | 'local_path'>(null)
const prevRemoteTailRef = useRef(existing ? lastPathSegment(existing.remote_path) : '')
const set = <K extends keyof StrmSyncPathInput>(key: K, value: StrmSyncPathInput[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const updateRemotePath = (remotePath: string) => {
setForm((f) => {
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current)
prevRemoteTailRef.current = synced.remoteTail
return { ...f, remote_path: remotePath, local_path: synced.localPath }
})
}
const commitLocalPath = (localPath: string) => {
setForm((f) => {
const synced = syncLocalPathWithRemote(localPath, f.remote_path, prevRemoteTailRef.current)
prevRemoteTailRef.current = synced.remoteTail
return { ...f, local_path: synced.localPath }
})
}
const isLocal = form.provider === 'local'
const availableAccounts = accounts.filter((a) => a.provider === form.provider && a.has_credential)
@@ -847,7 +1018,15 @@ export function StrmSyncPathDialog({
: '远端路径(可通过浏览选择)'
}
>
<input className={inputCls} value={form.remote_path} placeholder={isLocal ? 'D:\\movies' : '/'} onChange={(e) => set('remote_path', e.target.value)} />
<input
className={inputCls}
value={form.remote_path}
placeholder={isLocal ? 'D:\\movies' : '/'}
onChange={(e) => set('remote_path', e.target.value)}
onBlur={(e) => {
if (!isLocal) updateRemotePath(e.target.value)
}}
/>
</Field>
</div>
{isLocal && (
@@ -870,8 +1049,23 @@ export function StrmSyncPathDialog({
<div className="space-y-1">
<div className="flex items-end gap-2">
<div className="flex-1">
<Field label="本地输出目录" hint="生成的 .strm 与下载的元数据写到这里的对应目录结构下">
<input className={inputCls} value={form.local_path} placeholder="D:\\strm\\movies" onChange={(e) => set('local_path', e.target.value)} />
<Field
label="本地输出目录"
hint={
!isLocal && lastPathSegment(form.remote_path)
? `将自动拼接远端末级目录「${lastPathSegment(form.remote_path)}」`
: '生成的 .strm 与下载的元数据写到这里的对应目录结构下'
}
>
<input
className={inputCls}
value={form.local_path}
placeholder="D:\\strm\\movies"
onChange={(e) => set('local_path', e.target.value)}
onBlur={(e) => {
if (!isLocal) commitLocalPath(e.target.value)
}}
/>
</Field>
</div>
<button
@@ -956,7 +1150,7 @@ export function StrmSyncPathDialog({
accountId={form.account_id}
initialDir={form.remote_path || undefined}
onSelect={(id) => {
set('remote_path', id)
updateRemotePath(id)
setBrowsing(false)
}}
onClose={() => setBrowsing(false)}
@@ -967,7 +1161,11 @@ export function StrmSyncPathDialog({
<LocalDirBrowserDialog
initialDir={form[browsingLocal] || undefined}
onSelect={(path) => {
set(browsingLocal, path)
if (browsingLocal === 'local_path') {
commitLocalPath(path)
} else {
set(browsingLocal, path)
}
setBrowsingLocal(null)
}}
onClose={() => setBrowsingLocal(null)}
+1
View File
@@ -220,6 +220,7 @@ export function StrmManagePage() {
{dialog === 'account' && (
<StrmAccountDialog
key={editingAccount?.id ?? 'new'}
existing={editingAccount}
onClose={() => setDialog(null)}
onSaved={() => { setDialog(null); refresh().catch(() => undefined) }}
-12
View File
@@ -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 -7
View File
@@ -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,
-4
View File
@@ -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,
+19
View File
@@ -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
@@ -21,8 +26,22 @@ export interface StrmAccount {
last_test_ok: boolean
has_credential: boolean
provider_label: string
config_preview?: StrmAccountConfigPreview
/** 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理 */
proxy_play?: boolean
/** 仅远程 Emby 挂载账号返回:多线路配置 */
emby_lines?: EmbyRemoteLine[]
/** 当前优先使用的线路下标 */
emby_active_line?: number
}
export interface StrmAccountConfigPreview {
url?: string
server?: string
username?: string
has_password?: boolean
has_token?: boolean
has_api_key?: boolean
}
export interface StrmAccountInput {
+31
View File
@@ -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: '' }]
}
+2 -2
View File
@@ -84,12 +84,12 @@ export function isEpisodeLike(media: Media): boolean {
// 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便
// 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。
const EPISODIC_PATH_RE =
/[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i
/[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i
const SEASON_FOLDER_RE =
/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$/i
function pathLooksEpisodic(media: Media): boolean {
export function pathLooksEpisodic(media: Media): boolean {
const path = (media.path || media.display_library_path || media.library_path || '')
return EPISODIC_PATH_RE.test(path)
}
+47
View File
@@ -0,0 +1,47 @@
import type { Media } from '../types'
import { isRemoteEmbyID } from './remoteEmby'
import { getSeriesKey, isEpisodeLike, pathLooksEpisodic } from './groupSeries'
function pathLooksLikeMovie(media: Media): boolean {
const path = (media.path || media.display_library_path || media.library_path || '').toLowerCase()
return /[\\/](?:movie|movies|film|films|电影)[\\/]/i.test(path)
}
function isRemoteEmbyLibrarySeries(media: Media): boolean {
if (!isRemoteEmbyID(media.id) || isEpisodeLike(media)) return false
const libraryID = media.display_library_id || media.library_id
if (!libraryID) return false
return !pathLooksLikeMovie(media)
}
/** Whether opening the library series detail view is a better target than /media/:id. */
export function prefersLibrarySeriesView(media: Media): boolean {
if (isEpisodeLike(media)) return true
if (pathLooksEpisodic(media)) return true
if (media.series_id) return true
if (isRemoteEmbyLibrarySeries(media)) return true
return false
}
/** Series key used by /library/:id?series=... for both local and remote libraries. */
export function resolveLibrarySeriesKey(media: Media): string {
if (isRemoteEmbyID(media.id) && !isEpisodeLike(media)) {
return media.id
}
return getSeriesKey(media)
}
/** Best library URL for a media item, or null when /media/:id is preferred. */
export function mediaLibraryTarget(media: Media): string | null {
const libraryID = media.display_library_id || media.library_id
if (!libraryID || !prefersLibrarySeriesView(media)) return null
const seriesKey = resolveLibrarySeriesKey(media)
const base = `/library/${encodeURIComponent(libraryID)}`
return seriesKey ? `${base}?series=${encodeURIComponent(seriesKey)}` : base
}
/** Link target for favourites / cards that should open the library series view when possible. */
export function favouriteMediaLink(media: Media): string {
return mediaLibraryTarget(media) ?? `/media/${media.id}`
}
+48
View File
@@ -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
})
}
+53
View File
@@ -0,0 +1,53 @@
/** 取路径最后一级目录/文件名(兼容 / 与 \\)。 */
export function lastPathSegment(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/, '')
if (!trimmed) return ''
const parts = trimmed.split(/[/\\]/).filter(Boolean)
return parts[parts.length - 1] ?? ''
}
function pathSeparator(path: string): '/' | '\\' {
return path.includes('\\') ? '\\' : '/'
}
function normalizeSlashes(path: string): string {
return path.replace(/\\/g, '/').replace(/\/+$/, '')
}
/** 去掉 local 末尾与 prevRemoteTail 匹配的自动拼接段。 */
function stripRemoteTail(localPath: string, prevRemoteTail: string): string {
if (!prevRemoteTail) return localPath.trim()
const trimmed = localPath.trim()
if (!trimmed) return ''
const norm = normalizeSlashes(trimmed)
const suffix = '/' + prevRemoteTail
if (norm.endsWith(suffix)) {
const stripped = norm.slice(0, -suffix.length)
if (!stripped) return ''
return stripped.split('/').join(pathSeparator(trimmed))
}
if (norm === prevRemoteTail) return ''
return trimmed
}
/** 将远端最后一级目录名拼到本地输出目录末尾;远端变更时替换旧尾段。 */
export function syncLocalPathWithRemote(
localPath: string,
remotePath: string,
prevRemoteTail = '',
): { localPath: string; remoteTail: string } {
const remoteTail = lastPathSegment(remotePath)
const base = stripRemoteTail(localPath, prevRemoteTail)
if (!remoteTail) {
return { localPath: base || localPath.trim(), remoteTail: '' }
}
if (!base) {
return { localPath: localPath.trim(), remoteTail }
}
const sep = pathSeparator(base)
const normBase = normalizeSlashes(base)
if (normBase.endsWith('/' + remoteTail)) {
return { localPath: base, remoteTail }
}
return { localPath: `${base}${sep}${remoteTail}`, remoteTail }
}