mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d56fb53a08 | |||
| 30ca94c79b | |||
| 9c1f9a6e7e | |||
| ec7be38266 | |||
| ebf791c436 | |||
| 83c0b65a0b | |||
| 1153fe4191 | |||
| 93866d169b | |||
| d283d39008 | |||
| fc89d23867 | |||
| 1f14a1e85b | |||
| f0a6b630c6 | |||
| 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)
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
}
|
}
|
||||||
var cfg struct {
|
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 {
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
@@ -120,6 +121,9 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
|||||||
if cfg.MergeSources {
|
if cfg.MergeSources {
|
||||||
t.Fatal("default merge preference should be false")
|
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 {
|
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||||
t.Fatalf("set: %v", err)
|
t.Fatalf("set: %v", err)
|
||||||
@@ -137,7 +141,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
|||||||
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||||
svc := newDanmakuSettingsService(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")
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
||||||
updateDanmakuSettingsHandler(svc)(c)
|
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" {
|
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
||||||
t.Fatalf("unexpected render config: %+v", cfg)
|
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)
|
t.Fatalf("unexpected user preferences: %+v", cfg)
|
||||||
}
|
}
|
||||||
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
||||||
@@ -165,11 +169,63 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
|||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
t.Fatalf("read persisted user: %v", err)
|
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)
|
t.Fatalf("preferences not persisted: %+v", user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
|
||||||
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
|
||||||
|
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
|
||||||
|
if initial.Vr360GuideSeen {
|
||||||
|
t.Fatal("vr360 guide should be unread for a fresh user")
|
||||||
|
}
|
||||||
|
|
||||||
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||||
|
`{"vr360_guide_seen":true}`, "user-1")
|
||||||
|
updateDanmakuSettingsHandler(svc)(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var cfg service.DanmakuRenderConfig
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Vr360GuideSeen {
|
||||||
|
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||||
|
if err != nil || user == nil {
|
||||||
|
t.Fatalf("read persisted user: %v", err)
|
||||||
|
}
|
||||||
|
if !user.PlayerVr360GuideSeen {
|
||||||
|
t.Fatalf("guide flag not persisted on the user: %+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) {
|
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
|
||||||
svc := newDanmakuSettingsService(t)
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
|
||||||
|
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
|
||||||
|
// 否则多用户会串到同一个 UserId 上。
|
||||||
|
func embyEffectiveUserID(c *gin.Context) string {
|
||||||
|
return embyUserID(c)
|
||||||
|
}
|
||||||
|
|
||||||
const embyCompatSessionTTL = 30 * time.Minute
|
const embyCompatSessionTTL = 30 * time.Minute
|
||||||
|
|
||||||
type embyCompatSession struct {
|
type embyCompatSession struct {
|
||||||
|
|||||||
@@ -13,13 +13,7 @@ import (
|
|||||||
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
||||||
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
|
||||||
}
|
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
splitOpt := func(s string) []string {
|
splitOpt := func(s string) []string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -73,10 +67,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
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 {
|
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
|
||||||
}
|
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||||
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -126,13 +111,7 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
|
||||||
}
|
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -147,13 +126,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if svc != nil && svc.Emby != nil {
|
if svc != nil && svc.Emby != nil {
|
||||||
uid := firstQueryValue(c, "UserId", "userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = c.Param("userId")
|
|
||||||
}
|
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
|
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
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 {
|
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
params := service.ItemsParams{
|
params := service.ItemsParams{
|
||||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
UserID: embyEffectiveUserID(c),
|
||||||
ParentID: c.Param("id"),
|
ParentID: c.Param("id"),
|
||||||
Limit: 500,
|
Limit: 500,
|
||||||
}
|
}
|
||||||
@@ -230,7 +203,7 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
parentID = c.Param("id")
|
parentID = c.Param("id")
|
||||||
}
|
}
|
||||||
params := service.ItemsParams{
|
params := service.ItemsParams{
|
||||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
UserID: embyEffectiveUserID(c),
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
IncludeItemTypes: []string{"Episode"},
|
IncludeItemTypes: []string{"Episode"},
|
||||||
Recursive: true,
|
Recursive: true,
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ import (
|
|||||||
|
|
||||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -47,10 +44,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
// The official-format route carries a :format suffix (Stream.ass /
|
// The official-format route carries a :format suffix (Stream.ass /
|
||||||
// Stream.vtt); prefer it for the Content-Type when present, otherwise
|
// 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 {
|
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
mid := c.Param("itemId")
|
mid := c.Param("itemId")
|
||||||
if uid == "" || mid == "" {
|
if uid == "" || mid == "" {
|
||||||
c.Status(http.StatusBadRequest)
|
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 {
|
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
mid := c.Param("itemId")
|
mid := c.Param("itemId")
|
||||||
if uid == "" || mid == "" {
|
if uid == "" || mid == "" {
|
||||||
c.Status(http.StatusBadRequest)
|
c.Status(http.StatusBadRequest)
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ import (
|
|||||||
|
|
||||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
out, err := svc.Emby.Views(c.Request.Context(), uid)
|
out, err := svc.Emby.Views(c.Request.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -722,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mount.ProxyPlay {
|
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
|
||||||
|
// 与挂载账号的 proxy_play 开关等价。
|
||||||
|
if mount.ProxyPlay || wantSameOriginProxy(c) {
|
||||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||||
if !c.Writer.Written() {
|
if !c.Writer.Written() {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
@@ -758,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
|
||||||
|
// 画质与原文件一致,不触发转码。
|
||||||
|
if wantSameOriginProxy(c) {
|
||||||
|
handled, proxyErr := proxySTRMStream(c, svc, m)
|
||||||
|
if handled {
|
||||||
|
writeProxyError(c, proxyErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||||
if errors.Is(err, service.ErrMediaNotFound) {
|
if errors.Is(err, service.ErrMediaNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +14,45 @@ type pinnedLibrariesReq struct {
|
|||||||
LibraryIDs []string `json:"library_ids"`
|
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 {
|
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
|||||||
authed.PATCH("/me", updateProfileHandler(svc))
|
authed.PATCH("/me", updateProfileHandler(svc))
|
||||||
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
|
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
|
||||||
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(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/password", changePasswordHandler(svc))
|
||||||
authed.POST("/me/logout", logoutHandler(svc))
|
authed.POST("/me/logout", logoutHandler(svc))
|
||||||
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
|||||||
"GET /api/me",
|
"GET /api/me",
|
||||||
"GET /api/me/pinned-libraries",
|
"GET /api/me/pinned-libraries",
|
||||||
"PUT /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/auth/permissions",
|
||||||
"GET /api/libraries",
|
"GET /api/libraries",
|
||||||
"GET /api/media",
|
"GET /api/media",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
|
cards = sortRemoteSeriesCards(cards, sortSpec, history)
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
||||||
if page < 1 {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
|
||||||
|
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
|
||||||
|
//
|
||||||
|
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
|
||||||
|
// 避免把网盘流量无谓地压到服务器上。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
|
||||||
|
func wantSameOriginProxy(c *gin.Context) bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
|
||||||
|
case "1", "true", "yes":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
|
||||||
|
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
|
||||||
|
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
|
||||||
|
if c == nil || svc == nil || svc.Strm == nil || m == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if !service.IsStrmMediaRow(m) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
|
||||||
|
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
|
||||||
|
func writeProxyError(c *gin.Context, err error) {
|
||||||
|
if c == nil || err == nil || c.Writer.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
@@ -92,13 +92,14 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
|
userID := toString(uid)
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||||
if limit <= 0 || limit > 50 {
|
if limit <= 0 || limit > 50 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
var rows []model.PlaybackHistory
|
var rows []model.PlaybackHistory
|
||||||
if err := svc.Repo.DB.
|
if err := svc.Repo.DB.
|
||||||
Where("user_id = ? AND completed = ?", toString(uid), false).
|
Where("user_id = ? AND completed = ?", userID, false).
|
||||||
Order("watched_at desc").
|
Order("watched_at desc").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
@@ -116,61 +117,52 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
mIdx := make(map[string]model.Media, len(media))
|
mIdx := make(map[string]model.Media, len(media))
|
||||||
for _, m := range media {
|
for _, m := range media {
|
||||||
if !mediaVisibleForRequest(c, svc, &m) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mIdx[m.ID] = m
|
mIdx[m.ID] = m
|
||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(rows))
|
out := make([]gin.H, 0, len(rows))
|
||||||
|
staleIDs := make([]string, 0)
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
m, ok := mIdx[r.MediaID]
|
m, ok := mIdx[r.MediaID]
|
||||||
if !ok {
|
if ok {
|
||||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
if mediaVisibleForRequest(c, svc, &m) {
|
||||||
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 {
|
|
||||||
out = append(out, gin.H{
|
out = append(out, gin.H{
|
||||||
"history": r,
|
"history": r,
|
||||||
"media": *fallback,
|
"media": m,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, gin.H{
|
|
||||||
"history": r,
|
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
||||||
"media": m,
|
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)
|
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.
|
// historyDeleteHandler removes one or all history rows for the caller.
|
||||||
//
|
//
|
||||||
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-8
@@ -28,18 +28,27 @@ type User struct {
|
|||||||
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
||||||
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||||
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
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 是网页播放器外挂字幕的简繁转换偏好:
|
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
|
||||||
// original / simplified / traditional。
|
// original / simplified / traditional。
|
||||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
|
||||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
// 按用户保存:看过一次之后不再弹出。
|
||||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
|
||||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||||
|
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
||||||
|
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
||||||
|
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
||||||
|
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
||||||
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
|
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
|
||||||
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
||||||
// ExpiredAt is the account expiry time. Nil means the account never
|
// ExpiredAt is the account expiry time. Nil means the account never
|
||||||
@@ -102,4 +111,5 @@ func (u *User) PopulateComputedFields() {
|
|||||||
}
|
}
|
||||||
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
|
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
|
||||||
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
|
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
|
||||||
|
u.LibraryTagList = u.DecodeLibraryTags()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ const (
|
|||||||
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
|
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
|
||||||
const DanmakuDefaultSource = "https://api.dandanplay.net"
|
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
|
// danmakuOfficialBase is where identification (/api/v2/match) and the
|
||||||
// comment/search fallback always go, regardless of the configured source.
|
// 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.
|
// A package var (not a const) so tests can point it at a local server.
|
||||||
@@ -63,6 +69,10 @@ type DanmakuRenderConfig struct {
|
|||||||
FontSize string `json:"font_size"`
|
FontSize string `json:"font_size"`
|
||||||
Area string `json:"area"`
|
Area string `json:"area"`
|
||||||
Volume float64 `json:"volume"`
|
Volume float64 `json:"volume"`
|
||||||
|
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
||||||
|
PlaybackRate float64 `json:"playback_rate"`
|
||||||
|
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
|
||||||
|
Vr360GuideSeen bool `json:"vr360_guide_seen"`
|
||||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||||
MergeSources bool `json:"merge_sources"`
|
MergeSources bool `json:"merge_sources"`
|
||||||
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
||||||
@@ -89,6 +99,9 @@ type DanmakuSettingsPatch struct {
|
|||||||
Area *float64 `json:"area"`
|
Area *float64 `json:"area"`
|
||||||
MergeSources *bool `json:"merge_sources"`
|
MergeSources *bool `json:"merge_sources"`
|
||||||
Volume *float64 `json:"volume"`
|
Volume *float64 `json:"volume"`
|
||||||
|
PlaybackRate *float64 `json:"playback_rate"`
|
||||||
|
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
|
||||||
|
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type danmakuUserContextKey struct{}
|
type danmakuUserContextKey struct{}
|
||||||
@@ -345,10 +358,11 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
|
|||||||
// Config reads danmaku settings from the runtime settings table.
|
// Config reads danmaku settings from the runtime settings table.
|
||||||
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||||
cfg := DanmakuRenderConfig{
|
cfg := DanmakuRenderConfig{
|
||||||
Opacity: "1",
|
Opacity: "1",
|
||||||
FontSize: "24",
|
FontSize: "24",
|
||||||
Area: "1",
|
Area: "1",
|
||||||
Volume: 1,
|
Volume: 1,
|
||||||
|
PlaybackRate: defaultPlayerPlaybackRate,
|
||||||
}
|
}
|
||||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||||
return cfg
|
return cfg
|
||||||
@@ -399,7 +413,14 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
|
|||||||
|
|
||||||
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||||
if user == nil {
|
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
|
opacity := user.DanmakuOpacity
|
||||||
if opacity < 0.1 || opacity > 1 {
|
if opacity < 0.1 || opacity > 1 {
|
||||||
@@ -417,6 +438,10 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
|||||||
if volume < 0 || volume > 1 {
|
if volume < 0 || volume > 1 {
|
||||||
volume = 1
|
volume = 1
|
||||||
}
|
}
|
||||||
|
playbackRate := user.PlayerPlaybackRate
|
||||||
|
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
|
||||||
|
playbackRate = defaultPlayerPlaybackRate
|
||||||
|
}
|
||||||
return DanmakuRenderConfig{
|
return DanmakuRenderConfig{
|
||||||
Enabled: user.DanmakuEnabled,
|
Enabled: user.DanmakuEnabled,
|
||||||
Source: strings.TrimSpace(user.DanmakuSource),
|
Source: strings.TrimSpace(user.DanmakuSource),
|
||||||
@@ -425,6 +450,8 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
|||||||
FontSize: strconv.Itoa(fontSize),
|
FontSize: strconv.Itoa(fontSize),
|
||||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||||
Volume: volume,
|
Volume: volume,
|
||||||
|
PlaybackRate: playbackRate,
|
||||||
|
Vr360GuideSeen: user.PlayerVr360GuideSeen,
|
||||||
MergeSources: user.DanmakuMergeSources,
|
MergeSources: user.DanmakuMergeSources,
|
||||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||||
}
|
}
|
||||||
@@ -495,6 +522,20 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
|
|||||||
}
|
}
|
||||||
updates["player_volume"] = *patch.Volume
|
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 patch.Vr360GuideSeen != nil {
|
||||||
|
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
|
||||||
|
}
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
|||||||
user.DanmakuFontSize = 30
|
user.DanmakuFontSize = 30
|
||||||
user.DanmakuArea = 0.7
|
user.DanmakuArea = 0.7
|
||||||
user.PlayerVolume = 0.42
|
user.PlayerVolume = 0.42
|
||||||
|
user.PlayerPlaybackRate = 1.25
|
||||||
require.NoError(t, svc.repo.User.Create(ctx, &user))
|
require.NoError(t, svc.repo.User.Create(ctx, &user))
|
||||||
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
|
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
|
||||||
|
|
||||||
@@ -430,6 +431,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
|||||||
require.Contains(t, res.Raw, "弹幕A")
|
require.Contains(t, res.Raw, "弹幕A")
|
||||||
require.Equal(t, srv.URL(), res.Source)
|
require.Equal(t, srv.URL(), res.Source)
|
||||||
require.Equal(t, 0.42, res.Volume)
|
require.Equal(t, 0.42, res.Volume)
|
||||||
|
require.Equal(t, 1.25, res.PlaybackRate)
|
||||||
require.Equal(t, "0.65", res.Opacity)
|
require.Equal(t, "0.65", res.Opacity)
|
||||||
require.Equal(t, "30", res.FontSize)
|
require.Equal(t, "30", res.FontSize)
|
||||||
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
|
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") {
|
if containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||||
return e.favoriteItems(ctx, p)
|
return e.favoriteItems(ctx, p)
|
||||||
}
|
}
|
||||||
|
// 继续观看必须走 MeBox 本地 PlaybackHistory,不能转发到远程共用账号。
|
||||||
|
if containsEmbyFilter(p.Filters, "IsResumable") {
|
||||||
|
return e.resumableItems(ctx, p)
|
||||||
|
}
|
||||||
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||||
if mount == nil || acct == nil {
|
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 {
|
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||||
return nil, err
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != 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) {
|
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||||
return embyLatestCacheValue{Items: []map[string]any{}}, nil
|
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 {
|
if err != nil {
|
||||||
return embyLatestCacheValue{}, err
|
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
|
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))
|
localIDs := make([]string, 0, len(hist))
|
||||||
for _, h := range hist {
|
for _, h := range hist {
|
||||||
|
mediaIDs = append(mediaIDs, h.MediaID)
|
||||||
if !IsEmbyRemoteID(h.MediaID) {
|
if !IsEmbyRemoteID(h.MediaID) {
|
||||||
localIDs = append(localIDs, 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{}
|
byID := map[string]*model.Media{}
|
||||||
if len(localIDs) > 0 {
|
if len(localIDs) > 0 {
|
||||||
var medias []model.Media
|
var medias []model.Media
|
||||||
@@ -498,7 +504,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
localTotal++
|
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
|
continue
|
||||||
}
|
}
|
||||||
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
|
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
|
||||||
@@ -559,7 +565,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
|
item["UserData"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
|
||||||
slots[i].item = item
|
slots[i].item = item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -860,6 +860,180 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
|
|||||||
return out, nil
|
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 配置重写
|
// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写
|
||||||
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
|
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
|
||||||
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
|
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
|
||||||
|
|||||||
@@ -180,31 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
|
|||||||
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
|
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
|
||||||
seriesID = rid
|
seriesID = rid
|
||||||
}
|
}
|
||||||
rating := remoteItemFloat(item, "CommunityRating")
|
rating := remoteItemFloat(item, "CommunityRating")
|
||||||
if rating == 0 {
|
if rating == 0 {
|
||||||
rating = remoteItemFloat(item, "CriticRating")
|
rating = remoteItemFloat(item, "CriticRating")
|
||||||
}
|
}
|
||||||
year := remoteItemInt(item, "ProductionYear")
|
year := remoteItemInt(item, "ProductionYear")
|
||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = remoteItemInt(item, "Year")
|
year = remoteItemInt(item, "Year")
|
||||||
}
|
}
|
||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = remoteItemInt(item, "SeriesProductionYear")
|
year = remoteItemInt(item, "SeriesProductionYear")
|
||||||
}
|
}
|
||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = remoteItemInt(item, "SeriesYear")
|
year = remoteItemInt(item, "SeriesYear")
|
||||||
}
|
}
|
||||||
media := model.Media{
|
media := model.Media{
|
||||||
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
||||||
Title: remoteItemString(item, "Name"),
|
Title: remoteItemString(item, "Name"),
|
||||||
OriginalName: remoteItemString(item, "OriginalTitle"),
|
OriginalName: remoteItemString(item, "OriginalTitle"),
|
||||||
Overview: remoteItemString(item, "Overview"),
|
Overview: remoteItemString(item, "Overview"),
|
||||||
Year: year,
|
Year: year,
|
||||||
Rating: float32(rating),
|
Rating: float32(rating),
|
||||||
Path: remoteItemString(item, "Path"),
|
Path: remoteItemString(item, "Path"),
|
||||||
Genres: remoteItemGenres(item),
|
Genres: remoteItemGenres(item),
|
||||||
ScrapeStatus: "done",
|
ScrapeStatus: "done",
|
||||||
}
|
}
|
||||||
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
|
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
|
||||||
media.CreatedAt = date
|
media.CreatedAt = date
|
||||||
media.UpdatedAt = date
|
media.UpdatedAt = date
|
||||||
@@ -509,19 +509,24 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
|
|||||||
return items, total, nil
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remoteSeriesPageSize 是远程剧集分页拉取的每页条数:Fields 带 Overview/Path
|
||||||
|
// 等重字段,单页 1000 条时载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断
|
||||||
|
// 直接解析失败,因此按 200 条翻页拉全量。
|
||||||
|
const remoteSeriesPageSize = 200
|
||||||
|
|
||||||
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
|
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
|
||||||
//
|
//
|
||||||
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
|
// 与 Emby 客户端一致:IncludeItemTypes=Series + Recursive=true,按
|
||||||
// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次
|
// DateLastContentAdded 倒序分页拉全库剧集。多媒体根下的 anime/ 等中间容器
|
||||||
// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方
|
// 若偶发出现在结果里则过滤掉;LastAddedAt 仅在远程提供 DateLastMediaAdded
|
||||||
// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时
|
// 时填充。
|
||||||
// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。
|
|
||||||
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
|
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
|
||||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
var cached []SeriesCard
|
||||||
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
|
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||||
return cached, nil
|
return cached, nil
|
||||||
@@ -529,19 +534,17 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
|||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("ParentId", remoteViewID)
|
q.Set("ParentId", remoteViewID)
|
||||||
q.Set("IncludeItemTypes", "Series")
|
q.Set("IncludeItemTypes", "Series")
|
||||||
q.Set("Recursive", "false")
|
q.Set("Recursive", "true")
|
||||||
q.Set("SortBy", "DateLastContentAdded")
|
q.Set("SortBy", "DateLastContentAdded")
|
||||||
q.Set("SortOrder", "Descending")
|
q.Set("SortOrder", "Descending")
|
||||||
// 每页 200:Fields 带全量重字段(Overview/MediaStreams 等)时单页 1000
|
q.Set("Limit", strconv.Itoa(remoteSeriesPageSize))
|
||||||
// 条的载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断直接解析失败。
|
|
||||||
q.Set("Limit", "200")
|
|
||||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||||
var body struct {
|
var body struct {
|
||||||
Items []map[string]any `json:"Items"`
|
Items []map[string]any `json:"Items"`
|
||||||
TotalRecordCount int64 `json:"TotalRecordCount"`
|
TotalRecordCount int64 `json:"TotalRecordCount"`
|
||||||
}
|
}
|
||||||
cards := make([]SeriesCard, 0)
|
cards := make([]SeriesCard, 0)
|
||||||
for startIndex := 0; ; startIndex += 200 {
|
for startIndex := 0; ; startIndex += remoteSeriesPageSize {
|
||||||
q.Set("StartIndex", strconv.Itoa(startIndex))
|
q.Set("StartIndex", strconv.Itoa(startIndex))
|
||||||
body.Items = nil
|
body.Items = nil
|
||||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != 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
|
break
|
||||||
}
|
}
|
||||||
for _, it := range body.Items {
|
for _, it := range body.Items {
|
||||||
|
name := remoteItemString(it, "Name")
|
||||||
|
path := remoteItemString(it, "Path")
|
||||||
|
if remoteSeriesItemLooksLikeContainer(name, path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
RewriteEmbyRemoteIDs(it, mount.ID)
|
RewriteEmbyRemoteIDs(it, mount.ID)
|
||||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||||
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
|
|
||||||
count := remoteItemInt(it, "RecursiveItemCount")
|
count := remoteItemInt(it, "RecursiveItemCount")
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
count = remoteItemInt(it, "ChildCount")
|
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 {
|
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
|
||||||
lastAdded = &date
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,6 +591,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
|||||||
return cards, nil
|
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。
|
// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。
|
||||||
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
|
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)
|
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||||
@@ -589,52 +611,14 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
|||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。
|
items, err := r.RemoteLatestForDisplay(ctx, mount, acct, remoteViewID, limit)
|
||||||
// 避免 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cards := make([]SeriesCard, 0, len(items))
|
cards := make([]SeriesCard, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||||
|
isSeries := strings.EqualFold(strings.TrimSpace(remoteItemString(it, "Type")), "Series")
|
||||||
var lastAdded *time.Time
|
var lastAdded *time.Time
|
||||||
if !m.UpdatedAt.IsZero() {
|
if !m.UpdatedAt.IsZero() {
|
||||||
t := m.UpdatedAt
|
t := m.UpdatedAt
|
||||||
@@ -647,7 +631,17 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
|||||||
if count == 0 {
|
if count == 0 {
|
||||||
count = remoteItemInt(it, "ChildCount")
|
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 {
|
if r.cache != nil {
|
||||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -120,10 +121,15 @@ func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
|
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
|
||||||
q := r.URL.Query()
|
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)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if q.Get("Recursive") != "true" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte("expected Recursive=true"))
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"TotalRecordCount": 1,
|
"TotalRecordCount": 1,
|
||||||
@@ -230,6 +236,11 @@ func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"TotalRecordCount": 1,
|
"TotalRecordCount": 1,
|
||||||
@@ -465,6 +476,9 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
|
|||||||
if cards[0].Count != 12 {
|
if cards[0].Count != 12 {
|
||||||
t.Fatalf("expected Count 12, got %d", cards[0].Count)
|
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 == "" {
|
if cards[0].Rep.PosterURL == "" {
|
||||||
t.Fatalf("expected PosterURL not empty")
|
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) {
|
func TestRemoteLatestFields(t *testing.T) {
|
||||||
var requestedFields string
|
var requestedFields string
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入
|
// SetFavorite 把 mediaID 标为 userID 的收藏。只写入 MeBox 本地 favourites 表,
|
||||||
// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。
|
// 按 user_id 隔离;挂载远程 Emby 共用账号,不能再透传收藏以免串用户。
|
||||||
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
|
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 {
|
if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -22,15 +22,8 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
|
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
|
||||||
// 远程 Emby 条目直接透传到对应服务器(本地不落库)。
|
// 远程挂载条目同样只落本地 PlaybackHistory,按 MeBox 用户隔离。
|
||||||
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
|
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 {
|
if !played {
|
||||||
err := e.repo.DB.WithContext(ctx).
|
err := e.repo.DB.WithContext(ctx).
|
||||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
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
|
return err
|
||||||
}
|
}
|
||||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
dur := int64(0)
|
||||||
if err != nil || m == nil {
|
if IsEmbyRemoteID(mediaID) {
|
||||||
return errors.New("media not found")
|
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 {
|
if dur <= 0 {
|
||||||
dur = 1
|
dur = 1
|
||||||
}
|
}
|
||||||
err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
MediaID: mediaID,
|
MediaID: mediaID,
|
||||||
PositionMs: dur,
|
PositionMs: dur,
|
||||||
@@ -62,6 +60,36 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
|
|||||||
return err
|
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)。
|
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
|
||||||
// 不携带 PlaySessionId 的旧调用仍保持兼容。
|
// 不携带 PlaySessionId 的旧调用仍保持兼容。
|
||||||
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
|
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
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
|
// mergeRemoteUserData overlays the current MeBox user's locally recorded
|
||||||
// and favourite state to remote Emby payloads.
|
// 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 {
|
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
|
||||||
if strings.TrimSpace(userID) == "" || payload == nil {
|
if strings.TrimSpace(userID) == "" || payload == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -178,18 +207,7 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
|
|||||||
}
|
}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
id, _ := item["Id"].(string)
|
id, _ := item["Id"].(string)
|
||||||
userData, _ := item["UserData"].(map[string]any)
|
item["UserData"] = applyMeBoxUserData(item["UserData"], byMediaID[id], favSet[id])
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -221,12 +239,37 @@ func remoteItemMaps(payload any) []map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mergedRemoteUserData(raw any, history *model.PlaybackHistory) 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{}
|
userData := map[string]any{}
|
||||||
if existing, ok := raw.(map[string]any); ok {
|
if existing, ok := raw.(map[string]any); ok {
|
||||||
for key, value := range existing {
|
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
|
duration := history.DurationMs
|
||||||
position := history.PositionMs
|
position := history.PositionMs
|
||||||
percentage := float64(0)
|
percentage := float64(0)
|
||||||
@@ -237,18 +280,9 @@ func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]an
|
|||||||
userData["Played"] = history.Completed
|
userData["Played"] = history.Completed
|
||||||
userData["PlayedPercentage"] = percentage
|
userData["PlayedPercentage"] = percentage
|
||||||
if history.Completed {
|
if history.Completed {
|
||||||
playCount := 0
|
userData["PlayCount"] = 1
|
||||||
switch value := userData["PlayCount"].(type) {
|
} else {
|
||||||
case int:
|
userData["PlayCount"] = 0
|
||||||
playCount = value
|
|
||||||
case int64:
|
|
||||||
playCount = int(value)
|
|
||||||
case float64:
|
|
||||||
playCount = int(value)
|
|
||||||
}
|
|
||||||
if playCount < 1 {
|
|
||||||
userData["PlayCount"] = 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return userData
|
return userData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,18 @@ func TestMergedRemoteUserData(t *testing.T) {
|
|||||||
preserve any
|
preserve any
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "in-progress preserves remote fields",
|
name: "in-progress preserves non-user remote fields only",
|
||||||
raw: map[string]any{
|
raw: map[string]any{
|
||||||
"PlayCount": 2,
|
"PlayCount": 2,
|
||||||
"Custom": "remote-value",
|
"IsFavorite": true,
|
||||||
|
"PlaybackPositionTicks": int64(999),
|
||||||
|
"Custom": "remote-value",
|
||||||
},
|
},
|
||||||
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
|
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
|
||||||
position: 250_000_000,
|
position: 250_000_000,
|
||||||
played: false,
|
played: false,
|
||||||
percent: 25,
|
percent: 25,
|
||||||
count: 2,
|
count: 0,
|
||||||
preserve: "remote-value",
|
preserve: "remote-value",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,10 +61,47 @@ func TestMergedRemoteUserData(t *testing.T) {
|
|||||||
if tt.preserve != nil && out["Custom"] != tt.preserve {
|
if tt.preserve != nil && out["Custom"] != tt.preserve {
|
||||||
t.Fatalf("Custom = %#v, want %#v", 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) {
|
func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
|
||||||
remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
|
remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
@@ -108,3 +147,57 @@ func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
|
|||||||
t.Fatalf("expected 95%% progress to be completed")
|
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"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SyncUserFavorite keeps favourite state aligned across the local favourites table
|
// SyncUserFavorite keeps favourite state in the local favourites table, keyed
|
||||||
// and the upstream remote Emby server for mounted items.
|
// by MeBox user_id. Remote Emby mounts share one upstream account, so favourite
|
||||||
func SyncUserFavorite(ctx context.Context, repo *repository.Container, remote *EmbyRemoteService, userID, mediaID string, favorite bool) error {
|
// 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 == "" {
|
if repo == nil || userID == "" || mediaID == "" {
|
||||||
return errors.New("missing favourite sync inputs")
|
return errors.New("missing favourite sync inputs")
|
||||||
}
|
}
|
||||||
if err := setLocalFavorite(ctx, repo, userID, mediaID, favorite); err != nil {
|
return setLocalFavorite(ctx, repo, userID, mediaID, favorite)
|
||||||
return err
|
|
||||||
}
|
|
||||||
if favorite || IsEmbyRemoteID(mediaID) {
|
|
||||||
if err := proxyRemoteFavorite(ctx, remote, mediaID, favorite); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsUserFavorite reports whether the user has favourited mediaID locally.
|
// 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).
|
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||||
Delete(&model.Favorite{}).Error
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -179,9 +178,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
|||||||
libraryIDs = []string{media.LibraryID}
|
libraryIDs = []string{media.LibraryID}
|
||||||
}
|
}
|
||||||
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
||||||
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
|
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
|
||||||
if err != nil {
|
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
|
||||||
return nil, err
|
// ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠
|
||||||
|
// 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄,
|
||||||
|
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
|
||||||
|
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
|
||||||
|
var candidates []model.Media
|
||||||
|
narrowed := false
|
||||||
|
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
|
||||||
|
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
|
||||||
|
if !hasAdultCode {
|
||||||
|
candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !narrowed {
|
if !narrowed {
|
||||||
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
|
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
|
||||||
@@ -190,23 +201,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.attachLibraryMetadata(ctx, candidates)
|
s.attachLibraryMetadata(ctx, candidates)
|
||||||
|
// 同番号只要有一个分片被确认为成人内容,尚未刮削的分片也按番号折叠,
|
||||||
|
// 与列表页的分组结果保持一致。
|
||||||
|
vouched := adultCodesVouchedByNSFW(candidates)
|
||||||
|
if vouchKey := adultVouchedGroupKey(*media, vouched); vouchKey != "" {
|
||||||
|
key = vouchKey
|
||||||
|
}
|
||||||
matched := make([]model.Media, 0, 4)
|
matched := make([]model.Media, 0, 4)
|
||||||
for _, row := range candidates {
|
for _, row := range candidates {
|
||||||
if mediaVersionGroupKey(row) == key {
|
if mediaVersionGroupKeyWithAdultVouch(row, vouched) == key {
|
||||||
matched = append(matched, row)
|
matched = append(matched, row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(matched) == 0 {
|
if len(matched) == 0 {
|
||||||
return []model.Media{*media}, nil
|
return []model.Media{*media}, nil
|
||||||
}
|
}
|
||||||
sort.SliceStable(matched, func(i, j int) bool {
|
sortMediaVersionsForDisplay(matched)
|
||||||
if matched[i].ID == media.ID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if matched[j].ID == media.ID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return betterMediaVersion(matched[i], matched[j])
|
|
||||||
})
|
|
||||||
return matched, nil
|
return matched, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type SeriesCard struct {
|
|||||||
Rep model.Media `json:"rep"`
|
Rep model.Media `json:"rep"`
|
||||||
LinkMedia model.Media `json:"linkMedia"`
|
LinkMedia model.Media `json:"linkMedia"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
IsSeries bool `json:"is_series,omitempty"`
|
||||||
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ type SeriesCardView struct {
|
|||||||
Rep model.Media `json:"rep"`
|
Rep model.Media `json:"rep"`
|
||||||
LinkLibraryID string `json:"linkLibraryId,omitempty"`
|
LinkLibraryID string `json:"linkLibraryId,omitempty"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
IsSeries bool `json:"is_series,omitempty"`
|
||||||
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView {
|
|||||||
Rep: card.Rep,
|
Rep: card.Rep,
|
||||||
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
|
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
|
||||||
Count: card.Count,
|
Count: card.Count,
|
||||||
|
IsSeries: card.IsSeries,
|
||||||
LastAddedAt: card.LastAddedAt,
|
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 {
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
cmp := compareSeriesByField(out[i], out[j], spec, history)
|
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)
|
cmp = compareID(out[i].Key, out[j].Key)
|
||||||
}
|
}
|
||||||
return cmp < 0
|
return cmp < 0
|
||||||
@@ -173,7 +175,7 @@ func seriesUpdatedTime(card SeriesCard) (time.Time, bool) {
|
|||||||
if card.LastAddedAt != nil && !card.LastAddedAt.IsZero() {
|
if card.LastAddedAt != nil && !card.LastAddedAt.IsZero() {
|
||||||
return *card.LastAddedAt, true
|
return *card.LastAddedAt, true
|
||||||
}
|
}
|
||||||
return mediaUpdatedTime(card.Rep)
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func seriesLastPlayed(card SeriesCard, history map[string]time.Time) (time.Time, bool) {
|
func seriesLastPlayed(card SeriesCard, history map[string]time.Time) (time.Time, bool) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package service
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
@@ -75,35 +77,33 @@ func groupMediaVersions(items []model.Media) []MediaItem {
|
|||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// 同一番号只要有一个分片被刮削标记为成人内容,就认定整部片都是成人条目:
|
||||||
|
// 未刮削的分片(nsfw=false)也按番号折叠。否则「一个分片刮削成功、其余仍
|
||||||
|
// pending」时,已刮削的走番号分组、其余落到标题分组,整部片被拆成两张卡。
|
||||||
|
adultCodes := adultCodesVouchedByNSFW(items)
|
||||||
type group struct {
|
type group struct {
|
||||||
key string
|
key string
|
||||||
primary model.Media
|
rows []model.Media
|
||||||
rows []model.Media
|
|
||||||
}
|
}
|
||||||
groups := make([]group, 0, len(items))
|
groups := make([]group, 0, len(items))
|
||||||
byKey := make(map[string]int, len(items))
|
byKey := make(map[string]int, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
key := mediaVersionGroupKey(item)
|
key := mediaVersionGroupKeyWithAdultVouch(item, adultCodes)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
groups = append(groups, group{primary: item, rows: []model.Media{item}})
|
groups = append(groups, group{rows: []model.Media{item}})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if idx, ok := byKey[key]; ok {
|
if idx, ok := byKey[key]; ok {
|
||||||
groups[idx].rows = append(groups[idx].rows, item)
|
groups[idx].rows = append(groups[idx].rows, item)
|
||||||
if betterMediaVersion(item, groups[idx].primary) {
|
|
||||||
groups[idx].primary = item
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
byKey[key] = len(groups)
|
byKey[key] = len(groups)
|
||||||
groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}})
|
groups = append(groups, group{key: key, rows: []model.Media{item}})
|
||||||
}
|
}
|
||||||
out := make([]MediaItem, 0, len(groups))
|
out := make([]MediaItem, 0, len(groups))
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
sort.SliceStable(g.rows, func(i, j int) bool {
|
sortMediaVersionsForDisplay(g.rows)
|
||||||
return betterMediaVersion(g.rows[i], g.rows[j])
|
item := MediaItem{Media: g.rows[0]}
|
||||||
})
|
|
||||||
item := MediaItem{Media: g.primary}
|
|
||||||
if len(g.rows) > 1 {
|
if len(g.rows) > 1 {
|
||||||
item.Versions = g.rows
|
item.Versions = g.rows
|
||||||
}
|
}
|
||||||
@@ -139,16 +139,65 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
|
|||||||
return grouped
|
return grouped
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaVersionGroupKey(m model.Media) string {
|
// mediaVersionLibraryKey 返回版本身份里使用的库标识。
|
||||||
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
func mediaVersionLibraryKey(m model.Media) string {
|
||||||
if IsEmbyRemoteID(m.ID) {
|
|
||||||
return fmt.Sprintf("embyremote:%s", m.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
||||||
if libKey == "" {
|
if libKey == "" {
|
||||||
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
||||||
}
|
}
|
||||||
|
return libKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// adultCodesVouchedByNSFW 收集「已被刮削确认为成人内容」的番号。
|
||||||
|
func adultCodesVouchedByNSFW(items []model.Media) map[string]bool {
|
||||||
|
codes := map[string]bool{}
|
||||||
|
for _, item := range items {
|
||||||
|
if !item.NSFW {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if code := canonicalAdultGroupCode(AdultCodeFromMediaPath(item.Path)); code != "" {
|
||||||
|
codes[code] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if code := mediaAdultGroupCode(item); code != "" {
|
||||||
|
codes[code] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaVersionGroupKeyWithAdultVouch 是版本身份的统一入口:番号已被同组分片
|
||||||
|
// 确认时优先按番号折叠,否则沿用 mediaVersionGroupKey 的原判定。
|
||||||
|
func mediaVersionGroupKeyWithAdultVouch(m model.Media, vouched map[string]bool) string {
|
||||||
|
if key := adultVouchedGroupKey(m, vouched); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return mediaVersionGroupKey(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adultVouchedGroupKey 在番号已被同组分片确认时,返回番号分组键;
|
||||||
|
// 其余情况返回空串,表示沿用 mediaVersionGroupKey 的原判定。
|
||||||
|
func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string {
|
||||||
|
if len(vouched) == 0 || m.NSFW {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
code := canonicalAdultGroupCode(AdultCodeFromMediaPath(m.Path))
|
||||||
|
if code == "" || !vouched[code] {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
libKey := mediaVersionLibraryKey(m)
|
||||||
|
if libKey == "" {
|
||||||
|
libKey = "_"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("adult:%s:%s", libKey, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||||
|
if IsEmbyRemoteID(m.ID) {
|
||||||
|
return fmt.Sprintf("embyremote:%s", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
libKey := mediaVersionLibraryKey(m)
|
||||||
specialKind := mediaSpecialKind(m.Path)
|
specialKind := mediaSpecialKind(m.Path)
|
||||||
season, episode := m.SeasonNum, m.EpisodeNum
|
season, episode := m.SeasonNum, m.EpisodeNum
|
||||||
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
|
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
|
||||||
@@ -193,6 +242,18 @@ func mediaVersionGroupKey(m model.Media) string {
|
|||||||
return mediaVersionStemGroupKey(m, libKey)
|
return mediaVersionStemGroupKey(m, libKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 成人条目按番号折叠。在线刮削(MetaTube)会把番号与 provider「借用」
|
||||||
|
// 存进 douban_id/thetvdb_id,本地 NFO 路径则拿不到这两个字段;一旦按
|
||||||
|
// 外部 ID 分组,同一部片的各个分片就会因为元数据来源不同(在线刮削 vs
|
||||||
|
// 本地 NFO)裂成两张标题相同的卡。番号取自原文件名/路径/标题,与元数据
|
||||||
|
// 来源无关,因此这里统一用番号做版本身份。
|
||||||
|
if code := mediaAdultGroupCode(m); code != "" {
|
||||||
|
if libKey == "" {
|
||||||
|
libKey = "_"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("adult:%s:%s", libKey, code)
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case m.TMDbID > 0:
|
case m.TMDbID > 0:
|
||||||
if libKey != "" {
|
if libKey != "" {
|
||||||
@@ -267,6 +328,44 @@ func mediaVersionGroupKey(m model.Media) string {
|
|||||||
return fmt.Sprintf("movie:%s:%d", title, year)
|
return fmt.Sprintf("movie:%s:%d", title, year)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mediaAdultGroupCode 返回成人条目的番号,作为与元数据来源无关的版本身份。
|
||||||
|
//
|
||||||
|
// 只在已经确认是成人条目(NSFW)时才返回番号:番号本身来自原文件名/路径/
|
||||||
|
// 标题,不会随「在线刮削(MetaTube 把番号与 provider 借用进 douban_id/
|
||||||
|
// thetvdb_id)」还是「本地 NFO(不带外部 ID)」而改变,因此两条来源能算出
|
||||||
|
// 同一个键。未标记 NSFW 的条目仍走标题分组,普通影视库里 "The Matrix 1999"
|
||||||
|
// 这类文件名不会被当成番号。
|
||||||
|
func mediaAdultGroupCode(m model.Media) string {
|
||||||
|
if !m.NSFW {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
code := firstText(
|
||||||
|
normalizeAdultCode(m.OriginalName),
|
||||||
|
AdultCodeFromMediaPath(m.Path),
|
||||||
|
normalizeAdultCode(m.Title),
|
||||||
|
)
|
||||||
|
if code == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return canonicalAdultGroupCode(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalAdultGroupCode 去掉番号数字部分的补零,让 IPVR-00192 与 IPVR-192
|
||||||
|
// 这类同一部片的不同写法落到同一个分组键上。无法解析数字的番号原样返回
|
||||||
|
// (如 FC2-PPV-4701981)。
|
||||||
|
func canonicalAdultGroupCode(code string) string {
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
prefix, digits, ok := strings.Cut(code, "-")
|
||||||
|
if !ok {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(digits)
|
||||||
|
if err != nil {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
return prefix + "-" + strconv.Itoa(value)
|
||||||
|
}
|
||||||
|
|
||||||
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
|
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
|
||||||
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
||||||
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
||||||
@@ -300,8 +399,11 @@ func mediaVersionFileStem(name string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
||||||
|
// 当只有容器等无法区分多版本的信息时,回退到清理后的文件名。
|
||||||
func MediaVersionLabel(m model.Media) string {
|
func MediaVersionLabel(m model.Media) string {
|
||||||
parts := make([]string, 0, 4)
|
parts := make([]string, 0, 4)
|
||||||
|
isStrm := strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") ||
|
||||||
|
strings.TrimSpace(m.STRMURL) != ""
|
||||||
if m.Height > 0 {
|
if m.Height > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
||||||
} else if m.Width > 0 {
|
} else if m.Width > 0 {
|
||||||
@@ -317,17 +419,91 @@ func MediaVersionLabel(m model.Media) string {
|
|||||||
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
||||||
parts = append(parts, strings.ToUpper(codec))
|
parts = append(parts, strings.ToUpper(codec))
|
||||||
}
|
}
|
||||||
if m.SizeBytes > 0 {
|
// STRM 占位体积(通常几十到几百字节)不能区分分片,跳过。
|
||||||
|
placeholderSize := isStrm && m.SizeBytes > 0 && m.SizeBytes < 1024*1024
|
||||||
|
if m.SizeBytes > 0 && !placeholderSize {
|
||||||
parts = append(parts, formatMediaSize(m.SizeBytes))
|
parts = append(parts, formatMediaSize(m.SizeBytes))
|
||||||
}
|
}
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 && !mediaVersionLabelIndistinct(parts) {
|
||||||
return strings.Join(parts, " · ")
|
return strings.Join(parts, " · ")
|
||||||
}
|
}
|
||||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
|
if cleaned := cleanMediaVersionFilename(m.Path); cleaned != "" {
|
||||||
if base == "" || base == "." {
|
return cleaned
|
||||||
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
|
||||||
}
|
}
|
||||||
return base
|
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaVersionLabelIndistinct(parts []string) bool {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, part := range parts {
|
||||||
|
upper := strings.ToUpper(strings.TrimSpace(part))
|
||||||
|
if upper == "" || upper == "云端直链" || upper == "STRM" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := videoExtensions["."+strings.ToLower(upper)]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanMediaVersionFilename(path string) string {
|
||||||
|
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
|
||||||
|
if base == "" || base == "." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
stem := mediaFileStem(base)
|
||||||
|
stem = strings.ReplaceAll(stem, "_", " ")
|
||||||
|
stem = strings.ReplaceAll(stem, ".", " ")
|
||||||
|
return strings.Join(strings.Fields(stem), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaPartNumber 从路径提取分片序号(part1 / cd2 / -3)。
|
||||||
|
// 裸后缀只认 1–2 位数字,避免把年份 2024 当成 part。
|
||||||
|
func mediaPartNumber(path string) (int, bool) {
|
||||||
|
stem := mediaFileStem(filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")))
|
||||||
|
if stem == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(stem)
|
||||||
|
for _, re := range mediaPartNumberPatterns {
|
||||||
|
if m := re.FindStringSubmatch(lower); len(m) == 2 {
|
||||||
|
n, err := strconv.Atoi(m[1])
|
||||||
|
if err == nil && n > 0 {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaPartNumberPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?:^|[.\-_])part[\s._-]*(\d{1,3})$`),
|
||||||
|
regexp.MustCompile(`(?:^|[.\-_])(?:cd|disc|disk)[\s._-]*(\d{1,3})$`),
|
||||||
|
regexp.MustCompile(`[-_](\d{1,2})$`),
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortMediaVersionsForDisplay(rows []model.Media) {
|
||||||
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
|
return compareMediaVersionsForDisplay(rows[i], rows[j])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareMediaVersionsForDisplay:有分片号时按 1→2→3… 升序;否则按画质/体积择优。
|
||||||
|
func compareMediaVersionsForDisplay(a, b model.Media) bool {
|
||||||
|
pa, oka := mediaPartNumber(a.Path)
|
||||||
|
pb, okb := mediaPartNumber(b.Path)
|
||||||
|
if oka && okb {
|
||||||
|
if pa != pb {
|
||||||
|
return pa < pb
|
||||||
|
}
|
||||||
|
} else if oka != okb {
|
||||||
|
return oka
|
||||||
|
}
|
||||||
|
return betterMediaVersion(a, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaVersionContainerFromPath(path, strmURL string) string {
|
func mediaVersionContainerFromPath(path, strmURL string) string {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 真实场景:同一部片的分片里只有第 4 个被刮削成功(nsfw=true),其余仍是 pending。
|
||||||
|
// 修复前:已刮削的分片走「番号」分组、其余落到「标题」分组,7 个分片会裂成两张卡
|
||||||
|
// (一张只有第 4 个,另一张是其余 6 个)。
|
||||||
|
func TestGroupMediaVersionsKeepsUnscrapedPartsWithScrapedSibling(t *testing.T) {
|
||||||
|
const libID = "lib-1"
|
||||||
|
const dir = "/media/云下载/sivr-270/"
|
||||||
|
|
||||||
|
parts := func(nums ...int) []model.Media {
|
||||||
|
out := make([]model.Media, 0, len(nums))
|
||||||
|
for _, n := range nums {
|
||||||
|
out = append(out, model.Media{
|
||||||
|
LibraryID: libID,
|
||||||
|
Title: "sivr 270",
|
||||||
|
Path: dir + "sivr-270-" + strconv.Itoa(n) + ".strm",
|
||||||
|
Container: "strm",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
items := parts(1, 2, 3)
|
||||||
|
items = append(items, model.Media{
|
||||||
|
LibraryID: libID,
|
||||||
|
Title: "SIVR-270-【VR】河北彩花×ご奉仕ナース",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: dir + "sivr-270-4.strm",
|
||||||
|
NSFW: true,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
Container: "strm",
|
||||||
|
})
|
||||||
|
items = append(items, parts(5, 6, 7)...)
|
||||||
|
|
||||||
|
grouped := GroupMediaVersions(items)
|
||||||
|
if len(grouped) != 1 {
|
||||||
|
paths := make([]string, 0, len(grouped))
|
||||||
|
for _, g := range grouped {
|
||||||
|
paths = append(paths, g.Path)
|
||||||
|
}
|
||||||
|
t.Fatalf("分成 %d 张卡 %v,期望折叠成 1 张", len(grouped), paths)
|
||||||
|
}
|
||||||
|
if len(grouped[0].Versions) != 7 {
|
||||||
|
t.Fatalf("版本数 = %d,期望 7", len(grouped[0].Versions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反向保护:没有任何分片被确认为成人内容时,不能仅凭文件名里的番号形态折叠,
|
||||||
|
// 否则普通媒体也可能被误并进同一张卡。
|
||||||
|
func TestGroupMediaVersionsIgnoresUnvouchedAdultCode(t *testing.T) {
|
||||||
|
items := []model.Media{
|
||||||
|
{LibraryID: "lib-2", Title: "Alpha Movie", Path: "/media/movies/Alpha Movie/ABC-123-1080p.mkv"},
|
||||||
|
{LibraryID: "lib-2", Title: "Beta Movie", Path: "/media/movies/Beta Movie/ABC-123-2160p.mkv"},
|
||||||
|
}
|
||||||
|
grouped := GroupMediaVersions(items)
|
||||||
|
if len(grouped) != 2 {
|
||||||
|
t.Fatalf("分成 %d 张卡,期望 2 张(未被确认成人内容的番号不参与折叠)", len(grouped))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -442,3 +442,298 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
|
|||||||
t.Fatalf("unexpected label %q", label)
|
t.Fatalf("unexpected label %q", label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMediaPartNumberFromPath(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm", 1, true},
|
||||||
|
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm", 2, true},
|
||||||
|
{"/media/云下载/sivr-270/sivr-270-1.strm", 1, true},
|
||||||
|
{"/media/云下载/sivr-270/sivr-270-7.strm", 7, true},
|
||||||
|
{"/media/云下载/fc2/FC2PPV-4701981-cd2.strm", 2, true},
|
||||||
|
{"/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", 0, false},
|
||||||
|
{"/media/Movies/Movie.2024.mkv", 0, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, ok := mediaPartNumber(tc.path)
|
||||||
|
if ok != tc.ok || got != tc.want {
|
||||||
|
t.Fatalf("mediaPartNumber(%q) = (%d, %v), want (%d, %v)", tc.path, got, ok, tc.want, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupMediaVersionsSortsPartsAscending(t *testing.T) {
|
||||||
|
rows := []model.Media{
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "p3"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
SizeBytes: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "p1"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
SizeBytes: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "p2"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
SizeBytes: 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
grouped := groupMediaVersions(rows)
|
||||||
|
if len(grouped) != 1 || len(grouped[0].Versions) != 3 {
|
||||||
|
t.Fatalf("grouped = %#v", grouped)
|
||||||
|
}
|
||||||
|
want := []string{"p1", "p2", "p3"}
|
||||||
|
for i, id := range want {
|
||||||
|
if grouped[0].Versions[i].ID != id {
|
||||||
|
t.Fatalf("versions[%d] = %q, want %q (order %#v)", i, grouped[0].Versions[i].ID, id, grouped[0].Versions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if grouped[0].ID != "p1" {
|
||||||
|
t.Fatalf("primary = %q, want p1 (first part)", grouped[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaVersionLabelFallsBackToFilenameWhenIndistinct(t *testing.T) {
|
||||||
|
label := MediaVersionLabel(model.Media{
|
||||||
|
Title: "SIVR-270-【VR】河北彩花",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||||
|
SizeBytes: 157,
|
||||||
|
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=a",
|
||||||
|
})
|
||||||
|
if !strings.Contains(label, "sivr-270-1") {
|
||||||
|
t.Fatalf("indistinct strm label should fall back to filename, got %q", label)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(label, "MP4") || strings.Contains(label, "云端") {
|
||||||
|
t.Fatalf("should not keep generic-only label %q", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIVR-270 现场:一部分分片走在线刮削(MetaTube 把番号/provider 借用进
|
||||||
|
// douban_id/thetvdb_id),另一部分只从本地 NFO 拿到标题(没有外部 ID)。
|
||||||
|
// 按外部 ID 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。
|
||||||
|
func TestGroupMediaVersionsMergesAdultPartsAcrossMetadataSources(t *testing.T) {
|
||||||
|
online := model.Media{
|
||||||
|
Base: model.Base{ID: "sivr-270-2"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270-【VR】河北彩花",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
DoubanID: "SIVR-270",
|
||||||
|
TheTVDBID: "JavBus",
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 200,
|
||||||
|
}
|
||||||
|
fromNFO := model.Media{
|
||||||
|
Base: model.Base{ID: "sivr-270-3"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270-【VR】河北彩花",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped := groupMediaVersions([]model.Media{online, fromNFO})
|
||||||
|
if len(grouped) != 1 {
|
||||||
|
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
|
||||||
|
}
|
||||||
|
if len(grouped[0].Versions) != 2 {
|
||||||
|
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||||
|
}
|
||||||
|
if grouped[0].Path != online.Path {
|
||||||
|
t.Fatalf("larger part should stay primary, got %q", grouped[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupMediaVersionsKeepsDifferentAdultCodesSeparate(t *testing.T) {
|
||||||
|
rows := []model.Media{
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "sivr-270"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270 作品",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "sivr-271"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-271 作品",
|
||||||
|
Path: "/media/云下载/sivr-271/sivr-271-1.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
grouped := groupMediaVersions(rows)
|
||||||
|
if len(grouped) != 2 {
|
||||||
|
t.Fatalf("different adult codes must stay separate: %#v", grouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一部片的两种写法:路径里的 IPVR-00192 与刮削回来的 IPVR-192 必须归一。
|
||||||
|
func TestGroupMediaVersionsMergesAdultCodesIgnoringZeroPadding(t *testing.T) {
|
||||||
|
scraped := model.Media{
|
||||||
|
Base: model.Base{ID: "ipvr-192-part2"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "IPVR-192-相沢みなみ",
|
||||||
|
OriginalName: "IPVR-192",
|
||||||
|
Path: "/media/云下载/ipvr00192pl/scraped/part2.strm",
|
||||||
|
Year: 2022,
|
||||||
|
NSFW: true,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 200,
|
||||||
|
}
|
||||||
|
fromPath := model.Media{
|
||||||
|
Base: model.Base{ID: "ipvr-00192-part1"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "fbzip com@ipvr00192",
|
||||||
|
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
|
||||||
|
Year: 2022,
|
||||||
|
NSFW: true,
|
||||||
|
SizeBytes: 100,
|
||||||
|
}
|
||||||
|
grouped := groupMediaVersions([]model.Media{scraped, fromPath})
|
||||||
|
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
|
||||||
|
t.Fatalf("zero padded code must fold into one item: %#v", grouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T) {
|
||||||
|
// IPVR-00192 现场:两个 part 标题、年份完全一样,但 part2 是走 MetaTube
|
||||||
|
// 刮削的(番号/provider 被借用进 douban_id/thetvdb_id),part1 只有本地
|
||||||
|
// NFO 身份、两个字段为空。旧逻辑按外部 ID 分组会裂成两张卡。
|
||||||
|
part1 := model.Media{
|
||||||
|
Base: model.Base{ID: "ipvr-00192-part1"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "IPVR-00192-IPVR-192-【VR】相沢みなみ",
|
||||||
|
OriginalName: "IPVR-00192",
|
||||||
|
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
|
||||||
|
Year: 2022,
|
||||||
|
NSFW: true,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 100,
|
||||||
|
}
|
||||||
|
part2 := part1
|
||||||
|
part2.ID = "ipvr-00192-part2"
|
||||||
|
part2.Path = "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm"
|
||||||
|
part2.DoubanID = "ipvr00192"
|
||||||
|
part2.TheTVDBID = "JAV321"
|
||||||
|
part2.SizeBytes = 200
|
||||||
|
|
||||||
|
grouped := groupMediaVersions([]model.Media{part1, part2})
|
||||||
|
if len(grouped) != 1 {
|
||||||
|
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
|
||||||
|
}
|
||||||
|
if len(grouped[0].Versions) != 2 {
|
||||||
|
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||||
|
}
|
||||||
|
if grouped[0].Versions[0].ID != "ipvr-00192-part1" || grouped[0].Versions[1].ID != "ipvr-00192-part2" {
|
||||||
|
t.Fatalf("versions should be part1 then part2, got %#v", grouped[0].Versions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMediaItemListsAdultVersionsAcrossMetadataSources(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
lib := model.Library{Name: "云下载", Path: "/media/云下载", Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows := []model.Media{
|
||||||
|
{
|
||||||
|
// 走 MetaTube 刮削的分片:番号/provider 被借用进 douban/thetvdb。
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "SIVR-270-【VR】河北彩花",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
DoubanID: "SIVR-270",
|
||||||
|
TheTVDBID: "JavBus",
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 只有本地 NFO 身份的分片:两个外部 ID 都为空。
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "SIVR-270-【VR】河北彩花",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-4.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := db.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
|
||||||
|
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||||
|
item, err := svc.GetMediaItem(t.Context(), rows[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if item == nil || len(item.Versions) != 2 {
|
||||||
|
// 详情页版本列表不能只按外部 ID 收窄,否则没有 ID 的分片会消失。
|
||||||
|
t.Fatalf("versions = %#v, want both parts", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
m model.Media
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "普通影片名里的字母+数字不当番号",
|
||||||
|
m: model.Media{Title: "Spider-Man 2008 1080p", Path: "/media/Movies/Spider-Man 2008 1080p.mkv"},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "未识别成人的条目即使文件名像番号也不折叠",
|
||||||
|
m: model.Media{Title: "SIVR-270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "已标记 NSFW 时按番号分组",
|
||||||
|
m: model.Media{NSFW: true, Title: "sivr 270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
|
||||||
|
want: "SIVR-270",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "站点前缀里带补零的番号会归一",
|
||||||
|
m: model.Media{NSFW: true, Title: "fbzip com@ipvr00192", Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm"},
|
||||||
|
want: "IPVR-192",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := mediaAdultGroupCode(tc.m); got != tc.want {
|
||||||
|
t.Fatalf("mediaAdultGroupCode = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+44
-14
@@ -18,6 +18,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -48,9 +49,13 @@ type movieNFO struct {
|
|||||||
Poster string `xml:"thumb,omitempty"`
|
Poster string `xml:"thumb,omitempty"`
|
||||||
Fanart string `xml:"fanart,omitempty"`
|
Fanart string `xml:"fanart,omitempty"`
|
||||||
TMDb int `xml:"tmdbid,omitempty"`
|
TMDb int `xml:"tmdbid,omitempty"`
|
||||||
Genre []string `xml:"genre,omitempty"`
|
// UniqueIDs 让 sidecar 能把 MeBox 自己用的外部 ID 原样带回来。缺了它,
|
||||||
Country []string `xml:"country,omitempty"`
|
// 「在线刮削 → 写 NFO → 重扫读 NFO」会丢掉 douban/thetvdb(成人条目借用
|
||||||
Language []string `xml:"language,omitempty"`
|
// 这两个字段存番号/provider),同一部片的两条来源就会算出不同的版本键。
|
||||||
|
UniqueIDs []nfoUniqueID `xml:"uniqueid,omitempty"`
|
||||||
|
Genre []string `xml:"genre,omitempty"`
|
||||||
|
Country []string `xml:"country,omitempty"`
|
||||||
|
Language []string `xml:"language,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type episodeNFO struct {
|
type episodeNFO struct {
|
||||||
@@ -167,17 +172,18 @@ func WriteMediaNFO(m *model.Media) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
doc = movieNFO{
|
doc = movieNFO{
|
||||||
Title: title,
|
Title: title,
|
||||||
Original: original,
|
Original: original,
|
||||||
Year: m.Year,
|
Year: m.Year,
|
||||||
Plot: m.Overview,
|
Plot: m.Overview,
|
||||||
Rating: m.Rating,
|
Rating: m.Rating,
|
||||||
Poster: m.PosterURL,
|
Poster: m.PosterURL,
|
||||||
Fanart: m.BackdropURL,
|
Fanart: m.BackdropURL,
|
||||||
TMDb: m.TMDbID,
|
TMDb: m.TMDbID,
|
||||||
Genre: splitNFOList(m.Genres),
|
UniqueIDs: nfoExternalUniqueIDs(m),
|
||||||
Country: splitNFOList(m.Countries),
|
Genre: splitNFOList(m.Genres),
|
||||||
Language: splitNFOList(m.Languages),
|
Country: splitNFOList(m.Countries),
|
||||||
|
Language: splitNFOList(m.Languages),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out, err := xml.MarshalIndent(doc, "", " ")
|
out, err := xml.MarshalIndent(doc, "", " ")
|
||||||
@@ -202,3 +208,27 @@ func splitNFOList(value string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nfoExternalUniqueIDs 把 MeBox 使用的外部 ID 写进 sidecar,供自己重扫时读回
|
||||||
|
// (metadataFromDoc 会按 type 还原成 bangumi/douban/thetvdb 字段)。
|
||||||
|
// TMDb 走 <tmdbid>,单集 NFO 不写唯一 ID:单集 ID 属于单集,写进整剧身份会把
|
||||||
|
// 同一部剧拆成多张卡(见 mergeEpisodeMetadata 的约束说明)。
|
||||||
|
func nfoExternalUniqueIDs(m *model.Media) []nfoUniqueID {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]nfoUniqueID, 0, 3)
|
||||||
|
if m.BangumiID > 0 {
|
||||||
|
out = append(out, nfoUniqueID{Type: "bangumi", Value: strconv.Itoa(m.BangumiID)})
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(m.DoubanID); id != "" {
|
||||||
|
out = append(out, nfoUniqueID{Type: "douban", Value: id})
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(m.TheTVDBID); id != "" {
|
||||||
|
out = append(out, nfoUniqueID{Type: "thetvdb", Value: id})
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,3 +100,51 @@ func TestWriteMediaNFOAdultTitleWithCode(t *testing.T) {
|
|||||||
t.Fatalf("expected originaltitle with code, got:\n%s", text)
|
t.Fatalf("expected originaltitle with code, got:\n%s", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteMediaNFORoundTripsExternalUniqueIDs(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mediaPath := filepath.Join(root, "云下载", "sivr-270", "sivr-270-1.strm")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(mediaPath, []byte("strm"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := WriteMediaNFO(&model.Media{
|
||||||
|
Title: "SIVR-270-河北彩花",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: mediaPath,
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
DoubanID: "SIVR-270",
|
||||||
|
TheTVDBID: "JavBus",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(body)
|
||||||
|
// 成人条目把番号/provider 借用存在 douban/thetvdb 两列;sidecar 必须把它们
|
||||||
|
// 带回来,否则「在线刮削 → 写 NFO → 重扫读 NFO」之后同一部片会被按不同
|
||||||
|
// 分组键拆成多张卡。
|
||||||
|
for _, want := range []string{
|
||||||
|
`<uniqueid type="douban">SIVR-270</uniqueid>`,
|
||||||
|
`<uniqueid type="thetvdb">JavBus</uniqueid>`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("sidecar missing %s, got:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := ReadLocalMetadata(mediaPath, "", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta == nil || meta.DoubanID != "SIVR-270" || meta.TheTVDBID != "JavBus" {
|
||||||
|
t.Fatalf("external ids did not round trip: %#v", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,6 +144,85 @@ func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string,
|
|||||||
return normalized, nil
|
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) {
|
func (p *ProfileService) accessibleLibraryIDSet(ctx context.Context, visibility MediaVisibility) (map[string]struct{}, error) {
|
||||||
libs, err := p.repo.Library.List(ctx)
|
libs, err := p.repo.Library.List(ctx)
|
||||||
if err != nil {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,8 +33,9 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
|
|||||||
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
|
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
|
||||||
return ErrCloudPlaybackDisabled
|
return ErrCloudPlaybackDisabled
|
||||||
}
|
}
|
||||||
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
|
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host;
|
||||||
target := normalizeCloudPlayTarget(strmURL)
|
// 指向别的 MeBox 实例的地址保持原样,按第三方直链透传。
|
||||||
|
target := normalizeCloudPlayTarget(r.Context(), s.repo, s.cfg, r, strmURL)
|
||||||
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
||||||
setCloudRedirectNoStore(w)
|
setCloudRedirectNoStore(w)
|
||||||
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
|
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的
|
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的旧 host)
|
||||||
// 旧 host)被规范化为相对路径,使 302 始终基于当前请求地址构造。
|
// 被规范化为相对路径,使 302 始终基于当前请求地址构造。
|
||||||
func TestNormalizeCloudPlayTarget(t *testing.T) {
|
func TestNormalizeCloudPlayTarget(t *testing.T) {
|
||||||
ref := "/电影/某部影片 (2024)/movie.mkv"
|
ref := "/电影/某部影片 (2024)/movie.mkv"
|
||||||
stale := "http://192.168.1.4:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/media-1", nil)
|
||||||
got := normalizeCloudPlayTarget(stale)
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 相对路径:本来就是本机形态,按 provider+ref 重建(保持相对)。
|
||||||
|
relative := "/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
||||||
|
got := normalizeCloudPlayTarget(ctx, nil, nil, req, relative)
|
||||||
want := BuildRelativeCloudPlayURL("openlist", ref)
|
want := BuildRelativeCloudPlayURL("openlist", ref)
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("normalizeCloudPlayTarget = %q, want %q", got, want)
|
t.Fatalf("normalizeCloudPlayTarget(relative) = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
parsed, err := url.Parse(got)
|
parsed, err := url.Parse(got)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -26,16 +35,83 @@ func TestNormalizeCloudPlayTarget(t *testing.T) {
|
|||||||
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
|
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
|
||||||
}
|
}
|
||||||
|
|
||||||
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
// 绝对地址但 host 就是当前请求 host:宿主切换过(开发机扫描 → 部署)
|
||||||
gotStrm := normalizeCloudPlayTarget(strmStale)
|
// 之后仍要能当作本机地址处理。
|
||||||
|
sameHost := "http://nas.local:18080/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||||
|
gotStrm := normalizeCloudPlayTarget(ctx, nil, nil, req, sameHost)
|
||||||
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||||
if gotStrm != wantStrm {
|
if gotStrm != wantStrm {
|
||||||
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
|
t.Fatalf("normalizeCloudPlayTarget(same host) = %q, want %q", gotStrm, wantStrm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
|
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
|
||||||
passthrough := "https://dav.example.com/media/file.mkv"
|
passthrough := "https://dav.example.com/media/file.mkv"
|
||||||
if got := normalizeCloudPlayTarget(passthrough); got != passthrough {
|
if got := normalizeCloudPlayTarget(ctx, nil, nil, req, passthrough); got != passthrough {
|
||||||
t.Fatalf("non-cloud target should pass through, got %q", got)
|
t.Fatalf("non-cloud target should pass through, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNormalizeCloudPlayTargetKeepsForeignInstanceURL 验证别的 MeBox /
|
||||||
|
// MediaStationGo 实例生成的 .strm 内容不被本机账号解析,而是按第三方直链透传。
|
||||||
|
func TestNormalizeCloudPlayTargetKeepsForeignInstanceURL(t *testing.T) {
|
||||||
|
svc := testStrmService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
// 本机自己的 115 账号(strm.base_url 由 testStrmService 设为 http://test.local:8096)。
|
||||||
|
own := &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-own-115"},
|
||||||
|
Name: "own",
|
||||||
|
Provider: model.StrmProvider115,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := svc.repo.StrmAccount.Create(ctx, own); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://mebox.local/api/stream/media-1", nil)
|
||||||
|
|
||||||
|
// host 不认识 + acct 不是本机账号 → 另一个实例的 .strm,原样透传。
|
||||||
|
foreign := "http://other-mebox.example:18080/api/strm/play/cloud115/video.mkv?acct=acct-other&pickcode=xyz"
|
||||||
|
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, foreign); got != foreign {
|
||||||
|
t.Fatalf("foreign instance url should pass through, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// host 不认识但 acct 是本机账号 → 本机换了域名/IP 的老 .strm,仍要认领。
|
||||||
|
staleOwn := "http://192.168.1.4:9011/api/strm/play/cloud115/video.mkv?acct=acct-own-115&pickcode=123"
|
||||||
|
wantStale := "/api/strm/play/cloud115/video.mkv?acct=acct-own-115&pickcode=123"
|
||||||
|
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, staleOwn); got != wantStale {
|
||||||
|
t.Fatalf("own acct on stale host = %q, want %q", got, wantStale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// acct 撞上本机账号 ID 但账号类型与路径 provider 不一致:不算本机。
|
||||||
|
wrongProvider := "http://other-mebox.example/api/strm/play/openlist/video.mkv?acct=acct-own-115&pickcode=123"
|
||||||
|
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, wrongProvider); got != wrongProvider {
|
||||||
|
t.Fatalf("provider mismatch should pass through, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeCloudPlayTargetLegacyCloudURL 旧格式 /api/cloud/play(不带 acct)
|
||||||
|
// 无法凭账号 ID 判断归属:本机配了该类型账号就按本机处理(保住老固化地址的可
|
||||||
|
// 播放性),完全没配才透传。
|
||||||
|
func TestNormalizeCloudPlayTargetLegacyCloudURL(t *testing.T) {
|
||||||
|
svc := testStrmService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://mebox.local/api/stream/media-1", nil)
|
||||||
|
ref := "/Movies/Movie.mkv"
|
||||||
|
legacy := "http://old-host:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
||||||
|
|
||||||
|
// 本机没有 openlist 账号 → 不是本机地址。
|
||||||
|
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, legacy); got != legacy {
|
||||||
|
t.Fatalf("legacy url without local provider should pass through, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.repo.StrmAccount.Create(ctx, &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-openlist"},
|
||||||
|
Name: "openlist",
|
||||||
|
Provider: model.StrmProviderOpenList,
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, legacy); got != BuildRelativeCloudPlayURL("openlist", ref) {
|
||||||
|
t.Fatalf("legacy url with local provider = %q, want relative rebuild", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
||||||
@@ -11,28 +15,197 @@ import (
|
|||||||
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
|
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
|
||||||
// 在 Windows 开发机上扫描、再部署到 Docker(或更换了内网 IP/域名)后,
|
// 在 Windows 开发机上扫描、再部署到 Docker(或更换了内网 IP/域名)后,
|
||||||
// 这些绝对 URL 会指向已失效的旧地址,第三方播放器跟随 302 就会拿到
|
// 这些绝对 URL 会指向已失效的旧地址,第三方播放器跟随 302 就会拿到
|
||||||
// 连接失败/404。这里只要能从 URL 中解析出 provider+ref,就重建为相对
|
// 连接失败/404。所以只要确认这个地址「是本机自己生成的」,就从 URL 中解析出
|
||||||
// /api/cloud/play 路径,由 absoluteInternalRedirect 基于「当前请求」补全
|
// provider+ref,重建为相对 /api/cloud/play 或 /api/strm/play 路径,由
|
||||||
// host,从而对历史脏数据免疫。
|
// absoluteInternalRedirect 基于「当前请求」补全 host,从而对历史脏数据免疫。
|
||||||
func normalizeCloudPlayTarget(raw string) string {
|
//
|
||||||
|
// 反过来,指向**别的 MeBox / MediaStationGo 实例**的地址不能按本机账号解析:
|
||||||
|
// 别人 .strm 里的 acct 是他那台机器的账号 ID,拿到本机来查只会得到
|
||||||
|
// 「网盘账号不存在」。这种地址按普通第三方直链原样透传,让客户端跟着 302 去
|
||||||
|
// 对方实例取流(/api/strm/play 是公开端点,不需要本机凭据),或直接去对方的
|
||||||
|
// CDN 直链。归属判断见 isInternalPlaybackTarget。
|
||||||
|
func normalizeCloudPlayTarget(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
if !isInternalPlaybackTarget(ctx, repo, cfg, r, raw) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
if typ, ref, ok := parseCloudMediaPlaybackURL(raw); ok {
|
if typ, ref, ok := parseCloudMediaPlaybackURL(raw); ok {
|
||||||
return BuildRelativeCloudPlayURL(typ, ref)
|
return BuildRelativeCloudPlayURL(typ, ref)
|
||||||
}
|
}
|
||||||
if u, err := url.Parse(raw); err == nil {
|
u, err := url.Parse(raw)
|
||||||
path := strings.ToLower(u.Path)
|
if err != nil {
|
||||||
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
|
return raw
|
||||||
u.Scheme = ""
|
}
|
||||||
u.Host = ""
|
path := strings.ToLower(u.Path)
|
||||||
return u.String()
|
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
|
||||||
}
|
u.Scheme = ""
|
||||||
|
u.Host = ""
|
||||||
|
return u.String()
|
||||||
}
|
}
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isInternalPlaybackTarget 判断播放地址能否按「本机自己的云盘播放地址」处理
|
||||||
|
// (相对化 + 用本机账号解析)。判断顺序:
|
||||||
|
//
|
||||||
|
// 1. 相对路径一定是本机存库的常规形态;
|
||||||
|
// 2. 绝对地址的 host 与本机配置的 strm.base_url、各同步目录覆盖的 base_url、
|
||||||
|
// 当前请求 host 之一相同 → 就是本机(老 .strm 里固化的旧 host 属于这一类);
|
||||||
|
// 3. host 对不上时,再看地址里带的网盘账号 ID 是不是本机账号:MeBox /
|
||||||
|
// MediaStationGo 会把本机账号 ID 写进 acct,而账号 ID 由各实例自行生成,
|
||||||
|
// 跨实例几乎不可能撞号。这条用于「换了域名/IP 之后」认领自己的老 .strm。
|
||||||
|
//
|
||||||
|
// 三条都不满足(典型是另一台 MeBox 生成的 .strm)→ 视为第三方直链。
|
||||||
|
func isInternalPlaybackTarget(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, raw string) bool {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if u.Host == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !isPlaybackAPIPath(u.Path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if matchesLocalPlaybackHost(ctx, repo, cfg, r, u) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return localAccountOwnsPlaybackTarget(ctx, repo, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlaybackAPIPath 判断路径是不是本服务自己的播放端点。
|
||||||
|
func isPlaybackAPIPath(path string) bool {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(path))
|
||||||
|
for _, prefix := range []string{"/api/strm/play/", "/api/cloud/play/", "/api/stream/"} {
|
||||||
|
if strings.HasPrefix(lower, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesLocalPlaybackHost 判断绝对播放地址的 host 是否就是本机。
|
||||||
|
func matchesLocalPlaybackHost(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, u *url.URL) bool {
|
||||||
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
target := canonicalHostPort(u)
|
||||||
|
if target == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, base := range localPlaybackBaseURLs(ctx, repo, cfg, r) {
|
||||||
|
parsed, err := url.Parse(base)
|
||||||
|
if err != nil || parsed == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if canonicalHostPort(parsed) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// localPlaybackBaseURLs 汇总本机的播放基地址:管理员配置的公网地址 /
|
||||||
|
// strm.base_url、每条 STRM 同步目录单独覆盖的 base_url,以及当前请求的 host
|
||||||
|
// (含反向代理头)。请求 host 也要算进来:没有配 base_url 时 .strm 里固化的
|
||||||
|
// 就是请求地址。
|
||||||
|
func localPlaybackBaseURLs(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request) []string {
|
||||||
|
bases := make([]string, 0, 4)
|
||||||
|
if base := PublicServerURL(ctx, repo, cfg); base != "" {
|
||||||
|
bases = append(bases, base)
|
||||||
|
}
|
||||||
|
if repo != nil && repo.StrmSyncPath != nil {
|
||||||
|
if paths, err := repo.StrmSyncPath.List(ctx); err == nil {
|
||||||
|
for i := range paths {
|
||||||
|
if override := strings.TrimSpace(paths[i].StrmBaseURL); override != "" {
|
||||||
|
bases = append(bases, override)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r != nil {
|
||||||
|
if host := strings.TrimSpace(r.Host); host != "" {
|
||||||
|
bases = append(bases, "//"+host)
|
||||||
|
}
|
||||||
|
for _, header := range []string{"X-Forwarded-Host", "X-Original-Host"} {
|
||||||
|
if host := strings.TrimSpace(r.Header.Get(header)); host != "" {
|
||||||
|
bases = append(bases, "//"+host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bases
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalHostPort 归一化 host[:port]:小写、忽略默认端口(http 80 / https 443)。
|
||||||
|
func canonicalHostPort(u *url.URL) string {
|
||||||
|
if u == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
port := strings.TrimSpace(u.Port())
|
||||||
|
if port == "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||||
|
if (scheme == "http" || scheme == "") && port == "80" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
if scheme == "https" && port == "443" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return host + ":" + port
|
||||||
|
}
|
||||||
|
|
||||||
|
// localAccountOwnsPlaybackTarget 用「地址里带的网盘账号是不是本机的」判断播放
|
||||||
|
// 地址归属。账号 ID 跨实例不会撞号,因此比 host 更可靠:
|
||||||
|
//
|
||||||
|
// - /api/strm/play/{provider}/video{ext}?acct=…:acct 能在本机查到、且账号类型
|
||||||
|
// 与路径里的 provider 一致,才认作本机地址;
|
||||||
|
// - /api/cloud/play/{provider}?ref=…(旧格式,不带 acct):只能退化为「本机是否
|
||||||
|
// 配置了该类型账号」,配置了才按本机处理,保持老版本固化地址的可播放性。
|
||||||
|
func localAccountOwnsPlaybackTarget(ctx context.Context, repo *repository.Container, u *url.URL) bool {
|
||||||
|
if repo == nil || repo.StrmAccount == nil || u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
segments := strings.Split(strings.Trim(strings.TrimSpace(u.Path), "/"), "/")
|
||||||
|
if len(segments) < 4 || !strings.EqualFold(segments[0], "api") || !strings.EqualFold(segments[2], "play") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
provider := strings.TrimSpace(segments[3])
|
||||||
|
if provider == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(segments[1]) {
|
||||||
|
case "strm":
|
||||||
|
accountID := strings.TrimSpace(u.Query().Get("acct"))
|
||||||
|
if accountID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
account, err := repo.StrmAccount.FindByID(ctx, accountID)
|
||||||
|
if err != nil || account == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(account.Provider), provider)
|
||||||
|
case "cloud":
|
||||||
|
accounts, err := repo.StrmAccount.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range accounts {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(accounts[i].Provider), provider) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// BuildRelativeCloudPlayURL 构造相对的云盘播放 API 路径。
|
// BuildRelativeCloudPlayURL 构造相对的云盘播放 API 路径。
|
||||||
func BuildRelativeCloudPlayURL(typ, ref string) string {
|
func BuildRelativeCloudPlayURL(typ, ref string) string {
|
||||||
return "/api/cloud/play/" + url.PathEscape(strings.TrimSpace(typ)) + "?" + url.Values{"ref": []string{ref}}.Encode()
|
return "/api/cloud/play/" + url.PathEscape(strings.TrimSpace(typ)) + "?" + url.Values{"ref": []string{ref}}.Encode()
|
||||||
|
|||||||
@@ -216,6 +216,78 @@ func TestServeFileRedirectsLocalSTRMFileTargetByDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 别的 MeBox / MediaStationGo 实例生成的 .strm:里面的 acct 是对方实例的账号,
|
||||||
|
// 本机不能拿自己的账号去解析,直接把 302 透传给客户端,由客户端去对方实例取流。
|
||||||
|
func TestServeFilePassesThroughForeignInstanceSTRMURL(t *testing.T) {
|
||||||
|
repos := newStreamTestRepo(t)
|
||||||
|
target := "http://other-mebox.example:18080/api/strm/play/cloud115/video.mkv?acct=other-acct&pickcode=xyz"
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
Base: model.Base{ID: "foreign-strm"},
|
||||||
|
Title: "Foreign STRM",
|
||||||
|
Path: "D:/media/Foreign.strm",
|
||||||
|
Container: "strm",
|
||||||
|
STRMURL: target,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/foreign-strm?token=jwt123", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
if err := svc.ServeFile(w, req, "foreign-strm"); err != nil {
|
||||||
|
t.Fatalf("foreign instance strm url should be passed through: %v", err)
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d, want 302", w.Code)
|
||||||
|
}
|
||||||
|
loc := w.Header().Get("Location")
|
||||||
|
if loc != target {
|
||||||
|
t.Fatalf("Location = %q, want untouched %q", loc, target)
|
||||||
|
}
|
||||||
|
if strings.Contains(loc, "jwt123") {
|
||||||
|
t.Fatalf("foreign instance url must not receive our auth token, got %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本机自己生成的 .strm 在换了域名/IP 之后仍要认领:host 对不上,但 acct 是本机
|
||||||
|
// 网盘账号,于是按当前请求 host 相对化,保持可播放。
|
||||||
|
func TestServeFileRealignsOwnSTRMURLOtherHost(t *testing.T) {
|
||||||
|
repos := repository.New(newServiceTestDB(t, &model.Media{}, &model.Setting{}, &model.StrmAccount{}))
|
||||||
|
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "own-acct"},
|
||||||
|
Name: "own",
|
||||||
|
Provider: model.StrmProvider115,
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
Base: model.Base{ID: "own-strm"},
|
||||||
|
Title: "Own STRM",
|
||||||
|
Path: "D:/media/Own.strm",
|
||||||
|
Container: "strm",
|
||||||
|
STRMURL: "http://old-host:9011/api/strm/play/cloud115/video.mkv?acct=own-acct&pickcode=123",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/own-strm?token=jwt123", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
if err := svc.ServeFile(w, req, "own-strm"); err != nil {
|
||||||
|
t.Fatalf("own strm url on a stale host should still play: %v", err)
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d, want 302", w.Code)
|
||||||
|
}
|
||||||
|
loc := w.Header().Get("Location")
|
||||||
|
if !strings.HasPrefix(loc, "http://nas.local:18080/api/strm/play/cloud115/video.mkv?") ||
|
||||||
|
!strings.Contains(loc, "acct=own-acct") ||
|
||||||
|
!strings.Contains(loc, "pickcode=123") {
|
||||||
|
t.Fatalf("own strm url should be realigned to current host, got %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCloudPlaybackModeUsesExplicitModeBeforeLegacySTRMFlag(t *testing.T) {
|
func TestCloudPlaybackModeUsesExplicitModeBeforeLegacySTRMFlag(t *testing.T) {
|
||||||
repos := newStreamTestRepo(t)
|
repos := newStreamTestRepo(t)
|
||||||
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy {
|
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy {
|
||||||
|
|||||||
+106
-12
@@ -121,7 +121,7 @@ func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*St
|
|||||||
// 支持:
|
// 支持:
|
||||||
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
|
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
|
||||||
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
|
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
|
||||||
// - 绝对 http(s) 链接(直接透传)
|
// - 绝对 http(s) 链接(直接透传,包含别的 MeBox / MediaStationGo 实例的播放端点)
|
||||||
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
|
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
|
||||||
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
|
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
@@ -132,6 +132,17 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("解析播放目标失败: %w", err)
|
return nil, fmt.Errorf("解析播放目标失败: %w", err)
|
||||||
}
|
}
|
||||||
|
// 别的 MeBox / MediaStationGo 实例的播放端点:本机没有对应账号,按普通外部
|
||||||
|
// 直链处理——客户端与 ffmpeg 直接跟随 302 去对方实例取流(/api/strm/play 是
|
||||||
|
// 公开端点,不需要本机凭据),不能拿本机账号去查别人的 pickcode。
|
||||||
|
if isPlaybackAPIPath(u.Path) && !s.isLocalPlaybackTarget(ctx, raw) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
||||||
|
case "http", "https":
|
||||||
|
return &StrmPlayResult{RedirectURL: raw}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("不支持的播放目标协议: %s", u.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
lowerPath := strings.ToLower(u.Path)
|
lowerPath := strings.ToLower(u.Path)
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(lowerPath, "/api/strm/play/"):
|
case strings.HasPrefix(lowerPath, "/api/strm/play/"):
|
||||||
@@ -156,6 +167,16 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isLocalPlaybackTarget 报告播放地址是否属于本机。这里没有 HTTP 请求上下文,
|
||||||
|
// 「本机」由 strm.base_url / 各同步目录覆盖的 base_url / 本机网盘账号共同界定
|
||||||
|
// (见 isInternalPlaybackTarget)。
|
||||||
|
func (s *StrmService) isLocalPlaybackTarget(ctx context.Context, raw string) bool {
|
||||||
|
if s == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isInternalPlaybackTarget(ctx, s.repo, s.cfg, nil, raw)
|
||||||
|
}
|
||||||
|
|
||||||
// firstEnabledAccountOf 返回指定提供方第一个凭据可用的启用账号。
|
// firstEnabledAccountOf 返回指定提供方第一个凭据可用的启用账号。
|
||||||
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
|
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
|
||||||
accounts, err := s.repo.StrmAccount.List(ctx)
|
accounts, err := s.repo.StrmAccount.List(ctx)
|
||||||
@@ -174,38 +195,111 @@ func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrStrmProxyNotApplicable 表示该媒体没有需要服务端转发的直链(本地文件本身同源)。
|
||||||
|
var ErrStrmProxyNotApplicable = errors.New("strm proxy not applicable")
|
||||||
|
|
||||||
|
// ProxyMediaDirect 把媒体行的网盘/STRM 直链解析为真实地址后由服务端反向代理给
|
||||||
|
// 客户端,让浏览器拿到「同源」数据。画质与原文件完全一致,不触发任何转码。
|
||||||
|
//
|
||||||
|
// 用途:VR 全景播放要把视频帧读进 WebGL 纹理,而跨域直链(网盘 302 跳到 CDN)
|
||||||
|
// 在浏览器里属于被污染的资源,WebGL 读取会抛 SecurityError;把流量经服务端转发
|
||||||
|
// 是「原画 + VR」唯一可行的办法。
|
||||||
|
func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, m *model.Media) error {
|
||||||
|
if s == nil || m == nil {
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(m.STRMURL)
|
||||||
|
if raw == "" {
|
||||||
|
path := strings.TrimSpace(m.Path)
|
||||||
|
if !strings.HasSuffix(strings.ToLower(path), ".strm") {
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
target, err := readLocalSTRMTarget(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw = strings.TrimSpace(target)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
result, err := s.ResolvePlayTarget(ctx, raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case result.Link != nil && result.Link.URL != "":
|
||||||
|
return s.ProxyDirect(ctx, w, r, result.Link)
|
||||||
|
case result.RedirectURL != "":
|
||||||
|
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
|
||||||
|
default:
|
||||||
|
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||||
if link == nil || link.URL == "" {
|
if link == nil || link.URL == "" {
|
||||||
return errors.New("空直链")
|
return errors.New("空直链")
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
|
method := http.MethodGet
|
||||||
|
if r != nil && r.Method == http.MethodHead {
|
||||||
|
method = http.MethodHead
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for k, v := range link.Headers {
|
for k, v := range link.Headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
if r != nil {
|
||||||
req.Header.Set("Range", rangeHeader)
|
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||||
|
req.Header.Set("Range", rangeHeader)
|
||||||
|
}
|
||||||
|
// 部分网盘直链按 UA 防盗链;解析时未绑定 UA 的直链沿用浏览器 UA 更稳。
|
||||||
|
if ua := strings.TrimSpace(r.Header.Get("User-Agent")); ua != "" && req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", ua)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
resp, err := s.http.Do(req)
|
resp, err := s.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag"} {
|
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
||||||
if value := resp.Header.Get(header); value != "" {
|
if value := resp.Header.Get(header); value != "" {
|
||||||
w.Header().Set(header, value)
|
w.Header().Set(header, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
// 原样透传上游状态码:Range 请求必须回 206,改写成 200 会让浏览器误判
|
||||||
w.WriteHeader(http.StatusOK)
|
// 响应长度,拖动进度条时反复重新拉流。
|
||||||
} else {
|
w.WriteHeader(resp.StatusCode)
|
||||||
w.WriteHeader(resp.StatusCode)
|
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
|
if method == http.MethodHead {
|
||||||
_, _ = io.Copy(w, resp.Body)
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
// 边转发边 flush,避免大体积视频被 net/http 的写缓冲切成一段段卡顿。
|
||||||
|
writer := io.Writer(w)
|
||||||
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
|
writer = &flushWriter{writer: w, flusher: flusher}
|
||||||
|
}
|
||||||
|
_, err = io.Copy(writer, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type flushWriter struct {
|
||||||
|
writer io.Writer
|
||||||
|
flusher http.Flusher
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flushWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := f.writer.Write(p)
|
||||||
|
if f.flusher != nil {
|
||||||
|
f.flusher.Flush()
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
|
||||||
|
// 拖动进度条时反复重新拉流。
|
||||||
|
func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
|
||||||
|
var gotRange string
|
||||||
|
var gotUA string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotRange = r.Header.Get("Range")
|
||||||
|
gotUA = r.Header.Get("User-Agent")
|
||||||
|
w.Header().Set("Content-Type", "video/mp4")
|
||||||
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
w.Header().Set("Content-Range", "bytes 10-19/100")
|
||||||
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
_, _ = w.Write([]byte("0123456789"))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
svc := testStrmService(t)
|
||||||
|
media := &model.Media{STRMURL: upstream.URL + "/video.mp4"}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||||
|
req.Header.Set("Range", "bytes=10-19")
|
||||||
|
req.Header.Set("User-Agent", "MeBoxTest/1.0")
|
||||||
|
|
||||||
|
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
|
||||||
|
t.Fatalf("ProxyMediaDirect: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusPartialContent {
|
||||||
|
t.Fatalf("status = %d, want 206", rec.Code)
|
||||||
|
}
|
||||||
|
if gotRange != "bytes=10-19" {
|
||||||
|
t.Fatalf("upstream Range = %q, want bytes=10-19", gotRange)
|
||||||
|
}
|
||||||
|
if gotUA != "MeBoxTest/1.0" {
|
||||||
|
t.Fatalf("upstream User-Agent = %q, want MeBoxTest/1.0", gotUA)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Content-Range") != "bytes 10-19/100" {
|
||||||
|
t.Fatalf("Content-Range = %q", rec.Header().Get("Content-Range"))
|
||||||
|
}
|
||||||
|
if rec.Body.String() != "0123456789" {
|
||||||
|
t.Fatalf("body = %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
|
||||||
|
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
|
||||||
|
svc := testStrmService(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "movie.mkv")
|
||||||
|
writeFile(t, path, "not-a-video")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||||
|
err := svc.ProxyMediaDirect(context.Background(), rec, req, &model.Media{Path: path})
|
||||||
|
if !errors.Is(err, ErrStrmProxyNotApplicable) {
|
||||||
|
t.Fatalf("err = %v, want ErrStrmProxyNotApplicable", err)
|
||||||
|
}
|
||||||
|
if rec.Body.Len() != 0 {
|
||||||
|
t.Fatalf("local file must not be proxied, body = %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -746,7 +746,10 @@ func TestHandleMetaSha1Identity(t *testing.T) {
|
|||||||
|
|
||||||
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
|
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
|
||||||
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
|
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
|
||||||
|
// walkRemote 会并发调用 List,因此计数必须加锁:此前直接写 map 会偶发
|
||||||
|
// "fatal error: concurrent map writes",把整个测试进程带走。
|
||||||
type fakeRemoteProvider struct {
|
type fakeRemoteProvider struct {
|
||||||
|
mu sync.Mutex
|
||||||
listed map[string]int
|
listed map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,11 +759,20 @@ func (f *fakeRemoteProvider) Resolve(context.Context, string) (*cloud.DirectLink
|
|||||||
return &cloud.DirectLink{URL: "http://cdn/x.mkv"}, nil
|
return &cloud.DirectLink{URL: "http://cdn/x.mkv"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listedCount 返回某个目录被 List 的次数。
|
||||||
|
func (f *fakeRemoteProvider) listedCount(dirID string) int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.listed[dirID]
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeRemoteProvider) List(_ context.Context, dirID string) ([]cloud.FileEntry, error) {
|
func (f *fakeRemoteProvider) List(_ context.Context, dirID string) ([]cloud.FileEntry, error) {
|
||||||
|
f.mu.Lock()
|
||||||
if f.listed == nil {
|
if f.listed == nil {
|
||||||
f.listed = map[string]int{}
|
f.listed = map[string]int{}
|
||||||
}
|
}
|
||||||
f.listed[dirID]++
|
f.listed[dirID]++
|
||||||
|
f.mu.Unlock()
|
||||||
switch dirID {
|
switch dirID {
|
||||||
case "root":
|
case "root":
|
||||||
return []cloud.FileEntry{
|
return []cloud.FileEntry{
|
||||||
@@ -828,8 +840,8 @@ func TestWalkRemoteConcurrent(t *testing.T) {
|
|||||||
st.flushPreferredVideos()
|
st.flushPreferredVideos()
|
||||||
|
|
||||||
for _, dir := range []string{"root", "a", "a1", "b"} {
|
for _, dir := range []string{"root", "a", "a1", "b"} {
|
||||||
if provider.listed[dir] != 1 {
|
if count := provider.listedCount(dir); count != 1 {
|
||||||
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, provider.listed[dir])
|
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,8 +141,13 @@ const profileQuery = () => {
|
|||||||
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
||||||
// a query parameter because <video> elements cannot send Authorization
|
// a query parameter because <video> elements cannot send Authorization
|
||||||
// headers.
|
// headers.
|
||||||
export function streamURL(mediaId: string): string {
|
//
|
||||||
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
|
// proxy=true 时由服务端把网盘/STRM 直链转发为同源数据(画质与原文件一致、
|
||||||
|
// 不转码)。VR 全景渲染需要把视频帧读进 WebGL 纹理,跨域直链会被浏览器
|
||||||
|
// 判定为污染资源而禁止读取,因此只有这种场景才需要开启。
|
||||||
|
export function streamURL(mediaId: string, options: { proxy?: boolean } = {}): string {
|
||||||
|
const proxy = options.proxy ? '&proxy=1' : ''
|
||||||
|
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${proxy}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export interface DanmakuConfig {
|
|||||||
font_size: string
|
font_size: string
|
||||||
area: string
|
area: string
|
||||||
volume: number
|
volume: number
|
||||||
|
playback_rate: number
|
||||||
|
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
|
||||||
|
vr360_guide_seen: boolean
|
||||||
/** Per-user preference: merge the same episode's multiple sources. */
|
/** Per-user preference: merge the same episode's multiple sources. */
|
||||||
merge_sources: boolean
|
merge_sources: boolean
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,9 @@ export interface DanmakuSettingsPatch {
|
|||||||
area?: number
|
area?: number
|
||||||
merge_sources?: boolean
|
merge_sources?: boolean
|
||||||
volume?: number
|
volume?: number
|
||||||
|
playback_rate?: number
|
||||||
|
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
|
||||||
|
vr360_guide_seen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type { User } from '../types'
|
import type { LibraryTagSet, User } from '../types'
|
||||||
|
|
||||||
export const profileAPI = {
|
export const profileAPI = {
|
||||||
get: () => api.get<User>('/me').then((r) => r.data),
|
get: () => api.get<User>('/me').then((r) => r.data),
|
||||||
@@ -21,6 +21,12 @@ export const profileAPI = {
|
|||||||
setPinnedLibraries: (libraryIds: string[]) =>
|
setPinnedLibraries: (libraryIds: string[]) =>
|
||||||
api.put<{ library_ids: string[] }>('/me/pinned-libraries', { library_ids: libraryIds }).then((r) => r.data.library_ids ?? []),
|
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') =>
|
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
|
||||||
api.patch<User>(`/admin/users/${id}/role`, { role }).then((r) => r.data),
|
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 (topic === 'scrape' && p.finished) {
|
||||||
|
if (role !== 'admin') return
|
||||||
const processed = Number(p.processed ?? 0)
|
const processed = Number(p.processed ?? 0)
|
||||||
const matched = Number(p.matched ?? 0)
|
const matched = Number(p.matched ?? 0)
|
||||||
const failed = Number(p.failed ?? 0)
|
const failed = Number(p.failed ?? 0)
|
||||||
@@ -51,6 +52,7 @@ export function GlobalEvents() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (topic === 'subscription') {
|
if (topic === 'subscription') {
|
||||||
|
if (role !== 'admin') return
|
||||||
const queued = (p.queued as number | undefined) ?? 0
|
const queued = (p.queued as number | undefined) ?? 0
|
||||||
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
|
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,11 @@ export function Layout() {
|
|||||||
pathname={location.pathname}
|
pathname={location.pathname}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<LayoutWorkspace routeKey={location.pathname} showMobileBottomNav={showMobileBottomNav} />
|
<LayoutWorkspace
|
||||||
|
routeKey={location.pathname}
|
||||||
|
userKey={user?.id}
|
||||||
|
showMobileBottomNav={showMobileBottomNav}
|
||||||
|
/>
|
||||||
{showMobileBottomNav && (
|
{showMobileBottomNav && (
|
||||||
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
|
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
|||||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||||
import { isPlayerRoute } from './layoutNavigation'
|
import { isPlayerRoute } from './layoutNavigation'
|
||||||
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||||
|
import { useScrollMemory } from '../hooks/useScrollMemory'
|
||||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||||
|
|
||||||
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
|
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
|
||||||
@@ -30,6 +31,7 @@ type LayoutSidebarsProps = Omit<
|
|||||||
|
|
||||||
type LayoutWorkspaceProps = {
|
type LayoutWorkspaceProps = {
|
||||||
routeKey: string
|
routeKey: string
|
||||||
|
userKey?: string
|
||||||
showMobileBottomNav?: boolean
|
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
|
const bottomPad = showMobileBottomNav
|
||||||
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
? '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,165 @@
|
|||||||
|
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,520 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Check, GripVertical, 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 { useDragReorder } from '../hooks/useDragReorder'
|
||||||
|
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>
|
||||||
|
/** 按给定标签名顺序重排标签(拖拽排序的落点)。 */
|
||||||
|
onReorder: (orderedNames: 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,
|
||||||
|
onReorder,
|
||||||
|
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)
|
||||||
|
const { draggingId, dragOverId, dragProps } = useDragReorder(
|
||||||
|
tags.map((tag) => tag.name),
|
||||||
|
onReorder,
|
||||||
|
)
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const drag = dragProps(tag.name)
|
||||||
|
const isDragOver = dragOverId === tag.name && draggingId !== tag.name
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tag.name}
|
||||||
|
onDragOver={drag.onDragOver}
|
||||||
|
onDragLeave={drag.onDragLeave}
|
||||||
|
onDrop={drag.onDrop}
|
||||||
|
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
|
||||||
|
isDragOver
|
||||||
|
? 'border-brand-500/60 bg-brand-500/5'
|
||||||
|
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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"
|
||||||
|
draggable={drag.draggable}
|
||||||
|
onDragStart={drag.onDragStart}
|
||||||
|
onDragEnd={drag.onDragEnd}
|
||||||
|
className={`-ml-1 shrink-0 cursor-grab rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] ${
|
||||||
|
draggingId === tag.name ? 'opacity-40' : ''
|
||||||
|
}`}
|
||||||
|
title="拖拽调整标签顺序"
|
||||||
|
aria-label="拖拽调整标签顺序"
|
||||||
|
>
|
||||||
|
<GripVertical size={13} />
|
||||||
|
</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 { imageURL } from '../api/client'
|
||||||
import type { Media } from '../types'
|
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:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
|
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
|
||||||
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
|
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
|
||||||
export const MediaCard = memo(function MediaCard({
|
export const MediaCard = memo(function MediaCard({
|
||||||
@@ -79,25 +82,18 @@ export const MediaCard = memo(function MediaCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Episode count badge */}
|
{/* Episode count badge:统一放右下角(桌面端与移动端一致),与左上角评分彻底错开 */}
|
||||||
{count !== undefined && count > 1 && (
|
{(count !== undefined && count > 1) || (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">
|
<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="text-[#c9954a]" />
|
<Layers size={10} className="shrink-0 text-[#c9954a]" />
|
||||||
<span>{count} 集</span>
|
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rating Badge */}
|
{/* Rating Badge */}
|
||||||
{displayRating > 0 && (
|
{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">
|
<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" />
|
<Star size={10} fill="currentColor" className="shrink-0" />
|
||||||
<span>{displayRating.toFixed(1)}</span>
|
<span>{displayRating.toFixed(1)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -160,7 +156,7 @@ export const MediaCard = memo(function MediaCard({
|
|||||||
{card}
|
{card}
|
||||||
</button>
|
</button>
|
||||||
{actionContent && (
|
{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}
|
{actionContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -174,7 +170,7 @@ export const MediaCard = memo(function MediaCard({
|
|||||||
<Link to={href} className="block">
|
<Link to={href} className="block">
|
||||||
{card}
|
{card}
|
||||||
</Link>
|
</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}
|
{actionContent}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,26 +6,23 @@ import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaV
|
|||||||
|
|
||||||
type MediaVersionSwitcherProps = {
|
type MediaVersionSwitcherProps = {
|
||||||
media: Media
|
media: Media
|
||||||
/** 详情页:用 Link 跳转播放;播放页:回调切换 */
|
|
||||||
mode?: 'detail' | 'player'
|
|
||||||
onSelect?: (version: Media) => void
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaVersionSwitcher({
|
/**
|
||||||
media,
|
* 详情页的版本切换。
|
||||||
mode = 'detail',
|
*
|
||||||
onSelect,
|
* 播放器内不再单独浮出一块版本条:多版本切换统一收在「选集」面板里
|
||||||
className = '',
|
* (见 PlayerPlaylistPanel),这里只负责详情页的同片多版本跳转。
|
||||||
}: MediaVersionSwitcherProps) {
|
*/
|
||||||
|
export function MediaVersionSwitcher({ media, className = '' }: MediaVersionSwitcherProps) {
|
||||||
const versions = mediaVersionsOf(media)
|
const versions = mediaVersionsOf(media)
|
||||||
if (versions.length <= 1) return null
|
if (versions.length <= 1) return null
|
||||||
const dark = mode === 'player'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-2 ${className}`.trim()}>
|
<div className={`space-y-2 ${className}`.trim()}>
|
||||||
<div className={`flex items-center gap-2 text-sm font-semibold ${dark ? 'text-white/90' : 'text-ink-600'}`}>
|
<div className="flex items-center gap-2 text-sm font-semibold text-ink-600">
|
||||||
<Layers size={14} className={dark ? 'text-white/60' : 'text-sand-500'} />
|
<Layers size={14} className="text-sand-500" />
|
||||||
<span>版本({versions.length})</span>
|
<span>版本({versions.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -33,42 +30,10 @@ export function MediaVersionSwitcher({
|
|||||||
const active = version.id === media.id
|
const active = version.id === media.id
|
||||||
const isStrm = isStrmMedia(version)
|
const isStrm = isStrmMedia(version)
|
||||||
const label = mediaVersionLabel(version)
|
const label = mediaVersionLabel(version)
|
||||||
const btnClass = dark
|
const btnClass = active
|
||||||
? active
|
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
||||||
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
|
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
||||||
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
|
|
||||||
: active
|
|
||||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
|
||||||
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{isStrm && (
|
|
||||||
<Cloud
|
|
||||||
size={12}
|
|
||||||
className={dark ? (active ? 'text-brand-300' : 'text-white/60') : (active ? 'text-brand-500' : 'text-sand-400')}
|
|
||||||
aria-label="云端 / STRM"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span>{label}</span>
|
|
||||||
{!dark && active ? <span className="opacity-75">· 当前</span> : null}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (mode === 'player') {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={version.id}
|
|
||||||
type="button"
|
|
||||||
disabled={active}
|
|
||||||
onClick={() => onSelect?.(version)}
|
|
||||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${btnClass}`}
|
|
||||||
title={version.path || version.strm_url}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={version.id}
|
key={version.id}
|
||||||
@@ -77,7 +42,17 @@ export function MediaVersionSwitcher({
|
|||||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
|
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
|
||||||
title={version.path || version.strm_url}
|
title={version.path || version.strm_url}
|
||||||
>
|
>
|
||||||
{content}
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{isStrm && (
|
||||||
|
<Cloud
|
||||||
|
size={12}
|
||||||
|
className={active ? 'text-brand-500' : 'text-sand-400'}
|
||||||
|
aria-label="云端 / STRM"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{label}</span>
|
||||||
|
{active ? <span className="opacity-75">· 当前</span> : null}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
+1037
-394
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react'
|
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react'
|
||||||
|
|
||||||
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
|
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
|
||||||
|
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER } from './playerTheme'
|
||||||
|
|
||||||
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
|
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
|
||||||
// the matched danmaku details (anime title, episode title, comment count,
|
// the matched danmaku details (anime title, episode title, comment count,
|
||||||
@@ -194,24 +195,26 @@ export function PlayerDanmakuPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||||
|
// 排布和选集抽屉完全一致(整条贴住右边缘、标题栏常驻),两个面板互斥打开时
|
||||||
|
// 位置不会跳,用户也不用重新找入口。
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto overscroll-contain rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:bottom-auto sm:right-4 sm:top-16 sm:w-80 sm:max-h-[calc(100%-5rem)]"
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
|
className={`absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className={PLAYER_PANEL_HEADER}>
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||||
<MessageSquareText size={16} className="text-rose-400" /> 弹幕设置
|
<MessageSquareText size={16} className="shrink-0 text-rose-400" />
|
||||||
{settingsSaving && <Loader2 size={12} className="animate-spin text-rose-300" />}
|
<span className="truncate">弹幕设置</span>
|
||||||
|
{settingsSaving && <Loader2 size={12} className="shrink-0 animate-spin text-rose-300" />}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
|
||||||
title="关闭"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3.5">
|
||||||
{/* 是否加载弹幕 */}
|
{/* 是否加载弹幕 */}
|
||||||
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
|
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
|
||||||
<span className="text-white/85">加载弹幕</span>
|
<span className="text-white/85">加载弹幕</span>
|
||||||
@@ -573,6 +576,7 @@ export function PlayerDanmakuPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
|
import { Check, Cloud, Film, Layers, Play, Search, X } from 'lucide-react'
|
||||||
|
|
||||||
import { imageURL } from '../api/client'
|
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
|
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
|
||||||
|
import {
|
||||||
|
isStrmMedia,
|
||||||
|
mediaVersionFileName,
|
||||||
|
mediaVersionLabel,
|
||||||
|
mediaVersionMatches,
|
||||||
|
mediaVersionSourceLabel,
|
||||||
|
} from '../utils/mediaVersion'
|
||||||
|
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER } from './playerTheme'
|
||||||
|
|
||||||
export type SeasonGroup = {
|
export type SeasonGroup = {
|
||||||
season: number
|
season: number
|
||||||
@@ -14,20 +21,40 @@ type PlayerPlaylistPanelProps = {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
currentMediaId: string
|
currentMediaId: string
|
||||||
|
/** 当前正在播放媒体的全部版本(含自身)。多版本时面板顶部展示版本切换。 */
|
||||||
|
currentVersions?: Media[]
|
||||||
episodes: Media[]
|
episodes: Media[]
|
||||||
onSelectEpisode: (media: Media) => void
|
onSelectEpisode: (media: Media) => void
|
||||||
|
/** 切换到同一集/同一条目的另一个版本。 */
|
||||||
|
onSelectVersion?: (media: Media) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选集面板 —— 右侧整条抽屉,内容参考 B 站的番剧选集。
|
||||||
|
*
|
||||||
|
* 以前这里是「悬浮卡片 + 每集一行缩略图」,两三百集的库翻起来很累,卡片本身
|
||||||
|
* 也和画面上别的浮层长得不一样。现在改成:整条贴住播放区右边缘的抽屉,剧集用
|
||||||
|
* 编号宫格排(一屏能看到几十集),标题/时长放在悬停提示里;当前播放的那一格用
|
||||||
|
* 强调色填充。
|
||||||
|
*
|
||||||
|
* 多版本(同一部片的 4K / 1080p / 云端直链等)依旧不丢:宫格右上角的小角标表示
|
||||||
|
* 这一集有多个版本,点角标会在宫格上方展开版本列表;当前正在播放的条目的版本
|
||||||
|
* 则常驻在抽屉顶部,随时可换。单条媒体(电影)没有剧集列表时,抽屉本身就是
|
||||||
|
* 版本选择器。
|
||||||
|
*/
|
||||||
export function PlayerPlaylistPanel({
|
export function PlayerPlaylistPanel({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
currentMediaId,
|
currentMediaId,
|
||||||
|
currentVersions = [],
|
||||||
episodes,
|
episodes,
|
||||||
onSelectEpisode,
|
onSelectEpisode,
|
||||||
|
onSelectVersion,
|
||||||
}: PlayerPlaylistPanelProps) {
|
}: PlayerPlaylistPanelProps) {
|
||||||
const [filterText, setFilterText] = useState('')
|
const [filterText, setFilterText] = useState('')
|
||||||
const activeItemRef = useRef<HTMLDivElement | null>(null)
|
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
|
||||||
const listContainerRef = useRef<HTMLDivElement | null>(null)
|
const [versionsForEpisodeId, setVersionsForEpisodeId] = useState<string | null>(null)
|
||||||
|
const activeItemRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
// 按季分组
|
// 按季分组
|
||||||
const seasonGroups = useMemo<SeasonGroup[]>(() => {
|
const seasonGroups = useMemo<SeasonGroup[]>(() => {
|
||||||
@@ -46,9 +73,9 @@ export function PlayerPlaylistPanel({
|
|||||||
.map(([season, list]) => ({ season, episodes: list }))
|
.map(([season, list]) => ({ season, episodes: list }))
|
||||||
}, [episodes])
|
}, [episodes])
|
||||||
|
|
||||||
// 当前播放所在季
|
// 当前播放所在季(播放中的可能是某个剧集行的非首个版本,需按版本组比对)
|
||||||
const currentSeason = useMemo(() => {
|
const currentSeason = useMemo(() => {
|
||||||
const found = episodes.find((e) => e.id === currentMediaId)
|
const found = episodes.find((e) => mediaVersionMatches(e, currentMediaId))
|
||||||
if (!found) return seasonGroups[0]?.season ?? 1
|
if (!found) return seasonGroups[0]?.season ?? 1
|
||||||
return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1)
|
return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1)
|
||||||
}, [episodes, currentMediaId, seasonGroups])
|
}, [episodes, currentMediaId, seasonGroups])
|
||||||
@@ -62,15 +89,19 @@ export function PlayerPlaylistPanel({
|
|||||||
}
|
}
|
||||||
}, [open, currentSeason])
|
}, [open, currentSeason])
|
||||||
|
|
||||||
|
// 换集/换季时收起版本列表:那张列表是针对上一集的,留着会让人误点。
|
||||||
|
useEffect(() => {
|
||||||
|
setVersionsForEpisodeId(null)
|
||||||
|
}, [open, selectedSeason, currentMediaId])
|
||||||
|
|
||||||
// 当面板打开时,自动平滑滚动到当前播放集的位置
|
// 当面板打开时,自动平滑滚动到当前播放集的位置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && activeItemRef.current) {
|
if (!open || versionsForEpisodeId) return
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
activeItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
activeItemRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
}, 100)
|
}, 100)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}, [open, selectedSeason, currentMediaId, versionsForEpisodeId])
|
||||||
}, [open, selectedSeason, currentMediaId])
|
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
@@ -86,35 +117,99 @@ export function PlayerPlaylistPanel({
|
|||||||
})
|
})
|
||||||
: listToDisplay
|
: listToDisplay
|
||||||
|
|
||||||
|
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
|
||||||
|
// 只有一集(电影/单条媒体)时,面板就是版本选择器,不再重复列出一行「剧集」。
|
||||||
|
const hasEpisodeList = episodes.length > 1
|
||||||
|
const versionsForEpisode = versionsForEpisodeId
|
||||||
|
? (listToDisplay.find((ep) => ep.id === versionsForEpisodeId)?.versions ?? [])
|
||||||
|
: []
|
||||||
|
const expandedEpisode = versionsForEpisodeId
|
||||||
|
? listToDisplay.find((ep) => ep.id === versionsForEpisodeId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute inset-x-3 top-12 bottom-3 z-30 flex flex-col overflow-hidden rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-20 sm:w-96"
|
// 舞台用 pointerdown/pointerup 判断「移动端轻触画面」,抽屉上的触摸不该参与。
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
|
className={`absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`}
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
<div className={PLAYER_PANEL_HEADER}>
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
|
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
|
||||||
<ListVideo size={17} className="text-rose-400" />
|
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
|
||||||
<span>选集列表</span>
|
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
|
||||||
<span className="font-mono text-xs font-normal text-white/50">
|
|
||||||
({episodes.length} 集)
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
|
||||||
title="关闭"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
|
||||||
|
{versionsToSwitch.length > 0 && (
|
||||||
|
<div className="shrink-0 border-b border-white/10 px-3 py-2.5">
|
||||||
|
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-white/55">
|
||||||
|
<Layers size={12} className="text-rose-400" />
|
||||||
|
<span>正在播放的版本</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-44 space-y-1 overflow-y-auto pr-0.5">
|
||||||
|
{versionsToSwitch.map((version) => (
|
||||||
|
<VersionOption
|
||||||
|
key={version.id}
|
||||||
|
version={version}
|
||||||
|
active={mediaVersionMatches(version, currentMediaId)}
|
||||||
|
onSelect={() => onSelectVersion?.(version)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 某一集的版本列表:从宫格角标展开,选完即收起 */}
|
||||||
|
{expandedEpisode && versionsForEpisode.length > 0 && (
|
||||||
|
<div className="shrink-0 border-b border-white/10 bg-white/[0.03] px-3 py-2.5">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-amber-200">
|
||||||
|
<Layers size={12} className="shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{expandedEpisode.episode_num > 0
|
||||||
|
? `第 ${expandedEpisode.episode_num} 集 · 选择版本`
|
||||||
|
: '选择版本'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setVersionsForEpisodeId(null)}
|
||||||
|
className="shrink-0 rounded p-0.5 text-white/45 transition hover:bg-white/10 hover:text-white"
|
||||||
|
title="收起版本列表"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-44 space-y-1 overflow-y-auto pr-0.5">
|
||||||
|
{versionsForEpisode.map((version) => (
|
||||||
|
<VersionOption
|
||||||
|
key={version.id}
|
||||||
|
version={version}
|
||||||
|
active={mediaVersionMatches(version, currentMediaId)}
|
||||||
|
compact
|
||||||
|
onSelect={() => {
|
||||||
|
onSelectVersion?.(version)
|
||||||
|
setVersionsForEpisodeId(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 季选择 Tabs(若有多季) */}
|
{/* 季选择 Tabs(若有多季) */}
|
||||||
{seasonGroups.length > 1 && (
|
{hasEpisodeList && seasonGroups.length > 1 && (
|
||||||
<div className="flex items-center gap-1.5 border-b border-white/10 px-3 py-2 shrink-0 overflow-x-auto no-scrollbar">
|
<div className="no-scrollbar flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
|
||||||
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
||||||
const isSelected = selectedSeason === season
|
const isSelected = selectedSeason === season
|
||||||
const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId)
|
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId))
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={season}
|
key={season}
|
||||||
@@ -140,22 +235,19 @@ export function PlayerPlaylistPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 搜索/过滤单集(当单集数量较多时) */}
|
{/* 搜索/过滤单集(当单集数量较多时) */}
|
||||||
{listToDisplay.length > 10 && (
|
{hasEpisodeList && listToDisplay.length > 10 && (
|
||||||
<div className="px-3 pt-2.5 pb-1.5 shrink-0">
|
<div className="shrink-0 px-3 pb-1 pt-2.5">
|
||||||
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs">
|
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs">
|
||||||
<Search size={13} className="text-white/40 shrink-0" />
|
<Search size={13} className="shrink-0 text-white/40" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={filterText}
|
value={filterText}
|
||||||
onChange={(e) => setFilterText(e.target.value)}
|
onChange={(e) => setFilterText(e.target.value)}
|
||||||
placeholder="搜索集数或标题…"
|
placeholder="搜索集数或标题…"
|
||||||
className="w-full bg-transparent outline-none placeholder:text-white/30 text-white text-xs"
|
className="w-full bg-transparent text-xs text-white outline-none placeholder:text-white/30"
|
||||||
/>
|
/>
|
||||||
{filterText && (
|
{filterText && (
|
||||||
<button
|
<button onClick={() => setFilterText('')} className="text-white/40 hover:text-white">
|
||||||
onClick={() => setFilterText('')}
|
|
||||||
className="text-white/40 hover:text-white"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -163,100 +255,146 @@ export function PlayerPlaylistPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 集数列表 */}
|
{/* 集数宫格 */}
|
||||||
<div
|
{hasEpisodeList ? (
|
||||||
ref={listContainerRef}
|
<div className="min-h-0 flex-1 overflow-y-auto p-3 select-none">
|
||||||
className="flex-1 overflow-y-auto p-2.5 space-y-1.5 pr-2 select-none"
|
{filteredEpisodes.length === 0 ? (
|
||||||
>
|
<div className="py-10 text-center text-xs text-white/40">
|
||||||
{filteredEpisodes.length === 0 ? (
|
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
||||||
<div className="py-8 text-center text-xs text-white/40">
|
</div>
|
||||||
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
) : (
|
||||||
</div>
|
<div className="grid grid-cols-5 gap-1.5 sm:grid-cols-6">
|
||||||
) : (
|
{filteredEpisodes.map((ep) => {
|
||||||
filteredEpisodes.map((ep) => {
|
const isPlaying = mediaVersionMatches(ep, currentMediaId)
|
||||||
const isPlaying = ep.id === currentMediaId
|
const versions = ep.versions && ep.versions.length > 1 ? ep.versions : []
|
||||||
const displayTitle = getEpisodeTitle(ep, listToDisplay)
|
const displayTitle = getEpisodeTitle(ep, listToDisplay)
|
||||||
const durationText =
|
const durationText =
|
||||||
ep.duration_sec > 0 ? `${Math.floor(ep.duration_sec / 60)} 分钟` : ''
|
ep.duration_sec > 0 ? `${Math.round(ep.duration_sec / 60)} 分钟` : ''
|
||||||
|
const cellTitle = [
|
||||||
|
ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : '',
|
||||||
|
displayTitle,
|
||||||
|
durationText,
|
||||||
|
versions.length > 0 ? `${versions.length} 个版本` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={ep.id} className="relative">
|
||||||
key={ep.id}
|
<button
|
||||||
ref={isPlaying ? activeItemRef : null}
|
ref={isPlaying ? activeItemRef : null}
|
||||||
onClick={() => onSelectEpisode(ep)}
|
onClick={() => onSelectEpisode(ep)}
|
||||||
className={`group flex cursor-pointer items-center gap-2.5 rounded-xl p-2 transition border ${
|
title={cellTitle}
|
||||||
isPlaying
|
className={`flex h-9 w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
|
||||||
? 'border-rose-500/60 bg-rose-500/20 text-white'
|
isPlaying
|
||||||
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/10 text-white/85'
|
? 'bg-rose-500 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.2)_inset]'
|
||||||
}`}
|
: 'bg-white/[0.06] text-white/80 hover:bg-white/15 hover:text-white'
|
||||||
>
|
|
||||||
{/* 封面/集号 */}
|
|
||||||
<div className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10 text-xs font-semibold">
|
|
||||||
{ep.backdrop_url || ep.poster_url ? (
|
|
||||||
<img
|
|
||||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
|
||||||
alt=""
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Film size={16} className="text-white/40" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 正在播放动效 / 集数徽标 */}
|
|
||||||
{isPlaying ? (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-xs">
|
|
||||||
<div className="flex items-end gap-0.5 h-3">
|
|
||||||
<span className="w-0.5 bg-rose-400 animate-pulse h-full" />
|
|
||||||
<span className="w-0.5 bg-rose-400 animate-pulse h-2" />
|
|
||||||
<span className="w-0.5 bg-rose-400 animate-pulse h-3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Play size={14} className="text-white fill-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 角标显示集数 */}
|
|
||||||
<span className="absolute bottom-0.5 right-1 rounded bg-black/75 px-1 py-0.2 text-[9px] font-mono text-white/90">
|
|
||||||
{ep.episode_num > 0 ? `${ep.episode_num}` : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标题 & 时长 */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<p
|
|
||||||
className={`truncate text-xs font-medium ${
|
|
||||||
isPlaying ? 'text-rose-300 font-semibold' : 'group-hover:text-white'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{displayTitle}
|
{isPlaying ? (
|
||||||
</p>
|
<Play size={12} className="fill-current" />
|
||||||
</div>
|
) : ep.episode_num > 0 ? (
|
||||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-white/50">
|
ep.episode_num
|
||||||
{ep.episode_num > 0 && (
|
) : (
|
||||||
<span className="font-mono">第 {ep.episode_num} 集</span>
|
<Film size={13} className="text-white/45" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* 有多个版本:点角标挑版本,点格子本身直接播放默认版本 */}
|
||||||
|
{versions.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setVersionsForEpisodeId((current) =>
|
||||||
|
current === ep.id ? null : ep.id,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className={`absolute right-0 top-0 flex h-3.5 w-3.5 items-center justify-center rounded-bl-md rounded-tr-md text-[9px] font-semibold leading-none transition ${
|
||||||
|
versionsForEpisodeId === ep.id
|
||||||
|
? 'bg-amber-300 text-black'
|
||||||
|
: 'bg-black/45 text-amber-200 hover:bg-black/70'
|
||||||
|
}`}
|
||||||
|
title={`${versions.length} 个版本,点这里选择`}
|
||||||
|
>
|
||||||
|
{versions.length}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
{durationText && <span>{durationText}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
})}
|
||||||
{isPlaying && (
|
</div>
|
||||||
<div className="shrink-0 flex items-center gap-1 text-[11px] font-medium text-rose-400 px-1">
|
)}
|
||||||
<Check size={13} />
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
versionsToSwitch.length === 0 && (
|
||||||
</div>
|
<div className="flex flex-1 items-center justify-center px-4 text-center text-xs text-white/40">
|
||||||
)
|
暂无可切换的内容
|
||||||
})
|
</div>
|
||||||
)}
|
)
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单个版本选项:主行是画质/容器/体积,次行是来源与文件名。 */
|
||||||
|
function VersionOption({
|
||||||
|
version,
|
||||||
|
active,
|
||||||
|
compact = false,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
version: Media
|
||||||
|
active: boolean
|
||||||
|
compact?: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
const cloud = isStrmMedia(version)
|
||||||
|
const label = mediaVersionLabel(version)
|
||||||
|
const fileName = mediaVersionFileName(version)
|
||||||
|
const sourceLabel = mediaVersionSourceLabel(version)
|
||||||
|
// 没有探测数据时(网盘 STRM 常见)主行本身就是文件名,次行再重复一遍没有意义。
|
||||||
|
const showFileName = fileName !== '' && !labelKey(label).includes(labelKey(fileName))
|
||||||
|
const activeClass = 'border-rose-500/50 bg-rose-500/15 text-white'
|
||||||
|
const idleClass =
|
||||||
|
'border-white/5 bg-white/5 text-white/85 hover:border-white/15 hover:bg-white/10'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={active}
|
||||||
|
onClick={onSelect}
|
||||||
|
title={version.path || version.strm_url || undefined}
|
||||||
|
className={`flex w-full items-start gap-2 rounded-lg border px-2.5 text-left transition disabled:cursor-default ${
|
||||||
|
compact ? 'py-1.5' : 'py-2'
|
||||||
|
} ${active ? activeClass : idleClass}`}
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 shrink-0">
|
||||||
|
{cloud ? (
|
||||||
|
<Cloud size={13} className={active ? 'text-rose-300' : 'text-white/45'} />
|
||||||
|
) : (
|
||||||
|
<Film size={13} className={active ? 'text-rose-300' : 'text-white/45'} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className={`block truncate text-xs font-medium ${active ? 'text-rose-200' : 'text-white/90'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate text-[10px] text-white/45">
|
||||||
|
{sourceLabel}
|
||||||
|
{showFileName ? ` · ${fileName}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{active && <Check size={13} className="mt-0.5 shrink-0 text-rose-400" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化后比较:忽略大小写与分隔符(. _ - 空格),用于判断两段文案是否只是重复。 */
|
||||||
|
function labelKey(value: string): string {
|
||||||
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
function getEpisodeTitle(ep: Media, siblings: Media[]): string {
|
function getEpisodeTitle(ep: Media, siblings: Media[]): string {
|
||||||
const title = ep.episode_title?.trim()
|
const title = ep.episode_title?.trim()
|
||||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||||
|
|||||||
@@ -0,0 +1,690 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
||||||
|
import { Compass, LogOut, Minus, Plus, RotateCcw, Settings2, Smartphone } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
PLAYER_ICON_BUTTON,
|
||||||
|
PLAYER_SEGMENT,
|
||||||
|
PLAYER_SEGMENT_GROUP,
|
||||||
|
PLAYER_SEGMENT_OFF,
|
||||||
|
PLAYER_SEGMENT_ON,
|
||||||
|
PLAYER_TEXT_BUTTON,
|
||||||
|
} from './playerTheme'
|
||||||
|
import {
|
||||||
|
VR360_DEFAULT_FOV,
|
||||||
|
VR360_MAX_FOV,
|
||||||
|
VR360_MIN_FOV,
|
||||||
|
VR360_PROJECTION_OPTIONS,
|
||||||
|
VR360_STEREO_OPTIONS,
|
||||||
|
vr360FrameUv,
|
||||||
|
type Vr360Profile,
|
||||||
|
} from '../utils/vr360'
|
||||||
|
import {
|
||||||
|
applyViewOffset,
|
||||||
|
cameraForward,
|
||||||
|
cameraFromDeviceOrientation,
|
||||||
|
cameraFromYawPitch,
|
||||||
|
clampNumber,
|
||||||
|
degToRad,
|
||||||
|
forwardPitch,
|
||||||
|
forwardYaw,
|
||||||
|
viewMatrixFromCamera,
|
||||||
|
type Mat3,
|
||||||
|
} from '../utils/vr360Math'
|
||||||
|
// 只引入类型:渲染器本体走动态 import,普通播放不会下载 WebGL 全景渲染代码。
|
||||||
|
import type { Vr360Renderer } from '../utils/vr360Renderer'
|
||||||
|
|
||||||
|
// 全景播放舞台:一个覆盖整个播放区域的 WebGL 画布。视频元素本身仍在后台正常
|
||||||
|
// 播放(音轨、进度、弹幕、字幕都不受影响),只是画面被贴到了球面上。
|
||||||
|
//
|
||||||
|
// 视角控制:
|
||||||
|
// 1. 鼠标/手指拖拽 → 转动视角(画面跟随手指),滚轮/双指捏合 → 变焦;
|
||||||
|
// 2. 手机陀螺仪 → 转动设备即转动视角。iOS 13+ 必须在用户手势里调用
|
||||||
|
// DeviceOrientationEvent.requestPermission,所以这里做成显式按钮。
|
||||||
|
|
||||||
|
type ViewState = {
|
||||||
|
/** 拖拽模式下的方位角(弧度,0 = 默认正前方)。 */
|
||||||
|
yaw: number
|
||||||
|
/** 拖拽模式下的俯仰角(弧度,+ 为抬头)。 */
|
||||||
|
pitch: number
|
||||||
|
fov: number
|
||||||
|
/** 陀螺仪是否接管视角。 */
|
||||||
|
gyroActive: boolean
|
||||||
|
/** 最近一次设备姿态对应的相机旋转矩阵。 */
|
||||||
|
deviceCamera: Mat3 | null
|
||||||
|
gyroYawOffset: number
|
||||||
|
gyroPitchOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type PointerState = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
startAt: number
|
||||||
|
moved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type GyroState = 'off' | 'pending' | 'active' | 'denied' | 'unsupported' | 'unavailable'
|
||||||
|
|
||||||
|
type Vr360StageProps = {
|
||||||
|
videoRef: RefObject<HTMLVideoElement>
|
||||||
|
profile: Vr360Profile
|
||||||
|
/** 控制栏是否可见:不可见时隐藏 VR 自身的浮层按钮。 */
|
||||||
|
uiVisible: boolean
|
||||||
|
/** 轻触画面(未发生拖拽)时回调,与普通播放一致:唤出控制栏或切换播放。 */
|
||||||
|
onSurfaceTap: () => void
|
||||||
|
/**
|
||||||
|
* 第一帧真正画出来时回调,每次挂载只上报一次(改投影方式/画幅布局不会重新上报),
|
||||||
|
* 调用方据此结束「正在启动」提示。
|
||||||
|
*/
|
||||||
|
onReady: () => void
|
||||||
|
onError: (message: string) => void
|
||||||
|
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
|
||||||
|
onProfileChange?: (profile: Vr360Profile) => void
|
||||||
|
/** 退出 VR 全景播放(回到普通播放)。不提供时工具条上不显示退出入口。 */
|
||||||
|
onExitVr?: () => void
|
||||||
|
/**
|
||||||
|
* 鼠标是否停留在 VR 工具条/设置面板上。由播放页转给控制栏:悬停这些浮层时
|
||||||
|
* 控制栏不应该自动隐藏(否则设置面板会在点击前消失)。
|
||||||
|
*/
|
||||||
|
onUiHoldChange?: (hold: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每像素拖拽对应的转角(弧度)。视场角越小时画面越「拉近」,拖拽也应更细腻。 */
|
||||||
|
const DRAG_RAD_PER_PIXEL = 0.0035
|
||||||
|
const TAP_MAX_DISTANCE = 8
|
||||||
|
const TAP_MAX_DURATION_MS = 400
|
||||||
|
const GYRO_PROBE_MS = 1500
|
||||||
|
const PITCH_LIMIT = Math.PI / 2 - 0.03
|
||||||
|
const WHEEL_ZOOM_SPEED = 0.0015
|
||||||
|
|
||||||
|
function screenOrientationAngle(): number {
|
||||||
|
if (
|
||||||
|
typeof screen !== 'undefined' &&
|
||||||
|
screen.orientation &&
|
||||||
|
typeof screen.orientation.angle === 'number'
|
||||||
|
) {
|
||||||
|
return screen.orientation.angle
|
||||||
|
}
|
||||||
|
const legacy = (window as unknown as { orientation?: number }).orientation
|
||||||
|
return typeof legacy === 'number' ? legacy : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前设备是否没有真正的鼠标悬停能力(手机/平板)。
|
||||||
|
*
|
||||||
|
* 触摸设备上「悬停保持显示」这套机制本身就是错的:手指抬起后浏览器不保证补发
|
||||||
|
* mouseleave,一旦走进悬停状态标记就再也清不掉,控制栏和 VR 工具条会永久留在
|
||||||
|
* 画面上。这里按设备能力判断,而不是去猜某个鼠标事件是不是触摸合成的——
|
||||||
|
* 后者依赖 MouseEvent.sourceCapabilities,实测在不少浏览器里是 null,判断会
|
||||||
|
* 静默失效、等于没修。
|
||||||
|
*/
|
||||||
|
function isHoverlessDevice(): boolean {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true
|
||||||
|
return window.matchMedia('(hover: none)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceOrientationEventConstructor = {
|
||||||
|
requestPermission?: () => Promise<'granted' | 'denied' | 'default'>
|
||||||
|
}
|
||||||
|
|
||||||
|
function gyroStateHint(state: GyroState): string {
|
||||||
|
if (state === 'active') return '陀螺仪已开启:转动设备即可改变视角'
|
||||||
|
if (state === 'pending') return '正在等待陀螺仪数据…'
|
||||||
|
if (state === 'denied') return '陀螺仪权限被拒绝,请在浏览器设置里允许「运动与方向」访问'
|
||||||
|
if (state === 'unavailable') return '没有收到陀螺仪数据,当前设备可能不支持'
|
||||||
|
if (state === 'unsupported') return '当前浏览器不支持陀螺仪'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Vr360Stage({
|
||||||
|
videoRef,
|
||||||
|
profile,
|
||||||
|
uiVisible,
|
||||||
|
onSurfaceTap,
|
||||||
|
onReady,
|
||||||
|
onError,
|
||||||
|
onProfileChange,
|
||||||
|
onExitVr,
|
||||||
|
onUiHoldChange,
|
||||||
|
}: Vr360StageProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const rendererRef = useRef<Vr360Renderer | null>(null)
|
||||||
|
const viewRef = useRef<ViewState>({
|
||||||
|
yaw: 0,
|
||||||
|
pitch: 0,
|
||||||
|
fov: VR360_DEFAULT_FOV,
|
||||||
|
gyroActive: false,
|
||||||
|
deviceCamera: null,
|
||||||
|
gyroYawOffset: 0,
|
||||||
|
gyroPitchOffset: 0,
|
||||||
|
})
|
||||||
|
const dirtyRef = useRef(true)
|
||||||
|
const readyRef = useRef(false)
|
||||||
|
const pointersRef = useRef(new Map<number, PointerState>())
|
||||||
|
const pinchDistanceRef = useRef<number | null>(null)
|
||||||
|
const alignmentRef = useRef<{ yaw: number; pitch: number } | null>(null)
|
||||||
|
const gyroProbeRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const absoluteSeenRef = useRef(false)
|
||||||
|
const profileRef = useRef(profile)
|
||||||
|
const [gyroState, setGyroState] = useState<GyroState>('off')
|
||||||
|
const [fovHint, setFovHint] = useState(VR360_DEFAULT_FOV)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
|
// 回调放进 ref:渲染器只在挂载时创建一次,不因父组件重渲染而重建。
|
||||||
|
const callbacksRef = useRef({ onSurfaceTap, onReady, onError })
|
||||||
|
useEffect(() => {
|
||||||
|
callbacksRef.current = { onSurfaceTap, onReady, onError }
|
||||||
|
}, [onSurfaceTap, onReady, onError])
|
||||||
|
|
||||||
|
const markDirty = useCallback(() => {
|
||||||
|
dirtyRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 当前相机姿态:陀螺仪接管时以设备姿态为基准叠加拖拽偏移,否则纯拖拽角度。
|
||||||
|
const currentCamera = useCallback((): Mat3 => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (view.gyroActive && view.deviceCamera) {
|
||||||
|
return applyViewOffset(view.deviceCamera, view.gyroYawOffset, view.gyroPitchOffset)
|
||||||
|
}
|
||||||
|
return cameraFromYawPitch(view.yaw, view.pitch)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const renderer = rendererRef.current
|
||||||
|
if (!renderer) return
|
||||||
|
renderer.setView(viewMatrixFromCamera(currentCamera()))
|
||||||
|
renderer.setFov(viewRef.current.fov)
|
||||||
|
renderer.draw()
|
||||||
|
if (!readyRef.current) {
|
||||||
|
// 第一帧真正画出来之后才通知上层:渲染失败时上层会退出 VR 模式。
|
||||||
|
readyRef.current = true
|
||||||
|
callbacksRef.current.onReady()
|
||||||
|
}
|
||||||
|
}, [currentCamera])
|
||||||
|
|
||||||
|
const applyProfile = useCallback((renderer: Vr360Renderer, next: Vr360Profile) => {
|
||||||
|
renderer.setProjection(next.projection)
|
||||||
|
const { scale, offset } = vr360FrameUv(next.stereo)
|
||||||
|
renderer.setFrameUv(scale, offset)
|
||||||
|
dirtyRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 创建渲染器(懒加载 WebGL 模块,普通播放不会下载全景渲染代码)。
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!canvas || !video) return
|
||||||
|
let cancelled = false
|
||||||
|
let created: Vr360Renderer | null = null
|
||||||
|
|
||||||
|
void import('../utils/vr360Renderer')
|
||||||
|
.then((module) => {
|
||||||
|
if (cancelled) return
|
||||||
|
created = module.createVr360Renderer(canvas, video, {
|
||||||
|
onContextLost: () => callbacksRef.current.onError('VR 渲染上下文已丢失,已退出 VR 播放'),
|
||||||
|
onTextureError: () => callbacksRef.current.onError('无法读取视频画面用于 VR 渲染'),
|
||||||
|
})
|
||||||
|
if (!created) {
|
||||||
|
callbacksRef.current.onError('当前浏览器不支持 WebGL,无法使用 VR 播放')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rendererRef.current = created
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
created.resize(rect.width, rect.height)
|
||||||
|
applyProfile(created, profileRef.current)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) callbacksRef.current.onError('VR 渲染组件加载失败')
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
rendererRef.current = null
|
||||||
|
created?.dispose()
|
||||||
|
}
|
||||||
|
}, [applyProfile, videoRef])
|
||||||
|
|
||||||
|
// 投影方式/立体布局变化时重建网格与 UV 裁切。
|
||||||
|
useEffect(() => {
|
||||||
|
profileRef.current = profile
|
||||||
|
const renderer = rendererRef.current
|
||||||
|
if (renderer) applyProfile(renderer, profile)
|
||||||
|
}, [applyProfile, profile])
|
||||||
|
|
||||||
|
// 画布尺寸跟随舞台(窗口缩放、全屏切换、首次布局)。
|
||||||
|
// 观测父容器而不是画布自身:画布尺寸由 CSS 撑满父容器,观测自身会在
|
||||||
|
// 「设置绘制缓冲区分辨率 → 布局变化 → 再次触发观测」之间来回放大。
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const target = canvas.parentElement ?? canvas
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
if (!entry) return
|
||||||
|
rendererRef.current?.resize(entry.contentRect.width, entry.contentRect.height)
|
||||||
|
markDirty()
|
||||||
|
})
|
||||||
|
observer.observe(target)
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
rendererRef.current?.resize(rect.width, rect.height)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [markDirty])
|
||||||
|
|
||||||
|
// 渲染循环:requestVideoFrameCallback 标记「有新画面」,rAF 按需绘制。
|
||||||
|
// 暂停且无输入时不出图,避免持续占用 GPU。
|
||||||
|
useEffect(() => {
|
||||||
|
let handle = 0
|
||||||
|
let cancelled = false
|
||||||
|
const video = videoRef.current as
|
||||||
|
| (HTMLVideoElement & { requestVideoFrameCallback?: (callback: () => void) => number })
|
||||||
|
| null
|
||||||
|
|
||||||
|
if (video && typeof video.requestVideoFrameCallback === 'function') {
|
||||||
|
const onVideoFrame = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
dirtyRef.current = true
|
||||||
|
video.requestVideoFrameCallback?.(onVideoFrame)
|
||||||
|
}
|
||||||
|
video.requestVideoFrameCallback(onVideoFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
handle = requestAnimationFrame(tick)
|
||||||
|
if (!dirtyRef.current) return
|
||||||
|
dirtyRef.current = false
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
handle = requestAnimationFrame(tick)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
cancelAnimationFrame(handle)
|
||||||
|
}
|
||||||
|
}, [draw, videoRef])
|
||||||
|
|
||||||
|
// 设备姿态 → 相机姿态。第一帧有效数据到达时把视角对齐过去,避免画面跳动。
|
||||||
|
const handleOrientation = useCallback((event: DeviceOrientationEvent) => {
|
||||||
|
if (event.alpha === null && event.beta === null && event.gamma === null) return
|
||||||
|
if (event.type === 'deviceorientationabsolute') absoluteSeenRef.current = true
|
||||||
|
// 同时收到绝对与相对事件时以绝对朝向为准(方位角才有意义)。
|
||||||
|
else if (absoluteSeenRef.current) return
|
||||||
|
|
||||||
|
const camera = cameraFromDeviceOrientation(
|
||||||
|
degToRad(event.alpha ?? 0),
|
||||||
|
degToRad(event.beta ?? 0),
|
||||||
|
degToRad(event.gamma ?? 0),
|
||||||
|
degToRad(screenOrientationAngle()),
|
||||||
|
)
|
||||||
|
const view = viewRef.current
|
||||||
|
view.deviceCamera = camera
|
||||||
|
|
||||||
|
const alignment = alignmentRef.current
|
||||||
|
if (alignment) {
|
||||||
|
const forward = cameraForward(camera)
|
||||||
|
view.gyroYawOffset = alignment.yaw - forwardYaw(forward)
|
||||||
|
view.gyroPitchOffset = clampNumber(alignment.pitch - forwardPitch(forward), -1.2, 1.2)
|
||||||
|
alignmentRef.current = null
|
||||||
|
if (gyroProbeRef.current) {
|
||||||
|
clearTimeout(gyroProbeRef.current)
|
||||||
|
gyroProbeRef.current = null
|
||||||
|
}
|
||||||
|
setGyroState('active')
|
||||||
|
}
|
||||||
|
|
||||||
|
view.gyroActive = true
|
||||||
|
dirtyRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopGyro = useCallback(() => {
|
||||||
|
window.removeEventListener('deviceorientation', handleOrientation)
|
||||||
|
window.removeEventListener('deviceorientationabsolute', handleOrientation)
|
||||||
|
if (gyroProbeRef.current) {
|
||||||
|
clearTimeout(gyroProbeRef.current)
|
||||||
|
gyroProbeRef.current = null
|
||||||
|
}
|
||||||
|
alignmentRef.current = null
|
||||||
|
absoluteSeenRef.current = false
|
||||||
|
}, [handleOrientation])
|
||||||
|
|
||||||
|
const startGyro = useCallback(() => {
|
||||||
|
alignmentRef.current = { yaw: viewRef.current.yaw, pitch: viewRef.current.pitch }
|
||||||
|
absoluteSeenRef.current = false
|
||||||
|
window.addEventListener('deviceorientation', handleOrientation)
|
||||||
|
window.addEventListener('deviceorientationabsolute', handleOrientation)
|
||||||
|
// 桌面浏览器/无陀螺仪设备会一直收不到事件,超时后提示并回退到拖拽模式。
|
||||||
|
gyroProbeRef.current = setTimeout(() => {
|
||||||
|
gyroProbeRef.current = null
|
||||||
|
if (alignmentRef.current) {
|
||||||
|
stopGyro()
|
||||||
|
setGyroState('unavailable')
|
||||||
|
}
|
||||||
|
}, GYRO_PROBE_MS)
|
||||||
|
}, [handleOrientation, stopGyro])
|
||||||
|
|
||||||
|
const toggleGyro = useCallback(() => {
|
||||||
|
if (gyroState === 'active' || gyroState === 'pending') {
|
||||||
|
// 关闭陀螺仪时用当前合成视角续上,画面不会跳。
|
||||||
|
const forward = cameraForward(currentCamera())
|
||||||
|
viewRef.current.yaw = forwardYaw(forward)
|
||||||
|
viewRef.current.pitch = clampNumber(forwardPitch(forward), -PITCH_LIMIT, PITCH_LIMIT)
|
||||||
|
viewRef.current.gyroActive = false
|
||||||
|
viewRef.current.gyroYawOffset = 0
|
||||||
|
viewRef.current.gyroPitchOffset = 0
|
||||||
|
stopGyro()
|
||||||
|
setGyroState('off')
|
||||||
|
markDirty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const orientationEvent = (
|
||||||
|
window as unknown as { DeviceOrientationEvent?: DeviceOrientationEventConstructor }
|
||||||
|
).DeviceOrientationEvent
|
||||||
|
if (!orientationEvent) {
|
||||||
|
setGyroState('unsupported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGyroState('pending')
|
||||||
|
const requestPermission = orientationEvent.requestPermission
|
||||||
|
if (typeof requestPermission !== 'function') {
|
||||||
|
startGyro()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// iOS 13+ 必须在用户手势里申请权限,这里由按钮点击直接触发。
|
||||||
|
void requestPermission
|
||||||
|
.call(orientationEvent)
|
||||||
|
.then((result) => {
|
||||||
|
if (result === 'granted') startGyro()
|
||||||
|
else setGyroState('denied')
|
||||||
|
})
|
||||||
|
.catch(() => setGyroState('denied'))
|
||||||
|
}, [currentCamera, gyroState, markDirty, startGyro, stopGyro])
|
||||||
|
|
||||||
|
useEffect(() => () => stopGyro(), [stopGyro])
|
||||||
|
|
||||||
|
// 控制栏/浮层隐藏时收起「画面」设置面板:它属于同一套浮层,下次唤出控制栏时
|
||||||
|
// 不应该自动弹回来挡住画面。
|
||||||
|
useEffect(() => {
|
||||||
|
if (uiVisible) return
|
||||||
|
setSettingsOpen(false)
|
||||||
|
// 浮层不可见时鼠标离开事件不会再触发,这里主动解除「悬停保持显示」。
|
||||||
|
onUiHoldChange?.(false)
|
||||||
|
}, [uiVisible, onUiHoldChange])
|
||||||
|
|
||||||
|
const zoomBy = useCallback(
|
||||||
|
(factor: number) => {
|
||||||
|
const view = viewRef.current
|
||||||
|
view.fov = clampNumber(view.fov * factor, VR360_MIN_FOV, VR360_MAX_FOV)
|
||||||
|
setFovHint(view.fov)
|
||||||
|
markDirty()
|
||||||
|
},
|
||||||
|
[markDirty],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetView = useCallback(() => {
|
||||||
|
const view = viewRef.current
|
||||||
|
view.yaw = 0
|
||||||
|
view.pitch = 0
|
||||||
|
view.fov = VR360_DEFAULT_FOV
|
||||||
|
view.gyroYawOffset = 0
|
||||||
|
view.gyroPitchOffset = 0
|
||||||
|
setFovHint(view.fov)
|
||||||
|
markDirty()
|
||||||
|
}, [markDirty])
|
||||||
|
|
||||||
|
const pinchDistance = useCallback((): number => {
|
||||||
|
const pointers = [...pointersRef.current.values()]
|
||||||
|
if (pointers.length < 2) return 0
|
||||||
|
return Math.hypot(pointers[0].x - pointers[1].x, pointers[0].y - pointers[1].y)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
// 轻触画面把视角拖动之外的浮层悬停状态清掉:触摸设备不会再发出 mouseleave,
|
||||||
|
// 上一步点击工具条/设置面板留下的「悬停保持显示」会一直挂着,控制栏就永远
|
||||||
|
// 不隐藏了。用户已经在点画面,说明指针早已离开浮层。
|
||||||
|
if (event.pointerType === 'touch') onUiHoldChange?.(false)
|
||||||
|
try {
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||||
|
} catch {
|
||||||
|
// 某些环境(合成事件、非活跃指针)会抛 InvalidPointerId,退化为普通拖拽即可。
|
||||||
|
}
|
||||||
|
pointersRef.current.set(event.pointerId, {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
startAt: Date.now(),
|
||||||
|
moved: false,
|
||||||
|
})
|
||||||
|
if (pointersRef.current.size === 2) {
|
||||||
|
pinchDistanceRef.current = pinchDistance()
|
||||||
|
// 双指手势一律不算轻触,避免捏合缩放时误触发播放/暂停。
|
||||||
|
for (const pointer of pointersRef.current.values()) pointer.moved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const pointer = pointersRef.current.get(event.pointerId)
|
||||||
|
if (!pointer) return
|
||||||
|
const dx = event.clientX - pointer.x
|
||||||
|
const dy = event.clientY - pointer.y
|
||||||
|
pointer.x = event.clientX
|
||||||
|
pointer.y = event.clientY
|
||||||
|
if (
|
||||||
|
Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY) > TAP_MAX_DISTANCE
|
||||||
|
) {
|
||||||
|
pointer.moved = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointersRef.current.size >= 2) {
|
||||||
|
const distance = pinchDistance()
|
||||||
|
const previous = pinchDistanceRef.current
|
||||||
|
pinchDistanceRef.current = distance
|
||||||
|
if (previous && distance > 0) zoomBy(previous / distance)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = viewRef.current
|
||||||
|
const scale = DRAG_RAD_PER_PIXEL * (view.fov / VR360_DEFAULT_FOV)
|
||||||
|
if (view.gyroActive && view.deviceCamera) {
|
||||||
|
view.gyroYawOffset -= dx * scale
|
||||||
|
view.gyroPitchOffset = clampNumber(
|
||||||
|
view.gyroPitchOffset + dy * scale,
|
||||||
|
-PITCH_LIMIT,
|
||||||
|
PITCH_LIMIT,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
view.yaw -= dx * scale
|
||||||
|
view.pitch = clampNumber(view.pitch + dy * scale, -PITCH_LIMIT, PITCH_LIMIT)
|
||||||
|
}
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
const endPointer = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const pointer = pointersRef.current.get(event.pointerId)
|
||||||
|
pointersRef.current.delete(event.pointerId)
|
||||||
|
if (pointersRef.current.size < 2) pinchDistanceRef.current = null
|
||||||
|
if (!pointer) return
|
||||||
|
const isTap =
|
||||||
|
!pointer.moved &&
|
||||||
|
Date.now() - pointer.startAt <= TAP_MAX_DURATION_MS &&
|
||||||
|
pointersRef.current.size === 0
|
||||||
|
if (isTap) onSurfaceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const onWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
zoomBy(1 + event.deltaY * WHEEL_ZOOM_SPEED)
|
||||||
|
}
|
||||||
|
canvas.addEventListener('wheel', onWheel, { passive: false })
|
||||||
|
return () => canvas.removeEventListener('wheel', onWheel)
|
||||||
|
}, [zoomBy])
|
||||||
|
|
||||||
|
const hint = gyroStateHint(gyroState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
data-vr360-surface
|
||||||
|
className={`absolute inset-0 h-full w-full touch-none select-none ${
|
||||||
|
// 浮层隐藏时连鼠标指针一起藏起来:全景画面里光标一直悬在画面中间很出戏。
|
||||||
|
uiVisible ? 'cursor-grab active:cursor-grabbing' : 'cursor-none'
|
||||||
|
}`}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={endPointer}
|
||||||
|
onPointerCancel={endPointer}
|
||||||
|
// 轻触由指针逻辑自己处理(拖动时不能误触发播放/暂停),
|
||||||
|
// 同时阻止冒泡到舞台的点击逻辑,避免一次触摸切换两次播放状态。
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
// 阻止浮层内按钮/面板的点击冒泡到舞台的 handleSurfaceActivate:否则点击
|
||||||
|
// 投影方式/画幅布局等设置项会被误判为「单击收起控制栏」,面板在切换后立即消失。
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
className={`pointer-events-none absolute inset-x-0 top-2 z-20 flex flex-col items-center gap-1.5 px-2 transition-opacity duration-300 ${
|
||||||
|
uiVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center justify-center gap-0.5 rounded-xl border border-white/10 bg-[#0d0e12]/90 p-1 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl ${
|
||||||
|
// 浮层隐藏时必须彻底不接收指针事件:否则看不见的按钮既会在画面顶部
|
||||||
|
// 抢走拖动转视角的手势,也可能被误点(例如误开陀螺仪、误开设置面板)。
|
||||||
|
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||||
|
}`}
|
||||||
|
// 触摸设备不参与「悬停保持显示」:手指抬起后不会补发 mouseleave,
|
||||||
|
// 标记会永久挂在打开状态,控制栏和 VR 工具条就再也不自动隐藏。
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (isHoverlessDevice()) return
|
||||||
|
onUiHoldChange?.(true)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleGyro}
|
||||||
|
className={`${PLAYER_TEXT_BUTTON} ${
|
||||||
|
gyroState === 'active' ? 'bg-rose-500 text-white hover:bg-rose-500' : ''
|
||||||
|
}`}
|
||||||
|
title="用手机陀螺仪转动视角"
|
||||||
|
>
|
||||||
|
<Smartphone size={14} />
|
||||||
|
陀螺仪
|
||||||
|
{gyroState === 'active' && <Compass size={12} className="text-white/85" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetView}
|
||||||
|
className={PLAYER_TEXT_BUTTON}
|
||||||
|
title="恢复默认视角与视场角"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSettingsOpen((open) => !open)}
|
||||||
|
className={`${PLAYER_TEXT_BUTTON} ${settingsOpen ? 'bg-white/15 text-white' : ''}`}
|
||||||
|
title="投影方式与画幅布局"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
画面
|
||||||
|
</button>
|
||||||
|
<div className="mx-0.5 h-5 w-px bg-white/10" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomBy(1.15)}
|
||||||
|
className={PLAYER_ICON_BUTTON}
|
||||||
|
title="视野拉远"
|
||||||
|
>
|
||||||
|
<Minus size={15} />
|
||||||
|
</button>
|
||||||
|
<span className="w-9 text-center text-[10px] tabular-nums text-white/70">
|
||||||
|
{(VR360_DEFAULT_FOV / fovHint).toFixed(1)}×
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomBy(1 / 1.15)}
|
||||||
|
className={PLAYER_ICON_BUTTON}
|
||||||
|
title="视野拉近"
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
</button>
|
||||||
|
{onExitVr ? (
|
||||||
|
<>
|
||||||
|
<div className="mx-0.5 h-5 w-px bg-white/10" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExitVr}
|
||||||
|
className={`${PLAYER_TEXT_BUTTON} hover:bg-rose-500/20 hover:text-rose-200`}
|
||||||
|
title="退出 VR 全景,回到普通播放"
|
||||||
|
>
|
||||||
|
<LogOut size={14} />
|
||||||
|
退出 VR
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="pointer-events-none rounded-md bg-black/45 px-3 py-1 text-[10px] text-white/65 backdrop-blur">
|
||||||
|
{hint || '拖动旋转视角 · 滚轮或双指缩放'}
|
||||||
|
</p>
|
||||||
|
{settingsOpen ? (
|
||||||
|
<div
|
||||||
|
className={`w-[min(92vw,420px)] rounded-xl border border-white/10 bg-[#0d0e12]/95 px-3 py-3 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl ${
|
||||||
|
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (isHoverlessDevice()) return
|
||||||
|
onUiHoldChange?.(true)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||||
|
>
|
||||||
|
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/40">投影方式</p>
|
||||||
|
<div className={PLAYER_SEGMENT_GROUP}>
|
||||||
|
{VR360_PROJECTION_OPTIONS.map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onProfileChange?.({ ...profile, projection: value })}
|
||||||
|
className={`${PLAYER_SEGMENT} ${
|
||||||
|
profile.projection === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mb-1.5 mt-3 text-[10px] uppercase tracking-wide text-white/40">
|
||||||
|
画幅布局
|
||||||
|
</p>
|
||||||
|
<div className={PLAYER_SEGMENT_GROUP}>
|
||||||
|
{VR360_STEREO_OPTIONS.map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onProfileChange?.({ ...profile, stereo: value })}
|
||||||
|
className={`${PLAYER_SEGMENT} ${
|
||||||
|
profile.stereo === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{profile.stereo !== 'mono' && (
|
||||||
|
<p className="mt-2.5 text-[10px] leading-relaxed text-white/45">
|
||||||
|
左右/上下并排素材在当前屏幕上只显示左眼画面;真正的双眼立体画面需要 VR
|
||||||
|
头显(WebXR)。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 播放器浮层的统一视觉语言。
|
||||||
|
*
|
||||||
|
* 播放器里现在有十几种浮层:底部操作栏、清晰度/倍速/字幕小弹层、设置面板、
|
||||||
|
* 选集抽屉、弹幕面板、VR 工具条、错误提示……以前每一处都自己写一遍圆角、
|
||||||
|
* 边框、透明度,于是同一屏里出现了好几种不同的浮层样式,看起来就「乱」。
|
||||||
|
*
|
||||||
|
* 这里把冒泡、排版、图标按钮等重复的类名收敛成常量:改一次全部同步,
|
||||||
|
* 新加控件也不会再各写一套。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 面板底色。比纯黑更「实」,配合 backdrop-blur 后压在画面上仍然清晰可读。 */
|
||||||
|
const PANEL_BG = 'bg-[#0d0e12]/95'
|
||||||
|
|
||||||
|
/** 浮层外圈阴影:暗背景下用大范围柔光,避免出现一圈生硬的黑边。 */
|
||||||
|
const PANEL_SHADOW = 'shadow-[0_10px_38px_rgba(0,0,0,0.6)]'
|
||||||
|
|
||||||
|
/** 小弹层(清晰度/倍速/字幕/设置):贴着操作栏向上弹出。 */
|
||||||
|
export const PLAYER_POPOVER =
|
||||||
|
`z-40 overflow-hidden rounded-xl border border-white/10 ${PANEL_BG} py-1 text-white ${PANEL_SHADOW} backdrop-blur-xl`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右侧抽屉(选集/弹幕设置):整条贴住播放区域右边缘,通到顶底。
|
||||||
|
*
|
||||||
|
* 小屏上留出 12% 的宽度而不是铺满:这样顶栏的返回按钮和标题仍然露在外面,
|
||||||
|
* 用户不用先关抽屉才能返回(铺满时唯一的出口只剩标题栏上的关闭按钮)。
|
||||||
|
*/
|
||||||
|
export const PLAYER_DRAWER =
|
||||||
|
`flex h-full w-[88%] flex-col border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl sm:w-[380px] sm:border-l`
|
||||||
|
|
||||||
|
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
|
||||||
|
export const PLAYER_ICON_BUTTON =
|
||||||
|
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:w-8'
|
||||||
|
|
||||||
|
/** 带文字的按钮(清晰度/倍速/选集)。文字按钮和图标按钮同高,基线才对得齐。 */
|
||||||
|
export const PLAYER_TEXT_BUTTON =
|
||||||
|
'flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:px-2 sm:text-xs'
|
||||||
|
|
||||||
|
/** 弹层/面板里的一行选项。 */
|
||||||
|
export const PLAYER_MENU_ITEM =
|
||||||
|
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs text-white/85 transition hover:bg-white/10 hover:text-white'
|
||||||
|
|
||||||
|
/** 弹层里的小标题(「115 云端」「本地 HLS」这类分组名)。 */
|
||||||
|
export const PLAYER_MENU_LABEL =
|
||||||
|
'px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/35'
|
||||||
|
|
||||||
|
/** 分组之间的分隔线。 */
|
||||||
|
export const PLAYER_MENU_DIVIDER = 'my-1 h-px bg-white/10'
|
||||||
|
|
||||||
|
/** 抽屉/面板的标题栏。 */
|
||||||
|
export const PLAYER_PANEL_HEADER =
|
||||||
|
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-3'
|
||||||
|
|
||||||
|
/** 分段选择(播放方式、投影方式这类互斥选项)。 */
|
||||||
|
export const PLAYER_SEGMENT_GROUP = 'flex items-center gap-1 rounded-lg bg-white/5 p-0.5'
|
||||||
|
export const PLAYER_SEGMENT = 'flex-1 rounded-md px-2 py-1 text-[11px] font-medium transition'
|
||||||
|
export const PLAYER_SEGMENT_ON = 'bg-rose-500 text-white'
|
||||||
|
export const PLAYER_SEGMENT_OFF = 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||||
|
|
||||||
|
/** 开关状态的绿色小点(例如弹幕已加载、VR 素材已识别)。 */
|
||||||
|
export const PLAYER_STATUS_DOT = 'h-1.5 w-1.5 shrink-0 rounded-full'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进度条/音量条上的隐形 range 输入。
|
||||||
|
*
|
||||||
|
* 视觉部分(轨道、已播放段、圆点手柄)全部自己画,原生 range 只负责三件事:
|
||||||
|
* 点击定位、拖拽、键盘与无障碍语义。所以它必须完全透明——但**不能**
|
||||||
|
* pointer-events-none,否则拖不动。
|
||||||
|
*/
|
||||||
|
export const PLAYER_RANGE_OVERLAY =
|
||||||
|
'absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent outline-none ' +
|
||||||
|
'[&::-webkit-slider-runnable-track]:bg-transparent ' +
|
||||||
|
'[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none ' +
|
||||||
|
'[&::-moz-range-track]:bg-transparent ' +
|
||||||
|
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:border-0 ' +
|
||||||
|
'[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:bg-transparent'
|
||||||
|
|
||||||
|
/** 自绘的细轨道。悬停时整条轨道加粗,是 B 站进度条最直观的那点反馈。 */
|
||||||
|
export const PLAYER_TRACK =
|
||||||
|
'pointer-events-none absolute inset-x-0 h-[3px] rounded-full bg-white/25 transition-[height] duration-150'
|
||||||
|
export const PLAYER_TRACK_FILL = 'pointer-events-none absolute inset-y-0 left-0 rounded-full'
|
||||||
@@ -5,7 +5,6 @@ import type { User } from '../types'
|
|||||||
|
|
||||||
export function useLayoutPermissions(user: User | null | undefined) {
|
export function useLayoutPermissions(user: User | null | undefined) {
|
||||||
const permissions = usePermissionStore((state) => state.permissions)
|
const permissions = usePermissionStore((state) => state.permissions)
|
||||||
const isSuper = usePermissionStore((state) => state.isSuper)
|
|
||||||
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
|
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
|
||||||
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
|
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
|
||||||
|
|
||||||
@@ -17,8 +16,8 @@ export function useLayoutPermissions(user: User | null | undefined) {
|
|||||||
|
|
||||||
const isAdmin = user?.role === 'admin'
|
const isAdmin = user?.role === 'admin'
|
||||||
const can = useCallback(
|
const can = useCallback(
|
||||||
(key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true,
|
(key: string) => isAdmin || (permissions ?? {})[key] === true,
|
||||||
[isAdmin, isSuper, permissions],
|
[isAdmin, permissions],
|
||||||
)
|
)
|
||||||
|
|
||||||
return { can, isAdmin }
|
return { can, isAdmin }
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useCallback, useState, type DragEvent } from 'react'
|
||||||
|
|
||||||
|
/** 一组可直接展开到可拖拽元素上的事件处理器。 */
|
||||||
|
export type DragReorderHandlers = {
|
||||||
|
draggable: boolean
|
||||||
|
onDragStart: (event: DragEvent) => void
|
||||||
|
onDragOver: (event: DragEvent) => void
|
||||||
|
onDragLeave: (event: DragEvent) => void
|
||||||
|
onDrop: (event: DragEvent) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 `fromId` 移动到 `toId` 当前所在的位置,返回新数组;不改动入参。
|
||||||
|
* 任一 id 不存在,或两者相同时返回 null,表示无需重排。
|
||||||
|
*/
|
||||||
|
export function moveIdToPosition(ids: string[], fromId: string, toId: string): string[] | null {
|
||||||
|
const fromIndex = ids.indexOf(fromId)
|
||||||
|
const toIndex = ids.indexOf(toId)
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null
|
||||||
|
const next = [...ids]
|
||||||
|
const [moved] = next.splice(fromIndex, 1)
|
||||||
|
next.splice(toIndex, 0, moved)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表拖拽排序的通用状态机:只负责「一串稳定 id」的拖拽与重排计算,
|
||||||
|
* 不关心渲染。调用方把 `dragProps(id)` 展开到拖拽柄上,并用返回的
|
||||||
|
* `draggingId` / `dragOverId` 决定高亮样式。
|
||||||
|
*
|
||||||
|
* 落点语义:把被拖项插入到目标项所在的位置。未传 `onReorder` 时整体禁用。
|
||||||
|
*/
|
||||||
|
export function useDragReorder(
|
||||||
|
ids: string[],
|
||||||
|
onReorder?: (orderedIds: string[]) => void | Promise<void>,
|
||||||
|
): {
|
||||||
|
draggingId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
dragProps: (id: string) => DragReorderHandlers
|
||||||
|
} {
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||||
|
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const enabled = Boolean(onReorder)
|
||||||
|
|
||||||
|
const dragProps = useCallback(
|
||||||
|
(id: string): DragReorderHandlers => ({
|
||||||
|
draggable: enabled,
|
||||||
|
onDragStart: (event) => {
|
||||||
|
if (!enabled) return
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData('text/plain', id)
|
||||||
|
setDraggingId(id)
|
||||||
|
setDragOverId(null)
|
||||||
|
},
|
||||||
|
onDragOver: (event) => {
|
||||||
|
if (!enabled || draggingId === id) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
setDragOverId((prev) => (prev === id ? prev : id))
|
||||||
|
},
|
||||||
|
onDragLeave: () => {
|
||||||
|
setDragOverId((prev) => (prev === id ? null : prev))
|
||||||
|
},
|
||||||
|
onDrop: (event) => {
|
||||||
|
if (!enabled) return
|
||||||
|
event.preventDefault()
|
||||||
|
// 拖拽期间 state 可能尚未提交,优先信任 dataTransfer 里的来源 id。
|
||||||
|
const fromId = draggingId ?? event.dataTransfer.getData('text/plain')
|
||||||
|
setDraggingId(null)
|
||||||
|
setDragOverId(null)
|
||||||
|
if (!fromId || fromId === id) return
|
||||||
|
const next = moveIdToPosition(ids, fromId, id)
|
||||||
|
if (next) void onReorder?.(next)
|
||||||
|
},
|
||||||
|
onDragEnd: () => {
|
||||||
|
setDraggingId(null)
|
||||||
|
setDragOverId(null)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[enabled, draggingId, ids, onReorder],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { draggingId, dragOverId, dragProps }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
|
|||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { adminAPI } from '../api/admin'
|
import { adminAPI } from '../api/admin'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
const SETTING_KEY = 'scrape.episode_images'
|
const SETTING_KEY = 'scrape.episode_images'
|
||||||
|
|
||||||
@@ -13,8 +14,11 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
|
|||||||
const enabledRef = useRef(false)
|
const enabledRef = useRef(false)
|
||||||
const changedLocally = useRef(false)
|
const changedLocally = useRef(false)
|
||||||
const saveQueue = useRef(Promise.resolve())
|
const saveQueue = useRef(Promise.resolve())
|
||||||
|
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return
|
||||||
|
|
||||||
let active = true
|
let active = true
|
||||||
adminAPI.listSettings()
|
adminAPI.listSettings()
|
||||||
.then((settings) => {
|
.then((settings) => {
|
||||||
@@ -32,9 +36,11 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
|
|||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isAdmin])
|
||||||
|
|
||||||
const setEnabled = useCallback((nextValue: SetStateAction<boolean>) => {
|
const setEnabled = useCallback((nextValue: SetStateAction<boolean>) => {
|
||||||
|
if (!isAdmin) return
|
||||||
|
|
||||||
const next = typeof nextValue === 'function' ? nextValue(enabledRef.current) : nextValue
|
const next = typeof nextValue === 'function' ? nextValue(enabledRef.current) : nextValue
|
||||||
if (enabledRef.current === next) return
|
if (enabledRef.current === next) return
|
||||||
changedLocally.current = true
|
changedLocally.current = true
|
||||||
@@ -47,7 +53,7 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error('保存每集图片设置失败')
|
toast.error('保存每集图片设置失败')
|
||||||
})
|
})
|
||||||
}, [])
|
}, [isAdmin])
|
||||||
|
|
||||||
return [enabled, setEnabled]
|
return [enabled, setEnabled]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
readLibraryListSort,
|
||||||
|
writeLibraryListSort,
|
||||||
|
type LibraryListSortField,
|
||||||
|
type LibraryListSortOrder,
|
||||||
|
} from '../utils/libraryListSort'
|
||||||
|
|
||||||
|
export type UseLibraryListSortResult = {
|
||||||
|
field: LibraryListSortField
|
||||||
|
order: LibraryListSortOrder
|
||||||
|
setSort: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体库列表的排序偏好(localStorage 持久化)。
|
||||||
|
*
|
||||||
|
* 首页和 /libraries 读的是同一份偏好,这样在 /libraries 改了排序后回到首页
|
||||||
|
* 顺序也一致;默认「库名倒序」。另外监听 storage 事件,多标签页同时打开时
|
||||||
|
* 一边改动另一边也能跟上。
|
||||||
|
*/
|
||||||
|
export function useLibraryListSort(): UseLibraryListSortResult {
|
||||||
|
const [sort, setSort] = useState(() => readLibraryListSort())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setSort(readLibraryListSort())
|
||||||
|
window.addEventListener('storage', sync)
|
||||||
|
return () => window.removeEventListener('storage', sync)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const update = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
|
||||||
|
writeLibraryListSort(field, order)
|
||||||
|
setSort({ field, order })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { field: sort.field, order: sort.order, setSort: update }
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
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,
|
||||||
|
reorderLibraryTags,
|
||||||
|
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>
|
||||||
|
/** 按给定标签名顺序重排标签栏(拖拽排序的落点)。 */
|
||||||
|
reorderTags: (orderedNames: 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
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 选中的标签可能被删除,落回「全部」以免标签栏空白。
|
||||||
|
// 注意:标签首次加载完成前 tags 为空集合,此时不能据此判定标签已被删除,
|
||||||
|
// 否则从子页面返回时,持久化的选中标签会被误重置为「全部」。
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return
|
||||||
|
const resolved = resolveSelectedTagId(tags, selectedTagId)
|
||||||
|
if (resolved !== selectedTagId) {
|
||||||
|
selectedRef.current = resolved
|
||||||
|
setSelectedTagIdState(resolved)
|
||||||
|
}
|
||||||
|
}, [loading, 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 reorderTags = useCallback(
|
||||||
|
async (orderedNames: string[]) => {
|
||||||
|
await mutate((current) => reorderLibraryTags(current, orderedNames))
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
reorderTags,
|
||||||
|
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 - 检查用户是否拥有特定权限
|
* usePermission hook - 检查用户是否拥有特定权限
|
||||||
*
|
*
|
||||||
* @param key - 权限键名
|
* @param key - 权限键名
|
||||||
* @param options - 配置选项
|
* @param options - 配置选项
|
||||||
* @param options.autoFetch - 是否在权限未加载时自动获取(默认 true)
|
* @param options.autoFetch - 是否在权限未加载时自动获取(默认 true)
|
||||||
* @returns boolean - 用户是否拥有该权限
|
* @returns boolean - 用户是否拥有该权限
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* function MyComponent() {
|
* function MyComponent() {
|
||||||
* const canEdit = usePermission('can_edit_media')
|
* const canEdit = usePermission('can_edit_media')
|
||||||
*
|
*
|
||||||
* if (canEdit) {
|
* if (canEdit) {
|
||||||
* return <EditButton />
|
* return <EditButton />
|
||||||
* }
|
* }
|
||||||
@@ -30,26 +30,24 @@ export function usePermission(
|
|||||||
const { autoFetch = true } = options
|
const { autoFetch = true } = options
|
||||||
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染
|
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染
|
||||||
const hasPermission = usePermissionStore((s) => s.hasPermission)
|
const hasPermission = usePermissionStore((s) => s.hasPermission)
|
||||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
|
||||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||||
const tier = useAuthStore((state) => state.tier)
|
|
||||||
const role = useAuthStore((state) => state.user?.role)
|
const role = useAuthStore((state) => state.user?.role)
|
||||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||||
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin'
|
const isAdmin = role === 'admin'
|
||||||
|
|
||||||
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
|
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
|
||||||
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
|
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSuperAccess && isAuthenticated && autoFetch) {
|
if (!isAdmin && isAuthenticated && autoFetch) {
|
||||||
const { permissions, isLoading } = usePermissionStore.getState()
|
const { permissions, isLoading } = usePermissionStore.getState()
|
||||||
if (Object.keys(permissions).length === 0 && !isLoading) {
|
if (Object.keys(permissions).length === 0 && !isLoading) {
|
||||||
fetchPermissions()
|
fetchPermissions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated])
|
}, [autoFetch, fetchPermissions, isAdmin, isAuthenticated])
|
||||||
|
|
||||||
// 超级用户有所有权限
|
// 管理员拥有全部能力;plus / is_super 不能用来展示刮削、整理、删除等管理入口
|
||||||
if (hasSuperAccess) {
|
if (isAdmin) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,18 +61,18 @@ export function usePermission(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* usePermissions hook - 获取所有权限
|
* usePermissions hook - 获取所有权限
|
||||||
*
|
*
|
||||||
* @returns 权限状态和检查函数
|
* @returns 权限状态和检查函数
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* function MyComponent() {
|
* function MyComponent() {
|
||||||
* const { permissions, isSuper, check } = usePermissions()
|
* const { permissions, isSuper, check } = usePermissions()
|
||||||
*
|
*
|
||||||
* if (isSuper) {
|
* if (isSuper) {
|
||||||
* return <AdminPanel />
|
* return <AdminPanel />
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* return (
|
* return (
|
||||||
* <div>
|
* <div>
|
||||||
* {check('can_view_dashboard') && <Dashboard />}
|
* {check('can_view_dashboard') && <Dashboard />}
|
||||||
@@ -87,15 +85,14 @@ export function usePermission(
|
|||||||
export function usePermissions() {
|
export function usePermissions() {
|
||||||
// selector 订阅:只关注 permissions/isSuper/isLoading 变化
|
// selector 订阅:只关注 permissions/isSuper/isLoading 变化
|
||||||
const permissions = usePermissionStore((s) => s.permissions)
|
const permissions = usePermissionStore((s) => s.permissions)
|
||||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
|
||||||
const isLoading = usePermissionStore((s) => s.isLoading)
|
const isLoading = usePermissionStore((s) => s.isLoading)
|
||||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||||
const tier = useAuthStore((state) => state.tier)
|
|
||||||
const role = useAuthStore((state) => state.user?.role)
|
const role = useAuthStore((state) => state.user?.role)
|
||||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||||
|
const isAdmin = role === 'admin'
|
||||||
|
|
||||||
const check = (key: string): boolean => {
|
const check = (key: string): boolean => {
|
||||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
if (isAdmin) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return permissions[key] === true
|
return permissions[key] === true
|
||||||
@@ -103,7 +100,8 @@ export function usePermissions() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
permissions,
|
permissions,
|
||||||
isSuper: isSuper || tier === 'plus' || role === 'admin',
|
// Keep the field name for callers, but only admins are treated as full-access.
|
||||||
|
isSuper: isAdmin,
|
||||||
isLoading,
|
isLoading,
|
||||||
check,
|
check,
|
||||||
refetch: fetchPermissions,
|
refetch: fetchPermissions,
|
||||||
@@ -113,10 +111,10 @@ export function usePermissions() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* usePermissionMany hook - 批量检查多个权限
|
* usePermissionMany hook - 批量检查多个权限
|
||||||
*
|
*
|
||||||
* @param keys - 权限键数组
|
* @param keys - 权限键数组
|
||||||
* @returns 每个权限的布尔值映射
|
* @returns 每个权限的布尔值映射
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* function MyComponent() {
|
* function MyComponent() {
|
||||||
@@ -125,7 +123,7 @@ export function usePermissions() {
|
|||||||
* 'can_manage_users',
|
* 'can_manage_users',
|
||||||
* 'can_access_settings',
|
* 'can_access_settings',
|
||||||
* ])
|
* ])
|
||||||
*
|
*
|
||||||
* return (
|
* return (
|
||||||
* <div>
|
* <div>
|
||||||
* {perms['can_edit_media'] && <EditButton />}
|
* {perms['can_edit_media'] && <EditButton />}
|
||||||
@@ -137,7 +135,7 @@ export function usePermissions() {
|
|||||||
*/
|
*/
|
||||||
export function usePermissionMany(keys: string[]): Record<string, boolean> {
|
export function usePermissionMany(keys: string[]): Record<string, boolean> {
|
||||||
const { check } = usePermissions()
|
const { check } = usePermissions()
|
||||||
|
|
||||||
return keys.reduce((acc, key) => {
|
return keys.reduce((acc, key) => {
|
||||||
acc[key] = check(key)
|
acc[key] = check(key)
|
||||||
return acc
|
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-overlay-in { animation: overlay-in 0.15s ease-out both; }
|
||||||
.animate-drawer-in { animation: drawer-in 0.22s 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-accordion-in { animation: accordion-in 0.18s ease-out both; }
|
||||||
.animate-fade-slide-in { animation: fade-slide-in 0.2s 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; }
|
.animate-menu-in { animation: menu-in 0.15s ease-out both; }
|
||||||
|
|||||||
+103
-51
@@ -6,8 +6,13 @@ import type { HistoryItem } from '../api/playback'
|
|||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
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 { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
import { useLibraryListSort } from '../hooks/useLibraryListSort'
|
||||||
|
import { sortLibrariesByField } from '../utils/libraryListSort'
|
||||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||||
import {
|
import {
|
||||||
ContinueWatchingSection,
|
ContinueWatchingSection,
|
||||||
@@ -30,6 +35,8 @@ export function HomePage() {
|
|||||||
const [librariesLoading, setLibrariesLoading] = useState(true)
|
const [librariesLoading, setLibrariesLoading] = useState(true)
|
||||||
const [historyLoading, setHistoryLoading] = useState(true)
|
const [historyLoading, setHistoryLoading] = useState(true)
|
||||||
const { pinnedIds } = usePinnedLibraries()
|
const { pinnedIds } = usePinnedLibraries()
|
||||||
|
const { field: sortField, order: sortOrder } = useLibraryListSort()
|
||||||
|
const libraryTags = useLibraryTags()
|
||||||
|
|
||||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||||
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
||||||
@@ -85,7 +92,25 @@ export function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
// 与 /libraries 共用同一份排序偏好(默认库名倒序):置顶库始终在前,
|
||||||
|
// 组内按偏好排序字段排。这样在媒体库页改过排序后,首页顺序也一致。
|
||||||
|
const sortedLibraries = useMemo(
|
||||||
|
() => sortLibrariesByField(libraries, pinnedIds, sortField, sortOrder),
|
||||||
|
[libraries, pinnedIds, sortField, sortOrder],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。
|
||||||
|
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 张封面用于入口网格,
|
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
|
||||||
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
|
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
|
||||||
@@ -151,49 +176,46 @@ export function HomePage() {
|
|||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后再按批加载。
|
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后自行按批加载。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sortedLibraries.length === 0) return
|
if (taggedLibraries.length === 0) return
|
||||||
const carouselLibIds = sortedLibraries
|
const carouselLibIds = taggedLibraries
|
||||||
.filter((l) => l.carousel_enabled === true)
|
.filter((l) => l.carousel_enabled === true)
|
||||||
.map((l) => l.id)
|
.map((l) => l.id)
|
||||||
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
|
const topRowLibIds = taggedLibraries.slice(0, 3).map((l) => l.id)
|
||||||
// 入口网格仍然是一次展示 20 个库;没有自定义封面的库必须在首屏
|
|
||||||
// 立即拉预览,否则会先出现占位图标再补图,体感反而更慢。
|
|
||||||
const topGridLibIds = sortedLibraries
|
|
||||||
.slice(0, 20)
|
|
||||||
.filter((l) => !l.cover_url)
|
|
||||||
.map((l) => l.id)
|
|
||||||
const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
|
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(shelfTargets, 10)
|
||||||
void fetchPreviews(gridTargets, 2)
|
}, [taggedLibraries, fetchPreviews])
|
||||||
}, [sortedLibraries, fetchPreviews])
|
|
||||||
|
|
||||||
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
|
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
|
||||||
const INITIAL_ROWS = 3
|
const INITIAL_ROWS = 3
|
||||||
const STEP_ROWS = 2
|
const STEP_ROWS = 2
|
||||||
const [visibleTargetCount, setVisibleTargetCount] = useState(INITIAL_ROWS)
|
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 增加,按需触发后续库的预览加载
|
// 随 visibleTargetCount 增加,按需触发后续库的预览加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sortedLibraries.length === 0) return
|
if (taggedLibraries.length === 0) return
|
||||||
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
|
const currentTargets = taggedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
|
||||||
void fetchPreviews(currentTargets, 10)
|
void fetchPreviews(currentTargets, 10)
|
||||||
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
|
}, [taggedLibraries, visibleTargetCount, fetchPreviews])
|
||||||
|
|
||||||
// 当前已拉取并确认有内容的媒体库行
|
// 当前已拉取并确认有内容的媒体库行
|
||||||
const visibleLibraries = useMemo(() => {
|
const visibleLibraries = useMemo(() => {
|
||||||
return sortedLibraries
|
return taggedLibraries
|
||||||
.slice(0, visibleTargetCount)
|
.slice(0, visibleTargetCount)
|
||||||
.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0)
|
.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(() => {
|
useEffect(() => {
|
||||||
@@ -201,12 +223,11 @@ export function HomePage() {
|
|||||||
if (!scrollParent) return
|
if (!scrollParent) return
|
||||||
|
|
||||||
const handleCheckBottom = () => {
|
const handleCheckBottom = () => {
|
||||||
|
if (scrollParent.scrollTop <= 0) return
|
||||||
|
userScrolledRef.current = true
|
||||||
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
|
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
|
||||||
if (remaining < 600) {
|
if (remaining < 600) {
|
||||||
setVisibleTargetCount((prev) => {
|
revealMoreLibraries()
|
||||||
if (prev >= sortedLibraries.length) return prev
|
|
||||||
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,11 +239,8 @@ export function HomePage() {
|
|||||||
observer = new IntersectionObserver(
|
observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const [entry] = entries
|
const [entry] = entries
|
||||||
if (entry?.isIntersecting) {
|
if (entry?.isIntersecting && userScrolledRef.current) {
|
||||||
setVisibleTargetCount((prev) => {
|
revealMoreLibraries()
|
||||||
if (prev >= sortedLibraries.length) return prev
|
|
||||||
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -234,15 +252,13 @@ export function HomePage() {
|
|||||||
observer.observe(sentinel)
|
observer.observe(sentinel)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCheckBottom()
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
scrollParent.removeEventListener('scroll', handleCheckBottom)
|
scrollParent.removeEventListener('scroll', handleCheckBottom)
|
||||||
if (observer) {
|
if (observer) {
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [visibleLibraries.length, hasMoreLibraries, sortedLibraries.length])
|
}, [hasMoreLibraries, revealMoreLibraries, visibleLibraries.length])
|
||||||
|
|
||||||
// Quick lookup map for libraries
|
// Quick lookup map for libraries
|
||||||
const libraryMap = useMemo(() => {
|
const libraryMap = useMemo(() => {
|
||||||
@@ -267,7 +283,7 @@ export function HomePage() {
|
|||||||
// series-type libs (loaded via /series), so gate on cards instead.
|
// series-type libs (loaded via /series), so gate on cards instead.
|
||||||
const carouselItems = useMemo(() => {
|
const carouselItems = useMemo(() => {
|
||||||
const candidateMedia: Media[] = []
|
const candidateMedia: Media[] = []
|
||||||
const effectiveSelectedIds = libraries
|
const effectiveSelectedIds = taggedLibraries
|
||||||
.filter((l) => l.carousel_enabled === true)
|
.filter((l) => l.carousel_enabled === true)
|
||||||
.map((l) => l.id)
|
.map((l) => l.id)
|
||||||
|
|
||||||
@@ -284,7 +300,7 @@ export function HomePage() {
|
|||||||
|
|
||||||
// Fallback to all loaded items with artwork
|
// Fallback to all loaded items with artwork
|
||||||
if (candidateMedia.length === 0) {
|
if (candidateMedia.length === 0) {
|
||||||
for (const lib of libraries) {
|
for (const lib of taggedLibraries) {
|
||||||
const data = libraryData[lib.id]
|
const data = libraryData[lib.id]
|
||||||
if (data) {
|
if (data) {
|
||||||
for (const card of data.cards) {
|
for (const card of data.cards) {
|
||||||
@@ -295,14 +311,13 @@ export function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return candidateMedia.slice(0, 10)
|
return candidateMedia.slice(0, 10)
|
||||||
}, [libraries, libraryData])
|
}, [taggedLibraries, libraryData])
|
||||||
|
|
||||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||||
return <HomeLoadingState />
|
return <HomeLoadingState />
|
||||||
}
|
}
|
||||||
|
|
||||||
const empty =
|
const empty =
|
||||||
libraries.length === 0 &&
|
libraries.length === 0 &&
|
||||||
history.length === 0
|
history.length === 0
|
||||||
@@ -322,18 +337,50 @@ export function HomePage() {
|
|||||||
{historyLoading && <ContinueWatchingSkeleton />}
|
{historyLoading && <ContinueWatchingSkeleton />}
|
||||||
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
|
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||||
|
|
||||||
{/* 3. 媒体库卡片区(每页展示 20 个媒体库) */}
|
{/* 3. 媒体库标签栏 + 卡片区(每页展示 20 个媒体库) */}
|
||||||
{sortedLibraries.length > 0 && (
|
{sortedLibraries.length > 0 && (
|
||||||
<HomeLibrariesSection
|
<section className="space-y-6">
|
||||||
libraries={sortedLibraries}
|
<LibraryTagBar
|
||||||
libraryData={libraryData}
|
tabs={tagTabs}
|
||||||
libraryCounts={libraryCounts}
|
selectedTagId={effectiveTagId}
|
||||||
onNeedPreviews={fetchPreviews}
|
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}
|
||||||
|
onReorder={libraryTags.reorderTags}
|
||||||
|
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个分页限制) */}
|
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
|
||||||
{visibleLibraries.length > 0 && (
|
{(visibleLibraries.length > 0 || hasMoreLibraries) && (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{visibleLibraries.map((lib) => {
|
{visibleLibraries.map((lib) => {
|
||||||
const cards = libraryData[lib.id]?.cards || []
|
const cards = libraryData[lib.id]?.cards || []
|
||||||
@@ -346,12 +393,17 @@ export function HomePage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{hasMoreLibraries && (
|
{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="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" />
|
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
|
||||||
<span>加载更多媒体库…</span>
|
<span>加载更多媒体库…</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Library as LibraryIcon,
|
Library as LibraryIcon,
|
||||||
Music,
|
Music,
|
||||||
|
Pin,
|
||||||
Play,
|
Play,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import { useInViewOnce } from '../hooks/useInViewOnce'
|
import { useInViewOnce } from '../hooks/useInViewOnce'
|
||||||
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
|
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
|
||||||
|
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
||||||
import { MediaCard } from '../components/MediaCard'
|
import { MediaCard } from '../components/MediaCard'
|
||||||
import type { HistoryItem } from '../api/playback'
|
import type { HistoryItem } from '../api/playback'
|
||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
@@ -355,14 +357,28 @@ export function HomeLibrariesSection({
|
|||||||
libraryData,
|
libraryData,
|
||||||
libraryCounts,
|
libraryCounts,
|
||||||
onNeedPreviews,
|
onNeedPreviews,
|
||||||
|
pinnedIds = [],
|
||||||
|
onTogglePin,
|
||||||
|
showAllLink = true,
|
||||||
|
title = '媒体库',
|
||||||
}: {
|
}: {
|
||||||
libraries: Library[]
|
libraries: Library[]
|
||||||
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
|
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
|
||||||
libraryCounts: Record<string, number>
|
libraryCounts: Record<string, number>
|
||||||
onNeedPreviews?: (ids: string[], limit?: number) => void
|
onNeedPreviews?: (ids: string[], limit?: number) => void
|
||||||
|
pinnedIds?: string[]
|
||||||
|
onTogglePin?: (libraryId: string) => void
|
||||||
|
showAllLink?: boolean
|
||||||
|
title?: string
|
||||||
}) {
|
}) {
|
||||||
const PAGE_SIZE = 20
|
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 totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE))
|
||||||
const effectivePage = Math.min(currentPage, totalPages)
|
const effectivePage = Math.min(currentPage, totalPages)
|
||||||
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
|
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
|
||||||
@@ -382,7 +398,7 @@ export function HomeLibrariesSection({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||||
媒体库
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{libraries.length > PAGE_SIZE && (
|
{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)]">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{(totalPages > 1 || showAllLink) && (
|
||||||
{totalPages > 1 && (
|
<div className="flex items-center gap-3">
|
||||||
<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">
|
{totalPages > 1 && (
|
||||||
<button
|
<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">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
type="button"
|
||||||
disabled={effectivePage <= 1}
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 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"
|
disabled={effectivePage <= 1}
|
||||||
title="上一页"
|
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>
|
<ChevronLeft size={14} />
|
||||||
<span className="font-mono text-xs font-semibold text-[var(--app-subtle)]">
|
</button>
|
||||||
{effectivePage} / {totalPages}
|
<span className="font-mono text-xs font-semibold text-[var(--app-subtle)]">
|
||||||
</span>
|
{effectivePage} / {totalPages}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
type="button"
|
||||||
disabled={effectivePage >= totalPages}
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 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"
|
disabled={effectivePage >= totalPages}
|
||||||
title="下一页"
|
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>
|
<ChevronRight size={14} />
|
||||||
</div>
|
</button>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link
|
{showAllLink && (
|
||||||
to="/libraries"
|
<Link
|
||||||
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
|
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" />
|
<span>全部媒体库</span>
|
||||||
</Link>
|
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||||
</div>
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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">
|
<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}
|
library={lib}
|
||||||
count={libraryCounts[lib.id] ?? 0}
|
count={libraryCounts[lib.id] ?? 0}
|
||||||
cards={libraryData?.[lib.id]?.cards ?? []}
|
cards={libraryData?.[lib.id]?.cards ?? []}
|
||||||
|
pinned={pinnedIds.includes(lib.id)}
|
||||||
|
onTogglePin={onTogglePin ? () => onTogglePin(lib.id) : undefined}
|
||||||
onVisible={() => {
|
onVisible={() => {
|
||||||
if (!lib.cover_url) {
|
if (!lib.cover_url) {
|
||||||
queuePreview(lib.id)
|
queuePreview(lib.id)
|
||||||
@@ -452,73 +474,100 @@ export function HomeLibrariesSection({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeLibraryCard({
|
export function HomeLibraryCard({
|
||||||
library,
|
library,
|
||||||
count,
|
count,
|
||||||
cards,
|
cards,
|
||||||
|
pinned = false,
|
||||||
|
onTogglePin,
|
||||||
onVisible,
|
onVisible,
|
||||||
}: {
|
}: {
|
||||||
library: Library
|
library: Library
|
||||||
count: number
|
count: number
|
||||||
cards: SeriesCard[]
|
cards: SeriesCard[]
|
||||||
|
pinned?: boolean
|
||||||
|
onTogglePin?: () => void
|
||||||
onVisible: () => void
|
onVisible: () => void
|
||||||
}) {
|
}) {
|
||||||
const ref = useInViewOnce<HTMLAnchorElement>(onVisible)
|
const ref = useInViewOnce<HTMLDivElement>(onVisible)
|
||||||
const artwork = getLibraryArtworks(library, cards)
|
const artwork = getLibraryArtworks(library, cards)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
to={`/library/${library.id}`}
|
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 ${
|
||||||
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"
|
pinned ? 'border-brand-500/60 ring-1 ring-brand-500/20' : 'border-[var(--app-border)]'
|
||||||
title={library.name}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<Link
|
||||||
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
|
to={`/library/${library.id}`}
|
||||||
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
|
className="flex h-full flex-col justify-between p-3"
|
||||||
}`}
|
title={library.name}
|
||||||
>
|
>
|
||||||
{artwork.length > 0 ? (
|
<div
|
||||||
artwork.map(({ src, version }, index) => (
|
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
|
||||||
<img
|
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
|
||||||
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}
|
{artwork.length > 0 ? (
|
||||||
</h3>
|
artwork.map(({ src, version }, index) => (
|
||||||
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
|
<img
|
||||||
{count > 0 ? `${count} 部媒体` : '暂无条目'}
|
key={`${src}-${index}`}
|
||||||
</p>
|
src={imageURL(
|
||||||
</div>
|
src,
|
||||||
</Link>
|
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,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
|
|
||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import { toolsAPI } from '../api/tools'
|
import { toolsAPI } from '../api/tools'
|
||||||
|
import { LibraryTagBar } from '../components/LibraryTagBar'
|
||||||
|
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
||||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||||
|
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
|
import { useLibraryListSort } from '../hooks/useLibraryListSort'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
import {
|
import {
|
||||||
LibrariesContent,
|
LibrariesContent,
|
||||||
LibrariesEmptyState,
|
LibrariesEmptyState,
|
||||||
@@ -13,18 +18,29 @@ import {
|
|||||||
import type { LibraryPreview } from './librariesPageModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
import type { Library } from '../types'
|
import type { Library } from '../types'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
|
import {
|
||||||
|
sortLibraryPreviewsByField,
|
||||||
|
type LibraryListSortField,
|
||||||
|
type LibraryListSortOrder,
|
||||||
|
} from '../utils/libraryListSort'
|
||||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
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'
|
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||||
|
|
||||||
export function LibrariesPage() {
|
export function LibrariesPage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
||||||
|
// 会话缓存命中时首屏即用缓存渲染(避免先闪一帧占位再被撑高),
|
||||||
|
// 这样 useScrollMemory 的布局期恢复才能在返回列表时立刻落到原位置。
|
||||||
|
const [libraries, setLibraries] = useState<Library[]>(() => peekLibraries() ?? [])
|
||||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
||||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
const { pinnedIds, togglePin } = usePinnedLibraries()
|
||||||
const [loading, setLoading] = useState(true)
|
const libraryTags = useLibraryTags()
|
||||||
|
const [loading, setLoading] = useState(() => peekLibraries() === null)
|
||||||
const [repairing, setRepairing] = useState(false)
|
const [repairing, setRepairing] = useState(false)
|
||||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||||
const [repairMsg, setRepairMsg] = useState('')
|
const [repairMsg, setRepairMsg] = useState('')
|
||||||
|
const { field: sortField, order: sortOrder, setSort: setLibrarySort } = useLibraryListSort()
|
||||||
|
|
||||||
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
||||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||||
@@ -103,7 +119,7 @@ export function LibrariesPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function handleRepairRescrape() {
|
async function handleRepairRescrape() {
|
||||||
if (repairing) return
|
if (!isAdmin || repairing) return
|
||||||
setRepairing(true)
|
setRepairing(true)
|
||||||
setRepairMsg('')
|
setRepairMsg('')
|
||||||
try {
|
try {
|
||||||
@@ -117,6 +133,7 @@ export function LibrariesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleManageLibraries = async () => {
|
const handleManageLibraries = async () => {
|
||||||
|
if (!isAdmin) return
|
||||||
await openManageLibrariesDialog()
|
await openManageLibrariesDialog()
|
||||||
await loadLibraries({ force: true })
|
await loadLibraries({ force: true })
|
||||||
}
|
}
|
||||||
@@ -137,36 +154,90 @@ export function LibrariesPage() {
|
|||||||
})
|
})
|
||||||
}, [libraries, libraryData])
|
}, [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) => {
|
||||||
|
setLibrarySort(field, order)
|
||||||
|
}, [setLibrarySort])
|
||||||
|
|
||||||
const handleTogglePin = useCallback((libraryId: string) => {
|
const handleTogglePin = useCallback((libraryId: string) => {
|
||||||
void togglePin(libraryId)
|
void togglePin(libraryId)
|
||||||
}, [togglePin])
|
}, [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) {
|
if (loading) {
|
||||||
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<LibrariesHeader
|
<LibrariesHeader
|
||||||
previewCount={previews.length}
|
previewCount={taggedPreviews.length}
|
||||||
total={total}
|
total={taggedTotal}
|
||||||
|
isAdmin={isAdmin}
|
||||||
repairMsg={repairMsg}
|
repairMsg={repairMsg}
|
||||||
repairEpisodeArtwork={repairEpisodeArtwork}
|
repairEpisodeArtwork={repairEpisodeArtwork}
|
||||||
repairing={repairing}
|
repairing={repairing}
|
||||||
|
sortField={sortField}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
|
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
|
||||||
onRepairRescrape={handleRepairRescrape}
|
onRepairRescrape={handleRepairRescrape}
|
||||||
onManageLibraries={handleManageLibraries}
|
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}
|
||||||
|
onReorder={libraryTags.reorderTags}
|
||||||
|
onAssign={libraryTags.assignLibrary}
|
||||||
|
onAssignBatch={libraryTags.assignLibraries}
|
||||||
|
onClose={() => setLibraryTagsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{previews.length === 0 ? (
|
{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
|
<LibrariesContent
|
||||||
previews={sortedPreviews}
|
previews={taggedPreviews}
|
||||||
pinnedIds={pinnedIds}
|
pinnedIds={pinnedIds}
|
||||||
onTogglePin={handleTogglePin}
|
onTogglePin={handleTogglePin}
|
||||||
onNeedPreviews={fetchPreviews}
|
onNeedPreviews={fetchPreviews}
|
||||||
@@ -175,3 +246,18 @@ export function LibrariesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按选中标签过滤媒体库预览:复用共享的成员筛选(保持输入顺序),
|
||||||
|
* 这样排序下拉(含倒序)在标签页与「全部」页行为一致。
|
||||||
|
*/
|
||||||
|
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
|
||||||
|
if (!tagId || tagId === ALL_TAG_ID) return previews
|
||||||
|
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
|
||||||
|
const kept: LibraryPreview[] = []
|
||||||
|
for (const library of filterLibrariesByTag(previews.map((preview) => preview.library), tags, tagId)) {
|
||||||
|
const preview = byId.get(library.id)
|
||||||
|
if (preview) kept.push(preview)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { 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 { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||||
import { MediaCard } from '../components/MediaCard'
|
import { LibraryListSortDropdown } from '../components/LibraryListSortDropdown'
|
||||||
import { useInViewOnce } from '../hooks/useInViewOnce'
|
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
||||||
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
|
import type { LibraryListSortField, LibraryListSortOrder } from '../utils/libraryListSort'
|
||||||
import { seriesCardLink } from '../utils/groupSeries'
|
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
|
||||||
import { libraryDisplayPath } from './libraryDisplayModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
|
|
||||||
import { isLibraryPinned } from '../utils/pinnedLibraries'
|
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
// 返回媒体库列表时最多一次性解锁的货架数量:滚动到很深的位置后回来
|
||||||
movie: <Film size={18} />,
|
// 仍能落到原处,同时避免一次渲染过多货架。上限之外的继续按需解锁。
|
||||||
tv: <Tv size={18} />,
|
const MAX_RESTORED_SHELVES = 15
|
||||||
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: '成人',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LibrariesHeader({
|
export function LibrariesHeader({
|
||||||
previewCount,
|
previewCount,
|
||||||
total,
|
total,
|
||||||
|
isAdmin,
|
||||||
repairMsg,
|
repairMsg,
|
||||||
repairEpisodeArtwork,
|
repairEpisodeArtwork,
|
||||||
repairing,
|
repairing,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
onSortChange,
|
||||||
onRepairEpisodeArtworkChange,
|
onRepairEpisodeArtworkChange,
|
||||||
onRepairRescrape,
|
onRepairRescrape,
|
||||||
onManageLibraries,
|
onManageLibraries,
|
||||||
}: {
|
}: {
|
||||||
previewCount: number
|
previewCount: number
|
||||||
total: number
|
total: number
|
||||||
|
isAdmin: boolean
|
||||||
repairMsg: string
|
repairMsg: string
|
||||||
repairEpisodeArtwork: boolean
|
repairEpisodeArtwork: boolean
|
||||||
repairing: boolean
|
repairing: boolean
|
||||||
|
sortField: LibraryListSortField
|
||||||
|
sortOrder: LibraryListSortOrder
|
||||||
|
onSortChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||||
onRepairEpisodeArtworkChange: (value: boolean) => void
|
onRepairEpisodeArtworkChange: (value: boolean) => void
|
||||||
onRepairRescrape: () => void
|
onRepairRescrape: () => void
|
||||||
onManageLibraries: () => void
|
onManageLibraries: () => void
|
||||||
@@ -57,48 +49,55 @@ export function LibrariesHeader({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
<LibraryListSortDropdown value={sortField} order={sortOrder} onChange={onSortChange} />
|
||||||
<EpisodeArtworkToggle
|
{isAdmin && (
|
||||||
checked={repairEpisodeArtwork}
|
<>
|
||||||
onChange={onRepairEpisodeArtworkChange}
|
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
<EpisodeArtworkToggle
|
||||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
checked={repairEpisodeArtwork}
|
||||||
/>
|
onChange={onRepairEpisodeArtworkChange}
|
||||||
<button
|
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||||
type="button"
|
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
onClick={onRepairRescrape}
|
/>
|
||||||
disabled={repairing}
|
<button
|
||||||
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"
|
type="button"
|
||||||
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
onClick={onRepairRescrape}
|
||||||
>
|
disabled={repairing}
|
||||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
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"
|
||||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
||||||
</button>
|
>
|
||||||
<Link
|
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||||
to="/scraper/queue"
|
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||||
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"
|
</button>
|
||||||
title="查看正在进行的刮削任务与进度"
|
<Link
|
||||||
>
|
to="/scraper/queue"
|
||||||
<Sparkles size={14} className="text-brand-500" />
|
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"
|
||||||
<span>刮削队列</span>
|
title="查看正在进行的刮削任务与进度"
|
||||||
</Link>
|
>
|
||||||
<button
|
<Sparkles size={14} className="text-brand-500" />
|
||||||
type="button"
|
<span>刮削队列</span>
|
||||||
onClick={onManageLibraries}
|
</Link>
|
||||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
<button
|
||||||
>
|
type="button"
|
||||||
管理媒体库
|
onClick={onManageLibraries}
|
||||||
</button>
|
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||||
|
>
|
||||||
|
管理媒体库
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibrariesEmptyState() {
|
export function LibrariesEmptyState({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-sand-200 bg-white py-24 text-center">
|
<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-gray-400" />
|
<LibraryIcon className="mb-4 h-12 w-12 text-[var(--app-muted)]" />
|
||||||
<p className="text-sm text-ink-50">暂无媒体库,请到管理后台添加目录。</p>
|
<p className="text-sm text-[var(--app-muted)]">
|
||||||
|
{isAdmin ? '暂无媒体库,请到管理后台添加目录。' : '暂无可用媒体库。'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -114,33 +113,40 @@ export function LibrariesContent({
|
|||||||
onTogglePin: (libraryId: string) => void
|
onTogglePin: (libraryId: string) => void
|
||||||
onNeedPreviews?: (ids: string[], limit?: number) => void
|
onNeedPreviews?: (ids: string[], limit?: number) => void
|
||||||
}) {
|
}) {
|
||||||
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
|
// 下方媒体库货架继续按需解锁:首屏先展示前 3 个,滚动接近底部再加载 2 个。
|
||||||
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
|
|
||||||
|
|
||||||
// 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架,
|
|
||||||
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。
|
|
||||||
const INITIAL_SHELVES = 3
|
const INITIAL_SHELVES = 3
|
||||||
const STEP_SHELVES = 2
|
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(() => {
|
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)
|
onNeedPreviews?.(currentTargets, 10)
|
||||||
}, [previews, visibleCount, onNeedPreviews])
|
}, [previews, visibleCount, onNeedPreviews])
|
||||||
|
|
||||||
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollParent = document.getElementById('app-main-scroll')
|
const scrollParent = document.getElementById('app-main-scroll')
|
||||||
if (!scrollParent) return
|
if (!scrollParent) return
|
||||||
|
|
||||||
const handleCheckBottom = () => {
|
const handleCheckBottom = () => {
|
||||||
|
if (scrollParent.scrollTop <= 0) return
|
||||||
|
userScrolledRef.current = true
|
||||||
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
|
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
|
||||||
if (remaining < 600) {
|
if (remaining < 600) {
|
||||||
setVisibleCount((prev) => {
|
revealMoreShelves()
|
||||||
if (prev >= previews.length) return prev
|
|
||||||
return Math.min(prev + STEP_SHELVES, previews.length)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,11 +158,8 @@ export function LibrariesContent({
|
|||||||
observer = new IntersectionObserver(
|
observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const [entry] = entries
|
const [entry] = entries
|
||||||
if (entry?.isIntersecting) {
|
if (entry?.isIntersecting && userScrolledRef.current) {
|
||||||
setVisibleCount((prev) => {
|
revealMoreShelves()
|
||||||
if (prev >= previews.length) return prev
|
|
||||||
return Math.min(prev + STEP_SHELVES, previews.length)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -168,286 +171,74 @@ export function LibrariesContent({
|
|||||||
observer.observe(sentinel)
|
observer.observe(sentinel)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCheckBottom()
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
scrollParent.removeEventListener('scroll', handleCheckBottom)
|
scrollParent.removeEventListener('scroll', handleCheckBottom)
|
||||||
if (observer) {
|
if (observer) observer.disconnect()
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [visibleCount, previews.length])
|
}, [revealMoreShelves, 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])
|
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="space-y-4">
|
<HomeLibrariesSection
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
libraries={libraries}
|
||||||
<div>
|
libraryData={libraryData}
|
||||||
<div className="flex items-center gap-2">
|
libraryCounts={libraryCounts}
|
||||||
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
|
onNeedPreviews={onNeedPreviews}
|
||||||
{previews.length > ENTRY_PAGE_SIZE && (
|
pinnedIds={pinnedIds}
|
||||||
<span className="rounded-md border border-sand-200 bg-sand-50 px-2 py-0.5 text-xs font-semibold text-sand-700">
|
onTogglePin={onTogglePin}
|
||||||
共 {previews.length} 个 · 每页 {ENTRY_PAGE_SIZE} 个
|
showAllLink={false}
|
||||||
</span>
|
title="媒体库入口"
|
||||||
)}
|
/>
|
||||||
</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>
|
|
||||||
|
|
||||||
{visiblePreviews.length > 0 && (
|
{visiblePreviews.length > 0 && (
|
||||||
<section className="space-y-6">
|
<section className="space-y-10">
|
||||||
{visiblePreviews.map((preview) => (
|
{visiblePreviews.map((preview) => (
|
||||||
<div
|
<div key={preview.library.id} className="animate-page-in">
|
||||||
key={preview.library.id}
|
<HomeLibraryRowSection
|
||||||
className="animate-page-in"
|
library={preview.library}
|
||||||
>
|
cards={preview.cards}
|
||||||
<LibraryShelf
|
|
||||||
preview={preview}
|
|
||||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{visibleCount < previews.length && (
|
{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="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" />
|
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
|
||||||
<span>加载更多媒体库货架…</span>
|
<span>加载更多媒体库货架…</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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 (
|
return (
|
||||||
<>
|
<div className="hidden flex-wrap justify-end gap-1 sm:flex">
|
||||||
<button title="智能刮削" disabled={busy} onClick={() => onSmartScrape(media)} className={buttonClass}>
|
<button title="智能刮削" disabled={busy} onClick={() => onSmartScrape(media)} className={buttonClass}>
|
||||||
<Sparkles size={13} />
|
<Sparkles size={13} />
|
||||||
</button>
|
</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`}>
|
<button title="删除" disabled={busy} onClick={() => onDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
|
||||||
<Trash2 size={13} />
|
<Trash2 size={13} />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export function LibraryPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
resolvingSeries,
|
||||||
selectedEpisodes,
|
selectedEpisodes,
|
||||||
visibleEpisodes,
|
visibleEpisodes,
|
||||||
selectedSeriesEpisodes,
|
selectedSeriesEpisodes,
|
||||||
@@ -132,6 +133,7 @@ export function LibraryPage() {
|
|||||||
handleSeriesClick,
|
handleSeriesClick,
|
||||||
clearSelectedSeries,
|
clearSelectedSeries,
|
||||||
} = useLibrarySeriesSelection({
|
} = useLibrarySeriesSelection({
|
||||||
|
libraryID: id,
|
||||||
items,
|
items,
|
||||||
seriesEpisodeItems,
|
seriesEpisodeItems,
|
||||||
isSeriesLibrary,
|
isSeriesLibrary,
|
||||||
@@ -209,7 +211,9 @@ export function LibraryPage() {
|
|||||||
return <>{actions}</>
|
return <>{actions}</>
|
||||||
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
|
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
|
||||||
|
|
||||||
if (loading) {
|
// resolvingSeries:深链目标剧集不在已加载的一页卡片里,正在按 key
|
||||||
|
// 单独解析。此时先显示加载态,避免先渲染整个媒体库列表再跳走。
|
||||||
|
if (loading || resolvingSeries) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-32">
|
<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">
|
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 2 }} className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function MediaDetailMainContent({
|
|||||||
onToggleFavourite={onToggleFavourite}
|
onToggleFavourite={onToggleFavourite}
|
||||||
playTargetId={playTargetId}
|
playTargetId={playTargetId}
|
||||||
/>
|
/>
|
||||||
<MediaVersionSwitcher media={media} mode="detail" />
|
<MediaVersionSwitcher media={media} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<MediaDetailAdminPanel
|
<MediaDetailAdminPanel
|
||||||
media={media}
|
media={media}
|
||||||
|
|||||||
+318
-41
@@ -19,6 +19,8 @@ import { profileAPI } from '../api/profile'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
||||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||||
|
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||||
|
import { normalizePlaybackRate } from '../utils/playbackRate'
|
||||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||||
import {
|
import {
|
||||||
normalizeSubtitleChineseMode,
|
normalizeSubtitleChineseMode,
|
||||||
@@ -32,15 +34,21 @@ import {
|
|||||||
type SubtitlePosition,
|
type SubtitlePosition,
|
||||||
type SubtitleStylePreset,
|
type SubtitleStylePreset,
|
||||||
} from '../utils/subtitleDisplay'
|
} from '../utils/subtitleDisplay'
|
||||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
|
||||||
import { classifyDirectPlayError } from './directPlayError'
|
import { classifyDirectPlayError } from './directPlayError'
|
||||||
import { apiErrorMessage } from './StrmManagePage'
|
import { apiErrorMessage } from './StrmManagePage'
|
||||||
import { PlayerTopBar } from './PlayerTopBar'
|
import { PlayerTopBar } from './PlayerTopBar'
|
||||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||||
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
|
import {
|
||||||
import { mediaVersionsOf } from '../utils/mediaVersion'
|
detectVr360Profile,
|
||||||
|
loadVr360Preference,
|
||||||
|
sameVr360Profile,
|
||||||
|
saveVr360Preference,
|
||||||
|
type Vr360Detection,
|
||||||
|
type Vr360Profile,
|
||||||
|
} from '../utils/vr360'
|
||||||
|
|
||||||
// Fullscreen, dark-themed video page.
|
// Fullscreen, dark-themed video page.
|
||||||
//
|
//
|
||||||
@@ -95,6 +103,22 @@ export function PlayerPage() {
|
|||||||
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const [media, setMedia] = useState<Media | null>(null)
|
const [media, setMedia] = useState<Media | null>(null)
|
||||||
|
// VR 全景播放:null = 普通播放。识别结果与用户开关只存本机,
|
||||||
|
// 不改动数据库,换设备/换浏览器不会互相影响。
|
||||||
|
const [vr360, setVr360] = useState<Vr360Profile | null>(null)
|
||||||
|
const [vr360Detected, setVr360Detected] = useState(false)
|
||||||
|
const vr360TouchedRef = useRef(false)
|
||||||
|
// VR 取帧失败后的自愈:只尝试一次「切到 HLS」,并在切换窗口内忽略重复报错。
|
||||||
|
const vr360TextureRetryUsedRef = useRef(false)
|
||||||
|
const vr360TextureRetryUntilRef = useRef(0)
|
||||||
|
// VR 全景下 STRM/网盘直连必须由服务端同源转发,浏览器才允许 WebGL 读取画面;
|
||||||
|
// 普通播放仍然 302 直连,避免把网盘流量压到服务器。放在 ref 里是为了让
|
||||||
|
// handleVideoError 里的 src 比对始终拿到最新值。
|
||||||
|
const vr360DirectProxy = Boolean(vr360) && isStrmMedia(media)
|
||||||
|
const vr360DirectProxyRef = useRef(vr360DirectProxy)
|
||||||
|
useEffect(() => {
|
||||||
|
vr360DirectProxyRef.current = vr360DirectProxy
|
||||||
|
}, [vr360DirectProxy])
|
||||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
||||||
@@ -109,8 +133,16 @@ export function PlayerPage() {
|
|||||||
const [playerVolume, setPlayerVolume] = useState(() =>
|
const [playerVolume, setPlayerVolume] = useState(() =>
|
||||||
normalizePlayerVolume(authUser?.player_volume),
|
normalizePlayerVolume(authUser?.player_volume),
|
||||||
)
|
)
|
||||||
|
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() =>
|
||||||
|
normalizePlaybackRate(authUser?.player_playback_rate),
|
||||||
|
)
|
||||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||||
|
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
|
||||||
|
// 这时不弹说明,避免给老用户闪一下。
|
||||||
|
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
|
||||||
const playerVolumeTouchedRef = useRef(false)
|
const playerVolumeTouchedRef = useRef(false)
|
||||||
|
const playerPlaybackRateTouchedRef = useRef(false)
|
||||||
|
const playerPlaybackRateSaveSeqRef = useRef(0)
|
||||||
const subtitlePreferenceTouchedRef = useRef(false)
|
const subtitlePreferenceTouchedRef = useRef(false)
|
||||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||||
@@ -245,16 +277,25 @@ export function PlayerPage() {
|
|||||||
if (!playerVolumeTouchedRef.current) {
|
if (!playerVolumeTouchedRef.current) {
|
||||||
setPlayerVolume(volume)
|
setPlayerVolume(volume)
|
||||||
}
|
}
|
||||||
|
const playbackRate = normalizePlaybackRate(cfg.playback_rate)
|
||||||
|
if (!playerPlaybackRateTouchedRef.current) {
|
||||||
|
setPlayerPlaybackRate(playbackRate)
|
||||||
|
}
|
||||||
setDanmakuEnabled(cfg.enabled)
|
setDanmakuEnabled(cfg.enabled)
|
||||||
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
||||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||||
setDanmakuArea(Number(cfg.area) || 1)
|
setDanmakuArea(Number(cfg.area) || 1)
|
||||||
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
||||||
|
setVr360GuideSeen(Boolean(cfg.vr360_guide_seen))
|
||||||
applyDanmakuConnectionConfig(cfg)
|
applyDanmakuConnectionConfig(cfg)
|
||||||
if (!playerVolumeTouchedRef.current) {
|
if (!playerVolumeTouchedRef.current) {
|
||||||
const current = useAuthStore.getState().user
|
const current = useAuthStore.getState().user
|
||||||
if (current) setAuthUser({ ...current, player_volume: volume })
|
if (current) setAuthUser({ ...current, player_volume: volume })
|
||||||
}
|
}
|
||||||
|
if (!playerPlaybackRateTouchedRef.current) {
|
||||||
|
const current = useAuthStore.getState().user
|
||||||
|
if (current) setAuthUser({ ...current, player_playback_rate: playbackRate })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// 配置读取失败时保持本地默认值,不影响播放。
|
// 配置读取失败时保持本地默认值,不影响播放。
|
||||||
@@ -289,6 +330,34 @@ export function PlayerPage() {
|
|||||||
[setAuthUser],
|
[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(
|
const saveDanmakuAdvanced = useCallback(
|
||||||
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
|
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
|
||||||
const patch: DanmakuSettingsPatch = {
|
const patch: DanmakuSettingsPatch = {
|
||||||
@@ -416,6 +485,11 @@ export function PlayerPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMedia(null)
|
setMedia(null)
|
||||||
setLoadError('')
|
setLoadError('')
|
||||||
|
vr360TouchedRef.current = false
|
||||||
|
vr360TextureRetryUsedRef.current = false
|
||||||
|
vr360TextureRetryUntilRef.current = 0
|
||||||
|
setVr360(null)
|
||||||
|
setVr360Detected(false)
|
||||||
setHlsStartSec(0)
|
setHlsStartSec(0)
|
||||||
setPlaybackInfo(null)
|
setPlaybackInfo(null)
|
||||||
setHlsSource('local')
|
setHlsSource('local')
|
||||||
@@ -495,6 +569,97 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [id, modeParam, directOnly])
|
}, [id, modeParam, directOnly])
|
||||||
|
|
||||||
|
// VR 全景素材识别:只在用户没手动改过开关时自动进入 VR 模式。
|
||||||
|
// 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!media || media.id !== id) return
|
||||||
|
const detection = detectVr360Profile({
|
||||||
|
title: media.title,
|
||||||
|
originalName: media.original_name,
|
||||||
|
path: media.path,
|
||||||
|
relativePath: media.relative_path,
|
||||||
|
width: media.width,
|
||||||
|
height: media.height,
|
||||||
|
})
|
||||||
|
setVr360Detected(detection.confident)
|
||||||
|
if (vr360TouchedRef.current) return
|
||||||
|
const preference = loadVr360Preference()
|
||||||
|
if (!preference.autoDetect || !detection.confident) {
|
||||||
|
setVr360(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detection.profile.projection !== preference.profile.projection) {
|
||||||
|
// 投影方式由文件名决定(360 / 180 / 鱼眼),记住它作为下次的默认值。
|
||||||
|
saveVr360Preference({ ...preference, profile: detection.profile })
|
||||||
|
}
|
||||||
|
setVr360(detection.profile)
|
||||||
|
}, [id, media])
|
||||||
|
|
||||||
|
// 远端 Emby 挂载的媒体常常没有宽高元数据(width/height 为 0),等播放器拿到
|
||||||
|
// 真实画面尺寸后再补一次识别。HLS 转码是等比缩放,所以比例依然可信。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!media || media.id !== id) return
|
||||||
|
if (media.width > 0 && media.height > 0) return
|
||||||
|
const video = ref.current
|
||||||
|
if (!video) return
|
||||||
|
const refine = () => {
|
||||||
|
if (vr360TouchedRef.current || video.videoWidth <= 0 || video.videoHeight <= 0) return
|
||||||
|
const detection = detectVr360Profile({
|
||||||
|
title: media.title,
|
||||||
|
originalName: media.original_name,
|
||||||
|
path: media.path,
|
||||||
|
relativePath: media.relative_path,
|
||||||
|
width: video.videoWidth,
|
||||||
|
height: video.videoHeight,
|
||||||
|
})
|
||||||
|
setVr360Detected(detection.confident)
|
||||||
|
const preference = loadVr360Preference()
|
||||||
|
if (!preference.autoDetect || !detection.confident) return
|
||||||
|
setVr360((current) =>
|
||||||
|
current && sameVr360Profile(current, detection.profile) ? current : detection.profile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
video.addEventListener('loadedmetadata', refine)
|
||||||
|
refine()
|
||||||
|
return () => video.removeEventListener('loadedmetadata', refine)
|
||||||
|
}, [id, media])
|
||||||
|
|
||||||
|
const toggleVr360 = useCallback(() => {
|
||||||
|
vr360TouchedRef.current = true
|
||||||
|
if (vr360) {
|
||||||
|
setVr360(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = mediaRef.current
|
||||||
|
const detection: Vr360Detection | null = current
|
||||||
|
? detectVr360Profile({
|
||||||
|
title: current.title,
|
||||||
|
originalName: current.original_name,
|
||||||
|
path: current.path,
|
||||||
|
relativePath: current.relative_path,
|
||||||
|
width: current.width,
|
||||||
|
height: current.height,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
const preference = loadVr360Preference()
|
||||||
|
const profile = detection?.profile ?? preference.profile
|
||||||
|
saveVr360Preference({ ...preference, profile })
|
||||||
|
setVr360(profile)
|
||||||
|
}, [vr360])
|
||||||
|
|
||||||
|
const changeVr360Profile = useCallback((profile: Vr360Profile) => {
|
||||||
|
vr360TouchedRef.current = true
|
||||||
|
setVr360(profile)
|
||||||
|
saveVr360Preference({ ...loadVr360Preference(), profile })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// VR 操作说明是「按用户只弹一次」:用户关掉说明时就落库,之后进入 VR 不再显示。
|
||||||
|
// 保存失败只影响下次是否再弹一次,不打断播放,所以本地状态不回滚。
|
||||||
|
const dismissVr360Guide = useCallback(() => {
|
||||||
|
setVr360GuideSeen(true)
|
||||||
|
void danmakuAPI.updateSettings({ vr360_guide_seen: true }).catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Wire up the actual <video> element when we know the mode.
|
// Wire up the actual <video> element when we know the mode.
|
||||||
// Depend on media.id (not the media object): refreshing duration after
|
// Depend on media.id (not the media object): refreshing duration after
|
||||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||||
@@ -566,6 +731,33 @@ export function PlayerPage() {
|
|||||||
[mediaId, setPlaybackMode, switchToLocalHLS],
|
[mediaId, setPlaybackMode, switchToLocalHLS],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 切到 HLS 播放(优先 115 云端,其次本地转码)。toggleMode 与「VR 需要同源帧」
|
||||||
|
// 的自愈逻辑共用,避免两处各写一遍云端/本地选择。
|
||||||
|
const enterHlsPlayback = useCallback(
|
||||||
|
(startSec: number) => {
|
||||||
|
setHlsStartSec(startSec)
|
||||||
|
const preferred =
|
||||||
|
playbackInfo?.provider === 'cloud115'
|
||||||
|
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
||||||
|
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
|
||||||
|
: undefined
|
||||||
|
if (preferred?.source === 'cloud') {
|
||||||
|
setHlsSource('cloud')
|
||||||
|
if (preferred.available) {
|
||||||
|
setCloudWaiting(false)
|
||||||
|
} else {
|
||||||
|
setCloudWaiting(true)
|
||||||
|
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
|
||||||
|
void startCloudTranscode(Number(preferred.id) || 4)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switchToLocalHLS(startSec)
|
||||||
|
}
|
||||||
|
setPlaybackMode('hls')
|
||||||
|
},
|
||||||
|
[playbackInfo, selectedQuality, setPlaybackMode, startCloudTranscode, switchToLocalHLS],
|
||||||
|
)
|
||||||
|
|
||||||
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
|
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cloudWaiting || !mediaId) return
|
if (!cloudWaiting || !mediaId) return
|
||||||
@@ -750,13 +942,30 @@ export function PlayerPage() {
|
|||||||
setPlaybackMode('direct')
|
setPlaybackMode('direct')
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const url = streamURL(mediaId)
|
// VR 全景需要浏览器能读帧:STRM/网盘直链会被 302 到跨域 CDN,此时改为
|
||||||
|
// 服务端同源转发(原画,不转码);普通播放仍走 302 直连,省服务器流量。
|
||||||
|
const url = streamURL(mediaId, { proxy: vr360DirectProxyRef.current })
|
||||||
const absoluteURL = new URL(url, window.location.href).href
|
const absoluteURL = new URL(url, window.location.href).href
|
||||||
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
|
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
|
||||||
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
|
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
|
||||||
if (video.src !== absoluteURL) {
|
if (video.src !== absoluteURL) {
|
||||||
directRetryRef.current = false
|
directRetryRef.current = false
|
||||||
clearFallbackTimer()
|
clearFallbackTimer()
|
||||||
|
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
|
||||||
|
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
|
||||||
|
if (resumeAt > 0) {
|
||||||
|
video.addEventListener(
|
||||||
|
'loadedmetadata',
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
video.currentTime = resumeAt
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
video.src = url
|
video.src = url
|
||||||
void video.play().catch(() => undefined)
|
void video.play().catch(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -785,6 +994,7 @@ export function PlayerPage() {
|
|||||||
setPlaybackMode,
|
setPlaybackMode,
|
||||||
switchToLocalHLS,
|
switchToLocalHLS,
|
||||||
teardownHls,
|
teardownHls,
|
||||||
|
vr360DirectProxy,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
||||||
@@ -973,9 +1183,12 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [id, media])
|
}, [id, media])
|
||||||
|
|
||||||
|
// 选集列表按版本组折叠,行的 id 是组内最优版本;用户切到同一条目的其它
|
||||||
|
// 版本后 media.id 与行 id 不再相等,这里必须按版本组比对,否则上一集/下一集
|
||||||
|
// 与自动连播都会失效。
|
||||||
const currentEpisodeIndex = useMemo(() => {
|
const currentEpisodeIndex = useMemo(() => {
|
||||||
if (!media || playlistEpisodes.length === 0) return -1
|
if (!media || playlistEpisodes.length === 0) return -1
|
||||||
return playlistEpisodes.findIndex((e) => e.id === media.id)
|
return playlistEpisodes.findIndex((e) => mediaVersionMatches(e, media.id))
|
||||||
}, [media, playlistEpisodes])
|
}, [media, playlistEpisodes])
|
||||||
|
|
||||||
const prevEpisode = useMemo(() => {
|
const prevEpisode = useMemo(() => {
|
||||||
@@ -1013,8 +1226,8 @@ export function PlayerPage() {
|
|||||||
[navigate, location.search, location.state],
|
[navigate, location.search, location.state],
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本入口。
|
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本信息。
|
||||||
const versionList = useMemo(
|
const currentVersions = useMemo(
|
||||||
() => (media?.id === id ? mediaVersionsOf(media) : []),
|
() => (media?.id === id ? mediaVersionsOf(media) : []),
|
||||||
[id, media],
|
[id, media],
|
||||||
)
|
)
|
||||||
@@ -1113,7 +1326,47 @@ export function PlayerPage() {
|
|||||||
playbackInfo?.provider === 'cloud115'
|
playbackInfo?.provider === 'cloud115'
|
||||||
? [...(playbackInfo.cloud_qualities ?? []), ...(playbackInfo.local_qualities ?? [])]
|
? [...(playbackInfo.cloud_qualities ?? []), ...(playbackInfo.local_qualities ?? [])]
|
||||||
: (playbackInfo?.local_qualities ?? [])
|
: (playbackInfo?.local_qualities ?? [])
|
||||||
const showQuality = mode !== 'direct' && qualityOptions.length > 0
|
// 直连解码模式下宿主机不转码,档位选择没有意义(远程 Emby 挂载同理):直接隐藏。
|
||||||
|
const showQuality = !directOnly && qualityOptions.length > 0
|
||||||
|
// 清晰度按钮上显示的文字。直接播放时按「原画」呈现,和档位列表里的原画项一致,
|
||||||
|
// 避免出现「明明是原文件却显示 1080P」这种误导。
|
||||||
|
const originalQualityLabel =
|
||||||
|
qualityOptions.find((quality) => quality.source === 'original')?.label || '原画'
|
||||||
|
const selectedQualityLabel = findPlaybackQualityById(playbackInfo, selectedQuality)?.label || ''
|
||||||
|
const qualityLabel =
|
||||||
|
mode === 'direct' ? originalQualityLabel : selectedQualityLabel || '清晰度'
|
||||||
|
// 播放方式只描述状态,切换入口在控制栏的「设置」面板里(见 onTogglePlaybackMode)。
|
||||||
|
const playbackModeLabel = isDirectStream
|
||||||
|
? isRemoteEmbyID(media?.id)
|
||||||
|
? 'Emby 直连播放'
|
||||||
|
: '直连播放'
|
||||||
|
: directOnly
|
||||||
|
? '客户端直连解码'
|
||||||
|
: mode === 'hls'
|
||||||
|
? 'HLS 转码'
|
||||||
|
: '直接播放'
|
||||||
|
const canTogglePlaybackMode = !isDirectStream && !directOnly
|
||||||
|
|
||||||
|
// 顶栏标题下的次要信息:集数进度与版本数量,让用户一眼知道「在看什么、在哪」。
|
||||||
|
const topBarTitle = media?.title?.trim() || ''
|
||||||
|
const topBarSubtitle = useMemo(() => {
|
||||||
|
if (!media) return ''
|
||||||
|
const parts: string[] = []
|
||||||
|
if (prevEpisode || nextEpisode) {
|
||||||
|
const total = playlistEpisodes.length
|
||||||
|
const index = currentEpisodeIndex + 1
|
||||||
|
parts.push(total > 0 ? `第 ${index} / ${total} 集` : `第 ${index} 集`)
|
||||||
|
}
|
||||||
|
if (currentVersions.length > 1) parts.push(`${currentVersions.length} 个版本`)
|
||||||
|
return parts.join(' · ')
|
||||||
|
}, [
|
||||||
|
media,
|
||||||
|
currentEpisodeIndex,
|
||||||
|
currentVersions.length,
|
||||||
|
nextEpisode,
|
||||||
|
playlistEpisodes.length,
|
||||||
|
prevEpisode,
|
||||||
|
])
|
||||||
|
|
||||||
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
|
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1144,38 +1397,49 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||||
if (next === 'hls') {
|
if (next === 'hls') {
|
||||||
setHlsStartSec(0)
|
enterHlsPlayback(0)
|
||||||
const preferred =
|
|
||||||
playbackInfo?.provider === 'cloud115'
|
|
||||||
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
|
||||||
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
|
|
||||||
: undefined
|
|
||||||
if (preferred?.source === 'cloud') {
|
|
||||||
setHlsSource('cloud')
|
|
||||||
if (preferred.available) {
|
|
||||||
setCloudWaiting(false)
|
|
||||||
} else {
|
|
||||||
setCloudWaiting(true)
|
|
||||||
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
|
|
||||||
void startCloudTranscode(Number(preferred.id) || 4)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switchToLocalHLS()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setCloudWaiting(false)
|
setCloudWaiting(false)
|
||||||
}
|
}
|
||||||
setPlaybackMode(next)
|
setPlaybackMode(next)
|
||||||
}, [
|
}, [
|
||||||
|
enterHlsPlayback,
|
||||||
isDirectStream,
|
isDirectStream,
|
||||||
mode,
|
mode,
|
||||||
playbackInfo,
|
|
||||||
selectedQuality,
|
|
||||||
setPlaybackMode,
|
setPlaybackMode,
|
||||||
startCloudTranscode,
|
|
||||||
switchToLocalHLS,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// VR 渲染取不到帧时:网盘/STRM 的直连会 302 跳到 CDN,视频就变成跨域资源,
|
||||||
|
// 浏览器禁止 WebGL 读取(texImage2D 抛 SecurityError)。此时自动切到同源的
|
||||||
|
// HLS(云端优先)再继续 VR,只有确实做不到时才如实报错并退出 VR。
|
||||||
|
const handleVr360Error = useCallback(
|
||||||
|
(message: string) => {
|
||||||
|
const now = Date.now()
|
||||||
|
// 切换 HLS 的过程中旧的直连帧还会继续上报失败,先忽略这一窗口内的重复错误。
|
||||||
|
if (now < vr360TextureRetryUntilRef.current) return
|
||||||
|
const canRetryWithHls =
|
||||||
|
!vr360TextureRetryUsedRef.current &&
|
||||||
|
modeRef.current === 'direct' &&
|
||||||
|
!directOnly &&
|
||||||
|
!isRemoteEmbyID(mediaRef.current?.id)
|
||||||
|
if (canRetryWithHls) {
|
||||||
|
vr360TextureRetryUsedRef.current = true
|
||||||
|
vr360TextureRetryUntilRef.current = now + 15_000
|
||||||
|
setPlayerError('')
|
||||||
|
toast('直连源跨域,浏览器无法取帧;正在切到 HLS 转码后继续 VR 全景')
|
||||||
|
enterHlsPlayback(ref.current?.currentTime || 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vr360TouchedRef.current = true
|
||||||
|
setVr360(null)
|
||||||
|
setPlayerError(
|
||||||
|
`${message}。若当前是网盘/STRM 直连(会 302 跳转到 CDN),浏览器不允许 WebGL 读取跨域画面,请改用 HLS 播放后再开启 VR。`,
|
||||||
|
)
|
||||||
|
toast.error(message)
|
||||||
|
},
|
||||||
|
[directOnly, enterHlsPlayback],
|
||||||
|
)
|
||||||
|
|
||||||
const handleSeekAbsolute = useCallback(
|
const handleSeekAbsolute = useCallback(
|
||||||
(absoluteSec: number) => {
|
(absoluteSec: number) => {
|
||||||
if (mode !== 'hls') return false
|
if (mode !== 'hls') return false
|
||||||
@@ -1301,7 +1565,9 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
if (retryingDirectRef.current) return
|
if (retryingDirectRef.current) return
|
||||||
|
|
||||||
const expectedSrc = mediaId ? new URL(streamURL(mediaId), window.location.href).href : ''
|
const expectedSrc = mediaId
|
||||||
|
? new URL(streamURL(mediaId, { proxy: vr360DirectProxyRef.current }), window.location.href).href
|
||||||
|
: ''
|
||||||
const action = classifyDirectPlayError({
|
const action = classifyDirectPlayError({
|
||||||
errorCode: video?.error?.code,
|
errorCode: video?.error?.code,
|
||||||
readyState: video?.readyState ?? 0,
|
readyState: video?.readyState ?? 0,
|
||||||
@@ -1392,20 +1658,14 @@ export function PlayerPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
|
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
|
||||||
<PlayerTopBar
|
<PlayerTopBar
|
||||||
|
title={topBarTitle}
|
||||||
|
subtitle={topBarSubtitle}
|
||||||
directOnly={directOnly}
|
directOnly={directOnly}
|
||||||
isDirectStream={isDirectStream}
|
isDirectStream={isDirectStream}
|
||||||
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
|
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onBack={goBack}
|
onBack={goBack}
|
||||||
onToggleMode={toggleMode}
|
|
||||||
/>
|
/>
|
||||||
{versionList.length > 1 && (
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-16 z-20 flex justify-center px-4 sm:top-20">
|
|
||||||
<div className="pointer-events-auto max-w-3xl rounded-2xl border border-white/15 bg-black/70 px-3 py-2 shadow-xl backdrop-blur">
|
|
||||||
<MediaVersionSwitcher media={media!} mode="player" onSelect={switchVersion} className="text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<PlayerVideoStage
|
<PlayerVideoStage
|
||||||
media={media}
|
media={media}
|
||||||
loadError={loadError}
|
loadError={loadError}
|
||||||
@@ -1424,6 +1684,9 @@ export function PlayerPage() {
|
|||||||
playerVolume={playerVolume}
|
playerVolume={playerVolume}
|
||||||
onPlayerVolumeChange={changePlayerVolume}
|
onPlayerVolumeChange={changePlayerVolume}
|
||||||
onPlayerVolumeCommit={commitPlayerVolume}
|
onPlayerVolumeCommit={commitPlayerVolume}
|
||||||
|
playerPlaybackRate={playerPlaybackRate}
|
||||||
|
onPlayerPlaybackRateChange={changePlayerPlaybackRate}
|
||||||
|
onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
|
||||||
danmakuEnabled={danmakuEnabled}
|
danmakuEnabled={danmakuEnabled}
|
||||||
danmakuOpacity={danmakuOpacity}
|
danmakuOpacity={danmakuOpacity}
|
||||||
danmakuFontSize={danmakuFontSize}
|
danmakuFontSize={danmakuFontSize}
|
||||||
@@ -1432,7 +1695,8 @@ export function PlayerPage() {
|
|||||||
danmakuEpisodeId={danmakuEpisodeId}
|
danmakuEpisodeId={danmakuEpisodeId}
|
||||||
danmakuSearchTrigger={danmakuSearchTrigger}
|
danmakuSearchTrigger={danmakuSearchTrigger}
|
||||||
danmakuOpen={danmakuOpen}
|
danmakuOpen={danmakuOpen}
|
||||||
onToggleDanmaku={toggleDanmakuOpen}
|
onOpenDanmaku={toggleDanmakuOpen}
|
||||||
|
onToggleDanmakuEnabled={danmakuChangeEnabled}
|
||||||
onDanmakuLoaded={danmakuLoaded}
|
onDanmakuLoaded={danmakuLoaded}
|
||||||
onDanmakuCandidates={danmakuGotCandidates}
|
onDanmakuCandidates={danmakuGotCandidates}
|
||||||
onDanmakuAlternatives={danmakuGotAlternatives}
|
onDanmakuAlternatives={danmakuGotAlternatives}
|
||||||
@@ -1443,24 +1707,37 @@ export function PlayerPage() {
|
|||||||
prevEpisodeTitle={prevEpisodeTitle}
|
prevEpisodeTitle={prevEpisodeTitle}
|
||||||
nextEpisodeTitle={nextEpisodeTitle}
|
nextEpisodeTitle={nextEpisodeTitle}
|
||||||
playlistOpen={playlistOpen}
|
playlistOpen={playlistOpen}
|
||||||
hasPlaylist={playlistEpisodes.length > 0}
|
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
|
||||||
|
hasVersions={currentVersions.length > 1}
|
||||||
onTogglePlaylist={togglePlaylistOpen}
|
onTogglePlaylist={togglePlaylistOpen}
|
||||||
knownDuration={media?.duration_sec || 0}
|
knownDuration={media?.duration_sec || 0}
|
||||||
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
||||||
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
||||||
qualities={qualityOptions}
|
qualities={qualityOptions}
|
||||||
|
qualityLabel={qualityLabel}
|
||||||
selectedQuality={selectedQuality}
|
selectedQuality={selectedQuality}
|
||||||
onSelectQuality={selectPlaybackQuality}
|
onSelectQuality={selectPlaybackQuality}
|
||||||
showQuality={showQuality}
|
showQuality={showQuality}
|
||||||
|
playbackModeLabel={playbackModeLabel}
|
||||||
|
onTogglePlaybackMode={canTogglePlaybackMode ? toggleMode : undefined}
|
||||||
waiting={cloudWaiting}
|
waiting={cloudWaiting}
|
||||||
waitingMessage={cloudWaitMessage}
|
waitingMessage={cloudWaitMessage}
|
||||||
|
vr360={vr360}
|
||||||
|
vr360Detected={vr360Detected}
|
||||||
|
onToggleVr360={toggleVr360}
|
||||||
|
onVr360ProfileChange={changeVr360Profile}
|
||||||
|
onVr360Error={handleVr360Error}
|
||||||
|
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||||
|
onDismissVr360Guide={dismissVr360Guide}
|
||||||
playlistPanel={
|
playlistPanel={
|
||||||
<PlayerPlaylistPanel
|
<PlayerPlaylistPanel
|
||||||
open={playlistOpen}
|
open={playlistOpen}
|
||||||
onClose={() => setPlaylistOpen(false)}
|
onClose={() => setPlaylistOpen(false)}
|
||||||
currentMediaId={media?.id ?? ''}
|
currentMediaId={media?.id ?? ''}
|
||||||
|
currentVersions={currentVersions}
|
||||||
episodes={playlistEpisodes}
|
episodes={playlistEpisodes}
|
||||||
onSelectEpisode={playEpisode}
|
onSelectEpisode={playEpisode}
|
||||||
|
onSelectVersion={switchVersion}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
danmakuPanel={
|
danmakuPanel={
|
||||||
|
|||||||
@@ -1,66 +1,93 @@
|
|||||||
import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react'
|
import { ArrowLeft, Gauge, RefreshCw, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
import type { PlayerMode } from './playerPageModel'
|
import type { PlayerMode } from './playerPageModel'
|
||||||
|
import { PLAYER_ICON_BUTTON } from '../components/playerTheme'
|
||||||
|
|
||||||
|
// PlayerTopBar — 播放器顶部的极简浮层。
|
||||||
|
//
|
||||||
|
// 以前这里只有「返回」和一个播放方式药丸按钮,用户看不到自己在看什么;而播放方式
|
||||||
|
// 又要和控制栏里的画质/倍速混在一起点。现在改成 B 站那样:左边是返回图标 + 当前
|
||||||
|
// 媒体标题,右边只留一个只读的播放方式状态标签(真正的切换入口收进了控制栏的
|
||||||
|
// 「设置」面板),顶栏因此不再参与「一堆药丸按钮」的视觉噪音。
|
||||||
|
|
||||||
type PlayerTopBarProps = {
|
type PlayerTopBarProps = {
|
||||||
|
/** 当前播放的媒体标题。 */
|
||||||
|
title?: string
|
||||||
|
/** 标题下的次要信息(集数、版本等)。 */
|
||||||
|
subtitle?: string
|
||||||
directOnly: boolean
|
directOnly: boolean
|
||||||
isDirectStream?: boolean
|
isDirectStream?: boolean
|
||||||
directStreamLabel?: string
|
directStreamLabel?: string
|
||||||
mode: PlayerMode
|
mode: PlayerMode
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onToggleMode: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerTopBar({
|
export function PlayerTopBar({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
directOnly,
|
directOnly,
|
||||||
isDirectStream,
|
isDirectStream,
|
||||||
directStreamLabel,
|
directStreamLabel,
|
||||||
mode,
|
mode,
|
||||||
onBack,
|
onBack,
|
||||||
onToggleMode,
|
|
||||||
}: PlayerTopBarProps) {
|
}: PlayerTopBarProps) {
|
||||||
return (
|
const status = isDirectStream
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
|
? {
|
||||||
<button
|
icon: <Sparkles size={12} />,
|
||||||
onClick={onBack}
|
label: directStreamLabel || '直连播放',
|
||||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
title: directStreamLabel
|
||||||
>
|
? `${directStreamLabel},默认直连播放,不进行转码`
|
||||||
<ArrowLeft size={16} /> 返回
|
: '远程直连播放,不进行转码',
|
||||||
</button>
|
}
|
||||||
|
: directOnly
|
||||||
|
? {
|
||||||
|
icon: <Sparkles size={12} />,
|
||||||
|
label: '客户端直连解码',
|
||||||
|
title: '宿主机不转码,由客户端本地解码直连',
|
||||||
|
}
|
||||||
|
: mode === 'hls'
|
||||||
|
? {
|
||||||
|
icon: <RefreshCw size={12} />,
|
||||||
|
label: 'HLS 转码',
|
||||||
|
title: '当前为 HLS 转码播放,可在控制栏「设置」里切回直接播放',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
icon: <Gauge size={12} />,
|
||||||
|
label: '直接播放',
|
||||||
|
title: '当前直接播放原始文件,可在控制栏「设置」里切到 HLS 转码',
|
||||||
|
}
|
||||||
|
|
||||||
<div className="pointer-events-auto flex items-center gap-2">
|
return (
|
||||||
{isDirectStream ? (
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 bg-gradient-to-b from-black/60 via-black/20 to-transparent px-2.5 pb-10 pt-2.5 sm:px-3.5 sm:pt-3.5">
|
||||||
<span
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
<button
|
||||||
title={directStreamLabel ? `${directStreamLabel},默认直连播放,不进行转码` : '远程直连播放,不进行转码'}
|
onClick={onBack}
|
||||||
>
|
className={`${PLAYER_ICON_BUTTON} pointer-events-auto h-8 w-8 bg-black/35 backdrop-blur hover:bg-black/60 sm:h-9 sm:w-9`}
|
||||||
<Sparkles size={14} /> {directStreamLabel || '直连播放'}
|
title="返回 (Esc)"
|
||||||
</span>
|
>
|
||||||
) : directOnly ? (
|
<ArrowLeft size={18} />
|
||||||
<span
|
</button>
|
||||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
{title ? (
|
||||||
title="宿主机不转码,由客户端本地解码直连"
|
<div className="min-w-0">
|
||||||
>
|
<p className="truncate text-sm font-medium text-white/95 drop-shadow-[0_1px_4px_rgba(0,0,0,0.8)]">
|
||||||
<Sparkles size={14} /> 客户端直连解码
|
{title}
|
||||||
</span>
|
</p>
|
||||||
) : (
|
{subtitle ? (
|
||||||
<button
|
<p className="truncate text-[11px] text-white/65 drop-shadow-[0_1px_4px_rgba(0,0,0,0.8)]">
|
||||||
onClick={onToggleMode}
|
{subtitle}
|
||||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
</p>
|
||||||
title="切换播放模式"
|
) : null}
|
||||||
>
|
</div>
|
||||||
{mode === 'hls' ? (
|
) : null}
|
||||||
<>
|
|
||||||
<RefreshCw size={14} /> HLS 转码中
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles size={14} /> 直接播放
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="pointer-events-auto flex shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-[11px] font-medium text-white/80 backdrop-blur"
|
||||||
|
title={status.title}
|
||||||
|
>
|
||||||
|
{status.icon}
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||||
import { Loader2 } from 'lucide-react'
|
import {
|
||||||
|
CircleAlert,
|
||||||
|
Hand,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
LockOpen,
|
||||||
|
MousePointerClick,
|
||||||
|
Rotate3d,
|
||||||
|
Smartphone,
|
||||||
|
ZoomIn,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||||
@@ -12,11 +22,14 @@ import {
|
|||||||
import { AssSubtitleStage } from '../components/AssSubtitleStage'
|
import { AssSubtitleStage } from '../components/AssSubtitleStage'
|
||||||
import { DanmakuStage } from '../components/DanmakuStage'
|
import { DanmakuStage } from '../components/DanmakuStage'
|
||||||
import { PlayerControls } from '../components/PlayerControls'
|
import { PlayerControls } from '../components/PlayerControls'
|
||||||
|
import { Vr360Stage } from '../components/Vr360Stage'
|
||||||
|
import type { Vr360Profile } from '../utils/vr360'
|
||||||
import {
|
import {
|
||||||
subtitleTextStyle,
|
subtitleTextStyle,
|
||||||
type SubtitlePosition,
|
type SubtitlePosition,
|
||||||
type SubtitleStylePreset,
|
type SubtitleStylePreset,
|
||||||
} from '../utils/subtitleDisplay'
|
} from '../utils/subtitleDisplay'
|
||||||
|
import { isPointerInLockZone } from '../utils/playerLockZone'
|
||||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||||
|
|
||||||
type SubtitleRenderGroup = {
|
type SubtitleRenderGroup = {
|
||||||
@@ -25,6 +38,16 @@ type SubtitleRenderGroup = {
|
|||||||
style: CSSProperties
|
style: CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁按钮每次被唤出后停留的时间(毫秒)。
|
||||||
|
*
|
||||||
|
* 锁本身默认隐身,只在「鼠标悬停感应区」或「点击画面」之后露一会儿。解锁的瞬间
|
||||||
|
* 操作栏会立刻消失,如果锁也一起消失,用户就不知道锁在哪、也没法再锁回去。
|
||||||
|
*/
|
||||||
|
const LOCK_REVEAL_MS = 2600
|
||||||
|
/** 首次操作说明的最长停留时间:到这里仍未点「知道了」也自动收起并记录为已看过。 */
|
||||||
|
const VR_GUIDE_AUTO_DISMISS_MS = 20_000
|
||||||
|
|
||||||
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const unique: SubtitleCue[] = []
|
const unique: SubtitleCue[] = []
|
||||||
@@ -151,6 +174,9 @@ type PlayerVideoStageProps = {
|
|||||||
playerVolume: number
|
playerVolume: number
|
||||||
onPlayerVolumeChange: (volume: number) => void
|
onPlayerVolumeChange: (volume: number) => void
|
||||||
onPlayerVolumeCommit: (volume: number) => void
|
onPlayerVolumeCommit: (volume: number) => void
|
||||||
|
playerPlaybackRate: number
|
||||||
|
onPlayerPlaybackRateChange: (rate: number) => void
|
||||||
|
onPlayerPlaybackRateCommit: (rate: number) => void
|
||||||
danmakuEnabled: boolean
|
danmakuEnabled: boolean
|
||||||
danmakuOpacity: number
|
danmakuOpacity: number
|
||||||
danmakuFontSize: number
|
danmakuFontSize: number
|
||||||
@@ -159,7 +185,10 @@ type PlayerVideoStageProps = {
|
|||||||
danmakuEpisodeId: number | string | null
|
danmakuEpisodeId: number | string | null
|
||||||
danmakuSearchTrigger?: number
|
danmakuSearchTrigger?: number
|
||||||
danmakuOpen: boolean
|
danmakuOpen: boolean
|
||||||
onToggleDanmaku: () => void
|
/** 打开弹幕设置面板(搜索弹幕库、调整渲染参数)。 */
|
||||||
|
onOpenDanmaku: () => void
|
||||||
|
/** 操作栏上的「弹」开关:只控制画面上的弹幕是否渲染。 */
|
||||||
|
onToggleDanmakuEnabled: (next: boolean) => void
|
||||||
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
|
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
|
||||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||||
onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
|
onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
|
||||||
@@ -175,14 +204,32 @@ type PlayerVideoStageProps = {
|
|||||||
nextEpisodeTitle?: string
|
nextEpisodeTitle?: string
|
||||||
playlistOpen?: boolean
|
playlistOpen?: boolean
|
||||||
hasPlaylist?: boolean
|
hasPlaylist?: boolean
|
||||||
|
/** 当前条目存在多个版本(选集面板里提供版本切换,按钮提示随之变化)。 */
|
||||||
|
hasVersions?: boolean
|
||||||
onTogglePlaylist?: () => void
|
onTogglePlaylist?: () => void
|
||||||
knownDuration?: number
|
knownDuration?: number
|
||||||
streamOffset?: number
|
streamOffset?: number
|
||||||
onSeekAbsolute?: (seconds: number) => boolean
|
onSeekAbsolute?: (seconds: number) => boolean
|
||||||
qualities?: PlaybackQuality[]
|
qualities?: PlaybackQuality[]
|
||||||
|
/** 清晰度按钮上显示的文字(播放页按当前播放方式算好)。 */
|
||||||
|
qualityLabel?: string
|
||||||
selectedQuality?: string
|
selectedQuality?: string
|
||||||
onSelectQuality?: (quality: PlaybackQuality) => void
|
onSelectQuality?: (quality: PlaybackQuality) => void
|
||||||
showQuality?: boolean
|
showQuality?: boolean
|
||||||
|
/** 当前播放方式的文字描述,展示在设置面板里。 */
|
||||||
|
playbackModeLabel?: string
|
||||||
|
/** 可切换播放方式时提供;不提供则设置面板里只显示状态。 */
|
||||||
|
onTogglePlaybackMode?: () => void
|
||||||
|
/** VR 全景播放配置;非 null 表示当前处于 VR 模式。 */
|
||||||
|
vr360?: Vr360Profile | null
|
||||||
|
/** 是否由文件名/画幅自动识别出 VR 素材(用于在菜单里提示)。 */
|
||||||
|
vr360Detected?: boolean
|
||||||
|
onToggleVr360?: () => void
|
||||||
|
onVr360ProfileChange?: (profile: Vr360Profile) => void
|
||||||
|
onVr360Error?: (message: string) => void
|
||||||
|
/** 是否展示 VR 全景播放的首次操作说明(按用户只弹一次)。 */
|
||||||
|
showVr360Guide?: boolean
|
||||||
|
onDismissVr360Guide?: () => void
|
||||||
waiting?: boolean
|
waiting?: boolean
|
||||||
waitingMessage?: string
|
waitingMessage?: string
|
||||||
}
|
}
|
||||||
@@ -205,6 +252,9 @@ export function PlayerVideoStage({
|
|||||||
playerVolume,
|
playerVolume,
|
||||||
onPlayerVolumeChange,
|
onPlayerVolumeChange,
|
||||||
onPlayerVolumeCommit,
|
onPlayerVolumeCommit,
|
||||||
|
playerPlaybackRate,
|
||||||
|
onPlayerPlaybackRateChange,
|
||||||
|
onPlayerPlaybackRateCommit,
|
||||||
danmakuEnabled,
|
danmakuEnabled,
|
||||||
danmakuOpacity,
|
danmakuOpacity,
|
||||||
danmakuFontSize,
|
danmakuFontSize,
|
||||||
@@ -213,7 +263,8 @@ export function PlayerVideoStage({
|
|||||||
danmakuEpisodeId,
|
danmakuEpisodeId,
|
||||||
danmakuSearchTrigger = 0,
|
danmakuSearchTrigger = 0,
|
||||||
danmakuOpen,
|
danmakuOpen,
|
||||||
onToggleDanmaku,
|
onOpenDanmaku,
|
||||||
|
onToggleDanmakuEnabled,
|
||||||
onDanmakuLoaded,
|
onDanmakuLoaded,
|
||||||
onDanmakuCandidates,
|
onDanmakuCandidates,
|
||||||
onDanmakuAlternatives,
|
onDanmakuAlternatives,
|
||||||
@@ -227,14 +278,25 @@ export function PlayerVideoStage({
|
|||||||
nextEpisodeTitle,
|
nextEpisodeTitle,
|
||||||
playlistOpen,
|
playlistOpen,
|
||||||
hasPlaylist,
|
hasPlaylist,
|
||||||
|
hasVersions = false,
|
||||||
onTogglePlaylist,
|
onTogglePlaylist,
|
||||||
knownDuration,
|
knownDuration,
|
||||||
streamOffset,
|
streamOffset,
|
||||||
onSeekAbsolute,
|
onSeekAbsolute,
|
||||||
qualities = [],
|
qualities = [],
|
||||||
|
qualityLabel = '',
|
||||||
selectedQuality = '',
|
selectedQuality = '',
|
||||||
onSelectQuality,
|
onSelectQuality,
|
||||||
showQuality = false,
|
showQuality = false,
|
||||||
|
playbackModeLabel = '',
|
||||||
|
onTogglePlaybackMode,
|
||||||
|
vr360 = null,
|
||||||
|
vr360Detected = false,
|
||||||
|
onToggleVr360,
|
||||||
|
onVr360ProfileChange,
|
||||||
|
onVr360Error,
|
||||||
|
showVr360Guide = false,
|
||||||
|
onDismissVr360Guide,
|
||||||
waiting = false,
|
waiting = false,
|
||||||
waitingMessage = '',
|
waitingMessage = '',
|
||||||
}: PlayerVideoStageProps) {
|
}: PlayerVideoStageProps) {
|
||||||
@@ -242,6 +304,27 @@ export function PlayerVideoStage({
|
|||||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||||
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
||||||
const [controlsVisible, setControlsVisible] = useState(true)
|
const [controlsVisible, setControlsVisible] = useState(true)
|
||||||
|
// 鼠标是否停在 VR 工具条/设置面板上:停住时控制栏不自动隐藏,否则设置面板会在点击前消失。
|
||||||
|
const [vrUiHovered, setVrUiHovered] = useState(false)
|
||||||
|
// 画面是否被「锁住」:锁上之后操作栏、VR 工具条、进度框全部隐藏,只剩干净的
|
||||||
|
// 画面,轻触画面也不会把它们唤回来,也不会误触发播放/暂停;想看操作栏必须先
|
||||||
|
// 点左边的锁解锁。VR 和普通播放共用这一套状态。
|
||||||
|
const [locked, setLocked] = useState(false)
|
||||||
|
// 锁按钮本身默认隐身(见下方的 lockVisible),这三个状态决定它什么时候露面:
|
||||||
|
// lockHovered —— 桌面鼠标停在感应区里(移开就收);
|
||||||
|
// lockShown —— 点了画面之后的临时显示窗口;
|
||||||
|
// lockHint —— 刚解锁,顺带显示「已解锁」提示。
|
||||||
|
const [lockHovered, setLockHovered] = useState(false)
|
||||||
|
const [lockShown, setLockShown] = useState(false)
|
||||||
|
const [lockHint, setLockHint] = useState(false)
|
||||||
|
const lockRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const lockVisible = lockHovered || lockShown
|
||||||
|
const vrActive = Boolean(vr360)
|
||||||
|
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
|
||||||
|
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
|
||||||
|
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
|
||||||
|
const [vrReadyMediaId, setVrReadyMediaId] = useState<string | null>(null)
|
||||||
|
const vrReady = Boolean(media && vrReadyMediaId === media.id)
|
||||||
const revealControlsOnlyRef = useRef(false)
|
const revealControlsOnlyRef = useRef(false)
|
||||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||||
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
||||||
@@ -383,18 +466,117 @@ export function PlayerVideoStage({
|
|||||||
if (video.paused) void video.play()?.catch(() => undefined)
|
if (video.paused) void video.play()?.catch(() => undefined)
|
||||||
else video.pause()
|
else video.pause()
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 临时把锁按钮唤出来(移动端点击画面、以及刚解锁时)。
|
||||||
|
*
|
||||||
|
* 每次都重新计时:连续点击时不会因为第一次的计时器到点而提前隐身。
|
||||||
|
*/
|
||||||
|
const revealLock = useCallback((withUnlockHint: boolean) => {
|
||||||
|
setLockShown(true)
|
||||||
|
setLockHint(withUnlockHint)
|
||||||
|
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
|
||||||
|
lockRevealTimerRef.current = setTimeout(() => {
|
||||||
|
lockRevealTimerRef.current = null
|
||||||
|
setLockShown(false)
|
||||||
|
setLockHint(false)
|
||||||
|
}, LOCK_REVEAL_MS)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
|
// 触摸端没有悬停,靠点击画面把锁唤出来(顺带给刚上锁的用户一个解锁入口)。
|
||||||
|
if (event.pointerType === 'touch') revealLock(false)
|
||||||
|
// 锁住时轻触画面不做任何事;VR 模式下也不负责唤出控制栏(画面默认是干净的)。
|
||||||
|
if (locked) {
|
||||||
|
revealControlsOnlyRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 未锁的移动端:控制栏隐藏时首次轻触只唤出控制栏,不切换播放。
|
||||||
|
revealControlsOnlyRef.current =
|
||||||
|
!vrActive && event.pointerType === 'touch' && !controlsVisible
|
||||||
}
|
}
|
||||||
const handleStageClick = () => {
|
|
||||||
|
// 桌面端:鼠标移到画面左侧中间就把锁露出来,移开就收回去。
|
||||||
|
const handleStagePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.pointerType !== 'mouse') return
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
setLockHovered(
|
||||||
|
isPointerInLockZone(event.clientX - rect.left, event.clientY - rect.top, rect.height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 舞台任意位置的「激活」(轻触/单击):
|
||||||
|
// 普通播放:桌面端直接切换播放/暂停;移动端控制栏隐藏时首次轻触只唤出控制栏,
|
||||||
|
// 控制栏已显示时再次轻触才切换播放/暂停。
|
||||||
|
// VR 模式:单击只负责显示/收起操作栏,不切换播放。
|
||||||
|
// 两种情况都在锁住时完全无反应——只能点锁解锁(锁由上面的 pointerdown 唤出)。
|
||||||
|
const handleSurfaceActivate = () => {
|
||||||
|
if (locked) return
|
||||||
if (revealControlsOnlyRef.current) {
|
if (revealControlsOnlyRef.current) {
|
||||||
revealControlsOnlyRef.current = false
|
revealControlsOnlyRef.current = false
|
||||||
setControlsVisible(true)
|
setControlsVisible(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
revealControlsOnlyRef.current = false
|
revealControlsOnlyRef.current = false
|
||||||
|
if (vrActive) {
|
||||||
|
setControlsVisible((visible) => !visible)
|
||||||
|
return
|
||||||
|
}
|
||||||
togglePlay()
|
togglePlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleLock = () => {
|
||||||
|
const next = !locked
|
||||||
|
setLocked(next)
|
||||||
|
// 锁上时立刻收起操作栏;解锁时也保持收起,这样画面在任何一侧都不会突然
|
||||||
|
// 弹出一整条浮层。解锁那一下顺带显示「已解锁」提示。
|
||||||
|
setControlsVisible(false)
|
||||||
|
revealLock(!next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复位锁定状态:切模式/换片时调用,避免上一次的锁挡住新的内容。 */
|
||||||
|
const resetLock = useCallback(() => {
|
||||||
|
setLocked(false)
|
||||||
|
setLockShown(false)
|
||||||
|
setLockHint(false)
|
||||||
|
setLockHovered(false)
|
||||||
|
if (lockRevealTimerRef.current) {
|
||||||
|
clearTimeout(lockRevealTimerRef.current)
|
||||||
|
lockRevealTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 进入 VR 全景播放时先亮一次操作栏与 VR 工具条,让用户知道入口在哪;
|
||||||
|
// 退出 VR 时清掉 VR 浮层的悬停状态,否则控制栏会以为还有浮层被悬停而不隐藏。
|
||||||
|
// 两种情况都要解除锁定:切模式是个明确的操作意图,不该被上一次的锁挡住。
|
||||||
|
useEffect(() => {
|
||||||
|
resetLock()
|
||||||
|
if (vrActive) {
|
||||||
|
setControlsVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setVrUiHovered(false)
|
||||||
|
}, [resetLock, vrActive])
|
||||||
|
|
||||||
|
// 换集/换片时同样复位:新内容应该从干净的、可操作的状态开始。
|
||||||
|
useEffect(() => {
|
||||||
|
resetLock()
|
||||||
|
}, [media?.id, resetLock])
|
||||||
|
|
||||||
|
// 首次操作说明只出现一次:读完了点「知道了」会立即落库;放着不管也会在
|
||||||
|
// VR_GUIDE_AUTO_DISMISS_MS 之后自动收起并同样记录为已看过,避免每次进 VR 都弹。
|
||||||
|
const vrGuideVisible = vrActive && vrReady && showVr360Guide
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vrGuideVisible) return
|
||||||
|
const timer = setTimeout(() => onDismissVr360Guide?.(), VR_GUIDE_AUTO_DISMISS_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [vrGuideVisible, onDismissVr360Guide])
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
const stage = stageRef.current
|
const stage = stageRef.current
|
||||||
if (!stage) return
|
if (!stage) return
|
||||||
@@ -544,18 +726,25 @@ export function PlayerVideoStage({
|
|||||||
: true
|
: true
|
||||||
|
|
||||||
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||||
const wrapperStyle = videoRatio
|
// VR 模式下画面由 WebGL 画布输出,舞台窗口本身就是「镜头」,
|
||||||
|
// 不再按视频宽高比留黑边。
|
||||||
|
const wrapperStyle = vr360
|
||||||
? {
|
? {
|
||||||
aspectRatio: `${videoRatio}`,
|
|
||||||
width: isWiderThanStage ? '100%' : 'auto',
|
|
||||||
height: isWiderThanStage ? 'auto' : '100%',
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: '100%',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
}
|
}
|
||||||
|
: videoRatio
|
||||||
|
? {
|
||||||
|
aspectRatio: `${videoRatio}`,
|
||||||
|
width: isWiderThanStage ? '100%' : 'auto',
|
||||||
|
height: isWiderThanStage ? 'auto' : '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -563,7 +752,9 @@ export function PlayerVideoStage({
|
|||||||
data-player-stage
|
data-player-stage
|
||||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||||
onPointerDown={handleStagePointerDown}
|
onPointerDown={handleStagePointerDown}
|
||||||
onClick={handleStageClick}
|
onPointerMove={handleStagePointerMove}
|
||||||
|
onMouseLeave={() => setLockHovered(false)}
|
||||||
|
onClick={handleSurfaceActivate}
|
||||||
onDoubleClick={toggleFullscreen}
|
onDoubleClick={toggleFullscreen}
|
||||||
>
|
>
|
||||||
{media ? (
|
{media ? (
|
||||||
@@ -596,6 +787,20 @@ export function PlayerVideoStage({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</video>
|
</video>
|
||||||
|
{vr360 ? (
|
||||||
|
<Vr360Stage
|
||||||
|
key={`${media.id}:vr360`}
|
||||||
|
videoRef={videoRef}
|
||||||
|
profile={vr360}
|
||||||
|
uiVisible={controlsVisible}
|
||||||
|
onSurfaceTap={handleSurfaceActivate}
|
||||||
|
onReady={() => setVrReadyMediaId(media.id)}
|
||||||
|
onError={(message) => onVr360Error?.(message)}
|
||||||
|
onProfileChange={onVr360ProfileChange}
|
||||||
|
onExitVr={onToggleVr360}
|
||||||
|
onUiHoldChange={setVrUiHovered}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<DanmakuStage
|
<DanmakuStage
|
||||||
key={media.id}
|
key={media.id}
|
||||||
media={media}
|
media={media}
|
||||||
@@ -653,8 +858,13 @@ export function PlayerVideoStage({
|
|||||||
volume={playerVolume}
|
volume={playerVolume}
|
||||||
onVolumeChange={onPlayerVolumeChange}
|
onVolumeChange={onPlayerVolumeChange}
|
||||||
onVolumeCommit={onPlayerVolumeCommit}
|
onVolumeCommit={onPlayerVolumeCommit}
|
||||||
uiVisible={controlsVisible}
|
playbackRate={playerPlaybackRate}
|
||||||
|
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
||||||
|
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
||||||
|
uiVisible={controlsVisible && !locked}
|
||||||
onUiVisibleChange={setControlsVisible}
|
onUiVisibleChange={setControlsVisible}
|
||||||
|
uiHold={vrUiHovered}
|
||||||
|
uiLocked={locked}
|
||||||
subs={subs}
|
subs={subs}
|
||||||
subtitleIndex={subtitleIndex}
|
subtitleIndex={subtitleIndex}
|
||||||
onSelectSubtitle={onSelectSubtitle}
|
onSelectSubtitle={onSelectSubtitle}
|
||||||
@@ -666,7 +876,8 @@ export function PlayerVideoStage({
|
|||||||
onSubtitleStyleChange={onSubtitleStyleChange}
|
onSubtitleStyleChange={onSubtitleStyleChange}
|
||||||
danmakuOpen={danmakuOpen}
|
danmakuOpen={danmakuOpen}
|
||||||
danmakuEnabled={danmakuEnabled}
|
danmakuEnabled={danmakuEnabled}
|
||||||
onToggleDanmaku={onToggleDanmaku}
|
onToggleDanmakuEnabled={onToggleDanmakuEnabled}
|
||||||
|
onOpenDanmaku={onOpenDanmaku}
|
||||||
hasPrevEpisode={hasPrevEpisode}
|
hasPrevEpisode={hasPrevEpisode}
|
||||||
hasNextEpisode={hasNextEpisode}
|
hasNextEpisode={hasNextEpisode}
|
||||||
onPrevEpisode={onPrevEpisode}
|
onPrevEpisode={onPrevEpisode}
|
||||||
@@ -675,14 +886,21 @@ export function PlayerVideoStage({
|
|||||||
nextEpisodeTitle={nextEpisodeTitle}
|
nextEpisodeTitle={nextEpisodeTitle}
|
||||||
playlistOpen={playlistOpen}
|
playlistOpen={playlistOpen}
|
||||||
hasPlaylist={hasPlaylist}
|
hasPlaylist={hasPlaylist}
|
||||||
|
hasVersions={hasVersions}
|
||||||
onTogglePlaylist={onTogglePlaylist}
|
onTogglePlaylist={onTogglePlaylist}
|
||||||
knownDuration={knownDuration}
|
knownDuration={knownDuration}
|
||||||
streamOffset={streamOffset}
|
streamOffset={streamOffset}
|
||||||
onSeekAbsolute={onSeekAbsolute}
|
onSeekAbsolute={onSeekAbsolute}
|
||||||
qualities={qualities}
|
qualities={qualities}
|
||||||
|
qualityLabel={qualityLabel}
|
||||||
selectedQuality={selectedQuality}
|
selectedQuality={selectedQuality}
|
||||||
onSelectQuality={onSelectQuality}
|
onSelectQuality={onSelectQuality}
|
||||||
showQuality={showQuality}
|
showQuality={showQuality}
|
||||||
|
playbackModeLabel={playbackModeLabel}
|
||||||
|
onTogglePlaybackMode={onTogglePlaybackMode}
|
||||||
|
vr360={vr360}
|
||||||
|
vr360Detected={vr360Detected}
|
||||||
|
onToggleVr360={onToggleVr360}
|
||||||
/>
|
/>
|
||||||
{danmakuPanel}
|
{danmakuPanel}
|
||||||
{playlistPanel}
|
{playlistPanel}
|
||||||
@@ -692,17 +910,105 @@ export function PlayerVideoStage({
|
|||||||
) : (
|
) : (
|
||||||
<p className="text-sand-500">加载中…</p>
|
<p className="text-sand-500">加载中…</p>
|
||||||
)}
|
)}
|
||||||
|
{vr360 && media && !vrReady ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-[#0d0e12]/95 px-4 py-3 text-sm text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||||
|
<Loader2 className="animate-spin text-rose-400" size={18} />
|
||||||
|
正在启动 VR 全景渲染…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* VR 首次操作说明:按用户只弹一次。刻意不铺全屏遮罩,用户可以一边看说明
|
||||||
|
一边拖动画面试操作。 */}
|
||||||
|
{vrGuideVisible ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center px-4">
|
||||||
|
<div className="pointer-events-auto w-[min(92vw,400px)] rounded-2xl border border-white/10 bg-[#0d0e12]/95 px-5 py-4 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Rotate3d size={16} className="text-rose-400" />
|
||||||
|
VR 全景播放
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 space-y-2 text-xs leading-relaxed text-white/80">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Hand size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
|
按住拖动画面:转动视角
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<ZoomIn size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
|
滚轮 / 双指捏合:缩放视野
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<MousePointerClick size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
|
单击画面:显示或收起操作栏(进度条、清晰度、倍速、设置)
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Lock size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
|
锁默认隐身:电脑把鼠标移到左侧中间、手机点一下画面就会浮现
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Smartphone size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
|
手机上点顶部「陀螺仪」:转动设备就能转动视角
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismissVr360Guide}
|
||||||
|
className="mt-4 w-full rounded-xl bg-rose-500 px-4 py-2 text-xs font-medium text-white transition hover:bg-rose-600"
|
||||||
|
>
|
||||||
|
知道了,不再提示
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* 锁定按钮:贴在画面左侧正中,VR 和普通播放都有,但默认隐身。
|
||||||
|
桌面端把鼠标移到左侧中间就会浮现,移开即收;移动端点一下画面唤出,并在
|
||||||
|
LOCK_REVEAL_MS 后自动隐身。
|
||||||
|
未锁时点一下就把操作栏、进度框和 VR 工具条全部藏起来,同时屏蔽画面轻触
|
||||||
|
(不会误触发播放/暂停);锁住后按钮变淡,提醒「这里是锁」。
|
||||||
|
注意:隐身时必须 pointer-events-none——否则这块隐形按钮会吃掉画面单击,
|
||||||
|
手机上想点画面唤出锁反而会误触上锁。 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
toggleLock()
|
||||||
|
}}
|
||||||
|
// 舞台在 pointerdown 上判断「是不是移动端轻触唤出控制栏」,锁按钮上的
|
||||||
|
// 触摸不应该参与那套判断,否则点锁会被当成一次画面轻触。
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
aria-hidden={!lockVisible}
|
||||||
|
tabIndex={lockVisible ? 0 : -1}
|
||||||
|
aria-label={locked ? '解锁画面,恢复操作栏' : '锁定画面,隐藏操作栏和进度框'}
|
||||||
|
title={locked ? '点一下解锁,恢复操作栏与进度框' : '锁上后画面轻触无效,只留这个锁'}
|
||||||
|
className={`absolute left-2.5 top-1/2 z-30 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border shadow-[0_6px_20px_rgba(0,0,0,0.45)] backdrop-blur transition-opacity duration-200 sm:left-4 ${
|
||||||
|
lockVisible ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
|
||||||
|
} ${
|
||||||
|
locked
|
||||||
|
? // 锁住时按钮退到很淡的状态:既提示「这里是锁」,又不打扰观看。
|
||||||
|
'border-white/5 bg-black/30 text-white/40 hover:bg-black/60 hover:text-white'
|
||||||
|
: 'border-white/10 bg-black/50 text-white/85 hover:bg-black/75 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{locked ? <Lock size={18} /> : <LockOpen size={18} />}
|
||||||
|
</button>
|
||||||
|
{/* 解锁提示:操作栏此时已经藏起来了,用户点锁之后需要知道发生了什么。 */}
|
||||||
|
{lockHint ? (
|
||||||
|
<div className="pointer-events-none absolute left-16 top-1/2 z-30 -translate-y-1/2 rounded-lg border border-white/10 bg-[#0d0e12]/95 px-3 py-1.5 text-xs text-white/85 shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl sm:left-20">
|
||||||
|
已解锁,点画面可显示操作栏
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{playerError ? (
|
{playerError ? (
|
||||||
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
|
<div className="absolute bottom-20 left-1/2 flex w-[min(92vw,720px)] -translate-x-1/2 items-start gap-2.5 rounded-xl border border-rose-500/25 bg-[#0d0e12]/95 px-4 py-3 text-xs leading-relaxed text-white/85 shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||||
{playerError}
|
<CircleAlert size={15} className="mt-0.5 shrink-0 text-rose-400" />
|
||||||
|
<span className="min-w-0 flex-1">{playerError}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{waiting ? (
|
{waiting ? (
|
||||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
|
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
|
||||||
<div className="max-w-md rounded-xl border border-white/15 bg-black/85 px-6 py-5 text-center text-white shadow-2xl">
|
<div className="max-w-md rounded-2xl border border-white/10 bg-[#0d0e12]/95 px-6 py-5 text-center text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)]">
|
||||||
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={28} />
|
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={26} />
|
||||||
<p className="text-sm font-medium">{waitingMessage || '115 正在转码…'}</p>
|
<p className="text-sm font-medium">{waitingMessage || '115 正在转码…'}</p>
|
||||||
<p className="mt-1 text-xs text-white/60">转码完成后会自动播放</p>
|
<p className="mt-1 text-xs text-white/55">转码完成后会自动播放</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+100
-65
@@ -6,6 +6,7 @@ import type { Library, Media } from '../types'
|
|||||||
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
|
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
|
||||||
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
|
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
|
||||||
import type { SortField, SortOrder } from '../utils/mediaSort'
|
import type { SortField, SortOrder } from '../utils/mediaSort'
|
||||||
|
import { MAX_RESTORE_PAGES, readListPosition, writeListPosition } from '../hooks/useListPositionMemory'
|
||||||
|
|
||||||
export function useLibraryData(
|
export function useLibraryData(
|
||||||
libraryID: string,
|
libraryID: string,
|
||||||
@@ -43,6 +44,84 @@ export function useLibraryData(
|
|||||||
const modeRef = useRef<'media' | 'series'>('media')
|
const modeRef = useRef<'media' | 'series'>('media')
|
||||||
const moreInFlightRef = useRef(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!libraryID) return
|
if (!libraryID) return
|
||||||
const seq = ++requestSeqRef.current
|
const seq = ++requestSeqRef.current
|
||||||
@@ -86,7 +165,7 @@ export function useLibraryData(
|
|||||||
|
|
||||||
const seriesMode = isSeriesLibraryType(lib.type)
|
const seriesMode = isSeriesLibraryType(lib.type)
|
||||||
modeRef.current = seriesMode ? 'series' : 'media'
|
modeRef.current = seriesMode ? 'series' : 'media'
|
||||||
const pageSize = pageSizeFor(lib, seriesMode)
|
const pageSize = pageSizeFor(lib)
|
||||||
try {
|
try {
|
||||||
if (seriesMode) {
|
if (seriesMode) {
|
||||||
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
|
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
|
||||||
@@ -106,6 +185,19 @@ export function useLibraryData(
|
|||||||
setTotal(totalRef.current)
|
setTotal(totalRef.current)
|
||||||
hasMoreRef.current = loadedCountRef.current < totalRef.current
|
hasMoreRef.current = loadedCountRef.current < totalRef.current
|
||||||
setHasMore(hasMoreRef.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 {
|
} catch {
|
||||||
if (!cancelled && seq === requestSeqRef.current) {
|
if (!cancelled && seq === requestSeqRef.current) {
|
||||||
toast.error('媒体库加载失败')
|
toast.error('媒体库加载失败')
|
||||||
@@ -122,7 +214,7 @@ export function useLibraryData(
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
requestSeqRef.current += 1
|
requestSeqRef.current += 1
|
||||||
}
|
}
|
||||||
}, [libraryID, reloadTick, sortField, sortOrder])
|
}, [appendNextPage, libraryID, positionKey, reloadTick, sortField, sortOrder])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
|
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
|
||||||
@@ -146,65 +238,6 @@ export function useLibraryData(
|
|||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [libraryID, isSeriesLibrary, selectedSeries])
|
}, [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(() => {
|
const reloadCurrentLibrary = useCallback(() => {
|
||||||
setReloadTick((tick) => tick + 1)
|
setReloadTick((tick) => tick + 1)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -238,9 +271,11 @@ function isSeriesLibraryType(type?: string) {
|
|||||||
return type === 'tv' || type === 'anime' || type === 'variety'
|
return type === 'tv' || type === 'anime' || type === 'variety'
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageSizeFor(lib: Library, seriesMode: boolean): number {
|
function pageSizeFor(lib: Library): number {
|
||||||
if (lib.is_remote_emby) return 100
|
// 首屏保持一屏多一点,后续统一由底部哨兵按页追加。此前沿用旧的
|
||||||
return seriesMode ? 500 : 2000
|
// 500/2000 批量值,会让中小型媒体库在第一次请求时就等于全量加载。
|
||||||
|
if (lib.is_remote_emby) return 50
|
||||||
|
return 60
|
||||||
}
|
}
|
||||||
|
|
||||||
function yieldToBrowser(): Promise<void> {
|
function yieldToBrowser(): Promise<void> {
|
||||||
@@ -251,4 +286,4 @@ function yieldToBrowser(): Promise<void> {
|
|||||||
setTimeout(resolve, 0)
|
setTimeout(resolve, 0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
specialSectionForMedia,
|
specialSectionForMedia,
|
||||||
type SeriesCard,
|
type SeriesCard,
|
||||||
} from '../utils/groupSeries'
|
} from '../utils/groupSeries'
|
||||||
|
import { resolveSeriesCardByKey } from '../utils/seriesCardResolve'
|
||||||
|
|
||||||
type SeasonEpisodes = {
|
type SeasonEpisodes = {
|
||||||
season: number
|
season: number
|
||||||
@@ -16,6 +17,7 @@ type SeasonEpisodes = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UseLibrarySeriesSelectionOptions = {
|
type UseLibrarySeriesSelectionOptions = {
|
||||||
|
libraryID: string
|
||||||
items: Media[]
|
items: Media[]
|
||||||
seriesEpisodeItems: Media[]
|
seriesEpisodeItems: Media[]
|
||||||
isSeriesLibrary: boolean
|
isSeriesLibrary: boolean
|
||||||
@@ -32,6 +34,7 @@ type UseLibrarySeriesSelectionOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useLibrarySeriesSelection({
|
export function useLibrarySeriesSelection({
|
||||||
|
libraryID,
|
||||||
items,
|
items,
|
||||||
seriesEpisodeItems,
|
seriesEpisodeItems,
|
||||||
isSeriesLibrary,
|
isSeriesLibrary,
|
||||||
@@ -46,6 +49,14 @@ export function useLibrarySeriesSelection({
|
|||||||
setSelectedSeason,
|
setSelectedSeason,
|
||||||
onClearSeriesState,
|
onClearSeriesState,
|
||||||
}: UseLibrarySeriesSelectionOptions) {
|
}: 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 selectedEpisodes = useMemo(() => {
|
||||||
const sourceItems = isSeriesLibrary ? seriesEpisodeItems : items
|
const sourceItems = isSeriesLibrary ? seriesEpisodeItems : items
|
||||||
if (!selectedSeries || sourceItems.length === 0) return []
|
if (!selectedSeries || sourceItems.length === 0) return []
|
||||||
@@ -92,20 +103,53 @@ export function useLibrarySeriesSelection({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return
|
if (loading) return
|
||||||
if (!isSeries) {
|
if (!isSeries) {
|
||||||
|
setPendingSeriesKey('')
|
||||||
setSelectedSeries(null)
|
setSelectedSeries(null)
|
||||||
setSelectedSeason(null)
|
setSelectedSeason(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = searchParams.get('series')
|
if (!requestedSeriesKey) {
|
||||||
if (!key) {
|
setPendingSeriesKey('')
|
||||||
setSelectedSeries(null)
|
setSelectedSeries(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = seriesCards.find((card) => card.key === key)
|
const next = seriesCards.find((card) => card.key === requestedSeriesKey)
|
||||||
setSelectedSeries(next ?? null)
|
if (next) {
|
||||||
}, [isSeries, loading, searchParams, seriesCards, setSelectedSeason, setSelectedSeries])
|
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(() => {
|
useEffect(() => {
|
||||||
if (!selectedSeries || selectedEpisodes.length === 0) {
|
if (!selectedSeries || selectedEpisodes.length === 0) {
|
||||||
@@ -135,6 +179,7 @@ export function useLibrarySeriesSelection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
resolvingSeries,
|
||||||
selectedEpisodes,
|
selectedEpisodes,
|
||||||
visibleEpisodes,
|
visibleEpisodes,
|
||||||
selectedSeriesEpisodes,
|
selectedSeriesEpisodes,
|
||||||
|
|||||||
@@ -53,8 +53,9 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
|
|||||||
|
|
||||||
hasPermission: (key: string) => {
|
hasPermission: (key: string) => {
|
||||||
const state = get()
|
const state = get()
|
||||||
// Super user (admin or plus) has all permissions
|
// Only admins implicitly have every capability in the UI.
|
||||||
if (state.isSuper) {
|
// Plus / is_super must not reveal scrape/organize/delete controls.
|
||||||
|
if (state.role === 'admin') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return (state.permissions ?? {})[key] === true
|
return (state.permissions ?? {})[key] === true
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { LibraryTagSet } from './library'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
@@ -10,10 +12,14 @@ export interface User {
|
|||||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||||
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
|
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
|
||||||
player_volume?: number
|
player_volume?: number
|
||||||
|
/** 本地缓存的播放倍速,服务端配置接口会刷新。 */
|
||||||
|
player_playback_rate?: number
|
||||||
force_password_reset: boolean
|
force_password_reset: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
allowed_library_ids?: string[]
|
allowed_library_ids?: string[]
|
||||||
pinned_library_ids?: string[]
|
pinned_library_ids?: string[]
|
||||||
|
/** 当前用户维护的媒体库标签分组。 */
|
||||||
|
library_tags?: LibraryTagSet[]
|
||||||
is_default_admin?: boolean
|
is_default_admin?: boolean
|
||||||
is_protected?: boolean
|
is_protected?: boolean
|
||||||
realtime_online?: boolean
|
realtime_online?: boolean
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export interface Library {
|
|||||||
total?: number
|
total?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户自定义的媒体库标签分组。标签属于当前用户本人,用于在媒体库页把
|
||||||
|
* 同一标签下的媒体库聚合到一起。一个媒体库同时只属于一个标签。
|
||||||
|
*/
|
||||||
|
export interface LibraryTagSet {
|
||||||
|
name: string
|
||||||
|
library_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScanResult {
|
export interface ScanResult {
|
||||||
library_id: string
|
library_id: string
|
||||||
visited: number
|
visited: number
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type SeriesCard = {
|
|||||||
linkMedia?: Media
|
linkMedia?: Media
|
||||||
linkLibraryId?: string
|
linkLibraryId?: string
|
||||||
count: number
|
count: number
|
||||||
|
is_series?: boolean
|
||||||
last_added_at?: string
|
last_added_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +224,7 @@ export function seasonLabel(season: number): string {
|
|||||||
export function isSeriesCard(card: SeriesCard): boolean {
|
export function isSeriesCard(card: SeriesCard): boolean {
|
||||||
const linkMedia = card.linkMedia ?? card.rep
|
const linkMedia = card.linkMedia ?? card.rep
|
||||||
return (
|
return (
|
||||||
|
card.is_series === true ||
|
||||||
card.count > 1 ||
|
card.count > 1 ||
|
||||||
isEpisodeLike(card.rep) ||
|
isEpisodeLike(card.rep) ||
|
||||||
isEpisodeLike(linkMedia) ||
|
isEpisodeLike(linkMedia) ||
|
||||||
@@ -458,7 +460,7 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
|
|||||||
? compactSeriesKey(externalKey)
|
? compactSeriesKey(externalKey)
|
||||||
: getSeriesKey(m)
|
: getSeriesKey(m)
|
||||||
|
|
||||||
const mAddedAt = m.created_at || m.updated_at || ''
|
const mAddedAt = m.updated_at || m.created_at || ''
|
||||||
const g = groups.get(key)
|
const g = groups.get(key)
|
||||||
if (!g) {
|
if (!g) {
|
||||||
groups.set(key, { key, rep: m, linkMedia: m, count: 1, last_added_at: mAddedAt })
|
groups.set(key, { key, rep: m, linkMedia: m, count: 1, last_added_at: mAddedAt })
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_LIBRARY_LIST_SORT_FIELD,
|
||||||
|
DEFAULT_LIBRARY_LIST_SORT_ORDER,
|
||||||
|
readLibraryListSort,
|
||||||
|
sortLibrariesByField,
|
||||||
|
sortLibraryPreviewsByField,
|
||||||
|
} from './libraryListSort.ts'
|
||||||
|
import type { Library } from '../types/index.ts'
|
||||||
|
|
||||||
|
function check(name: string, condition: boolean) {
|
||||||
|
if (!condition) throw new Error(`libraryListSort: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lib(id: string, name: string, sortOrder = 0): Library {
|
||||||
|
return { id, name, sort_order: sortOrder } as Library
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = (libs: Library[]) => libs.map((item) => item.id).join(',')
|
||||||
|
|
||||||
|
// 首页默认就是「库名倒序」,所以没写过偏好时要拿到 name/desc。
|
||||||
|
const fallback = readLibraryListSort()
|
||||||
|
check(
|
||||||
|
'default preference is name descending',
|
||||||
|
fallback.field === 'name' && fallback.order === 'desc',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'default constants stay in sync with the fallback',
|
||||||
|
DEFAULT_LIBRARY_LIST_SORT_FIELD === 'name' && DEFAULT_LIBRARY_LIST_SORT_ORDER === 'desc',
|
||||||
|
)
|
||||||
|
|
||||||
|
const alpha = [lib('a', '动画'), lib('b', '电影'), lib('c', '纪录片')]
|
||||||
|
// zh-CN 拼音升序是 电影(b) → 动画(a) → 纪录片(c)。
|
||||||
|
check(
|
||||||
|
'name descending is the default order',
|
||||||
|
ordered(sortLibrariesByField(alpha, [], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'name ascending reverses it',
|
||||||
|
ordered(sortLibrariesByField(alpha, [], 'name', 'asc')) === 'b,a,c',
|
||||||
|
)
|
||||||
|
|
||||||
|
const manual = [lib('a', '动画', 2), lib('b', '电影', 0), lib('c', '纪录片', 1)]
|
||||||
|
check(
|
||||||
|
'manual order ascending follows sort_order',
|
||||||
|
ordered(sortLibrariesByField(manual, [], 'sort_order', 'asc')) === 'b,c,a',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'manual order descending flips sort_order',
|
||||||
|
ordered(sortLibrariesByField(manual, [], 'sort_order', 'desc')) === 'a,c,b',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 置顶只决定分组:置顶库整体在前,组内仍然按当前字段/方向排。
|
||||||
|
check(
|
||||||
|
'pinned libraries always come first',
|
||||||
|
ordered(sortLibrariesByField(alpha, ['c'], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'pinned group is sorted by the active field too',
|
||||||
|
ordered(sortLibrariesByField(alpha, ['a', 'c'], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'pinned ids that no longer exist are ignored',
|
||||||
|
ordered(sortLibrariesByField(alpha, ['missing'], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 同名时按 sort_order 兜底,再按 id 兜底,保证顺序稳定。
|
||||||
|
const sameName = [lib('b', '动画', 1), lib('a', '动画', 1), lib('c', '动画', 0)]
|
||||||
|
check(
|
||||||
|
'equal names fall back to sort_order then id',
|
||||||
|
ordered(sortLibrariesByField(sameName, [], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
|
||||||
|
check('single library is returned as-is', sortLibrariesByField([lib('a', '动画')], [], 'name', 'desc').length === 1)
|
||||||
|
|
||||||
|
const previews = [
|
||||||
|
{ library: lib('a', '动画'), total: 1 },
|
||||||
|
{ library: lib('b', '电影'), total: 2 },
|
||||||
|
]
|
||||||
|
check(
|
||||||
|
'previews follow the library order',
|
||||||
|
sortLibraryPreviewsByField(previews, [], 'name', 'desc').map((item) => item.library.id).join(',') === 'a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'previews keep their payload after sorting',
|
||||||
|
sortLibraryPreviewsByField(previews, [], 'name', 'desc')[0].total === 1,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'previews respect pinning',
|
||||||
|
sortLibraryPreviewsByField(previews, ['b'], 'name', 'desc').map((item) => item.library.id).join(',') === 'b,a',
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('libraryListSort.test.ts ok')
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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 的相对顺序只决定「是否置顶」,组内不再保留置顶拖拽序。
|
||||||
|
*
|
||||||
|
* 首页与 /libraries 共用这份排序,保证两处的媒体库顺序一致。
|
||||||
|
*/
|
||||||
|
export function sortLibrariesByField(
|
||||||
|
libraries: Library[],
|
||||||
|
pinnedIds: string[],
|
||||||
|
field: LibraryListSortField,
|
||||||
|
order: LibraryListSortOrder,
|
||||||
|
): Library[] {
|
||||||
|
if (libraries.length < 2) return libraries
|
||||||
|
|
||||||
|
const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index]))
|
||||||
|
const pinned: Library[] = []
|
||||||
|
const rest: Library[] = []
|
||||||
|
for (const library of libraries) {
|
||||||
|
if (pinnedRank.has(library.id)) pinned.push(library)
|
||||||
|
else rest.push(library)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byField = (a: Library, b: Library) => compareLibraries(a, b, field, order)
|
||||||
|
pinned.sort(byField)
|
||||||
|
rest.sort(byField)
|
||||||
|
return [...pinned, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览列表按库排序:先排库,再按排好的库顺序取回对应预览。 */
|
||||||
|
export function sortLibraryPreviewsByField<T extends { library: Library }>(
|
||||||
|
items: T[],
|
||||||
|
pinnedIds: string[],
|
||||||
|
field: LibraryListSortField,
|
||||||
|
order: LibraryListSortOrder,
|
||||||
|
): T[] {
|
||||||
|
if (items.length < 2) return items
|
||||||
|
const ordered = sortLibrariesByField(
|
||||||
|
items.map((item) => item.library),
|
||||||
|
pinnedIds,
|
||||||
|
field,
|
||||||
|
order,
|
||||||
|
)
|
||||||
|
const byId = new Map(items.map((item) => [item.library.id, item]))
|
||||||
|
return ordered
|
||||||
|
.map((library) => byId.get(library.id))
|
||||||
|
.filter((item): item is T => item !== undefined)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
ALL_TAG_ID,
|
||||||
|
assignLibrariesToTag,
|
||||||
|
attachLibrariesToTag,
|
||||||
|
attachLibraryToTag,
|
||||||
|
buildLibraryTagTabs,
|
||||||
|
dedupeLibraryTags,
|
||||||
|
detachLibrariesFromTag,
|
||||||
|
detachLibraryFromTag,
|
||||||
|
filterLibrariesByTag,
|
||||||
|
filterLibrariesForTagging,
|
||||||
|
normalizeLibraryTags,
|
||||||
|
normalizeTagName,
|
||||||
|
reorderLibraryTags,
|
||||||
|
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 drops unknown ids and keeps input order',
|
||||||
|
filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画')
|
||||||
|
.map((lib) => lib.id)
|
||||||
|
.join(',') === 'lib-a,lib-c',
|
||||||
|
)
|
||||||
|
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: [] }], '动画') === '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const reorderable = [
|
||||||
|
{ name: '动画', library_ids: [] },
|
||||||
|
{ name: '电影', library_ids: [] },
|
||||||
|
{ name: '音乐', library_ids: [] },
|
||||||
|
]
|
||||||
|
check(
|
||||||
|
'reorder moves a tag to the requested slot',
|
||||||
|
reorderLibraryTags(reorderable, ['电影', '动画', '音乐']).map((tag) => tag.name).join(',') === '电影,动画,音乐',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'reorder can move a tag to the front',
|
||||||
|
reorderLibraryTags(reorderable, ['音乐', '动画', '电影']).map((tag) => tag.name).join(',') === '音乐,动画,电影',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'names missing from the order keep relative order at the end',
|
||||||
|
reorderLibraryTags(reorderable, ['电影']).map((tag) => tag.name).join(',') === '电影,动画,音乐',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'empty order leaves tags untouched',
|
||||||
|
reorderLibraryTags(reorderable, []).map((tag) => tag.name).join(',') === '动画,电影,音乐',
|
||||||
|
)
|
||||||
|
check('reorder ignores extra unknown names', reorderLibraryTags(reorderable, ['不存在', '音乐']).map((tag) => tag.name).join(',') === '音乐,动画,电影')
|
||||||
|
check(
|
||||||
|
'reordering with fewer than two tags is a no-op',
|
||||||
|
reorderLibraryTags([{ name: '动画', library_ids: [] }], []).map((tag) => tag.name).join(',') === '动画',
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('libraryTags.test.ts ok')
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
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` 为「全部」时原样返回。
|
||||||
|
* 只做成员筛选、保持输入顺序,展示顺序(置顶、排序下拉等)由调用方决定;
|
||||||
|
* 标签里记录的 library_ids 顺序只表示归属,不作为展示排序。
|
||||||
|
*/
|
||||||
|
export function filterLibrariesByTag<T extends { id: string }>(
|
||||||
|
libraries: T[],
|
||||||
|
tags: LibraryTag[],
|
||||||
|
tagId: string,
|
||||||
|
): T[] {
|
||||||
|
if (!tagId || tagId === ALL_TAG_ID) return libraries
|
||||||
|
const ids = new Set(tagLibraryIds(tags, tagId))
|
||||||
|
if (ids.size === 0) return []
|
||||||
|
return libraries.filter((lib) => ids.has(lib.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按给定标签名顺序重排标签栏:`orderedNames` 是期望的标签名顺序(通常来自拖拽后的顺序)。
|
||||||
|
* 未出现在顺序里的标签(例如新建、旧数据并发写入)保持原相对顺序,追加到末尾,绝不丢失。
|
||||||
|
*/
|
||||||
|
export function reorderLibraryTags(tags: LibraryTag[], orderedNames: string[]): LibraryTag[] {
|
||||||
|
if (tags.length < 2) return tags
|
||||||
|
const rank = new Map(orderedNames.map((name, index) => [name, index]))
|
||||||
|
const ranked: LibraryTag[] = []
|
||||||
|
const rest: LibraryTag[] = []
|
||||||
|
for (const tag of tags) {
|
||||||
|
;(rank.has(tag.name) ? ranked : rest).push(tag)
|
||||||
|
}
|
||||||
|
ranked.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))
|
||||||
|
return [...ranked, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选中的标签是否仍存在;被删除或被过滤掉时回落到「全部」。 */
|
||||||
|
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':
|
case 'created_at':
|
||||||
return compareDates(a.created_at, b.created_at, order) || compareStrings(a.title, b.title, 'asc')
|
return compareDates(a.created_at, b.created_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||||
case 'updated_at':
|
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 'rating':
|
||||||
case 'imdb_rating': {
|
case 'imdb_rating': {
|
||||||
const cmp = compareNumbers(a.rating || 0, b.rating || 0, order)
|
const cmp = compareNumbers(a.rating || 0, b.rating || 0, order)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
isStrmMedia,
|
||||||
|
mediaVersionFileName,
|
||||||
|
mediaVersionLabel,
|
||||||
|
mediaVersionMatches,
|
||||||
|
mediaVersionSourceLabel,
|
||||||
|
} from './mediaVersion.ts'
|
||||||
|
import type { Media } from '../types/index.ts'
|
||||||
|
|
||||||
|
function check(name: string, condition: boolean) {
|
||||||
|
if (!condition) throw new Error(`mediaVersion: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function media(partial: Partial<Media>): Media {
|
||||||
|
return {
|
||||||
|
id: 'm1',
|
||||||
|
title: 'SIVR-270',
|
||||||
|
path: '',
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
size_bytes: 0,
|
||||||
|
...partial,
|
||||||
|
} as Media
|
||||||
|
}
|
||||||
|
|
||||||
|
const partLabel = mediaVersionLabel(media({
|
||||||
|
path: '/media/云下载/sivr-270/sivr-270-1.strm',
|
||||||
|
size_bytes: 157,
|
||||||
|
strm_url: '/api/strm/play/cloud115/video.mp4?pickcode=a',
|
||||||
|
}))
|
||||||
|
check('indistinct strm falls back to filename', partLabel.includes('sivr-270-1'))
|
||||||
|
check('indistinct strm is not generic MP4 only', partLabel.toUpperCase() !== 'MP4')
|
||||||
|
|
||||||
|
const richLabel = mediaVersionLabel(media({
|
||||||
|
path: '/strm/竞女01.mkv.strm',
|
||||||
|
height: 1080,
|
||||||
|
size_bytes: 1024 * 1024 * 1200,
|
||||||
|
strm_url: '/api/strm/play/cloud115/video.mkv?pickcode=a',
|
||||||
|
}))
|
||||||
|
check('rich metadata keeps resolution label', richLabel.includes('1080p'))
|
||||||
|
check('rich metadata keeps container', richLabel.toUpperCase().includes('MKV'))
|
||||||
|
|
||||||
|
const dottedPart = mediaVersionLabel(media({
|
||||||
|
path: '/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm',
|
||||||
|
size_bytes: 157,
|
||||||
|
strm_url: '/api/strm/play/cloud115/video.mp4?pickcode=b',
|
||||||
|
}))
|
||||||
|
check('part2 filename fallback keeps part token', dottedPart.includes('part2'))
|
||||||
|
|
||||||
|
// 折叠后的选集行 id 是最优版本;播放中的可能是组内另一个版本。
|
||||||
|
const local4k = media({ id: 'v-4k', path: '/media/Inception.2010.2160p.mkv', height: 2160 })
|
||||||
|
const local1080 = media({ id: 'v-1080', path: '/media/Inception.2010.1080p.mkv', height: 1080 })
|
||||||
|
const cloudRow = media({ id: 'v-cloud', path: '/strm/Inception.2010.mkv.strm', strm_url: '/api/strm/play/cloud115/video.mkv?pickcode=a' })
|
||||||
|
const groupedRow = media({ id: 'v-4k', versions: [local4k, local1080, cloudRow] })
|
||||||
|
|
||||||
|
check('matches own id', mediaVersionMatches(groupedRow, 'v-4k'))
|
||||||
|
check('matches nested version id', mediaVersionMatches(groupedRow, 'v-1080'))
|
||||||
|
check('rejects unrelated id', !mediaVersionMatches(groupedRow, 'v-other'))
|
||||||
|
check('rejects empty id', !mediaVersionMatches(groupedRow, ''))
|
||||||
|
|
||||||
|
check('strm is cloud source', mediaVersionSourceLabel(cloudRow) === '云端直链')
|
||||||
|
check('mkv is local source', mediaVersionSourceLabel(local1080) === '本地文件')
|
||||||
|
check('strm filename drops outer extension', mediaVersionFileName(cloudRow) === 'Inception.2010.mkv')
|
||||||
|
check('missing path has no filename', mediaVersionFileName(media({ path: '' })) === '')
|
||||||
|
check('isStrmMedia detects strm url', isStrmMedia(cloudRow))
|
||||||
|
|
||||||
|
console.log('mediaVersion.test.ts: ok')
|
||||||
@@ -107,6 +107,15 @@ function cleanFallbackFilename(raw: string): string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isIndistinctVersionLabel(parts: string[]): boolean {
|
||||||
|
if (parts.length === 0) return true
|
||||||
|
return parts.every((part) => {
|
||||||
|
const upper = part.toUpperCase()
|
||||||
|
if (upper === '云端直链' || upper === 'STRM') return true
|
||||||
|
return VIDEO_EXTENSIONS.has(part.toLowerCase())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
|
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
|
||||||
export function mediaVersionLabel(media: Media): string {
|
export function mediaVersionLabel(media: Media): string {
|
||||||
const isStrm = isStrmMedia(media)
|
const isStrm = isStrmMedia(media)
|
||||||
@@ -153,11 +162,11 @@ export function mediaVersionLabel(media: Media): string {
|
|||||||
parts.push('云端直链')
|
parts.push('云端直链')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length > 0) {
|
// 只有容器 /「云端直链」时无法区分多分片,回退文件名(sivr-270-1 / …part1)
|
||||||
|
if (parts.length > 0 && !isIndistinctVersionLabel(parts)) {
|
||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底:使用清理后的文件名
|
|
||||||
const cleaned = cleanFallbackFilename(media.path || '')
|
const cleaned = cleanFallbackFilename(media.path || '')
|
||||||
return cleaned || media.title || media.id
|
return cleaned || media.title || media.id
|
||||||
}
|
}
|
||||||
@@ -167,3 +176,30 @@ export function mediaVersionsOf(media: Media | null | undefined): Media[] {
|
|||||||
if (media.versions && media.versions.length > 1) return media.versions
|
if (media.versions && media.versions.length > 1) return media.versions
|
||||||
return [media]
|
return [media]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* media 自身或它的任一版本是否命中 id。
|
||||||
|
*
|
||||||
|
* 选集列表按「版本组」折叠后,行的 id 是该组首个(最优)版本;用户切到
|
||||||
|
* 组内其它版本时播放中的 id 与行 id 不再相等,必须连带比对 versions,
|
||||||
|
* 否则当前集高亮、上一集/下一集都会失效。
|
||||||
|
*/
|
||||||
|
export function mediaVersionMatches(media: Media | null | undefined, id: string): boolean {
|
||||||
|
if (!media || !id) return false
|
||||||
|
if (media.id === id) return true
|
||||||
|
return Boolean(media.versions?.some((version) => version.id === id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本来源:云端(STRM / 网盘直链)或本地文件。 */
|
||||||
|
export function mediaVersionSourceLabel(media: Media): string {
|
||||||
|
return isStrmMedia(media) ? '云端直链' : '本地文件'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本文件名(去掉目录,并剥掉 .strm 外层扩展名),用作版本选项的次要说明。 */
|
||||||
|
export function mediaVersionFileName(media: Media): string {
|
||||||
|
const path = (media.path || '').replace(/\\/g, '/').trim()
|
||||||
|
if (!path) return ''
|
||||||
|
const base = path.split('/').filter(Boolean).pop() || ''
|
||||||
|
if (!base) return ''
|
||||||
|
return base.replace(/\.strm$/i, '')
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,31 +67,3 @@ export function togglePinnedLibraryId(current: string[], id: string): string[] {
|
|||||||
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
|
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
|
||||||
return pinnedIds.includes(id)
|
return pinnedIds.includes(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortByPinnedIds<T extends { id: string }>(items: T[], pinnedIds: string[]): T[] {
|
|
||||||
if (pinnedIds.length === 0) return items
|
|
||||||
const rank = new Map(pinnedIds.map((pinnedId, index) => [pinnedId, index]))
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
const aRank = rank.get(a.id)
|
|
||||||
const bRank = rank.get(b.id)
|
|
||||||
const aPinned = aRank !== undefined
|
|
||||||
const bPinned = bRank !== undefined
|
|
||||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
|
||||||
if (aPinned && bPinned) return aRank - bRank
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortLibraryPreviews<T extends { library: { id: string } }>(items: T[], pinnedIds: string[]): T[] {
|
|
||||||
if (pinnedIds.length === 0) return items
|
|
||||||
const rank = new Map(pinnedIds.map((id, index) => [id, index]))
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
const aRank = rank.get(a.library.id)
|
|
||||||
const bRank = rank.get(b.library.id)
|
|
||||||
const aPinned = aRank !== undefined
|
|
||||||
const bPinned = bRank !== undefined
|
|
||||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
|
||||||
if (aPinned && bPinned) return aRank - bRank
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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')
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user