This commit is contained in:
truewhile
2026-09-04 11:56:29 +08:00
parent cc40169616
commit 203abd106a
72 changed files with 1979 additions and 1155 deletions
+4 -1
View File
@@ -12,6 +12,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -50,6 +51,8 @@ func (a *AuditService) RecordBestEffort(userID, action, target, ip, detail strin
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
a.Record(ctx, userID, action, target, ip, detail)
helper.Run(a.log, "audit.record", func() {
a.Record(ctx, userID, action, target, ip, detail)
})
}()
}
+6 -3
View File
@@ -13,6 +13,7 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -176,9 +177,11 @@ func (s *AuthService) touchLoginBestEffort(userID string) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
}
helper.Run(s.log, "auth.touchLogin", func() {
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
}
})
}()
}
+1 -1
View File
@@ -54,7 +54,7 @@ func (e *EmbyService) countVisibleSeries(ctx context.Context, userID string) (in
for i := range rows {
key := strings.TrimSpace(rows[i].SeriesID)
if key == "" {
key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(&rows[i]))
key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(ctx, &rows[i]))
}
seen[key] = struct{}{}
}
+6 -5
View File
@@ -98,7 +98,8 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
pos = h.PositionMs
}
}
return e.itemPayload(ctx, m, fav, pos), nil
// 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil
}
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
@@ -177,7 +178,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(rows)
groups := e.seriesGroupsFromMedia(ctx, rows)
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
if len(groups) > limit {
groups = groups[:limit]
@@ -415,9 +416,9 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
seasonID := ""
if e.mediaShouldBeEpisode(ctx, m) {
itemType = "Episode"
seriesID = e.seriesIDForMedia(m)
seriesName = e.seriesNameForMedia(m)
seasonID = e.seasonIDForMedia(m)
seriesID = e.seriesIDForMedia(ctx, m)
seriesName = e.seriesNameForMedia(ctx, m)
seasonID = e.seasonIDForMedia(ctx, m)
parentID = seasonID
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
if episodeTitle != "" {
+4 -1
View File
@@ -149,6 +149,9 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
}
func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) {
// 请求级缓存:库类型与 series 标题整页只查一次,消除逐条目 N+1。
ctx = e.withPayloadCache(ctx)
e.prefetchPayloadCache(ctx, rows)
rows = e.collapseMediaVersionRows(ctx, rows)
userFavs := map[string]bool{}
userPos := map[string]int64{}
@@ -240,7 +243,7 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(rows)
groups := e.seriesGroupsFromMedia(ctx, rows)
sortSeriesGroups(groups, p)
total := len(groups)
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
+8 -7
View File
@@ -65,7 +65,7 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
return nil, err
}
}
seriesGroups := e.seriesGroupsFromMedia(episodicRows)
seriesGroups := e.seriesGroupsFromMedia(ctx, episodicRows)
// 真正的电影 -> Movie 项(剔除剧集结构行)。
movieQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
@@ -135,10 +135,11 @@ func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (
if strings.TrimSpace(libraryID) == "" {
return false, nil
}
if lib, err := e.repo.Library.FindByID(ctx, libraryID); err != nil {
// 走请求级缓存(若有),避免同一请求内对同一库重复查表。
if typ, ok, err := e.payloadLibraryType(ctx, libraryID); err != nil {
return false, err
} else if lib != nil {
return embyLibraryTypeIsEpisodic(lib.Type), nil
} else if ok {
return embyLibraryTypeIsEpisodic(typ), nil
}
var count int64
err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
@@ -151,11 +152,11 @@ func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *mode
if e == nil || m == nil || strings.TrimSpace(m.LibraryID) == "" {
return false
}
lib, err := e.repo.Library.FindByID(ctx, m.LibraryID)
if err != nil || lib == nil {
typ, ok, err := e.payloadLibraryType(ctx, m.LibraryID)
if err != nil || !ok {
return false
}
return embyLibraryTypeIsEpisodic(lib.Type)
return embyLibraryTypeIsEpisodic(typ)
}
func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool {
+175
View File
@@ -0,0 +1,175 @@
package service
import (
"context"
"errors"
"strings"
"sync"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// 请求级 payload 构建缓存:/Items 列表为每行构建 payload 时,
// mediaShouldBeEpisode 需要库类型、剧集 payload 需要 series 标题。
// 一次页面请求内这些值高度重复(同一库、同一部剧),挂在 ctx 上的
// 小缓存可以把每条目 2-3 次 DB 查询降为整个请求各 1 次预取。
type embyPayloadCacheKey struct{}
type embyLibraryTypeEntry struct {
typ string
found bool // 库不存在时 found=false,调用方可退回计数启发式
}
type embyPayloadCache struct {
mu sync.Mutex
libTypes map[string]embyLibraryTypeEntry
series map[string]string // series_id -> title("" 表示不存在/无标题)
}
func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.libTypes[id]
return entry, ok
}
func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry) {
c.mu.Lock()
defer c.mu.Unlock()
c.libTypes[id] = entry
}
func (c *embyPayloadCache) seriesTitle(id string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
title, ok := c.series[id]
return title, ok
}
func (c *embyPayloadCache) setSeriesTitle(id, title string) {
c.mu.Lock()
defer c.mu.Unlock()
c.series[id] = title
}
// withPayloadCache attaches a fresh request-scoped cache if none exists yet.
func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context {
if e == nil || e.repo == nil {
return ctx
}
if ctx.Value(embyPayloadCacheKey{}) != nil {
return ctx
}
return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{
libTypes: map[string]embyLibraryTypeEntry{},
series: map[string]string{},
})
}
// prefetchPayloadCache warms the cache for the given media rows with two bulk
// queries (library types, series titles) instead of per-item lookups.
func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Media) {
cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache)
if !ok || len(rows) == 0 {
return
}
libIDs := make([]string, 0, 8)
seriesIDs := make([]string, 0, 8)
seenLib := map[string]struct{}{}
seenSeries := map[string]struct{}{}
for i := range rows {
row := &rows[i]
if id := strings.TrimSpace(row.LibraryID); id != "" {
if _, done := seenLib[id]; !done {
// 已在缓存中的库不必再查。
if _, hit := cache.libraryType(id); !hit {
seenLib[id] = struct{}{}
libIDs = append(libIDs, id)
}
}
}
if id := strings.TrimSpace(row.SeriesID); id != "" {
if _, done := seenSeries[id]; !done {
if _, hit := cache.seriesTitle(id); !hit {
seenSeries[id] = struct{}{}
seriesIDs = append(seriesIDs, id)
}
}
}
}
if len(libIDs) > 0 {
var libs []model.Library
if err := e.repo.DB.WithContext(ctx).Select("id, type").Where("id IN ?", libIDs).Find(&libs).Error; err == nil {
found := map[string]string{}
for _, lib := range libs {
found[lib.ID] = lib.Type
}
for _, id := range libIDs {
typ, ok := found[id]
cache.setLibraryType(id, embyLibraryTypeEntry{typ: typ, found: ok})
}
}
}
if len(seriesIDs) > 0 {
var series []model.Series
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
for _, s := range series {
cache.setSeriesTitle(s.ID, s.Title)
}
}
}
}
// payloadLibraryType resolves a library type through the request cache,
// falling back to a direct lookup when no cache is attached. found=false
// means the library row does not exist (soft-deleted or orphaned id).
func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) {
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
if entry, hit := cache.libraryType(libraryID); hit {
return entry.typ, entry.found, nil
}
var lib model.Library
if dbErr := e.repo.DB.WithContext(ctx).Select("id, type").Where("id = ?", libraryID).First(&lib).Error; dbErr != nil {
cache.setLibraryType(libraryID, embyLibraryTypeEntry{})
return "", false, nil
}
cache.setLibraryType(lib.ID, embyLibraryTypeEntry{typ: lib.Type, found: true})
return lib.Type, true, nil
}
var lib model.Library
if err = e.repo.DB.WithContext(ctx).Select("id, type").Where("id = ?", libraryID).First(&lib).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", false, nil
}
return "", false, err
}
return lib.Type, true, nil
}
// payloadSeriesTitle resolves a series title through the request cache,
// falling back to a direct lookup when no cache is attached.
func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) {
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
if title, hit := cache.seriesTitle(seriesID); hit {
return title, true, nil
}
var s model.Series
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil {
cache.setSeriesTitle(seriesID, "")
return "", true, nil
}
cache.setSeriesTitle(s.ID, s.Title)
return s.Title, true, nil
}
series, err := e.repo.Series.FindByID(ctx, seriesID)
if err != nil {
return "", false, err
}
if series == nil {
return "", false, nil
}
return series.Title, true, nil
}
+27 -24
View File
@@ -18,6 +18,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
)
@@ -609,32 +610,34 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
return
}
q := url.Values{}
q.Set("ParentId", target.mount.RemoteViewID)
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("Limit", strconv.Itoa(limit))
q.Set("StartIndex", "0")
helper.Run(r.log, "emby.remoteSearch", func() {
q := url.Values{}
q.Set("ParentId", target.mount.RemoteViewID)
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("Limit", strconv.Itoa(limit))
q.Set("StartIndex", "0")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil {
if r.log != nil {
r.log.Warn("remote search failed",
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
var body struct {
Items []map[string]any `json:"Items"`
}
return
}
medias := make([]model.Media, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, target.mount.ID)
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
medias = append(medias, m)
}
results[idx] = searchResult{items: medias}
if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil {
if r.log != nil {
r.log.Warn("remote search failed",
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
}
return
}
medias := make([]model.Media, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, target.mount.ID)
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
medias = append(medias, m)
}
results[idx] = searchResult{items: medias}
})
}(i, t)
}
wg.Wait()
+78 -6
View File
@@ -49,11 +49,15 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
q = e.applyUserMediaVisibility(ctx, q, userID)
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
q = q.Where("series_id = ?", id)
} else {
// 虚拟 series ID 只可能来自 series_id 为空的媒体:
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
q = q.Where("series_id IS NULL OR series_id = ''")
}
if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range e.seriesGroupsFromMedia(rows) {
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
if group.ID == id {
e.rememberSeriesGroup(group)
return group, true, nil
@@ -88,9 +92,18 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
if season, ok := e.cachedSeasonGroup(id); ok {
return season, true, nil
}
// 虚拟 Season ID 是 hash(seriesKey, seasonNum),无法反解出 series。
// 常见情况(已刮削、series_id 非空)先用一条小型 DISTINCT 查询枚举候选对,
// 在内存中算哈希匹配,命中后只加载该一部剧的剧集行,避免整库扫描。
if season, ok, err := e.findSeasonGroupBySeriesCandidates(ctx, id, userID); err != nil {
return embySeasonGroup{}, false, err
} else if ok {
return season, true, nil
}
// 回退:未刮削(series_id 为空,虚拟 key 由库名+名称派生)的媒体只能全量分组。
var rows []model.Media
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("season_num > 0 OR episode_num > 0")
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
@@ -98,7 +111,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
}
for _, series := range e.seriesGroupsFromMedia(rows) {
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
for _, season := range e.seasonsForSeries(series) {
if season.ID == id {
e.rememberSeriesGroup(series)
@@ -109,18 +122,77 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
return embySeasonGroup{}, false, nil
}
func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGroup {
// findSeasonGroupBySeriesCandidates resolves virtual season IDs for media that
// carry a real series_id: enumerate distinct (series_id, season_num) pairs via
// SQL, hash each candidate to find the matching season, then load only that
// one series' episodes.
func (e *EmbyService) findSeasonGroupBySeriesCandidates(ctx context.Context, id, userID string) (embySeasonGroup, bool, error) {
type seasonCandidate struct {
SeriesID string
SeasonNum int
}
var candidates []seasonCandidate
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Select("DISTINCT series_id, season_num").
Where("series_id <> '' AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.Find(&candidates).Error; err != nil {
return embySeasonGroup{}, false, err
}
matched := make([]string, 0, 1)
for _, cand := range candidates {
if seasonID(cand.SeriesID, cand.SeasonNum) == id {
matched = append(matched, cand.SeriesID)
}
}
for _, matchedSeries := range matched {
season, ok, err := e.seasonGroupForSeries(ctx, id, matchedSeries, userID)
if err != nil || ok {
return season, ok, err
}
}
return embySeasonGroup{}, false, nil
}
// seasonGroupForSeries rebuilds the season groups of one series (small row
// set) and returns the one matching the virtual season id.
func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, userID string) (embySeasonGroup, bool, error) {
var rows []model.Media
rq := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("series_id = ? AND (season_num > 0 OR episode_num > 0)", seriesID)
rq = e.applyUserMediaVisibility(ctx, rq, userID)
if err := rq.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
}
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
if series.ID != seriesID {
continue
}
for _, season := range e.seasonsForSeries(series) {
if season.ID == id {
e.rememberSeriesGroup(series)
return season, true, nil
}
}
}
return embySeasonGroup{}, false, nil
}
func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Media) []embySeriesGroup {
byID := map[string]*embySeriesGroup{}
order := []string{}
for _, row := range rows {
row := row
seriesID := e.seriesIDForMedia(&row)
seriesID := e.seriesIDForMedia(ctx, &row)
group, ok := byID[seriesID]
if !ok {
group = &embySeriesGroup{
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(&row),
Name: e.seriesNameForMedia(ctx, &row),
Year: row.Year,
ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID,
+8 -7
View File
@@ -14,21 +14,22 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
func (e *EmbyService) seriesIDForMedia(m *model.Media) string {
func (e *EmbyService) seriesIDForMedia(ctx context.Context, m *model.Media) string {
if strings.TrimSpace(m.SeriesID) != "" {
return m.SeriesID
}
return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(m))
return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(ctx, m))
}
func (e *EmbyService) seasonIDForMedia(m *model.Media) string {
return seasonID(e.seriesIDForMedia(m), m.SeasonNum)
func (e *EmbyService) seasonIDForMedia(ctx context.Context, m *model.Media) string {
return seasonID(e.seriesIDForMedia(ctx, m), m.SeasonNum)
}
func (e *EmbyService) seriesNameForMedia(m *model.Media) string {
func (e *EmbyService) seriesNameForMedia(ctx context.Context, m *model.Media) string {
if strings.TrimSpace(m.SeriesID) != "" {
if series, err := e.repo.Series.FindByID(context.Background(), m.SeriesID); err == nil && series != nil && strings.TrimSpace(series.Title) != "" {
return series.Title
// 走请求级缓存;无缓存 ctx 时退化为单次查询。
if title, ok, err := e.payloadSeriesTitle(ctx, m.SeriesID); err == nil && ok && strings.TrimSpace(title) != "" {
return title
}
}
if strings.EqualFold(strings.TrimSpace(m.ScrapeStatus), "matched") && strings.TrimSpace(m.Title) != "" {
+2 -1
View File
@@ -21,6 +21,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -152,7 +153,7 @@ func (s *FFmpegToolsService) StartInstall(ctx context.Context) error {
s.mu.Unlock()
s.setMessage("准备下载…")
go s.runInstall()
helper.Go(s.log, "ffmpeg.install", s.runInstall)
return nil
}
@@ -7,6 +7,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
)
@@ -45,7 +46,7 @@ func (s *ScannerService) startLocalMediaProbeWorkers() {
s.localMediaProbeOnce.Do(func() {
workers := s.ffprobeWorkerCount()
for i := 0; i < workers; i++ {
go s.localMediaProbeWorker()
helper.Go(s.log, "scanner.probeWorker", s.localMediaProbeWorker)
}
})
}
+9 -5
View File
@@ -5,6 +5,8 @@ import (
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
)
func (s *ScannerService) invalidateMediaCache(ctx context.Context) {
@@ -18,10 +20,12 @@ func (s *ScannerService) startAutoScrape(ctx context.Context, libraryID string)
scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute)
go func() {
defer cancel()
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
if err != nil {
s.log.Warn("scraper enrich failed", zap.Error(err))
return
}
// 扫描触发的后台刮削与请求线程无关,panic 只记日志,不能带崩进程。
helper.Run(s.log, "scanner.autoScrape", func() {
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
if err != nil {
s.log.Warn("scraper enrich failed", zap.Error(err))
}
})
}()
}
+2 -1
View File
@@ -22,6 +22,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -139,7 +140,7 @@ func (s *SchedulerService) Start(ctx context.Context) {
// 首轮等满一个完整周期再跑,平时节奏不变。
initialDelay = j.interval
}
go s.loopWithInitialDelay(ctx, j, initialDelay)
helper.Go(s.log, "scheduler.loop."+j.name, func() { s.loopWithInitialDelay(ctx, j, initialDelay) })
}
}
+4 -1
View File
@@ -6,6 +6,8 @@ import (
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
)
// JobStatus is a snapshot suitable for the admin UI.
@@ -142,7 +144,8 @@ func (s *SchedulerService) beginRun(j *scheduledJob) error {
}
func (s *SchedulerService) runReserved(ctx context.Context, j *scheduledJob) error {
err := j.run(ctx)
// 任务 panic 转为 error,保证下方 running/lastErr 状态照常清理、调度循环存活。
err := helper.Recover(s.log, "scheduler.job."+j.name, func() error { return j.run(ctx) })
s.mu.Lock()
j.lastRun = s.currentTime()
if err != nil {
+5 -1
View File
@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
)
@@ -73,7 +74,10 @@ func (s *ScraperService) queueWorker(ctx context.Context) {
}
defer func() { <-sem }()
s.processScrapeTask(ctx, t)
// 刮削要解析远端元数据响应,单个任务 panic 不应拖垮队列 worker。
helper.Run(s.log, "scraper.task", func() {
s.processScrapeTask(ctx, t)
})
}(&tasks[i])
}
wg.Wait()
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -96,7 +97,7 @@ func (c *Container) Boot() {
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
c.Log.Warn("api config seed failed", zap.Error(err))
}
go c.warmMediaSearchIndex(c.stopCtx)
helper.Go(c.Log, "service.warmMediaSearchIndex", func() { c.warmMediaSearchIndex(c.stopCtx) })
// 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
@@ -119,7 +120,7 @@ func (c *Container) Boot() {
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// 每天触发一次评估;规则里的窗口可随机,不固定。
if c.Device != nil {
go c.runInactivitySweeper(c.stopCtx)
helper.Go(c.Log, "service.inactivitySweeper", func() { c.runInactivitySweeper(c.stopCtx) })
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -47,12 +48,12 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
func (b *serviceContainerBuilder) startRealtimeServices() {
b.c.WSHub = NewHub(b.log)
go b.c.WSHub.Run()
helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
b.c.Tasks = NewTaskTrackerService(b.log, b.c.WSHub)
b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version)
b.c.SSEHub = NewSSEHub(b.log)
go b.c.SSEHub.Run()
helper.Go(b.log, "sse.hub", b.c.SSEHub.Run)
}
func (b *serviceContainerBuilder) initProviderServices() {
+5 -1
View File
@@ -18,6 +18,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
@@ -66,7 +67,10 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
return
}
defer s.releaseDownloadSlot(task.Provider)
s.processDownloadTask(ctx, task)
// 单个任务 panic 不应拖垮整个下载 worker。
helper.Run(s.log, "strm.downloadTask", func() {
s.processDownloadTask(ctx, task)
})
}(i)
}
wg.Wait()
+6 -5
View File
@@ -24,6 +24,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
@@ -186,14 +187,14 @@ func (s *StrmService) Start(ctx context.Context) {
uploadThreads = 4
}
for i := 0; i < downloadThreads; i++ {
go s.downloadWorker(ctx)
helper.Go(s.log, "strm.downloadWorker", func() { s.downloadWorker(ctx) })
}
for i := 0; i < uploadThreads; i++ {
go s.uploadWorker(ctx)
helper.Go(s.log, "strm.uploadWorker", func() { s.uploadWorker(ctx) })
}
go s.cronLoop(ctx)
go s.queueCleanupLoop(ctx)
go s.refresh115TokensLoop(ctx)
helper.Go(s.log, "strm.cronLoop", func() { s.cronLoop(ctx) })
helper.Go(s.log, "strm.queueCleanupLoop", func() { s.queueCleanupLoop(ctx) })
helper.Go(s.log, "strm.refresh115TokensLoop", func() { s.refresh115TokensLoop(ctx) })
s.log.Info("strm service started",
zap.Int("download_threads", downloadThreads),
zap.Int("upload_threads", uploadThreads))
+118 -97
View File
@@ -18,6 +18,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
@@ -105,7 +106,7 @@ func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...
p.LastSyncMessage = "同步进行中"
_ = s.repo.StrmSyncPath.Update(ctx, p)
go s.runSync(runCtx, p, rec)
helper.Go(s.log, "strm.sync", func() { s.runSync(runCtx, p, rec) })
return nil
}
@@ -361,38 +362,45 @@ func (st *strmSyncState) walkRemote() error {
wg.Add(1)
go func() {
defer wg.Done()
for task := range queue {
if ctx.Err() != nil {
return
}
entries, err := st.provider.List(ctx, task.id)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
// worker 解析远端响应 panic 时取消整个同步,让 closer 与其余
// worker 正常收尾,避免队列与 pending 计数卡死;正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walkRemote", func() error {
for task := range queue {
if ctx.Err() != nil {
return nil
}
errMu.Unlock()
cancel()
return
}
for _, entry := range entries {
cleanName := cleanEntryName(entry.Name, entry.IsDir)
rel := cleanName
if task.rel != "" {
rel = task.rel + "/" + cleanName
}
if entry.IsDir {
pending.Add(1)
select {
case queue <- dirTask{id: entry.ID, rel: rel}:
case <-ctx.Done():
pending.Add(-1)
entries, err := st.provider.List(ctx, task.id)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
}
} else {
st.processRemoteFile(entry, rel)
errMu.Unlock()
cancel()
return nil
}
for _, entry := range entries {
cleanName := cleanEntryName(entry.Name, entry.IsDir)
rel := cleanName
if task.rel != "" {
rel = task.rel + "/" + cleanName
}
if entry.IsDir {
pending.Add(1)
select {
case queue <- dirTask{id: entry.ID, rel: rel}:
case <-ctx.Done():
pending.Add(-1)
}
} else {
st.processRemoteFile(entry, rel)
}
}
pending.Add(-1)
}
pending.Add(-1)
return nil
}); err != nil {
cancel()
}
}()
}
@@ -560,22 +568,28 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
wg.Add(1)
go func() {
defer wg.Done()
for t := range taskCh {
if ctx.Err() != nil {
return
}
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
if err != nil {
errMu.Lock()
if fetchErr == nil {
fetchErr = err
// 分页拉取 panic 时取消整个同步;正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walk115.page", func() error {
for t := range taskCh {
if ctx.Err() != nil {
return nil
}
errMu.Unlock()
return
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
if err != nil {
errMu.Lock()
if fetchErr == nil {
fetchErr = err
}
errMu.Unlock()
return nil
}
filesMu.Lock()
allFiles = append(allFiles, files...)
filesMu.Unlock()
}
filesMu.Lock()
allFiles = append(allFiles, files...)
filesMu.Unlock()
return nil
}); err != nil {
cancel()
}
}()
}
@@ -632,63 +646,70 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
pwg.Add(1)
go func() {
defer pwg.Done()
for pid := range pidCh {
if ctx.Err() != nil {
return
}
if _, loaded := st.dirCache.Load(pid); loaded {
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
// 解析目录详情 panic 时中止整个同步(避免带着损坏的相对路径
// 继续执行);正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walk115.dirTree", func() error {
for pid := range pidCh {
if ctx.Err() != nil {
return nil
}
if _, loaded := st.dirCache.Load(pid); loaded {
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
continue
}
detail, err := open115.GetFsDetailByCid(ctx, pid)
if err != nil {
// 目录详情解析失败会导致下游文件 rel 无法还原真实父路径,
// seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会
// 误传本地未变文件、下载会重复下载。这里不是降级容错,而是
// 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径
// 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err)
}
errMu.Unlock()
st.scanIncomplete.Store(true)
cancel()
return nil
} else if detail != nil {
// 解析相对路径
relPath := cleanDirRel(detail.RelativePath(rootCID))
st.dirCache.Store(pid, relPath)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath)
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
for _, ancestor := range detail.Paths {
if ancestor.FileId == "0" || ancestor.FileId == rootCID {
continue
}
if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded {
subDetail := &cloud115.RemoteFileDetail{
FileId: ancestor.FileId,
FileName: ancestor.Name,
Paths: nil,
}
for _, p := range detail.Paths {
subDetail.Paths = append(subDetail.Paths, p)
if p.FileId == ancestor.FileId {
break
}
}
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
st.dirCache.Store(ancestor.FileId, ancestorRel)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel)
}
}
}
if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
continue
}
detail, err := open115.GetFsDetailByCid(ctx, pid)
if err != nil {
// 目录详情解析失败会导致下游文件 rel 无法还原真实父路径,
// seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会
// 误传本地未变文件、下载会重复下载。这里不是降级容错,而是
// 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径
// 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err)
}
errMu.Unlock()
st.scanIncomplete.Store(true)
cancel()
return
} else if detail != nil {
// 解析相对路径
relPath := cleanDirRel(detail.RelativePath(rootCID))
st.dirCache.Store(pid, relPath)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath)
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
for _, ancestor := range detail.Paths {
if ancestor.FileId == "0" || ancestor.FileId == rootCID {
continue
}
if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded {
subDetail := &cloud115.RemoteFileDetail{
FileId: ancestor.FileId,
FileName: ancestor.Name,
Paths: nil,
}
for _, p := range detail.Paths {
subDetail.Paths = append(subDetail.Paths, p)
if p.FileId == ancestor.FileId {
break
}
}
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
st.dirCache.Store(ancestor.FileId, ancestorRel)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel)
}
}
}
if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
return nil
}); err != nil {
cancel()
}
}()
}
+61
View File
@@ -23,6 +23,8 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -35,6 +37,21 @@ type SubtitleService struct {
log *zap.Logger
repo *repository.Container
cfg *config.Config
// 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次
// os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。
cacheMu sync.Mutex
discovery map[string]subtitleDiscoveryEntry
}
const (
subtitleDiscoveryTTL = 2 * time.Minute
subtitleDiscoveryCacheCap = 4096
)
type subtitleDiscoveryEntry struct {
tracks []SubtitleTrack
expiresAt time.Time
}
// NewSubtitleService is the constructor.
@@ -73,6 +90,50 @@ func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID stri
}
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
if tracks, ok := s.cachedDiscovery(mediaID); ok {
return tracks, nil
}
tracks, err := s.discoverUncached(ctx, mediaID)
if err != nil {
return nil, err
}
s.rememberDiscovery(mediaID, tracks)
return tracks, nil
}
func (s *SubtitleService) cachedDiscovery(mediaID string) ([]SubtitleTrack, bool) {
now := time.Now()
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
entry, ok := s.discovery[mediaID]
if !ok {
return nil, false
}
if now.After(entry.expiresAt) {
delete(s.discovery, mediaID)
return nil, false
}
// 返回副本,避免调用方修改缓存内容。
return append([]SubtitleTrack(nil), entry.tracks...), true
}
func (s *SubtitleService) rememberDiscovery(mediaID string, tracks []SubtitleTrack) {
now := time.Now()
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
if s.discovery == nil {
s.discovery = make(map[string]subtitleDiscoveryEntry)
}
if len(s.discovery) >= subtitleDiscoveryCacheCap {
s.discovery = make(map[string]subtitleDiscoveryEntry)
}
s.discovery[mediaID] = subtitleDiscoveryEntry{
tracks: append([]SubtitleTrack(nil), tracks...),
expiresAt: now.Add(subtitleDiscoveryTTL),
}
}
func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -132,7 +133,7 @@ func (s *SystemUpdateService) Apply(ctx context.Context) (SystemUpdateStatus, er
s.last = &status
s.mu.Unlock()
go s.runUpdate(context.Background(), status, task)
helper.Go(s.log, "system.update", func() { s.runUpdate(context.Background(), status, task) })
return status, nil
}
+8 -4
View File
@@ -7,6 +7,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -26,11 +27,14 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
done <- s.storeRefreshToken(ctx, &model.RefreshToken{
UserID: userID,
TokenHash: tokenHash,
ExpiresAt: expiresAt,
err := helper.Recover(s.log, "token.storeRefreshToken", func() error {
return s.storeRefreshToken(ctx, &model.RefreshToken{
UserID: userID,
TokenHash: tokenHash,
ExpiresAt: expiresAt,
})
})
done <- err
}()
select {
case err := <-done:
+3 -2
View File
@@ -33,6 +33,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -143,7 +144,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
t.jobs[mediaID] = job
t.mu.Unlock()
go t.monitorIdle(jobCtx, job)
go t.runFFmpeg(jobCtx, job, m.Path)
helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) })
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, m.Path) })
return t.PlaylistPath(mediaID), nil
}
+3 -2
View File
@@ -22,6 +22,7 @@ import (
"github.com/fsnotify/fsnotify"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -69,8 +70,8 @@ func (w *WatcherService) Start(ctx context.Context) error {
if err := w.Refresh(ctx); err != nil {
w.log.Warn("watcher refresh failed", zap.Error(err))
}
go w.loop(ctx)
go w.debouncer(ctx)
helper.Go(w.log, "watcher.loop", func() { w.loop(ctx) })
helper.Go(w.log, "watcher.debouncer", func() { w.debouncer(ctx) })
return nil
}