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
}
// 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, ",") {
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 {
+3 -3
View File
@@ -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
+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 解密账号配置。
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
}
+80 -47
View File
@@ -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 ""
}
+222 -3
View File
@@ -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) {
+47 -41
View File
@@ -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 {
+81 -4
View File
@@ -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)
}
}
+22 -7
View File
@@ -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") {
+21 -16
View File
@@ -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),
+19 -5
View File
@@ -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),
})
}
+7 -3
View File
@@ -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{
+2 -1
View File
@@ -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)
}
+115
View File
@@ -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 {
+13 -8
View File
@@ -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
}
+20 -2
View File
@@ -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)) {
+7 -3
View File
@@ -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':