mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c723664e46 | |||
| 5faa6975d0 | |||
| cbfae0252e | |||
| 57ee482146 | |||
| a9b3110b2c | |||
| ff86c2f597 | |||
| a5731fa963 | |||
| 531e45f677 | |||
| cd3f028f0b | |||
| bd7b7a722b | |||
| 83deffcf45 | |||
| c74f6be250 | |||
| c911a57467 | |||
| 1dbc3f8fe7 | |||
| a563ee11d6 | |||
| 215ebf6942 | |||
| e4c0e85eaa | |||
| 60f05129c9 | |||
| fa4c90d47a | |||
| 1ec8303fec |
@@ -0,0 +1,2 @@
|
||||
'Get-Content' is not recognized as an internal or external command,
|
||||
operable program or batch file.
|
||||
@@ -112,7 +112,8 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var cfg struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
PlaybackRate float64 `json:"playback_rate"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -120,6 +121,9 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
if cfg.MergeSources {
|
||||
t.Fatal("default merge preference should be false")
|
||||
}
|
||||
if cfg.PlaybackRate != 1 {
|
||||
t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate)
|
||||
}
|
||||
|
||||
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
@@ -137,7 +141,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
@@ -154,7 +158,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
||||
t.Fatalf("unexpected render config: %+v", cfg)
|
||||
}
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 {
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 || cfg.PlaybackRate != 1.5 {
|
||||
t.Fatalf("unexpected user preferences: %+v", cfg)
|
||||
}
|
||||
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
||||
@@ -165,11 +169,30 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read persisted user: %v", err)
|
||||
}
|
||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
|
||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" {
|
||||
t.Fatalf("preferences not persisted: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"playback_rate":4}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read user: %v", err)
|
||||
}
|
||||
if user.PlayerPlaybackRate != 1 {
|
||||
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
|
||||
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
|
||||
// 否则多用户会串到同一个 UserId 上。
|
||||
func embyEffectiveUserID(c *gin.Context) string {
|
||||
return embyUserID(c)
|
||||
}
|
||||
|
||||
const embyCompatSessionTTL = 30 * time.Minute
|
||||
|
||||
type embyCompatSession struct {
|
||||
|
||||
@@ -13,13 +13,7 @@ import (
|
||||
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
||||
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
splitOpt := func(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
@@ -73,10 +67,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -106,13 +97,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
||||
if err != nil {
|
||||
@@ -126,13 +111,7 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
||||
if err != nil {
|
||||
@@ -147,13 +126,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc != nil && svc.Emby != nil {
|
||||
uid := firstQueryValue(c, "UserId", "userId")
|
||||
if uid == "" {
|
||||
uid = c.Param("userId")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -209,7 +182,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
UserID: embyEffectiveUserID(c),
|
||||
ParentID: c.Param("id"),
|
||||
Limit: 500,
|
||||
}
|
||||
@@ -230,7 +203,7 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
parentID = c.Param("id")
|
||||
}
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
UserID: embyEffectiveUserID(c),
|
||||
ParentID: parentID,
|
||||
IncludeItemTypes: []string{"Episode"},
|
||||
Recursive: true,
|
||||
|
||||
@@ -13,10 +13,7 @@ import (
|
||||
|
||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -47,10 +44,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
return
|
||||
}
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
ctx := c.Request.Context()
|
||||
// The official-format route carries a :format suffix (Stream.ass /
|
||||
// Stream.vtt); prefer it for the Content-Type when present, otherwise
|
||||
|
||||
@@ -92,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
@@ -116,10 +113,7 @@ func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||
|
||||
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
|
||||
@@ -10,10 +10,7 @@ import (
|
||||
|
||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.Views(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
@@ -13,6 +14,45 @@ type pinnedLibrariesReq struct {
|
||||
LibraryIDs []string `json:"library_ids"`
|
||||
}
|
||||
|
||||
type libraryTagsReq struct {
|
||||
Tags []model.LibraryTagSet `json:"tags"`
|
||||
}
|
||||
|
||||
func getLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
tags, err := svc.Profile.GetLibraryTags(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []model.LibraryTagSet{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
}
|
||||
|
||||
func setLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req libraryTagsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
tags, err := svc.Profile.SetLibraryTags(c.Request.Context(), uid.(string), req.Tags)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []model.LibraryTagSet{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
}
|
||||
|
||||
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
|
||||
@@ -12,6 +12,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
||||
authed.PATCH("/me", updateProfileHandler(svc))
|
||||
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
|
||||
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
|
||||
authed.GET("/me/library-tags", getLibraryTagsHandler(svc))
|
||||
authed.PUT("/me/library-tags", setLibraryTagsHandler(svc))
|
||||
authed.POST("/me/password", changePasswordHandler(svc))
|
||||
authed.POST("/me/logout", logoutHandler(svc))
|
||||
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||
|
||||
@@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
||||
"GET /api/me",
|
||||
"GET /api/me/pinned-libraries",
|
||||
"PUT /api/me/pinned-libraries",
|
||||
"GET /api/me/library-tags",
|
||||
"PUT /api/me/library-tags",
|
||||
"GET /api/auth/permissions",
|
||||
"GET /api/libraries",
|
||||
"GET /api/media",
|
||||
|
||||
@@ -89,7 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
|
||||
cards = sortRemoteSeriesCards(cards, sortSpec, history)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
||||
if page < 1 {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// sortRemoteSeriesCards keeps the DateLastContentAdded order returned by the
|
||||
// remote Emby server when LastAddedAt is not exposed in the Series DTO.
|
||||
func sortRemoteSeriesCards(cards []service.SeriesCard, spec service.MediaSortSpec, history map[string]time.Time) []service.SeriesCard {
|
||||
if spec.Field != "updated_at" {
|
||||
return service.SortSeriesCards(cards, spec.Field, spec.Order, history)
|
||||
}
|
||||
|
||||
out := append([]service.SeriesCard(nil), cards...)
|
||||
// RemoteSeriesCards always fetches DateLastContentAdded in descending order.
|
||||
if spec.Order == "asc" {
|
||||
slices.Reverse(out)
|
||||
}
|
||||
return service.SortSeriesCards(out, spec.Field, spec.Order, history)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestSortRemoteSeriesCardsUpdatedAtUsesRemoteOrder(t *testing.T) {
|
||||
cards := []service.SeriesCard{{Key: "newest"}, {Key: "middle"}, {Key: "oldest"}}
|
||||
|
||||
desc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "desc"}, nil)
|
||||
if desc[0].Key != "newest" || desc[1].Key != "middle" || desc[2].Key != "oldest" {
|
||||
t.Fatalf("descending order = %q, %q, %q", desc[0].Key, desc[1].Key, desc[2].Key)
|
||||
}
|
||||
|
||||
asc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "asc"}, nil)
|
||||
if asc[0].Key != "oldest" || asc[1].Key != "middle" || asc[2].Key != "newest" {
|
||||
t.Fatalf("ascending order = %q, %q, %q", asc[0].Key, asc[1].Key, asc[2].Key)
|
||||
}
|
||||
}
|
||||
@@ -92,13 +92,14 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID := toString(uid)
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
var rows []model.PlaybackHistory
|
||||
if err := svc.Repo.DB.
|
||||
Where("user_id = ? AND completed = ?", toString(uid), false).
|
||||
Where("user_id = ? AND completed = ?", userID, false).
|
||||
Order("watched_at desc").
|
||||
Limit(limit).
|
||||
Find(&rows).Error; err != nil {
|
||||
@@ -116,61 +117,52 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
mIdx := make(map[string]model.Media, len(media))
|
||||
for _, m := range media {
|
||||
if !mediaVisibleForRequest(c, svc, &m) {
|
||||
continue
|
||||
}
|
||||
mIdx[m.ID] = m
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
staleIDs := make([]string, 0)
|
||||
for _, r := range rows {
|
||||
m, ok := mIdx[r.MediaID]
|
||||
if !ok {
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
|
||||
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
|
||||
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
|
||||
if mediaVisibleForRequest(c, svc, rm) {
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": *rm,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback := fallbackHistoryMedia(r.MediaID)
|
||||
if fallback != nil {
|
||||
if ok {
|
||||
if mediaVisibleForRequest(c, svc, &m) {
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": *fallback,
|
||||
"media": m,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": m,
|
||||
})
|
||||
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
|
||||
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID)
|
||||
if resolveErr == nil && mount != nil && acct != nil {
|
||||
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID)
|
||||
if detailErr == nil && remoteMedia != nil {
|
||||
if mediaVisibleForRequest(c, svc, remoteMedia) {
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": *remoteMedia,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体记录已不存在。继续返回占位卡只会让用户点击后遇到 404,
|
||||
// 因此清理这条失效播放记录,不再占用继续观看列表。
|
||||
staleIDs = append(staleIDs, r.MediaID)
|
||||
}
|
||||
if len(staleIDs) > 0 {
|
||||
_ = svc.Repo.DB.WithContext(c.Request.Context()).Unscoped().
|
||||
Where("user_id = ? AND media_id IN ?", userID, staleIDs).
|
||||
Delete(&model.PlaybackHistory{}).Error
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackHistoryMedia(mediaID string) *model.Media {
|
||||
if mediaID == "" {
|
||||
return nil
|
||||
}
|
||||
title := "媒体"
|
||||
if service.IsEmbyRemoteID(mediaID) {
|
||||
title = "远程媒体"
|
||||
}
|
||||
return &model.Media{
|
||||
Base: model.Base{ID: mediaID},
|
||||
Title: title,
|
||||
}
|
||||
}
|
||||
|
||||
// historyDeleteHandler removes one or all history rows for the caller.
|
||||
//
|
||||
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid := model.Media{
|
||||
Base: model.Base{ID: "media-valid"},
|
||||
Title: "有效影片",
|
||||
Path: "/media/valid.mkv",
|
||||
}
|
||||
if err := db.Create(&valid).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := db.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "history-valid"},
|
||||
UserID: "user-1",
|
||||
MediaID: valid.ID,
|
||||
PositionMs: 30_000,
|
||||
DurationMs: 120_000,
|
||||
WatchedAt: now.Add(-time.Minute),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "history-stale"},
|
||||
UserID: "user-1",
|
||||
MediaID: "media-deleted",
|
||||
PositionMs: 60_000,
|
||||
DurationMs: 120_000,
|
||||
WatchedAt: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Log: zap.NewNop(),
|
||||
Repo: repos,
|
||||
Playback: service.NewPlaybackService(zap.NewNop(), repos),
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/watch-history/continue?limit=10", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var body []struct {
|
||||
History model.PlaybackHistory `json:"history"`
|
||||
Media model.Media `json:"media"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body) != 1 {
|
||||
t.Fatalf("continue watching rows = %d, want 1: %s", len(body), rec.Body.String())
|
||||
}
|
||||
if body[0].Media.ID != valid.ID || body[0].History.ID != "history-valid" {
|
||||
t.Fatalf("unexpected row: %#v", body[0])
|
||||
}
|
||||
|
||||
var staleCount int64
|
||||
if err := db.Unscoped().Model(&model.PlaybackHistory{}).
|
||||
Where("user_id = ? AND media_id = ?", "user-1", "media-deleted").
|
||||
Count(&staleCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if staleCount != 0 {
|
||||
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxLibraryTags 是单个用户可创建的标签数量上限,避免恶意写入过大的 JSON。
|
||||
const MaxLibraryTags = 50
|
||||
|
||||
// MaxLibraryTagNameLen 是单个标签名的最大字符长度(按 rune 计数)。
|
||||
const MaxLibraryTagNameLen = 24
|
||||
|
||||
// LibraryTagSet 是用户自定义的媒体库标签分组。
|
||||
// LibraryIDs 保存该标签下媒体库的 ID(含远程 Emby 挂载库的 embyremote~ 形式),
|
||||
// 顺序即媒体库在该标签内的展示顺序。
|
||||
type LibraryTagSet struct {
|
||||
Name string `json:"name"`
|
||||
LibraryIDs []string `json:"library_ids"`
|
||||
}
|
||||
|
||||
// DecodeLibraryTags 解析 LibraryTags 字段,忽略损坏的数据。
|
||||
func (u *User) DecodeLibraryTags() []LibraryTagSet {
|
||||
if u == nil || strings.TrimSpace(u.LibraryTags) == "" {
|
||||
return nil
|
||||
}
|
||||
var tags []LibraryTagSet
|
||||
if err := json.Unmarshal([]byte(u.LibraryTags), &tags); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]LibraryTagSet, 0, len(tags))
|
||||
for _, tag := range NormalizeLibraryTags(tags) {
|
||||
out = append(out, tag)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EncodeLibraryTags 把标签集合序列化为可写入 LibraryTags 字段的 JSON 字符串。
|
||||
// 空集合序列化为空字符串,便于用零值表达"没有标签"。
|
||||
func EncodeLibraryTags(tags []LibraryTagSet) (string, error) {
|
||||
if len(tags) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// NormalizeLibraryTags 清洗标签集合:去空白、丢弃空名标签、去掉标签名与
|
||||
// 标签内媒体库 ID 的重复项,并把标签名重复的项合并。保持传入顺序。
|
||||
func NormalizeLibraryTags(tags []LibraryTagSet) []LibraryTagSet {
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]LibraryTagSet, 0, len(tags))
|
||||
indexByName := make(map[string]int, len(tags))
|
||||
for _, tag := range tags {
|
||||
name := TruncateLibraryTagName(tag.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if len(out) >= MaxLibraryTags {
|
||||
break
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
pos, exists := indexByName[key]
|
||||
if !exists {
|
||||
if len(out) >= MaxLibraryTags {
|
||||
break
|
||||
}
|
||||
out = append(out, LibraryTagSet{Name: name, LibraryIDs: []string{}})
|
||||
pos = len(out) - 1
|
||||
indexByName[key] = pos
|
||||
}
|
||||
seen := make(map[string]struct{}, len(out[pos].LibraryIDs))
|
||||
for _, id := range out[pos].LibraryIDs {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range tag.LibraryIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out[pos].LibraryIDs = append(out[pos].LibraryIDs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].LibraryIDs == nil {
|
||||
out[i].LibraryIDs = []string{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TruncateLibraryTagName 去掉首尾空白并按 rune 截断到长度上限。
|
||||
func TruncateLibraryTagName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(name)
|
||||
if len(runes) > MaxLibraryTagNameLen {
|
||||
runes = runes[:MaxLibraryTagNameLen]
|
||||
}
|
||||
return strings.TrimSpace(string(runes))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeLibraryTagsMergesDuplicatesAndTrims(t *testing.T) {
|
||||
tags := []LibraryTagSet{
|
||||
{Name: " 动画 ", LibraryIDs: []string{" a ", "", "b", "a"}},
|
||||
{Name: "动画", LibraryIDs: []string{"c", "b"}},
|
||||
{Name: " ", LibraryIDs: []string{"x"}},
|
||||
{Name: "电影", LibraryIDs: nil},
|
||||
}
|
||||
got := NormalizeLibraryTags(tags)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("NormalizeLibraryTags len = %d, want 2 (%#v)", len(got), got)
|
||||
}
|
||||
if got[0].Name != "动画" {
|
||||
t.Fatalf("first tag name = %q, want 动画", got[0].Name)
|
||||
}
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got[0].LibraryIDs) != len(want) {
|
||||
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[0].LibraryIDs[i] != want[i] {
|
||||
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
|
||||
}
|
||||
}
|
||||
if got[1].Name != "电影" || got[1].LibraryIDs == nil {
|
||||
t.Fatalf("empty tag should survive with an empty non-nil id list: %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLibraryTagsCapsCountAndNameLength(t *testing.T) {
|
||||
long := make([]rune, MaxLibraryTagNameLen+10)
|
||||
for i := range long {
|
||||
long[i] = 'x'
|
||||
}
|
||||
got := NormalizeLibraryTags([]LibraryTagSet{{Name: string(long)}})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if runes := []rune(got[0].Name); len(runes) != MaxLibraryTagNameLen {
|
||||
t.Fatalf("name length = %d, want %d", len(runes), MaxLibraryTagNameLen)
|
||||
}
|
||||
|
||||
many := make([]LibraryTagSet, 0, MaxLibraryTags+5)
|
||||
for i := 0; i < MaxLibraryTags+5; i++ {
|
||||
many = append(many, LibraryTagSet{Name: string(rune('a' + i%26)) + "-" + string(rune('a'+i/26))})
|
||||
}
|
||||
if capped := NormalizeLibraryTags(many); len(capped) > MaxLibraryTags {
|
||||
t.Fatalf("capped len = %d, want <= %d", len(capped), MaxLibraryTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeLibraryTagsRoundTrip(t *testing.T) {
|
||||
user := &User{}
|
||||
if encoded, err := EncodeLibraryTags(nil); err != nil || encoded != "" {
|
||||
t.Fatalf("EncodeLibraryTags(nil) = %q, %v; want \"\", nil", encoded, err)
|
||||
}
|
||||
raw, err := EncodeLibraryTags([]LibraryTagSet{{Name: "动画", LibraryIDs: []string{"lib-1"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeLibraryTags: %v", err)
|
||||
}
|
||||
user.LibraryTags = raw
|
||||
decoded := user.DecodeLibraryTags()
|
||||
if len(decoded) != 1 || decoded[0].Name != "动画" || len(decoded[0].LibraryIDs) != 1 || decoded[0].LibraryIDs[0] != "lib-1" {
|
||||
t.Fatalf("DecodeLibraryTags = %#v", decoded)
|
||||
}
|
||||
|
||||
user.LibraryTags = "{not json"
|
||||
if decoded := user.DecodeLibraryTags(); decoded != nil {
|
||||
t.Fatalf("corrupt payload should decode to nil, got %#v", decoded)
|
||||
}
|
||||
|
||||
user.PopulateComputedFields()
|
||||
if user.LibraryTagList != nil {
|
||||
t.Fatalf("PopulateComputedFields should mirror DecodeLibraryTags, got %#v", user.LibraryTagList)
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,17 @@ type User struct {
|
||||
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
||||
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
||||
// LibraryTags 存储用户自定义的媒体库标签分组(JSON 字符串),
|
||||
// 形如 [{"name":"动画","library_ids":["lib-1","lib-2"]}]。标签属于用户本人,
|
||||
// 用于在媒体库页把同一标签下的媒体库聚合到一起。
|
||||
LibraryTags string `gorm:"type:text" json:"-"`
|
||||
LibraryTagList []LibraryTagSet `gorm:"-" json:"library_tags,omitempty"`
|
||||
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||
@@ -102,4 +108,5 @@ func (u *User) PopulateComputedFields() {
|
||||
}
|
||||
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
|
||||
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
|
||||
u.LibraryTagList = u.DecodeLibraryTags()
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ const (
|
||||
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
|
||||
const DanmakuDefaultSource = "https://api.dandanplay.net"
|
||||
|
||||
const (
|
||||
minPlayerPlaybackRate = 0.5
|
||||
maxPlayerPlaybackRate = 3.0
|
||||
defaultPlayerPlaybackRate = 1.0
|
||||
)
|
||||
|
||||
// danmakuOfficialBase is where identification (/api/v2/match) and the
|
||||
// comment/search fallback always go, regardless of the configured source.
|
||||
// A package var (not a const) so tests can point it at a local server.
|
||||
@@ -63,6 +69,8 @@ type DanmakuRenderConfig struct {
|
||||
FontSize string `json:"font_size"`
|
||||
Area string `json:"area"`
|
||||
Volume float64 `json:"volume"`
|
||||
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
||||
PlaybackRate float64 `json:"playback_rate"`
|
||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
||||
@@ -89,6 +97,7 @@ type DanmakuSettingsPatch struct {
|
||||
Area *float64 `json:"area"`
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
Volume *float64 `json:"volume"`
|
||||
PlaybackRate *float64 `json:"playback_rate"`
|
||||
}
|
||||
|
||||
type danmakuUserContextKey struct{}
|
||||
@@ -345,10 +354,11 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
|
||||
// Config reads danmaku settings from the runtime settings table.
|
||||
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
cfg := DanmakuRenderConfig{
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
PlaybackRate: defaultPlayerPlaybackRate,
|
||||
}
|
||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||
return cfg
|
||||
@@ -399,7 +409,14 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
|
||||
|
||||
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
if user == nil {
|
||||
return DanmakuRenderConfig{Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1}
|
||||
return DanmakuRenderConfig{
|
||||
Enabled: true,
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
PlaybackRate: defaultPlayerPlaybackRate,
|
||||
}
|
||||
}
|
||||
opacity := user.DanmakuOpacity
|
||||
if opacity < 0.1 || opacity > 1 {
|
||||
@@ -417,6 +434,10 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
if volume < 0 || volume > 1 {
|
||||
volume = 1
|
||||
}
|
||||
playbackRate := user.PlayerPlaybackRate
|
||||
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
|
||||
playbackRate = defaultPlayerPlaybackRate
|
||||
}
|
||||
return DanmakuRenderConfig{
|
||||
Enabled: user.DanmakuEnabled,
|
||||
Source: strings.TrimSpace(user.DanmakuSource),
|
||||
@@ -425,6 +446,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
FontSize: strconv.Itoa(fontSize),
|
||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||
Volume: volume,
|
||||
PlaybackRate: playbackRate,
|
||||
MergeSources: user.DanmakuMergeSources,
|
||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||
}
|
||||
@@ -495,6 +517,17 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
|
||||
}
|
||||
updates["player_volume"] = *patch.Volume
|
||||
}
|
||||
if patch.PlaybackRate != nil {
|
||||
if *patch.PlaybackRate < minPlayerPlaybackRate || *patch.PlaybackRate > maxPlayerPlaybackRate {
|
||||
return DanmakuRenderConfig{}, fmt.Errorf(
|
||||
"%w: playback_rate must be between %.2f and %.2f",
|
||||
ErrInvalidDanmakuSettings,
|
||||
minPlayerPlaybackRate,
|
||||
maxPlayerPlaybackRate,
|
||||
)
|
||||
}
|
||||
updates["player_playback_rate"] = *patch.PlaybackRate
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
||||
}
|
||||
|
||||
@@ -422,6 +422,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
||||
user.DanmakuFontSize = 30
|
||||
user.DanmakuArea = 0.7
|
||||
user.PlayerVolume = 0.42
|
||||
user.PlayerPlaybackRate = 1.25
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &user))
|
||||
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
|
||||
|
||||
@@ -430,6 +431,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Equal(t, srv.URL(), res.Source)
|
||||
require.Equal(t, 0.42, res.Volume)
|
||||
require.Equal(t, 1.25, res.PlaybackRate)
|
||||
require.Equal(t, "0.65", res.Opacity)
|
||||
require.Equal(t, "30", res.FontSize)
|
||||
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
|
||||
|
||||
@@ -205,6 +205,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
if containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||
return e.favoriteItems(ctx, p)
|
||||
}
|
||||
// 继续观看必须走 MeBox 本地 PlaybackHistory,不能转发到远程共用账号。
|
||||
if containsEmbyFilter(p.Filters, "IsResumable") {
|
||||
return e.resumableItems(ctx, p)
|
||||
}
|
||||
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
|
||||
@@ -38,14 +38,6 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite {
|
||||
userData, _ := out["UserData"].(map[string]any)
|
||||
if userData == nil {
|
||||
userData = map[string]any{}
|
||||
out["UserData"] = userData
|
||||
}
|
||||
userData["IsFavorite"] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
|
||||
@@ -218,7 +210,7 @@ func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID stri
|
||||
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||
return embyLatestCacheValue{Items: []map[string]any{}}, nil
|
||||
}
|
||||
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
||||
out, err := e.remote.RemoteLatestForDisplay(ctx, mount, acct, remoteParent, limit)
|
||||
if err != nil {
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
@@ -466,12 +458,26 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
mediaIDs := make([]string, 0, len(hist))
|
||||
localIDs := make([]string, 0, len(hist))
|
||||
for _, h := range hist {
|
||||
mediaIDs = append(mediaIDs, h.MediaID)
|
||||
if !IsEmbyRemoteID(h.MediaID) {
|
||||
localIDs = append(localIDs, h.MediaID)
|
||||
}
|
||||
}
|
||||
favSet := map[string]bool{}
|
||||
if len(mediaIDs) > 0 {
|
||||
var favs []model.Favorite
|
||||
if err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id IN ?", p.UserID, mediaIDs).
|
||||
Find(&favs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, fav := range favs {
|
||||
favSet[fav.MediaID] = true
|
||||
}
|
||||
}
|
||||
byID := map[string]*model.Media{}
|
||||
if len(localIDs) > 0 {
|
||||
var medias []model.Media
|
||||
@@ -498,7 +504,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
continue
|
||||
}
|
||||
localTotal++
|
||||
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
|
||||
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, favSet[h.MediaID], h.PositionMs, false)})
|
||||
continue
|
||||
}
|
||||
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
|
||||
@@ -559,7 +565,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
continue
|
||||
}
|
||||
}
|
||||
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
|
||||
item["UserData"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
|
||||
slots[i].item = item
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,6 +860,180 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoteLatestForDisplay 返回可直接展示的最新媒体条目。剧集库优先取 Series;
|
||||
// 远程 Latest 只返回 Episode 时,按 SeriesId 归并,避免调用方拿到单集 ID
|
||||
// 后无法在 Series 卡片列表中找到对应条目。
|
||||
func (r *EmbyRemoteService) RemoteLatestForDisplay(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
|
||||
if remoteCollectionLooksEpisodic(mount.CollectionType) {
|
||||
return r.RemoteLatestSeries(ctx, mount, acct, remoteViewID, limit)
|
||||
}
|
||||
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remoteItemsContainEpisodes(items) {
|
||||
return remoteSeriesItemsFromLatest(mount, items, limit), nil
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// RemoteLatestSeries 拉取剧集库最近更新的 Series。部分 Emby 服务不支持
|
||||
// DateLastContentAdded 或过滤 Series,此时回退到 Latest 并把 Episode 归并到
|
||||
// 对应 Series。
|
||||
//
|
||||
// 使用 Recursive=true 并跳过 anime/ 等中间容器,与 RemoteSeriesCards /
|
||||
// Emby 客户端列剧集方式一致。
|
||||
func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", remoteViewID)
|
||||
q.Set("IncludeItemTypes", "Series")
|
||||
q.Set("Recursive", "true")
|
||||
q.Set("SortBy", "DateLastContentAdded")
|
||||
q.Set("SortOrder", "Descending")
|
||||
// 多取一些以便滤掉中间容器后仍够 limit。
|
||||
q.Set("Limit", strconv.Itoa(limit*4))
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
|
||||
filtered := make([]map[string]any, 0, limit)
|
||||
for _, it := range body.Items {
|
||||
name := remoteItemString(it, "Name")
|
||||
path := remoteItemString(it, "Path")
|
||||
if remoteSeriesItemLooksLikeContainer(name, path) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, it)
|
||||
if len(filtered) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(filtered) > 0 {
|
||||
RewriteEmbyRemoteIDs(filtered, mount.ID)
|
||||
return filtered, nil
|
||||
}
|
||||
}
|
||||
|
||||
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return remoteSeriesItemsFromLatest(mount, items, limit), nil
|
||||
}
|
||||
|
||||
func remoteCollectionLooksEpisodic(collectionType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(collectionType)) {
|
||||
case "tvshows", "tv":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func remoteItemsContainEpisodes(items []map[string]any) bool {
|
||||
for _, item := range items {
|
||||
if strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func remoteSeriesItemsFromLatest(mount *model.EmbyMount, items []map[string]any, limit int) []map[string]any {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") {
|
||||
id := strings.TrimSpace(remoteItemString(item, "Id"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, item)
|
||||
continue
|
||||
}
|
||||
|
||||
seriesID := remoteItemSeriesID(item, mount)
|
||||
if seriesID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[seriesID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[seriesID] = struct{}{}
|
||||
out = append(out, remoteSeriesPayloadFromEpisode(mount, item, seriesID))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func remoteItemSeriesID(item map[string]any, mount *model.EmbyMount) string {
|
||||
seriesID := strings.TrimSpace(remoteItemString(item, "SeriesId"))
|
||||
if seriesID == "" {
|
||||
return ""
|
||||
}
|
||||
if _, rawID, ok := DecodeEmbyRemoteID(seriesID); ok {
|
||||
if mount == nil {
|
||||
return rawID
|
||||
}
|
||||
return EncodeEmbyRemoteID(mount.ID, rawID)
|
||||
}
|
||||
if mount == nil {
|
||||
return seriesID
|
||||
}
|
||||
return EncodeEmbyRemoteID(mount.ID, seriesID)
|
||||
}
|
||||
|
||||
func remoteSeriesPayloadFromEpisode(mount *model.EmbyMount, episode map[string]any, seriesID string) map[string]any {
|
||||
name := strings.TrimSpace(remoteItemString(episode, "SeriesName"))
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(remoteItemString(episode, "Name"))
|
||||
}
|
||||
out := map[string]any{
|
||||
"Id": seriesID,
|
||||
"Name": name,
|
||||
"Type": "Series",
|
||||
}
|
||||
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
|
||||
out["ParentId"] = EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
|
||||
}
|
||||
if tag := strings.TrimSpace(remoteItemString(episode, "SeriesPrimaryImageTag")); tag != "" {
|
||||
out["ImageTags"] = map[string]any{"Primary": tag}
|
||||
}
|
||||
year := remoteItemInt(episode, "SeriesProductionYear")
|
||||
if year == 0 {
|
||||
year = remoteItemInt(episode, "SeriesYear")
|
||||
}
|
||||
if year == 0 {
|
||||
year = remoteItemInt(episode, "ProductionYear")
|
||||
}
|
||||
if year > 0 {
|
||||
out["ProductionYear"] = year
|
||||
}
|
||||
for _, key := range []string{"Genres", "ProviderIds", "DateLastMediaAdded", "CommunityRating", "CriticRating", "OfficialRating"} {
|
||||
if value, ok := episode[key]; ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写
|
||||
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
|
||||
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
|
||||
|
||||
@@ -180,31 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
|
||||
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
|
||||
seriesID = rid
|
||||
}
|
||||
rating := remoteItemFloat(item, "CommunityRating")
|
||||
if rating == 0 {
|
||||
rating = remoteItemFloat(item, "CriticRating")
|
||||
}
|
||||
year := remoteItemInt(item, "ProductionYear")
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "Year")
|
||||
}
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "SeriesProductionYear")
|
||||
}
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "SeriesYear")
|
||||
}
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
||||
Title: remoteItemString(item, "Name"),
|
||||
OriginalName: remoteItemString(item, "OriginalTitle"),
|
||||
Overview: remoteItemString(item, "Overview"),
|
||||
Year: year,
|
||||
Rating: float32(rating),
|
||||
Path: remoteItemString(item, "Path"),
|
||||
Genres: remoteItemGenres(item),
|
||||
ScrapeStatus: "done",
|
||||
}
|
||||
rating := remoteItemFloat(item, "CommunityRating")
|
||||
if rating == 0 {
|
||||
rating = remoteItemFloat(item, "CriticRating")
|
||||
}
|
||||
year := remoteItemInt(item, "ProductionYear")
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "Year")
|
||||
}
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "SeriesProductionYear")
|
||||
}
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "SeriesYear")
|
||||
}
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
||||
Title: remoteItemString(item, "Name"),
|
||||
OriginalName: remoteItemString(item, "OriginalTitle"),
|
||||
Overview: remoteItemString(item, "Overview"),
|
||||
Year: year,
|
||||
Rating: float32(rating),
|
||||
Path: remoteItemString(item, "Path"),
|
||||
Genres: remoteItemGenres(item),
|
||||
ScrapeStatus: "done",
|
||||
}
|
||||
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
|
||||
media.CreatedAt = date
|
||||
media.UpdatedAt = date
|
||||
@@ -509,19 +509,24 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// remoteSeriesPageSize 是远程剧集分页拉取的每页条数:Fields 带 Overview/Path
|
||||
// 等重字段,单页 1000 条时载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断
|
||||
// 直接解析失败,因此按 200 条翻页拉全量。
|
||||
const remoteSeriesPageSize = 200
|
||||
|
||||
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
|
||||
//
|
||||
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
|
||||
// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次
|
||||
// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方
|
||||
// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时
|
||||
// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。
|
||||
// 与 Emby 客户端一致:IncludeItemTypes=Series + Recursive=true,按
|
||||
// DateLastContentAdded 倒序分页拉全库剧集。多媒体根下的 anime/ 等中间容器
|
||||
// 若偶发出现在结果里则过滤掉;LastAddedAt 仅在远程提供 DateLastMediaAdded
|
||||
// 时填充。
|
||||
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID)
|
||||
// v3:Recursive=true(对齐 Emby 客户端),与旧直属/下探缓存区分。
|
||||
cacheKey := r.remoteCacheKey("series-cards-v3", acct.ID, mount.ID, remoteViewID)
|
||||
var cached []SeriesCard
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
@@ -529,19 +534,17 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", remoteViewID)
|
||||
q.Set("IncludeItemTypes", "Series")
|
||||
q.Set("Recursive", "false")
|
||||
q.Set("Recursive", "true")
|
||||
q.Set("SortBy", "DateLastContentAdded")
|
||||
q.Set("SortOrder", "Descending")
|
||||
// 每页 200:Fields 带全量重字段(Overview/MediaStreams 等)时单页 1000
|
||||
// 条的载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断直接解析失败。
|
||||
q.Set("Limit", "200")
|
||||
q.Set("Limit", strconv.Itoa(remoteSeriesPageSize))
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
TotalRecordCount int64 `json:"TotalRecordCount"`
|
||||
}
|
||||
cards := make([]SeriesCard, 0)
|
||||
for startIndex := 0; ; startIndex += 200 {
|
||||
for startIndex := 0; ; startIndex += remoteSeriesPageSize {
|
||||
q.Set("StartIndex", strconv.Itoa(startIndex))
|
||||
body.Items = nil
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
|
||||
@@ -551,9 +554,13 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
||||
break
|
||||
}
|
||||
for _, it := range body.Items {
|
||||
name := remoteItemString(it, "Name")
|
||||
path := remoteItemString(it, "Path")
|
||||
if remoteSeriesItemLooksLikeContainer(name, path) {
|
||||
continue
|
||||
}
|
||||
RewriteEmbyRemoteIDs(it, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
|
||||
count := remoteItemInt(it, "RecursiveItemCount")
|
||||
if count == 0 {
|
||||
count = remoteItemInt(it, "ChildCount")
|
||||
@@ -565,9 +572,16 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
||||
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
|
||||
lastAdded = &date
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
|
||||
cards = append(cards, SeriesCard{
|
||||
Key: m.ID,
|
||||
Rep: m,
|
||||
LinkMedia: m,
|
||||
Count: count,
|
||||
IsSeries: true,
|
||||
LastAddedAt: lastAdded,
|
||||
})
|
||||
}
|
||||
if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < 1000 {
|
||||
if int64(startIndex+len(body.Items)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -577,6 +591,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func remoteSeriesItemLooksLikeContainer(name, path string) bool {
|
||||
if isEmbyGenericContainer(name) {
|
||||
return true
|
||||
}
|
||||
base := pathBaseSlash(strings.TrimRight(strings.ReplaceAll(path, "\\", "/"), "/"))
|
||||
return base != "" && isEmbyGenericContainer(base)
|
||||
}
|
||||
|
||||
// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。
|
||||
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
@@ -589,52 +611,14 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。
|
||||
// 避免 Emby /Items/Latest 默认返回无年份/无系列海报的单集(Episode)。
|
||||
if mount != nil && (mount.CollectionType == "tvshows" || mount.CollectionType == "tv") {
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", remoteViewID)
|
||||
q.Set("IncludeItemTypes", "Series")
|
||||
q.Set("Recursive", "false")
|
||||
q.Set("SortBy", "DateLastContentAdded")
|
||||
q.Set("SortOrder", "Descending")
|
||||
q.Set("Limit", strconv.Itoa(limit))
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
|
||||
cards := make([]SeriesCard, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
count := remoteItemInt(it, "RecursiveItemCount")
|
||||
if count == 0 {
|
||||
count = remoteItemInt(it, "ChildCount")
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
var lastAdded *time.Time
|
||||
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
|
||||
lastAdded = &date
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
}
|
||||
|
||||
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
|
||||
items, err := r.RemoteLatestForDisplay(ctx, mount, acct, remoteViewID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards := make([]SeriesCard, 0, len(items))
|
||||
for _, it := range items {
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
isSeries := strings.EqualFold(strings.TrimSpace(remoteItemString(it, "Type")), "Series")
|
||||
var lastAdded *time.Time
|
||||
if !m.UpdatedAt.IsZero() {
|
||||
t := m.UpdatedAt
|
||||
@@ -647,7 +631,17 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
||||
if count == 0 {
|
||||
count = remoteItemInt(it, "ChildCount")
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
cards = append(cards, SeriesCard{
|
||||
Key: m.ID,
|
||||
Rep: m,
|
||||
LinkMedia: m,
|
||||
Count: count,
|
||||
IsSeries: isSeries,
|
||||
LastAddedAt: lastAdded,
|
||||
})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -120,10 +121,15 @@ func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) {
|
||||
}
|
||||
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
|
||||
q := r.URL.Query()
|
||||
if q.Get("ParentId") != "view-1" || q.Get("IncludeItemTypes") != "Series" {
|
||||
if q.Get("IncludeItemTypes") != "Series" || q.Get("ParentId") != "view-1" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if q.Get("Recursive") != "true" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("expected Recursive=true"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
@@ -230,6 +236,11 @@ func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) {
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
|
||||
q := r.URL.Query()
|
||||
if q.Get("ParentId") != "view-2" || q.Get("Recursive") != "true" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
@@ -465,6 +476,9 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
|
||||
if cards[0].Count != 12 {
|
||||
t.Fatalf("expected Count 12, got %d", cards[0].Count)
|
||||
}
|
||||
if !cards[0].IsSeries {
|
||||
t.Fatalf("expected remote latest card to be marked as series")
|
||||
}
|
||||
if cards[0].Rep.PosterURL == "" {
|
||||
t.Fatalf("expected PosterURL not empty")
|
||||
}
|
||||
@@ -476,6 +490,439 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLatestCardsTvShowsGroupsEpisodeFallbackBySeries(t *testing.T) {
|
||||
var seriesQueryCalled atomic.Bool
|
||||
var latestCalled atomic.Bool
|
||||
var requestedSeriesType atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch strings.TrimPrefix(r.URL.Path, "/emby") {
|
||||
case "/Users/remote-user/Items":
|
||||
seriesQueryCalled.Store(true)
|
||||
requestedSeriesType.Store(r.URL.Query().Get("IncludeItemTypes"))
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": []map[string]any{},
|
||||
"TotalRecordCount": 0,
|
||||
})
|
||||
case "/Users/remote-user/Items/Latest":
|
||||
latestCalled.Store(true)
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{
|
||||
"Id": "episode-2",
|
||||
"Name": "第二集",
|
||||
"Type": "Episode",
|
||||
"SeriesId": "series-b",
|
||||
"SeriesName": "剧集 B",
|
||||
"SeriesProductionYear": 2025,
|
||||
"SeriesPrimaryImageTag": "poster-b",
|
||||
},
|
||||
{
|
||||
"Id": "episode-1",
|
||||
"Name": "第一集",
|
||||
"Type": "Episode",
|
||||
"SeriesId": "series-a",
|
||||
"SeriesName": "剧集 A",
|
||||
"SeriesProductionYear": 2024,
|
||||
},
|
||||
{
|
||||
"Id": "episode-1b",
|
||||
"Name": "第一集下",
|
||||
"Type": "Episode",
|
||||
"SeriesId": "series-a",
|
||||
"SeriesName": "剧集 A",
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
"remote_user_id": "remote-user",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-tv-fallback"},
|
||||
Name: "tv-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-tv-fallback"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-tv",
|
||||
RemoteViewName: "剧集库",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
cards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteLatestCards failed: %v", err)
|
||||
}
|
||||
if !seriesQueryCalled.Load() || !latestCalled.Load() {
|
||||
t.Fatalf("expected both Series query and Latest fallback, series=%v latest=%v", seriesQueryCalled.Load(), latestCalled.Load())
|
||||
}
|
||||
if got, _ := requestedSeriesType.Load().(string); got != "Series" {
|
||||
t.Fatalf("series query IncludeItemTypes = %q, want Series", got)
|
||||
}
|
||||
if len(cards) != 2 {
|
||||
t.Fatalf("expected 2 deduplicated series cards, got %d: %#v", len(cards), cards)
|
||||
}
|
||||
wantKeys := []string{
|
||||
EncodeEmbyRemoteID(mount.ID, "series-b"),
|
||||
EncodeEmbyRemoteID(mount.ID, "series-a"),
|
||||
}
|
||||
for i, want := range wantKeys {
|
||||
if cards[i].Key != want {
|
||||
t.Fatalf("card[%d].Key = %q, want %q", i, cards[i].Key, want)
|
||||
}
|
||||
if !cards[i].IsSeries {
|
||||
t.Fatalf("card[%d] should be marked as series", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLatestItemsRemoteTvShowsUseSeriesIDs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch strings.TrimPrefix(r.URL.Path, "/emby") {
|
||||
case "/Users/remote-user/Items":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": []map[string]any{},
|
||||
"TotalRecordCount": 0,
|
||||
})
|
||||
case "/Users/remote-user/Items/Latest":
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{
|
||||
"Id": "episode-10",
|
||||
"Name": "第十集",
|
||||
"Type": "Episode",
|
||||
"SeriesId": "series-10",
|
||||
"SeriesName": "远程剧集",
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t,
|
||||
&model.Setting{},
|
||||
&model.User{},
|
||||
&model.Favorite{},
|
||||
&model.PlaybackHistory{},
|
||||
&model.StrmAccount{},
|
||||
&model.EmbyMount{},
|
||||
)
|
||||
repos := repository.New(db)
|
||||
remoteSvc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
svc := NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetEmbyRemote(remoteSvc)
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
"remote_user_id": "remote-user",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-emby-latest"},
|
||||
Name: "tv-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-emby-latest"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-tv",
|
||||
RemoteViewName: "剧集库",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
items, err := svc.LatestItems(t.Context(), "user-1", EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("LatestItems failed: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 series item, got %d: %#v", len(items), items)
|
||||
}
|
||||
if items[0]["Type"] != "Series" {
|
||||
t.Fatalf("latest item Type = %#v, want Series", items[0]["Type"])
|
||||
}
|
||||
wantID := EncodeEmbyRemoteID(mount.ID, "series-10")
|
||||
if items[0]["Id"] != wantID {
|
||||
t.Fatalf("latest item Id = %#v, want %q", items[0]["Id"], wantID)
|
||||
}
|
||||
}
|
||||
|
||||
// 远程剧集库分页拉全量:超过一页时必须继续翻页,否则第 201 条之后的剧集
|
||||
// 既不会出现在媒体库列表里,首页深链过来的 ?series= 也无从命中。
|
||||
func TestRemoteSeriesCardsFetchAllPages(t *testing.T) {
|
||||
const totalSeries = remoteSeriesPageSize*2 + 50
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
query := r.URL.Query()
|
||||
if query.Get("Recursive") != "true" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("expected Recursive=true"))
|
||||
return
|
||||
}
|
||||
startIndex, _ := strconv.Atoi(query.Get("StartIndex"))
|
||||
limit, _ := strconv.Atoi(query.Get("Limit"))
|
||||
requests.Add(1)
|
||||
items := make([]map[string]any, 0, limit)
|
||||
for i := startIndex; i < totalSeries && len(items) < limit; i++ {
|
||||
items = append(items, map[string]any{
|
||||
"Id": "series-" + strconv.Itoa(i),
|
||||
"Name": "剧集 " + strconv.Itoa(i),
|
||||
"Type": "Series",
|
||||
"RecursiveItemCount": 12,
|
||||
})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": items,
|
||||
"TotalRecordCount": totalSeries,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
"remote_user_id": "remote-user",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-series-pages"},
|
||||
Name: "tv-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-series-pages"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-tv",
|
||||
RemoteViewName: "电视剧",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteSeriesCards failed: %v", err)
|
||||
}
|
||||
if len(cards) != totalSeries {
|
||||
t.Fatalf("expected all %d series, got %d", totalSeries, len(cards))
|
||||
}
|
||||
if got := requests.Load(); got != 3 {
|
||||
t.Fatalf("expected 3 paged requests, got %d", got)
|
||||
}
|
||||
lastKey := EncodeEmbyRemoteID(mount.ID, "series-"+strconv.Itoa(totalSeries-1))
|
||||
if cards[len(cards)-1].Key != lastKey {
|
||||
t.Fatalf("last card key = %q, want %q", cards[len(cards)-1].Key, lastKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 与 Emby 客户端一致:Recursive=true 拉全库 Series,并过滤 anime 等中间容器。
|
||||
func TestRemoteSeriesCardsRecursiveFiltersAnimeContainers(t *testing.T) {
|
||||
var sawRecursive atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("ParentId") != "view-2023" || q.Get("IncludeItemTypes") != "Series" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if q.Get("Recursive") == "true" {
|
||||
sawRecursive.Store(true)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 5,
|
||||
"Items": []map[string]any{
|
||||
{"Id": "c0", "Name": "anime", "Type": "Series", "Path": "https://cdn/0/anime/", "RecursiveItemCount": 3086},
|
||||
{"Id": "c1", "Name": "anime", "Type": "Series", "Path": "https://cdn/1/anime/", "RecursiveItemCount": 567},
|
||||
{"Id": "s-a", "Name": "数码宝贝 BEATBREAK", "Type": "Series", "Path": "https://cdn/0/anime/digimon", "RecursiveItemCount": 24},
|
||||
{"Id": "s-b", "Name": "活死喵之夜", "Type": "Series", "Path": "https://cdn/0/anime/nyaight", "RecursiveItemCount": 12},
|
||||
{"Id": "s-c", "Name": "药屋少女的呢喃", "Type": "Series", "Path": "https://cdn/1/anime/kusuriya", "RecursiveItemCount": 51},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
"remote_user_id": "remote-user",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-recursive"},
|
||||
Name: "nijigem",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-recursive"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-2023",
|
||||
RemoteViewName: "2023前 动漫",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteSeriesCards failed: %v", err)
|
||||
}
|
||||
if !sawRecursive.Load() {
|
||||
t.Fatal("expected Recursive=true on remote Items request")
|
||||
}
|
||||
if len(cards) != 3 {
|
||||
t.Fatalf("cards = %d, want 3 real series after filtering anime containers", len(cards))
|
||||
}
|
||||
for _, card := range cards {
|
||||
if strings.EqualFold(card.Rep.Title, "anime") {
|
||||
t.Fatalf("container title %q should have been filtered", card.Rep.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 首页「最新条目」卡片 key 必须与媒体库剧集列表的 key 一致,否则点击后
|
||||
// 媒体库页找不到目标剧集,只能退回整库列表。
|
||||
func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) {
|
||||
var seriesQueryCalls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
seriesQueryCalls.Add(1)
|
||||
parentID := r.URL.Query().Get("ParentId")
|
||||
if parentID == "series-latest" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": []map[string]any{},
|
||||
"TotalRecordCount": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": []map[string]any{
|
||||
{
|
||||
"Id": "series-latest",
|
||||
"Name": "刚刚更新",
|
||||
"Type": "Series",
|
||||
"RecursiveItemCount": 24,
|
||||
},
|
||||
},
|
||||
"TotalRecordCount": 1,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
"remote_user_id": "remote-user",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-latest-key"},
|
||||
Name: "tv-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-latest-key"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-tv",
|
||||
RemoteViewName: "电视剧",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
latestCards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteLatestCards failed: %v", err)
|
||||
}
|
||||
if len(latestCards) != 1 || !latestCards[0].IsSeries {
|
||||
t.Fatalf("expected 1 series latest card, got %#v", latestCards)
|
||||
}
|
||||
seriesCards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteSeriesCards failed: %v", err)
|
||||
}
|
||||
if seriesQueryCalls.Load() < 2 {
|
||||
t.Fatalf("expected both latest and series list queries, got %d", seriesQueryCalls.Load())
|
||||
}
|
||||
found := false
|
||||
for _, card := range seriesCards {
|
||||
if card.Key == latestCards[0].Key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("latest card key %q not present in series list keys %#v", latestCards[0].Key, seriesCards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLatestFields(t *testing.T) {
|
||||
var requestedFields string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入
|
||||
// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。只写入 MeBox 本地 favourites 表,
|
||||
// 按 user_id 隔离;挂载远程 Emby 共用账号,不能再透传收藏以免串用户。
|
||||
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
|
||||
if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
|
||||
return err
|
||||
@@ -22,15 +22,8 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f
|
||||
}
|
||||
|
||||
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
|
||||
// 远程 Emby 条目直接透传到对应服务器(本地不落库)。
|
||||
// 远程挂载条目同样只落本地 PlaybackHistory,按 MeBox 用户隔离。
|
||||
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
if err := e.ProxyRemoteSetPlayed(ctx, acctID, remoteID, played); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !played {
|
||||
err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
@@ -40,15 +33,20 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
|
||||
}
|
||||
return err
|
||||
}
|
||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
dur := int64(0)
|
||||
if IsEmbyRemoteID(mediaID) {
|
||||
dur = remoteItemDurationMs(ctx, e, mediaID)
|
||||
} else {
|
||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
dur = int64(m.DurationSec) * 1000
|
||||
}
|
||||
dur := int64(m.DurationSec) * 1000
|
||||
if dur <= 0 {
|
||||
dur = 1
|
||||
}
|
||||
err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
||||
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
PositionMs: dur,
|
||||
@@ -62,6 +60,36 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
|
||||
return err
|
||||
}
|
||||
|
||||
func remoteItemDurationMs(ctx context.Context, e *EmbyService, mediaID string) int64 {
|
||||
if e == nil || e.remote == nil || !IsEmbyRemoteID(mediaID) {
|
||||
return 0
|
||||
}
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
return 0
|
||||
}
|
||||
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
||||
if err != nil || item == nil {
|
||||
return 0
|
||||
}
|
||||
switch ticks := item["RunTimeTicks"].(type) {
|
||||
case float64:
|
||||
if ticks > 0 {
|
||||
return int64(ticks) / 10_000
|
||||
}
|
||||
case int64:
|
||||
if ticks > 0 {
|
||||
return ticks / 10_000
|
||||
}
|
||||
case int:
|
||||
if ticks > 0 {
|
||||
return int64(ticks) / 10_000
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
|
||||
// 不携带 PlaySessionId 的旧调用仍保持兼容。
|
||||
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
|
||||
@@ -138,8 +166,9 @@ func embyPlaySessionStartedAtMs(playSessionID string) int64 {
|
||||
return value
|
||||
}
|
||||
|
||||
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
|
||||
// and favourite state to remote Emby payloads.
|
||||
// mergeRemoteUserData overlays the current MeBox user's locally recorded
|
||||
// playback and favourite state onto remote Emby payloads. Remote mounts share
|
||||
// one upstream Emby account, so upstream UserData must never leak across MeBox users.
|
||||
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
|
||||
if strings.TrimSpace(userID) == "" || payload == nil {
|
||||
return nil
|
||||
@@ -178,18 +207,7 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
|
||||
}
|
||||
for _, item := range items {
|
||||
id, _ := item["Id"].(string)
|
||||
userData, _ := item["UserData"].(map[string]any)
|
||||
if h := byMediaID[id]; h != nil {
|
||||
item["UserData"] = mergedRemoteUserData(userData, h)
|
||||
userData, _ = item["UserData"].(map[string]any)
|
||||
}
|
||||
if favSet[id] {
|
||||
if userData == nil {
|
||||
userData = map[string]any{}
|
||||
item["UserData"] = userData
|
||||
}
|
||||
userData["IsFavorite"] = true
|
||||
}
|
||||
item["UserData"] = applyMeBoxUserData(item["UserData"], byMediaID[id], favSet[id])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -221,12 +239,37 @@ func remoteItemMaps(payload any) []map[string]any {
|
||||
}
|
||||
|
||||
func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]any {
|
||||
favorite := false
|
||||
if existing, ok := raw.(map[string]any); ok {
|
||||
if v, ok := existing["IsFavorite"].(bool); ok {
|
||||
favorite = v
|
||||
}
|
||||
}
|
||||
return applyMeBoxUserData(raw, history, favorite)
|
||||
}
|
||||
|
||||
// applyMeBoxUserData rebuilds UserData for a remote item using only MeBox-local
|
||||
// per-user state. Shared upstream Emby favourite/progress fields are discarded.
|
||||
func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool) map[string]any {
|
||||
userData := map[string]any{}
|
||||
if existing, ok := raw.(map[string]any); ok {
|
||||
for key, value := range existing {
|
||||
userData[key] = value
|
||||
switch key {
|
||||
case "IsFavorite", "PlaybackPositionTicks", "Played", "PlayedPercentage", "PlayCount", "LastPlayedDate":
|
||||
continue
|
||||
default:
|
||||
userData[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
userData["IsFavorite"] = favorite
|
||||
if history == nil {
|
||||
userData["PlaybackPositionTicks"] = int64(0)
|
||||
userData["Played"] = false
|
||||
userData["PlayedPercentage"] = float64(0)
|
||||
userData["PlayCount"] = 0
|
||||
return userData
|
||||
}
|
||||
duration := history.DurationMs
|
||||
position := history.PositionMs
|
||||
percentage := float64(0)
|
||||
@@ -237,18 +280,9 @@ func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]an
|
||||
userData["Played"] = history.Completed
|
||||
userData["PlayedPercentage"] = percentage
|
||||
if history.Completed {
|
||||
playCount := 0
|
||||
switch value := userData["PlayCount"].(type) {
|
||||
case int:
|
||||
playCount = value
|
||||
case int64:
|
||||
playCount = int(value)
|
||||
case float64:
|
||||
playCount = int(value)
|
||||
}
|
||||
if playCount < 1 {
|
||||
userData["PlayCount"] = 1
|
||||
}
|
||||
userData["PlayCount"] = 1
|
||||
} else {
|
||||
userData["PlayCount"] = 0
|
||||
}
|
||||
return userData
|
||||
}
|
||||
|
||||
@@ -18,16 +18,18 @@ func TestMergedRemoteUserData(t *testing.T) {
|
||||
preserve any
|
||||
}{
|
||||
{
|
||||
name: "in-progress preserves remote fields",
|
||||
name: "in-progress preserves non-user remote fields only",
|
||||
raw: map[string]any{
|
||||
"PlayCount": 2,
|
||||
"Custom": "remote-value",
|
||||
"PlayCount": 2,
|
||||
"IsFavorite": true,
|
||||
"PlaybackPositionTicks": int64(999),
|
||||
"Custom": "remote-value",
|
||||
},
|
||||
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
|
||||
position: 250_000_000,
|
||||
played: false,
|
||||
percent: 25,
|
||||
count: 2,
|
||||
count: 0,
|
||||
preserve: "remote-value",
|
||||
},
|
||||
{
|
||||
@@ -59,10 +61,47 @@ func TestMergedRemoteUserData(t *testing.T) {
|
||||
if tt.preserve != nil && out["Custom"] != tt.preserve {
|
||||
t.Fatalf("Custom = %#v, want %#v", out["Custom"], tt.preserve)
|
||||
}
|
||||
if out["IsFavorite"] != false && out["IsFavorite"] != true {
|
||||
t.Fatalf("IsFavorite missing: %#v", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMeBoxUserDataClearsSharedRemoteState(t *testing.T) {
|
||||
out := applyMeBoxUserData(map[string]any{
|
||||
"IsFavorite": true,
|
||||
"PlaybackPositionTicks": int64(42_000_000),
|
||||
"Played": true,
|
||||
"PlayedPercentage": 80.0,
|
||||
"PlayCount": 3,
|
||||
"Key": "keep",
|
||||
}, nil, false)
|
||||
if out["IsFavorite"] != false {
|
||||
t.Fatalf("IsFavorite = %#v, want false", out["IsFavorite"])
|
||||
}
|
||||
if out["PlaybackPositionTicks"] != int64(0) {
|
||||
t.Fatalf("PlaybackPositionTicks = %#v, want 0", out["PlaybackPositionTicks"])
|
||||
}
|
||||
if out["Played"] != false {
|
||||
t.Fatalf("Played = %#v, want false", out["Played"])
|
||||
}
|
||||
if out["PlayedPercentage"] != float64(0) {
|
||||
t.Fatalf("PlayedPercentage = %#v, want 0", out["PlayedPercentage"])
|
||||
}
|
||||
if out["PlayCount"] != 0 {
|
||||
t.Fatalf("PlayCount = %#v, want 0", out["PlayCount"])
|
||||
}
|
||||
if out["Key"] != "keep" {
|
||||
t.Fatalf("Key = %#v, want keep", out["Key"])
|
||||
}
|
||||
|
||||
fav := applyMeBoxUserData(map[string]any{"IsFavorite": false}, nil, true)
|
||||
if fav["IsFavorite"] != true {
|
||||
t.Fatalf("favorite overlay IsFavorite = %#v, want true", fav["IsFavorite"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
|
||||
remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
|
||||
payload := map[string]any{
|
||||
@@ -108,3 +147,57 @@ func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
|
||||
t.Fatalf("expected 95%% progress to be completed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkPlayedStoresRemoteItemLocallyPerUser(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
remoteID := EncodeEmbyRemoteID("mount-test", "item-played")
|
||||
alice := &model.User{Username: "alice_played", Role: "user", Tier: "free", IsActive: true}
|
||||
bob := &model.User{Username: "bob_played", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := svc.repo.User.Create(t.Context(), alice); err != nil {
|
||||
t.Fatalf("create alice: %v", err)
|
||||
}
|
||||
if err := svc.repo.User.Create(t.Context(), bob); err != nil {
|
||||
t.Fatalf("create bob: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.MarkPlayed(t.Context(), alice.ID, remoteID, true); err != nil {
|
||||
t.Fatalf("mark played: %v", err)
|
||||
}
|
||||
|
||||
var aliceRows, bobRows int64
|
||||
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", alice.ID, remoteID).Count(&aliceRows)
|
||||
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", bob.ID, remoteID).Count(&bobRows)
|
||||
if aliceRows != 1 {
|
||||
t.Fatalf("alice history rows = %d, want 1", aliceRows)
|
||||
}
|
||||
if bobRows != 0 {
|
||||
t.Fatalf("bob should not see alice remote played state, rows=%d", bobRows)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"Id": remoteID,
|
||||
"UserData": map[string]any{
|
||||
"IsFavorite": true,
|
||||
"PlaybackPositionTicks": int64(50_000_000),
|
||||
"Played": true,
|
||||
},
|
||||
}
|
||||
if err := svc.mergeRemoteUserData(t.Context(), bob.ID, payload); err != nil {
|
||||
t.Fatalf("merge for bob: %v", err)
|
||||
}
|
||||
bobData := payload["UserData"].(map[string]any)
|
||||
if bobData["IsFavorite"] != false {
|
||||
t.Fatalf("bob IsFavorite leaked: %#v", bobData)
|
||||
}
|
||||
if bobData["Played"] != false || bobData["PlaybackPositionTicks"] != int64(0) {
|
||||
t.Fatalf("bob playback leaked: %#v", bobData)
|
||||
}
|
||||
|
||||
if err := svc.mergeRemoteUserData(t.Context(), alice.ID, payload); err != nil {
|
||||
t.Fatalf("merge for alice: %v", err)
|
||||
}
|
||||
aliceData := payload["UserData"].(map[string]any)
|
||||
if aliceData["Played"] != true {
|
||||
t.Fatalf("alice Played = %#v, want true", aliceData["Played"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,21 +10,14 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// SyncUserFavorite keeps favourite state aligned across the local favourites table
|
||||
// and the upstream remote Emby server for mounted items.
|
||||
func SyncUserFavorite(ctx context.Context, repo *repository.Container, remote *EmbyRemoteService, userID, mediaID string, favorite bool) error {
|
||||
// SyncUserFavorite keeps favourite state in the local favourites table, keyed
|
||||
// by MeBox user_id. Remote Emby mounts share one upstream account, so favourite
|
||||
// state is intentionally not proxied upstream.
|
||||
func SyncUserFavorite(ctx context.Context, repo *repository.Container, _ *EmbyRemoteService, userID, mediaID string, favorite bool) error {
|
||||
if repo == nil || userID == "" || mediaID == "" {
|
||||
return errors.New("missing favourite sync inputs")
|
||||
}
|
||||
if err := setLocalFavorite(ctx, repo, userID, mediaID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
if favorite || IsEmbyRemoteID(mediaID) {
|
||||
if err := proxyRemoteFavorite(ctx, remote, mediaID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return setLocalFavorite(ctx, repo, userID, mediaID, favorite)
|
||||
}
|
||||
|
||||
// IsUserFavorite reports whether the user has favourited mediaID locally.
|
||||
@@ -57,18 +50,3 @@ func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, m
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
Delete(&model.Favorite{}).Error
|
||||
}
|
||||
|
||||
func proxyRemoteFavorite(ctx context.Context, remote *EmbyRemoteService, mediaID string, favorite bool) error {
|
||||
if remote == nil || !IsEmbyRemoteID(mediaID) {
|
||||
return nil
|
||||
}
|
||||
mountID, remoteItemID, ok := DecodeEmbyRemoteID(mediaID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
_, acct, err := remote.ResolveMount(ctx, mountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return remote.ProxySetFavorite(ctx, acct, remoteItemID, favorite)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type SeriesCard struct {
|
||||
Rep model.Media `json:"rep"`
|
||||
LinkMedia model.Media `json:"linkMedia"`
|
||||
Count int `json:"count"`
|
||||
IsSeries bool `json:"is_series,omitempty"`
|
||||
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ type SeriesCardView struct {
|
||||
Rep model.Media `json:"rep"`
|
||||
LinkLibraryID string `json:"linkLibraryId,omitempty"`
|
||||
Count int `json:"count"`
|
||||
IsSeries bool `json:"is_series,omitempty"`
|
||||
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView {
|
||||
Rep: card.Rep,
|
||||
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
|
||||
Count: card.Count,
|
||||
IsSeries: card.IsSeries,
|
||||
LastAddedAt: card.LastAddedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,9 @@ func SortSeriesCards(cards []SeriesCard, field, order string, history map[string
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
cmp := compareSeriesByField(out[i], out[j], spec, history)
|
||||
if cmp == 0 {
|
||||
// updated_at 表示最后一集添加日期。远程卡片可能没有该字段,此时
|
||||
// 保持调用方给定的顺序,不要用 ID 再次打乱。
|
||||
if cmp == 0 && spec.Field != "updated_at" {
|
||||
cmp = compareID(out[i].Key, out[j].Key)
|
||||
}
|
||||
return cmp < 0
|
||||
@@ -173,7 +175,7 @@ func seriesUpdatedTime(card SeriesCard) (time.Time, bool) {
|
||||
if card.LastAddedAt != nil && !card.LastAddedAt.IsZero() {
|
||||
return *card.LastAddedAt, true
|
||||
}
|
||||
return mediaUpdatedTime(card.Rep)
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func seriesLastPlayed(card SeriesCard, history map[string]time.Time) (time.Time, bool) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
@@ -40,3 +41,45 @@ func TestSortSeriesCardsTitleAsc(t *testing.T) {
|
||||
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortSeriesCardsUpdatedAtDoesNotFallbackToMediaDate(t *testing.T) {
|
||||
olderMediaDate := time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
newerMediaDate := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
cards := []SeriesCard{
|
||||
{
|
||||
Key: "remote-first",
|
||||
Rep: model.Media{Base: model.Base{
|
||||
ID: "remote-first",
|
||||
CreatedAt: olderMediaDate,
|
||||
UpdatedAt: olderMediaDate,
|
||||
}},
|
||||
},
|
||||
{
|
||||
Key: "remote-second",
|
||||
Rep: model.Media{Base: model.Base{
|
||||
ID: "remote-second",
|
||||
CreatedAt: newerMediaDate,
|
||||
UpdatedAt: newerMediaDate,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
got := SortSeriesCards(cards, "updated_at", "desc", nil)
|
||||
if got[0].Key != "remote-first" || got[1].Key != "remote-second" {
|
||||
t.Fatalf("series order = %q, %q, want remote-provided order", got[0].Key, got[1].Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortSeriesCardsUpdatedAtSortsKnownLastEpisodeDates(t *testing.T) {
|
||||
older := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
newer := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
cards := []SeriesCard{
|
||||
{Key: "older", LastAddedAt: &older},
|
||||
{Key: "newer", LastAddedAt: &newer},
|
||||
}
|
||||
|
||||
got := SortSeriesCards(cards, "updated_at", "desc", nil)
|
||||
if got[0].Key != "newer" || got[1].Key != "older" {
|
||||
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,85 @@ func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string,
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// GetLibraryTags returns the user's library tag groups, filtered to libraries
|
||||
// the user can still access. Empty tags are kept so an editor does not lose a
|
||||
// tag that was just created.
|
||||
func (p *ProfileService) GetLibraryTags(ctx context.Context, userID string) ([]model.LibraryTagSet, error) {
|
||||
user, err := p.repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
tags := model.NormalizeLibraryTags(user.DecodeLibraryTags())
|
||||
if len(tags) == 0 {
|
||||
return []model.LibraryTagSet{}, nil
|
||||
}
|
||||
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
|
||||
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range tags {
|
||||
tags[i].LibraryIDs = filterPinnedLibraryIDs(tags[i].LibraryIDs, accessible)
|
||||
if tags[i].LibraryIDs == nil {
|
||||
tags[i].LibraryIDs = []string{}
|
||||
}
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// SetLibraryTags persists the user's library tag groups after dropping
|
||||
// inaccessible libraries. A library belongs to at most one tag: the first tag
|
||||
// that lists it wins, so the saved state always matches the tab UI.
|
||||
func (p *ProfileService) SetLibraryTags(ctx context.Context, userID string, tags []model.LibraryTagSet) ([]model.LibraryTagSet, error) {
|
||||
if userID == "" {
|
||||
return nil, errors.New("missing user id")
|
||||
}
|
||||
user, err := p.repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
|
||||
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := model.NormalizeLibraryTags(tags)
|
||||
if len(normalized) > model.MaxLibraryTags {
|
||||
normalized = normalized[:model.MaxLibraryTags]
|
||||
}
|
||||
claimed := make(map[string]struct{})
|
||||
for i := range normalized {
|
||||
filtered := make([]string, 0, len(normalized[i].LibraryIDs))
|
||||
for _, id := range filterPinnedLibraryIDs(normalized[i].LibraryIDs, accessible) {
|
||||
if _, taken := claimed[id]; taken {
|
||||
continue
|
||||
}
|
||||
claimed[id] = struct{}{}
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
normalized[i].LibraryIDs = filtered
|
||||
}
|
||||
raw, err := model.EncodeLibraryTags(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.repo.User.UpdateFields(ctx, userID, map[string]any{
|
||||
"library_tags": raw,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalized == nil {
|
||||
normalized = []model.LibraryTagSet{}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (p *ProfileService) accessibleLibraryIDSet(ctx context.Context, visibility MediaVisibility) (map[string]struct{}, error) {
|
||||
libs, err := p.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProfileLibraryTagsFiltersInaccessibleAndKeepsOneTagPerLibrary(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
|
||||
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
libA := &model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
libB := &model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true}
|
||||
libHidden := &model.Library{Name: "Adult", Path: "/media/adult", Type: "movie", Enabled: true}
|
||||
for _, lib := range []*model.Library{libA, libB, libHidden} {
|
||||
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repos.User.UpdateFields(t.Context(), user.ID, map[string]any{
|
||||
"allowed_library_ids": `["` + libA.ID + `","` + libB.ID + `"]`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{
|
||||
{Name: " 热门 ", LibraryIDs: []string{libA.ID, libHidden.ID, "missing", libA.ID}},
|
||||
{Name: "热门", LibraryIDs: []string{libB.ID, libA.ID}},
|
||||
{Name: "", LibraryIDs: []string{libB.ID}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetLibraryTags: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("SetLibraryTags returned %d tags, want 1 (%#v)", len(got), got)
|
||||
}
|
||||
if got[0].Name != "热门" {
|
||||
t.Fatalf("tag name = %q, want 热门", got[0].Name)
|
||||
}
|
||||
// libA 已在第一个标签里占位,第二个标签里的 libA 应被丢弃。
|
||||
want := []string{libA.ID, libB.ID}
|
||||
if len(got[0].LibraryIDs) != len(want) {
|
||||
t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[0].LibraryIDs[i] != want[i] {
|
||||
t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want)
|
||||
}
|
||||
}
|
||||
|
||||
loaded, err := svc.GetLibraryTags(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLibraryTags: %v", err)
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0].Name != "热门" || len(loaded[0].LibraryIDs) != len(want) {
|
||||
t.Fatalf("GetLibraryTags = %#v, want one tag with %v", loaded, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileLibraryTagsKeepsEmptyTagAndMountedEmby(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
|
||||
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
AccountID: "acct-1",
|
||||
RemoteViewID: "view-42",
|
||||
RemoteViewName: "Remote Movies",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
|
||||
|
||||
got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{
|
||||
{Name: "远程", LibraryIDs: []string{remoteID, "embyremote~missing~view"}},
|
||||
{Name: "以后再用", LibraryIDs: []string{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetLibraryTags: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("SetLibraryTags = %#v, want 2 tags", got)
|
||||
}
|
||||
if len(got[0].LibraryIDs) != 1 || got[0].LibraryIDs[0] != remoteID {
|
||||
t.Fatalf("remote tag ids = %v, want [%s]", got[0].LibraryIDs, remoteID)
|
||||
}
|
||||
if got[1].Name != "以后再用" || len(got[1].LibraryIDs) != 0 {
|
||||
t.Fatalf("empty tag should survive as-is, got %#v", got[1])
|
||||
}
|
||||
|
||||
loaded, err := svc.GetLibraryTags(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLibraryTags: %v", err)
|
||||
}
|
||||
if len(loaded) != 2 || len(loaded[1].LibraryIDs) != 0 {
|
||||
t.Fatalf("GetLibraryTags = %#v, want the empty tag preserved", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileLibraryTagsEmptyStateReturnsEmptySlice(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
|
||||
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tags, err := svc.GetLibraryTags(t.Context(), user.ID); err != nil || tags == nil || len(tags) != 0 {
|
||||
t.Fatalf("GetLibraryTags = %#v, %v; want empty non-nil slice", tags, err)
|
||||
}
|
||||
if tags, err := svc.SetLibraryTags(t.Context(), user.ID, nil); err != nil || tags == nil || len(tags) != 0 {
|
||||
t.Fatalf("SetLibraryTags(nil) = %#v, %v; want empty non-nil slice", tags, err)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export interface DanmakuConfig {
|
||||
font_size: string
|
||||
area: string
|
||||
volume: number
|
||||
playback_rate: number
|
||||
/** Per-user preference: merge the same episode's multiple sources. */
|
||||
merge_sources: boolean
|
||||
}
|
||||
@@ -87,6 +88,7 @@ export interface DanmakuSettingsPatch {
|
||||
area?: number
|
||||
merge_sources?: boolean
|
||||
volume?: number
|
||||
playback_rate?: number
|
||||
}
|
||||
|
||||
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from './client'
|
||||
import type { User } from '../types'
|
||||
import type { LibraryTagSet, User } from '../types'
|
||||
|
||||
export const profileAPI = {
|
||||
get: () => api.get<User>('/me').then((r) => r.data),
|
||||
@@ -21,6 +21,12 @@ export const profileAPI = {
|
||||
setPinnedLibraries: (libraryIds: string[]) =>
|
||||
api.put<{ library_ids: string[] }>('/me/pinned-libraries', { library_ids: libraryIds }).then((r) => r.data.library_ids ?? []),
|
||||
|
||||
getLibraryTags: () =>
|
||||
api.get<{ tags: LibraryTagSet[] | null }>('/me/library-tags').then((r) => r.data.tags ?? []),
|
||||
|
||||
setLibraryTags: (tags: LibraryTagSet[]) =>
|
||||
api.put<{ tags: LibraryTagSet[] | null }>('/me/library-tags', { tags }).then((r) => r.data.tags ?? []),
|
||||
|
||||
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
|
||||
api.patch<User>(`/admin/users/${id}/role`, { role }).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function GlobalEvents() {
|
||||
}
|
||||
}
|
||||
if (topic === 'scrape' && p.finished) {
|
||||
if (role !== 'admin') return
|
||||
const processed = Number(p.processed ?? 0)
|
||||
const matched = Number(p.matched ?? 0)
|
||||
const failed = Number(p.failed ?? 0)
|
||||
@@ -51,6 +52,7 @@ export function GlobalEvents() {
|
||||
}
|
||||
}
|
||||
if (topic === 'subscription') {
|
||||
if (role !== 'admin') return
|
||||
const queued = (p.queued as number | undefined) ?? 0
|
||||
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,11 @@ export function Layout() {
|
||||
pathname={location.pathname}
|
||||
/>
|
||||
)}
|
||||
<LayoutWorkspace routeKey={location.pathname} showMobileBottomNav={showMobileBottomNav} />
|
||||
<LayoutWorkspace
|
||||
routeKey={location.pathname}
|
||||
userKey={user?.id}
|
||||
showMobileBottomNav={showMobileBottomNav}
|
||||
/>
|
||||
{showMobileBottomNav && (
|
||||
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||
import { isPlayerRoute } from './layoutNavigation'
|
||||
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||
import { useScrollMemory } from '../hooks/useScrollMemory'
|
||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||
|
||||
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
|
||||
@@ -30,6 +31,7 @@ type LayoutSidebarsProps = Omit<
|
||||
|
||||
type LayoutWorkspaceProps = {
|
||||
routeKey: string
|
||||
userKey?: string
|
||||
showMobileBottomNav?: boolean
|
||||
}
|
||||
|
||||
@@ -109,7 +111,13 @@ export function LayoutSidebars({
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: LayoutWorkspaceProps) {
|
||||
export function LayoutWorkspace({
|
||||
routeKey,
|
||||
userKey = 'anonymous',
|
||||
showMobileBottomNav = false,
|
||||
}: LayoutWorkspaceProps) {
|
||||
useScrollMemory(routeKey, userKey)
|
||||
|
||||
const bottomPad = showMobileBottomNav
|
||||
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
||||
: ''
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Check } from 'lucide-react'
|
||||
|
||||
import {
|
||||
LIBRARY_LIST_SORT_OPTIONS,
|
||||
type LibraryListSortField,
|
||||
type LibraryListSortOption,
|
||||
type LibraryListSortOrder,
|
||||
} from '../utils/libraryListSort'
|
||||
|
||||
type LibraryListSortDropdownProps = {
|
||||
value: LibraryListSortField
|
||||
order: LibraryListSortOrder
|
||||
onChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LibraryListSortDropdown({
|
||||
value,
|
||||
order,
|
||||
onChange,
|
||||
className = '',
|
||||
}: LibraryListSortDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const currentOption = LIBRARY_LIST_SORT_OPTIONS.find((opt) => opt.id === value) ?? LIBRARY_LIST_SORT_OPTIONS[0]
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleSelect = (option: LibraryListSortOption) => {
|
||||
if (value === option.id) {
|
||||
onChange(option.id, order === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
onChange(option.id, option.defaultOrder)
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 sm:h-10 sm:text-sm"
|
||||
title="更改媒体库列表排序"
|
||||
>
|
||||
<ArrowUpDown size={14} className="text-sand-500" />
|
||||
<span>排序: {currentOption.label}</span>
|
||||
{order === 'asc' ? (
|
||||
<ArrowUp size={13} className="font-bold text-brand-600" />
|
||||
) : (
|
||||
<ArrowDown size={13} className="font-bold text-brand-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 z-50 mt-1.5 w-44 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="border-b border-sand-100 px-2.5 py-1.5 text-[11px] font-bold text-sand-500">
|
||||
排序方式
|
||||
</div>
|
||||
<div className="space-y-0.5 py-1">
|
||||
{LIBRARY_LIST_SORT_OPTIONS.map((option) => {
|
||||
const isSelected = value === option.id
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-50 font-semibold text-brand-700'
|
||||
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && <Check size={13} className="text-brand-600" />}
|
||||
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="flex items-center text-brand-600">
|
||||
{order === 'asc' ? (
|
||||
<ArrowUp size={14} className="stroke-[2.5]" />
|
||||
) : (
|
||||
<ArrowDown size={14} className="stroke-[2.5]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Layers, Plus, Settings2, X } from 'lucide-react'
|
||||
|
||||
import { MAX_LIBRARY_TAGS } from '../utils/libraryTags'
|
||||
import type { LibraryTagTab } from '../utils/libraryTags'
|
||||
|
||||
/**
|
||||
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
|
||||
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
|
||||
*/
|
||||
export function LibraryTagBar({
|
||||
tabs,
|
||||
selectedTagId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onManage,
|
||||
manageLabel = '管理标签',
|
||||
showCreate = true,
|
||||
busy = false,
|
||||
}: {
|
||||
tabs: LibraryTagTab[]
|
||||
selectedTagId: string
|
||||
onSelect: (tagId: string) => void
|
||||
onCreate?: (name: string) => void
|
||||
onManage?: () => void
|
||||
manageLabel?: string
|
||||
showCreate?: boolean
|
||||
busy?: boolean
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
// 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。
|
||||
const submittedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) {
|
||||
submittedRef.current = false
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [creating])
|
||||
|
||||
const hasTags = useMemo(() => tabs.some((tab) => !tab.isAll), [tabs])
|
||||
|
||||
const submit = () => {
|
||||
if (submittedRef.current) return
|
||||
submittedRef.current = true
|
||||
const name = draft.trim()
|
||||
setDraft('')
|
||||
setCreating(false)
|
||||
if (!name) return
|
||||
onCreate?.(name)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
submittedRef.current = true
|
||||
setDraft('')
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--app-border)] pb-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === selectedTagId
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(tab.id)}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
|
||||
active
|
||||
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
|
||||
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
|
||||
}`}
|
||||
title={tab.isAll ? '显示全部媒体库' : `只看「${tab.name}」标签下的媒体库`}
|
||||
>
|
||||
{tab.isAll && <Layers size={13} />}
|
||||
<span className="max-w-[8rem] truncate">{tab.name}</span>
|
||||
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(showCreate || onManage) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{showCreate && onCreate && (creating || !hasTags) && (
|
||||
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setDraft('')
|
||||
setCreating(false)
|
||||
}
|
||||
}}
|
||||
onBlur={() => submit()}
|
||||
maxLength={24}
|
||||
placeholder="新标签名"
|
||||
className="h-6 w-24 bg-transparent text-xs font-semibold text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
|
||||
title={`最多 24 个字,当前最多 ${MAX_LIBRARY_TAGS} 个标签`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={submit}
|
||||
disabled={busy || !draft.trim()}
|
||||
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)] disabled:opacity-40"
|
||||
title="创建标签"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={cancel}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="取消"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && onCreate && !creating && hasTags && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
disabled={busy || tabs.length - 1 >= MAX_LIBRARY_TAGS}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-dashed border-[var(--app-border)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-muted)] transition-colors hover:border-brand-500/50 hover:text-brand-500 disabled:opacity-40"
|
||||
title="新建标签"
|
||||
>
|
||||
<Plus size={13} />
|
||||
<span>新建标签</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManage}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500"
|
||||
title="重命名 / 删除标签,调整标签下的媒体库"
|
||||
>
|
||||
<Settings2 size={13} />
|
||||
<span>{manageLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { confirmAction } from './confirmAction'
|
||||
import type { Library } from '../types'
|
||||
import {
|
||||
MAX_LIBRARY_TAGS,
|
||||
filterLibrariesForTagging,
|
||||
normalizeTagName,
|
||||
tagNameError,
|
||||
type LibraryTag,
|
||||
} from '../utils/libraryTags'
|
||||
|
||||
export type ManageLibraryTagsDialogProps = {
|
||||
tags: LibraryTag[]
|
||||
libraries: Library[]
|
||||
saving: boolean
|
||||
onCreate: (name: string) => Promise<string>
|
||||
onRename: (from: string, to: string) => Promise<void>
|
||||
onRemove: (name: string) => Promise<void>
|
||||
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
|
||||
onAssign: (libraryId: string, tagName: string) => Promise<void>
|
||||
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
|
||||
onAssignBatch: (libraryIds: string[], tagName: string) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** 「未分类」在筛选/批量目标里的哨兵值,与标签名不会冲突。 */
|
||||
const UNTAGGED = '__untagged__'
|
||||
|
||||
/**
|
||||
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把媒体库归入
|
||||
* 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。
|
||||
*
|
||||
* 右侧支持搜索、按当前标签筛选、多选后一次性批量归类,避免逐条点击下拉框。
|
||||
*/
|
||||
export function ManageLibraryTagsDialogView({
|
||||
tags,
|
||||
libraries,
|
||||
saving,
|
||||
onCreate,
|
||||
onRename,
|
||||
onRemove,
|
||||
onAssign,
|
||||
onAssignBatch,
|
||||
onClose,
|
||||
}: ManageLibraryTagsDialogProps) {
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [renaming, setRenaming] = useState('')
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
const [busyLibrary, setBusyLibrary] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [tagFilter, setTagFilter] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [batchTag, setBatchTag] = useState('')
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const tagByLibrary = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const tag of tags) {
|
||||
for (const id of tag.library_ids) {
|
||||
if (!map.has(id)) map.set(id, tag.name)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [tags])
|
||||
|
||||
// 标签被删除/重命名后,批量目标与筛选值可能已经失效,落回默认项。
|
||||
useEffect(() => {
|
||||
if (batchTag && batchTag !== UNTAGGED && !tags.some((tag) => tag.name === batchTag)) {
|
||||
setBatchTag('')
|
||||
}
|
||||
if (tagFilter && tagFilter !== UNTAGGED && !tags.some((tag) => tag.name === tagFilter)) {
|
||||
setTagFilter('')
|
||||
}
|
||||
}, [tags, batchTag, tagFilter])
|
||||
|
||||
// 媒体库被删除后清掉已选项,避免批量应用时带着不存在的 ID。
|
||||
useEffect(() => {
|
||||
const available = new Set(libraries.map((lib) => lib.id))
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.filter((id) => available.has(id))
|
||||
return next.length === prev.length ? prev : next
|
||||
})
|
||||
}, [libraries])
|
||||
|
||||
const visibleLibraries = useMemo(() => {
|
||||
const searched = filterLibrariesForTagging(libraries, keyword)
|
||||
if (!tagFilter) return searched
|
||||
return searched.filter((lib) => {
|
||||
const current = tagByLibrary.get(lib.id) ?? ''
|
||||
return tagFilter === UNTAGGED ? current === '' : current === tagFilter
|
||||
})
|
||||
}, [libraries, keyword, tagFilter, tagByLibrary])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
const visibleSelectedCount = visibleLibraries.reduce(
|
||||
(sum, lib) => sum + (selectedSet.has(lib.id) ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
const allVisibleSelected = visibleLibraries.length > 0 && visibleSelectedCount === visibleLibraries.length
|
||||
|
||||
const toggleLibrary = useCallback((libraryId: string) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId],
|
||||
)
|
||||
}, [])
|
||||
|
||||
const toggleVisible = useCallback(() => {
|
||||
const visibleIds = visibleLibraries.map((lib) => lib.id)
|
||||
setSelectedIds((prev) => {
|
||||
const current = new Set(prev)
|
||||
const everySelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id))
|
||||
if (everySelected) return prev.filter((id) => !visibleIds.includes(id))
|
||||
for (const id of visibleIds) current.add(id)
|
||||
return Array.from(current)
|
||||
})
|
||||
}, [visibleLibraries])
|
||||
|
||||
const handleCreate = async () => {
|
||||
const error = tagNameError(draftName, tags)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
return
|
||||
}
|
||||
if (tags.length >= MAX_LIBRARY_TAGS) {
|
||||
toast.error(`最多 ${MAX_LIBRARY_TAGS} 个标签`)
|
||||
return
|
||||
}
|
||||
const created = await onCreate(normalizeTagName(draftName))
|
||||
if (created) {
|
||||
setDraftName('')
|
||||
toast.success(`标签「${created}」已创建`)
|
||||
} else {
|
||||
toast.error('标签创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRename = async (from: string) => {
|
||||
const error = tagNameError(renameDraft, tags, from)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
return
|
||||
}
|
||||
await onRename(from, renameDraft)
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}
|
||||
|
||||
const handleRemove = async (tag: LibraryTag) => {
|
||||
const confirmed = await confirmAction({
|
||||
title: '删除标签',
|
||||
message: `确定删除标签「${tag.name}」?\n媒体库本身不会被删除,只会回到「未分类」。`,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await onRemove(tag.name)
|
||||
}
|
||||
|
||||
const handleAssign = async (library: Library, tagName: string) => {
|
||||
setBusyLibrary(library.id)
|
||||
try {
|
||||
await onAssign(library.id, tagName)
|
||||
} finally {
|
||||
setBusyLibrary('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyBatch = async () => {
|
||||
if (selectedIds.length === 0 || applying) return
|
||||
setApplying(true)
|
||||
try {
|
||||
await onAssignBatch(selectedIds, batchTag)
|
||||
const count = selectedIds.length
|
||||
toast.success(
|
||||
batchTag ? `已将 ${count} 个媒体库归入「${batchTag}」` : `已将 ${count} 个媒体库移出标签`,
|
||||
)
|
||||
setSelectedIds([])
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const busy = saving || applying
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 backdrop-blur-sm sm:p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[calc(100dvh-1rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-[var(--app-panel)] shadow-2xl sm:h-[82vh] sm:rounded-3xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag size={18} className="text-brand-500" />
|
||||
<h3 className="font-display text-base font-bold text-[var(--app-text)] sm:text-lg">管理标签</h3>
|
||||
{busy && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-1 gap-4 overflow-hidden p-3 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
|
||||
<section className="flex min-h-0 flex-col gap-3">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">标签</h4>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void handleCreate()
|
||||
}
|
||||
}}
|
||||
maxLength={24}
|
||||
placeholder="新建标签名"
|
||||
className="input-base !py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={busy || !draftName.trim()}
|
||||
className="btn-outline shrink-0 !px-3 !py-2 text-xs"
|
||||
title="创建标签"
|
||||
>
|
||||
<Plus size={14} />
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||
{tags.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
|
||||
还没有标签。先创建一个,再把媒体库归入其中。
|
||||
</p>
|
||||
) : (
|
||||
tags.map((tag) => (
|
||||
<div
|
||||
key={tag.name}
|
||||
className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2"
|
||||
>
|
||||
{renaming === tag.name ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(event) => setRenameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void handleRename(tag.name)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}
|
||||
}}
|
||||
maxLength={24}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRename(tag.name)}
|
||||
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="保存名称"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="取消"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTagFilter(tag.name)}
|
||||
className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500"
|
||||
title={`只看「${tag.name}」标签下的媒体库`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
||||
{tag.library_ids.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRenaming(tag.name)
|
||||
setRenameDraft(tag.name)
|
||||
}}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove(tag)}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
|
||||
title="删除标签"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col gap-3 border-t border-[var(--app-border)] pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
|
||||
媒体库归类({libraries.length})
|
||||
</h4>
|
||||
<span className="text-xs text-[var(--app-muted)]">勾选多个后可一次性设置标签</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索名称 / 路径 / 类型"
|
||||
className="input-base h-9 min-w-[10rem] flex-1 !py-0 text-xs"
|
||||
/>
|
||||
<select
|
||||
value={tagFilter}
|
||||
onChange={(event) => setTagFilter(event.target.value)}
|
||||
className="input-base h-9 w-32 shrink-0 !py-0 text-xs"
|
||||
title="筛选媒体库"
|
||||
>
|
||||
<option value="">全部标签</option>
|
||||
<option value={UNTAGGED}>未分类</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVisible}
|
||||
disabled={visibleLibraries.length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500 disabled:opacity-40"
|
||||
title="全选 / 取消全选当前列表中的媒体库"
|
||||
>
|
||||
<Check size={13} />
|
||||
{allVisibleSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<span className="text-xs font-semibold text-[var(--app-subtle)]">
|
||||
已选 {selectedIds.length} 个
|
||||
</span>
|
||||
<select
|
||||
value={batchTag}
|
||||
onChange={(event) => setBatchTag(event.target.value)}
|
||||
className="input-base ml-auto h-8 w-36 shrink-0 !py-0 text-xs"
|
||||
title="选择要批量设置的标签"
|
||||
>
|
||||
<option value="">未分类(移出标签)</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleApplyBatch()}
|
||||
disabled={busy || selectedIds.length === 0}
|
||||
className="btn-outline shrink-0 !px-3 !py-1.5 text-xs"
|
||||
title="把选中媒体库的标签一次性设为左侧所选值"
|
||||
>
|
||||
{applying && <Loader2 size={13} className="animate-spin" />}
|
||||
应用到已选
|
||||
</button>
|
||||
{selectedIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIds([])}
|
||||
disabled={applying}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||
title="清空选择"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||
{visibleLibraries.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
|
||||
{libraries.length === 0 ? '暂无可归类的媒体库。' : '没有符合当前筛选条件的媒体库。'}
|
||||
</p>
|
||||
) : (
|
||||
visibleLibraries.map((library) => {
|
||||
const current = tagByLibrary.get(library.id) ?? ''
|
||||
const checked = selectedSet.has(library.id)
|
||||
return (
|
||||
<div
|
||||
key={library.id}
|
||||
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
|
||||
checked
|
||||
? 'border-brand-500/50 bg-brand-500/5'
|
||||
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleLibrary(library.id)}
|
||||
disabled={applying}
|
||||
className="h-4 w-4 shrink-0 accent-brand-400"
|
||||
title="勾选后可批量设置标签"
|
||||
/>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
|
||||
title={library.path || library.name}
|
||||
>
|
||||
{library.name}
|
||||
</span>
|
||||
{busyLibrary === library.id && (
|
||||
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
|
||||
)}
|
||||
<select
|
||||
value={current}
|
||||
disabled={busy || busyLibrary === library.id}
|
||||
onChange={(event) => void handleAssign(library, event.target.value)}
|
||||
className="input-base h-8 w-32 shrink-0 !py-0 text-xs"
|
||||
title="选择该媒体库所属标签"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-[var(--app-border)] px-4 py-3 sm:px-6">
|
||||
<span className="text-xs text-[var(--app-muted)]">标签按用户保存;一个媒体库同时只属于一个标签。</span>
|
||||
<button type="button" onClick={onClose} className="btn-outline !px-4 !py-2 text-sm">
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import { Film, Play, Layers, Star } from 'lucide-react'
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
const ACTION_OVERLAY_CLASS =
|
||||
'absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-100 transition-opacity sm:pointer-events-none sm:opacity-0 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 sm:focus-within:pointer-events-auto sm:focus-within:opacity-100'
|
||||
|
||||
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
|
||||
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
|
||||
export const MediaCard = memo(function MediaCard({
|
||||
@@ -79,25 +82,18 @@ export const MediaCard = memo(function MediaCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Episode count badge */}
|
||||
{count !== undefined && count > 1 && (
|
||||
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
|
||||
<Layers size={10} className="text-[#c9954a]" />
|
||||
<span>{count} 集</span>
|
||||
{/* Episode count badge:统一放右下角(桌面端与移动端一致),与左上角评分彻底错开 */}
|
||||
{(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
|
||||
<span className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-lg border border-white/15 bg-[#111827]/90 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm">
|
||||
<Layers size={10} className="shrink-0 text-[#c9954a]" />
|
||||
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{count === undefined && versionCount > 1 && (
|
||||
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
|
||||
<Layers size={10} className="text-[#c9954a]" />
|
||||
<span>{versionCount} 版本</span>
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Rating Badge */}
|
||||
{displayRating > 0 && (
|
||||
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm">
|
||||
<Star size={10} fill="currentColor" />
|
||||
<span className="absolute left-2 top-2 inline-flex items-center gap-0.5 rounded-lg border border-white/15 bg-[#111827]/90 px-1.5 py-0.5 text-[10px] font-bold text-[#c9954a] shadow-sm">
|
||||
<Star size={10} fill="currentColor" className="shrink-0" />
|
||||
<span>{displayRating.toFixed(1)}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -160,7 +156,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
{card}
|
||||
</button>
|
||||
{actionContent && (
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<div className={ACTION_OVERLAY_CLASS}>
|
||||
{actionContent}
|
||||
</div>
|
||||
)}
|
||||
@@ -174,7 +170,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
<Link to={href} className="block">
|
||||
{card}
|
||||
</Link>
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<div className={ACTION_OVERLAY_CLASS}>
|
||||
{actionContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,11 +18,18 @@ import {
|
||||
Rewind,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Timer,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import type { SubtitleTrack } from '../api/subtitles'
|
||||
import type { PlaybackQuality } from '../types'
|
||||
import {
|
||||
formatPlaybackRate,
|
||||
normalizePlaybackRate,
|
||||
PLAYBACK_RATE_OPTIONS,
|
||||
stepPlaybackRate,
|
||||
} from '../utils/playbackRate'
|
||||
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
|
||||
import {
|
||||
SUBTITLE_POSITION_OPTIONS,
|
||||
@@ -47,18 +54,28 @@ const SEEK_STEP_SEC = 10
|
||||
const SEEK_REPEAT_MS = 160
|
||||
const SEEK_APPLY_MS = 220
|
||||
const SEEK_HINT_MS = 700
|
||||
const PLAYBACK_RATE_REPEAT_MS = 140
|
||||
const PLAYBACK_RATE_HINT_MS = 900
|
||||
|
||||
type SeekHint = {
|
||||
dir: 'back' | 'forward'
|
||||
seconds: number
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
function isPlayerSeekRange(target: EventTarget | null): boolean {
|
||||
return target instanceof HTMLInputElement && target.dataset.playerSeekRange === 'true'
|
||||
}
|
||||
|
||||
function shouldIgnorePlayerSeekShortcut(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return Boolean(
|
||||
target.closest('input, textarea, select, [contenteditable="true"]') ||
|
||||
target.isContentEditable,
|
||||
const control = target.closest<HTMLElement>(
|
||||
'input, textarea, select, [contenteditable="true"]',
|
||||
)
|
||||
if (control instanceof HTMLInputElement) {
|
||||
// 进度条需要继续走播放器的 10 秒快捷键;其它输入控件保留原生键盘行为。
|
||||
return !isPlayerSeekRange(control)
|
||||
}
|
||||
return Boolean(control || target.isContentEditable)
|
||||
}
|
||||
|
||||
type PlayerControlsProps = {
|
||||
@@ -67,6 +84,10 @@ type PlayerControlsProps = {
|
||||
volume?: number
|
||||
onVolumeChange?: (volume: number) => void
|
||||
onVolumeCommit?: (volume: number) => void
|
||||
/** 用户级播放倍速,由播放页从数据库读取。 */
|
||||
playbackRate?: number
|
||||
onPlaybackRateChange?: (rate: number) => void
|
||||
onPlaybackRateCommit?: (rate: number) => void
|
||||
uiVisible: boolean
|
||||
onUiVisibleChange: (visible: boolean) => void
|
||||
subs: SubtitleTrack[]
|
||||
@@ -108,6 +129,9 @@ export function PlayerControls({
|
||||
volume: volumeProp = 1,
|
||||
onVolumeChange,
|
||||
onVolumeCommit,
|
||||
playbackRate: playbackRateProp = 1,
|
||||
onPlaybackRateChange,
|
||||
onPlaybackRateCommit,
|
||||
uiVisible,
|
||||
onUiVisibleChange,
|
||||
subs,
|
||||
@@ -159,11 +183,14 @@ export function PlayerControls({
|
||||
const subtitleMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [qualityMenuOpen, setQualityMenuOpen] = useState(false)
|
||||
const qualityMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [speedMenuOpen, setSpeedMenuOpen] = useState(false)
|
||||
const speedMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const controlsHoveredRef = useRef(false)
|
||||
const isScrubbingRef = useRef(false)
|
||||
const subtitleMenuOpenRef = useRef(false)
|
||||
const qualityMenuOpenRef = useRef(false)
|
||||
const speedMenuOpenRef = useRef(false)
|
||||
const danmakuOpenRef = useRef(false)
|
||||
const playlistOpenRef = useRef(false)
|
||||
const pendingSeekRef = useRef<number | null>(null)
|
||||
@@ -173,7 +200,11 @@ export function PlayerControls({
|
||||
const seekApplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const seekHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lastSeekAtRef = useRef(0)
|
||||
const playbackRateRef = useRef(normalizePlaybackRate(playbackRateProp))
|
||||
const playbackRateHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lastPlaybackRateAtRef = useRef(0)
|
||||
const [seekHint, setSeekHint] = useState<SeekHint | null>(null)
|
||||
const [playbackRateHint, setPlaybackRateHint] = useState<number | null>(null)
|
||||
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -192,6 +223,10 @@ export function PlayerControls({
|
||||
qualityMenuOpenRef.current = qualityMenuOpen
|
||||
}, [qualityMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
speedMenuOpenRef.current = speedMenuOpen
|
||||
}, [speedMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
danmakuOpenRef.current = danmakuOpen
|
||||
}, [danmakuOpen])
|
||||
@@ -204,6 +239,10 @@ export function PlayerControls({
|
||||
durationRef.current = duration
|
||||
}, [duration])
|
||||
|
||||
useEffect(() => {
|
||||
playbackRateRef.current = normalizePlaybackRate(playbackRateProp)
|
||||
}, [playbackRateProp])
|
||||
|
||||
useEffect(() => {
|
||||
setStageEl(container())
|
||||
}, [container])
|
||||
@@ -219,9 +258,19 @@ export function PlayerControls({
|
||||
setMuted(next === 0)
|
||||
}, [video, volumeProp])
|
||||
|
||||
// 点击控制栏外部时关闭字幕/画质菜单
|
||||
// 倍速由播放页按用户持久化;配置加载、切换剧集或切换播放源后同步到当前 video。
|
||||
useEffect(() => {
|
||||
if (!subtitleMenuOpen && !qualityMenuOpen) return
|
||||
const el = video()
|
||||
if (!el) return
|
||||
const next = normalizePlaybackRate(playbackRateProp)
|
||||
if (Math.abs(el.playbackRate - next) > 0.001) {
|
||||
el.playbackRate = next
|
||||
}
|
||||
}, [video, playbackRateProp])
|
||||
|
||||
// 点击控制栏外部时关闭字幕/画质/倍速菜单
|
||||
useEffect(() => {
|
||||
if (!subtitleMenuOpen && !qualityMenuOpen && !speedMenuOpen) return
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
|
||||
setSubtitleMenuOpen(false)
|
||||
@@ -229,10 +278,13 @@ export function PlayerControls({
|
||||
if (qualityMenuRef.current && !qualityMenuRef.current.contains(e.target as Node)) {
|
||||
setQualityMenuOpen(false)
|
||||
}
|
||||
if (speedMenuRef.current && !speedMenuRef.current.contains(e.target as Node)) {
|
||||
setSpeedMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
return () => document.removeEventListener('mousedown', onDocClick)
|
||||
}, [subtitleMenuOpen, qualityMenuOpen])
|
||||
}, [subtitleMenuOpen, qualityMenuOpen, speedMenuOpen])
|
||||
|
||||
// 播放时 3 秒无操作自动隐藏控制栏;暂停/悬停/拖动进度条/打开菜单时保持显示。
|
||||
// 监听挂在整个播放器舞台容器(data-player-stage)上,避免光标移到控制栏时因离开视频画面而误触发 mouseleave。
|
||||
@@ -249,6 +301,7 @@ export function PlayerControls({
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!qualityMenuOpenRef.current &&
|
||||
!speedMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
@@ -258,6 +311,7 @@ export function PlayerControls({
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!qualityMenuOpenRef.current &&
|
||||
!speedMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
@@ -360,7 +414,7 @@ export function PlayerControls({
|
||||
|
||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||
useEffect(() => {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || danmakuOpen || playlistOpen) {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || speedMenuOpen || danmakuOpen || playlistOpen) {
|
||||
onUiVisibleChange(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
} else {
|
||||
@@ -370,7 +424,7 @@ export function PlayerControls({
|
||||
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
|
||||
}
|
||||
}
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, speedMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
@@ -379,6 +433,30 @@ export function PlayerControls({
|
||||
else el.pause()
|
||||
}
|
||||
|
||||
const changePlaybackRate = useCallback((next: number) => {
|
||||
const normalized = normalizePlaybackRate(next)
|
||||
playbackRateRef.current = normalized
|
||||
onPlaybackRateChange?.(normalized)
|
||||
setPlaybackRateHint(normalized)
|
||||
if (playbackRateHintTimerRef.current) {
|
||||
clearTimeout(playbackRateHintTimerRef.current)
|
||||
}
|
||||
playbackRateHintTimerRef.current = setTimeout(() => {
|
||||
setPlaybackRateHint(null)
|
||||
playbackRateHintTimerRef.current = null
|
||||
}, PLAYBACK_RATE_HINT_MS)
|
||||
}, [onPlaybackRateChange])
|
||||
|
||||
const commitPlaybackRate = useCallback(() => {
|
||||
onPlaybackRateCommit?.(playbackRateRef.current)
|
||||
}, [onPlaybackRateCommit])
|
||||
|
||||
const selectPlaybackRate = useCallback((next: number) => {
|
||||
const normalized = normalizePlaybackRate(next)
|
||||
changePlaybackRate(normalized)
|
||||
onPlaybackRateCommit?.(normalized)
|
||||
}, [changePlaybackRate, onPlaybackRateCommit])
|
||||
|
||||
const applyAbsoluteSeek = useCallback(
|
||||
(absolute: number) => {
|
||||
const el = video()
|
||||
@@ -454,7 +532,7 @@ export function PlayerControls({
|
||||
}, 3000)
|
||||
}, [onUiVisibleChange, video])
|
||||
|
||||
const queueRelativeSeek = useCallback((delta: number) => {
|
||||
const queueRelativeSeek = useCallback((delta: number, immediate: boolean) => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
if (!seekBurstRef.current) {
|
||||
@@ -489,6 +567,16 @@ export function PlayerControls({
|
||||
|
||||
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
|
||||
seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS)
|
||||
if (immediate) {
|
||||
if (seekApplyTimerRef.current) {
|
||||
clearTimeout(seekApplyTimerRef.current)
|
||||
seekApplyTimerRef.current = null
|
||||
}
|
||||
const burst = seekBurstRef.current
|
||||
seekBurstRef.current = null
|
||||
applySeekRef.current(burst.base + burst.delta)
|
||||
return
|
||||
}
|
||||
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
|
||||
seekApplyTimerRef.current = setTimeout(() => {
|
||||
const burst = seekBurstRef.current
|
||||
@@ -502,23 +590,47 @@ export function PlayerControls({
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||
if (isEditableTarget(e.target)) return
|
||||
if (shouldIgnorePlayerSeekShortcut(e.target)) return
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const now = Date.now()
|
||||
if (e.repeat && now - lastPlaybackRateAtRef.current < PLAYBACK_RATE_REPEAT_MS) return
|
||||
lastPlaybackRateAtRef.current = now
|
||||
changePlaybackRate(
|
||||
stepPlaybackRate(playbackRateRef.current, e.key === 'ArrowUp' ? 1 : -1),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const now = Date.now()
|
||||
if (e.repeat && now - lastSeekAtRef.current < SEEK_REPEAT_MS) return
|
||||
const immediate = !e.repeat && now - lastSeekAtRef.current >= SEEK_APPLY_MS
|
||||
lastSeekAtRef.current = now
|
||||
e.preventDefault()
|
||||
queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC)
|
||||
queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC, immediate)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [queueRelativeSeek])
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [changePlaybackRate, queueRelativeSeek])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
|
||||
if (shouldIgnorePlayerSeekShortcut(e.target)) return
|
||||
e.preventDefault()
|
||||
commitPlaybackRate()
|
||||
}
|
||||
window.addEventListener('keyup', onKeyUp, true)
|
||||
return () => window.removeEventListener('keyup', onKeyUp, true)
|
||||
}, [commitPlaybackRate])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
|
||||
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
|
||||
if (playbackRateHintTimerRef.current) clearTimeout(playbackRateHintTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -612,9 +724,24 @@ export function PlayerControls({
|
||||
)
|
||||
: null
|
||||
|
||||
const playbackRateOverlay = playbackRateHint && stageEl
|
||||
? createPortal(
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-full bg-black/65 px-5 py-3 text-white shadow-lg backdrop-blur-sm">
|
||||
<Timer size={22} />
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{formatPlaybackRate(playbackRateHint)} 倍速
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
stageEl,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
{seekOverlay}
|
||||
{playbackRateOverlay}
|
||||
<div
|
||||
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-3 pt-14 transition-opacity duration-300 ${
|
||||
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
@@ -665,6 +792,7 @@ export function PlayerControls({
|
||||
<div className="order-2 flex min-w-0 basis-full items-center gap-2 sm:order-none sm:basis-auto sm:flex-1">
|
||||
<input
|
||||
type="range"
|
||||
data-player-seek-range="true"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
@@ -697,6 +825,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubtitleMenuOpen(false)
|
||||
setSpeedMenuOpen(false)
|
||||
setQualityMenuOpen((v) => !v)
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15"
|
||||
@@ -754,11 +883,55 @@ export function PlayerControls({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative" ref={speedMenuRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubtitleMenuOpen(false)
|
||||
setQualityMenuOpen(false)
|
||||
setSpeedMenuOpen((v) => !v)
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="播放速度(↑/↓ 调节)"
|
||||
>
|
||||
<Timer size={18} className={speedMenuOpen ? 'text-rose-400' : 'text-white/80'} />
|
||||
<span className="text-[10px] font-semibold tabular-nums text-white/80">
|
||||
{formatPlaybackRate(playbackRateProp)}
|
||||
</span>
|
||||
</button>
|
||||
{speedMenuOpen && (
|
||||
<div className="absolute bottom-11 right-0 z-30 min-w-36 overflow-hidden rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
<p className="px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/40">
|
||||
播放速度
|
||||
</p>
|
||||
{PLAYBACK_RATE_OPTIONS.map((rate) => {
|
||||
const current = normalizePlaybackRate(playbackRateProp) === rate
|
||||
return (
|
||||
<button
|
||||
key={rate}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
selectPlaybackRate(rate)
|
||||
setSpeedMenuOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition ${
|
||||
current ? 'text-rose-400 hover:bg-white/10' : 'text-white/85 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="tabular-nums">{formatPlaybackRate(rate)}</span>
|
||||
{current && <Check size={13} className="ml-auto text-rose-400" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subs.length > 0 && (
|
||||
<div className="relative" ref={subtitleMenuRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setQualityMenuOpen(false)
|
||||
setSpeedMenuOpen(false)
|
||||
setSubtitleMenuOpen((v) => !v)
|
||||
}}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { User } from '../types'
|
||||
|
||||
export function useLayoutPermissions(user: User | null | undefined) {
|
||||
const permissions = usePermissionStore((state) => state.permissions)
|
||||
const isSuper = usePermissionStore((state) => state.isSuper)
|
||||
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
|
||||
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
|
||||
|
||||
@@ -17,8 +16,8 @@ export function useLayoutPermissions(user: User | null | undefined) {
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const can = useCallback(
|
||||
(key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true,
|
||||
[isAdmin, isSuper, permissions],
|
||||
(key: string) => isAdmin || (permissions ?? {})[key] === true,
|
||||
[isAdmin, permissions],
|
||||
)
|
||||
|
||||
return { can, isAdmin }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const SETTING_KEY = 'scrape.episode_images'
|
||||
|
||||
@@ -13,8 +14,11 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
|
||||
const enabledRef = useRef(false)
|
||||
const changedLocally = useRef(false)
|
||||
const saveQueue = useRef(Promise.resolve())
|
||||
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return
|
||||
|
||||
let active = true
|
||||
adminAPI.listSettings()
|
||||
.then((settings) => {
|
||||
@@ -32,9 +36,11 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
}, [isAdmin])
|
||||
|
||||
const setEnabled = useCallback((nextValue: SetStateAction<boolean>) => {
|
||||
if (!isAdmin) return
|
||||
|
||||
const next = typeof nextValue === 'function' ? nextValue(enabledRef.current) : nextValue
|
||||
if (enabledRef.current === next) return
|
||||
changedLocally.current = true
|
||||
@@ -47,7 +53,7 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
|
||||
.catch(() => {
|
||||
toast.error('保存每集图片设置失败')
|
||||
})
|
||||
}, [])
|
||||
}, [isAdmin])
|
||||
|
||||
return [enabled, setEnabled]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
ALL_TAG_ID,
|
||||
assignLibrariesToTag,
|
||||
attachLibraryToTag,
|
||||
dedupeLibraryTags,
|
||||
loadLibraryTags,
|
||||
normalizeLibraryTags,
|
||||
normalizeTagName,
|
||||
readCachedLibraryTags,
|
||||
readSelectedTagId,
|
||||
resolveSelectedTagId,
|
||||
saveLibraryTags,
|
||||
writeCachedLibraryTags,
|
||||
writeSelectedTagId,
|
||||
type LibraryTag,
|
||||
} from '../utils/libraryTags'
|
||||
|
||||
export type UseLibraryTagsResult = {
|
||||
/** 当前用户维护的标签(顺序即标签栏顺序)。 */
|
||||
tags: LibraryTag[]
|
||||
/** 标签数据是否仍在首次加载中。 */
|
||||
loading: boolean
|
||||
/** 是否有写入在途(可用于禁用重复点击)。 */
|
||||
saving: boolean
|
||||
/** 接口不可用(例如旧后端)时降级为本地标签。 */
|
||||
loadError: boolean
|
||||
/** 当前选中的标签栏:ALL_TAG_ID 表示「全部」。 */
|
||||
selectedTagId: string
|
||||
setSelectedTagId: (tagId: string) => void
|
||||
createTag: (name: string) => Promise<string>
|
||||
renameTag: (from: string, to: string) => Promise<void>
|
||||
removeTag: (name: string) => Promise<void>
|
||||
/** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */
|
||||
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
|
||||
/** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */
|
||||
assignLibrary: (libraryId: string, tagName: string) => Promise<void>
|
||||
/** 批量把媒体库挂到标签下;tagName 为空表示批量移出所有标签。一次写入。 */
|
||||
assignLibraries: (libraryIds: string[], tagName: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体库标签的用户级读写。
|
||||
*
|
||||
* 写入采用「先乐观更新、串行提交、失败回滚」:标签是整份替换(`PUT`),
|
||||
* 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是
|
||||
* 排队时的最新本地状态。接口不可用(旧后端/网络异常)时退回本地存储,
|
||||
* 标签栏依旧可用,不会因为一次失败就让标签凭空消失。
|
||||
*/
|
||||
export function useLibraryTags(): UseLibraryTagsResult {
|
||||
const [tags, setTags] = useState<LibraryTag[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const [selectedTagId, setSelectedTagIdState] = useState(() => readSelectedTagId())
|
||||
|
||||
const tagsRef = useRef<LibraryTag[]>([])
|
||||
const selectedRef = useRef(selectedTagId)
|
||||
const savedRef = useRef<LibraryTag[]>([])
|
||||
const chainRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
const pendingRef = useRef(0)
|
||||
// 后端没有该接口(旧版本)时退化为仅本地存储,不再反复提示保存失败。
|
||||
const localOnlyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
tagsRef.current = tags
|
||||
}, [tags])
|
||||
|
||||
useEffect(() => {
|
||||
selectedRef.current = selectedTagId
|
||||
}, [selectedTagId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
loadLibraryTags()
|
||||
.then((rows) => {
|
||||
if (cancelled) return
|
||||
tagsRef.current = rows
|
||||
savedRef.current = rows
|
||||
setTags(rows)
|
||||
setLoadError(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
// 旧后端没有 /me/library-tags:用本地缓存兜底,标签栏照常可用。
|
||||
const cached = readCachedLibraryTags()
|
||||
tagsRef.current = cached
|
||||
savedRef.current = cached
|
||||
setTags(cached)
|
||||
setLoadError(true)
|
||||
// 接口不存在时(旧后端)只做本地读写,避免每次编辑都弹一次保存失败。
|
||||
localOnlyRef.current = isMissingEndpoint(err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 选中的标签可能被删除,落回「全部」以免标签栏空白。
|
||||
useEffect(() => {
|
||||
const resolved = resolveSelectedTagId(tags, selectedTagId)
|
||||
if (resolved !== selectedTagId) {
|
||||
selectedRef.current = resolved
|
||||
setSelectedTagIdState(resolved)
|
||||
}
|
||||
}, [tags, selectedTagId])
|
||||
|
||||
const setSelectedTagId = useCallback((tagId: string) => {
|
||||
const next = tagId && tagId !== ALL_TAG_ID ? tagId : ALL_TAG_ID
|
||||
selectedRef.current = next
|
||||
writeSelectedTagId(next)
|
||||
setSelectedTagIdState(next)
|
||||
}, [])
|
||||
|
||||
const mutate = useCallback((updater: (current: LibraryTag[]) => LibraryTag[]) => {
|
||||
const rollback = savedRef.current
|
||||
const next = dedupeLibraryTags(normalizeLibraryTags(updater(tagsRef.current)))
|
||||
tagsRef.current = next
|
||||
setTags(next)
|
||||
|
||||
pendingRef.current += 1
|
||||
setSaving(true)
|
||||
const task = chainRef.current.then(async () => {
|
||||
// 发送排队时的最新本地状态:链上更早的写入已经被后面的状态取代。
|
||||
const snapshot = tagsRef.current
|
||||
if (localOnlyRef.current) {
|
||||
// 已知后端不支持标签接口:只落本地缓存,不再发请求。
|
||||
if (tagsRef.current === snapshot) savedRef.current = snapshot
|
||||
writeCachedLibraryTags(snapshot)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = await saveLibraryTags(snapshot)
|
||||
savedRef.current = saved
|
||||
setLoadError(false)
|
||||
if (tagsRef.current === snapshot) {
|
||||
tagsRef.current = saved
|
||||
setTags(saved)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMissingEndpoint(err)) {
|
||||
// 旧后端不支持标签接口:本次修改保留在本地存储,标签栏照常可用。
|
||||
localOnlyRef.current = true
|
||||
savedRef.current = snapshot
|
||||
writeCachedLibraryTags(snapshot)
|
||||
setLoadError(true)
|
||||
return
|
||||
}
|
||||
// 用最后一次成功保存的状态回滚,避免前端显示一份服务端并不存在的标签。
|
||||
tagsRef.current = rollback
|
||||
savedRef.current = rollback
|
||||
setTags(rollback)
|
||||
setLoadError(true)
|
||||
toast.error('标签保存失败,请稍后重试')
|
||||
}
|
||||
})
|
||||
chainRef.current = task.catch(() => undefined)
|
||||
return task.finally(() => {
|
||||
pendingRef.current = Math.max(0, pendingRef.current - 1)
|
||||
if (pendingRef.current === 0) setSaving(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const createTag = useCallback(
|
||||
async (name: string) => {
|
||||
const trimmed = normalizeTagName(name)
|
||||
if (!trimmed) return ''
|
||||
if (tagsRef.current.some((tag) => tag.name.toLowerCase() === trimmed.toLowerCase())) {
|
||||
return trimmed
|
||||
}
|
||||
await mutate((current) => [...current, { name: trimmed, library_ids: [] }])
|
||||
return tagsRef.current.some((tag) => tag.name === trimmed) ? trimmed : ''
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const renameTag = useCallback(
|
||||
async (from: string, to: string) => {
|
||||
const trimmed = normalizeTagName(to)
|
||||
if (!trimmed || trimmed === from) return
|
||||
await mutate((current) =>
|
||||
current.map((tag) => (tag.name === from ? { ...tag, name: trimmed } : tag)),
|
||||
)
|
||||
// 重命名后选中项要跟着换到新名字,否则标签栏会落回「全部」。
|
||||
if (selectedRef.current === from) setSelectedTagId(trimmed)
|
||||
},
|
||||
[mutate, setSelectedTagId],
|
||||
)
|
||||
|
||||
const removeTag = useCallback(
|
||||
async (name: string) => {
|
||||
await mutate((current) => current.filter((tag) => tag.name !== name))
|
||||
if (selectedRef.current === name) setSelectedTagId(ALL_TAG_ID)
|
||||
},
|
||||
[mutate, setSelectedTagId],
|
||||
)
|
||||
|
||||
const setTagLibraries = useCallback(
|
||||
async (name: string, libraryIds: string[]) => {
|
||||
await mutate((current) =>
|
||||
current.map((tag) => (tag.name === name ? { ...tag, library_ids: libraryIds } : tag)),
|
||||
)
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const assignLibrary = useCallback(
|
||||
async (libraryId: string, tagName: string) => {
|
||||
await mutate((current) => {
|
||||
if (tagName) return attachLibraryToTag(current, libraryId, tagName)
|
||||
return current.map((tag) => ({
|
||||
...tag,
|
||||
library_ids: tag.library_ids.filter((id) => id !== libraryId),
|
||||
}))
|
||||
})
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const assignLibraries = useCallback(
|
||||
async (libraryIds: string[], tagName: string) => {
|
||||
await mutate((current) => assignLibrariesToTag(current, libraryIds, tagName))
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
return {
|
||||
tags,
|
||||
loading,
|
||||
saving,
|
||||
loadError,
|
||||
selectedTagId,
|
||||
setSelectedTagId,
|
||||
createTag,
|
||||
renameTag,
|
||||
removeTag,
|
||||
setTagLibraries,
|
||||
assignLibrary,
|
||||
assignLibraries,
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断错误是否说明后端没有这个接口(旧版本 / 反向代理未更新)。 */
|
||||
function isMissingEndpoint(err: unknown): boolean {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
return status === 404 || status === 405 || status === 501
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
|
||||
import {
|
||||
forgetListPosition,
|
||||
normalizeListPosition,
|
||||
readListPosition,
|
||||
useRememberedListPosition,
|
||||
writeListPosition,
|
||||
} from './useListPositionMemory.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`useListPositionMemory: ${name}`)
|
||||
}
|
||||
|
||||
check('unknown key falls back', readListPosition('unknown-key', 3) === 3)
|
||||
check('missing value uses default fallback', readListPosition('unknown-key') === 1)
|
||||
|
||||
writeListPosition('demo', 4)
|
||||
check('round trip keeps value', readListPosition('demo') === 4)
|
||||
|
||||
writeListPosition('demo', 9)
|
||||
check('rewrite keeps latest value', readListPosition('demo') === 9)
|
||||
|
||||
forgetListPosition('demo')
|
||||
check('forget resets to fallback', readListPosition('demo', 2) === 2)
|
||||
|
||||
check('normalize parses numeric string', normalizeListPosition('7', 1) === 7)
|
||||
check('normalize floors decimals', normalizeListPosition(3.8, 1) === 3)
|
||||
check('normalize rejects zero', normalizeListPosition(0, 2) === 2)
|
||||
check('normalize rejects negatives', normalizeListPosition(-5, 2) === 2)
|
||||
check('normalize rejects NaN input', normalizeListPosition('abc', 2) === 2)
|
||||
check('normalize rejects infinity', normalizeListPosition(Number.POSITIVE_INFINITY, 2) === 2)
|
||||
check('normalize clamps to max', normalizeListPosition(99, 1, 8) === 8)
|
||||
check('normalize keeps value under max', normalizeListPosition(6, 1, 8) === 6)
|
||||
|
||||
// 详情页返回时列表组件会重新挂载:挂载即可读到上次页码,而不是先回第一页。
|
||||
function PageProbe({ positionKey, max }: { positionKey: string; max?: number }) {
|
||||
const [position] = useRememberedListPosition(positionKey, 1, max)
|
||||
return createElement('span', null, String(position))
|
||||
}
|
||||
|
||||
writeListPosition('grid:/libraries', 3)
|
||||
check(
|
||||
'remount restores remembered page',
|
||||
renderToStaticMarkup(createElement(PageProbe, { positionKey: 'grid:/libraries' })) === '<span>3</span>',
|
||||
)
|
||||
check(
|
||||
'remount restores independent key',
|
||||
renderToStaticMarkup(createElement(PageProbe, { positionKey: 'grid:/' })) === '<span>1</span>',
|
||||
)
|
||||
writeListPosition('shelves', 40)
|
||||
check(
|
||||
'remount clamps to restore cap',
|
||||
renderToStaticMarkup(createElement(PageProbe, { positionKey: 'shelves', max: 15 })) === '<span>15</span>',
|
||||
)
|
||||
check(
|
||||
'live state can grow past restore cap',
|
||||
readListPosition('shelves') === 40,
|
||||
)
|
||||
|
||||
console.log('useListPositionMemory.test.ts ok')
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
// 列表分页位置(媒体库入口当前页、媒体库已加载页数、货架解锁数量)需要在
|
||||
// 进入详情页再返回后依然生效,所以用 sessionStorage 记录,并在内存里留一份
|
||||
// 兜底缓存;同一标签页内刷新也仍然记得。
|
||||
const LIST_POSITION_PREFIX = 'mebox.listpos.'
|
||||
const positions = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* 详情页返回时最多自动补拉的分页数量:滚动到很深的用户重新进入列表时
|
||||
* 依次补拉这些页,剩下的继续由底部哨兵按需加载,避免一次打太多请求。
|
||||
*/
|
||||
export const MAX_RESTORE_PAGES = 8
|
||||
|
||||
/** 把存储里的任意值收敛成 >= 1 的整数分页位置。 */
|
||||
export function normalizeListPosition(
|
||||
value: unknown,
|
||||
fallback = 1,
|
||||
max = Number.MAX_SAFE_INTEGER,
|
||||
): number {
|
||||
const numeric = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isFinite(numeric)) return fallback
|
||||
const rounded = Math.floor(numeric)
|
||||
if (rounded < 1) return fallback
|
||||
return Math.min(rounded, max)
|
||||
}
|
||||
|
||||
export function readListPosition(key: string, fallback = 1, max = Number.MAX_SAFE_INTEGER): number {
|
||||
const cached = positions.get(key)
|
||||
if (cached !== undefined) return Math.min(cached, max)
|
||||
if (typeof window === 'undefined') return fallback
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(LIST_POSITION_PREFIX + key)
|
||||
if (raw === null) return fallback
|
||||
const value = normalizeListPosition(raw, fallback, max)
|
||||
positions.set(key, value)
|
||||
return value
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function writeListPosition(key: string, value: number): void {
|
||||
const next = normalizeListPosition(value)
|
||||
positions.set(key, next)
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.sessionStorage.setItem(LIST_POSITION_PREFIX + key, String(next))
|
||||
} catch {
|
||||
// 私密模式等场景忽略存储失败,不影响分页。
|
||||
}
|
||||
}
|
||||
|
||||
export function forgetListPosition(key: string): void {
|
||||
positions.delete(key)
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.sessionStorage.removeItem(LIST_POSITION_PREFIX + key)
|
||||
} catch {
|
||||
// 同上,存储不可用时无需处理。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住列表当前分页位置。key 需要在一份挂载生命周期内保持稳定:路由切换会
|
||||
* 让列表重新挂载,因此每次挂载都能直接读到上次记录的位置(不会先闪回第一页)。
|
||||
*/
|
||||
export function useRememberedListPosition(
|
||||
key: string,
|
||||
fallback = 1,
|
||||
max = Number.MAX_SAFE_INTEGER,
|
||||
): [number, (next: number | ((prev: number) => number)) => void] {
|
||||
const [position, setPosition] = useState(() => readListPosition(key, fallback, max))
|
||||
|
||||
useEffect(() => {
|
||||
writeListPosition(key, position)
|
||||
}, [key, position])
|
||||
|
||||
const remember = useCallback((next: number | ((prev: number) => number)) => {
|
||||
setPosition((prev) => {
|
||||
const value = typeof next === 'function' ? next(prev) : next
|
||||
return normalizeListPosition(value, prev)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return [position, remember]
|
||||
}
|
||||
@@ -5,17 +5,17 @@ import { useAuthStore } from '../stores/auth'
|
||||
|
||||
/**
|
||||
* usePermission hook - 检查用户是否拥有特定权限
|
||||
*
|
||||
*
|
||||
* @param key - 权限键名
|
||||
* @param options - 配置选项
|
||||
* @param options.autoFetch - 是否在权限未加载时自动获取(默认 true)
|
||||
* @returns boolean - 用户是否拥有该权限
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const canEdit = usePermission('can_edit_media')
|
||||
*
|
||||
*
|
||||
* if (canEdit) {
|
||||
* return <EditButton />
|
||||
* }
|
||||
@@ -30,26 +30,24 @@ export function usePermission(
|
||||
const { autoFetch = true } = options
|
||||
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染
|
||||
const hasPermission = usePermissionStore((s) => s.hasPermission)
|
||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin'
|
||||
const isAdmin = role === 'admin'
|
||||
|
||||
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
|
||||
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
|
||||
useEffect(() => {
|
||||
if (!hasSuperAccess && isAuthenticated && autoFetch) {
|
||||
if (!isAdmin && isAuthenticated && autoFetch) {
|
||||
const { permissions, isLoading } = usePermissionStore.getState()
|
||||
if (Object.keys(permissions).length === 0 && !isLoading) {
|
||||
fetchPermissions()
|
||||
}
|
||||
}
|
||||
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated])
|
||||
}, [autoFetch, fetchPermissions, isAdmin, isAuthenticated])
|
||||
|
||||
// 超级用户有所有权限
|
||||
if (hasSuperAccess) {
|
||||
// 管理员拥有全部能力;plus / is_super 不能用来展示刮削、整理、删除等管理入口
|
||||
if (isAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -63,18 +61,18 @@ export function usePermission(
|
||||
|
||||
/**
|
||||
* usePermissions hook - 获取所有权限
|
||||
*
|
||||
*
|
||||
* @returns 权限状态和检查函数
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { permissions, isSuper, check } = usePermissions()
|
||||
*
|
||||
*
|
||||
* if (isSuper) {
|
||||
* return <AdminPanel />
|
||||
* }
|
||||
*
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {check('can_view_dashboard') && <Dashboard />}
|
||||
@@ -87,15 +85,14 @@ export function usePermission(
|
||||
export function usePermissions() {
|
||||
// selector 订阅:只关注 permissions/isSuper/isLoading 变化
|
||||
const permissions = usePermissionStore((s) => s.permissions)
|
||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||
const isLoading = usePermissionStore((s) => s.isLoading)
|
||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
const isAdmin = role === 'admin'
|
||||
|
||||
const check = (key: string): boolean => {
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
if (isAdmin) {
|
||||
return true
|
||||
}
|
||||
return permissions[key] === true
|
||||
@@ -103,7 +100,8 @@ export function usePermissions() {
|
||||
|
||||
return {
|
||||
permissions,
|
||||
isSuper: isSuper || tier === 'plus' || role === 'admin',
|
||||
// Keep the field name for callers, but only admins are treated as full-access.
|
||||
isSuper: isAdmin,
|
||||
isLoading,
|
||||
check,
|
||||
refetch: fetchPermissions,
|
||||
@@ -113,10 +111,10 @@ export function usePermissions() {
|
||||
|
||||
/**
|
||||
* usePermissionMany hook - 批量检查多个权限
|
||||
*
|
||||
*
|
||||
* @param keys - 权限键数组
|
||||
* @returns 每个权限的布尔值映射
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
@@ -125,7 +123,7 @@ export function usePermissions() {
|
||||
* 'can_manage_users',
|
||||
* 'can_access_settings',
|
||||
* ])
|
||||
*
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {perms['can_edit_media'] && <EditButton />}
|
||||
@@ -137,7 +135,7 @@ export function usePermissions() {
|
||||
*/
|
||||
export function usePermissionMany(keys: string[]): Record<string, boolean> {
|
||||
const { check } = usePermissions()
|
||||
|
||||
|
||||
return keys.reduce((acc, key) => {
|
||||
acc[key] = check(key)
|
||||
return acc
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { shouldPersistScrollSample, shouldRememberScroll } from './useScrollMemory.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`useScrollMemory: ${name}`)
|
||||
}
|
||||
|
||||
check('home remembers scroll', shouldRememberScroll('/'))
|
||||
check('library list remembers scroll', shouldRememberScroll('/libraries'))
|
||||
check('library detail remembers scroll', shouldRememberScroll('/library/lib-1'))
|
||||
check('media detail remembers scroll', shouldRememberScroll('/media/media-1'))
|
||||
check('settings does not remember scroll', !shouldRememberScroll('/settings'))
|
||||
check('player does not remember scroll', !shouldRememberScroll('/play/media-1'))
|
||||
|
||||
check(
|
||||
'route transition collapse is ignored',
|
||||
!shouldPersistScrollSample({
|
||||
current: 0,
|
||||
lastSaved: 1800,
|
||||
height: 400,
|
||||
lastHeight: 4200,
|
||||
}),
|
||||
)
|
||||
check(
|
||||
'intentional scroll to top is persisted',
|
||||
shouldPersistScrollSample({
|
||||
current: 0,
|
||||
lastSaved: 1800,
|
||||
height: 4200,
|
||||
lastHeight: 4200,
|
||||
}),
|
||||
)
|
||||
check(
|
||||
'normal downward scroll is persisted',
|
||||
shouldPersistScrollSample({
|
||||
current: 1900,
|
||||
lastSaved: 1800,
|
||||
height: 4200,
|
||||
lastHeight: 4200,
|
||||
}),
|
||||
)
|
||||
check(
|
||||
'content growth still allows persist',
|
||||
shouldPersistScrollSample({
|
||||
current: 1800,
|
||||
lastSaved: 1800,
|
||||
height: 5200,
|
||||
lastHeight: 4200,
|
||||
}),
|
||||
)
|
||||
|
||||
console.log('useScrollMemory.test.ts ok')
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useLayoutEffect } from 'react'
|
||||
|
||||
const SCROLL_STORAGE_PREFIX = 'mebox.scroll.'
|
||||
const scrollPositions = new Map<string, number>()
|
||||
|
||||
function readScrollPosition(key: string): number {
|
||||
const cached = scrollPositions.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
if (typeof window === 'undefined') return 0
|
||||
try {
|
||||
const value = Number(window.sessionStorage.getItem(SCROLL_STORAGE_PREFIX + key))
|
||||
if (Number.isFinite(value) && value >= 0) {
|
||||
scrollPositions.set(key, value)
|
||||
return value
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage 不可用时仍可使用内存缓存。
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function writeScrollPosition(key: string, value: number): void {
|
||||
const next = Math.max(0, Math.round(value))
|
||||
scrollPositions.set(key, next)
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.sessionStorage.setItem(SCROLL_STORAGE_PREFIX + key, String(next))
|
||||
} catch {
|
||||
// 私密模式等场景忽略存储失败,不影响页面滚动。
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRememberScroll(pathname: string): boolean {
|
||||
return (
|
||||
pathname === '/' ||
|
||||
pathname === '/libraries' ||
|
||||
pathname.startsWith('/library/') ||
|
||||
pathname.startsWith('/media/')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由切换时 Outlet 内容变矮,浏览器会把共享滚动容器的 scrollTop 钳低,
|
||||
* 并可能同步触发 scroll 事件。这种“假滚动”不能写入存储,否则返回时永远回到顶部。
|
||||
*/
|
||||
export function shouldPersistScrollSample(input: {
|
||||
current: number
|
||||
lastSaved: number
|
||||
height: number
|
||||
lastHeight: number
|
||||
}): boolean {
|
||||
const { current, lastSaved, height, lastHeight } = input
|
||||
if (height + 1 < lastHeight && current < lastSaved) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度,
|
||||
* 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。
|
||||
*/
|
||||
export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
||||
useLayoutEffect(() => {
|
||||
const el = document.getElementById('app-main-scroll')
|
||||
if (!el) return
|
||||
|
||||
if (!shouldRememberScroll(pathname)) {
|
||||
el.scrollTop = 0
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${userKey}:${pathname}`
|
||||
const saved = readScrollPosition(key)
|
||||
let restoring = saved > 0
|
||||
let observer: ResizeObserver | null = null
|
||||
let restoreTimer: number | null = null
|
||||
let restoreFrame = 0
|
||||
let restorePumpUntil = 0
|
||||
let lastSaved = saved
|
||||
let lastHeight = el.scrollHeight
|
||||
|
||||
const stopRestore = () => {
|
||||
if (restoreFrame) {
|
||||
window.cancelAnimationFrame(restoreFrame)
|
||||
restoreFrame = 0
|
||||
}
|
||||
if (restoreTimer != null) {
|
||||
window.clearTimeout(restoreTimer)
|
||||
restoreTimer = null
|
||||
}
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
}
|
||||
|
||||
const finishRestore = () => {
|
||||
if (!restoring) return
|
||||
restoring = false
|
||||
stopRestore()
|
||||
lastSaved = Math.round(el.scrollTop)
|
||||
lastHeight = el.scrollHeight
|
||||
writeScrollPosition(key, lastSaved)
|
||||
}
|
||||
|
||||
const tryRestore = () => {
|
||||
if (!restoring) return
|
||||
const max = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const target = Math.min(saved, max)
|
||||
if (target > 0 && Math.abs(el.scrollTop - target) > 1) {
|
||||
el.scrollTop = target
|
||||
}
|
||||
if (target >= saved - 1) {
|
||||
finishRestore()
|
||||
}
|
||||
}
|
||||
|
||||
const pumpRestore = () => {
|
||||
if (!restoring) return
|
||||
tryRestore()
|
||||
if (restoring && performance.now() < restorePumpUntil) {
|
||||
restoreFrame = window.requestAnimationFrame(pumpRestore)
|
||||
} else {
|
||||
restoreFrame = 0
|
||||
}
|
||||
}
|
||||
|
||||
const saveNow = () => {
|
||||
if (restoring) return
|
||||
const current = Math.round(el.scrollTop)
|
||||
const height = el.scrollHeight
|
||||
if (
|
||||
!shouldPersistScrollSample({
|
||||
current,
|
||||
lastSaved,
|
||||
height,
|
||||
lastHeight,
|
||||
})
|
||||
) {
|
||||
lastHeight = height
|
||||
return
|
||||
}
|
||||
lastHeight = height
|
||||
if (current === lastSaved) return
|
||||
lastSaved = current
|
||||
writeScrollPosition(key, current)
|
||||
}
|
||||
|
||||
const cancelRestore = () => {
|
||||
if (!restoring) return
|
||||
restoring = false
|
||||
stopRestore()
|
||||
lastSaved = Math.round(el.scrollTop)
|
||||
lastHeight = el.scrollHeight
|
||||
writeScrollPosition(key, lastSaved)
|
||||
}
|
||||
|
||||
if (saved <= 0) {
|
||||
el.scrollTop = 0
|
||||
} else {
|
||||
tryRestore()
|
||||
if (restoring) {
|
||||
const content = el.firstElementChild
|
||||
if (typeof ResizeObserver !== 'undefined' && content instanceof HTMLElement) {
|
||||
observer = new ResizeObserver(() => tryRestore())
|
||||
observer.observe(content)
|
||||
}
|
||||
restorePumpUntil = performance.now() + 1000
|
||||
restoreFrame = window.requestAnimationFrame(pumpRestore)
|
||||
restoreTimer = window.setTimeout(finishRestore, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.target === el) {
|
||||
cancelRestore()
|
||||
}
|
||||
saveNow()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === 'PageDown' ||
|
||||
event.key === 'PageUp' ||
|
||||
event.key === 'Home' ||
|
||||
event.key === 'End' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === ' '
|
||||
) {
|
||||
cancelRestore()
|
||||
}
|
||||
saveNow()
|
||||
}
|
||||
|
||||
el.addEventListener('scroll', saveNow, { passive: true })
|
||||
el.addEventListener('pointerdown', onPointerDown, true)
|
||||
window.addEventListener('wheel', cancelRestore, { passive: true, capture: true })
|
||||
window.addEventListener('touchstart', cancelRestore, { passive: true, capture: true })
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', saveNow)
|
||||
el.removeEventListener('pointerdown', onPointerDown, true)
|
||||
window.removeEventListener('wheel', cancelRestore, true)
|
||||
window.removeEventListener('touchstart', cancelRestore, true)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
stopRestore()
|
||||
// 清理时写入最后一次有效位置,避免依赖已被钳制的 el.scrollTop。
|
||||
writeScrollPosition(key, lastSaved)
|
||||
}
|
||||
}, [pathname, userKey])
|
||||
}
|
||||
Vendored
+2
-1
@@ -430,7 +430,8 @@ video::cue(*) {
|
||||
|
||||
.animate-overlay-in { animation: overlay-in 0.15s ease-out both; }
|
||||
.animate-drawer-in { animation: drawer-in 0.22s ease-out both; }
|
||||
.animate-page-in { animation: page-in 0.25s ease-out both; }
|
||||
/* 播放完成后不能保留 transform,否则长页面会成为 fixed 弹框的包含块。 */
|
||||
.animate-page-in { animation: page-in 0.25s ease-out backwards; }
|
||||
.animate-accordion-in { animation: accordion-in 0.18s ease-out both; }
|
||||
.animate-fade-slide-in { animation: fade-slide-in 0.2s ease-out both; }
|
||||
.animate-menu-in { animation: menu-in 0.15s ease-out both; }
|
||||
|
||||
+93
-49
@@ -6,6 +6,10 @@ import type { HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
||||
import { LibraryTagBar } from '../components/LibraryTagBar'
|
||||
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
||||
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||
@@ -30,6 +34,7 @@ export function HomePage() {
|
||||
const [librariesLoading, setLibrariesLoading] = useState(true)
|
||||
const [historyLoading, setHistoryLoading] = useState(true)
|
||||
const { pinnedIds } = usePinnedLibraries()
|
||||
const libraryTags = useLibraryTags()
|
||||
|
||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
||||
@@ -87,6 +92,19 @@ export function HomePage() {
|
||||
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
// 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。
|
||||
const effectiveTagId = libraryTags.selectedTagId
|
||||
const tagTabs = useMemo(
|
||||
() => buildLibraryTagTabs(sortedLibraries, libraryTags.tags),
|
||||
[sortedLibraries, libraryTags.tags],
|
||||
)
|
||||
const taggedLibraries = useMemo(
|
||||
() => filterLibrariesByTag(sortedLibraries, libraryTags.tags, effectiveTagId),
|
||||
[sortedLibraries, libraryTags.tags, effectiveTagId],
|
||||
)
|
||||
|
||||
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
|
||||
|
||||
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
|
||||
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
|
||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||
@@ -151,49 +169,46 @@ export function HomePage() {
|
||||
)
|
||||
}, [])
|
||||
|
||||
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后再按批加载。
|
||||
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后自行按批加载。
|
||||
useEffect(() => {
|
||||
if (sortedLibraries.length === 0) return
|
||||
const carouselLibIds = sortedLibraries
|
||||
if (taggedLibraries.length === 0) return
|
||||
const carouselLibIds = taggedLibraries
|
||||
.filter((l) => l.carousel_enabled === true)
|
||||
.map((l) => l.id)
|
||||
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
|
||||
// 入口网格仍然是一次展示 20 个库;没有自定义封面的库必须在首屏
|
||||
// 立即拉预览,否则会先出现占位图标再补图,体感反而更慢。
|
||||
const topGridLibIds = sortedLibraries
|
||||
.slice(0, 20)
|
||||
.filter((l) => !l.cover_url)
|
||||
.map((l) => l.id)
|
||||
const topRowLibIds = taggedLibraries.slice(0, 3).map((l) => l.id)
|
||||
const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
|
||||
const shelfTargetSet = new Set(shelfTargets)
|
||||
const gridTargets = topGridLibIds.filter((id) => !shelfTargetSet.has(id))
|
||||
// 入口网格只需要 2 张封面,横向货架才需要 10 张。分开请求可以避免
|
||||
// 为暂时不会出现的货架预取完整卡片窗口。
|
||||
void fetchPreviews(shelfTargets, 10)
|
||||
void fetchPreviews(gridTargets, 2)
|
||||
}, [sortedLibraries, fetchPreviews])
|
||||
}, [taggedLibraries, fetchPreviews])
|
||||
|
||||
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
|
||||
const INITIAL_ROWS = 3
|
||||
const STEP_ROWS = 2
|
||||
const [visibleTargetCount, setVisibleTargetCount] = useState(INITIAL_ROWS)
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
const sentinelRef = useRef<HTMLButtonElement | null>(null)
|
||||
const userScrolledRef = useRef(false)
|
||||
|
||||
const revealMoreLibraries = useCallback(() => {
|
||||
setVisibleTargetCount((prev) => {
|
||||
if (prev >= taggedLibraries.length) return prev
|
||||
return Math.min(prev + STEP_ROWS, taggedLibraries.length)
|
||||
})
|
||||
}, [taggedLibraries.length])
|
||||
|
||||
// 随 visibleTargetCount 增加,按需触发后续库的预览加载
|
||||
useEffect(() => {
|
||||
if (sortedLibraries.length === 0) return
|
||||
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
|
||||
if (taggedLibraries.length === 0) return
|
||||
const currentTargets = taggedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
|
||||
void fetchPreviews(currentTargets, 10)
|
||||
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
|
||||
}, [taggedLibraries, visibleTargetCount, fetchPreviews])
|
||||
|
||||
// 当前已拉取并确认有内容的媒体库行
|
||||
const visibleLibraries = useMemo(() => {
|
||||
return sortedLibraries
|
||||
return taggedLibraries
|
||||
.slice(0, visibleTargetCount)
|
||||
.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0)
|
||||
}, [sortedLibraries, visibleTargetCount, libraryData])
|
||||
}, [taggedLibraries, visibleTargetCount, libraryData])
|
||||
|
||||
const hasMoreLibraries = visibleTargetCount < sortedLibraries.length
|
||||
const hasMoreLibraries = visibleTargetCount < taggedLibraries.length
|
||||
|
||||
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库行)
|
||||
useEffect(() => {
|
||||
@@ -201,12 +216,11 @@ export function HomePage() {
|
||||
if (!scrollParent) return
|
||||
|
||||
const handleCheckBottom = () => {
|
||||
if (scrollParent.scrollTop <= 0) return
|
||||
userScrolledRef.current = true
|
||||
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
|
||||
if (remaining < 600) {
|
||||
setVisibleTargetCount((prev) => {
|
||||
if (prev >= sortedLibraries.length) return prev
|
||||
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
|
||||
})
|
||||
revealMoreLibraries()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,11 +232,8 @@ export function HomePage() {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
setVisibleTargetCount((prev) => {
|
||||
if (prev >= sortedLibraries.length) return prev
|
||||
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
|
||||
})
|
||||
if (entry?.isIntersecting && userScrolledRef.current) {
|
||||
revealMoreLibraries()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -234,15 +245,13 @@ export function HomePage() {
|
||||
observer.observe(sentinel)
|
||||
}
|
||||
|
||||
handleCheckBottom()
|
||||
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleCheckBottom)
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
}, [visibleLibraries.length, hasMoreLibraries, sortedLibraries.length])
|
||||
}, [hasMoreLibraries, revealMoreLibraries, visibleLibraries.length])
|
||||
|
||||
// Quick lookup map for libraries
|
||||
const libraryMap = useMemo(() => {
|
||||
@@ -267,7 +276,7 @@ export function HomePage() {
|
||||
// series-type libs (loaded via /series), so gate on cards instead.
|
||||
const carouselItems = useMemo(() => {
|
||||
const candidateMedia: Media[] = []
|
||||
const effectiveSelectedIds = libraries
|
||||
const effectiveSelectedIds = taggedLibraries
|
||||
.filter((l) => l.carousel_enabled === true)
|
||||
.map((l) => l.id)
|
||||
|
||||
@@ -284,7 +293,7 @@ export function HomePage() {
|
||||
|
||||
// Fallback to all loaded items with artwork
|
||||
if (candidateMedia.length === 0) {
|
||||
for (const lib of libraries) {
|
||||
for (const lib of taggedLibraries) {
|
||||
const data = libraryData[lib.id]
|
||||
if (data) {
|
||||
for (const card of data.cards) {
|
||||
@@ -295,14 +304,13 @@ export function HomePage() {
|
||||
}
|
||||
|
||||
return candidateMedia.slice(0, 10)
|
||||
}, [libraries, libraryData])
|
||||
}, [taggedLibraries, libraryData])
|
||||
|
||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||
return <HomeLoadingState />
|
||||
}
|
||||
|
||||
const empty =
|
||||
libraries.length === 0 &&
|
||||
history.length === 0
|
||||
@@ -322,18 +330,49 @@ export function HomePage() {
|
||||
{historyLoading && <ContinueWatchingSkeleton />}
|
||||
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||
|
||||
{/* 3. 媒体库卡片区(每页展示 20 个媒体库) */}
|
||||
{/* 3. 媒体库标签栏 + 卡片区(每页展示 20 个媒体库) */}
|
||||
{sortedLibraries.length > 0 && (
|
||||
<HomeLibrariesSection
|
||||
libraries={sortedLibraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
/>
|
||||
<section className="space-y-6">
|
||||
<LibraryTagBar
|
||||
tabs={tagTabs}
|
||||
selectedTagId={effectiveTagId}
|
||||
onSelect={libraryTags.setSelectedTagId}
|
||||
onCreate={(name) => {
|
||||
void libraryTags.createTag(name)
|
||||
}}
|
||||
onManage={() => setLibraryTagsOpen(true)}
|
||||
busy={libraryTags.saving}
|
||||
/>
|
||||
{libraryTagsOpen && (
|
||||
<ManageLibraryTagsDialogView
|
||||
tags={libraryTags.tags}
|
||||
libraries={libraries}
|
||||
saving={libraryTags.saving}
|
||||
onCreate={libraryTags.createTag}
|
||||
onRename={libraryTags.renameTag}
|
||||
onRemove={libraryTags.removeTag}
|
||||
onAssign={libraryTags.assignLibrary}
|
||||
onAssignBatch={libraryTags.assignLibraries}
|
||||
onClose={() => setLibraryTagsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{taggedLibraries.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-12 text-center text-sm text-[var(--app-muted)]">
|
||||
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
|
||||
</p>
|
||||
) : (
|
||||
<HomeLibrariesSection
|
||||
libraries={taggedLibraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
|
||||
{visibleLibraries.length > 0 && (
|
||||
{(visibleLibraries.length > 0 || hasMoreLibraries) && (
|
||||
<div className="space-y-10">
|
||||
{visibleLibraries.map((lib) => {
|
||||
const cards = libraryData[lib.id]?.cards || []
|
||||
@@ -346,12 +385,17 @@ export function HomePage() {
|
||||
)
|
||||
})}
|
||||
{hasMoreLibraries && (
|
||||
<div ref={sentinelRef} className="flex h-10 w-full items-center justify-center py-2 opacity-60">
|
||||
<button
|
||||
ref={sentinelRef}
|
||||
type="button"
|
||||
onClick={revealMoreLibraries}
|
||||
className="flex h-10 w-full items-center justify-center py-2 opacity-60 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
|
||||
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
|
||||
<span>加载更多媒体库…</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FolderOpen,
|
||||
Library as LibraryIcon,
|
||||
Music,
|
||||
Pin,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Sparkles,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
import { imageURL } from '../api/client'
|
||||
import { useInViewOnce } from '../hooks/useInViewOnce'
|
||||
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
|
||||
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
@@ -355,14 +357,28 @@ export function HomeLibrariesSection({
|
||||
libraryData,
|
||||
libraryCounts,
|
||||
onNeedPreviews,
|
||||
pinnedIds = [],
|
||||
onTogglePin,
|
||||
showAllLink = true,
|
||||
title = '媒体库',
|
||||
}: {
|
||||
libraries: Library[]
|
||||
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
|
||||
libraryCounts: Record<string, number>
|
||||
onNeedPreviews?: (ids: string[], limit?: number) => void
|
||||
pinnedIds?: string[]
|
||||
onTogglePin?: (libraryId: string) => void
|
||||
showAllLink?: boolean
|
||||
title?: string
|
||||
}) {
|
||||
const PAGE_SIZE = 20
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
// 分页位置按路由分别记忆:首页「媒体库」和 /libraries 的「媒体库入口」
|
||||
// 各自保留自己的页码,进入媒体库详情再返回时不会掉回第一页。
|
||||
const { pathname } = useLocation()
|
||||
const [currentPage, setCurrentPage] = useRememberedListPosition(
|
||||
`libraries-grid:${pathname}`,
|
||||
1,
|
||||
)
|
||||
const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE))
|
||||
const effectivePage = Math.min(currentPage, totalPages)
|
||||
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
|
||||
@@ -382,7 +398,7 @@ export function HomeLibrariesSection({
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||
媒体库
|
||||
{title}
|
||||
</h2>
|
||||
{libraries.length > PAGE_SIZE && (
|
||||
<span className="rounded-md border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[11px] font-semibold text-[var(--app-muted)]">
|
||||
@@ -396,41 +412,45 @@ export function HomeLibrariesSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={effectivePage <= 1}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
|
||||
title="上一页"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="font-mono text-xs font-semibold text-[var(--app-subtle)]">
|
||||
{effectivePage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={effectivePage >= totalPages}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
|
||||
title="下一页"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(totalPages > 1 || showAllLink) && (
|
||||
<div className="flex items-center gap-3">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={effectivePage <= 1}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
|
||||
title="上一页"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="font-mono text-xs font-semibold text-[var(--app-subtle)]">
|
||||
{effectivePage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={effectivePage >= totalPages}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
|
||||
title="下一页"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/libraries"
|
||||
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
|
||||
>
|
||||
<span>全部媒体库</span>
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
{showAllLink && (
|
||||
<Link
|
||||
to="/libraries"
|
||||
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
|
||||
>
|
||||
<span>全部媒体库</span>
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
|
||||
@@ -440,6 +460,8 @@ export function HomeLibrariesSection({
|
||||
library={lib}
|
||||
count={libraryCounts[lib.id] ?? 0}
|
||||
cards={libraryData?.[lib.id]?.cards ?? []}
|
||||
pinned={pinnedIds.includes(lib.id)}
|
||||
onTogglePin={onTogglePin ? () => onTogglePin(lib.id) : undefined}
|
||||
onVisible={() => {
|
||||
if (!lib.cover_url) {
|
||||
queuePreview(lib.id)
|
||||
@@ -452,73 +474,100 @@ export function HomeLibrariesSection({
|
||||
)
|
||||
}
|
||||
|
||||
function HomeLibraryCard({
|
||||
export function HomeLibraryCard({
|
||||
library,
|
||||
count,
|
||||
cards,
|
||||
pinned = false,
|
||||
onTogglePin,
|
||||
onVisible,
|
||||
}: {
|
||||
library: Library
|
||||
count: number
|
||||
cards: SeriesCard[]
|
||||
pinned?: boolean
|
||||
onTogglePin?: () => void
|
||||
onVisible: () => void
|
||||
}) {
|
||||
const ref = useInViewOnce<HTMLAnchorElement>(onVisible)
|
||||
const ref = useInViewOnce<HTMLDivElement>(onVisible)
|
||||
const artwork = getLibraryArtworks(library, cards)
|
||||
|
||||
return (
|
||||
<Link
|
||||
<div
|
||||
ref={ref}
|
||||
to={`/library/${library.id}`}
|
||||
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
|
||||
title={library.name}
|
||||
className={`group relative h-full overflow-hidden rounded-2xl border bg-[var(--app-panel)] transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10 ${
|
||||
pinned ? 'border-brand-500/60 ring-1 ring-brand-500/20' : 'border-[var(--app-border)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
|
||||
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
|
||||
}`}
|
||||
<Link
|
||||
to={`/library/${library.id}`}
|
||||
className="flex h-full flex-col justify-between p-3"
|
||||
title={library.name}
|
||||
>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(
|
||||
src,
|
||||
version,
|
||||
library.cover_url ? undefined : { maxWidth: 480, maxHeight: 320, quality: 78 },
|
||||
)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.visibility = 'hidden'
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-brand-500">
|
||||
{TYPE_ICONS[library.type] || <FolderOpen size={28} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
|
||||
{TYPE_LABELS[library.type] || '自定义'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col justify-between">
|
||||
<h3
|
||||
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
|
||||
title={library.name}
|
||||
<div
|
||||
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
|
||||
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
|
||||
}`}
|
||||
>
|
||||
{library.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
|
||||
{count > 0 ? `${count} 部媒体` : '暂无条目'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(
|
||||
src,
|
||||
version,
|
||||
library.cover_url ? undefined : { maxWidth: 480, maxHeight: 320, quality: 78 },
|
||||
)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.visibility = 'hidden'
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-brand-500">
|
||||
{TYPE_ICONS[library.type] || <FolderOpen size={28} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
|
||||
{TYPE_LABELS[library.type] || '自定义'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col justify-between">
|
||||
<h3
|
||||
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
|
||||
title={library.name}
|
||||
>
|
||||
{library.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
|
||||
{count > 0 ? `${count} 部媒体` : '暂无条目'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{onTogglePin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePin}
|
||||
className={`absolute left-2 top-2 z-10 rounded-lg border p-1.5 shadow-sm backdrop-blur-md transition-all duration-200 opacity-100 sm:pointer-events-none sm:opacity-0 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:focus-visible:pointer-events-auto sm:focus-visible:opacity-100 ${
|
||||
pinned
|
||||
? 'border-brand-400/50 bg-brand-500 text-white'
|
||||
: 'border-white/20 bg-black/60 text-white hover:bg-black/75'
|
||||
}`}
|
||||
title={pinned ? '取消置顶' : '置顶媒体库'}
|
||||
aria-label={pinned ? '取消置顶' : '置顶媒体库'}
|
||||
aria-pressed={pinned}
|
||||
>
|
||||
<Pin size={13} className={pinned ? 'fill-current' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import { LibraryTagBar } from '../components/LibraryTagBar'
|
||||
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import {
|
||||
LibrariesContent,
|
||||
LibrariesEmptyState,
|
||||
@@ -13,18 +17,30 @@ import {
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
import type { Library } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import {
|
||||
readLibraryListSort,
|
||||
sortLibraryPreviewsByField,
|
||||
writeLibraryListSort,
|
||||
type LibraryListSortField,
|
||||
type LibraryListSortOrder,
|
||||
} from '../utils/libraryListSort'
|
||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
||||
import type { LibraryTag } from '../utils/libraryTags'
|
||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||
const libraryTags = useLibraryTags()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||
const [repairMsg, setRepairMsg] = useState('')
|
||||
const [sortField, setSortField] = useState<LibraryListSortField>(() => readLibraryListSort().field)
|
||||
const [sortOrder, setSortOrder] = useState<LibraryListSortOrder>(() => readLibraryListSort().order)
|
||||
|
||||
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||
@@ -103,7 +119,7 @@ export function LibrariesPage() {
|
||||
}, [])
|
||||
|
||||
async function handleRepairRescrape() {
|
||||
if (repairing) return
|
||||
if (!isAdmin || repairing) return
|
||||
setRepairing(true)
|
||||
setRepairMsg('')
|
||||
try {
|
||||
@@ -117,6 +133,7 @@ export function LibrariesPage() {
|
||||
}
|
||||
|
||||
const handleManageLibraries = async () => {
|
||||
if (!isAdmin) return
|
||||
await openManageLibrariesDialog()
|
||||
await loadLibraries({ force: true })
|
||||
}
|
||||
@@ -137,13 +154,35 @@ export function LibrariesPage() {
|
||||
})
|
||||
}, [libraries, libraryData])
|
||||
|
||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
||||
const sortedPreviews = useMemo(
|
||||
() => sortLibraryPreviewsByField(previews, pinnedIds, sortField, sortOrder),
|
||||
[previews, pinnedIds, sortField, sortOrder],
|
||||
)
|
||||
|
||||
const handleSortChange = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
|
||||
setSortField(field)
|
||||
setSortOrder(order)
|
||||
writeLibraryListSort(field, order)
|
||||
}, [])
|
||||
|
||||
const handleTogglePin = useCallback((libraryId: string) => {
|
||||
void togglePin(libraryId)
|
||||
}, [togglePin])
|
||||
|
||||
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
|
||||
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
|
||||
const effectiveTagId = libraryTags.selectedTagId
|
||||
const tagTabs = useMemo(
|
||||
() => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags),
|
||||
[sortedPreviews, libraryTags.tags],
|
||||
)
|
||||
const taggedPreviews = useMemo(
|
||||
() => filterPreviewsByTag(sortedPreviews, libraryTags.tags, effectiveTagId),
|
||||
[sortedPreviews, libraryTags.tags, effectiveTagId],
|
||||
)
|
||||
const taggedTotal = useMemo(
|
||||
() => taggedPreviews.reduce((sum, preview) => sum + preview.total, 0),
|
||||
[taggedPreviews],
|
||||
)
|
||||
|
||||
if (loading || pinnedLoading) {
|
||||
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
||||
@@ -152,21 +191,54 @@ export function LibrariesPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<LibrariesHeader
|
||||
previewCount={previews.length}
|
||||
total={total}
|
||||
previewCount={taggedPreviews.length}
|
||||
total={taggedTotal}
|
||||
isAdmin={isAdmin}
|
||||
repairMsg={repairMsg}
|
||||
repairEpisodeArtwork={repairEpisodeArtwork}
|
||||
repairing={repairing}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
|
||||
onRepairRescrape={handleRepairRescrape}
|
||||
onManageLibraries={handleManageLibraries}
|
||||
/>
|
||||
|
||||
<LibraryTagBar
|
||||
tabs={tagTabs}
|
||||
selectedTagId={effectiveTagId}
|
||||
onSelect={libraryTags.setSelectedTagId}
|
||||
onCreate={(name) => {
|
||||
void libraryTags.createTag(name)
|
||||
}}
|
||||
onManage={() => setLibraryTagsOpen(true)}
|
||||
busy={libraryTags.saving}
|
||||
/>
|
||||
|
||||
{libraryTagsOpen && (
|
||||
<ManageLibraryTagsDialogView
|
||||
tags={libraryTags.tags}
|
||||
libraries={libraries}
|
||||
saving={libraryTags.saving}
|
||||
onCreate={libraryTags.createTag}
|
||||
onRename={libraryTags.renameTag}
|
||||
onRemove={libraryTags.removeTag}
|
||||
onAssign={libraryTags.assignLibrary}
|
||||
onAssignBatch={libraryTags.assignLibraries}
|
||||
onClose={() => setLibraryTagsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previews.length === 0 ? (
|
||||
<LibrariesEmptyState />
|
||||
<LibrariesEmptyState isAdmin={isAdmin} />
|
||||
) : taggedPreviews.length === 0 ? (
|
||||
<p className="rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-16 text-center text-sm text-[var(--app-muted)]">
|
||||
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
|
||||
</p>
|
||||
) : (
|
||||
<LibrariesContent
|
||||
previews={sortedPreviews}
|
||||
previews={taggedPreviews}
|
||||
pinnedIds={pinnedIds}
|
||||
onTogglePin={handleTogglePin}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
@@ -175,3 +247,20 @@ export function LibrariesPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/列表排序)。 */
|
||||
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
|
||||
if (!tagId || tagId === ALL_TAG_ID) return previews
|
||||
const ordered = filterLibrariesByTag(
|
||||
previews.map((preview) => ({ id: preview.library.id })),
|
||||
tags,
|
||||
tagId,
|
||||
)
|
||||
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
|
||||
const out: LibraryPreview[] = []
|
||||
for (const item of ordered) {
|
||||
const preview = byId.get(item.id)
|
||||
if (preview) out.push(preview)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,49 +1,41 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowRight, ChevronLeft, ChevronRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { useInViewOnce } from '../hooks/useInViewOnce'
|
||||
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { libraryDisplayPath } from './libraryDisplayModel'
|
||||
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
|
||||
import { isLibraryPinned } from '../utils/pinnedLibraries'
|
||||
import { LibraryListSortDropdown } from '../components/LibraryListSortDropdown'
|
||||
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
||||
import type { LibraryListSortField, LibraryListSortOrder } from '../utils/libraryListSort'
|
||||
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={18} />,
|
||||
tv: <Tv size={18} />,
|
||||
anime: <PlayCircle size={18} />,
|
||||
variety: <Tv size={18} />,
|
||||
music: <Music size={18} />,
|
||||
adult: <Film size={18} />,
|
||||
}
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '剧集',
|
||||
anime: '动漫',
|
||||
variety: '综艺',
|
||||
music: '音乐',
|
||||
adult: '成人',
|
||||
}
|
||||
// 返回媒体库列表时最多一次性解锁的货架数量:滚动到很深的位置后回来
|
||||
// 仍能落到原处,同时避免一次渲染过多货架。上限之外的继续按需解锁。
|
||||
const MAX_RESTORED_SHELVES = 15
|
||||
|
||||
export function LibrariesHeader({
|
||||
previewCount,
|
||||
total,
|
||||
isAdmin,
|
||||
repairMsg,
|
||||
repairEpisodeArtwork,
|
||||
repairing,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
onRepairEpisodeArtworkChange,
|
||||
onRepairRescrape,
|
||||
onManageLibraries,
|
||||
}: {
|
||||
previewCount: number
|
||||
total: number
|
||||
isAdmin: boolean
|
||||
repairMsg: string
|
||||
repairEpisodeArtwork: boolean
|
||||
repairing: boolean
|
||||
sortField: LibraryListSortField
|
||||
sortOrder: LibraryListSortOrder
|
||||
onSortChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||
onRepairEpisodeArtworkChange: (value: boolean) => void
|
||||
onRepairRescrape: () => void
|
||||
onManageLibraries: () => void
|
||||
@@ -57,48 +49,55 @@ export function LibrariesHeader({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||
<EpisodeArtworkToggle
|
||||
checked={repairEpisodeArtwork}
|
||||
onChange={onRepairEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
||||
>
|
||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||
</button>
|
||||
<Link
|
||||
to="/scraper/queue"
|
||||
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
title="查看正在进行的刮削任务与进度"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>刮削队列</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageLibraries}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
>
|
||||
管理媒体库
|
||||
</button>
|
||||
<LibraryListSortDropdown value={sortField} order={sortOrder} onChange={onSortChange} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||
<EpisodeArtworkToggle
|
||||
checked={repairEpisodeArtwork}
|
||||
onChange={onRepairEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
||||
>
|
||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||
</button>
|
||||
<Link
|
||||
to="/scraper/queue"
|
||||
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
title="查看正在进行的刮削任务与进度"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>刮削队列</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageLibraries}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
>
|
||||
管理媒体库
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LibrariesEmptyState() {
|
||||
export function LibrariesEmptyState({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-sand-200 bg-white py-24 text-center">
|
||||
<LibraryIcon className="mb-4 h-12 w-12 text-gray-400" />
|
||||
<p className="text-sm text-ink-50">暂无媒体库,请到管理后台添加目录。</p>
|
||||
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] py-24 text-center">
|
||||
<LibraryIcon className="mb-4 h-12 w-12 text-[var(--app-muted)]" />
|
||||
<p className="text-sm text-[var(--app-muted)]">
|
||||
{isAdmin ? '暂无媒体库,请到管理后台添加目录。' : '暂无可用媒体库。'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -114,33 +113,40 @@ export function LibrariesContent({
|
||||
onTogglePin: (libraryId: string) => void
|
||||
onNeedPreviews?: (ids: string[], limit?: number) => void
|
||||
}) {
|
||||
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
|
||||
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
|
||||
|
||||
// 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架,
|
||||
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。
|
||||
// 下方媒体库货架继续按需解锁:首屏先展示前 3 个,滚动接近底部再加载 2 个。
|
||||
const INITIAL_SHELVES = 3
|
||||
const STEP_SHELVES = 2
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_SHELVES)
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
// 货架解锁数量同样跨路由记忆:进入媒体库详情再返回不会从头解锁。
|
||||
const [visibleCount, setVisibleCount] = useRememberedListPosition(
|
||||
'libraries-shelves',
|
||||
INITIAL_SHELVES,
|
||||
MAX_RESTORED_SHELVES,
|
||||
)
|
||||
const sentinelRef = useRef<HTMLButtonElement | null>(null)
|
||||
const userScrolledRef = useRef(false)
|
||||
|
||||
const revealMoreShelves = useCallback(() => {
|
||||
setVisibleCount((prev) => {
|
||||
if (prev >= previews.length) return prev
|
||||
return Math.min(prev + STEP_SHELVES, previews.length)
|
||||
})
|
||||
}, [previews.length, setVisibleCount])
|
||||
|
||||
useEffect(() => {
|
||||
const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id)
|
||||
const currentTargets = previews.slice(0, visibleCount).map((preview) => preview.library.id)
|
||||
onNeedPreviews?.(currentTargets, 10)
|
||||
}, [previews, visibleCount, onNeedPreviews])
|
||||
|
||||
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架)
|
||||
useEffect(() => {
|
||||
const scrollParent = document.getElementById('app-main-scroll')
|
||||
if (!scrollParent) return
|
||||
|
||||
const handleCheckBottom = () => {
|
||||
if (scrollParent.scrollTop <= 0) return
|
||||
userScrolledRef.current = true
|
||||
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
|
||||
if (remaining < 600) {
|
||||
setVisibleCount((prev) => {
|
||||
if (prev >= previews.length) return prev
|
||||
return Math.min(prev + STEP_SHELVES, previews.length)
|
||||
})
|
||||
revealMoreShelves()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +158,8 @@ export function LibrariesContent({
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
setVisibleCount((prev) => {
|
||||
if (prev >= previews.length) return prev
|
||||
return Math.min(prev + STEP_SHELVES, previews.length)
|
||||
})
|
||||
if (entry?.isIntersecting && userScrolledRef.current) {
|
||||
revealMoreShelves()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -168,286 +171,74 @@ export function LibrariesContent({
|
||||
observer.observe(sentinel)
|
||||
}
|
||||
|
||||
handleCheckBottom()
|
||||
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleCheckBottom)
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
if (observer) observer.disconnect()
|
||||
}
|
||||
}, [visibleCount, previews.length])
|
||||
|
||||
const visiblePreviews = useMemo(() => {
|
||||
return previews.slice(0, visibleCount)
|
||||
}, [previews, visibleCount])
|
||||
|
||||
const ENTRY_PAGE_SIZE = 20
|
||||
const [entryPage, setEntryPage] = useState(1)
|
||||
const totalEntryPages = Math.max(1, Math.ceil(previews.length / ENTRY_PAGE_SIZE))
|
||||
const effectiveEntryPage = Math.min(entryPage, totalEntryPages)
|
||||
|
||||
const pagedPreviews = useMemo<LibraryPreview[]>(() => {
|
||||
const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE
|
||||
return previews.slice(start, start + ENTRY_PAGE_SIZE)
|
||||
}, [previews, effectiveEntryPage])
|
||||
|
||||
// 入口网格首屏展示前 20 个库;没有自定义封面的库需要立即拉预览,否则卡片会先渲染占位图标再补图。
|
||||
useEffect(() => {
|
||||
const ids = pagedPreviews.filter((preview) => !preview.library.cover_url).map((preview) => preview.library.id)
|
||||
if (ids.length > 0) {
|
||||
onNeedPreviews?.(ids, 2)
|
||||
}
|
||||
}, [pagedPreviews, onNeedPreviews])
|
||||
}, [revealMoreShelves, visibleCount, previews.length])
|
||||
|
||||
const visiblePreviews = useMemo(
|
||||
() => previews.slice(0, visibleCount),
|
||||
[previews, visibleCount],
|
||||
)
|
||||
const libraries = useMemo(() => previews.map((preview) => preview.library), [previews])
|
||||
const libraryData = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
previews.map((preview) => [
|
||||
preview.library.id,
|
||||
{ cards: preview.cards, items: [], total: preview.total },
|
||||
]),
|
||||
),
|
||||
[previews],
|
||||
)
|
||||
const libraryCounts = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
previews.map((preview) => [preview.library.id, preview.total]),
|
||||
),
|
||||
[previews],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
|
||||
{previews.length > ENTRY_PAGE_SIZE && (
|
||||
<span className="rounded-md border border-sand-200 bg-sand-50 px-2 py-0.5 text-xs font-semibold text-sand-700">
|
||||
共 {previews.length} 个 · 每页 {ENTRY_PAGE_SIZE} 个
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-ink-50">
|
||||
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
|
||||
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{totalEntryPages > 1 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-sand-200 bg-white px-2.5 py-1 text-xs shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntryPage((p) => Math.max(1, p - 1))}
|
||||
disabled={effectiveEntryPage <= 1}
|
||||
className="rounded-lg p-1 text-ink-50 hover:bg-sand-100 hover:text-ink-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-50 transition-colors"
|
||||
title="上一页"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="font-mono text-xs font-semibold text-ink-600">
|
||||
{effectiveEntryPage} / {totalEntryPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntryPage((p) => Math.min(totalEntryPages, p + 1))}
|
||||
disabled={effectiveEntryPage >= totalEntryPages}
|
||||
className="rounded-lg p-1 text-ink-50 hover:bg-sand-100 hover:text-ink-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-50 transition-colors"
|
||||
title="下一页"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
|
||||
{pagedPreviews.map((preview) => (
|
||||
<div
|
||||
key={preview.library.id}
|
||||
className="animate-page-in"
|
||||
>
|
||||
<LibraryEntryCard
|
||||
preview={preview}
|
||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||
onTogglePin={() => onTogglePin(preview.library.id)}
|
||||
onVisible={() => {
|
||||
if (!preview.library.cover_url) {
|
||||
queuePreview(preview.library.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<HomeLibrariesSection
|
||||
libraries={libraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
onNeedPreviews={onNeedPreviews}
|
||||
pinnedIds={pinnedIds}
|
||||
onTogglePin={onTogglePin}
|
||||
showAllLink={false}
|
||||
title="媒体库入口"
|
||||
/>
|
||||
|
||||
{visiblePreviews.length > 0 && (
|
||||
<section className="space-y-6">
|
||||
<section className="space-y-10">
|
||||
{visiblePreviews.map((preview) => (
|
||||
<div
|
||||
key={preview.library.id}
|
||||
className="animate-page-in"
|
||||
>
|
||||
<LibraryShelf
|
||||
preview={preview}
|
||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||
<div key={preview.library.id} className="animate-page-in">
|
||||
<HomeLibraryRowSection
|
||||
library={preview.library}
|
||||
cards={preview.cards}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{visibleCount < previews.length && (
|
||||
<div ref={sentinelRef} className="flex h-10 w-full items-center justify-center py-2 opacity-60">
|
||||
<button
|
||||
ref={sentinelRef}
|
||||
type="button"
|
||||
onClick={revealMoreShelves}
|
||||
className="flex h-10 w-full items-center justify-center py-2 opacity-60 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
|
||||
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
|
||||
<span>加载更多媒体库货架…</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryEntryCard({
|
||||
preview,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
onVisible,
|
||||
}: {
|
||||
preview: LibraryPreview
|
||||
pinned: boolean
|
||||
onTogglePin: () => void
|
||||
onVisible?: () => void
|
||||
}) {
|
||||
const library = preview.library
|
||||
const ref = useInViewOnce<HTMLDivElement>(onVisible ?? (() => {}))
|
||||
const artwork = library.cover_url
|
||||
? [{ src: library.cover_url, version: library.updated_at }]
|
||||
: libraryArtworkItems(preview.cards)
|
||||
const displayPath = libraryDisplayPath(library.path)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={
|
||||
'group relative flex overflow-hidden rounded-2xl sm:rounded-3xl border bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:shadow-card-hover ' +
|
||||
(pinned ? 'border-brand-300 ring-1 ring-brand-100' : 'border-sand-200 hover:border-brand-200')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onTogglePin()
|
||||
}}
|
||||
className={
|
||||
'absolute right-2 top-2 z-10 rounded-full border p-1.5 transition ' +
|
||||
(pinned
|
||||
? 'border-brand-200 bg-brand-50 text-brand-600 hover:bg-brand-100'
|
||||
: 'border-sand-200 bg-white/95 text-sand-500 opacity-0 hover:bg-gray-50 hover:text-brand-600 group-hover:opacity-100')
|
||||
}
|
||||
title={pinned ? '取消置顶' : '置顶媒体库'}
|
||||
aria-label={pinned ? '取消置顶' : '置顶媒体库'}
|
||||
aria-pressed={pinned}
|
||||
>
|
||||
<Pin size={14} className={pinned ? 'fill-current' : ''} />
|
||||
</button>
|
||||
<Link
|
||||
to={`/library/${library.id}`}
|
||||
className="flex min-w-0 flex-1"
|
||||
title={library.name}
|
||||
>
|
||||
<div className={`grid h-20 w-24 sm:h-24 sm:w-36 shrink-0 gap-1 overflow-hidden rounded-xl sm:rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(
|
||||
src,
|
||||
version,
|
||||
library.cover_url ? undefined : { maxWidth: 400, maxHeight: 300, quality: 78 },
|
||||
)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover"
|
||||
onError={(event) => { event.currentTarget.style.visibility = 'hidden' }}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-2 flex h-full items-center justify-center text-brand-500">
|
||||
{TYPE_ICONS[library.type] ?? <FolderOpen size={28} className="sm:h-8 sm:w-8" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between px-3 sm:px-4 py-0.5 sm:py-1">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between gap-1.5 pr-8">
|
||||
<span className="inline-flex rounded-full bg-sand-100 px-2 py-0.5 text-[10px] font-bold text-sand-600">
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-sand-500 sm:hidden">
|
||||
{preview.total.toLocaleString()} 个条目
|
||||
</span>
|
||||
</div>
|
||||
<h2
|
||||
className="line-clamp-2 break-words font-display text-sm font-bold leading-snug text-ink-600 group-hover:text-brand-600 sm:text-base lg:text-lg sm:font-black"
|
||||
title={library.name}
|
||||
>
|
||||
{library.name}
|
||||
{pinned ? <span className="ml-1.5 align-middle text-[10px] font-bold text-brand-600">置顶</span> : null}
|
||||
</h2>
|
||||
<p className="mt-0.5 line-clamp-1 break-all text-[11px] text-ink-50 sm:mt-1 sm:text-xs" title={library.path}>
|
||||
{displayPath}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-xs font-bold sm:mt-0">
|
||||
<span className="hidden text-sand-600 sm:inline">{preview.total.toLocaleString()} 个条目</span>
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[11px] text-brand-600 sm:text-xs">
|
||||
浏览全部 <ArrowRight size={12} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryShelf({ preview, pinned }: { preview: LibraryPreview; pinned?: boolean }) {
|
||||
const library = preview.library
|
||||
const cards = preview.cards.slice(0, 10)
|
||||
const displayPath = libraryDisplayPath(library.path)
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl sm:rounded-[1.7rem] border border-sand-200 bg-white/75 p-3.5 sm:p-4 shadow-card">
|
||||
<div className="mb-3 sm:mb-4 flex flex-wrap items-end justify-between gap-2 sm:gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 inline-flex items-center gap-1.5 sm:gap-2 rounded-full bg-brand-50 px-2 sm:px-2.5 py-0.5 sm:py-1 text-[10px] sm:text-[11px] font-bold text-brand-700">
|
||||
{TYPE_ICONS[library.type] ?? <LibraryIcon size={14} />}
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</div>
|
||||
<h2
|
||||
className="line-clamp-2 break-words font-display text-xl sm:text-2xl font-black text-ink-600"
|
||||
title={library.name}
|
||||
>
|
||||
{library.name}
|
||||
{pinned ? <span className="ml-2 align-middle text-xs font-bold text-brand-600">置顶</span> : null}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-1 break-all text-xs text-ink-50">
|
||||
<span title={library.path}>{displayPath}</span> · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部
|
||||
</p>
|
||||
</div>
|
||||
<Link to={`/library/${library.id}`} className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm shrink-0">
|
||||
浏览全部
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{cards.length > 0 ? (
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 pr-1">
|
||||
{cards.map((card) => (
|
||||
<div key={card.key} className="w-[9.5rem] shrink-0 lg:w-[10rem] 2xl:w-[10.5rem]">
|
||||
<MediaCard
|
||||
media={card.rep}
|
||||
count={card.count}
|
||||
linkTo={seriesCardLink(card)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-sand-200 bg-white px-6 py-10 text-center text-sm text-ink-50">
|
||||
该目录暂无可展示内容,扫描媒体库后会出现在这里。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function LibraryMovieActions({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden flex-wrap justify-end gap-1 sm:flex">
|
||||
<button title="智能刮削" disabled={busy} onClick={() => onSmartScrape(media)} className={buttonClass}>
|
||||
<Sparkles size={13} />
|
||||
</button>
|
||||
@@ -46,6 +46,6 @@ export function LibraryMovieActions({
|
||||
<button title="删除" disabled={busy} onClick={() => onDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ export function LibraryPage() {
|
||||
})
|
||||
|
||||
const {
|
||||
resolvingSeries,
|
||||
selectedEpisodes,
|
||||
visibleEpisodes,
|
||||
selectedSeriesEpisodes,
|
||||
@@ -132,6 +133,7 @@ export function LibraryPage() {
|
||||
handleSeriesClick,
|
||||
clearSelectedSeries,
|
||||
} = useLibrarySeriesSelection({
|
||||
libraryID: id,
|
||||
items,
|
||||
seriesEpisodeItems,
|
||||
isSeriesLibrary,
|
||||
@@ -209,7 +211,9 @@ export function LibraryPage() {
|
||||
return <>{actions}</>
|
||||
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
|
||||
|
||||
if (loading) {
|
||||
// resolvingSeries:深链目标剧集不在已加载的一页卡片里,正在按 key
|
||||
// 单独解析。此时先显示加载态,避免先渲染整个媒体库列表再跳走。
|
||||
if (loading || resolvingSeries) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 2 }} className="flex items-center gap-3">
|
||||
|
||||
@@ -19,6 +19,7 @@ import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
normalizeSubtitleChineseMode,
|
||||
@@ -109,8 +110,13 @@ export function PlayerPage() {
|
||||
const [playerVolume, setPlayerVolume] = useState(() =>
|
||||
normalizePlayerVolume(authUser?.player_volume),
|
||||
)
|
||||
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() =>
|
||||
normalizePlaybackRate(authUser?.player_playback_rate),
|
||||
)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
const playerVolumeTouchedRef = useRef(false)
|
||||
const playerPlaybackRateTouchedRef = useRef(false)
|
||||
const playerPlaybackRateSaveSeqRef = useRef(0)
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
@@ -245,6 +251,10 @@ export function PlayerPage() {
|
||||
if (!playerVolumeTouchedRef.current) {
|
||||
setPlayerVolume(volume)
|
||||
}
|
||||
const playbackRate = normalizePlaybackRate(cfg.playback_rate)
|
||||
if (!playerPlaybackRateTouchedRef.current) {
|
||||
setPlayerPlaybackRate(playbackRate)
|
||||
}
|
||||
setDanmakuEnabled(cfg.enabled)
|
||||
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||
@@ -255,6 +265,10 @@ export function PlayerPage() {
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_volume: volume })
|
||||
}
|
||||
if (!playerPlaybackRateTouchedRef.current) {
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_playback_rate: playbackRate })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 配置读取失败时保持本地默认值,不影响播放。
|
||||
@@ -289,6 +303,34 @@ export function PlayerPage() {
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
const changePlayerPlaybackRate = useCallback((next: number) => {
|
||||
playerPlaybackRateTouchedRef.current = true
|
||||
setPlayerPlaybackRate(normalizePlaybackRate(next))
|
||||
}, [])
|
||||
|
||||
const commitPlayerPlaybackRate = useCallback(
|
||||
(next: number) => {
|
||||
playerPlaybackRateTouchedRef.current = true
|
||||
const playbackRate = normalizePlaybackRate(next)
|
||||
setPlayerPlaybackRate(playbackRate)
|
||||
const saveSeq = ++playerPlaybackRateSaveSeqRef.current
|
||||
void danmakuAPI
|
||||
.updateSettings({ playback_rate: playbackRate })
|
||||
.then((cfg) => {
|
||||
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
|
||||
const saved = normalizePlaybackRate(cfg.playback_rate)
|
||||
setPlayerPlaybackRate(saved)
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_playback_rate: saved })
|
||||
})
|
||||
.catch(() => {
|
||||
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
|
||||
toast.error('倍速保存失败,请重试')
|
||||
})
|
||||
},
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
const saveDanmakuAdvanced = useCallback(
|
||||
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
|
||||
const patch: DanmakuSettingsPatch = {
|
||||
@@ -1424,6 +1466,9 @@ export function PlayerPage() {
|
||||
playerVolume={playerVolume}
|
||||
onPlayerVolumeChange={changePlayerVolume}
|
||||
onPlayerVolumeCommit={commitPlayerVolume}
|
||||
playerPlaybackRate={playerPlaybackRate}
|
||||
onPlayerPlaybackRateChange={changePlayerPlaybackRate}
|
||||
onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
danmakuOpacity={danmakuOpacity}
|
||||
danmakuFontSize={danmakuFontSize}
|
||||
|
||||
@@ -151,6 +151,9 @@ type PlayerVideoStageProps = {
|
||||
playerVolume: number
|
||||
onPlayerVolumeChange: (volume: number) => void
|
||||
onPlayerVolumeCommit: (volume: number) => void
|
||||
playerPlaybackRate: number
|
||||
onPlayerPlaybackRateChange: (rate: number) => void
|
||||
onPlayerPlaybackRateCommit: (rate: number) => void
|
||||
danmakuEnabled: boolean
|
||||
danmakuOpacity: number
|
||||
danmakuFontSize: number
|
||||
@@ -205,6 +208,9 @@ export function PlayerVideoStage({
|
||||
playerVolume,
|
||||
onPlayerVolumeChange,
|
||||
onPlayerVolumeCommit,
|
||||
playerPlaybackRate,
|
||||
onPlayerPlaybackRateChange,
|
||||
onPlayerPlaybackRateCommit,
|
||||
danmakuEnabled,
|
||||
danmakuOpacity,
|
||||
danmakuFontSize,
|
||||
@@ -653,6 +659,9 @@ export function PlayerVideoStage({
|
||||
volume={playerVolume}
|
||||
onVolumeChange={onPlayerVolumeChange}
|
||||
onVolumeCommit={onPlayerVolumeCommit}
|
||||
playbackRate={playerPlaybackRate}
|
||||
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
||||
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
||||
uiVisible={controlsVisible}
|
||||
onUiVisibleChange={setControlsVisible}
|
||||
subs={subs}
|
||||
|
||||
+100
-65
@@ -6,6 +6,7 @@ import type { Library, Media } from '../types'
|
||||
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
|
||||
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
|
||||
import type { SortField, SortOrder } from '../utils/mediaSort'
|
||||
import { MAX_RESTORE_PAGES, readListPosition, writeListPosition } from '../hooks/useListPositionMemory'
|
||||
|
||||
export function useLibraryData(
|
||||
libraryID: string,
|
||||
@@ -43,6 +44,84 @@ export function useLibraryData(
|
||||
const modeRef = useRef<'media' | 'series'>('media')
|
||||
const moreInFlightRef = useRef(false)
|
||||
|
||||
// 分页位置按「媒体库 + 排序」记忆:详情页返回、甚至换完排序再切回来,
|
||||
// 都能回到上次加载到的页数。
|
||||
const positionKey = `library:${libraryID}:${sortField}:${sortOrder}`
|
||||
|
||||
// 拉取并追加下一页。滚动哨兵、按钮和首屏分页恢复共用这一条路径,
|
||||
// 避免两套分页逻辑各自算页码。
|
||||
const appendNextPage = useCallback(async (options?: { remember?: boolean }) => {
|
||||
const lib = libraryRef.current
|
||||
if (!lib || !hasMoreRef.current) return false
|
||||
const seq = requestSeqRef.current
|
||||
const page = nextPageRef.current
|
||||
try {
|
||||
if (modeRef.current === 'series') {
|
||||
const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib), {
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return false
|
||||
const pageItems = data.items ?? []
|
||||
setServerSeriesCards((prev) => [...prev, ...pageItems])
|
||||
loadedCountRef.current += pageItems.length
|
||||
totalRef.current = data.total ?? totalRef.current
|
||||
nextPageRef.current = page + 1
|
||||
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
|
||||
} else {
|
||||
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib), {
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return false
|
||||
const pageItems = data.items ?? []
|
||||
setItems((prev) => [...prev, ...pageItems])
|
||||
loadedCountRef.current += pageItems.length
|
||||
totalRef.current = data.total ?? totalRef.current
|
||||
nextPageRef.current = page + 1
|
||||
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
|
||||
}
|
||||
setTotal(totalRef.current)
|
||||
setHasMore(hasMoreRef.current)
|
||||
if (options?.remember !== false) writeListPosition(positionKey, page)
|
||||
return true
|
||||
} catch {
|
||||
if (seq === requestSeqRef.current) {
|
||||
toast.error('媒体库加载失败')
|
||||
hasMoreRef.current = false
|
||||
setHasMore(false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}, [libraryID, positionKey, sortField, sortOrder])
|
||||
|
||||
const loadMore = useCallback(async (options?: { remember?: boolean }) => {
|
||||
if (moreInFlightRef.current || !hasMoreRef.current) return
|
||||
moreInFlightRef.current = true
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
await appendNextPage(options)
|
||||
} finally {
|
||||
moreInFlightRef.current = false
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [appendNextPage])
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
const seq = requestSeqRef.current
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
while (seq === requestSeqRef.current && hasMoreRef.current) {
|
||||
// 一次拉全量(random 排序前的准备)不代表用户的分页位置,不写入记忆。
|
||||
await loadMore({ remember: false })
|
||||
if (seq !== requestSeqRef.current || !hasMoreRef.current) break
|
||||
await yieldToBrowser()
|
||||
}
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setLoadingMore(false)
|
||||
}
|
||||
}, [loadMore])
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID) return
|
||||
const seq = ++requestSeqRef.current
|
||||
@@ -86,7 +165,7 @@ export function useLibraryData(
|
||||
|
||||
const seriesMode = isSeriesLibraryType(lib.type)
|
||||
modeRef.current = seriesMode ? 'series' : 'media'
|
||||
const pageSize = pageSizeFor(lib, seriesMode)
|
||||
const pageSize = pageSizeFor(lib)
|
||||
try {
|
||||
if (seriesMode) {
|
||||
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
|
||||
@@ -106,6 +185,19 @@ export function useLibraryData(
|
||||
setTotal(totalRef.current)
|
||||
hasMoreRef.current = loadedCountRef.current < totalRef.current
|
||||
setHasMore(hasMoreRef.current)
|
||||
|
||||
// 按记忆的分页位置把后续页补回来:从详情页返回时直接回到上次翻到的
|
||||
// 位置,而不是只剩第一页。超出上限的部分继续交给底部哨兵按需加载。
|
||||
const restoreTarget = Math.min(readListPosition(positionKey, 1), MAX_RESTORE_PAGES)
|
||||
while (
|
||||
!cancelled &&
|
||||
seq === requestSeqRef.current &&
|
||||
hasMoreRef.current &&
|
||||
nextPageRef.current <= restoreTarget
|
||||
) {
|
||||
const advanced = await appendNextPage()
|
||||
if (!advanced) break
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled && seq === requestSeqRef.current) {
|
||||
toast.error('媒体库加载失败')
|
||||
@@ -122,7 +214,7 @@ export function useLibraryData(
|
||||
cancelled = true
|
||||
requestSeqRef.current += 1
|
||||
}
|
||||
}, [libraryID, reloadTick, sortField, sortOrder])
|
||||
}, [appendNextPage, libraryID, positionKey, reloadTick, sortField, sortOrder])
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
|
||||
@@ -146,65 +238,6 @@ export function useLibraryData(
|
||||
return () => { cancelled = true }
|
||||
}, [libraryID, isSeriesLibrary, selectedSeries])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const lib = libraryRef.current
|
||||
if (!lib || moreInFlightRef.current || !hasMoreRef.current) return
|
||||
const seq = requestSeqRef.current
|
||||
moreInFlightRef.current = true
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const page = nextPageRef.current
|
||||
if (modeRef.current === 'series') {
|
||||
const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib, true), {
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return
|
||||
const pageItems = data.items ?? []
|
||||
setServerSeriesCards((prev) => [...prev, ...pageItems])
|
||||
loadedCountRef.current += pageItems.length
|
||||
totalRef.current = data.total ?? totalRef.current
|
||||
nextPageRef.current = page + 1
|
||||
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
|
||||
} else {
|
||||
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib, false), {
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return
|
||||
const pageItems = data.items ?? []
|
||||
setItems((prev) => [...prev, ...pageItems])
|
||||
loadedCountRef.current += pageItems.length
|
||||
totalRef.current = data.total ?? totalRef.current
|
||||
nextPageRef.current = page + 1
|
||||
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
|
||||
}
|
||||
setTotal(totalRef.current)
|
||||
setHasMore(hasMoreRef.current)
|
||||
} catch {
|
||||
toast.error('媒体库加载失败')
|
||||
hasMoreRef.current = false
|
||||
setHasMore(false)
|
||||
} finally {
|
||||
moreInFlightRef.current = false
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [libraryID, sortField, sortOrder])
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
const seq = requestSeqRef.current
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
while (seq === requestSeqRef.current && hasMoreRef.current) {
|
||||
await loadMore()
|
||||
if (seq !== requestSeqRef.current || !hasMoreRef.current) break
|
||||
await yieldToBrowser()
|
||||
}
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setLoadingMore(false)
|
||||
}
|
||||
}, [loadMore])
|
||||
|
||||
const reloadCurrentLibrary = useCallback(() => {
|
||||
setReloadTick((tick) => tick + 1)
|
||||
}, [])
|
||||
@@ -238,9 +271,11 @@ function isSeriesLibraryType(type?: string) {
|
||||
return type === 'tv' || type === 'anime' || type === 'variety'
|
||||
}
|
||||
|
||||
function pageSizeFor(lib: Library, seriesMode: boolean): number {
|
||||
if (lib.is_remote_emby) return 100
|
||||
return seriesMode ? 500 : 2000
|
||||
function pageSizeFor(lib: Library): number {
|
||||
// 首屏保持一屏多一点,后续统一由底部哨兵按页追加。此前沿用旧的
|
||||
// 500/2000 批量值,会让中小型媒体库在第一次请求时就等于全量加载。
|
||||
if (lib.is_remote_emby) return 50
|
||||
return 60
|
||||
}
|
||||
|
||||
function yieldToBrowser(): Promise<void> {
|
||||
@@ -251,4 +286,4 @@ function yieldToBrowser(): Promise<void> {
|
||||
setTimeout(resolve, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
specialSectionForMedia,
|
||||
type SeriesCard,
|
||||
} from '../utils/groupSeries'
|
||||
import { resolveSeriesCardByKey } from '../utils/seriesCardResolve'
|
||||
|
||||
type SeasonEpisodes = {
|
||||
season: number
|
||||
@@ -16,6 +17,7 @@ type SeasonEpisodes = {
|
||||
}
|
||||
|
||||
type UseLibrarySeriesSelectionOptions = {
|
||||
libraryID: string
|
||||
items: Media[]
|
||||
seriesEpisodeItems: Media[]
|
||||
isSeriesLibrary: boolean
|
||||
@@ -32,6 +34,7 @@ type UseLibrarySeriesSelectionOptions = {
|
||||
}
|
||||
|
||||
export function useLibrarySeriesSelection({
|
||||
libraryID,
|
||||
items,
|
||||
seriesEpisodeItems,
|
||||
isSeriesLibrary,
|
||||
@@ -46,6 +49,14 @@ export function useLibrarySeriesSelection({
|
||||
setSelectedSeason,
|
||||
onClearSeriesState,
|
||||
}: UseLibrarySeriesSelectionOptions) {
|
||||
// 深链(首页最新条目 / 海报墙 ?series=<key>)指向的剧集不在已加载的
|
||||
// 一页卡片里时,需要单独按 key 解析;resolvingSeries 让页面在这段时间
|
||||
// 显示加载态,而不是先闪一下整个媒体库列表。
|
||||
const [resolvingSeries, setResolvingSeries] = useState(false)
|
||||
const [pendingSeriesKey, setPendingSeriesKey] = useState('')
|
||||
// 当前 URL 请求的剧集 key(非剧集库 / 加载中 / 没带参数时为空)。
|
||||
const requestedSeriesKey = !loading && isSeries ? (searchParams.get('series') ?? '') : ''
|
||||
|
||||
const selectedEpisodes = useMemo(() => {
|
||||
const sourceItems = isSeriesLibrary ? seriesEpisodeItems : items
|
||||
if (!selectedSeries || sourceItems.length === 0) return []
|
||||
@@ -92,20 +103,53 @@ export function useLibrarySeriesSelection({
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
if (!isSeries) {
|
||||
setPendingSeriesKey('')
|
||||
setSelectedSeries(null)
|
||||
setSelectedSeason(null)
|
||||
return
|
||||
}
|
||||
|
||||
const key = searchParams.get('series')
|
||||
if (!key) {
|
||||
if (!requestedSeriesKey) {
|
||||
setPendingSeriesKey('')
|
||||
setSelectedSeries(null)
|
||||
return
|
||||
}
|
||||
|
||||
const next = seriesCards.find((card) => card.key === key)
|
||||
setSelectedSeries(next ?? null)
|
||||
}, [isSeries, loading, searchParams, seriesCards, setSelectedSeason, setSelectedSeries])
|
||||
const next = seriesCards.find((card) => card.key === requestedSeriesKey)
|
||||
if (next) {
|
||||
setPendingSeriesKey('')
|
||||
setSelectedSeries(next)
|
||||
return
|
||||
}
|
||||
|
||||
// 目标剧集尚未加载到(远程库分页 / 大库分页):登记待解析 key。
|
||||
setPendingSeriesKey((prev) => (prev === requestedSeriesKey ? prev : requestedSeriesKey))
|
||||
}, [isSeries, loading, requestedSeriesKey, seriesCards, setSelectedSeason, setSelectedSeries])
|
||||
|
||||
useEffect(() => {
|
||||
// pendingSeriesKey 必须仍然是 URL 上请求的 key:切换媒体库/清空参数
|
||||
// 时直接丢弃待解析状态,避免把别的库的剧集解析进来。
|
||||
if (!pendingSeriesKey || pendingSeriesKey !== requestedSeriesKey) {
|
||||
setResolvingSeries(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setResolvingSeries(true)
|
||||
resolveSeriesCardByKey(libraryID, pendingSeriesKey)
|
||||
.then((card) => {
|
||||
if (cancelled) return
|
||||
setResolvingSeries(false)
|
||||
setSelectedSeries(card)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setResolvingSeries(false)
|
||||
setSelectedSeries(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [libraryID, pendingSeriesKey, requestedSeriesKey, setSelectedSeries])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSeries || selectedEpisodes.length === 0) {
|
||||
@@ -135,6 +179,7 @@ export function useLibrarySeriesSelection({
|
||||
}
|
||||
|
||||
return {
|
||||
resolvingSeries,
|
||||
selectedEpisodes,
|
||||
visibleEpisodes,
|
||||
selectedSeriesEpisodes,
|
||||
|
||||
@@ -53,8 +53,9 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||
|
||||
hasPermission: (key: string) => {
|
||||
const state = get()
|
||||
// Super user (admin or plus) has all permissions
|
||||
if (state.isSuper) {
|
||||
// Only admins implicitly have every capability in the UI.
|
||||
// Plus / is_super must not reveal scrape/organize/delete controls.
|
||||
if (state.role === 'admin') {
|
||||
return true
|
||||
}
|
||||
return (state.permissions ?? {})[key] === true
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LibraryTagSet } from './library'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
@@ -10,10 +12,14 @@ export interface User {
|
||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
|
||||
player_volume?: number
|
||||
/** 本地缓存的播放倍速,服务端配置接口会刷新。 */
|
||||
player_playback_rate?: number
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
allowed_library_ids?: string[]
|
||||
pinned_library_ids?: string[]
|
||||
/** 当前用户维护的媒体库标签分组。 */
|
||||
library_tags?: LibraryTagSet[]
|
||||
is_default_admin?: boolean
|
||||
is_protected?: boolean
|
||||
realtime_online?: boolean
|
||||
|
||||
@@ -28,6 +28,15 @@ export interface Library {
|
||||
total?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户自定义的媒体库标签分组。标签属于当前用户本人,用于在媒体库页把
|
||||
* 同一标签下的媒体库聚合到一起。一个媒体库同时只属于一个标签。
|
||||
*/
|
||||
export interface LibraryTagSet {
|
||||
name: string
|
||||
library_ids: string[]
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
library_id: string
|
||||
visited: number
|
||||
|
||||
@@ -36,6 +36,7 @@ export type SeriesCard = {
|
||||
linkMedia?: Media
|
||||
linkLibraryId?: string
|
||||
count: number
|
||||
is_series?: boolean
|
||||
last_added_at?: string
|
||||
}
|
||||
|
||||
@@ -223,6 +224,7 @@ export function seasonLabel(season: number): string {
|
||||
export function isSeriesCard(card: SeriesCard): boolean {
|
||||
const linkMedia = card.linkMedia ?? card.rep
|
||||
return (
|
||||
card.is_series === true ||
|
||||
card.count > 1 ||
|
||||
isEpisodeLike(card.rep) ||
|
||||
isEpisodeLike(linkMedia) ||
|
||||
@@ -458,7 +460,7 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
|
||||
? compactSeriesKey(externalKey)
|
||||
: getSeriesKey(m)
|
||||
|
||||
const mAddedAt = m.created_at || m.updated_at || ''
|
||||
const mAddedAt = m.updated_at || m.created_at || ''
|
||||
const g = groups.get(key)
|
||||
if (!g) {
|
||||
groups.set(key, { key, rep: m, linkMedia: m, count: 1, last_added_at: mAddedAt })
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Library } from '../types'
|
||||
|
||||
export type LibraryListSortField = 'name' | 'sort_order'
|
||||
export type LibraryListSortOrder = 'asc' | 'desc'
|
||||
|
||||
export type LibraryListSortOption = {
|
||||
id: LibraryListSortField
|
||||
label: string
|
||||
defaultOrder: LibraryListSortOrder
|
||||
}
|
||||
|
||||
export const LIBRARY_LIST_SORT_OPTIONS: LibraryListSortOption[] = [
|
||||
{ id: 'name', label: '库名', defaultOrder: 'desc' },
|
||||
{ id: 'sort_order', label: '手动顺序', defaultOrder: 'asc' },
|
||||
]
|
||||
|
||||
export const DEFAULT_LIBRARY_LIST_SORT_FIELD: LibraryListSortField = 'name'
|
||||
export const DEFAULT_LIBRARY_LIST_SORT_ORDER: LibraryListSortOrder = 'desc'
|
||||
|
||||
const FIELD_KEY = 'mebox_libraries_sort_field'
|
||||
const ORDER_KEY = 'mebox_libraries_sort_order'
|
||||
|
||||
export function readLibraryListSort(): { field: LibraryListSortField; order: LibraryListSortOrder } {
|
||||
if (typeof window === 'undefined') {
|
||||
return { field: DEFAULT_LIBRARY_LIST_SORT_FIELD, order: DEFAULT_LIBRARY_LIST_SORT_ORDER }
|
||||
}
|
||||
const rawField = window.localStorage.getItem(FIELD_KEY)
|
||||
const rawOrder = window.localStorage.getItem(ORDER_KEY)
|
||||
const field: LibraryListSortField =
|
||||
rawField === 'name' || rawField === 'sort_order' ? rawField : DEFAULT_LIBRARY_LIST_SORT_FIELD
|
||||
const order: LibraryListSortOrder =
|
||||
rawOrder === 'asc' || rawOrder === 'desc'
|
||||
? rawOrder
|
||||
: field === 'name'
|
||||
? DEFAULT_LIBRARY_LIST_SORT_ORDER
|
||||
: 'asc'
|
||||
return { field, order }
|
||||
}
|
||||
|
||||
export function writeLibraryListSort(field: LibraryListSortField, order: LibraryListSortOrder): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(FIELD_KEY, field)
|
||||
window.localStorage.setItem(ORDER_KEY, order)
|
||||
}
|
||||
|
||||
function compareNames(a: string, b: string, order: LibraryListSortOrder): number {
|
||||
const cmp = a.localeCompare(b, 'zh-CN', { numeric: true, sensitivity: 'base' })
|
||||
return order === 'asc' ? cmp : -cmp
|
||||
}
|
||||
|
||||
function compareSortOrder(a: number, b: number, order: LibraryListSortOrder): number {
|
||||
const cmp = a - b
|
||||
return order === 'asc' ? cmp : -cmp
|
||||
}
|
||||
|
||||
function librarySortKey(library: Library): { name: string; sortOrder: number } {
|
||||
return {
|
||||
name: (library.name || '').trim(),
|
||||
sortOrder: library.sort_order ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function compareLibraries(a: Library, b: Library, field: LibraryListSortField, order: LibraryListSortOrder): number {
|
||||
const ka = librarySortKey(a)
|
||||
const kb = librarySortKey(b)
|
||||
if (field === 'name') {
|
||||
const byName = compareNames(ka.name, kb.name, order)
|
||||
if (byName !== 0) return byName
|
||||
return compareSortOrder(ka.sortOrder, kb.sortOrder, 'asc') || a.id.localeCompare(b.id)
|
||||
}
|
||||
const byOrder = compareSortOrder(ka.sortOrder, kb.sortOrder, order)
|
||||
if (byOrder !== 0) return byOrder
|
||||
return compareNames(ka.name, kb.name, 'asc') || a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 置顶组始终在前;组内按 field/order 排序。
|
||||
* pinnedIds 的相对顺序只决定「是否置顶」,组内不再保留置顶拖拽序。
|
||||
*/
|
||||
export function sortLibraryPreviewsByField<T extends { library: Library }>(
|
||||
items: T[],
|
||||
pinnedIds: string[],
|
||||
field: LibraryListSortField,
|
||||
order: LibraryListSortOrder,
|
||||
): T[] {
|
||||
if (items.length < 2) return items
|
||||
|
||||
const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index]))
|
||||
const pinned: T[] = []
|
||||
const rest: T[] = []
|
||||
for (const item of items) {
|
||||
if (pinnedRank.has(item.library.id)) pinned.push(item)
|
||||
else rest.push(item)
|
||||
}
|
||||
|
||||
const byField = (a: T, b: T) => compareLibraries(a.library, b.library, field, order)
|
||||
pinned.sort(byField)
|
||||
rest.sort(byField)
|
||||
return [...pinned, ...rest]
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
ALL_TAG_ID,
|
||||
assignLibrariesToTag,
|
||||
attachLibrariesToTag,
|
||||
attachLibraryToTag,
|
||||
buildLibraryTagTabs,
|
||||
dedupeLibraryTags,
|
||||
detachLibrariesFromTag,
|
||||
detachLibraryFromTag,
|
||||
filterLibrariesByTag,
|
||||
filterLibrariesForTagging,
|
||||
normalizeLibraryTags,
|
||||
normalizeTagName,
|
||||
resolveSelectedTagId,
|
||||
tagNameError,
|
||||
} from './libraryTags.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`libraryTags: ${name}`)
|
||||
}
|
||||
|
||||
const libs = [
|
||||
{ id: 'lib-a', total: 10 },
|
||||
{ id: 'lib-b', total: 5 },
|
||||
{ id: 'lib-c', total: 0 },
|
||||
]
|
||||
|
||||
check('empty input normalizes to empty list', normalizeLibraryTags(null).length === 0)
|
||||
check('trims and drops blank names', normalizeLibraryTags([{ name: ' ', library_ids: [] }]).length === 0)
|
||||
check(
|
||||
'merges duplicate tag names case-insensitively',
|
||||
normalizeLibraryTags([
|
||||
{ name: 'Anime', library_ids: ['a'] },
|
||||
{ name: 'anime', library_ids: ['b', 'a'] },
|
||||
]).length === 1,
|
||||
)
|
||||
check(
|
||||
'dedupes ids inside one tag',
|
||||
normalizeLibraryTags([{ name: '动画', library_ids: ['a', '', 'a', 'b'] }])[0].library_ids.join(',') === 'a,b',
|
||||
)
|
||||
check('truncates over-long names to 24 chars', Array.from(normalizeTagName('x'.repeat(40))).length === 24)
|
||||
|
||||
const tabs = buildLibraryTagTabs(libs, [
|
||||
{ name: '动画', library_ids: ['lib-b', 'missing'] },
|
||||
{ name: '电影', library_ids: [] },
|
||||
])
|
||||
check('tab bar always starts with 全部', tabs[0].id === ALL_TAG_ID && tabs[0].count === 3)
|
||||
check('tag tab keeps creation order', tabs[1].name === '动画' && tabs[2].name === '电影')
|
||||
check('tag count ignores unavailable libraries', tabs[1].count === 1)
|
||||
|
||||
check(
|
||||
'filtering keeps tag order and drops unknown ids',
|
||||
filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画')
|
||||
.map((lib) => lib.id)
|
||||
.join(',') === 'lib-c,lib-a',
|
||||
)
|
||||
check('全部 returns every library in original order', filterLibrariesByTag(libs, [], ALL_TAG_ID).length === 3)
|
||||
check('unknown tag yields no libraries', filterLibrariesByTag(libs, [], 'nope').length === 0)
|
||||
|
||||
check(
|
||||
'selected tag falls back to 全部 when the tag is gone',
|
||||
resolveSelectedTagId([{ name: '动画', library_ids: [] }], '动画') === '动画' &&
|
||||
resolveSelectedTagId([{ name: '动画', library_ids: [] }], '电影') === ALL_TAG_ID,
|
||||
)
|
||||
|
||||
const claimed = dedupeLibraryTags([
|
||||
{ name: '动画', library_ids: ['a', 'b'] },
|
||||
{ name: '电影', library_ids: ['b', 'c'] },
|
||||
])
|
||||
check('one library belongs to a single tag', claimed[0].library_ids.join(',') === 'a,b')
|
||||
check('later tag loses the shared library', claimed[1].library_ids.join(',') === 'c')
|
||||
|
||||
const attached = attachLibraryToTag(
|
||||
[
|
||||
{ name: '动画', library_ids: ['a'] },
|
||||
{ name: '电影', library_ids: ['b'] },
|
||||
],
|
||||
'a',
|
||||
'电影',
|
||||
)
|
||||
check('attach moves the library out of its old tag', attached[0].library_ids.length === 0)
|
||||
check('attach appends to the target tag', attached[1].library_ids.join(',') === 'b,a')
|
||||
|
||||
const detached = detachLibraryFromTag(
|
||||
[{ name: '电影', library_ids: ['b', 'a'] }],
|
||||
'a',
|
||||
'电影',
|
||||
)
|
||||
check('detach removes only the requested library', detached[0].library_ids.join(',') === 'b')
|
||||
|
||||
const batchAttached = attachLibrariesToTag(
|
||||
[
|
||||
{ name: '动画', library_ids: ['a', 'x'] },
|
||||
{ name: '电影', library_ids: ['b'] },
|
||||
],
|
||||
['b', 'x', 'b', ' missing '],
|
||||
'动画',
|
||||
)
|
||||
check('batch attach moves every id out of its old tag', batchAttached[1].library_ids.length === 0)
|
||||
check('batch attach keeps order and dedupes ids', batchAttached[0].library_ids.join(',') === 'a,b,x,missing')
|
||||
check('batch attach ignores unknown target tag', attachLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '不存在')[0].library_ids.length === 0)
|
||||
check('batch attach with no ids is a no-op', attachLibrariesToTag([{ name: '动画', library_ids: ['a'] }], [], '动画')[0].library_ids.join(',') === 'a')
|
||||
|
||||
const batchDetached = detachLibrariesFromTag(
|
||||
[
|
||||
{ name: '动画', library_ids: ['a', 'b'] },
|
||||
{ name: '电影', library_ids: ['b', 'c'] },
|
||||
],
|
||||
['b', 'c'],
|
||||
)
|
||||
check('batch detach removes ids from every tag', batchDetached[0].library_ids.join(',') === 'a')
|
||||
check('batch detach clears emptied tags', batchDetached[1].library_ids.length === 0)
|
||||
check(
|
||||
'assign with empty tag name clears everything',
|
||||
assignLibrariesToTag([{ name: '动画', library_ids: ['a'] }], ['a'], '')[0].library_ids.length === 0,
|
||||
)
|
||||
check(
|
||||
'assign with a tag routes to attach',
|
||||
assignLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '动画')[0].library_ids.join(',') === 'a',
|
||||
)
|
||||
|
||||
const searchable = [
|
||||
{ id: 'lib-a', name: '电影库', path: '/media/movies', type: 'movie' },
|
||||
{ id: 'lib-b', name: 'TV Shows', path: '/media/tv', type: 'tv' },
|
||||
]
|
||||
check('tagging search matches name', filterLibrariesForTagging(searchable as never, '电影').length === 1)
|
||||
check('tagging search matches path', filterLibrariesForTagging(searchable as never, 'media/tv').length === 1)
|
||||
check('tagging search matches type case-insensitively', filterLibrariesForTagging(searchable as never, 'MOVIE').length === 1)
|
||||
check('tagging search with blank keyword returns all', filterLibrariesForTagging(searchable as never, ' ').length === 2)
|
||||
|
||||
check('blank name is rejected', tagNameError(' ', []) !== '')
|
||||
check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '')
|
||||
check(
|
||||
'renaming a tag to itself is allowed',
|
||||
tagNameError('动画', [{ name: '动画', library_ids: [] }], '动画') === '',
|
||||
)
|
||||
|
||||
console.log('libraryTags.test.ts ok')
|
||||
@@ -0,0 +1,298 @@
|
||||
import type { Library, LibraryTagSet } from '../types'
|
||||
|
||||
/** 与后端 model.MaxLibraryTagNameLen 保持一致。 */
|
||||
export const MAX_TAG_NAME_LENGTH = 24
|
||||
/** 与后端 model.MaxLibraryTags 保持一致。 */
|
||||
export const MAX_LIBRARY_TAGS = 50
|
||||
|
||||
/** 「全部」伪标签:不参与持久化,仅用于标签栏选中项。 */
|
||||
export const ALL_TAG_ID = '__all__'
|
||||
|
||||
export type LibraryTag = LibraryTagSet
|
||||
|
||||
export type LibraryTagTab = {
|
||||
/** 选中的稳定标识:ALL_TAG_ID 或标签名。 */
|
||||
id: string
|
||||
name: string
|
||||
count: number
|
||||
/** 是否为「全部」标签栏 */
|
||||
isAll: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'mebox_library_tags'
|
||||
const SELECTED_KEY = 'mebox_library_tag_selected'
|
||||
|
||||
/** 标签名去空白并按字符数截断,与后端清洗规则一致。 */
|
||||
export function normalizeTagName(name: string): string {
|
||||
return Array.from(name.trim()).slice(0, MAX_TAG_NAME_LENGTH).join('').trim()
|
||||
}
|
||||
|
||||
export function normalizeLibraryTags(tags: unknown): LibraryTag[] {
|
||||
if (!Array.isArray(tags)) return []
|
||||
const out: LibraryTag[] = []
|
||||
const indexByName = new Map<string, number>()
|
||||
for (const raw of tags) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const name = normalizeTagName(String((raw as LibraryTagSet).name ?? ''))
|
||||
if (!name) continue
|
||||
const key = name.toLowerCase()
|
||||
let pos = indexByName.get(key)
|
||||
if (pos === undefined) {
|
||||
if (out.length >= MAX_LIBRARY_TAGS) break
|
||||
out.push({ name, library_ids: [] })
|
||||
pos = out.length - 1
|
||||
indexByName.set(key, pos)
|
||||
}
|
||||
const ids = (raw as LibraryTagSet).library_ids
|
||||
if (!Array.isArray(ids)) continue
|
||||
const seen = new Set(out[pos].library_ids)
|
||||
for (const id of ids) {
|
||||
const trimmed = String(id ?? '').trim()
|
||||
if (!trimmed || seen.has(trimmed)) continue
|
||||
seen.add(trimmed)
|
||||
out[pos].library_ids.push(trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 读取本地兜底缓存:接口不可用时标签栏仍然可用。 */
|
||||
export function readCachedLibraryTags(): LibraryTag[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
try {
|
||||
return normalizeLibraryTags(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedLibraryTags(tags: LibraryTag[]): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
if (tags.length === 0) {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tags))
|
||||
} catch {
|
||||
// 私密模式等场景忽略存储失败。
|
||||
}
|
||||
}
|
||||
|
||||
export function readSelectedTagId(): string {
|
||||
if (typeof window === 'undefined') return ALL_TAG_ID
|
||||
try {
|
||||
return window.localStorage.getItem(SELECTED_KEY)?.trim() || ALL_TAG_ID
|
||||
} catch {
|
||||
return ALL_TAG_ID
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSelectedTagId(tagId: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
if (!tagId || tagId === ALL_TAG_ID) {
|
||||
window.localStorage.removeItem(SELECTED_KEY)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(SELECTED_KEY, tagId)
|
||||
} catch {
|
||||
// 同上。
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLibraryTags(): Promise<LibraryTag[]> {
|
||||
const { profileAPI } = await import('../api/profile')
|
||||
const remote = normalizeLibraryTags(await profileAPI.getLibraryTags())
|
||||
writeCachedLibraryTags(remote)
|
||||
return remote
|
||||
}
|
||||
|
||||
export async function saveLibraryTags(tags: LibraryTag[]): Promise<LibraryTag[]> {
|
||||
const { profileAPI } = await import('../api/profile')
|
||||
const saved = normalizeLibraryTags(await profileAPI.setLibraryTags(normalizeLibraryTags(tags)))
|
||||
writeCachedLibraryTags(saved)
|
||||
return saved
|
||||
}
|
||||
|
||||
/**
|
||||
* 把标签集合中的媒体库 ID 收敛为「每个库只归属一个标签」:越靠前的标签优先,
|
||||
* 后面的标签里重复出现的库会被移除。与后端 SetLibraryTags 的语义一致。
|
||||
*/
|
||||
export function dedupeLibraryTags(tags: LibraryTag[]): LibraryTag[] {
|
||||
const claimed = new Set<string>()
|
||||
return tags.map((tag) => {
|
||||
const libraryIds: string[] = []
|
||||
for (const id of tag.library_ids) {
|
||||
if (claimed.has(id)) continue
|
||||
claimed.add(id)
|
||||
libraryIds.push(id)
|
||||
}
|
||||
return { ...tag, library_ids: libraryIds }
|
||||
})
|
||||
}
|
||||
|
||||
/** 标签名是否可用:非空、未超长、且(除了自身以外)没有重名。 */
|
||||
export function tagNameError(name: string, tags: LibraryTag[], exceptName?: string): string {
|
||||
const trimmed = normalizeTagName(name)
|
||||
if (!trimmed) return '标签名不能为空'
|
||||
if (Array.from(name.trim()).length > MAX_TAG_NAME_LENGTH) {
|
||||
return `标签名最多 ${MAX_TAG_NAME_LENGTH} 个字`
|
||||
}
|
||||
const key = trimmed.toLowerCase()
|
||||
const selfKey = exceptName ? normalizeTagName(exceptName).toLowerCase() : ''
|
||||
if (key !== selfKey && tags.some((tag) => tag.name.toLowerCase() === key)) {
|
||||
return '标签名已存在'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function findTagIdByName(tags: LibraryTag[], name: string): string {
|
||||
const key = normalizeTagName(name).toLowerCase()
|
||||
if (!key) return ''
|
||||
return tags.find((tag) => tag.name.toLowerCase() === key)?.name ?? ''
|
||||
}
|
||||
|
||||
export function tagLibraryIds(tags: LibraryTag[], tagId: string): string[] {
|
||||
if (!tagId || tagId === ALL_TAG_ID) return []
|
||||
const tag = tags.find((item) => item.name === tagId)
|
||||
return tag ? tag.library_ids : []
|
||||
}
|
||||
|
||||
export function isLibraryInTag(tags: LibraryTag[], tagId: string, libraryId: string): boolean {
|
||||
return tagLibraryIds(tags, tagId).includes(libraryId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回(保持原有置顶/手动排序),
|
||||
* 否则只保留该标签下的库,并保持标签里记录的顺序。
|
||||
*/
|
||||
export function filterLibrariesByTag<T extends { id: string }>(
|
||||
libraries: T[],
|
||||
tags: LibraryTag[],
|
||||
tagId: string,
|
||||
): T[] {
|
||||
if (!tagId || tagId === ALL_TAG_ID) return libraries
|
||||
const ids = tagLibraryIds(tags, tagId)
|
||||
if (ids.length === 0) return []
|
||||
const byId = new Map(libraries.map((lib) => [lib.id, lib]))
|
||||
const out: T[] = []
|
||||
for (const id of ids) {
|
||||
const lib = byId.get(id)
|
||||
if (lib) out.push(lib)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */
|
||||
export function buildLibraryTagTabs(libraries: Library[], tags: LibraryTag[]): LibraryTagTab[] {
|
||||
const tabs: LibraryTagTab[] = [
|
||||
{ id: ALL_TAG_ID, name: '全部', count: libraries.length, isAll: true },
|
||||
]
|
||||
if (tags.length === 0) return tabs
|
||||
const available = new Set(libraries.map((lib) => lib.id))
|
||||
for (const tag of tags) {
|
||||
tabs.push({
|
||||
id: tag.name,
|
||||
name: tag.name,
|
||||
count: tag.library_ids.filter((id) => available.has(id)).length,
|
||||
isAll: false,
|
||||
})
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
|
||||
/** 选中的标签是否仍存在;被删除或被过滤掉时回落到「全部」。 */
|
||||
export function resolveSelectedTagId(tags: LibraryTag[], selectedId: string): string {
|
||||
if (!selectedId || selectedId === ALL_TAG_ID) return ALL_TAG_ID
|
||||
return tags.some((tag) => tag.name === selectedId) ? selectedId : ALL_TAG_ID
|
||||
}
|
||||
|
||||
/** 标签下的媒体库条目总数(用于标题文案)。 */
|
||||
export function sumLibraryTotals(libraries: Library[]): number {
|
||||
return libraries.reduce((sum, lib) => sum + (lib.total ?? 0), 0)
|
||||
}
|
||||
|
||||
/** 把一个媒体库挂到标签下:会先从原标签移除,保证一库一标签。 */
|
||||
export function attachLibraryToTag(tags: LibraryTag[], libraryId: string, tagName: string): LibraryTag[] {
|
||||
const target = findTagIdByName(tags, tagName)
|
||||
if (!target) return tags
|
||||
const next = tags.map((tag) => ({
|
||||
...tag,
|
||||
library_ids: tag.library_ids.filter((id) => id !== libraryId),
|
||||
}))
|
||||
return next.map((tag) =>
|
||||
tag.name === target ? { ...tag, library_ids: [...tag.library_ids, libraryId] } : tag,
|
||||
)
|
||||
}
|
||||
|
||||
/** 从标签下移除媒体库。 */
|
||||
export function detachLibraryFromTag(tags: LibraryTag[], libraryId: string, tagName: string): LibraryTag[] {
|
||||
return tags.map((tag) =>
|
||||
tag.name === tagName ? { ...tag, library_ids: tag.library_ids.filter((id) => id !== libraryId) } : tag,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量把一个或多个媒体库挂到标签下:先从各自原标签移除,再按传入顺序追加,
|
||||
* 保证「一库一标签」。整批只产生一次写入。
|
||||
*/
|
||||
export function attachLibrariesToTag(
|
||||
tags: LibraryTag[],
|
||||
libraryIds: string[],
|
||||
tagName: string,
|
||||
): LibraryTag[] {
|
||||
const target = findTagIdByName(tags, tagName)
|
||||
const moving = normalizeLibraryIds(libraryIds)
|
||||
if (!target || moving.length === 0) return tags
|
||||
const movingSet = new Set(moving)
|
||||
const next = tags.map((tag) => ({
|
||||
...tag,
|
||||
library_ids: tag.library_ids.filter((id) => !movingSet.has(id)),
|
||||
}))
|
||||
return next.map((tag) =>
|
||||
tag.name === target ? { ...tag, library_ids: [...tag.library_ids, ...moving] } : tag,
|
||||
)
|
||||
}
|
||||
|
||||
/** 批量把媒体库移出所有标签(即变为「未分类」)。 */
|
||||
export function detachLibrariesFromTag(tags: LibraryTag[], libraryIds: string[]): LibraryTag[] {
|
||||
const moving = new Set(normalizeLibraryIds(libraryIds))
|
||||
if (moving.size === 0) return tags
|
||||
return tags.map((tag) => ({
|
||||
...tag,
|
||||
library_ids: tag.library_ids.filter((id) => !moving.has(id)),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 批量归类:tagName 为空表示移出所有标签。 */
|
||||
export function assignLibrariesToTag(
|
||||
tags: LibraryTag[],
|
||||
libraryIds: string[],
|
||||
tagName: string,
|
||||
): LibraryTag[] {
|
||||
return tagName ? attachLibrariesToTag(tags, libraryIds, tagName) : detachLibrariesFromTag(tags, libraryIds)
|
||||
}
|
||||
|
||||
/** 去掉空白与重复,保留首次出现的顺序。 */
|
||||
export function normalizeLibraryIds(libraryIds: unknown): string[] {
|
||||
if (!Array.isArray(libraryIds)) return []
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const raw of libraryIds) {
|
||||
const trimmed = String(raw ?? '').trim()
|
||||
if (!trimmed || seen.has(trimmed)) continue
|
||||
seen.add(trimmed)
|
||||
out.push(trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 媒体库列表的模糊匹配(名称 / 路径 / 类型),供标签管理里的搜索框使用。 */
|
||||
export function filterLibrariesForTagging(libraries: Library[], keyword: string): Library[] {
|
||||
const needle = keyword.trim().toLowerCase()
|
||||
if (!needle) return libraries
|
||||
return libraries.filter((lib) =>
|
||||
`${lib.name} ${lib.path} ${lib.type}`.toLowerCase().includes(needle),
|
||||
)
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export function sortMediaList(
|
||||
case 'created_at':
|
||||
return compareDates(a.created_at, b.created_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||
case 'updated_at':
|
||||
return compareDates(a.created_at || a.updated_at, b.created_at || b.updated_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||
return compareDates(a.updated_at || a.created_at, b.updated_at || b.created_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||
case 'rating':
|
||||
case 'imdb_rating': {
|
||||
const cmp = compareNumbers(a.rating || 0, b.rating || 0, order)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
formatPlaybackRate,
|
||||
normalizePlaybackRate,
|
||||
stepPlaybackRate,
|
||||
} from './playbackRate.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`playbackRate: ${name}`)
|
||||
}
|
||||
|
||||
check('invalid value falls back to 1x', normalizePlaybackRate(undefined) === 1)
|
||||
check('value snaps to nearest selectable rate', normalizePlaybackRate(1.2) === 1.25)
|
||||
check('value below range clamps to the minimum', normalizePlaybackRate(0.1) === 0.5)
|
||||
check('value above range clamps to the maximum', normalizePlaybackRate(9) === 3)
|
||||
check('up steps to the next rate', stepPlaybackRate(1, 1) === 1.25)
|
||||
check('down steps to the previous rate', stepPlaybackRate(1, -1) === 0.75)
|
||||
check('up stops at the maximum', stepPlaybackRate(3, 1) === 3)
|
||||
check('down stops at the minimum', stepPlaybackRate(0.5, -1) === 0.5)
|
||||
check('integer formatting', formatPlaybackRate(1) === '1x')
|
||||
check('decimal formatting', formatPlaybackRate(1.25) === '1.25x')
|
||||
|
||||
console.log('playbackRate.test.ts ok')
|
||||
@@ -0,0 +1,31 @@
|
||||
export const PLAYBACK_RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3] as const
|
||||
|
||||
export const MIN_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[0]
|
||||
export const MAX_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[PLAYBACK_RATE_OPTIONS.length - 1]
|
||||
|
||||
export function normalizePlaybackRate(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
let closest: number = PLAYBACK_RATE_OPTIONS[0]
|
||||
let closestDistance = Number.POSITIVE_INFINITY
|
||||
for (const option of PLAYBACK_RATE_OPTIONS) {
|
||||
const distance = Math.abs(option - parsed)
|
||||
if (distance < closestDistance) {
|
||||
closest = option
|
||||
closestDistance = distance
|
||||
}
|
||||
}
|
||||
return closest
|
||||
}
|
||||
|
||||
export function stepPlaybackRate(current: unknown, direction: 1 | -1): number {
|
||||
const normalized = normalizePlaybackRate(current)
|
||||
const index = PLAYBACK_RATE_OPTIONS.indexOf(normalized as (typeof PLAYBACK_RATE_OPTIONS)[number])
|
||||
const next = Math.min(PLAYBACK_RATE_OPTIONS.length - 1, Math.max(0, index + direction))
|
||||
return PLAYBACK_RATE_OPTIONS[next]
|
||||
}
|
||||
|
||||
export function formatPlaybackRate(rate: unknown): string {
|
||||
const normalized = normalizePlaybackRate(rate)
|
||||
return `${Number.isInteger(normalized) ? normalized.toFixed(0) : String(normalized)}x`
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { libraryAPI, mediaAPI } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
import { groupSeries, type SeriesCard } from './groupSeries'
|
||||
import { isRemoteEmbyID } from './remoteEmby'
|
||||
|
||||
/**
|
||||
* 按 series key 反解出剧集卡片。
|
||||
*
|
||||
* 首页「最新条目」、海报墙等入口点击后带 ?series=<key> 深链进媒体库页,
|
||||
* 但媒体库页每次只加载一页剧集卡片(远程 Emby 库 100 条),目标剧集不在
|
||||
* 这一页时列表里找不到对应卡片,页面就退化成整个媒体库列表。这里按 key
|
||||
* 单独解析目标剧集,与分页无关:
|
||||
*
|
||||
* - 远程 Emby 的 key 本身就是(伪装后的)剧集 ID,直接取该条目详情;
|
||||
* - 本地 key 是分组 hash,改取该剧集的集列表,再折叠出代表条目。
|
||||
*
|
||||
* key 失效(剧集已删除等)时返回 null,调用方保持"整库列表"原状态。
|
||||
*/
|
||||
export async function resolveSeriesCardByKey(libraryID: string, key: string): Promise<SeriesCard | null> {
|
||||
if (!libraryID || !key) return null
|
||||
|
||||
if (isRemoteEmbyID(key)) {
|
||||
const rep = await fetchRemoteSeriesMedia(key)
|
||||
if (rep) {
|
||||
return { key, rep, linkMedia: rep, count: 0, is_series: true }
|
||||
}
|
||||
}
|
||||
|
||||
const episodes = await fetchSeriesEpisodes(libraryID, key)
|
||||
if (episodes.length === 0) return null
|
||||
// 与列表页同一套折叠规则,选出海报/元数据最好的那一集做代表卡片。
|
||||
const [grouped] = groupSeries(episodes)
|
||||
const rep = grouped?.rep ?? episodes[0]
|
||||
return {
|
||||
key,
|
||||
rep,
|
||||
linkMedia: grouped?.linkMedia ?? rep,
|
||||
count: episodes.length,
|
||||
is_series: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemoteSeriesMedia(id: string): Promise<Media | null> {
|
||||
try {
|
||||
const media = await mediaAPI.get(id)
|
||||
return media?.id ? media : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSeriesEpisodes(libraryID: string, key: string): Promise<Media[]> {
|
||||
try {
|
||||
const page = await libraryAPI.listSeriesEpisodes(libraryID, key)
|
||||
return Array.isArray(page.items) ? page.items : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user