mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
优化续播
This commit is contained in:
@@ -138,4 +138,3 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,11 @@ var ErrUnsupported = errors.New("unsupported cloud provider")
|
||||
|
||||
// FileEntry is one item in a cloud directory listing.
|
||||
type FileEntry struct {
|
||||
ID string `json:"id"` // provider-native file id
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
MTime int64 `json:"mtime,omitempty"`
|
||||
ID string `json:"id"` // provider-native file id
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
MTime int64 `json:"mtime,omitempty"`
|
||||
// PickCode is 115-specific; other providers use ID directly.
|
||||
PickCode string `json:"pick_code,omitempty"`
|
||||
}
|
||||
|
||||
@@ -184,12 +184,12 @@ func (p *embyProvider) ensureToken(ctx context.Context) (string, error) {
|
||||
|
||||
// embyItemSummary 目录浏览所需的最小 Emby 条目字段。
|
||||
type embyItemSummary struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
IsFolder bool `json:"IsFolder"`
|
||||
ChildCount int `json:"ChildCount"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
IsFolder bool `json:"IsFolder"`
|
||||
ChildCount int `json:"ChildCount"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
}
|
||||
|
||||
type embyItemListResponse struct {
|
||||
@@ -243,4 +243,4 @@ func (p *embyProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink
|
||||
u := p.apiBase() + "/Videos/" + url.PathEscape(fileRef) + "/stream"
|
||||
u += "?api_key=" + url.QueryEscape(token) + "&Static=true&MediaSourceId=" + url.QueryEscape(fileRef)
|
||||
return &DirectLink{URL: u, Headers: map[string]string{"X-Emby-Token": token}, Proxy: p.proxyPlay}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ func TestEmbyProviderListViewsAndChildren(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeEmbyRemote, map[string]any{
|
||||
"url": srv.URL,
|
||||
"api_key": "fixed-token",
|
||||
"url": srv.URL,
|
||||
"api_key": "fixed-token",
|
||||
"remote_user_id": "user-9",
|
||||
}, srv.Client())
|
||||
if err != nil {
|
||||
@@ -87,8 +87,8 @@ func TestEmbyProviderResolveDirectURLByDefault(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeEmbyRemote, map[string]any{
|
||||
"url": srv.URL,
|
||||
"api_key": "fixed-token",
|
||||
"url": srv.URL,
|
||||
"api_key": "fixed-token",
|
||||
"remote_user_id": "user-9",
|
||||
}, srv.Client())
|
||||
if err != nil {
|
||||
@@ -115,10 +115,10 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeEmbyRemote, map[string]any{
|
||||
"url": srv.URL,
|
||||
"api_key": "fixed-token",
|
||||
"url": srv.URL,
|
||||
"api_key": "fixed-token",
|
||||
"remote_user_id": "user-9",
|
||||
"proxy_play": "true",
|
||||
"proxy_play": "true",
|
||||
}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -133,4 +133,4 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) {
|
||||
if link.URL == "" {
|
||||
t.Fatal("proxy link must still carry the remote URL")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,4 +62,4 @@ func xorDecode(hexStr string) string {
|
||||
func dandanplaySignature(appID, appSecret string, ts int64, path string) string {
|
||||
sum := sha256.Sum256([]byte(appID + strconv.FormatInt(ts, 10) + path + appSecret))
|
||||
return base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,4 +81,4 @@ func TestDanmakuCredentialsSelection(t *testing.T) {
|
||||
require.False(t, ok)
|
||||
require.Empty(t, id)
|
||||
require.Empty(t, key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,72 +278,72 @@ func TestDanmakuSameBase(t *testing.T) {
|
||||
require.False(t, sameDanmakuBase("", "https://api.dandanplay.net"))
|
||||
}
|
||||
|
||||
// fetchCommentWithFallback:配置源与官方同源时不重复请求;
|
||||
// 全失败时带出最后一跳错误。
|
||||
func TestDanmakuFetchCommentWithFallback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
// fetchCommentWithFallback:配置源与官方同源时不重复请求;
|
||||
// 全失败时带出最后一跳错误。
|
||||
func TestDanmakuFetchCommentWithFallback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
|
||||
require.Error(t, err)
|
||||
require.Empty(t, raw)
|
||||
require.Equal(t, "auto", st)
|
||||
}
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
|
||||
require.Error(t, err)
|
||||
require.Empty(t, raw)
|
||||
require.Equal(t, "auto", st)
|
||||
}
|
||||
|
||||
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
|
||||
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
|
||||
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
// 官方服务同时提供 match 和 search:
|
||||
// match 会返回 episodeId=25484(动画A)
|
||||
// search 会根据关键词返回 episodeId=99999(动画B)
|
||||
mux := http.NewServeMux()
|
||||
var matchCalled bool
|
||||
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
matchCalled = true
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(mux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
// 官方服务同时提供 match 和 search:
|
||||
// match 会返回 episodeId=25484(动画A)
|
||||
// search 会根据关键词返回 episodeId=99999(动画B)
|
||||
mux := http.NewServeMux()
|
||||
var matchCalled bool
|
||||
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
matchCalled = true
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(mux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1)
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1)
|
||||
|
||||
// 1) 默认自动识别:命中 Hash 识别
|
||||
resAuto, err := svc.Fetch(ctx, "mManual", "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, matchCalled)
|
||||
require.Equal(t, "hash", resAuto.MatchMode)
|
||||
require.Equal(t, int64(25484), resAuto.EpisodeID)
|
||||
require.Contains(t, resAuto.Raw, "自动识别弹幕")
|
||||
// 1) 默认自动识别:命中 Hash 识别
|
||||
resAuto, err := svc.Fetch(ctx, "mManual", "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, matchCalled)
|
||||
require.Equal(t, "hash", resAuto.MatchMode)
|
||||
require.Equal(t, int64(25484), resAuto.EpisodeID)
|
||||
require.Contains(t, resAuto.Raw, "自动识别弹幕")
|
||||
|
||||
// 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B
|
||||
resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "search", resManual.MatchMode)
|
||||
require.Equal(t, int64(99999), resManual.EpisodeID)
|
||||
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
|
||||
require.Contains(t, resManual.Raw, "手动搜索弹幕")
|
||||
}
|
||||
// 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B
|
||||
resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "search", resManual.MatchMode)
|
||||
require.Equal(t, int64(99999), resManual.EpisodeID)
|
||||
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
|
||||
require.Contains(t, resManual.Raw, "手动搜索弹幕")
|
||||
}
|
||||
|
||||
// Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。
|
||||
func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) {
|
||||
@@ -445,4 +445,4 @@ func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
|
||||
require.Equal(t, int64(7799), res.EpisodeID)
|
||||
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
|
||||
require.Contains(t, res.Raw, "降级搜索弹幕")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,94 +206,94 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
configured := strings.TrimRight(strings.TrimSpace(res.Source), "/")
|
||||
official := danmakuOfficialBase
|
||||
|
||||
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
|
||||
if target := strings.TrimSpace(episodeID); target != "" {
|
||||
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
res.Raw, res.SourceType = raw, st
|
||||
if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
|
||||
res.EpisodeID = id
|
||||
}
|
||||
res.MatchMode = "manual"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
term, media, err := s.searchTerms(ctx, mediaID)
|
||||
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
|
||||
if target := strings.TrimSpace(episodeID); target != "" {
|
||||
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
manualKeyword := strings.TrimSpace(keyword) != ""
|
||||
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||
term.name = kw
|
||||
res.Raw, res.SourceType = raw, st
|
||||
if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
|
||||
res.EpisodeID = id
|
||||
}
|
||||
if strings.TrimSpace(term.name) == "" {
|
||||
res.MatchMode = "manual"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
term, media, err := s.searchTerms(ctx, mediaID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
manualKeyword := strings.TrimSpace(keyword) != ""
|
||||
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||
term.name = kw
|
||||
}
|
||||
if strings.TrimSpace(term.name) == "" {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
target := ""
|
||||
|
||||
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
|
||||
if hash, ok := s.mediaHash(ctx, media); ok {
|
||||
fileSize := media.SizeBytes
|
||||
if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") {
|
||||
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
|
||||
}
|
||||
matchName := danmakuMatchFileName(media.Path)
|
||||
if matchName == "" {
|
||||
matchName = term.name
|
||||
}
|
||||
matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
} else if len(matches) > 0 {
|
||||
target = fmt.Sprintf("%d", matches[0].EpisodeID)
|
||||
res.AnimeTitle = matches[0].AnimeTitle
|
||||
res.EpisodeTitle = matches[0].EpisodeTitle
|
||||
res.EpisodeID = matches[0].EpisodeID
|
||||
res.MatchMode = "hash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
if target == "" && !manualKeyword && media != nil && media.Path != "" {
|
||||
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
|
||||
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
|
||||
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
res.AnimeTitle = candidates[0].AnimeTitle
|
||||
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
|
||||
res.MatchMode = "filename"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
|
||||
// 多结果返回候选列表交给播放器(歧义处理)。
|
||||
if target == "" {
|
||||
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
res.Candidates = candidates
|
||||
return res, nil
|
||||
}
|
||||
|
||||
target := ""
|
||||
|
||||
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
|
||||
if hash, ok := s.mediaHash(ctx, media); ok {
|
||||
fileSize := media.SizeBytes
|
||||
if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") {
|
||||
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
|
||||
}
|
||||
matchName := danmakuMatchFileName(media.Path)
|
||||
if matchName == "" {
|
||||
matchName = term.name
|
||||
}
|
||||
matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
} else if len(matches) > 0 {
|
||||
target = fmt.Sprintf("%d", matches[0].EpisodeID)
|
||||
res.AnimeTitle = matches[0].AnimeTitle
|
||||
res.EpisodeTitle = matches[0].EpisodeTitle
|
||||
res.EpisodeID = matches[0].EpisodeID
|
||||
res.MatchMode = "hash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
if target == "" && !manualKeyword && media != nil && media.Path != "" {
|
||||
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
|
||||
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
|
||||
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
res.AnimeTitle = candidates[0].AnimeTitle
|
||||
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
|
||||
res.MatchMode = "filename"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
|
||||
// 多结果返回候选列表交给播放器(歧义处理)。
|
||||
if target == "" {
|
||||
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
res.Candidates = candidates
|
||||
return res, nil
|
||||
}
|
||||
if len(candidates[0].Episodes) == 0 {
|
||||
return res, errors.New("no danmaku library found for this video")
|
||||
}
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
res.AnimeTitle = candidates[0].AnimeTitle
|
||||
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
|
||||
res.MatchMode = "search"
|
||||
if len(candidates[0].Episodes) == 0 {
|
||||
return res, errors.New("no danmaku library found for this video")
|
||||
}
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
res.AnimeTitle = candidates[0].AnimeTitle
|
||||
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
|
||||
res.MatchMode = "search"
|
||||
}
|
||||
|
||||
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
|
||||
if err != nil {
|
||||
@@ -787,7 +787,7 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s
|
||||
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Success bool `json:"success"`
|
||||
Success bool `json:"success"`
|
||||
Matches []danmakuMatch `json:"matches"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
|
||||
@@ -168,6 +168,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
}
|
||||
}
|
||||
|
||||
if containsEmbyFilter(p.Filters, "IsResumable") {
|
||||
return e.resumableItems(ctx, p)
|
||||
}
|
||||
|
||||
if len(p.IDs) > 0 {
|
||||
items := make([]map[string]any, 0, len(p.IDs))
|
||||
for _, id := range p.IDs {
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
|
||||
// Item 单条目详情。
|
||||
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
@@ -174,17 +177,29 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
|
||||
// ResumeItems 列出有未完成播放进度的媒体。
|
||||
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
|
||||
}
|
||||
|
||||
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
|
||||
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
if p.Limit <= 0 || p.Limit > 100 {
|
||||
p.Limit = 50
|
||||
}
|
||||
if p.StartIndex < 0 {
|
||||
p.StartIndex = 0
|
||||
}
|
||||
if strings.TrimSpace(p.UserID) == "" {
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
var hist []model.PlaybackHistory
|
||||
if err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false).
|
||||
Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil {
|
||||
Where("user_id = ? AND completed = ? AND position_ms > 0", p.UserID, false).
|
||||
Order("watched_at desc").Find(&hist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(hist) == 0 {
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": 0}, nil
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
localIDs := make([]string, 0, len(hist))
|
||||
@@ -197,7 +212,7 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
||||
if len(localIDs) > 0 {
|
||||
var medias []model.Media
|
||||
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
if err := q.Find(&medias).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -209,6 +224,9 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
||||
items := make([]map[string]any, 0, len(hist))
|
||||
for _, h := range hist {
|
||||
if m, ok := byID[h.MediaID]; ok {
|
||||
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID {
|
||||
continue
|
||||
}
|
||||
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
|
||||
continue
|
||||
}
|
||||
@@ -224,10 +242,23 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
||||
if err != nil || item == nil {
|
||||
continue
|
||||
}
|
||||
if p.ParentID != "" {
|
||||
parentID, _ := item["ParentId"].(string)
|
||||
seriesID, _ := item["SeriesId"].(string)
|
||||
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
|
||||
items = append(items, item)
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
|
||||
|
||||
total := int64(len(items))
|
||||
if p.StartIndex >= len(items) {
|
||||
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
end := minInt(p.StartIndex+p.Limit, len(items))
|
||||
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
|
||||
|
||||
@@ -106,4 +106,4 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
|
||||
if items, ok := m["Items"]; ok {
|
||||
RewriteEmbyRemoteIDs(items, accountID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ func TestDecodeEmbyRemoteIDRejectsLocalUUIDs(t *testing.T) {
|
||||
|
||||
func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"Id": "item-1",
|
||||
"ParentId": "folder-1",
|
||||
"SeriesId": "series-1",
|
||||
"SeasonId": "season-1",
|
||||
"PrimaryImageItemId": "item-1",
|
||||
"Id": "item-1",
|
||||
"ParentId": "folder-1",
|
||||
"SeriesId": "series-1",
|
||||
"SeasonId": "season-1",
|
||||
"PrimaryImageItemId": "item-1",
|
||||
"DisplayPreferencesId": "folder-1",
|
||||
"ImageTags": map[string]any{
|
||||
"Primary": "item-1",
|
||||
@@ -58,8 +58,8 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
|
||||
"MediaSources": []any{
|
||||
map[string]any{
|
||||
"Id": "ms-9",
|
||||
"DirectStreamUrl": "/Videos/item-1/stream",
|
||||
"Id": "ms-9",
|
||||
"DirectStreamUrl": "/Videos/item-1/stream",
|
||||
"MediaStreams": []any{
|
||||
map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/Subtitles/2/Stream.srt"},
|
||||
},
|
||||
@@ -99,4 +99,4 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
if ms["DirectStreamUrl"] != "/Videos/item-1/stream" {
|
||||
t.Fatalf("DirectStreamUrl must stay raw, got %v", ms["DirectStreamUrl"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +49,11 @@ func TestRewriteSubtitleDeliveryURLsFallsBackIndexOne(t *testing.T) {
|
||||
}
|
||||
rewriteSubtitleDeliveryURLs(src, "/Videos/embyremote~acct-1~item-1", &EmbyRemoteConfig{})
|
||||
streams := src["MediaStreams"].([]any)
|
||||
want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream"
|
||||
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want {
|
||||
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
|
||||
}
|
||||
want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream"
|
||||
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want {
|
||||
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) {
|
||||
r := &EmbyRemoteService{}
|
||||
@@ -96,4 +96,4 @@ func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) {
|
||||
if media.SizeBytes != 104857600 {
|
||||
t.Fatalf("size = %d, want 104857600", media.SizeBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,23 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
|
||||
// runtimeTicks 缺失时回退到 media.DurationSec
|
||||
if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil {
|
||||
dur = int64(m.DurationSec) * 1000
|
||||
} else if IsEmbyRemoteID(mediaID) {
|
||||
// 远程挂载条目:尝试从既有历史记录或远程详情补齐时长
|
||||
var oldHist model.PlaybackHistory
|
||||
if err := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&oldHist).Error; err == nil && oldHist.DurationMs > 0 {
|
||||
dur = oldHist.DurationMs
|
||||
} else if e.remote != nil {
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
if mount, acct, _ := e.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil {
|
||||
if item, _ := e.remote.RemoteItem(ctx, mount, acct, remoteID); item != nil {
|
||||
if ticks, ok := item["RunTimeTicks"].(float64); ok && ticks > 0 {
|
||||
dur = int64(ticks) / 10_000
|
||||
} else if ticks, ok := item["RunTimeTicks"].(int64); ok && ticks > 0 {
|
||||
dur = ticks / 10_000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
completed := dur > 0 && pos >= dur*9/10
|
||||
|
||||
@@ -79,3 +79,32 @@ func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
|
||||
t.Fatalf("first item ID = %#v, want %q", items[0]["Id"], remoteID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
remoteID := EncodeEmbyRemoteID("mount-test", "item-999")
|
||||
user := &model.User{Username: "resume_test_user", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := svc.repo.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
// 先以有 runtimeTicks 写入首次进度
|
||||
if err := svc.RecordProgress(t.Context(), user.ID, remoteID, 10_000_000, 100_000_000); err != nil {
|
||||
t.Fatalf("first record progress: %v", err)
|
||||
}
|
||||
// 再次上报,但某些客户端此时发了 0 runtimeTicks
|
||||
if err := svc.RecordProgress(t.Context(), user.ID, remoteID, 95_000_000, 0); err != nil {
|
||||
t.Fatalf("second record progress: %v", err)
|
||||
}
|
||||
|
||||
var hist model.PlaybackHistory
|
||||
if err := svc.repo.DB.Where("user_id = ? AND media_id = ?", user.ID, remoteID).First(&hist).Error; err != nil {
|
||||
t.Fatalf("find hist: %v", err)
|
||||
}
|
||||
if hist.DurationMs != 10_000 {
|
||||
t.Fatalf("expected duration 10000ms, got %d", hist.DurationMs)
|
||||
}
|
||||
if !hist.Completed {
|
||||
t.Fatalf("expected 95%% progress to be completed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,4 +111,3 @@ func safeZipTarget(destRoot, name string) (string, error) {
|
||||
}
|
||||
return targetAbs, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -78,4 +78,3 @@ func TestStartInstallRejectsConcurrent(t *testing.T) {
|
||||
svc.running = false
|
||||
svc.mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,11 @@ func TestResolveSSLMaterial(t *testing.T) {
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
name string
|
||||
content string
|
||||
path string
|
||||
want string
|
||||
err bool
|
||||
path string
|
||||
want string
|
||||
err bool
|
||||
}{
|
||||
{name: "content only", content: certPEM, want: certPEM},
|
||||
{name: "path only", path: path, want: certPEM},
|
||||
|
||||
@@ -93,4 +93,3 @@ func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||||
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
|
||||
return PruneImageCache(p.cacheDir, maxBytes)
|
||||
}
|
||||
|
||||
|
||||
@@ -179,9 +179,9 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) {
|
||||
if len(results) != 1 || results[0].TMDbID != 27205 {
|
||||
t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries)
|
||||
}
|
||||
if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" {
|
||||
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
|
||||
}
|
||||
if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" {
|
||||
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
|
||||
|
||||
@@ -117,10 +117,10 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
||||
if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error
|
||||
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error
|
||||
})
|
||||
if err == nil {
|
||||
s.invalidateMediaCache(ctx)
|
||||
|
||||
@@ -229,9 +229,9 @@ func (o *OrganizerService) replaceVersions(ctx context.Context, src string, exis
|
||||
o.log.Warn("organize replace remove existing failed",
|
||||
zap.String("path", e), zap.Error(err))
|
||||
}
|
||||
if o.repo != nil && o.repo.DB != nil {
|
||||
_ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error
|
||||
}
|
||||
if o.repo != nil && o.repo.DB != nil {
|
||||
_ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error
|
||||
}
|
||||
}
|
||||
// Move staged file + sidecars into the final path.
|
||||
if err := os.Rename(stage, dst); err != nil {
|
||||
|
||||
@@ -19,8 +19,9 @@ import (
|
||||
|
||||
// PlaybackService bundles history / favourite / playlist business logic.
|
||||
type PlaybackService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
remote *EmbyRemoteService
|
||||
}
|
||||
|
||||
// NewPlaybackService is the constructor.
|
||||
@@ -28,6 +29,14 @@ func NewPlaybackService(log *zap.Logger, repo *repository.Container) *PlaybackSe
|
||||
return &PlaybackService{log: log, repo: repo}
|
||||
}
|
||||
|
||||
// SetEmbyRemote wires the remote Emby service for hydrating mounted remote items.
|
||||
func (p *PlaybackService) SetEmbyRemote(remote *EmbyRemoteService) *PlaybackService {
|
||||
if p != nil {
|
||||
p.remote = remote
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ─── History ────────────────────────────────────────────────────────────────
|
||||
|
||||
// RecordProgress upserts the resume position for a (user, media) pair. A
|
||||
@@ -84,9 +93,19 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
|
||||
if m, ok := mediaByID[rows[i].MediaID]; ok {
|
||||
media := m
|
||||
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
|
||||
} else {
|
||||
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
|
||||
continue
|
||||
}
|
||||
if p.remote != nil && IsEmbyRemoteID(rows[i].MediaID) {
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(rows[i].MediaID)
|
||||
if mount, acct, _ := p.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil {
|
||||
if rm, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID); err == nil && rm != nil {
|
||||
media := *rm
|
||||
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -106,18 +106,18 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
||||
cfg.App.SSLCert = value
|
||||
case "https.key":
|
||||
cfg.App.SSLKey = value
|
||||
case "https.cert_path":
|
||||
cfg.App.SSLCertPath = strings.TrimSpace(value)
|
||||
case "https.key_path":
|
||||
cfg.App.SSLKeyPath = strings.TrimSpace(value)
|
||||
case "cache.images_max_size_mb":
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
cfg.Cache.ImagesMaxSizeMB = n
|
||||
case "https.cert_path":
|
||||
cfg.App.SSLCertPath = strings.TrimSpace(value)
|
||||
case "https.key_path":
|
||||
cfg.App.SSLKeyPath = strings.TrimSpace(value)
|
||||
case "cache.images_max_size_mb":
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
cfg.Cache.ImagesMaxSizeMB = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseBoolSetting is the exported variant of parseBoolSetting for handlers
|
||||
|
||||
@@ -231,4 +231,3 @@ func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+11
-11
@@ -59,13 +59,13 @@ type Container struct {
|
||||
Device *DeviceService
|
||||
Cache *RuntimeCacheService
|
||||
Sessions *SessionTrackerService
|
||||
RecognitionWords *RecognitionWordsService
|
||||
Danmaku *DanmakuService
|
||||
Strm *StrmService
|
||||
Database *DatabaseAdminService
|
||||
FFTools *FFmpegToolsService
|
||||
RecognitionWords *RecognitionWordsService
|
||||
Danmaku *DanmakuService
|
||||
Strm *StrmService
|
||||
Database *DatabaseAdminService
|
||||
FFTools *FFmpegToolsService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
|
||||
// ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler
|
||||
@@ -110,12 +110,12 @@ func (c *Container) Boot() {
|
||||
c.Strm.Start(c.stopCtx)
|
||||
}
|
||||
|
||||
// 启动刮削队列后台消费者
|
||||
if c.Scraper != nil {
|
||||
c.Scraper.Start(c.stopCtx)
|
||||
}
|
||||
// 启动刮削队列后台消费者
|
||||
if c.Scraper != nil {
|
||||
c.Scraper.Start(c.stopCtx)
|
||||
}
|
||||
|
||||
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
||||
if c.Device != nil {
|
||||
go c.runInactivitySweeper(c.stopCtx)
|
||||
|
||||
@@ -110,7 +110,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
|
||||
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
|
||||
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
|
||||
b.c.Playback = NewPlaybackService(b.log, b.repos)
|
||||
b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote)
|
||||
b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
|
||||
b.c.Profile = NewProfileService(b.log, b.repos)
|
||||
b.c.Audit = NewAuditService(b.log, b.repos)
|
||||
|
||||
@@ -22,18 +22,18 @@ func TestNormalizeCloudPlayTarget(t *testing.T) {
|
||||
if parsed.IsAbs() || parsed.Host != "" {
|
||||
t.Fatalf("normalized target should be relative, got %q", got)
|
||||
}
|
||||
if parsed.Query().Get("ref") != ref {
|
||||
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
|
||||
}
|
||||
if parsed.Query().Get("ref") != ref {
|
||||
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
|
||||
}
|
||||
|
||||
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||
gotStrm := normalizeCloudPlayTarget(strmStale)
|
||||
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||
if gotStrm != wantStrm {
|
||||
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
|
||||
}
|
||||
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||
gotStrm := normalizeCloudPlayTarget(strmStale)
|
||||
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||
if gotStrm != wantStrm {
|
||||
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
|
||||
}
|
||||
|
||||
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
|
||||
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
|
||||
passthrough := "https://dav.example.com/media/file.mkv"
|
||||
if got := normalizeCloudPlayTarget(passthrough); got != passthrough {
|
||||
t.Fatalf("non-cloud target should pass through, got %q", got)
|
||||
|
||||
@@ -91,7 +91,7 @@ type StrmService struct {
|
||||
oauthSessions map[string]*strm115AuthSession
|
||||
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
|
||||
|
||||
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
|
||||
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
|
||||
downloadSemOnce sync.Once
|
||||
}
|
||||
|
||||
|
||||
@@ -205,4 +205,4 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
|
||||
defer f.Close()
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user