bug处理

This commit is contained in:
truewhile
2026-09-05 01:15:11 +08:00
parent a311438aa1
commit 4790f7753e
17 changed files with 748 additions and 150 deletions
@@ -125,3 +125,16 @@ func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID s
}) })
return count, err 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
}
+1 -1
View File
@@ -63,7 +63,7 @@ func primarySupportedEmbySort(sortBy string, resumeFilter bool) string {
for _, part := range strings.Split(sortBy, ",") { for _, part := range strings.Split(sortBy, ",") {
key := strings.ToLower(strings.TrimSpace(part)) key := strings.ToLower(strings.TrimSpace(part))
switch key { switch key {
case "sortname", "name", "premieredate", "productionyear", "datecreated", "communityrating": case "sortname", "name", "premieredate", "productionyear", "datecreated", "datelastmediaadded", "datelastcontentadded", "communityrating":
return key return key
case "dateplayed": case "dateplayed":
if resumeFilter { if resumeFilter {
+3 -3
View File
@@ -75,9 +75,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
orderIncludesDirection = false orderIncludesDirection = false
case "premieredate", "productionyear": case "premieredate", "productionyear":
order = mediaReleaseOrderSQL(desc) order = mediaReleaseOrderSQL(desc)
case "datecreated": case "datecreated", "datelastmediaadded", "datelastcontentadded":
order = "media.created_at" order = "media.created_at"
orderIncludesDirection = false orderIncludesDirection = false
case "dateplayed": case "dateplayed":
order = "resume.watched_at" order = "resume.watched_at"
orderIncludesDirection = false orderIncludesDirection = false
+75 -6
View File
@@ -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 解密账号配置。 // configOf 解密账号配置。
func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig, error) { func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig, error) {
raw := map[string]string{} raw := map[string]string{}
@@ -469,6 +534,10 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount,
} }
} }
if lastErr != nil { 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 lastErr
} }
return errors.New("远程 Emby 请求失败") return errors.New("远程 Emby 请求失败")
@@ -553,7 +622,7 @@ func (r *EmbyRemoteService) ProxyPlayOf(acct *model.StrmAccount) (bool, error) {
// RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。 // RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。
func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -588,7 +657,7 @@ func (r *EmbyRemoteService) remoteUserID(cfg *EmbyRemoteConfig) string {
// RemoteItems 向远程 Emby 转发 /Items 浏览/搜索请求,返回重写后的响应载荷。 // RemoteItems 向远程 Emby 转发 /Items 浏览/搜索请求,返回重写后的响应载荷。
// p 的分页/排序/过滤参数原样转发,分页语义完全由远程承接。 // p 的分页/排序/过滤参数原样转发,分页语义完全由远程承接。
func (r *EmbyRemoteService) RemoteItems(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -633,7 +702,7 @@ func (r *EmbyRemoteService) RemoteItems(ctx context.Context, mount *model.EmbyMo
// RemoteSearchMount 对单个挂载的媒体库执行全局搜索(ParentId=挂载的远程库, // RemoteSearchMount 对单个挂载的媒体库执行全局搜索(ParentId=挂载的远程库,
// Recursive 返回库内全部命中),结果归属明确可直接伪装。 // Recursive 返回库内全部命中),结果归属明确可直接伪装。
func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -666,7 +735,7 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.
// RemoteItem 拉取远程单条目详情(含响应的重写)。 // RemoteItem 拉取远程单条目详情(含响应的重写)。
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -681,7 +750,7 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
// RemoteLatest 拉取远程「最近添加」(用于 /Items/Latest 聚合)。 // RemoteLatest 拉取远程「最近添加」(用于 /Items/Latest 聚合)。
func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string, limit int) ([]map[string]any, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -702,7 +771,7 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox // 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。 // 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID, userID string) (map[string]any, error) { 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 { if err != nil {
return nil, err return nil, err
} }
+80 -47
View File
@@ -58,7 +58,7 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar
acctData[m.AccountID] = nil acctData[m.AccountID] = nil
continue continue
} }
cfg, cfgErr := r.configOf(acct) cfg, cfgErr := r.remoteConfigWithToken(ctx, acct)
if cfgErr != nil { if cfgErr != nil {
acctData[m.AccountID] = nil acctData[m.AccountID] = nil
continue continue
@@ -199,6 +199,9 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
media.CreatedAt = date media.CreatedAt = date
media.UpdatedAt = date media.UpdatedAt = date
} }
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateLastMediaAdded")); ok {
media.UpdatedAt = date
}
// 只有远程明确存在图片标签才下发图片 URL。 // 只有远程明确存在图片标签才下发图片 URL。
if remoteItemHasImageTag(item, "Primary") { if remoteItemHasImageTag(item, "Primary") {
media.PosterURL = r.remoteItemImageURL(cfg, remoteID, "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.SeasonNum = 0
media.EpisodeNum = 0 media.EpisodeNum = 0
} }
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" { if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID) libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
media.DisplayLibraryID = libID media.DisplayLibraryID = libID
media.LibraryID = libID media.LibraryID = libID
libName := strings.TrimSpace(mount.Name) libName := strings.TrimSpace(mount.Name)
if libName == "" { if libName == "" {
libName = strings.TrimSpace(mount.RemoteViewName) 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 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 return media
} }
// RemoteLibraryMedia 拉远程库直属条目(电影库=Movie,剧集库=Series),映射分页。 // 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) { 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 { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -368,7 +371,7 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model
// RemoteMediaDetail 拉远程单条目映射为 Media(网页详情页)。 // RemoteMediaDetail 拉远程单条目映射为 Media(网页详情页)。
func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, error) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -452,8 +455,14 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
} }
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。 // 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) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -466,28 +475,44 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
q.Set("ParentId", remoteViewID) q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series") q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false") q.Set("Recursive", "false")
q.Set("StartIndex", "0") q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
q.Set("Limit", "1000") 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 { 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 { cards := make([]SeriesCard, 0)
return nil, err for startIndex := 0; ; startIndex += 1000 {
} q.Set("StartIndex", strconv.Itoa(startIndex))
cards := make([]SeriesCard, 0, len(body.Items)) body.Items = nil
for _, it := range body.Items { if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
RewriteEmbyRemoteIDs(it, mount.ID) return nil, err
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
} }
if count == 0 { if len(body.Items) == 0 {
count = 1 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 { if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
@@ -497,7 +522,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
// 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.configOf(acct) cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -513,13 +538,21 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
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)
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0}) var lastAdded *time.Time
} if !m.UpdatedAt.IsZero() {
if r.cache != nil { t := m.UpdatedAt
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) 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), // RemoteSearchMedia 在全部启用的挂载库中并发搜索影视条目(Movie,Series),
// 并将远程结果映射为 model.Media。遵循当前用户的 MediaVisibility 权限规则。 // 并将远程结果映射为 model.Media。遵循当前用户的 MediaVisibility 权限规则。
@@ -580,7 +613,7 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
if acct == nil { if acct == nil {
continue continue
} }
cfg, cfgErr := r.configOf(acct) cfg, cfgErr := r.remoteConfigWithToken(ctx, acct)
if cfgErr != nil { if cfgErr != nil {
continue continue
} }
@@ -616,7 +649,7 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
q.Set("Recursive", "true") q.Set("Recursive", "true")
q.Set("SearchTerm", query) q.Set("SearchTerm", query)
q.Set("IncludeItemTypes", "Movie,Series") 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("Limit", strconv.Itoa(limit))
q.Set("StartIndex", "0") q.Set("StartIndex", "0")
@@ -664,7 +697,7 @@ func (r *EmbyRemoteService) WebStreamURL(ctx context.Context, acct *model.StrmAc
// remoteItemType 轻量查询远程条目 Type(避免依赖映射载荷)。 // remoteItemType 轻量查询远程条目 Type(避免依赖映射载荷)。
func (r *EmbyRemoteService) remoteItemType(ctx context.Context, acct *model.StrmAccount, remoteID string) string { 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 { if err != nil {
return "" return ""
} }
@@ -677,7 +710,7 @@ func (r *EmbyRemoteService) remoteItemType(ctx context.Context, acct *model.Strm
// remoteItemSeriesID 轻量查询 Episode 的 SeriesId。 // remoteItemSeriesID 轻量查询 Episode 的 SeriesId。
func (r *EmbyRemoteService) remoteItemSeriesID(ctx context.Context, acct *model.StrmAccount, remoteID string) string { 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 { if err != nil {
return "" return ""
} }
+222 -3
View File
@@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -68,10 +70,227 @@ func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) {
if media.Rating != 9.2 { if media.Rating != 9.2 {
t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating) t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating)
} }
if media.Year != 2022 { if media.Year != 2022 {
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year) 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) { func TestRemoteSearchMedia(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+47 -41
View File
@@ -10,19 +10,20 @@ import (
) )
type embySeriesGroup struct { type embySeriesGroup struct {
ID string ID string
LibraryID string LibraryID string
Name string Name string
PosterURL string PosterURL string
BackdropURL string BackdropURL string
Overview string Overview string
Rating float32 Rating float32
Year int Year int
ReleaseDate string ReleaseDate string
TMDbID int TMDbID int
BangumiID int BangumiID int
CreatedAt time.Time CreatedAt time.Time
Episodes []model.Media DateLastMediaAdded time.Time
Episodes []model.Media
} }
type embySeasonGroup struct { 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 { if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
return embySeriesGroup{}, false, err return embySeriesGroup{}, false, err
} else if series != nil { } else if series != nil {
return embySeriesGroup{ return embySeriesGroup{
ID: series.ID, ID: series.ID,
LibraryID: series.LibraryID, LibraryID: series.LibraryID,
Name: series.Title, Name: series.Title,
PosterURL: series.PosterURL, PosterURL: series.PosterURL,
BackdropURL: series.BackdropURL, BackdropURL: series.BackdropURL,
Overview: series.Overview, Overview: series.Overview,
Rating: series.Rating, Rating: series.Rating,
Year: series.Year, Year: series.Year,
TMDbID: series.TMDbID, TMDbID: series.TMDbID,
BangumiID: series.BangumiID, BangumiID: series.BangumiID,
CreatedAt: series.CreatedAt, CreatedAt: series.CreatedAt,
}, true, nil DateLastMediaAdded: series.CreatedAt,
}, true, nil
} }
} }
return embySeriesGroup{}, false, nil return embySeriesGroup{}, false, nil
@@ -189,22 +191,26 @@ func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Me
seriesID := e.seriesIDForMedia(ctx, &row) seriesID := e.seriesIDForMedia(ctx, &row)
group, ok := byID[seriesID] group, ok := byID[seriesID]
if !ok { if !ok {
group = &embySeriesGroup{ group = &embySeriesGroup{
ID: seriesID, ID: seriesID,
LibraryID: row.LibraryID, LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(ctx, &row), Name: e.seriesNameForMedia(ctx, &row),
Year: row.Year, Year: row.Year,
ReleaseDate: row.ReleaseDate, ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID, TMDbID: row.TMDbID,
BangumiID: row.BangumiID, BangumiID: row.BangumiID,
CreatedAt: row.CreatedAt, 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)) { if strings.TrimSpace(row.ReleaseDate) != "" && mediaReleaseSortTime(row).After(embySeriesReleaseSortTime(*group)) {
group.ReleaseDate = row.ReleaseDate group.ReleaseDate = row.ReleaseDate
if row.Year > 0 { if row.Year > 0 {
+81 -4
View File
@@ -429,10 +429,87 @@ func TestInferSeriesNameFromPath(t *testing.T) {
want: "间谍过家家", want: "间谍过家家",
}, },
} }
for _, tc := range tests { for _, tc := range tests {
got := inferSeriesNameFromPath(tc.path) got := inferSeriesNameFromPath(tc.path)
if got != tc.want { if got != tc.want {
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, 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)
}
} }
+22 -7
View File
@@ -126,13 +126,28 @@ func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
} }
return groups[i].Name < groups[j].Name return groups[i].Name < groups[j].Name
}) })
case "datecreated": case "datecreated":
sort.SliceStable(groups, func(i, j int) bool { sort.SliceStable(groups, func(i, j int) bool {
if strings.EqualFold(p.SortOrder, "Ascending") { if strings.EqualFold(p.SortOrder, "Ascending") {
return groups[i].CreatedAt.Before(groups[j].CreatedAt) return groups[i].CreatedAt.Before(groups[j].CreatedAt)
} }
return groups[i].CreatedAt.After(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: default:
sort.SliceStable(groups, func(i, j int) bool { sort.SliceStable(groups, func(i, j int) bool {
if strings.EqualFold(p.SortOrder, "Ascending") { if strings.EqualFold(p.SortOrder, "Ascending") {
+21 -16
View File
@@ -10,22 +10,27 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
if group.BackdropURL != "" { if group.BackdropURL != "" {
backdropTags = append(backdropTags, group.ID+"-bd") backdropTags = append(backdropTags, group.ID+"-bd")
} }
item := map[string]any{ lastMediaAdded := group.DateLastMediaAdded
"Id": group.ID, if lastMediaAdded.IsZero() {
"Name": group.Name, lastMediaAdded = group.CreatedAt
"ServerId": embyServerID, }
"Type": "Series", item := map[string]any{
"MediaType": "Video", "Id": group.ID,
"IsFolder": true, "Name": group.Name,
"ParentId": group.LibraryID, "ServerId": embyServerID,
"ProductionYear": group.Year, "Type": "Series",
"Overview": group.Overview, "MediaType": "Video",
"CommunityRating": group.Rating, "IsFolder": true,
"RecursiveItemCount": len(group.Episodes), "ParentId": group.LibraryID,
"ChildCount": len(e.seasonsForSeries(group)), "ProductionYear": group.Year,
"DateCreated": group.CreatedAt, "Overview": group.Overview,
"ImageTags": imageTags, "CommunityRating": group.Rating,
"BackdropImageTags": backdropTags, "RecursiveItemCount": len(group.Episodes),
"ChildCount": len(e.seasonsForSeries(group)),
"DateCreated": group.CreatedAt,
"DateLastMediaAdded": lastMediaAdded,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"ProviderIds": map[string]string{ "ProviderIds": map[string]string{
"Tmdb": intToStr(group.TMDbID), "Tmdb": intToStr(group.TMDbID),
"Bangumi": intToStr(group.BangumiID), "Bangumi": intToStr(group.BangumiID),
+19 -5
View File
@@ -27,10 +27,11 @@ type libraryRowsCacheValue struct {
} }
type SeriesCard struct { type SeriesCard struct {
Key string `json:"key"` Key string `json:"key"`
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"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
} }
type seriesCardGroup struct { type seriesCardGroup struct {
@@ -277,12 +278,20 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
if key == "" { if key == "" {
continue continue
} }
itemAdded := item.CreatedAt
if itemAdded.IsZero() {
itemAdded = item.UpdatedAt
}
if idx, ok := byKey[key]; ok { if idx, ok := byKey[key]; ok {
group := &groups[idx] group := &groups[idx]
if latest := seriesMediaTime(item); latest.After(group.latest) { if latest := seriesMediaTime(item); latest.After(group.latest) {
group.latest = latest group.latest = latest
} }
card := &group.card 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, // A shared external ID means duplicate encodes/locations for movies,
// not multiple episodes. Keep a single movie card without presenting // not multiple episodes. Keep a single movie card without presenting
// its versions as an "N episodes" collection. // its versions as an "N episodes" collection.
@@ -305,9 +314,14 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
} }
continue continue
} }
var initialLastAdded *time.Time
if !itemAdded.IsZero() {
t := itemAdded
initialLastAdded = &t
}
byKey[key] = len(groups) byKey[key] = len(groups)
groups = append(groups, seriesCardGroup{ 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), latest: seriesMediaTime(item),
}) })
} }
+7 -3
View File
@@ -47,10 +47,14 @@ func TestListRecentSeriesCardsCountsAllEpisodesInSeries(t *testing.T) {
if len(cards) != 1 { if len(cards) != 1 {
t.Fatalf("recent cards = %#v, want one series card", cards) t.Fatalf("recent cards = %#v, want one series card", cards)
} }
if cards[0].Count != 40 { if cards[0].Count != 40 {
t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count) 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) { func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) {
main := model.Media{ main := model.Media{
+2 -1
View File
@@ -102,8 +102,9 @@ func (c *Container) Boot() {
// 启动调度器定时任务 // 启动调度器定时任务
c.Scheduler.Start(c.stopCtx) c.Scheduler.Start(c.stopCtx)
// 远程 Emby 挂载兼容迁移:旧账号无挂载时自动全量挂载 // 远程 Emby 挂载兼容迁移:清理已删账号的残留挂载;旧账号无挂载时自动全量挂载
if c.EmbyRemote != nil { if c.EmbyRemote != nil {
c.EmbyRemote.CleanupOrphanMounts(c.stopCtx)
c.EmbyRemote.AutoSeedMounts(c.stopCtx) c.EmbyRemote.AutoSeedMounts(c.stopCtx)
} }
+115
View File
@@ -5,7 +5,11 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
) )
func TestStrmAccountConfigPreviewOf(t *testing.T) { 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 { func mustJSON(v any) string {
data, err := json.Marshal(v) data, err := json.Marshal(v)
if err != nil { if err != nil {
+13 -8
View File
@@ -92,8 +92,8 @@ type StrmService struct {
oauthSessions map[string]*strm115AuthSession oauthSessions map[string]*strm115AuthSession
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护) wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底) downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限 downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
downloadSemOnce sync.Once downloadSemOnce sync.Once
} }
@@ -379,13 +379,13 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en
if enabled != nil { if enabled != nil {
acct.Enabled = *enabled acct.Enabled = *enabled
} }
if len(config) > 0 { if len(config) > 0 {
enc, err := s.mergeStrmAccountConfig(acct.Config, config) enc, err := s.mergeStrmAccountConfig(acct.Config, config)
if err != nil { if err != nil {
return nil, err return nil, err
}
acct.Config = enc
} }
acct.Config = enc
}
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil { if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
return nil, err 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 { if err := s.repo.StrmAccount.Delete(ctx, id); err != nil {
return err 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 return nil
} }
+20 -2
View File
@@ -30,7 +30,13 @@ import type { Media } from '../types'
* *
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。 * 同一组内取最早 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 { export function getSeriesKey(media: Media): string {
return compactSeriesKey(getSeriesRawKey(media)) return compactSeriesKey(getSeriesRawKey(media))
@@ -327,10 +333,22 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
? compactSeriesKey(externalKey) ? compactSeriesKey(externalKey)
: getSeriesKey(m) : getSeriesKey(m)
const mAddedAt = m.created_at || m.updated_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 }) groups.set(key, { key, rep: m, linkMedia: m, count: 1, last_added_at: mAddedAt })
} else { } 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 // Repeated movie IDs represent alternate locations/encodes, not
// episodes. Fold the versions but keep the card in movie mode. // episodes. Fold the versions but keep the card in movie mode.
if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(g.linkMedia) || pathLooksEpisodic(g.linkMedia)) { if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(g.linkMedia) || pathLooksEpisodic(g.linkMedia)) {
+7 -3
View File
@@ -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.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 '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)
@@ -175,8 +175,12 @@ export function sortSeriesList(
case 'created_at': case 'created_at':
return compareDates(repA.created_at, repB.created_at, order) || compareStrings(titleA, titleB, 'asc') return compareDates(repA.created_at, repB.created_at, order) || compareStrings(titleA, titleB, 'asc')
case 'updated_at': { case 'updated_at': {
const updateA = repA.updated_at || repA.created_at // 远程挂载库的卡片由服务器按 DateLastContentAdded(上次添加集日期)
const updateB = repB.updated_at || repB.created_at // 倒序返回且不回传日期值:缺失时保持服务器顺序,避免错误回退成加入日期。
// 本地库卡片(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') return compareDates(updateA, updateB, order) || compareStrings(titleA, titleB, 'asc')
} }
case 'rating': case 'rating':