From 4790f7753e25fcf014b820043660d025a849e9b4 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:15:11 +0800 Subject: [PATCH] =?UTF-8?q?bug=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/emby_mount_repository.go | 13 + internal/service/emby_items_helpers.go | 2 +- internal/service/emby_items_list.go | 6 +- internal/service/emby_remote.go | 81 ++++++- internal/service/emby_remote_web.go | 127 ++++++---- internal/service/emby_remote_web_test.go | 225 +++++++++++++++++- internal/service/emby_series.go | 88 +++---- .../service/emby_series_hierarchy_test.go | 85 ++++++- internal/service/emby_series_ids.go | 29 ++- internal/service/emby_series_payload.go | 37 +-- internal/service/media_series.go | 24 +- internal/service/media_series_test.go | 10 +- internal/service/service.go | 3 +- internal/service/strm_account_test.go | 115 +++++++++ internal/service/strm_service.go | 21 +- web/src/utils/groupSeries.ts | 22 +- web/src/utils/mediaSort.ts | 10 +- 17 files changed, 748 insertions(+), 150 deletions(-) diff --git a/internal/repository/emby_mount_repository.go b/internal/repository/emby_mount_repository.go index 8b9af27..c96c11f 100644 --- a/internal/repository/emby_mount_repository.go +++ b/internal/repository/emby_mount_repository.go @@ -125,3 +125,16 @@ func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID s }) return count, err } + +// DeleteOrphans 清理账号已不存在的挂载(老版本删除账号未级联的历史残留)。 +func (r *EmbyMountRepository) DeleteOrphans(ctx context.Context) (int64, error) { + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx). + Where("account_id NOT IN (SELECT id FROM strm_accounts)"). + Delete(&model.EmbyMount{}) + count = res.RowsAffected + return res.Error + }) + return count, err +} diff --git a/internal/service/emby_items_helpers.go b/internal/service/emby_items_helpers.go index 03ddbad..d5ac9f5 100644 --- a/internal/service/emby_items_helpers.go +++ b/internal/service/emby_items_helpers.go @@ -63,7 +63,7 @@ func primarySupportedEmbySort(sortBy string, resumeFilter bool) string { for _, part := range strings.Split(sortBy, ",") { key := strings.ToLower(strings.TrimSpace(part)) switch key { - case "sortname", "name", "premieredate", "productionyear", "datecreated", "communityrating": + case "sortname", "name", "premieredate", "productionyear", "datecreated", "datelastmediaadded", "datelastcontentadded", "communityrating": return key case "dateplayed": if resumeFilter { diff --git a/internal/service/emby_items_list.go b/internal/service/emby_items_list.go index 88fe491..cf07895 100644 --- a/internal/service/emby_items_list.go +++ b/internal/service/emby_items_list.go @@ -75,9 +75,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string orderIncludesDirection = false case "premieredate", "productionyear": order = mediaReleaseOrderSQL(desc) - case "datecreated": - order = "media.created_at" - orderIncludesDirection = false + case "datecreated", "datelastmediaadded", "datelastcontentadded": + order = "media.created_at" + orderIncludesDirection = false case "dateplayed": order = "resume.watched_at" orderIncludesDirection = false diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index c868c6f..afd5aa2 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -316,6 +316,71 @@ func (r *EmbyRemoteService) AutoSeedMounts(ctx context.Context) { } } +// remoteConfigWithToken 解密账号配置并确保已有可用凭据(首次请求自动认证并 +// 回写 token 与 remote_user_id,等价于管理端「测试连接」),保证后续构造的 +// /Users/{userId} 路径使用远程真实用户 GUID,而不是未认证兜底的 "0"。 +func (r *EmbyRemoteService) remoteConfigWithToken(ctx context.Context, acct *model.StrmAccount) (*EmbyRemoteConfig, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + if err := r.ensureToken(ctx, acct, cfg); err != nil { + return nil, err + } + // api_key 直连(未配用户名/密码)的账号认证步骤不会回填用户 ID;此时用 + // api_key 拉一次用户列表取真实用户 ID 并回写,避免 /Users/{uid} 请求路径 + // 落回兜底 "0" 被远程 Emby 拒绝(Unrecognized Guid format)。 + if strings.TrimSpace(cfg.RemoteUserID) == "" { + r.resolveRemoteUserID(ctx, acct, cfg) + } + return cfg, nil +} + +// resolveRemoteUserID 用已有 api_key 拉远程用户列表,把首个用户 ID 回写账号 +// 配置(取不到时静默跳过,保持兜底行为不变)。 +func (r *EmbyRemoteService) resolveRemoteUserID(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) { + if acct == nil || cfg == nil || strings.TrimSpace(cfg.Token) == "" || strings.TrimSpace(cfg.RemoteUserID) != "" { + return + } + q := url.Values{"api_key": {cfg.Token}} + var users []map[string]any + if err := r.doGet(ctx, acct, cfg, "/Users", q, &users); err != nil || len(users) == 0 { + return + } + uid := strings.TrimSpace(remoteItemString(users[0], "Id")) + if uid == "" { + return + } + cfg.RemoteUserID = uid + raw := map[string]string{} + _ = json.Unmarshal([]byte(acct.Config), &raw) + raw["remote_user_id"] = uid + data, err := json.Marshal(raw) + if err != nil { + return + } + acct.Config = string(data) + _ = r.repo.StrmAccount.Update(ctx, acct) +} + +// CleanupOrphanMounts 清理账号已删除的残留挂载(老版本删除账号未级联), +// 避免挂载计数/列表出现永远清不掉的孤儿数据。 +func (r *EmbyRemoteService) CleanupOrphanMounts(ctx context.Context) { + n, err := r.repo.EmbyMount.DeleteOrphans(ctx) + if err != nil { + if r.log != nil { + r.log.Warn("cleanup orphan emby mounts failed", zap.Error(err)) + } + return + } + if n > 0 { + r.invalidateRemoteMediaCache(ctx) + if r.log != nil { + r.log.Info("cleaned up orphan emby mounts", zap.Int64("mounts", n)) + } + } +} + // configOf 解密账号配置。 func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig, error) { raw := map[string]string{} @@ -469,6 +534,10 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount, } } if lastErr != nil { + if r.log != nil && acct != nil { + r.log.Warn("remote emby request failed", + zap.String("account", acct.Name), zap.String("path", path), zap.Error(lastErr)) + } return lastErr } return errors.New("远程 Emby 请求失败") @@ -553,7 +622,7 @@ func (r *EmbyRemoteService) ProxyPlayOf(acct *model.StrmAccount) (bool, error) { // RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。 func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err } @@ -588,7 +657,7 @@ func (r *EmbyRemoteService) remoteUserID(cfg *EmbyRemoteConfig) string { // RemoteItems 向远程 Emby 转发 /Items 浏览/搜索请求,返回重写后的响应载荷。 // p 的分页/排序/过滤参数原样转发,分页语义完全由远程承接。 func (r *EmbyRemoteService) RemoteItems(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err } @@ -633,7 +702,7 @@ func (r *EmbyRemoteService) RemoteItems(ctx context.Context, mount *model.EmbyMo // RemoteSearchMount 对单个挂载的媒体库执行全局搜索(ParentId=挂载的远程库, // Recursive 返回库内全部命中),结果归属明确可直接伪装。 func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err } @@ -666,7 +735,7 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model. // RemoteItem 拉取远程单条目详情(含响应的重写)。 func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err } @@ -681,7 +750,7 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou // RemoteLatest 拉取远程「最近添加」(用于 /Items/Latest 聚合)。 func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string, limit int) ([]map[string]any, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err } @@ -702,7 +771,7 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM // 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox // 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。 func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID, userID string) (map[string]any, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err } diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index 57e57e4..cd60967 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -58,7 +58,7 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar acctData[m.AccountID] = nil continue } - cfg, cfgErr := r.configOf(acct) + cfg, cfgErr := r.remoteConfigWithToken(ctx, acct) if cfgErr != nil { acctData[m.AccountID] = nil continue @@ -199,6 +199,9 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod media.CreatedAt = date media.UpdatedAt = date } + if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateLastMediaAdded")); ok { + media.UpdatedAt = date + } // 只有远程明确存在图片标签才下发图片 URL。 if remoteItemHasImageTag(item, "Primary") { media.PosterURL = r.remoteItemImageURL(cfg, remoteID, "Primary") @@ -302,28 +305,28 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod media.SeasonNum = 0 media.EpisodeNum = 0 } - if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" { - libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID) - media.DisplayLibraryID = libID - media.LibraryID = libID - libName := strings.TrimSpace(mount.Name) - if libName == "" { - libName = strings.TrimSpace(mount.RemoteViewName) - } - if libName == "" && acct != nil { - libName = acct.Name - } else if acct != nil && acct.Name != "" && !strings.Contains(libName, acct.Name) { - libName = acct.Name + " · " + libName - } - media.LibraryName = libName - media.DisplayLibraryName = libName + if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" { + libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID) + media.DisplayLibraryID = libID + media.LibraryID = libID + libName := strings.TrimSpace(mount.Name) + if libName == "" { + libName = strings.TrimSpace(mount.RemoteViewName) } + if libName == "" && acct != nil { + libName = acct.Name + } else if acct != nil && acct.Name != "" && !strings.Contains(libName, acct.Name) { + libName = acct.Name + " · " + libName + } + media.LibraryName = libName + media.DisplayLibraryName = libName + } return media } // RemoteLibraryMedia 拉远程库直属条目(电影库=Movie,剧集库=Series),映射分页。 func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, itemTypes string, offset, limit int) ([]model.Media, int64, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, 0, err } @@ -368,7 +371,7 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model // RemoteMediaDetail 拉远程单条目映射为 Media(网页详情页)。 func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err } @@ -424,7 +427,7 @@ func (r *EmbyRemoteService) RemoteEpisodes(ctx context.Context, mount *model.Emb } func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string) ([]model.Media, int64, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, 0, err } @@ -452,8 +455,14 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E } // RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。 +// +// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields +// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次 +// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方 +// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时 +// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。 func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err } @@ -466,28 +475,44 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. q.Set("ParentId", remoteViewID) q.Set("IncludeItemTypes", "Series") q.Set("Recursive", "false") - q.Set("StartIndex", "0") + q.Set("SortBy", "DateLastContentAdded") + q.Set("SortOrder", "Descending") q.Set("Limit", "1000") - q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating") var body struct { - Items []map[string]any `json:"Items"` + Items []map[string]any `json:"Items"` + TotalRecordCount int64 `json:"TotalRecordCount"` } - if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil { - return nil, err - } - cards := make([]SeriesCard, 0, len(body.Items)) - for _, it := range body.Items { - RewriteEmbyRemoteIDs(it, mount.ID) - m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) - // 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。 - count := remoteItemInt(it, "RecursiveItemCount") - if count == 0 { - count = remoteItemInt(it, "ChildCount") + cards := make([]SeriesCard, 0) + for startIndex := 0; ; startIndex += 1000 { + q.Set("StartIndex", strconv.Itoa(startIndex)) + body.Items = nil + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil { + return nil, err } - if count == 0 { - count = 1 + if len(body.Items) == 0 { + break + } + for _, it := range body.Items { + RewriteEmbyRemoteIDs(it, mount.ID) + m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) + // 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。 + count := remoteItemInt(it, "RecursiveItemCount") + if count == 0 { + count = remoteItemInt(it, "ChildCount") + } + 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 int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < 1000 { + break } - cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count}) } if r.cache != nil { r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) @@ -497,7 +522,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. // RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。 func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err } @@ -513,13 +538,21 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model. cards := make([]SeriesCard, 0, len(items)) for _, it := range items { m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) - cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0}) - } - if r.cache != nil { - r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) + var lastAdded *time.Time + if !m.UpdatedAt.IsZero() { + t := m.UpdatedAt + lastAdded = &t + } else if !m.CreatedAt.IsZero() { + t := m.CreatedAt + lastAdded = &t } - return cards, nil + cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0, LastAddedAt: lastAdded}) } + if r.cache != nil { + r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) + } + return cards, nil +} // RemoteSearchMedia 在全部启用的挂载库中并发搜索影视条目(Movie,Series), // 并将远程结果映射为 model.Media。遵循当前用户的 MediaVisibility 权限规则。 @@ -580,7 +613,7 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string, if acct == nil { continue } - cfg, cfgErr := r.configOf(acct) + cfg, cfgErr := r.remoteConfigWithToken(ctx, acct) if cfgErr != nil { continue } @@ -616,7 +649,7 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string, q.Set("Recursive", "true") q.Set("SearchTerm", query) q.Set("IncludeItemTypes", "Movie,Series") - q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating") q.Set("Limit", strconv.Itoa(limit)) q.Set("StartIndex", "0") @@ -664,7 +697,7 @@ func (r *EmbyRemoteService) WebStreamURL(ctx context.Context, acct *model.StrmAc // remoteItemType 轻量查询远程条目 Type(避免依赖映射载荷)。 func (r *EmbyRemoteService) remoteItemType(ctx context.Context, acct *model.StrmAccount, remoteID string) string { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return "" } @@ -677,7 +710,7 @@ func (r *EmbyRemoteService) remoteItemType(ctx context.Context, acct *model.Strm // remoteItemSeriesID 轻量查询 Episode 的 SeriesId。 func (r *EmbyRemoteService) remoteItemSeriesID(ctx context.Context, acct *model.StrmAccount, remoteID string) string { - cfg, err := r.configOf(acct) + cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return "" } diff --git a/internal/service/emby_remote_web_test.go b/internal/service/emby_remote_web_test.go index 8886825..f6f5d88 100644 --- a/internal/service/emby_remote_web_test.go +++ b/internal/service/emby_remote_web_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" + "sync/atomic" "testing" "time" @@ -68,10 +70,227 @@ func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) { if media.Rating != 9.2 { t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating) } - if media.Year != 2022 { - t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year) - } + if media.Year != 2022 { + t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year) } +} + +func TestMapRemoteItemToMediaDateLastMediaAdded(t *testing.T) { + svc := &EmbyRemoteService{} + mount := &model.EmbyMount{Base: model.Base{ID: "mount-1"}} + acct := &model.StrmAccount{Base: model.Base{ID: "acct-1"}} + cfg := &EmbyRemoteConfig{BaseURL: "http://localhost:8096"} + + item := map[string]any{ + "Id": "series-1", + "Name": "测试剧集", + "DateCreated": "2023-01-01T00:00:00.0000000Z", + "DateLastMediaAdded": "2024-05-20T10:00:00.0000000Z", + } + + media := svc.MapRemoteItemToMedia(context.Background(), mount, acct, cfg, item) + expectedCreated, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z") + expectedLastAdded, _ := time.Parse(time.RFC3339, "2024-05-20T10:00:00Z") + if !media.CreatedAt.Equal(expectedCreated) { + t.Fatalf("CreatedAt = %v, want %v", media.CreatedAt, expectedCreated) + } + if !media.UpdatedAt.Equal(expectedLastAdded) { + t.Fatalf("UpdatedAt = %v, want %v", media.UpdatedAt, expectedLastAdded) + } +} + +func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) { + // 模拟远程 Emby:未认证兜底用户 ID "0" 被拒绝(与真实服务器一致), + // 只有认证拿到的真实用户 GUID 才能浏览。 + var zeroUserHits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/AuthenticateByName") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "AccessToken": "real-token", + "User": map[string]any{"Id": "real-user-guid"}, + }) + return + } + if r.URL.Path == "/emby/Users/0/Items" { + zeroUserHits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Unrecognized Guid format.")) + return + } + if r.URL.Path == "/emby/Users/real-user-guid/Items" { + q := r.URL.Query() + if q.Get("ParentId") != "view-1" || q.Get("IncludeItemTypes") != "Series" { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "TotalRecordCount": 1, + "Items": []map[string]any{ + { + "Id": "series-100", + "Name": "测试剧", + "Type": "Series", + "ProductionYear": 2024, + "RecursiveItemCount": 12, + "ChildCount": 2, + }, + }, + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}) + repos := repository.New(db) + svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())) + + // 账号只配置了用户名/密码,从未「测试连接」:无 api_key、无 remote_user_id。 + rawConfig, _ := json.Marshal(map[string]string{ + "url": server.URL, + "username": "user", + "password": "pass", + }) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-1"}, + Name: "test-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-1"}, + AccountID: acct.ID, + RemoteViewID: "view-1", + 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, "view-1") + if err != nil { + t.Fatalf("RemoteSeriesCards on first browse failed: %v", err) + } + if len(cards) != 1 { + t.Fatalf("cards = %d, want 1", len(cards)) + } + if cards[0].Rep.Title != "测试剧" { + t.Fatalf("title = %q, want 测试剧", cards[0].Rep.Title) + } + if cards[0].Count != 12 { + t.Fatalf("count = %d, want 12 (RecursiveItemCount)", cards[0].Count) + } + if n := zeroUserHits.Load(); n != 0 { + t.Fatalf("request hit /Users/0/Items %d time(s), want 0 (must use real user id)", n) + } + + // 首次浏览自动认证应把 token 与 remote_user_id 回写账号配置(等价于测试连接)。 + stored := map[string]string{} + if err := json.Unmarshal([]byte(acct.Config), &stored); err != nil { + t.Fatalf("decode account config: %v", err) + } + if stored["api_key"] == "" { + t.Fatalf("account config missing api_key after first browse: %v", stored) + } + if stored["remote_user_id"] != "real-user-guid" { + t.Fatalf("remote_user_id = %q, want real-user-guid (config %v)", stored["remote_user_id"], stored) + } + + // 第二次浏览不再需要认证步骤,直接命中真实用户 ID。 + if _, err := svc.RemoteSeriesCards(t.Context(), mount, acct, "view-1"); err != nil { + t.Fatalf("RemoteSeriesCards second browse failed: %v", err) + } +} + +func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) { + // api_key 直连场景:账号只填了 token(无用户名/密码),从未回写过 + // remote_user_id。首次浏览应通过 /Users 列表解析出真实用户 GUID。 + var zeroUserHits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/emby/Users" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"Id": "real-user-guid", "Name": "admin"}, + }) + return + } + if r.URL.Path == "/emby/Users/0/Items" { + zeroUserHits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Unrecognized Guid format.")) + return + } + if r.URL.Path == "/emby/Users/real-user-guid/Items" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "TotalRecordCount": 1, + "Items": []map[string]any{ + {"Id": "series-200", "Name": "API剧", "Type": "Series", "RecursiveItemCount": 8}, + }, + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + 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": "api-key-only", + }) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-2"}, + Name: "api-key-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-2"}, + AccountID: acct.ID, + RemoteViewID: "view-2", + 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, "view-2") + if err != nil { + t.Fatalf("RemoteSeriesCards with api_key only failed: %v", err) + } + if len(cards) != 1 || cards[0].Rep.Title != "API剧" { + t.Fatalf("cards = %#v, want 1 card 测试剧", cards) + } + if n := zeroUserHits.Load(); n != 0 { + t.Fatalf("request hit /Users/0/Items %d time(s), want 0", n) + } + stored := map[string]string{} + if err := json.Unmarshal([]byte(acct.Config), &stored); err != nil { + t.Fatalf("decode account config: %v", err) + } + if stored["remote_user_id"] != "real-user-guid" { + t.Fatalf("remote_user_id = %q, want real-user-guid (config %v)", stored["remote_user_id"], stored) + } +} func TestRemoteSearchMedia(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/emby_series.go b/internal/service/emby_series.go index c473c16..3d17ae3 100644 --- a/internal/service/emby_series.go +++ b/internal/service/emby_series.go @@ -10,19 +10,20 @@ import ( ) type embySeriesGroup struct { - ID string - LibraryID string - Name string - PosterURL string - BackdropURL string - Overview string - Rating float32 - Year int - ReleaseDate string - TMDbID int - BangumiID int - CreatedAt time.Time - Episodes []model.Media + ID string + LibraryID string + Name string + PosterURL string + BackdropURL string + Overview string + Rating float32 + Year int + ReleaseDate string + TMDbID int + BangumiID int + CreatedAt time.Time + DateLastMediaAdded time.Time + Episodes []model.Media } type embySeasonGroup struct { @@ -67,19 +68,20 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e if series, err := e.repo.Series.FindByID(ctx, id); err != nil { return embySeriesGroup{}, false, err } else if series != nil { - return embySeriesGroup{ - ID: series.ID, - LibraryID: series.LibraryID, - Name: series.Title, - PosterURL: series.PosterURL, - BackdropURL: series.BackdropURL, - Overview: series.Overview, - Rating: series.Rating, - Year: series.Year, - TMDbID: series.TMDbID, - BangumiID: series.BangumiID, - CreatedAt: series.CreatedAt, - }, true, nil + return embySeriesGroup{ + ID: series.ID, + LibraryID: series.LibraryID, + Name: series.Title, + PosterURL: series.PosterURL, + BackdropURL: series.BackdropURL, + Overview: series.Overview, + Rating: series.Rating, + Year: series.Year, + TMDbID: series.TMDbID, + BangumiID: series.BangumiID, + CreatedAt: series.CreatedAt, + DateLastMediaAdded: series.CreatedAt, + }, true, nil } } return embySeriesGroup{}, false, nil @@ -189,22 +191,26 @@ func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Me seriesID := e.seriesIDForMedia(ctx, &row) group, ok := byID[seriesID] if !ok { - group = &embySeriesGroup{ - ID: seriesID, - LibraryID: row.LibraryID, - Name: e.seriesNameForMedia(ctx, &row), - Year: row.Year, - ReleaseDate: row.ReleaseDate, - TMDbID: row.TMDbID, - BangumiID: row.BangumiID, - CreatedAt: row.CreatedAt, + group = &embySeriesGroup{ + ID: seriesID, + LibraryID: row.LibraryID, + Name: e.seriesNameForMedia(ctx, &row), + Year: row.Year, + ReleaseDate: row.ReleaseDate, + TMDbID: row.TMDbID, + BangumiID: row.BangumiID, + CreatedAt: row.CreatedAt, + DateLastMediaAdded: row.CreatedAt, + } + byID[seriesID] = group + order = append(order, seriesID) + } + if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() { + group.CreatedAt = row.CreatedAt + } + if row.CreatedAt.After(group.DateLastMediaAdded) { + group.DateLastMediaAdded = row.CreatedAt } - byID[seriesID] = group - order = append(order, seriesID) - } - if row.CreatedAt.After(group.CreatedAt) { - group.CreatedAt = row.CreatedAt - } if strings.TrimSpace(row.ReleaseDate) != "" && mediaReleaseSortTime(row).After(embySeriesReleaseSortTime(*group)) { group.ReleaseDate = row.ReleaseDate if row.Year > 0 { diff --git a/internal/service/emby_series_hierarchy_test.go b/internal/service/emby_series_hierarchy_test.go index 5775210..4e1c85d 100644 --- a/internal/service/emby_series_hierarchy_test.go +++ b/internal/service/emby_series_hierarchy_test.go @@ -429,10 +429,87 @@ func TestInferSeriesNameFromPath(t *testing.T) { want: "间谍过家家", }, } - for _, tc := range tests { - got := inferSeriesNameFromPath(tc.path) - if got != tc.want { - t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want) + for _, tc := range tests { + got := inferSeriesNameFromPath(tc.path) + if got != tc.want { + t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want) + } + } + } + +func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "测试剧库", Path: `/media/tv`, Type: "tv", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + t0 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + tOld := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + tNew := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + + // Series A: 较早创建,但最近添加了新一集 (Last episode at tNew) + // Series B: 较晚创建,但最后一集在 tOld + rows := []model.Media{ + { + Base: model.Base{ID: "showA-ep01", CreatedAt: t0, UpdatedAt: t0}, + LibraryID: lib.ID, + Title: "剧集A", + Path: `/media/tv/剧集A/Season 01/剧集A.S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + }, + { + Base: model.Base{ID: "showA-ep02", CreatedAt: tNew, UpdatedAt: tNew}, + LibraryID: lib.ID, + Title: "剧集A", + Path: `/media/tv/剧集A/Season 01/剧集A.S01E02.mkv`, + SeasonNum: 1, + EpisodeNum: 2, + }, + { + Base: model.Base{ID: "showB-ep01", CreatedAt: tOld.Add(-24 * time.Hour), UpdatedAt: tOld.Add(-24 * time.Hour)}, + LibraryID: lib.ID, + Title: "剧集B", + Path: `/media/tv/剧集B/Season 01/剧集B.S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + }, + { + Base: model.Base{ID: "showB-ep02", CreatedAt: tOld, UpdatedAt: tOld}, + LibraryID: lib.ID, + Title: "剧集B", + Path: `/media/tv/剧集B/Season 01/剧集B.S01E02.mkv`, + SeasonNum: 1, + EpisodeNum: 2, + }, + } + for _, m := range rows { + if err := svc.repo.DB.Create(&m).Error; err != nil { + t.Fatalf("create media: %v", err) } } + + // 降序排序:剧集A最后一集在 tNew,剧集B最后一集在 tOld,剧集A应排在第一位 + res, err := svc.Items(t.Context(), ItemsParams{ + ParentID: lib.ID, + SortBy: "DateLastMediaAdded", + SortOrder: "Descending", + Limit: 10, + }) + if err != nil { + t.Fatalf("items DateLastMediaAdded: %v", err) + } + items := res["Items"].([]map[string]any) + if len(items) != 2 { + t.Fatalf("items count = %d, want 2", len(items)) + } + if items[0]["Name"] != "剧集A" { + t.Fatalf("first item = %v, want 剧集A (last episode at tNew)", items[0]["Name"]) + } + if items[1]["Name"] != "剧集B" { + t.Fatalf("second item = %v, want 剧集B", items[1]["Name"]) + } + if items[0]["DateLastMediaAdded"] != tNew { + t.Fatalf("DateLastMediaAdded = %v, want %v", items[0]["DateLastMediaAdded"], tNew) + } } diff --git a/internal/service/emby_series_ids.go b/internal/service/emby_series_ids.go index 43ff625..c6ca2d0 100644 --- a/internal/service/emby_series_ids.go +++ b/internal/service/emby_series_ids.go @@ -126,13 +126,28 @@ func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) { } return groups[i].Name < groups[j].Name }) - case "datecreated": - sort.SliceStable(groups, func(i, j int) bool { - if strings.EqualFold(p.SortOrder, "Ascending") { - return groups[i].CreatedAt.Before(groups[j].CreatedAt) - } - return groups[i].CreatedAt.After(groups[j].CreatedAt) - }) + case "datecreated": + sort.SliceStable(groups, func(i, j int) bool { + if strings.EqualFold(p.SortOrder, "Ascending") { + return groups[i].CreatedAt.Before(groups[j].CreatedAt) + } + return groups[i].CreatedAt.After(groups[j].CreatedAt) + }) + case "datelastmediaadded", "datelastcontentadded": + sort.SliceStable(groups, func(i, j int) bool { + tI := groups[i].DateLastMediaAdded + if tI.IsZero() { + tI = groups[i].CreatedAt + } + tJ := groups[j].DateLastMediaAdded + if tJ.IsZero() { + tJ = groups[j].CreatedAt + } + if strings.EqualFold(p.SortOrder, "Ascending") { + return tI.Before(tJ) + } + return tI.After(tJ) + }) default: sort.SliceStable(groups, func(i, j int) bool { if strings.EqualFold(p.SortOrder, "Ascending") { diff --git a/internal/service/emby_series_payload.go b/internal/service/emby_series_payload.go index f794e46..2acea9d 100644 --- a/internal/service/emby_series_payload.go +++ b/internal/service/emby_series_payload.go @@ -10,22 +10,27 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any { if group.BackdropURL != "" { backdropTags = append(backdropTags, group.ID+"-bd") } - item := map[string]any{ - "Id": group.ID, - "Name": group.Name, - "ServerId": embyServerID, - "Type": "Series", - "MediaType": "Video", - "IsFolder": true, - "ParentId": group.LibraryID, - "ProductionYear": group.Year, - "Overview": group.Overview, - "CommunityRating": group.Rating, - "RecursiveItemCount": len(group.Episodes), - "ChildCount": len(e.seasonsForSeries(group)), - "DateCreated": group.CreatedAt, - "ImageTags": imageTags, - "BackdropImageTags": backdropTags, + lastMediaAdded := group.DateLastMediaAdded + if lastMediaAdded.IsZero() { + lastMediaAdded = group.CreatedAt + } + item := map[string]any{ + "Id": group.ID, + "Name": group.Name, + "ServerId": embyServerID, + "Type": "Series", + "MediaType": "Video", + "IsFolder": true, + "ParentId": group.LibraryID, + "ProductionYear": group.Year, + "Overview": group.Overview, + "CommunityRating": group.Rating, + "RecursiveItemCount": len(group.Episodes), + "ChildCount": len(e.seasonsForSeries(group)), + "DateCreated": group.CreatedAt, + "DateLastMediaAdded": lastMediaAdded, + "ImageTags": imageTags, + "BackdropImageTags": backdropTags, "ProviderIds": map[string]string{ "Tmdb": intToStr(group.TMDbID), "Bangumi": intToStr(group.BangumiID), diff --git a/internal/service/media_series.go b/internal/service/media_series.go index b060085..6d34fdc 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -27,10 +27,11 @@ type libraryRowsCacheValue struct { } type SeriesCard struct { - Key string `json:"key"` - Rep model.Media `json:"rep"` - LinkMedia model.Media `json:"linkMedia"` - Count int `json:"count"` + Key string `json:"key"` + Rep model.Media `json:"rep"` + LinkMedia model.Media `json:"linkMedia"` + Count int `json:"count"` + LastAddedAt *time.Time `json:"last_added_at,omitempty"` } type seriesCardGroup struct { @@ -277,12 +278,20 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard { if key == "" { continue } + itemAdded := item.CreatedAt + if itemAdded.IsZero() { + itemAdded = item.UpdatedAt + } if idx, ok := byKey[key]; ok { group := &groups[idx] if latest := seriesMediaTime(item); latest.After(group.latest) { group.latest = latest } card := &group.card + if card.LastAddedAt == nil || (!itemAdded.IsZero() && itemAdded.After(*card.LastAddedAt)) { + t := itemAdded + card.LastAddedAt = &t + } // A shared external ID means duplicate encodes/locations for movies, // not multiple episodes. Keep a single movie card without presenting // its versions as an "N episodes" collection. @@ -305,9 +314,14 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard { } continue } + var initialLastAdded *time.Time + if !itemAdded.IsZero() { + t := itemAdded + initialLastAdded = &t + } byKey[key] = len(groups) groups = append(groups, seriesCardGroup{ - card: SeriesCard{Key: key, Rep: item, LinkMedia: item, Count: 1}, + card: SeriesCard{Key: key, Rep: item, LinkMedia: item, Count: 1, LastAddedAt: initialLastAdded}, latest: seriesMediaTime(item), }) } diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index 412204b..55ece69 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -47,10 +47,14 @@ func TestListRecentSeriesCardsCountsAllEpisodesInSeries(t *testing.T) { if len(cards) != 1 { t.Fatalf("recent cards = %#v, want one series card", cards) } - if cards[0].Count != 40 { - t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count) + if cards[0].Count != 40 { + t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count) + } + expectedLastAdded := now.Add(40 * time.Minute) + if cards[0].LastAddedAt == nil || !cards[0].LastAddedAt.Equal(expectedLastAdded) { + t.Fatalf("recent series LastAddedAt = %v, want %v", cards[0].LastAddedAt, expectedLastAdded) + } } -} func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) { main := model.Media{ diff --git a/internal/service/service.go b/internal/service/service.go index 3f4f6d4..53582bd 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -102,8 +102,9 @@ func (c *Container) Boot() { // 启动调度器定时任务 c.Scheduler.Start(c.stopCtx) - // 远程 Emby 挂载兼容迁移:旧账号无挂载时自动全量挂载 + // 远程 Emby 挂载兼容迁移:清理已删账号的残留挂载;旧账号无挂载时自动全量挂载 if c.EmbyRemote != nil { + c.EmbyRemote.CleanupOrphanMounts(c.stopCtx) c.EmbyRemote.AutoSeedMounts(c.stopCtx) } diff --git a/internal/service/strm_account_test.go b/internal/service/strm_account_test.go index 25da9de..b140319 100644 --- a/internal/service/strm_account_test.go +++ b/internal/service/strm_account_test.go @@ -5,7 +5,11 @@ import ( "encoding/json" "testing" + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestStrmAccountConfigPreviewOf(t *testing.T) { @@ -67,6 +71,117 @@ func TestUpdateStrmAccountMergesConfigWithoutClearingSecrets(t *testing.T) { } } +func TestDeleteStrmAccountCascadesEmbyMounts(t *testing.T) { + ctx := context.Background() + db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{}) + repos := repository.New(db) + svc := NewStrmService(nil, zap.NewNop(), repos, nil) + + createEmbyAcct := func(id, name string) *model.StrmAccount { + acct := &model.StrmAccount{ + Base: model.Base{ID: id}, + Name: name, + Provider: model.StrmProviderEmbyRemote, + Enabled: true, + } + if err := repos.StrmAccount.Create(ctx, acct); err != nil { + t.Fatalf("create account %s: %v", id, err) + } + return acct + } + createMounts := func(accountID string, viewIDs ...string) { + for _, vid := range viewIDs { + m := &model.EmbyMount{ + AccountID: accountID, + RemoteViewID: vid, + RemoteViewName: "库-" + vid, + Enabled: true, + } + if err := repos.EmbyMount.Create(ctx, m); err != nil { + t.Fatalf("create mount %s: %v", vid, err) + } + } + } + + gone := createEmbyAcct("acct-gone", "要删除的账号") + createMounts(gone.ID, "view-1", "view-2", "view-3") + keep := createEmbyAcct("acct-keep", "保留的账号") + createMounts(keep.ID, "view-a") + + if err := svc.DeleteStrmAccount(ctx, gone.ID); err != nil { + t.Fatalf("DeleteStrmAccount failed: %v", err) + } + + remaining, err := repos.StrmAccount.FindByID(ctx, gone.ID) + if err != nil { + t.Fatalf("find account: %v", err) + } + if remaining != nil { + t.Fatalf("account %s should be deleted", gone.ID) + } + goneMounts, err := repos.EmbyMount.ListByAccountID(ctx, gone.ID) + if err != nil { + t.Fatalf("list mounts: %v", err) + } + if len(goneMounts) != 0 { + t.Fatalf("deleted account still has %d mounts (orphans)", len(goneMounts)) + } + keepMounts, err := repos.EmbyMount.ListByAccountID(ctx, keep.ID) + if err != nil { + t.Fatalf("list mounts: %v", err) + } + if len(keepMounts) != 1 || keepMounts[0].RemoteViewID != "view-a" { + t.Fatalf("keep account mounts = %#v, want 1 (view-a)", keepMounts) + } +} + +func TestCleanupOrphanMountsRemovesStaleRows(t *testing.T) { + ctx := context.Background() + db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}) + repos := repository.New(db) + svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, nil) + + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-1"}, + Name: "emby", + Provider: model.StrmProviderEmbyRemote, + Enabled: true, + } + if err := repos.StrmAccount.Create(ctx, acct); err != nil { + t.Fatalf("create account: %v", err) + } + for i, vid := range []string{"v1", "v2", "v-orphan-1", "v-orphan-2"} { + m := &model.EmbyMount{ + Base: model.Base{ID: "mount-" + vid}, + AccountID: acct.ID, + RemoteViewID: vid, + Enabled: true, + } + if i >= 2 { + // 模拟历史残留:挂载归属不存在的账号 + m.AccountID = "no-such-account" + } + if err := repos.EmbyMount.Create(ctx, m); err != nil { + t.Fatalf("create mount %s: %v", vid, err) + } + } + + svc.CleanupOrphanMounts(ctx) + + left, err := repos.EmbyMount.List(ctx) + if err != nil { + t.Fatalf("list mounts: %v", err) + } + if len(left) != 2 { + t.Fatalf("mounts after cleanup = %d, want 2 (orphans removed)", len(left)) + } + for _, m := range left { + if m.AccountID != acct.ID { + t.Fatalf("mount %s still orphan (account %s)", m.ID, m.AccountID) + } + } +} + func mustJSON(v any) string { data, err := json.Marshal(v) if err != nil { diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go index 43a099c..edac44f 100644 --- a/internal/service/strm_service.go +++ b/internal/service/strm_service.go @@ -92,8 +92,8 @@ type StrmService struct { oauthSessions map[string]*strm115AuthSession wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护) - downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底) - downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限 + downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底) + downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限 downloadSemOnce sync.Once } @@ -379,13 +379,13 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en if enabled != nil { acct.Enabled = *enabled } - if len(config) > 0 { - enc, err := s.mergeStrmAccountConfig(acct.Config, config) - if err != nil { - return nil, err - } - acct.Config = enc + if len(config) > 0 { + enc, err := s.mergeStrmAccountConfig(acct.Config, config) + if err != nil { + return nil, err } + acct.Config = enc + } if err := s.repo.StrmAccount.Update(ctx, acct); err != nil { return nil, err } @@ -406,6 +406,11 @@ func (s *StrmService) DeleteStrmAccount(ctx context.Context, id string) error { if err := s.repo.StrmAccount.Delete(ctx, id); err != nil { return err } + // 级联清理远程 Emby 挂载:否则留下孤儿挂载,挂载计数/列表仍会显示。 + // 账号已删,挂载清理失败只记日志,不让删除请求报错。 + if _, err := s.repo.EmbyMount.DeleteByAccountID(ctx, id); err != nil && s.log != nil { + s.log.Warn("delete emby mounts for account failed", zap.String("account", id), zap.Error(err)) + } return nil } diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index be21b6e..ec4bf2d 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -30,7 +30,13 @@ import type { Media } from '../types' * * 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。 */ -export type SeriesCard = { key: string; rep: Media; linkMedia: Media; count: number } +export type SeriesCard = { + key: string + rep: Media + linkMedia: Media + count: number + last_added_at?: string +} export function getSeriesKey(media: Media): string { return compactSeriesKey(getSeriesRawKey(media)) @@ -327,10 +333,22 @@ export function groupSeries(items: Media[] = []): SeriesCard[] { ? compactSeriesKey(externalKey) : getSeriesKey(m) + const mAddedAt = m.created_at || m.updated_at || '' const g = groups.get(key) if (!g) { - groups.set(key, { key, rep: m, linkMedia: m, count: 1 }) + groups.set(key, { key, rep: m, linkMedia: m, count: 1, last_added_at: mAddedAt }) } else { + if (mAddedAt) { + if (!g.last_added_at) { + g.last_added_at = mAddedAt + } else { + const prevTime = new Date(g.last_added_at).getTime() + const curTime = new Date(mAddedAt).getTime() + if (!isNaN(curTime) && (isNaN(prevTime) || curTime > prevTime)) { + g.last_added_at = mAddedAt + } + } + } // Repeated movie IDs represent alternate locations/encodes, not // episodes. Fold the versions but keep the card in movie mode. if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(g.linkMedia) || pathLooksEpisodic(g.linkMedia)) { diff --git a/web/src/utils/mediaSort.ts b/web/src/utils/mediaSort.ts index 41657be..cb3b43e 100644 --- a/web/src/utils/mediaSort.ts +++ b/web/src/utils/mediaSort.ts @@ -114,7 +114,7 @@ export function sortMediaList( case 'created_at': return compareDates(a.created_at, b.created_at, order) || compareStrings(a.title, b.title, 'asc') case 'updated_at': - return compareDates(a.updated_at || a.created_at, b.updated_at || b.created_at, order) || compareStrings(a.title, b.title, 'asc') + return compareDates(a.created_at || a.updated_at, b.created_at || b.updated_at, order) || compareStrings(a.title, b.title, 'asc') case 'rating': case 'imdb_rating': { const cmp = compareNumbers(a.rating || 0, b.rating || 0, order) @@ -175,8 +175,12 @@ export function sortSeriesList( case 'created_at': return compareDates(repA.created_at, repB.created_at, order) || compareStrings(titleA, titleB, 'asc') case 'updated_at': { - const updateA = repA.updated_at || repA.created_at - const updateB = repB.updated_at || repB.created_at + // 远程挂载库的卡片由服务器按 DateLastContentAdded(上次添加集日期) + // 倒序返回且不回传日期值:缺失时保持服务器顺序,避免错误回退成加入日期。 + // 本地库卡片(groupSeries)始终带 last_added_at,按真实值排序。 + if (!a.last_added_at && !b.last_added_at) return 0 + const updateA = a.last_added_at || '' + const updateB = b.last_added_at || '' return compareDates(updateA, updateB, order) || compareStrings(titleA, titleB, 'asc') } case 'rating':