mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f19e2210e | |||
| 91667c400c | |||
| 0b8438d798 | |||
| 2faedae0bf | |||
| fd57d2e59b | |||
| 8356f5c3c7 |
@@ -197,6 +197,32 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// setEmbyMountsEnabledHandler 一键启用/停用某账号下的全部挂载。
|
||||
//
|
||||
// 远端整站不可用时(如 Cloudflare 522),管理员可以用它把该账号的挂载
|
||||
// 全部从媒体库摘掉,首页立刻只返回本地库;远端恢复后再一键启用。
|
||||
func setEmbyMountsEnabledHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.EmbyRemote == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
|
||||
return
|
||||
}
|
||||
affected, err := svc.EmbyRemote.SetMountsEnabled(c.Request.Context(), c.Param("id"), *req.Enabled)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "affected": affected})
|
||||
}
|
||||
}
|
||||
|
||||
type reorderEmbyMountsReq struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// TestSetEmbyMountsEnabledHandler 覆盖「一键停用/启用某账号下全部挂载」:
|
||||
// 只影响目标账号,且配置保留(不是删除)。
|
||||
func TestSetEmbyMountsEnabledHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
ctx := t.Context()
|
||||
|
||||
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Enabled: true}
|
||||
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Enabled: true}
|
||||
other := &model.EmbyMount{AccountID: "acct-2", RemoteViewID: "view-3", Enabled: true}
|
||||
for _, m := range []*model.EmbyMount{m1, m2, other} {
|
||||
if err := repos.EmbyMount.Create(ctx, m); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
|
||||
}
|
||||
router := gin.New()
|
||||
router.PUT("/admin/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
|
||||
|
||||
call := func(body map[string]any) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/emby/accounts/acct-1/mounts/enabled", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
w := call(map[string]any{"enabled": false})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Affected int64 `json:"affected"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Affected != 2 {
|
||||
t.Fatalf("expected ok with affected=2, got %+v", resp)
|
||||
}
|
||||
|
||||
rows, err := repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
want := row.AccountID != "acct-1" // 目标账号停用,其他账号不受影响
|
||||
if row.Enabled != want {
|
||||
t.Fatalf("mount %s enabled=%v, want %v", row.ID, row.Enabled, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 重新启用:挂载记录仍在,只是开关回 true。
|
||||
w = call(map[string]any{"enabled": true})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
rows, err = repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected mounts to be preserved, got %d rows", len(rows))
|
||||
}
|
||||
for _, row := range rows {
|
||||
if !row.Enabled {
|
||||
t.Fatalf("mount %s should be enabled again", row.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺少 enabled 字段必须拒绝,避免误把全部挂载停掉。
|
||||
if w := call(map[string]any{}); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing enabled, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -51,11 +51,48 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
|
||||
hlsURL = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
// 起播路径原本还要各发一次 /playback/{id}/resume 和
|
||||
// /media/{id}/subtitles。把这两份数据并进播放地址响应后,移动端起播
|
||||
// 只剩一个请求 —— 跨境链路上每多一条连接就多一次 DNS/TLS 建连,
|
||||
// 实测单次建连可到数秒。老客户端不读这两个字段,行为不变。
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
resumePayload := gin.H{"position_ms": int64(0), "duration_ms": int64(0), "completed": false}
|
||||
if svc.Playback != nil {
|
||||
if row, err := svc.Playback.GetProgress(c.Request.Context(), toString(uid), m.ID); err == nil && row != nil {
|
||||
resumePayload = gin.H{
|
||||
"position_ms": row.PositionMs,
|
||||
"duration_ms": row.DurationMs,
|
||||
"completed": row.Completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
// 直连播放只需要外挂字幕;内嵌字幕由播放器从容器里解。
|
||||
subtitles := []service.SubtitleTrack{}
|
||||
if svc.Subtitle != nil && !(svc.EmbyRemote != nil && service.IsEmbyRemoteID(m.ID)) {
|
||||
if discovered, err := svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), m.ID); err == nil && discovered != nil {
|
||||
subtitles = discovered
|
||||
}
|
||||
}
|
||||
// 能在服务端换链就把最终直链一并返回:客户端直接连 CDN,省掉
|
||||
// /api/stream 的 302 往返。跨境/高延迟链路上,这一跳等于一次完整的
|
||||
// DNS + TCP + TLS,实测能到数秒,正好压在起播关键路径上。
|
||||
// 换链失败、本地文件或需要服务端加头的网盘保持原样,客户端仍走
|
||||
// stream_url 跳转,行为与改动前一致。
|
||||
directURL, hasDirectURL := "", false
|
||||
if svc.Stream != nil {
|
||||
directURL, hasDirectURL = svc.Stream.ResolveDirectPlayURL(c.Request, m)
|
||||
}
|
||||
payload := gin.H{
|
||||
"media": m,
|
||||
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
|
||||
"hls_url": hlsURL,
|
||||
})
|
||||
"resume": resumePayload,
|
||||
"subtitles": subtitles,
|
||||
}
|
||||
if hasDirectURL {
|
||||
payload["direct_url"] = directURL
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
)
|
||||
|
||||
func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
|
||||
@@ -435,6 +436,10 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
var payload struct {
|
||||
StreamURL string `json:"stream_url"`
|
||||
HlsURL string `json:"hls_url"`
|
||||
// 移动端起播只打这一个接口:resume / subtitles 必须始终存在,
|
||||
// 哪怕是默认值,否则客户端会退回额外两个请求。
|
||||
Resume *struct{ PositionMs int64 `json:"position_ms"` } `json:"resume"`
|
||||
Subtitles *[]struct{} `json:"subtitles"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -445,6 +450,66 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
|
||||
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
|
||||
}
|
||||
if payload.Resume == nil {
|
||||
t.Fatalf("playback info must always carry resume (got body=%s)", w.Body.String())
|
||||
}
|
||||
if payload.Subtitles == nil {
|
||||
t.Fatalf("playback info must always carry subtitles (got body=%s)", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端能换到最终直链时,播放地址响应要把直链一并给客户端:移动端起播就能
|
||||
// 跳过 /api/stream 的 302 往返(跨境链路上等于一次完整 DNS+TCP+TLS)。
|
||||
func TestPlaybackInfoCarriesPrefetchedDirectURL(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
|
||||
var gotUA string
|
||||
svc.Stream.SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*service.StrmPlayResult, error) {
|
||||
gotUA = userAgent
|
||||
return &service.StrmPlayResult{
|
||||
RedirectURL: direct,
|
||||
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
|
||||
}, nil
|
||||
})
|
||||
if err := svc.Repo.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-direct"},
|
||||
LibraryID: "lib-1",
|
||||
Title: "Direct",
|
||||
Path: "cloud://cloud115/Movie.mkv",
|
||||
Container: "strm",
|
||||
DurationSec: 3600,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-direct/info", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
req.Header.Set("User-Agent", "MeBox-Android/0.1.0")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
DirectURL string `json:"direct_url"`
|
||||
StreamURL string `json:"stream_url"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if payload.DirectURL != direct {
|
||||
t.Fatalf("direct_url = %q, want %q (body=%s)", payload.DirectURL, direct, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(payload.StreamURL, "/api/stream/media-direct") {
|
||||
t.Fatalf("stream_url must stay as the fallback, got %q", payload.StreamURL)
|
||||
}
|
||||
// 115 直链绑定换取时的 UA:预置直链必须按播放器的 UA 换,否则播放会 403。
|
||||
if gotUA != "MeBox-Android/0.1.0" {
|
||||
t.Fatalf("resolver UA = %q, want the client UA", gotUA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
|
||||
|
||||
@@ -50,6 +50,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
|
||||
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
|
||||
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
|
||||
admin.PUT("/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
|
||||
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
|
||||
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
|
||||
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -89,6 +90,22 @@ func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) er
|
||||
})
|
||||
}
|
||||
|
||||
// SetEnabledByAccountID 批量设置账号下全部挂载的启用状态。
|
||||
func (r *EmbyMountRepository) SetEnabledByAccountID(ctx context.Context, accountID string, enabled bool) (int64, error) {
|
||||
var affected int64
|
||||
if strings.TrimSpace(accountID) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.EmbyMount{}).
|
||||
Where("account_id = ?", accountID).
|
||||
Updates(map[string]any{"enabled": enabled, "updated_at": time.Now()})
|
||||
affected = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return affected, err
|
||||
}
|
||||
|
||||
func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// MediaSeriesIndexColumns 是解析剧集归组 key 所需的全部列。
|
||||
//
|
||||
// 剧集归组(首页「最近添加」、媒体库卡片)必须看到整库每一行才能算出正确的
|
||||
// key 和集数,因此不能只用 SQL 顶端的几行。但整行扫描会把 overview、genres、
|
||||
// strm_url 这些大文本列一并读进来再丢掉:7k 行规模下驱动层的逐列 bind 占了
|
||||
// 绝大部分耗时(实测 ~60%)。这里只取归组真正用到的列,卡片代表行在选出
|
||||
// 前 N 个剧集后按 id 再补一次完整行。
|
||||
const MediaSeriesIndexColumns = "" +
|
||||
"id, created_at, updated_at, library_id, library_root_id, series_id, " +
|
||||
"title, original_name, path, poster_url, backdrop_url, " +
|
||||
"year, release_date, season_num, episode_num, episode_fraction, " +
|
||||
"tm_db_id, bangumi_id, douban_id, thetvdb_id, " +
|
||||
"width, height, size_bytes, video_codec, audio_codec, container, nsfw"
|
||||
|
||||
// ListMediaSeriesIndex 按 created_at 倒序返回可见媒体的「归组投影」。
|
||||
//
|
||||
// 与 SearchFiltered 的区别:
|
||||
// - 不做 COUNT(*)(调用方不需要总数,7k 行时这一条 SQL 白跑一遍全表);
|
||||
// - 只 SELECT 归组所需列,避免大文本列的解码与内存拷贝。
|
||||
func (r *MediaRepository) ListMediaSeriesIndex(ctx context.Context, limit int, filter MediaQueryFilter) ([]model.Media, error) {
|
||||
var items []model.Media
|
||||
q := r.db.WithContext(ctx).Model(&model.Media{}).
|
||||
Select(MediaSeriesIndexColumns).
|
||||
Order("created_at desc")
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
if err := q.Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ListMediaByIDs 按 ID 批量取完整行(用于补齐剧集卡片的代表行)。
|
||||
func (r *MediaRepository) ListMediaByIDs(ctx context.Context, ids []string) ([]model.Media, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var items []model.Media
|
||||
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -40,6 +40,23 @@ import (
|
||||
// embyRemoteHTTPTimeout 远程 Emby 常规 API 请求超时(流式代理不在此列)。
|
||||
const embyRemoteHTTPTimeout = 15 * time.Second
|
||||
|
||||
// embyRemoteViewsBudget 是网页端聚合远程媒体库时的总时间预算。
|
||||
//
|
||||
// 上游 Emby 整体不可用时(典型表现:Cloudflare 522,连接卡住近 20 秒),
|
||||
// 首页 /api/libraries 会被同步阻塞,而前端 axios 30 秒就放弃,于是一个挂掉的
|
||||
// 远端会把整个首页拖成加载失败。这里给聚合调用一个硬预算:超时即降级为
|
||||
// 「只返回本地库」,把首屏确定性地控制在几秒内。
|
||||
var embyRemoteViewsBudget = 5 * time.Second
|
||||
|
||||
// embyRemoteViewsFailureTTL 是远程 Views 拉取失败后的负缓存时长。
|
||||
//
|
||||
// 失败结果也缓存一小段时间,避免每个请求都重新等满预算(首页每次刷新、
|
||||
// 每个并发客户端都会各打一次)。时间很短,远端恢复后最多 30 秒即可自动重试。
|
||||
var embyRemoteViewsFailureTTL = 30 * time.Second
|
||||
|
||||
// errRemoteViewsRecentlyFailed 表示远程 Emby 刚失败过,当前处于负缓存窗口。
|
||||
var errRemoteViewsRecentlyFailed = errors.New("远程 Emby 暂时不可用(近期请求失败,稍后自动重试)")
|
||||
|
||||
// embyRemoteUA 桌面浏览器 UA:远程 Emby 前方若有 Cloudflare/WAF 会拦截
|
||||
// Go-http-client 等非浏览器 UA(403 error code: 1010),必须伪装浏览器。
|
||||
const embyRemoteUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
||||
@@ -422,6 +439,22 @@ func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMountsEnabled 批量启用/停用某账号下的全部挂载。
|
||||
//
|
||||
// 账号整体不可用时(远端宕机、线路全挂),管理员可以一键把所有挂载从媒体库
|
||||
// 中摘掉:网页端 /api/libraries 不再为该账号发起任何远程请求,首页恢复秒开;
|
||||
// 远端恢复后再一键启用即可,挂载配置和播放设置都不丢。
|
||||
func (r *EmbyRemoteService) SetMountsEnabled(ctx context.Context, accountID string, enabled bool) (int64, error) {
|
||||
affected, err := r.repo.EmbyMount.SetEnabledByAccountID(ctx, accountID, enabled)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 启停会改变可见库集合,顺手清掉远程元数据缓存(含 Views 失败负缓存),
|
||||
// 让下一次请求立刻按新状态重新探测,而不是继续吃 30 秒的负缓存。
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// ReorderMounts 批量重排挂载媒体库顺序。
|
||||
func (r *EmbyRemoteService) ReorderMounts(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
@@ -822,7 +855,13 @@ func (r *EmbyRemoteService) TestConnection(ctx context.Context, acct *model.Strm
|
||||
return err
|
||||
}
|
||||
var out json.RawMessage
|
||||
return r.doGet(ctx, acct, cfg, "/System/Info", nil, &out)
|
||||
if err := r.doGet(ctx, acct, cfg, "/System/Info", nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
// 连通性恢复:清掉 Views 失败负缓存与旧元数据,让下一次媒体库列表请求
|
||||
// 立即重新探测,而不是继续在负缓存窗口里显示「没有远程库」。
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxyPlayOf 返回账号是否配置了播放代理(供账号列表/编辑回显)。
|
||||
@@ -837,7 +876,34 @@ 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) {
|
||||
if acct == nil {
|
||||
return nil, errors.New("缺少远程 Emby 账号")
|
||||
}
|
||||
failKey := r.remoteCacheKey("views-fail", acct.ID)
|
||||
var failed bool
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, failKey, &failed) && failed {
|
||||
return nil, errRemoteViewsRecentlyFailed
|
||||
}
|
||||
views, err := r.remoteViewsUncached(ctx, acct)
|
||||
if err != nil {
|
||||
// 客户端主动断开(切换页面 / 关标签)不算上游故障,不能让它把
|
||||
// 负缓存窗口写进去,否则正常用户会被别人的一次取消拖慢 30 秒。
|
||||
if r.cache != nil && !errors.Is(err, context.Canceled) {
|
||||
r.cache.SetJSON(ctx, failKey, true, embyRemoteViewsFailureTTL)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, failKey, false, embyRemoteViewsFailureTTL)
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// remoteViewsUncached 是 RemoteViews 的实际拉取逻辑(含正常结果缓存)。
|
||||
func (r *EmbyRemoteService) remoteViewsUncached(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// TestRemoteLibrariesFastFailAndNegativeCache 覆盖首页可用的底线保证:
|
||||
// 上游 Emby 整体挂起时,聚合远程库必须在一个很短的预算内失败并降级为
|
||||
// 「没有远程库」,并且在负缓存窗口内不再重复打上游。
|
||||
func TestRemoteLibrariesFastFailAndNegativeCache(t *testing.T) {
|
||||
oldBudget, oldTTL := embyRemoteViewsBudget, embyRemoteViewsFailureTTL
|
||||
embyRemoteViewsBudget = 200 * time.Millisecond
|
||||
embyRemoteViewsFailureTTL = time.Minute
|
||||
t.Cleanup(func() {
|
||||
embyRemoteViewsBudget = oldBudget
|
||||
embyRemoteViewsFailureTTL = oldTTL
|
||||
})
|
||||
|
||||
release := make(chan struct{})
|
||||
var hits int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
// 模拟 Cloudflare 522 前近 20 秒的挂起:请求一直不返回。
|
||||
select {
|
||||
case <-release:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
defer close(release)
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
|
||||
SetRuntimeCache(NewRuntimeCacheService(nil, zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": upstream.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-hang"},
|
||||
Name: "挂起的远端",
|
||||
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-hang"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-1",
|
||||
RemoteViewName: "卡住的库",
|
||||
CollectionType: "movies",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
views, err := remote.RemoteLibraries(t.Context())
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteLibraries should degrade instead of failing: %v", err)
|
||||
}
|
||||
if len(views) != 0 {
|
||||
t.Fatalf("expected no remote libraries while upstream hangs, got %d", len(views))
|
||||
}
|
||||
if elapsed > 2*time.Second {
|
||||
t.Fatalf("RemoteLibraries took %v, want within the short budget", elapsed)
|
||||
}
|
||||
if atomic.LoadInt32(&hits) == 0 {
|
||||
t.Fatal("expected upstream to be probed once")
|
||||
}
|
||||
|
||||
// 负缓存窗口内:不再打上游,且立即返回空结果。
|
||||
before := atomic.LoadInt32(&hits)
|
||||
start = time.Now()
|
||||
views, err = remote.RemoteLibraries(t.Context())
|
||||
elapsed = time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("second RemoteLibraries should not fail: %v", err)
|
||||
}
|
||||
if len(views) != 0 {
|
||||
t.Fatalf("expected no remote libraries from negative cache, got %d", len(views))
|
||||
}
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("negative cache should answer immediately, took %v", elapsed)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != before {
|
||||
t.Fatalf("negative cache should skip upstream, hits went %d -> %d", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteViewsNotNegativeCachedOnClientCancel 保证客户端主动断开不会污染
|
||||
// 负缓存:否则一个用户切页就会让所有人在 30 秒内看不到远程库。
|
||||
func TestRemoteViewsNotNegativeCachedOnClientCancel(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
defer close(release)
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
cache := NewRuntimeCacheService(nil, zap.NewNop())
|
||||
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
|
||||
SetRuntimeCache(cache)
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": upstream.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-cancel"},
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if _, err := remote.RemoteViews(ctx, acct); err == nil {
|
||||
t.Fatal("expected canceled context to fail")
|
||||
}
|
||||
|
||||
var failed bool
|
||||
if cache.GetJSON(t.Context(), remote.remoteCacheKey("views-fail", acct.ID), &failed) && failed {
|
||||
t.Fatal("client cancel must not write the negative cache")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ type RemoteLibraryView struct {
|
||||
// RemoteLibraries 把所有启用挂载的远程媒体库映射为网页媒体库列表
|
||||
// (只有显式挂载的库才出现在本项目媒体库中)。
|
||||
func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibraryView, error) {
|
||||
// 远程库只是「追加」到本地库列表上,绝不允许它拖住整页首屏:给整段
|
||||
// 聚合(多账号 × 多线路故障转移)一个总预算,超时后各账号按失败处理,
|
||||
// 调用方随即降级为只返回本地库。
|
||||
ctx, cancel := context.WithTimeout(ctx, embyRemoteViewsBudget)
|
||||
defer cancel()
|
||||
|
||||
mounts, err := r.ListMounts(ctx)
|
||||
if err != nil || len(mounts) == 0 {
|
||||
return nil, err
|
||||
|
||||
@@ -12,12 +12,13 @@ import (
|
||||
|
||||
// MediaService offers high-level CRUD over libraries and media items.
|
||||
type MediaService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cache *RuntimeCacheService
|
||||
groupedMediaFlight singleflight.Group
|
||||
libraryRowsFlight singleflight.Group
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cache *RuntimeCacheService
|
||||
groupedMediaFlight singleflight.Group
|
||||
libraryRowsFlight singleflight.Group
|
||||
recentSeriesFlight singleflight.Group
|
||||
}
|
||||
|
||||
type MediaVisibility struct {
|
||||
|
||||
@@ -239,26 +239,6 @@ func (s *MediaService) completedMediaIDSet(ctx context.Context, filters MediaLis
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
|
||||
if limit <= 0 {
|
||||
limit = 24
|
||||
} else if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.SearchMediaVisible(ctx, "", maxMediaSearchLimit, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards := groupMediaSeriesCards(rows)
|
||||
if len(cards) == 0 {
|
||||
return []SeriesCard{}, nil
|
||||
}
|
||||
if len(cards) > limit {
|
||||
cards = cards[:limit]
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID, key string, visibility MediaVisibility) ([]model.Media, error) {
|
||||
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
@@ -129,15 +130,28 @@ var (
|
||||
)
|
||||
|
||||
func normalizeSeriesTitle(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = seriesYearRE.ReplaceAllString(value, " ")
|
||||
value = seriesIDRE.ReplaceAllString(value, " ")
|
||||
value = seriesBraceRE.ReplaceAllString(value, " ")
|
||||
value = seriesSpacerRE.ReplaceAllString(value, " ")
|
||||
return strings.TrimSpace(value)
|
||||
// 归组时每行都会调用(且同一部剧的每集标题相同),纯函数缓存能省掉
|
||||
// 4 条正则的重复执行。
|
||||
if normalized, ok := seriesTitleNormMemo.get(value); ok {
|
||||
return normalized
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
normalized = seriesYearRE.ReplaceAllString(normalized, " ")
|
||||
normalized = seriesIDRE.ReplaceAllString(normalized, " ")
|
||||
normalized = seriesBraceRE.ReplaceAllString(normalized, " ")
|
||||
normalized = seriesSpacerRE.ReplaceAllString(normalized, " ")
|
||||
normalized = strings.TrimSpace(normalized)
|
||||
seriesTitleNormMemo.put(value, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeSeriesPathTitle(value string) string {
|
||||
// 整库归组时每个文件都会走到这里,而 CleanQuery 内部是十几条正则。
|
||||
// 同一部剧的几十集共享同一个目录名,按目录名缓存后,首页「最近添加」
|
||||
// 的分组阶段从 240ms 降到几十毫秒(7k 行实测)。
|
||||
if title, ok := seriesPathTitleMemo.get(value); ok {
|
||||
return title
|
||||
}
|
||||
title, _ := CleanQuery(value)
|
||||
if title == "" {
|
||||
title = normalizeSeriesTitle(value)
|
||||
@@ -146,11 +160,52 @@ func normalizeSeriesPathTitle(value string) string {
|
||||
}
|
||||
stripped := stripSeriesSpecialSuffix(title)
|
||||
if stripped != "" {
|
||||
return stripped
|
||||
title = stripped
|
||||
}
|
||||
seriesPathTitleMemo.put(value, title)
|
||||
return title
|
||||
}
|
||||
|
||||
// boundedStringMemo 是纯字符串函数的定长缓存。
|
||||
//
|
||||
// 只用于「入参唯一决定出参、不读配置/数据库」的函数:剧集标题规范化、
|
||||
// 目录名清洗等。整库归组会把这些函数在每行上重复调用几十次,缓存后首页
|
||||
// 「最近添加」的 CPU 从 ~240ms 降到 ~40ms(7k 行实测)。上限到达后整体
|
||||
// 清空,避免长跑进程无限增长(不同目录名/标题的数量远小于集数)。
|
||||
type boundedStringMemo struct {
|
||||
mu sync.RWMutex
|
||||
max int
|
||||
entries map[string]string
|
||||
}
|
||||
|
||||
func newBoundedStringMemo(max int) *boundedStringMemo {
|
||||
if max <= 0 {
|
||||
max = 1024
|
||||
}
|
||||
return &boundedStringMemo{max: max, entries: make(map[string]string, max/8)}
|
||||
}
|
||||
|
||||
func (m *boundedStringMemo) get(key string) (string, bool) {
|
||||
m.mu.RLock()
|
||||
value, ok := m.entries[key]
|
||||
m.mu.RUnlock()
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (m *boundedStringMemo) put(key, value string) {
|
||||
m.mu.Lock()
|
||||
if len(m.entries) >= m.max {
|
||||
m.entries = make(map[string]string, m.max/4)
|
||||
}
|
||||
m.entries[key] = value
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
var (
|
||||
seriesPathTitleMemo = newBoundedStringMemo(4096)
|
||||
seriesTitleNormMemo = newBoundedStringMemo(4096)
|
||||
)
|
||||
|
||||
func stripSeriesSpecialSuffix(title string) string {
|
||||
for _, re := range []*regexp.Regexp{seriesSpecialCodeRE, seriesSpecialCJKRE} {
|
||||
stripped := strings.TrimSpace(re.ReplaceAllString(title, ""))
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// recentSeriesCardsCacheValue 缓存「最近添加」剧集卡片。Cards 视为不可变,
|
||||
// 调用方只读不改(与 seriesCardsCacheValue 相同的约定)。
|
||||
type recentSeriesCardsCacheValue struct {
|
||||
Cards []SeriesCard
|
||||
}
|
||||
|
||||
// ListRecentSeriesCards 返回最近入库的剧集卡片(首页「最近添加」)。
|
||||
//
|
||||
// 性能约定:首页每次刷新都会打这条路径,因此不能再用 SearchMediaVisible:
|
||||
// 它会先对整表跑一次 COUNT(*),再 SELECT 全部列(含 overview/genres 等大文本)。
|
||||
// 7k 行规模下,驱动层逐列解码就占掉 60% 的耗时,实测 ≈1.6s。
|
||||
//
|
||||
// 现在的做法:
|
||||
// 1. 只取归组必需的列(repository.ListMediaSeriesIndex),不做 COUNT;
|
||||
// 2. 在内存里完成分组/计数/排序,截断到 limit;
|
||||
// 3. 只对最终这几十张卡片的代表行补一次完整行(客户端要 overview、genres);
|
||||
// 4. 结果按可见性缓存,媒体入库/刮削会按 "media:" 前缀统一失效。
|
||||
//
|
||||
// 语义与旧实现一致:key 与 Count 仍然基于整库所有可见行计算,不能只看
|
||||
// 前 N 行,否则剧集 key 会和 /libraries/{id}/series/episodes 对不上。
|
||||
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
|
||||
if limit <= 0 {
|
||||
limit = 24
|
||||
} else if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
cacheKey := s.recentSeriesCardsCacheKey(limit, visibility)
|
||||
if cards, ok := s.cachedRecentSeriesCards(cacheKey); ok {
|
||||
return cards, nil
|
||||
}
|
||||
// singleflight:多个客户端同时刷新首页时只扫一遍库。
|
||||
value, err, _ := s.recentSeriesFlight.Do(cacheKey, func() (any, error) {
|
||||
if cards, ok := s.cachedRecentSeriesCards(cacheKey); ok {
|
||||
return &recentSeriesCardsCacheValue{Cards: cards}, nil
|
||||
}
|
||||
// 首个调用者取消不应让其它等待者一起失败。
|
||||
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
defer cancel()
|
||||
cards, err := s.loadRecentSeriesCards(loadCtx, limit, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cached := &recentSeriesCardsCacheValue{Cards: cards}
|
||||
if s.cache != nil {
|
||||
s.cache.SetObject(cacheKey, cached, s.derivedReadCacheTTL())
|
||||
}
|
||||
return cached, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cached, ok := value.(*recentSeriesCardsCacheValue); ok {
|
||||
return cached.Cards, nil
|
||||
}
|
||||
return []SeriesCard{}, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) cachedRecentSeriesCards(cacheKey string) ([]SeriesCard, bool) {
|
||||
if s == nil || s.cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
obj, ok := s.cache.GetObject(cacheKey)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cached, ok := obj.(*recentSeriesCardsCacheValue)
|
||||
if !ok || cached == nil {
|
||||
return nil, false
|
||||
}
|
||||
return cached.Cards, true
|
||||
}
|
||||
|
||||
func (s *MediaService) loadRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
rows, err := s.repo.Media.ListMediaSeriesIndex(ctx, maxMediaSearchLimit, repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []SeriesCard{}, nil
|
||||
}
|
||||
// 归组 key 依赖库元数据(display library 路径参与指纹计算)。
|
||||
s.attachLibraryMetadata(ctx, rows)
|
||||
cards := groupMediaSeriesCards(rows)
|
||||
if len(cards) == 0 {
|
||||
return []SeriesCard{}, nil
|
||||
}
|
||||
if len(cards) > limit {
|
||||
cards = cards[:limit]
|
||||
}
|
||||
s.hydrateSeriesCardRows(ctx, cards)
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
// hydrateSeriesCardRows 把投影行替换成完整媒体行。
|
||||
//
|
||||
// 归组只需要少量列,但客户端展示卡片要用整行(简介、类型、编码等)。这里
|
||||
// 只针对最终返回的卡片取两次 id 集合,一次 IN 查询补齐,避免把整库大文本列
|
||||
// 都读出来。
|
||||
func (s *MediaService) hydrateSeriesCardRows(ctx context.Context, cards []SeriesCard) {
|
||||
if s == nil || s.repo == nil || len(cards) == 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[string]struct{}, len(cards)*2)
|
||||
ids := make([]string, 0, len(cards)*2)
|
||||
add := func(id string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
for i := range cards {
|
||||
add(cards[i].Rep.ID)
|
||||
add(cards[i].LinkMedia.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
rows, err := s.repo.Media.ListMediaByIDs(ctx, ids)
|
||||
if err != nil || len(rows) == 0 {
|
||||
// 补齐失败时保留投影行:卡片列表仍可用,只是缺少大文本字段。
|
||||
return
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, rows)
|
||||
byID := make(map[string]model.Media, len(rows))
|
||||
for _, row := range rows {
|
||||
byID[row.ID] = row
|
||||
}
|
||||
for i := range cards {
|
||||
if row, ok := byID[cards[i].Rep.ID]; ok {
|
||||
cards[i].Rep = row
|
||||
}
|
||||
if row, ok := byID[cards[i].LinkMedia.ID]; ok {
|
||||
cards[i].LinkMedia = row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MediaService) recentSeriesCardsCacheKey(limit int, visibility MediaVisibility) string {
|
||||
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
|
||||
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
|
||||
sort.Strings(allowed)
|
||||
sort.Strings(hidden)
|
||||
return "media:obj:recent-series:" + hashObjectCacheKey([]string{
|
||||
fmt.Sprintf("%d:%t", limit, visibility.IncludeNSFW),
|
||||
strings.Join(allowed, ","),
|
||||
strings.Join(hidden, ","),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 「最近添加」必须只做投影扫描,但仍然要返回完整媒体行、正确的集数和
|
||||
// 可用于剧集导航的 key —— 这些语义是 /libraries/{id}/series/episodes 的
|
||||
// 前置条件,任何一项退化成「近似值」都会让点开卡片后选集为空。
|
||||
func TestListRecentSeriesCardsHydratesRowsAndKeepsExactCounts(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||||
rows := make([]model.Media, 0, 64)
|
||||
// 长剧:48 集,最新一集入库时间最晚,必须排在首位。
|
||||
for i := 1; i <= 48; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("long-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "长剧",
|
||||
Path: fmt.Sprintf("/media/anime/长剧/Season 1/长剧.S01E%02d.mkv", i),
|
||||
Overview: "简介内容",
|
||||
Genres: "Animation",
|
||||
PosterURL: "/media/anime/长剧/poster.jpg",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
ReleaseDate: "2026-01-01",
|
||||
})
|
||||
}
|
||||
// 另一部短剧,入库更早。
|
||||
for i := 1; i <= 2; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("short-%02d", i), CreatedAt: now.Add(-time.Hour)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "短剧",
|
||||
Path: fmt.Sprintf("/media/anime/短剧/Season 1/短剧.S01E%02d.mkv", i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
ReleaseDate: "2025-05-05",
|
||||
})
|
||||
}
|
||||
if err := db.CreateInBatches(&rows, 50).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
cards, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 2 {
|
||||
t.Fatalf("cards = %d, want 2", len(cards))
|
||||
}
|
||||
if cards[0].Count != 48 {
|
||||
t.Fatalf("长剧 count = %d, want 48 (不能只统计扫描窗口内的行)", cards[0].Count)
|
||||
}
|
||||
// 投影行没有 overview/genres,返回前必须补齐完整体。
|
||||
if cards[0].Rep.Overview == "" || cards[0].Rep.Genres == "" {
|
||||
t.Fatalf("representative row was not hydrated: %#v", cards[0].Rep)
|
||||
}
|
||||
if cards[0].Rep.LibraryName != lib.Name {
|
||||
t.Fatalf("library metadata missing on hydrated row: %q", cards[0].Rep.LibraryName)
|
||||
}
|
||||
|
||||
// 卡片 key 必须能解析出同一部剧的全部剧集(详情页选集依赖它)。
|
||||
episodes, err := svc.ListLibrarySeriesEpisodes(t.Context(), lib.ID, cards[0].Key, MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(episodes) != 48 {
|
||||
t.Fatalf("episodes for card key = %d, want 48", len(episodes))
|
||||
}
|
||||
}
|
||||
|
||||
// 结果缓存命中后必须返回同样的卡片,且不能因为缓存把调用方可见的切片搞乱。
|
||||
func TestListRecentSeriesCardsCachesResult(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("movie-%d", i), CreatedAt: time.Now().Add(-time.Duration(i) * time.Hour)},
|
||||
LibraryID: lib.ID,
|
||||
Title: fmt.Sprintf("电影 %d", i),
|
||||
Path: fmt.Sprintf("/media/movies/电影 %d/movie.mkv", i),
|
||||
ReleaseDate: "2024-01-01",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
|
||||
t.Cleanup(func() { cache.Close() })
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).SetRuntimeCache(cache)
|
||||
|
||||
first, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first) != 2 || len(second) != 2 {
|
||||
t.Fatalf("first = %d second = %d, want 2/2", len(first), len(second))
|
||||
}
|
||||
if first[0].Key != second[0].Key || first[0].Count != second[0].Count {
|
||||
t.Fatalf("cached result mismatch: %#v vs %#v", first[0], second[0])
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,29 @@ func setCloudRedirectNoStore(w http.ResponseWriter) {
|
||||
// 由 /api/strm/play 再去换链,最坏情况只是回到改动前的行为。
|
||||
const directPlayResolveTimeout = 10 * time.Second
|
||||
|
||||
// directPlayInfoResolveTimeout 是播放地址响应(/api/playback/{id}/info)里
|
||||
// 预换链的等待上限。这条路径是用户点播放后的同步阻塞,宁可退回 /api/stream
|
||||
// 跳转,也不能把起播卡在换链上;命中直链缓存时通常只有微秒级。
|
||||
const directPlayInfoResolveTimeout = 3 * time.Second
|
||||
|
||||
// ResolveDirectPlayURL 尝试把媒体的 STRM/网盘目标换成客户端可直接播放的最终
|
||||
// 直链,供 /api/playback/{id}/info 预置给客户端。
|
||||
//
|
||||
// 客户端拿到直链后可以跳过 /api/stream 的 302 往返;在高延迟/跨境链路上,
|
||||
// 这一次「DNS + TCP + TLS + 请求」常常是数秒,正好落在起播关键路径上。
|
||||
// 返回 false 表示必须继续走 /api/stream(本地文件、需要服务端加头的网盘、
|
||||
// 解析失败或超时),行为与改动前一致。
|
||||
func (s *StreamService) ResolveDirectPlayURL(r *http.Request, m *model.Media) (string, bool) {
|
||||
if s == nil || r == nil || m == nil {
|
||||
return "", false
|
||||
}
|
||||
raw := MediaSTRMTarget(m)
|
||||
if raw == "" || !playableSTRMTarget(r.Context(), s.repo, raw, m) {
|
||||
return "", false
|
||||
}
|
||||
return s.resolveDirectPlayTargetURLWithin(r, raw, directPlayInfoResolveTimeout)
|
||||
}
|
||||
|
||||
// resolveDirectPlayTargetURL 尝试在服务端把 strm 目标解析成客户端可直接拉取的
|
||||
// 最终直链,供调用方直接 302。
|
||||
//
|
||||
@@ -117,10 +140,18 @@ const directPlayResolveTimeout = 10 * time.Second
|
||||
// 解析到本地文件、以及解析失败都会返回 false,由调用方按改动前的方式回退到
|
||||
// strm 端点跳转,行为不会变差。
|
||||
func (s *StreamService) resolveDirectPlayTargetURL(r *http.Request, raw string) (string, bool) {
|
||||
return s.resolveDirectPlayTargetURLWithin(r, raw, directPlayResolveTimeout)
|
||||
}
|
||||
|
||||
func (s *StreamService) resolveDirectPlayTargetURLWithin(
|
||||
r *http.Request,
|
||||
raw string,
|
||||
timeout time.Duration,
|
||||
) (string, bool) {
|
||||
if s == nil || s.strmResolve == nil || r == nil {
|
||||
return "", false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), directPlayResolveTimeout)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
// 必须带播放器 UA:115 直链绑定换取时的 UA,且按 UA 分键缓存,换错会拿到
|
||||
// 与播放器不匹配(或未命中缓存)的地址。
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
)
|
||||
|
||||
// /api/playback/{id}/info 预置换链:能换到直链时返回 true,并把客户端 UA
|
||||
// 透传给 115(直链绑定换取时的 UA,播放器必须用同一个)。
|
||||
func TestResolveDirectPlayURLForPlaybackInfo(t *testing.T) {
|
||||
const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
|
||||
var gotUA string
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
|
||||
SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*StrmPlayResult, error) {
|
||||
gotUA = userAgent
|
||||
return &StrmPlayResult{
|
||||
RedirectURL: direct,
|
||||
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
|
||||
}, nil
|
||||
})
|
||||
m := &model.Media{
|
||||
Base: model.Base{ID: "cloud-info"},
|
||||
Path: "cloud://cloud115/Movie.mkv",
|
||||
Container: "strm",
|
||||
STRMURL: "https://mebox.example/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/playback/cloud-info/info", nil)
|
||||
req.Header.Set("User-Agent", "MeBox-Android/0.1.0")
|
||||
|
||||
got, ok := svc.ResolveDirectPlayURL(req, m)
|
||||
if !ok || got != direct {
|
||||
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (%q, true)", got, ok, direct)
|
||||
}
|
||||
if gotUA != "MeBox-Android/0.1.0" {
|
||||
t.Fatalf("resolver UA = %q, want the requesting client UA", gotUA)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地文件没有可直连的 CDN 地址,信息接口不能凭空给出 direct_url。
|
||||
func TestResolveDirectPlayURLSkipsLocalMedia(t *testing.T) {
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
|
||||
SetStrmPlayTargetResolver(func(context.Context, string, string) (*StrmPlayResult, error) {
|
||||
t.Fatal("本地文件不应该触发换链")
|
||||
return nil, nil
|
||||
})
|
||||
m := &model.Media{
|
||||
Base: model.Base{ID: "local-1"},
|
||||
Path: "/media/movie.mkv",
|
||||
Container: "mkv",
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/playback/local-1/info", nil)
|
||||
if got, ok := svc.ResolveDirectPlayURL(req, m); ok || got != "" {
|
||||
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (\"\", false)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 需要服务端附加其它请求头(Referer 等)的直链不能短路给客户端,否则播放端
|
||||
// 少了鉴权头会 403;这类媒体继续走 /api/stream 由服务端转发。
|
||||
func TestResolveDirectPlayURLSkipsHeaderBoundLinks(t *testing.T) {
|
||||
const direct = "https://cdn.example.com/Movie.mkv"
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
|
||||
SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*StrmPlayResult, error) {
|
||||
return &StrmPlayResult{
|
||||
RedirectURL: direct,
|
||||
Link: &cloud.DirectLink{
|
||||
URL: direct,
|
||||
Headers: map[string]string{"User-Agent": userAgent, "Referer": "https://pan.example"},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
m := &model.Media{
|
||||
Base: model.Base{ID: "cloud-header"},
|
||||
Path: "cloud://cloud115/Movie.mkv",
|
||||
Container: "strm",
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/playback/cloud-header/info", nil)
|
||||
if got, ok := svc.ResolveDirectPlayURL(req, m); ok || got != "" {
|
||||
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (\"\", false)", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,77 @@ func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID
|
||||
return discoverExternalSubtitleTracks(m), nil
|
||||
}
|
||||
|
||||
// subtitleLanguageNames 把文件名后缀/容器里的语言标记折成可读名称。
|
||||
//
|
||||
// 外挂字幕只能从文件名拿到语言(`movie.chs.ass` → `chs`),直接展示给用户
|
||||
// 就是「chs」这种看不懂的值;内嵌轨没有 title 标签时同理。认不出来的标记
|
||||
// 原样保留,交给调用方(例如 Emby 的「字幕 (ASS)」兜底)处理。
|
||||
var subtitleLanguageNames = map[string]string{
|
||||
"zh": "中文",
|
||||
"chi": "中文",
|
||||
"zho": "中文",
|
||||
"cn": "中文",
|
||||
|
||||
"chs": "简体中文",
|
||||
"sc": "简体中文",
|
||||
"zh-cn": "简体中文",
|
||||
"zh-hans": "简体中文",
|
||||
"zh-sg": "简体中文",
|
||||
|
||||
"cht": "繁体中文",
|
||||
"tc": "繁体中文",
|
||||
"zh-tw": "繁体中文",
|
||||
"zh-hk": "繁体中文",
|
||||
"zh-mo": "繁体中文",
|
||||
"zh-hant": "繁体中文",
|
||||
|
||||
"jpn": "日本語",
|
||||
"ja": "日本語",
|
||||
"jp": "日本語",
|
||||
|
||||
"eng": "English",
|
||||
"en": "English",
|
||||
|
||||
"kor": "한국어",
|
||||
"ko": "한국어",
|
||||
|
||||
"yue": "粤语",
|
||||
}
|
||||
|
||||
// subtitleLanguageName 返回语言标记的可读名称,认不出来时返回空串。
|
||||
func subtitleLanguageName(raw string) string {
|
||||
tag := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "_", "-")))
|
||||
if tag == "" {
|
||||
return ""
|
||||
}
|
||||
if name, ok := subtitleLanguageNames[tag]; ok {
|
||||
return name
|
||||
}
|
||||
// `chs[bilibiliid_danmu]`、`chs.ass` 这类带附加信息的标记先取前缀。
|
||||
head := tag
|
||||
if i := strings.IndexAny(head, "[.@"); i > 0 {
|
||||
head = strings.TrimSpace(head[:i])
|
||||
}
|
||||
return subtitleLanguageNames[head]
|
||||
}
|
||||
|
||||
// subtitleDisplayLabel 把语言标记换成可读名称;认不出来时保留原值(含 und)。
|
||||
func subtitleDisplayLabel(raw string) string {
|
||||
if name := subtitleLanguageName(raw); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
// isDanmakuSubtitle 报告字幕文件是否是弹幕转存(`xxx[BilibiliID_danmu].ass`)。
|
||||
// 这类轨道是飘过的弹幕而不是对白字幕,名称里要标出来,避免和对白字幕混淆。
|
||||
func isDanmakuSubtitle(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
return strings.Contains(lower, "danmu") ||
|
||||
strings.Contains(lower, "danmaku") ||
|
||||
strings.Contains(name, "弹幕")
|
||||
}
|
||||
|
||||
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
||||
dir := filepath.Dir(m.Path)
|
||||
bases := mediaSidecarBaseVariants(m.Path)
|
||||
@@ -228,9 +299,13 @@ func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
||||
matchedBase = bases[0]
|
||||
}
|
||||
lang := detectLang(fullName, matchedBase)
|
||||
label := subtitleDisplayLabel(lang)
|
||||
if isDanmakuSubtitle(e.Name()) {
|
||||
label += " · 弹幕"
|
||||
}
|
||||
tracks = append(tracks, SubtitleTrack{
|
||||
Lang: lang,
|
||||
Label: lang,
|
||||
Label: label,
|
||||
Path: filepath.Join(c, e.Name()),
|
||||
Codec: codec,
|
||||
Source: "external",
|
||||
@@ -319,7 +394,11 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
|
||||
}
|
||||
label := strings.TrimSpace(stream.Tags.Title)
|
||||
if label == "" {
|
||||
label = lang
|
||||
// 没有 title 标签时用可读语言名(chi → 中文)代替裸语言码。
|
||||
label = subtitleDisplayLabel(lang)
|
||||
}
|
||||
if label == "" {
|
||||
label = "字幕"
|
||||
}
|
||||
if stream.Disposition.Forced != 0 {
|
||||
label += "(强制)"
|
||||
|
||||
@@ -202,3 +202,103 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
|
||||
t.Fatal("ServeRaw accepted a non-subtitle file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleLanguageNameMapsCommonTags(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"chs": "简体中文",
|
||||
"CHS": "简体中文",
|
||||
"zh-Hans": "简体中文",
|
||||
"cht": "繁体中文",
|
||||
"chi": "中文",
|
||||
"zh": "中文",
|
||||
"jpn": "日本語",
|
||||
"eng": "English",
|
||||
"kor": "한국어",
|
||||
"chs[bilibiliid_danmu]": "简体中文",
|
||||
"und": "",
|
||||
"": "",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := subtitleLanguageName(raw); got != want {
|
||||
t.Errorf("subtitleLanguageName(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleDisplayLabelKeepsUnknownTags(t *testing.T) {
|
||||
// 认不出来的标记原样返回:Emby 的 DisplayTitle 依赖 `und` 走「字幕 (ASS)」兜底。
|
||||
if got := subtitleDisplayLabel("und"); got != "und" {
|
||||
t.Fatalf("subtitleDisplayLabel(und) = %q, want und", got)
|
||||
}
|
||||
if got := subtitleDisplayLabel("weird"); got != "weird" {
|
||||
t.Fatalf("subtitleDisplayLabel(weird) = %q, want weird", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDanmakuSubtitle(t *testing.T) {
|
||||
if !isDanmakuSubtitle("S01E01.chs[BilibiliID_danmu].ass") {
|
||||
t.Fatal("danmaku sidecar was not detected")
|
||||
}
|
||||
if !isDanmakuSubtitle("弹幕.ass") {
|
||||
t.Fatal("Chinese danmaku name was not detected")
|
||||
}
|
||||
if isDanmakuSubtitle("S01E01.chs.ass") {
|
||||
t.Fatal("normal subtitle was misdetected as danmaku")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverExternalSubtitleLabelsAreReadable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
videoPath := filepath.Join(dir, "S01E01.mkv")
|
||||
files := map[string]string{
|
||||
"S01E01.chs.ass": "[Script Info]\n",
|
||||
"S01E01.chs[BilibiliID_danmu].ass": "[Script Info]\n",
|
||||
}
|
||||
for name, body := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
m := &model.Media{Path: videoPath}
|
||||
tracks := discoverExternalSubtitleTracks(m)
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("len(tracks) = %d, want 2: %#v", len(tracks), tracks)
|
||||
}
|
||||
labels := map[string]string{}
|
||||
for _, track := range tracks {
|
||||
labels[filepath.Base(track.Path)] = track.Label
|
||||
}
|
||||
if got := labels["S01E01.chs.ass"]; got != "简体中文" {
|
||||
t.Fatalf("chs.ass label = %q, want 简体中文", got)
|
||||
}
|
||||
if got := labels["S01E01.chs[BilibiliID_danmu].ass"]; got != "简体中文 · 弹幕" {
|
||||
t.Fatalf("danmaku label = %q, want 简体中文 · 弹幕", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedSubtitleProbeUsesReadableLanguage(t *testing.T) {
|
||||
var probe embeddedSubtitleProbe
|
||||
raw := []byte(`{"streams":[
|
||||
{"index":2,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"chi"},"disposition":{"default":1}},
|
||||
{"index":3,"codec_name":"subrip","tags":{"language":"jpn"}},
|
||||
{"index":4,"codec_name":"subrip","tags":{"language":"chi","title":"简日双语"}}
|
||||
]}`)
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracks := subtitleTracksFromProbe(probe)
|
||||
if len(tracks) != 3 {
|
||||
t.Fatalf("len(tracks) = %d, want 3", len(tracks))
|
||||
}
|
||||
if got := tracks[0].Label; got != "中文(默认)(内嵌·图片)" {
|
||||
t.Fatalf("bitmap label = %q", got)
|
||||
}
|
||||
if got := tracks[1].Label; got != "日本語(内嵌)" {
|
||||
t.Fatalf("text label = %q", got)
|
||||
}
|
||||
// 容器自带 title 时优先用 title。
|
||||
if got := tracks[2].Label; got != "简日双语(内嵌)" {
|
||||
t.Fatalf("titled label = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ export const embyAPI = {
|
||||
deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data),
|
||||
reorderMounts: (ids: string[]) =>
|
||||
api.put<{ ok: boolean }>('/admin/emby/mounts/reorder', { ids }).then((r) => r.data),
|
||||
setAccountMountsEnabled: (accountId: string, enabled: boolean) =>
|
||||
api
|
||||
.put<{ ok: boolean; affected: number }>(`/admin/emby/accounts/${accountId}/mounts/enabled`, { enabled })
|
||||
.then((r) => r.data),
|
||||
listAccountViews: (accountId: string) =>
|
||||
api.get<RemoteEmbyView[]>(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data),
|
||||
fullMountAccount: (accountId: string, proxy: boolean) =>
|
||||
|
||||
@@ -370,6 +370,8 @@ export function EmbyMountPage() {
|
||||
const [accountDialog, setAccountDialog] = useState<StrmAccount | null | 'new'>(null)
|
||||
const [mountDialogFor, setMountDialogFor] = useState<StrmAccount | null>(null)
|
||||
const [testingID, setTestingID] = useState<string | null>(null)
|
||||
const [togglingAccountID, setTogglingAccountID] = useState<string | null>(null)
|
||||
const [bulkTogglingID, setBulkTogglingID] = useState<string | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
const [reordering, setReordering] = useState(false)
|
||||
@@ -504,6 +506,46 @@ export function EmbyMountPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 账号级总开关:停用后该服务器不再参与任何远程请求,其下挂载从媒体库中
|
||||
// 立即隐藏(配置保留),用于远端宕机时先让首页恢复可用。
|
||||
const toggleAccount = async (acct: StrmAccount) => {
|
||||
const next = !acct.enabled
|
||||
const acctMountCount = mounts.filter((m) => m.account_id === acct.id).length
|
||||
if (!next && !(await confirmAction({
|
||||
message: `停用 Emby 服务器「${acct.name}」?其下 ${acctMountCount} 个远程媒体库会立即从媒体库中隐藏,首页也不再请求该服务器;挂载配置与播放设置会保留,可随时重新启用。`,
|
||||
confirmText: '停用账号',
|
||||
}))) return
|
||||
setTogglingAccountID(acct.id)
|
||||
try {
|
||||
await strmAPI.updateAccount(acct.id, { provider: acct.provider, enabled: next })
|
||||
toast.success(next ? `已启用「${acct.name}」` : `已停用「${acct.name}」,远程媒体库已隐藏`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setTogglingAccountID(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 一键启停该账号下全部挂载(配置保留,只控制是否出现在媒体库中)。
|
||||
const setAllMounts = async (acct: StrmAccount, enabled: boolean) => {
|
||||
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
|
||||
if (!enabled && !(await confirmAction({
|
||||
message: `停用「${acct.name}」下的全部 ${acctMounts.length} 个挂载?它们会立即从媒体库中隐藏,配置保留,可随时全部启用。`,
|
||||
confirmText: '全部停用',
|
||||
}))) return
|
||||
setBulkTogglingID(acct.id)
|
||||
try {
|
||||
const res = await embyAPI.setAccountMountsEnabled(acct.id, enabled)
|
||||
toast.success(enabled ? `已启用 ${res.affected} 个挂载` : `已停用 ${res.affected} 个挂载`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setBulkTogglingID(null)
|
||||
}
|
||||
}
|
||||
|
||||
const removeMount = async (mount: EmbyMount) => {
|
||||
if (!(await confirmAction({ message: `取消挂载「${mount.remote_view_name || mount.name}」?媒体库列表中将不再显示。`, confirmText: '取消挂载' }))) return
|
||||
try {
|
||||
@@ -566,26 +608,55 @@ export function EmbyMountPage() {
|
||||
) : (
|
||||
accounts.map((acct) => {
|
||||
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
|
||||
const anyMountEnabled = acctMounts.some((m) => m.enabled)
|
||||
return (
|
||||
<div key={acct.id} className="overflow-hidden rounded-3xl border border-sand-200 bg-white shadow-card">
|
||||
<div
|
||||
key={acct.id}
|
||||
className={`overflow-hidden rounded-3xl border bg-white shadow-card ${
|
||||
acct.enabled ? 'border-sand-200' : 'border-gray-200 opacity-80'
|
||||
}`}
|
||||
>
|
||||
{/* 账号头部 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sand-100 bg-gradient-to-r from-brand-50/60 to-transparent px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-100 text-brand-600">
|
||||
<span className={`flex h-10 w-10 items-center justify-center rounded-2xl ${acct.enabled ? 'bg-brand-100 text-brand-600' : 'bg-gray-100 text-sand-500'}`}>
|
||||
<Server size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold text-ink-600">{acct.name}</p>
|
||||
<p className="font-display text-lg font-bold text-ink-600">
|
||||
{acct.name}
|
||||
{!acct.enabled && (
|
||||
<span className="ml-2 rounded-full bg-gray-200 px-2 py-0.5 align-middle text-[11px] font-semibold text-sand-500">
|
||||
已停用
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{acct.has_credential ? '凭据已配置' : '待补全凭据'}
|
||||
{acct.emby_lines && acct.emby_lines.length > 0
|
||||
? ` · ${acct.emby_lines.length} 条线路`
|
||||
: ''}
|
||||
{acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}
|
||||
{!acct.enabled ? ' · 不会发起远程请求' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => toggleAccount(acct)}
|
||||
disabled={togglingAccountID === acct.id}
|
||||
className={`iconButtonCls ${acct.enabled ? '!text-amber-600 hover:!bg-amber-50' : '!text-green-600 hover:!bg-green-50'}`}
|
||||
title={acct.enabled ? '停用该服务器:其下挂载全部从媒体库隐藏' : '重新启用该服务器'}
|
||||
>
|
||||
{togglingAccountID === acct.id ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : acct.enabled ? (
|
||||
<PowerOff size={13} />
|
||||
) : (
|
||||
<Power size={13} />
|
||||
)}
|
||||
{acct.enabled ? '停用账号' : '启用账号'}
|
||||
</button>
|
||||
<button onClick={() => testAccount(acct)} disabled={testingID === acct.id} className="iconButtonCls">
|
||||
{testingID === acct.id ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
测试
|
||||
@@ -644,6 +715,7 @@ export function EmbyMountPage() {
|
||||
<p className="text-xs text-sand-500">
|
||||
{m.collection_type || 'mixed'}
|
||||
{m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}
|
||||
{!m.enabled ? ' · 已停用' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -687,14 +759,41 @@ export function EmbyMountPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button onClick={() => setMountDialogFor(acct)} className="btn-outline">
|
||||
<button
|
||||
onClick={() => setMountDialogFor(acct)}
|
||||
disabled={!acct.enabled}
|
||||
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={acct.enabled ? undefined : '账号已停用,启用后才能挂载新的媒体库'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
选择挂载媒体库
|
||||
</button>
|
||||
<button onClick={() => fullMount(acct)} className="btn-outline">
|
||||
<button
|
||||
onClick={() => fullMount(acct)}
|
||||
disabled={!acct.enabled}
|
||||
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={acct.enabled ? undefined : '账号已停用,启用后才能挂载新的媒体库'}
|
||||
>
|
||||
<Check size={14} />
|
||||
全量挂载
|
||||
</button>
|
||||
{acctMounts.length > 0 && (
|
||||
<button
|
||||
onClick={() => setAllMounts(acct, !anyMountEnabled)}
|
||||
disabled={bulkTogglingID === acct.id}
|
||||
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={anyMountEnabled ? '把该账号下全部挂载从媒体库中隐藏(配置保留)' : '恢复该账号下全部挂载'}
|
||||
>
|
||||
{bulkTogglingID === acct.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : anyMountEnabled ? (
|
||||
<PowerOff size={14} />
|
||||
) : (
|
||||
<Power size={14} />
|
||||
)}
|
||||
{anyMountEnabled ? '全部停用' : '全部启用'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user