mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
bug处理
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user