Compare commits

..

32 Commits

Author SHA1 Message Date
truewhile 3d30f6f27c bug处理 2026-09-08 21:08:47 +08:00
truewhile bd02e194ff 优化 2026-09-08 20:52:35 +08:00
truewhile 1c9b742c8d 优化 2026-09-08 20:24:45 +08:00
truewhile 20e7d7fa99 bug处理 2026-09-08 18:12:52 +08:00
truewhile 394ea0382e 优化 2026-09-08 09:46:02 +08:00
truewhile 4d698d2757 优化播放 2026-09-08 09:24:29 +08:00
truewhile fdfb2ee987 bug处理 2026-09-08 08:41:01 +08:00
truewhile b23b1d728e bug处理 2026-09-08 00:36:56 +08:00
truewhile ad427fc0d2 bug处理 2026-09-08 00:10:25 +08:00
truewhile c489374b61 bug处理 2026-09-07 23:45:59 +08:00
truewhile 5a47e6f52d bug处理 2026-09-07 23:21:24 +08:00
truewhile c4b8114115 优化转码逻辑 2026-09-07 23:10:48 +08:00
truewhile 0e592105c4 fix(web): STRM 直连失败后回退 HLS 转码
fix(web): STRM 直连失败后回退 HLS 转码
2026-09-07 22:56:29 +08:00
truewhile f20c9c3234 fix(web): STRM 直连失败后回退 HLS 转码
不再把 STRM/云盘媒体锁死为直连,解析远端源后允许 ffmpeg 转码播放。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 22:52:54 +08:00
truewhile 38b85bb487 feat(strm): 同名多版本择优 / keep_ext,并支持合并展示与播放切换 (#30)
* feat(strm): 同名多版本择优/keep_ext,并支持合并展示与播放切换

默认对同名不同扩展(如 .mkv/.mp4)按体积→mtime→扩展名优先级择优生成一条 strm,冲突打日志;开启 keep_ext 则分别生成 name.ext.strm。媒体详情与播放器可切换版本,列表继续合并同片多版本,Emby MediaSources 名称区分容器/分辨率。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* fix(meta): 边车匹配忽略 .mkv.strm 中间扩展,同片共享 NFO/海报/字幕

NFO、海报、字幕与刮削写出统一使用 mediaSidecarBase 词干,使竞女01.mkv.strm
与竞女01.mp4.strm 配对同一套 竞女01.nfo / 竞女01-poster / 竞女01.srt;
仍兼容历史上的 name.mkv.nfo 命名。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* fix(emby): People NFO 按共享词干匹配 keep_ext strm

resolveMediaPeople 改为查找竞女01.nfo(并兼容旧的竞女01.mkv.nfo),
同时修正把 decodeNFOFile 的 partial 误当 ok 导致完整 NFO 被跳过的问题。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-07 13:56:30 +08:00
truewhile 6bf8a6878a fix(strm): 115 元数据上传幂等,避免重复上传 (#29)
上传/重试前按父目录+文件名+SHA1 探活,已有同内容副本则跳过并清脏副本;
成功后回写新 file_id;同步扫描认可 30 分钟内同路径同大小的 done 任务,
缩短「任务已完成但列表未反映」导致的再入队窗口。

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-07 09:01:58 +08:00
truewhile 2e636ce43f feat(web): 支持生成电视端临时登录码
- 新增创建临时登录密码的 API 请求
- 新增电视端临时登录密码弹窗组件并在用户菜单中提供入口
- 在个人资料页面添加电视临时登录码生成与倒计时展示
2026-09-06 23:22:34 +08:00
truewhile 28aa466107 feat: 支持临时登录密码并完善 Emby 媒体详情兼容
- 新增 6 位临时密码生成与验证接口,支持 TV 及客户端快速登录
- 媒体及剧集详情补充 People 演职员信息解析与返回
- 优化剧集背景图与海报标签继承机制及播放时长兜底逻辑
- 增加临时密码登录相关的单元测试
2026-09-06 22:58:41 +08:00
truewhile f1b8010496 bug处理+emby客户端兼容 2026-09-06 22:10:23 +08:00
truewhile 394d1a3cda feat(danmaku): 支持快捷填入当前片名并优化重新搜索机制 2026-09-06 20:32:12 +08:00
truewhile 465e98a792 bug 2026-09-06 19:02:53 +08:00
truewhile 292ae22dcd fix(sync): deduplicate remoteMeta by file ID to prevent deleting valid remote metadata copies 2026-09-06 17:36:54 +08:00
truewhile bc7e5fc79d 内网挂载emby封面无法加载问题处理 2026-09-06 17:35:33 +08:00
truewhile f7fec93d44 fix(upload): eliminate concurrent temp file name collision and support direct local file upload for 115 2026-09-06 17:05:56 +08:00
truewhile b4a1dc38bb 优化同步逻辑
优化同步逻辑
2026-09-06 16:17:42 +08:00
truewhile 3150971f10 feat(sync): implement 115 adaptive hierarchical flat scan to bypass deep paging limit 2026-09-06 16:15:26 +08:00
truewhile a71a18ce82 fix(upload): 删旧元数据失败时不中止任务,继续上传新文件
旧逻辑:DeleteFiles 失败 → uploadTaskFailWithRetry → 任务重试 →
再次 DeleteFiles 失败 → 永远无法上传,形成死循环。

新逻辑:DeleteFiles 失败时记录 warn 日志后继续上传新文件。
旧副本由下次同步的 scanLocalMetaForUpload 检测(新旧两个副本,
命中新版本后把旧版本 cid 收入 pendingDeletes)并通过
cleanupBatchRedundantFiles 异步批量清理。

这修复了小姐姐库在 115 限流环境下每次增量同步都重复上传
大量元数据的问题(Heyzo 等目录文件被上传了 7 次以上)。
2026-09-06 13:42:20 +08:00
truewhile 5a189a44fc Merge pull request #27 from gaodyoffice/fix/strm-sync-metadata
fix: OpenList metadata download 401 - use API instead of WebDAV
2026-09-06 12:30:40 +08:00
truewhile fb84c62e9a fix(sync): fallback to recursive traversal when 115 flat list hits deep-paging limit
115 API's flat list (search under the hood) enforces a hard limit of offset+limit <= 10000.
When syncing huge directories (e.g. >10000 files), this silently truncates results,
causing remote files to appear as missing locally, leading to infinite metadata
re-upload loops and potential wrongful deletion of strm files.

This patch auto-detects if the total file count >= 9500 and dynamically
falls back to the standard recursive concurrent traversal (walkRemote).
2026-09-06 12:27:56 +08:00
Gaodaiyang 355fd06036 fix: OpenList metadata download using API instead of WebDAV
OpenList 同步目录下载元数据(nfo/jpg/png/srt 等)全部失败,错误 http 401。

根因:Resolve() 中非视频文件走 WebDAV 直接下载,用 API token 作为 Authorization。
但 AList WebDAV 端点不接受 API token 认证,需要 Basic Auth。

修复:OpenList 在有 apiBase 时,所有文件都走 API /api/fs/get 获取直链,
不再走 WebDAV。API 失败时非视频文件可回退到 WebDAV。

影响范围:
- 只影响 OpenList 类型的非视频文件下载
- 不影响 115、CloudDrive2 等其他网盘
- 不影响视频播放/strm 生成
2026-09-06 11:41:12 +08:00
truewhile 4173caac5d bug处理 2026-09-06 01:16:30 +08:00
truewhile 2aeedcc182 bug处理 2026-09-06 00:01:40 +08:00
99 changed files with 6412 additions and 837 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"mcpServers": {
"ssh": {
"command": "cmd.exe",
"args": [
"/c",
"npx",
"-y",
"@aiondadotcom/mcp-ssh"
],
"env": {
"ProgramData": "C:\\ProgramData"
}
}
}
}
+5 -2
View File
@@ -70,8 +70,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
r.GET(rootFile, serveFSFile(root, name))
r.HEAD(rootFile, serveFSFile(root, name))
}
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
path := c.Request.URL.Path
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
if handler.TryHandleEmbyNormalizedRoute(c, r) {
return
}
path := c.Request.URL.Path
if shouldBypassSPAFallback(path) {
c.Status(http.StatusNotFound)
return
+20 -1
View File
@@ -120,6 +120,25 @@ func changePasswordHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
c.Status(http.StatusNoContent)
}
}
func temporaryPasswordHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, ok := c.Get(middleware.CtxUserID)
if !ok || uid == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
code, expireSec, err := svc.Auth.CreateTemporaryPassword(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"expires_in": expireSec,
})
}
}
+30 -1
View File
@@ -257,7 +257,10 @@ func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
c.Query("Client"),
c.Query("client"),
c.Query("X-Emby-Client"),
c.Query("X-MediaBrowser-Client"),
auth["Client"],
auth["client"],
),
}
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
@@ -315,10 +318,36 @@ func embyClientFromUserAgent(ua string) string {
return "Emby"
case strings.Contains(lower, "jellyfin"):
return "Jellyfin"
case strings.Contains(lower, "capyplayer") || strings.Contains(lower, "capy player") || strings.Contains(lower, "卡皮巴拉"):
return "CapyPlayer"
case strings.Contains(lower, "senplayer") || strings.Contains(lower, "sen player") || strings.Contains(lower, "森播"):
return "SenPlayer"
case strings.Contains(lower, "yamby"):
return "Yamby"
case strings.Contains(lower, "vidhub"):
return "VidHub"
case strings.Contains(lower, "fileball"):
return "Fileball"
case strings.Contains(lower, "hamhub"):
return "HamHub"
case strings.Contains(lower, "afusekt") || strings.Contains(lower, "afuse"):
return "AfuseKt"
case strings.Contains(lower, "cony"):
return "Cony"
case strings.Contains(lower, "kodi"):
return "Kodi"
case strings.Contains(lower, "mrmc"):
return "MrMC"
case strings.Contains(lower, "forward"):
return "Forward"
case strings.Contains(lower, "alpha"):
return "Alpha"
case strings.Contains(lower, "dandanplay") || strings.Contains(lower, "弹弹play"):
return "DanDanPlay"
case strings.Contains(lower, "potplayer"):
return "PotPlayer"
case strings.Contains(lower, "vlc"):
return "VLC"
case strings.Contains(lower, "hills"):
return "Hills"
default:
@@ -343,7 +372,7 @@ func embyDeviceFromUserAgent(ua string) string {
return "Mac"
case strings.Contains(lower, "linux"):
return "Linux PC"
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv") || strings.Contains(lower, "appletvos"):
return "Apple TV"
default:
return ""
@@ -0,0 +1,594 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestNormalizeEmbyPath(t *testing.T) {
tests := []struct {
input string
wantPath string
changed bool
}{
{
input: "/emby/System/Info",
wantPath: "/emby/system/info",
changed: true,
},
{
input: "/emby/emby/System/Info",
wantPath: "/emby/system/info",
changed: true,
},
{
input: "/emby/emby/emby/items/123/playbackInfo",
wantPath: "/emby/items/123/playbackinfo",
changed: true,
},
{
input: "//emby//System//Info//Public",
wantPath: "/emby/system/info/public",
changed: true,
},
{
input: "/Items/msgo-series-1/PlaybackInfo",
wantPath: "/items/msgo-series-1/playbackinfo",
changed: true,
},
{
input: "/Videos/m-123/Master.m3u8",
wantPath: "/videos/m-123/master.m3u8",
changed: true,
},
{
input: "/api/unknown/other",
wantPath: "/api/unknown/other",
changed: false,
},
}
for _, tt := range tests {
gotPath, changed := NormalizeEmbyPath(tt.input)
if gotPath != tt.wantPath || changed != tt.changed {
t.Errorf("NormalizeEmbyPath(%q) = (%q, %v), want (%q, %v)", tt.input, gotPath, changed, tt.wantPath, tt.changed)
}
}
}
func TestEmbyDuplicatePrefixHandling(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
// 模拟重复拼接前缀的客户端请求: /emby/emby/System/Info/Public
req := httptest.NewRequest(http.MethodGet, "/emby/emby/System/Info/Public", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK for /emby/emby/System/Info/Public, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "ServerName") {
t.Fatalf("expected server info body, got: %s", w.Body.String())
}
}
func TestEmbyMixedCaseHandling(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
// 混合大小写驼峰: /emby/system/Info/Public
req := httptest.NewRequest(http.MethodGet, "/emby/system/Info/Public", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK for /emby/system/Info/Public, got %d: %s", w.Code, w.Body.String())
}
}
func TestEmbyClientIdentification(t *testing.T) {
tests := []struct {
name string
ua string
query string
headerAuth string
wantClient string
}{
{
name: "CapyPlayer via UA",
ua: "CapyPlayer/1.2.0 (iOS)",
wantClient: "CapyPlayer",
},
{
name: "SenPlayer via UA",
ua: "SenPlayer/2.1",
wantClient: "SenPlayer",
},
{
name: "Fileball via UA",
ua: "Fileball/1.0.0",
wantClient: "Fileball",
},
{
name: "Kodi via UA",
ua: "Kodi/20.2",
wantClient: "Kodi",
},
{
name: "Client in query",
ua: "CustomApp/1.0",
query: "?X-Emby-Client=CapyPlayer",
wantClient: "CapyPlayer",
},
{
name: "Client in auth header",
ua: "Custom/1.0",
headerAuth: `MediaBrowser Client="SenPlayer", Device="AppleTV", DeviceId="abc"`,
wantClient: "SenPlayer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
url := "/test"
if tt.query != "" {
url += tt.query
}
req := httptest.NewRequest(http.MethodGet, url, nil)
if tt.ua != "" {
req.Header.Set("User-Agent", tt.ua)
}
if tt.headerAuth != "" {
req.Header.Set("X-Emby-Authorization", tt.headerAuth)
}
c.Request = req
info := embyClientInfoFromRequest(c)
if info.Client != tt.wantClient {
t.Fatalf("embyClientInfoFromRequest Client = %q, want %q", info.Client, tt.wantClient)
}
})
}
}
func TestEmbyAdaptivePrefixPlaybackInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, svc)
token := signedTestToken(t, secret)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "m-adaptive-1"},
Title: "测试媒体",
Path: "D:\\media\\test.mkv",
LibraryID: "lib-1",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
// 1. 从 /emby 前缀请求 PlaybackInfo
req1 := httptest.NewRequest(http.MethodGet, "/emby/Items/m-adaptive-1/PlaybackInfo", nil)
req1.Header.Set("X-Emby-Token", token)
w1 := httptest.NewRecorder()
router.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("playbackinfo /emby code = %d: %s", w1.Code, w1.Body.String())
}
var res1 struct {
MediaSources []struct {
DirectStreamURL string `json:"DirectStreamUrl"`
} `json:"MediaSources"`
}
if err := json.Unmarshal(w1.Body.Bytes(), &res1); err != nil || len(res1.MediaSources) == 0 {
t.Fatalf("unmarshal /emby response: %v, body: %s", err, w1.Body.String())
}
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "/Videos/m-adaptive-1/stream") {
t.Fatalf("DirectStreamUrl should point to video stream endpoint, got: %s", res1.MediaSources[0].DirectStreamURL)
}
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "api_key="+token) {
t.Fatalf("DirectStreamUrl should carry api_key token, got: %s", res1.MediaSources[0].DirectStreamURL)
}
// 2. 从重复前缀 /emby/emby 请求 PlaybackInfo (模拟客户端再次追加 BaseUrl 场景)
req2 := httptest.NewRequest(http.MethodGet, "/emby/emby/Items/m-adaptive-1/PlaybackInfo", nil)
req2.Header.Set("X-Emby-Token", token)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("playbackinfo /emby/emby code = %d: %s", w2.Code, w2.Body.String())
}
var res2 struct {
MediaSources []struct {
DirectStreamURL string `json:"DirectStreamUrl"`
} `json:"MediaSources"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &res2); err != nil || len(res2.MediaSources) == 0 {
t.Fatalf("unmarshal /emby/emby response: %v, body: %s", err, w2.Body.String())
}
// 3. 从根路径 /Items 请求 PlaybackInfo
req3 := httptest.NewRequest(http.MethodGet, "/Items/m-adaptive-1/PlaybackInfo", nil)
req3.Header.Set("X-Emby-Token", token)
w3 := httptest.NewRecorder()
router.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("playbackinfo root code = %d: %s", w3.Code, w3.Body.String())
}
}
func TestEmbyImageClearNoStore(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
req := httptest.NewRequest(http.MethodGet, "/emby/Items/non-existent-item/Images/Primary", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("placeholder image should return 200, got %d", w.Code)
}
cacheControl := w.Header().Get("Cache-Control")
if strings.Contains(cacheControl, "no-store") {
t.Fatalf("image response should not have no-store, got: %s", cacheControl)
}
if !strings.Contains(cacheControl, "public") {
t.Fatalf("image response should have public cache-control, got: %s", cacheControl)
}
}
func TestEmbyTemporaryPasswordLogin(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-jwt-secret-very-secure-key-12345"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
tokenSvc := service.NewTokenService(cfg, log, repos)
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
embySvc := service.NewEmbyService(nil, nil, repos)
svc := &service.Container{
Repo: repos,
Auth: authSvc,
Token: tokenSvc,
Emby: embySvc,
}
user, _, err := authSvc.Register(t.Context(), "tvuser", "strongpassword123")
if err != nil {
t.Fatalf("register: %v", err)
}
// 1. 生成 6 位纯数字临时密码 (OTP)
code, expireSec, err := authSvc.CreateTemporaryPassword(t.Context(), user.ID)
if err != nil {
t.Fatalf("create temp password: %v", err)
}
if len(code) != 6 || expireSec <= 0 {
t.Fatalf("invalid temp password format: %s, expire: %d", code, expireSec)
}
router := gin.New()
registerEmbyRoutes(router, "test-jwt-secret-very-secure-key-12345", svc)
// 2. 使用临时密码在 Emby 接口登录
body := `{"Username":"tvuser","Pw":"` + code + `"}`
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login with temp password code = %d: %s", w.Code, w.Body.String())
}
var loginResp struct {
AccessToken string `json:"AccessToken"`
User struct {
ID string `json:"Id"`
Name string `json:"Name"`
} `json:"User"`
}
if err := json.Unmarshal(w.Body.Bytes(), &loginResp); err != nil {
t.Fatalf("unmarshal login resp: %v", err)
}
if loginResp.AccessToken == "" || loginResp.User.ID != user.ID {
t.Fatalf("unexpected login payload: %#v", loginResp)
}
// 3. 验证阅后即焚:第二次使用同一临时密码应登录失败 (401)
req2 := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
req2.Header.Set("Content-Type", "application/json")
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusUnauthorized {
t.Fatalf("second login with consumed temp password should fail, got %d", w2.Code)
}
}
func TestEmbySeriesArtworkInheritanceAndRunTimeTicksFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret-compat"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
tokenSvc := service.NewTokenService(cfg, log, repos)
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
embySvc := service.NewEmbyService(nil, nil, repos)
svc := &service.Container{
Repo: repos,
Auth: authSvc,
Token: tokenSvc,
Emby: embySvc,
}
user, _, err := authSvc.Register(t.Context(), "artworkuser", "password123")
if err != nil {
t.Fatalf("register: %v", err)
}
token, err := authSvc.IssueEmbyToken(user)
if err != nil {
t.Fatalf("issue token: %v", err)
}
// 创建 TV Library
lib := &model.Library{
Name: "电视剧",
Path: "/media/电视剧",
Type: "tv",
}
lib.ID = "lib-tv-1"
if err := db.Create(lib).Error; err != nil {
t.Fatalf("create lib: %v", err)
}
// 创建 Series
series := &model.Series{
LibraryID: "lib-tv-1",
Title: "Test Drama",
PosterURL: "https://example.com/series_poster.jpg",
BackdropURL: "https://example.com/series_backdrop.jpg",
}
series.ID = "s-test-1"
if err := db.Create(series).Error; err != nil {
t.Fatalf("create series: %v", err)
}
// 创建单集 Episode(DurationSec 为 0,但有播放进度 posMs,用于测试 RunTimeTicks 兜底)
ep := &model.Media{
LibraryID: "lib-tv-1",
SeriesID: "s-test-1",
Title: "Test Episode 1",
Path: "/media/电视剧/Test Drama/Season 1/S01E01.mp4",
SeasonNum: 1,
EpisodeNum: 1,
DurationSec: 0, // 未知时长
PosterURL: "https://example.com/ep1_still.jpg",
}
ep.ID = "ep-test-1"
if err := db.Create(ep).Error; err != nil {
t.Fatalf("create ep: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
// 1. 获取 Series 详情
reqSeries := httptest.NewRequest(http.MethodGet, "/emby/Items/s-test-1", nil)
reqSeries.Header.Set("X-Emby-Token", token)
wSeries := httptest.NewRecorder()
router.ServeHTTP(wSeries, reqSeries)
if wSeries.Code != http.StatusOK {
t.Fatalf("get series code = %d: %s", wSeries.Code, wSeries.Body.String())
}
var seriesPayload map[string]any
_ = json.Unmarshal(wSeries.Body.Bytes(), &seriesPayload)
if seriesPayload["PrimaryImageTag"] != "s-test-1" {
t.Fatalf("series PrimaryImageTag should match series ID, got %v", seriesPayload["PrimaryImageTag"])
}
if _, ok := seriesPayload["People"]; !ok {
t.Fatalf("series payload should include People array")
}
// 2. 获取 Episode 详情,验证继承 SeriesPrimaryImageTag 和 ParentBackdropItemId
// 添加一条播放进度记录 (posMs = 60000)
hist := &model.PlaybackHistory{
UserID: user.ID,
MediaID: ep.ID,
PositionMs: 60000,
}
_ = db.Create(hist).Error
reqEp := httptest.NewRequest(http.MethodGet, "/emby/Users/"+user.ID+"/Items/ep-test-1", nil)
reqEp.Header.Set("X-Emby-Token", token)
wEp := httptest.NewRecorder()
router.ServeHTTP(wEp, reqEp)
if wEp.Code != http.StatusOK {
t.Fatalf("get ep code = %d: %s", wEp.Code, wEp.Body.String())
}
var epPayload map[string]any
_ = json.Unmarshal(wEp.Body.Bytes(), &epPayload)
t.Logf("epPayload: %#v", epPayload)
// 验证图片继承
if epPayload["SeriesPrimaryImageTag"] != "s-test-1" {
t.Fatalf("ep SeriesPrimaryImageTag should inherit series ID, got %v", epPayload["SeriesPrimaryImageTag"])
}
if epPayload["ParentBackdropItemId"] != "s-test-1" {
t.Fatalf("ep ParentBackdropItemId should inherit series ID, got %v", epPayload["ParentBackdropItemId"])
}
if _, ok := epPayload["People"]; !ok {
t.Fatalf("ep payload should include People array")
}
// 验证 RunTimeTicks 兜底
runTimeTicks, _ := epPayload["RunTimeTicks"].(float64)
if runTimeTicks <= 0 {
t.Fatalf("ep RunTimeTicks should be safely fallback to positive value, got %v", runTimeTicks)
}
userData, _ := epPayload["UserData"].(map[string]any)
playedPct, _ := userData["PlayedPercentage"].(float64)
if playedPct <= 0 {
t.Fatalf("ep PlayedPercentage should be > 0, got %v", playedPct)
}
}
func TestMeTemporaryPasswordEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret-temp"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
tokenSvc := service.NewTokenService(cfg, log, repos)
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
svc := &service.Container{
Repo: repos,
Auth: authSvc,
Token: tokenSvc,
}
user, tokens, err := authSvc.Register(t.Context(), "optuser", "password123")
if err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
api := router.Group("/api")
authed := api.Group("")
authed.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, user.ID)
c.Next()
})
registerAuthedUserAndLicenseRoutes(authed, svc)
req := httptest.NewRequest(http.MethodPost, "/api/me/temporary-password", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("generate temp password code = %d: %s", w.Code, w.Body.String())
}
var resp struct {
Code string `json:"code"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal resp: %v", err)
}
if len(resp.Code) != 6 || resp.ExpiresIn <= 0 {
t.Fatalf("invalid temp password resp: %#v", resp)
}
// 验证使用生成的临时密码能登录
loginResp, err := authSvc.LoginWithTemporaryPassword(t.Context(), "optuser", resp.Code)
if err != nil || loginResp == nil || loginResp.User.ID != user.ID {
t.Fatalf("login with temp pass failed: %v", err)
}
_ = tokens
}
+2 -1
View File
@@ -51,13 +51,14 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
}
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
c.Writer.Header().Del("Cache-Control")
c.Writer.Header().Del("Pragma")
c.Writer.Header().Del("Expires")
}
func embyServePlaceholderImage(c *gin.Context) {
c.Header("Content-Type", "image/png")
c.Header("Cache-Control", "public, max-age=3600")
c.Header("Cache-Control", "public, max-age=86400")
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
if c.Request.Method == http.MethodHead {
c.Status(http.StatusOK)
+190
View File
@@ -0,0 +1,190 @@
package handler
import (
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
var (
multipleSlashesRE = regexp.MustCompile(`/{2,}`)
)
// embyStaticSegments 包含 Emby API 中已知的保留静态路由分段(全部小写),
// 用于在遇到客户端混合大小写(如 /System/info, /items/:id/playbackInfo)时,
// 将静态段规范化为对应的小写形式,而保留动态参数段(:id, :userId 等)的原大小写。
var embyStaticSegments = map[string]struct{}{
"system": {},
"info": {},
"public": {},
"endpoint": {},
"configuration": {},
"ping": {},
"users": {},
"useritems": {},
"me": {},
"authenticatebyname": {},
"items": {},
"counts": {},
"latest": {},
"resume": {},
"playbackinfo": {},
"shows": {},
"seasons": {},
"episodes": {},
"nextup": {},
"upcoming": {},
"similar": {},
"thumbnailset": {},
"thememedia": {},
"specialfeatures": {},
"intros": {},
"videos": {},
"stream": {},
"subtitles": {},
"master.m3u8": {},
"main.m3u8": {},
"sessions": {},
"playing": {},
"progress": {},
"stopped": {},
"capabilities": {},
"full": {},
"logout": {},
"views": {},
"library": {},
"mediafolders": {},
"virtualfolders": {},
"selectablemediafolders": {},
"branding": {},
"css": {},
"localization": {},
"options": {},
"cultures": {},
"customcssjs": {},
"scripts": {},
"displaypreferences": {},
"quickconnect": {},
"enabled": {},
"startup": {},
"complete": {},
"favoriteitems": {},
"playeditems": {},
"images": {},
"primary": {},
"backdrop": {},
"banner": {},
"thumb": {},
"logo": {},
"serverdomains": {},
"ext": {},
"danmu": {},
"raw": {},
"mediasegments": {},
"artists": {},
"persons": {},
"genres": {},
"embywebsocket": {},
}
// NormalizeEmbyPath 规范化 Emby 请求路径:
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
// 3. 将静态关键字段归一化为小写,同时保留动态 ID/参数的原有大小写。
func NormalizeEmbyPath(p string) (string, bool) {
if p == "" {
return "", false
}
original := p
// 1. 折叠多余斜杠
p = multipleSlashesRE.ReplaceAllString(p, "/")
// 2. 折叠重复的 /emby 前缀
for {
lower := strings.ToLower(p)
if strings.HasPrefix(lower, "/emby/emby/") {
p = "/emby/" + p[len("/emby/emby/"):]
continue
}
if lower == "/emby/emby" {
p = "/emby"
break
}
break
}
// 3. 分析是否具有 Emby 路由特征
hasEmbyPrefix := false
workPath := p
if strings.HasPrefix(strings.ToLower(workPath), "/emby/") {
hasEmbyPrefix = true
workPath = workPath[len("/emby"):]
} else if strings.EqualFold(workPath, "/emby") {
return "/emby", original != "/emby"
}
segments := strings.Split(strings.Trim(workPath, "/"), "/")
if len(segments) == 0 || segments[0] == "" {
return p, p != original
}
// 检查第一段是否为 Emby 根路由关键字
firstLower := strings.ToLower(segments[0])
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
// 不是 Emby 相关路径,保持原样
return original, false
}
// 4. 将已知静态段转为小写,动态段保留原样
for i, seg := range segments {
segLower := strings.ToLower(seg)
if _, isStatic := embyStaticSegments[segLower]; isStatic {
if seg != segLower {
segments[i] = segLower
}
}
}
var builder strings.Builder
if hasEmbyPrefix {
builder.WriteString("/emby")
}
for _, seg := range segments {
builder.WriteString("/")
builder.WriteString(seg)
}
if strings.HasSuffix(original, "/") && !strings.HasSuffix(builder.String(), "/") {
builder.WriteString("/")
}
normalized := builder.String()
return normalized, normalized != original
}
const embyNormalizedCtxKey = "emby_normalized_path"
// TryHandleEmbyNormalizedRoute 尝试在 404 NoRoute 阶段对 Emby 路径做前缀与大小写纠偏并重定向分发。
// 若成功分发并处理,返回 true;否则返回 false。
func TryHandleEmbyNormalizedRoute(c *gin.Context, r *gin.Engine) bool {
if c == nil || r == nil {
return false
}
if c.GetBool(embyNormalizedCtxKey) {
return false
}
normalized, changed := NormalizeEmbyPath(c.Request.URL.Path)
if !changed {
return false
}
c.Set(embyNormalizedCtxKey, true)
c.Request.URL.Path = normalized
// 重置 context 状态并由 engine 重新查找路由树
c.Params = nil
c.Writer.Header().Del("Content-Type")
r.HandleContext(c)
return true
}
+8
View File
@@ -23,6 +23,14 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
}
// 为没有设置 NoRoute 的 Engine 提供 Emby 路径前缀与大小写纠偏(如单测或独立挂载环境)
r.NoRoute(func(c *gin.Context) {
if TryHandleEmbyNormalizedRoute(c, r) {
return
}
c.Status(404)
})
}
type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc
+12 -5
View File
@@ -34,11 +34,18 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
embyError(c, http.StatusBadRequest, "missing username or password")
return
}
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
if err != nil {
embyError(c, http.StatusUnauthorized, err.Error())
return
}
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
if err != nil {
// 支持电视端/客户端一次性 6 位临时密码登录 (OTP)
if tempResp, tempErr := svc.Auth.LoginWithTemporaryPassword(c.Request.Context(), req.Username, password); tempErr == nil {
resp = tempResp
err = nil
}
}
if err != nil {
embyError(c, http.StatusUnauthorized, err.Error())
return
}
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
clientInfo := embyClientInfoFromRequest(c)
if svc.Sessions != nil {
+27 -10
View File
@@ -170,7 +170,13 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
if acct == nil {
return
}
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
tmpMount := &model.EmbyMount{
Base: model.Base{ID: v.MountID},
AccountID: v.AccountID,
RemoteViewID: v.RemoteID,
CollectionType: v.CollectionType,
Name: v.Library.Name,
}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
@@ -482,7 +488,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, m)
return
}
m, err := svc.Media.GetMedia(ctx, id)
m, err := svc.Media.GetMediaItem(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -491,7 +497,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !mediaVisibleForRequest(c, svc, m) {
if !mediaVisibleForRequest(c, svc, &m.Media) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
@@ -660,14 +666,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
}
return
}
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
m, err := svc.Media.GetMedia(ctx, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
+2 -2
View File
@@ -45,8 +45,8 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
profileQuery := externalProfileQuery(c)
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
if service.IsEmbyRemoteID(m.ID) {
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
hlsURL = ""
}
c.JSON(http.StatusOK, gin.H{
+3 -3
View File
@@ -413,7 +413,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
return router, svc, cfg.Secrets.JWTSecret
}
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
router, _, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
@@ -435,8 +435,8 @@ func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
if payload.StreamURL == "" {
t.Fatalf("expected non-empty stream_url")
}
if payload.HlsURL != "" {
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
}
}
@@ -14,6 +14,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.POST("/me/logout", logoutHandler(svc))
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
// 远程 Emby 挂载没有本地文件,不能转码。STRM 允许直连失败后走 HLS。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
+2
View File
@@ -232,6 +232,7 @@ type strmSyncPathReq struct {
DownloadMeta *bool `json:"download_meta"`
UploadMeta *bool `json:"upload_meta"`
DeleteDir *bool `json:"delete_dir"`
KeepExt *bool `json:"keep_ext"`
Cron string `json:"cron"`
EnableCron *bool `json:"enable_cron"`
SyncMode string `json:"sync_mode"`
@@ -703,6 +704,7 @@ func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
DownloadMeta: boolValue(req.DownloadMeta, true),
UploadMeta: boolValue(req.UploadMeta, false),
DeleteDir: boolValue(req.DeleteDir, false),
KeepExt: boolValue(req.KeepExt, false),
Cron: strings.TrimSpace(req.Cron),
EnableCron: boolValue(req.EnableCron, false),
SyncMode: strings.TrimSpace(req.SyncMode),
+4 -1
View File
@@ -51,6 +51,9 @@ type StrmSyncPath struct {
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
// KeepExt=true 时为每个视频生成 name.mkv.strm / name.mp4.strm(保留全部版本);
// false(默认)时同名不同扩展只择优生成一条 name.strm,避免互相覆盖与来回抖动。
KeepExt bool `json:"keep_ext"`
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
@@ -128,7 +131,7 @@ type StrmUploadTask struct {
FileName string `gorm:"size:512" json:"file_name"`
LocalPath string `gorm:"size:1024" json:"local_path"` // 本地源文件
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目标路径
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 远端同名旧文件引用(115 文件 ID;上传覆盖前先删除旧文件,WebDAV/OpenList 直接覆盖无需删除)
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 上传前:远端同名旧文件 ID(逗号分隔,覆盖前先删);上传成功后:新文件 ID
Size int64 `json:"size"`
Status string `gorm:"size:16;index" json:"status"`
Error string `gorm:"size:1024" json:"error"`
+29
View File
@@ -110,6 +110,7 @@ func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPa
"download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir,
"keep_ext": p.KeepExt,
"cron": p.Cron,
"enable_cron": p.EnableCron,
"sync_mode": p.SyncMode,
@@ -861,6 +862,34 @@ func (r *StrmUploadTaskRepository) GetActiveLocalPathMap(ctx context.Context, sy
return out, nil
}
// GetRecentDoneUploadSizeMap 返回近期已成功上传的 local_path → size。
// 用于缩短「上传已 done 但 115 列表尚未反映」窗口内的重复入队:同路径且大小未变则跳过。
// 同一路径存在多条 done 时取最新一条(finished_at 降序)。
func (r *StrmUploadTaskRepository) GetRecentDoneUploadSizeMap(ctx context.Context, syncPathID string, since time.Time) (map[string]int64, error) {
var rows []model.StrmUploadTask
err := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Select("local_path", "size", "finished_at").
Where("sync_path_id = ? AND status = ? AND finished_at IS NOT NULL AND finished_at >= ?",
syncPathID, model.StrmTaskDone, since).
Order("finished_at DESC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, row := range rows {
if row.LocalPath == "" {
continue
}
// 已按 finished_at DESC;先写入的是最新,后续同路径跳过
if _, exists := out[row.LocalPath]; exists {
continue
}
out[row.LocalPath] = row.Size
}
return out, nil
}
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("status IN ? AND finished_at < ?",
+115 -1
View File
@@ -3,10 +3,13 @@ package service
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"os"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -18,6 +21,12 @@ import (
"github.com/truewhile/MeBox/internal/repository"
)
type temporaryPasswordEntry struct {
userID string
username string
expiresAt time.Time
}
// AuthService handles registration, login, and JWT issuance.
type AuthService struct {
cfg *config.Config
@@ -25,11 +34,21 @@ type AuthService struct {
repo *repository.Container
tokenSvc *TokenService
permissionSvc *PermissionService
tempPassMu sync.RWMutex
tempPasswords map[string]temporaryPasswordEntry
}
// NewAuthService is the constructor.
func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tokenSvc *TokenService, permissionSvc *PermissionService) *AuthService {
return &AuthService{cfg: cfg, log: log, repo: repo, tokenSvc: tokenSvc, permissionSvc: permissionSvc}
return &AuthService{
cfg: cfg,
log: log,
repo: repo,
tokenSvc: tokenSvc,
permissionSvc: permissionSvc,
tempPasswords: make(map[string]temporaryPasswordEntry),
}
}
// Common service-level errors.
@@ -254,3 +273,98 @@ func hashPassword(p string) (string, error) {
}
return string(h), nil
}
const temporaryPasswordTTL = 5 * time.Minute
// CreateTemporaryPassword 为指定用户生成一个 6 位数字的临时登录密码(有效期 5 分钟),
// 供 Emby 电视端/客户端进行无键盘或快速输入登录。
func (s *AuthService) CreateTemporaryPassword(ctx context.Context, userID string) (string, int, error) {
if s == nil || s.repo == nil {
return "", 0, errors.New("auth service unavailable")
}
user, err := s.repo.User.FindByID(ctx, userID)
if err != nil || user == nil {
return "", 0, ErrInvalidCredentials
}
if !user.IsActive {
return "", 0, ErrUserInactive
}
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
return "", 0, ErrUserExpired
}
n, err := rand.Int(rand.Reader, big.NewInt(900000))
if err != nil {
return "", 0, err
}
code := fmt.Sprintf("%06d", n.Int64()+100000)
s.tempPassMu.Lock()
defer s.tempPassMu.Unlock()
now := time.Now()
for k, v := range s.tempPasswords {
if now.After(v.expiresAt) {
delete(s.tempPasswords, k)
}
}
s.tempPasswords[code] = temporaryPasswordEntry{
userID: user.ID,
username: user.Username,
expiresAt: now.Add(temporaryPasswordTTL),
}
return code, int(temporaryPasswordTTL.Seconds()), nil
}
// VerifyAndConsumeTemporaryPassword 校验并消费临时登录密码(阅后即焚)。
func (s *AuthService) VerifyAndConsumeTemporaryPassword(ctx context.Context, username, code string) (*model.User, bool) {
if s == nil || s.repo == nil {
return nil, false
}
code = strings.TrimSpace(code)
if len(code) != 6 {
return nil, false
}
s.tempPassMu.Lock()
entry, ok := s.tempPasswords[code]
if ok {
delete(s.tempPasswords, code)
}
s.tempPassMu.Unlock()
if !ok || time.Now().After(entry.expiresAt) {
return nil, false
}
if strings.TrimSpace(username) != "" && !strings.EqualFold(strings.TrimSpace(username), entry.username) {
return nil, false
}
user, err := s.repo.User.FindByID(ctx, entry.userID)
if err != nil || user == nil || !user.IsActive {
return nil, false
}
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
return nil, false
}
return user, true
}
// LoginWithTemporaryPassword 尝试使用 6 位数字临时登录密码 (OTP) 进行登录。
func (s *AuthService) LoginWithTemporaryPassword(ctx context.Context, username, code string) (*LoginResponse, error) {
user, ok := s.VerifyAndConsumeTemporaryPassword(ctx, username, code)
if !ok || user == nil {
return nil, ErrInvalidCredentials
}
if s.tokenSvc == nil {
return nil, errors.New("token service unavailable")
}
tokens, err := s.tokenSvc.IssuePair(ctx, user.ID, user.Role, user.Tier)
if err != nil {
return nil, err
}
return &LoginResponse{
User: user,
Tokens: tokens,
}, nil
}
+7 -7
View File
@@ -87,15 +87,15 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
if ref == "/" {
return nil, fmt.Errorf("%s: file reference required", p.name)
}
if p.typ == TypeOpenList && isCloudVideoPlaybackCandidate(ref) {
if p.apiBase == nil {
return nil, fmt.Errorf("%s: pure 302 playback requires an OpenList API server address; configure server/api_url so /api/fs/get can return raw_url", p.name)
}
if p.typ == TypeOpenList && p.apiBase != nil {
link, err := p.resolveOpenListAPIDirect(ctx, ref)
if err != nil {
return nil, fmt.Errorf("%s: pure 302 playback requires OpenList raw_url for %s: %w", p.name, ref, err)
if err == nil {
return link, nil
}
// API 获取直链失败:非视频文件(元数据)回退到 WebDAV;视频文件报错
if isCloudVideoPlaybackCandidate(ref) {
return nil, fmt.Errorf("%s: resolve download URL for %s via API failed: %w", p.name, ref, err)
}
return link, nil
}
if p.typ == TypeCloudDrive2 && isCloudVideoPlaybackCandidate(ref) {
link, err := p.resolveCloudDAVRedirectDirect(ctx, ref)
+3 -2
View File
@@ -61,9 +61,10 @@ func TestOpenListWebDAVListAndResolve(t *testing.T) {
if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 {
t.Fatalf("entries = %#v", entries)
}
// Video file: API fails → error (no WebDAV fallback for video)
_, err = p.Resolve(context.Background(), entries[0].ID)
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
t.Fatalf("openlist video resolve should require raw_url instead of WebDAV proxy fallback, err=%v", err)
if err == nil || !strings.Contains(err.Error(), "resolve download URL") || !strings.Contains(err.Error(), "via API failed") {
t.Fatalf("openlist video resolve should error on API failure, err=%v", err)
}
}
@@ -195,10 +195,43 @@ func TestOpenListResolveDoesNotFallbackToWebDAVWhenAPIRawURLFails(t *testing.T)
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
t.Fatalf("resolve error = %v, want raw_url requirement", err)
if err == nil || !strings.Contains(err.Error(), "resolve download URL") || !strings.Contains(err.Error(), "via API failed") {
t.Fatalf("resolve error = %v, want API resolve failure", err)
}
if davSeen {
t.Fatal("openlist video resolve fell back to WebDAV after raw_url failure")
}
}
func TestOpenListResolveMetadataUsesAPIInsteadOfWebDAV(t *testing.T) {
var gotPath, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
if r.Method != http.MethodPost || r.URL.Path != "/api/fs/get" {
t.Fatalf("unexpected request %s %s; metadata should use API, not WebDAV", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/poster.jpg?sign=1"}}`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
// .nfo metadata file should use API, not WebDAV
link, err := p.Resolve(context.Background(), "/Cloud/Movie/Movie.nfo")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if gotPath != "/api/fs/get" {
t.Fatalf("api path = %q, want /api/fs/get (metadata should not use WebDAV)", gotPath)
}
if gotAuth != "alist-token" {
t.Fatalf("Authorization = %q, want token", gotAuth)
}
if link.URL != "https://cdn.example.test/poster.jpg?sign=1" {
t.Fatalf("url = %q", link.URL)
}
}
+26 -20
View File
@@ -123,35 +123,41 @@ func (p *openAPI115Provider) ResolveBatch(ctx context.Context, fileRefs []string
// OpenClient 暴露底层客户端(token 刷新用)。
func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c }
// PutLocalFile 直接上传本地文件,避免通过 io.Reader 复制临时文件产生的磁盘开销与并发重命名碰撞。
func (p *openAPI115Provider) PutLocalFile(ctx context.Context, parentCID, localPath string) error {
_, err := p.c.Upload(ctx, localPath, parentCID, "", "")
return err
}
// PutFileNamed 把本地元数据上传到 115 指定父目录(parentCID 为父目录 cid)。
// io.Reader 无法携带文件名,因此走独立的 named 上传接口。将内容落为临时文件后
// 重命名为目标文件名,再交给 115 上传(/open/upload/init 的 file_name 取真实文件名)。
// 为防止多并发上传线程在同一临时目录下发生同名文件(如 poster.jpg)碰撞覆盖与误删,
// 为每个上传任务分配专属临时子目录。
func (p *openAPI115Provider) PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error {
tmp, err := os.CreateTemp("", "mebox-upload-*")
tmpDir, err := os.MkdirTemp("", "mebox-upload-*")
if err != nil {
return fmt.Errorf("115: 创建临时目录失败:%w", err)
}
defer func() {
_ = os.RemoveAll(tmpDir)
}()
safeName := filepath.Base(fileName)
if safeName == "" || safeName == "." {
safeName = "file"
}
tmpPath := filepath.Join(tmpDir, safeName)
dst, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("115: 创建临时文件失败:%w", err)
}
tmpPath := tmp.Name()
defer func() {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}()
if _, err := io.Copy(tmp, r); err != nil {
if _, err := io.Copy(dst, r); err != nil {
_ = dst.Close()
return fmt.Errorf("115: 写入临时文件失败:%w", err)
}
if err := tmp.Close(); err != nil {
if err := dst.Close(); err != nil {
return fmt.Errorf("115: 关闭临时文件失败:%w", err)
}
// 重命名为目标文件名,保证上传到 115 后保留原始文件名。
// 重命名失败必须 fail fast:静默用随机临时名上传会导致 115 上的文件名
// 变成 mebox-upload-xxx,破坏元数据文件名契约。
if fileName != "" && fileName != filepath.Base(tmpPath) {
namedPath := filepath.Join(filepath.Dir(tmpPath), fileName)
if err := os.Rename(tmpPath, namedPath); err != nil {
return fmt.Errorf("115: 重命名临时文件为 %s 失败:%w", fileName, err)
}
tmpPath = namedPath
}
_, err = p.c.Upload(ctx, tmpPath, parentCID, "", "")
if err != nil {
return err
+13 -10
View File
@@ -150,6 +150,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
if err != nil {
return nil, err
}
attemptedAccess := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
resp, err := c.HTTP.Do(req)
if err != nil {
@@ -218,7 +219,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
// 避免长时间同步因 token 过期而整体失败。
if isTokenCode(base.Code) {
if access && c.tryRefreshTokenLocked(ctx) {
if access && c.tryRefreshTokenLocked(ctx, attemptedAccess) {
continue
}
if access {
@@ -305,21 +306,23 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
// 应使用内存中的新 token 重试原请求)。
//
// 拿到写锁后在锁内读取 oldAccess,与持锁期间的当前值对比:若已被其他
// goroutine 刷新过则直接复用新 token,避免并发请求连环轮转消耗 115 的
// 一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
// failedAccess 是失败请求实际携带的 token。拿到写锁后与当前 token 对比:
// 若已被其他 goroutine 刷新过则直接复用,避免并发请求连环轮转消耗 115
// 的一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
//
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context, failedAccess string) bool {
c.tokenMu.Lock()
// 在已持有写锁内读取当前 token 作为"刷新前快照",消除双重加锁窗口:
// 若在拿锁期间已有其他 goroutine 完成刷新,refreshTokenWhileLocked
// 内的 c.AccessToken != oldAccess 判断会立即命中并返回复用。
oldAccess := c.AccessToken
newToken, ok := c.refreshTokenWhileLocked(ctx, oldAccess)
// 请求发出后若其他 goroutine 已经刷新完成,直接复用新 token 重试;
// 不能再次轮换一次性的 refresh_token。
if failedAccess != "" && c.AccessToken != failedAccess {
c.tokenMu.Unlock()
return true
}
newToken, ok := c.refreshTokenWhileLocked(ctx, failedAccess)
c.tokenMu.Unlock()
// 回调必须在 tokenMu 释放后调用,避免上层在回调内访问客户端时死锁
if ok && newToken != nil && c.OnTokenRefreshed != nil {
@@ -140,6 +140,47 @@ func TestRefreshTokenDead(t *testing.T) {
}
}
func TestConcurrentTokenFailuresShareOneRefresh(t *testing.T) {
var mu sync.Mutex
refreshCalls := 0
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/refreshToken" {
t.Errorf("unexpected path %s", r.URL.Path)
}
mu.Lock()
refreshCalls++
mu.Unlock()
time.Sleep(50 * time.Millisecond)
_, _ = w.Write([]byte(`{"state":true,"data":{"access_token":"at-new","refresh_token":"rt-new","expires_in":7200}}`))
})
client := NewOpenClient("100195129", "at-old", "rt-old")
var wg sync.WaitGroup
results := make(chan bool, 2)
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
results <- client.tryRefreshTokenLocked(context.Background(), "at-old")
}()
}
wg.Wait()
close(results)
for ok := range results {
if !ok {
t.Fatal("concurrent refresh should reuse the refreshed token")
}
}
mu.Lock()
defer mu.Unlock()
if refreshCalls != 1 {
t.Fatalf("refresh calls = %d, want 1", refreshCalls)
}
if client.CurrentAccessToken() != "at-new" {
t.Fatalf("access token = %q, want at-new", client.CurrentAccessToken())
}
}
func TestFsListAndDownload(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
+65 -3
View File
@@ -119,6 +119,60 @@ func (c *OpenClient) GetFsListFlat(ctx context.Context, cid string, offset, limi
return files, resp.Count, nil
}
// FindNamedContentInParent 在父目录下查找与本地内容一致的同名文件。
// 匹配条件:文件名完全一致、大小一致,且 SHA1 为空或与 expectedSHA1 大小写不敏感相等。
// 同时返回该目录下全部同名文件(含未匹配的脏副本),便于上传前清理。
// 目录过大时分页扫描,最多拉取 maxListPages 页(每页 pageSize 条)。
func (c *OpenClient) FindNamedContentInParent(ctx context.Context, parentCID, fileName, expectedSHA1 string, expectedSize int64) (matched *RemoteFile, sameName []RemoteFile, err error) {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return nil, nil, nil
}
const pageSize = 200
const maxListPages = 20 // 最多扫描 4000 项,元数据父目录通常远小于此
expectedSHA1 = strings.TrimSpace(expectedSHA1)
offset := 0
for page := 0; page < maxListPages; page++ {
files, _, listErr := c.GetFsList(ctx, parentCID, offset, pageSize)
if listErr != nil {
return nil, sameName, listErr
}
if len(files) == 0 {
break
}
for i := range files {
f := files[i]
if f.Category == TypeDir {
continue
}
// fta=0/2 表示未上传完成,不可作为已存在副本
if f.Fta == "0" || f.Fta == "2" {
continue
}
if f.FileName != fileName {
continue
}
sameName = append(sameName, f)
if matched != nil {
continue
}
if f.FileSize != expectedSize {
continue
}
remoteSha := strings.TrimSpace(f.Sha1)
if remoteSha == "" || remoteSha == "-" || strings.EqualFold(remoteSha, expectedSHA1) {
cp := f
matched = &cp
}
}
if len(files) < pageSize {
break
}
offset += len(files)
}
return matched, sameName, nil
}
// GetFsDetailByCid 查询文件(夹)详情。
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
params := map[string]string{"file_id": fileId}
@@ -423,24 +477,32 @@ func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
// RefreshToken 刷新访问令牌。
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
c.tokenMu.Lock()
if refreshToken == "" {
refreshToken = c.currentRefreshToken()
refreshToken = c.RefreshTokenStr
}
if refreshToken == "" {
c.tokenMu.Unlock()
return nil, fmt.Errorf("没有可用的 refresh_token")
}
token, err := c.doRefreshToken(refreshToken)
if err != nil {
// refresh_token 已失效时清空内存令牌(提示需重新授权)
if IsRefreshTokenDead(err) {
c.SetAuthToken("", "")
c.setAuthTokenLocked("", "")
}
c.tokenMu.Unlock()
return nil, err
}
if token.AccessToken == "" || token.RefreshToken == "" {
c.tokenMu.Unlock()
return nil, fmt.Errorf("115: 刷新返回空凭证(access_token/refresh_token 缺失)")
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
c.setAuthTokenLocked(token.AccessToken, token.RefreshToken)
c.tokenMu.Unlock()
if c.OnTokenRefreshed != nil {
c.OnTokenRefreshed(token.AccessToken, token.RefreshToken)
}
return token, nil
}
@@ -0,0 +1,54 @@
package cloud115
import (
"context"
"fmt"
"net/http"
"testing"
)
func TestFindNamedContentInParentMatchAndSameName(t *testing.T) {
sha := "AABBCCDDEEFF00112233445566778899AABBCCDD"
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/ufile/files" {
t.Errorf("unexpected path %s", r.URL.Path)
}
w.Write([]byte(fmt.Sprintf(`{"state":true,"data":[
{"fid":"dir-1","fc":"0","fn":"sub","fs":0},
{"fid":"keep","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"1"},
{"fid":"dirty","fc":"1","fn":"a.nfo","fs":10,"sha1":"OTHER","fta":"1"},
{"fid":"other","fc":"1","fn":"b.nfo","fs":10,"sha1":%q,"fta":"1"},
{"fid":"incomplete","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"0"}
]}`, sha, sha, sha)))
})
c := NewOpenClient("100195125", "at1", "rt1")
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", sha, 10)
if err != nil {
t.Fatal(err)
}
if matched == nil || matched.FileId != "keep" {
t.Fatalf("matched = %+v, want keep", matched)
}
if len(sameName) != 2 { // keep + dirty;incomplete 被 fta 过滤
t.Fatalf("sameName len=%d, want 2 (incomplete excluded)", len(sameName))
}
}
func TestFindNamedContentInParentNoMatch(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"state":true,"data":[
{"fid":"x","fc":"1","fn":"a.nfo","fs":9,"sha1":"OTHER","fta":"1"}
]}`))
})
c := NewOpenClient("100195125", "at1", "rt1")
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", "WANT", 10)
if err != nil {
t.Fatal(err)
}
if matched != nil {
t.Fatalf("expected no match, got %+v", matched)
}
if len(sameName) != 1 {
t.Fatalf("sameName should still list name hits, got %d", len(sameName))
}
}
+5 -18
View File
@@ -369,7 +369,10 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma
} else if name := strings.TrimSpace(m.Title); name != "" {
term.name = name
} else {
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
term.name = mediaSidecarBase(m.Path)
if term.name == "" {
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
}
}
if m.EpisodeNum > 0 {
term.episode = strconv.Itoa(m.EpisodeNum)
@@ -527,23 +530,7 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) {
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
func danmakuMatchFileName(path string) string {
if path == "" {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx >= 0 {
clean = clean[idx+1:]
}
base := filepath.Base(clean)
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
if second := strings.ToLower(filepath.Ext(base)); second != "" {
if _, ok := videoExtensions[second]; ok && second != ".strm" {
base = strings.TrimSuffix(base, filepath.Ext(base))
}
}
return strings.TrimSpace(base)
return mediaSidecarBase(path)
}
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
+110
View File
@@ -2,7 +2,11 @@ package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -455,6 +459,13 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
runTimeTicks := int64(m.DurationSec) * 10_000_000
durationMs := int64(m.DurationSec) * 1000
if durationMs <= 0 && posMs > 0 {
durationMs = posMs * 2
if durationMs < 30*60*1000 {
durationMs = 30 * 60 * 1000
}
runTimeTicks = durationMs * 10_000
}
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
pct := 0.0
if durationMs > 0 {
@@ -488,6 +499,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"Genres": splitCSV(m.Genres),
"People": e.resolveMediaPeople(ctx, m),
"ProviderIds": map[string]string{
"Tmdb": intToStr(m.TMDbID),
"Bangumi": intToStr(m.BangumiID),
@@ -501,8 +513,106 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
},
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
}
if primaryArtwork != "" {
item["PrimaryImageTag"] = m.ID
}
if seriesID != "" {
if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok {
if sEntry.posterURL != "" {
item["SeriesPrimaryImageTag"] = seriesID
}
if len(backdropTags) == 0 && sEntry.backdropURL != "" {
item["ParentBackdropItemId"] = seriesID
item["ParentBackdropImageTags"] = []string{seriesID + "-bd"}
}
}
}
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
item["PremiereDate"] = premiered
}
return item
}
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
if m == nil || strings.TrimSpace(m.Path) == "" {
return []map[string]any{}
}
dir := filepath.Dir(m.Path)
candidates := make([]string, 0, 6)
seenPath := map[string]struct{}{}
add := func(path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
key := strings.ToLower(filepath.Clean(path))
if _, ok := seenPath[key]; ok {
return
}
seenPath[key] = struct{}{}
candidates = append(candidates, path)
}
// 共享词干优先(竞女01.mkv.strm → 竞女01.nfo),并兼容旧的单层剥扩展命名。
add(nfoPath(m.Path))
for _, base := range mediaSidecarBaseVariants(m.Path) {
add(filepath.Join(dir, base+".nfo"))
}
add(filepath.Join(dir, "movie.nfo"))
add(filepath.Join(dir, "tvshow.nfo"))
people := make([]map[string]any, 0)
seen := make(map[string]bool)
for _, p := range candidates {
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
doc, _, err := decodeNFOFile(p)
if err == nil && doc != nil {
for _, d := range doc.Directors {
name := strings.TrimSpace(d)
if name == "" {
continue
}
personID := embyPersonID(name, "Director")
if seen[personID] {
continue
}
seen[personID] = true
people = append(people, map[string]any{
"Id": personID,
"Name": name,
"Type": "Director",
"Role": "Director",
})
}
for _, a := range doc.Actors {
name := strings.TrimSpace(a.Name)
if name == "" {
continue
}
personID := embyPersonID(name, "Actor")
if seen[personID] {
continue
}
seen[personID] = true
role := strings.TrimSpace(a.Role)
if role == "" {
role = "Actor"
}
people = append(people, map[string]any{
"Id": personID,
"Name": name,
"Type": "Actor",
"Role": role,
})
}
break
}
}
}
return people
}
func embyPersonID(name, roleType string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
return "person-" + hex.EncodeToString(sum[:8])
}
+87 -30
View File
@@ -23,10 +23,17 @@ type embyLibraryTypeEntry struct {
found bool // 库不存在时 found=false,调用方可退回计数启发式
}
type embyPayloadSeriesEntry struct {
title string
posterURL string
backdropURL string
found bool
}
type embyPayloadCache struct {
mu sync.Mutex
libTypes map[string]embyLibraryTypeEntry
series map[string]string // series_id -> title("" 表示不存在/无标题)
series map[string]embyPayloadSeriesEntry
}
func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) {
@@ -45,14 +52,33 @@ func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry)
func (c *embyPayloadCache) seriesTitle(id string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
title, ok := c.series[id]
return title, ok
entry, ok := c.series[id]
if !ok {
return "", false
}
return entry.title, true
}
func (c *embyPayloadCache) seriesEntry(id string) (embyPayloadSeriesEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.series[id]
return entry, ok
}
func (c *embyPayloadCache) setSeriesEntry(id string, entry embyPayloadSeriesEntry) {
c.mu.Lock()
defer c.mu.Unlock()
c.series[id] = entry
}
func (c *embyPayloadCache) setSeriesTitle(id, title string) {
c.mu.Lock()
defer c.mu.Unlock()
c.series[id] = title
entry := c.series[id]
entry.title = title
entry.found = true
c.series[id] = entry
}
// withPayloadCache attaches a fresh request-scoped cache if none exists yet.
@@ -65,7 +91,7 @@ func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context {
}
return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{
libTypes: map[string]embyLibraryTypeEntry{},
series: map[string]string{},
series: map[string]embyPayloadSeriesEntry{},
})
}
@@ -113,17 +139,22 @@ func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Med
}
}
}
if len(seriesIDs) > 0 {
var series []model.Series
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
for _, s := range series {
cache.setSeriesTitle(s.ID, s.Title)
if len(seriesIDs) > 0 {
var series []model.Series
if err := e.repo.DB.WithContext(ctx).Select("id, title, poster_url, backdrop_url").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
for _, s := range series {
cache.setSeriesEntry(s.ID, embyPayloadSeriesEntry{
title: s.Title,
posterURL: s.PosterURL,
backdropURL: s.BackdropURL,
found: true,
})
}
}
}
}
}
// payloadLibraryType resolves a library type through the request cache,
// payloadLibraryType resolves a library type through the request cache,
// falling back to a direct lookup when no cache is attached. found=false
// means the library row does not exist (soft-deleted or orphaned id).
func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) {
@@ -152,24 +183,50 @@ func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string)
// payloadSeriesTitle resolves a series title through the request cache,
// falling back to a direct lookup when no cache is attached.
func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) {
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
if title, hit := cache.seriesTitle(seriesID); hit {
return title, true, nil
}
var s model.Series
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil {
cache.setSeriesTitle(seriesID, "")
return "", true, nil
}
cache.setSeriesTitle(s.ID, s.Title)
return s.Title, true, nil
}
series, err := e.repo.Series.FindByID(ctx, seriesID)
if err != nil {
entry, ok, err := e.payloadSeriesEntry(ctx, seriesID)
if err != nil || !ok {
return "", false, err
}
if series == nil {
return "", false, nil
}
return series.Title, true, nil
return entry.title, true, nil
}
// payloadSeriesEntry resolves a series entry through the request cache,
// falling back to a direct lookup when no cache is attached.
func (e *EmbyService) payloadSeriesEntry(ctx context.Context, seriesID string) (embyPayloadSeriesEntry, bool, error) {
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
if entry, hit := cache.seriesEntry(seriesID); hit {
return entry, entry.found, nil
}
if e.repo != nil && e.repo.Series != nil {
s, err := e.repo.Series.FindByID(ctx, seriesID)
if err != nil || s == nil {
cache.setSeriesEntry(seriesID, embyPayloadSeriesEntry{})
return embyPayloadSeriesEntry{}, false, err
}
entry := embyPayloadSeriesEntry{
title: s.Title,
posterURL: s.PosterURL,
backdropURL: s.BackdropURL,
found: true,
}
cache.setSeriesEntry(seriesID, entry)
return entry, true, nil
}
}
if e.repo != nil && e.repo.Series != nil {
series, err := e.repo.Series.FindByID(ctx, seriesID)
if err != nil {
return embyPayloadSeriesEntry{}, false, err
}
if series == nil {
return embyPayloadSeriesEntry{}, false, nil
}
return embyPayloadSeriesEntry{
title: series.Title,
posterURL: series.PosterURL,
backdropURL: series.BackdropURL,
found: true,
}, true, nil
}
return embyPayloadSeriesEntry{}, false, nil
}
+57
View File
@@ -0,0 +1,57 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestResolveMediaPeopleUsesSharedStemBesideKeepExtStrm(t *testing.T) {
svc := newTestEmbyService(t)
dir := t.TempDir()
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
nfoPath := filepath.Join(dir, "竞女01.nfo")
if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil {
t.Fatal(err)
}
nfo := `<movie>
<title>竞女01</title>
<director>导演甲</director>
<actor><name>演员乙</name><role>主角</role></actor>
</movie>`
if err := os.WriteFile(nfoPath, []byte(nfo), 0o644); err != nil {
t.Fatal(err)
}
people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath, Title: "竞女01"})
if len(people) != 2 {
t.Fatalf("people=%d want 2: %#v", len(people), people)
}
got := map[string]string{}
for _, p := range people {
got[p["Name"].(string)] = p["Type"].(string)
}
if got["导演甲"] != "Director" || got["演员乙"] != "Actor" {
t.Fatalf("unexpected people: %#v", people)
}
}
func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) {
svc := newTestEmbyService(t)
dir := t.TempDir()
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
legacyNFO := filepath.Join(dir, "竞女01.mkv.nfo")
if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyNFO, []byte(`<movie><director>旧导演</director></movie>`), 0o644); err != nil {
t.Fatal(err)
}
people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath})
if len(people) != 1 || people[0]["Name"] != "旧导演" {
t.Fatalf("expected legacy keep_ext nfo people, got %#v", people)
}
}
+1 -1
View File
@@ -321,7 +321,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
func (e *EmbyService) baseMediaSource(ctx context.Context, m *model.Media, container string, isCloud bool, playURL string, directOnly bool) map[string]any {
return map[string]any{
"Id": m.ID,
"Name": m.Title,
"Name": MediaVersionLabel(*m),
"Path": embyMediaSourcePath(m),
"Container": container,
"Size": m.SizeBytes,
+32
View File
@@ -139,6 +139,37 @@ func (r *EmbyRemoteService) ListAccounts(ctx context.Context) ([]model.StrmAccou
return out, nil
}
// ConfiguredRemoteHosts 返回所有已配置的远程 Emby 线路的主机名/IP(去重、不含端口)。
func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string {
if r == nil || r.repo == nil || r.repo.StrmAccount == nil {
return nil
}
accounts, err := r.ListAccounts(ctx)
if err != nil || len(accounts) == 0 {
return nil
}
seen := make(map[string]bool)
var hosts []string
for _, acct := range accounts {
lines, _, err := r.LinesOf(&acct)
if err != nil {
continue
}
for _, line := range lines {
u, err := url.Parse(line.URL)
if err != nil || u.Hostname() == "" {
continue
}
h := strings.ToLower(u.Hostname())
if !seen[h] {
seen[h] = true
hosts = append(hosts, h)
}
}
}
return hosts
}
// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
if strings.TrimSpace(id) == "" {
@@ -789,6 +820,7 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
if parentID != "" {
q.Set("ParentId", parentID)
}
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/Latest"
var out []map[string]any
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
+70 -16
View File
@@ -180,21 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
seriesID = rid
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: remoteItemInt(item, "ProductionYear"),
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
year := remoteItemInt(item, "ProductionYear")
if year == 0 {
year = remoteItemInt(item, "Year")
}
if year == 0 {
year = remoteItemInt(item, "SeriesProductionYear")
}
if year == 0 {
year = remoteItemInt(item, "SeriesYear")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: year,
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
media.CreatedAt = date
media.UpdatedAt = date
@@ -561,6 +571,46 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
return cached, nil
}
// 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。
// 避免 Emby /Items/Latest 默认返回无年份/无系列海报的单集(Episode)。
if mount != nil && (mount.CollectionType == "tvshows" || mount.CollectionType == "tv") {
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
q.Set("Limit", strconv.Itoa(limit))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
cards := make([]SeriesCard, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
if count == 0 {
count = 1
}
var lastAdded *time.Time
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
lastAdded = &date
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
}
return cards, nil
}
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
@@ -576,7 +626,11 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
t := m.CreatedAt
lastAdded = &t
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0, LastAddedAt: lastAdded})
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
+127
View File
@@ -400,3 +400,130 @@ func TestRemoteSearchMedia(t *testing.T) {
t.Fatalf("expected 0 items due to HiddenLibraryIDs, got %d", len(hiddenFiltered))
}
}
func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
var requestedFields string
var requestedIncludeItemTypes string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedFields = r.URL.Query().Get("Fields")
requestedIncludeItemTypes = r.URL.Query().Get("IncludeItemTypes")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{
{
"Id": "series-100",
"Name": "炒翻天",
"Type": "Series",
"ProductionYear": 2024,
"ImageTags": map[string]any{
"Primary": "tag123",
},
"RecursiveItemCount": 12,
},
},
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-tv"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
_ = repos.StrmAccount.Create(t.Context(), acct)
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-tv"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}
_ = repos.EmbyMount.Create(t.Context(), mount)
cards, err := svc.RemoteLatestCards(t.Context(), mount, acct, "view-tv", 10)
if err != nil {
t.Fatalf("RemoteLatestCards failed: %v", err)
}
if len(cards) != 1 {
t.Fatalf("expected 1 card, got %d", len(cards))
}
if cards[0].Rep.Year != 2024 {
t.Fatalf("expected Year 2024, got %d", cards[0].Rep.Year)
}
if cards[0].Count != 12 {
t.Fatalf("expected Count 12, got %d", cards[0].Count)
}
if cards[0].Rep.PosterURL == "" {
t.Fatalf("expected PosterURL not empty")
}
if requestedIncludeItemTypes != "Series" {
t.Fatalf("expected IncludeItemTypes=Series, got %q", requestedIncludeItemTypes)
}
if !strings.Contains(requestedFields, "ProductionYear") {
t.Fatalf("expected Fields to contain ProductionYear, got %q", requestedFields)
}
}
func TestRemoteLatestFields(t *testing.T) {
var requestedFields string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedFields = r.URL.Query().Get("Fields")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "movie-100",
"Name": "测试电影",
"Type": "Movie",
"ProductionYear": 2023,
},
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-movie"},
Name: "movie-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
_ = repos.StrmAccount.Create(t.Context(), acct)
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-movie"},
AccountID: acct.ID,
RemoteViewID: "view-movie",
CollectionType: "movies",
Enabled: true,
}
items, err := svc.RemoteLatest(t.Context(), mount, acct, "view-movie", 10)
if err != nil {
t.Fatalf("RemoteLatest failed: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if !strings.Contains(requestedFields, "ProductionYear") {
t.Fatalf("expected Fields to contain ProductionYear, got %q", requestedFields)
}
}
+19 -5
View File
@@ -27,16 +27,20 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
"CommunityRating": group.Rating,
"RecursiveItemCount": len(group.Episodes),
"ChildCount": len(e.seasonsForSeries(group)),
"DateCreated": group.CreatedAt,
"DateLastMediaAdded": lastMediaAdded,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"DateCreated": group.CreatedAt,
"DateLastMediaAdded": lastMediaAdded,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"People": []map[string]any{},
"ProviderIds": map[string]string{
"Tmdb": intToStr(group.TMDbID),
"Bangumi": intToStr(group.BangumiID),
},
"UserData": emptyUserData(),
}
if group.PosterURL != "" {
item["PrimaryImageTag"] = group.ID
}
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
item["PremiereDate"] = premiered
}
@@ -53,7 +57,7 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
if season.Series.BackdropURL != "" {
backdropTags = append(backdropTags, season.ID+"-bd")
}
return map[string]any{
item := map[string]any{
"Id": season.ID,
"Name": season.Name,
"ServerId": embyServerID,
@@ -67,6 +71,16 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
"ChildCount": len(season.Episodes),
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"People": []map[string]any{},
"UserData": emptyUserData(),
}
if season.Series.PosterURL != "" {
item["PrimaryImageTag"] = season.ID
item["SeriesPrimaryImageTag"] = season.Series.ID
}
if season.Series.BackdropURL != "" {
item["ParentBackdropItemId"] = season.Series.ID
item["ParentBackdropImageTags"] = []string{season.Series.ID + "-bd"}
}
return item
}
+11 -3
View File
@@ -11,6 +11,10 @@ import (
// SystemInfo returns the full Emby identity payload.
func (e *EmbyService) SystemInfo() map[string]any {
port := 8096
if e != nil && e.cfg != nil {
port = e.cfg.App.Port
}
return map[string]any{
"Id": embyServerID,
"ServerId": embyServerID,
@@ -27,10 +31,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
"SupportsLibraryMonitor": true,
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
"HttpServerPortNumber": e.cfg.App.Port,
"HttpServerPortNumber": port,
"HttpsPortNumber": 0,
"PublishedServerUrl": "",
"WebSocketPortNumber": e.cfg.App.Port,
"WebSocketPortNumber": port,
"CompletedInstallations": []any{},
"CanSelfRestart": false,
"CanLaunchWebBrowser": false,
@@ -40,6 +44,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。
func (e *EmbyService) SystemInfoPublic() map[string]any {
port := 8096
if e != nil && e.cfg != nil {
port = e.cfg.App.Port
}
return map[string]any{
"Id": embyServerID,
"ServerId": embyServerID,
@@ -50,7 +58,7 @@ func (e *EmbyService) SystemInfoPublic() map[string]any {
"OperatingSystem": "Windows",
"LocalAddress": "",
"WanAddress": "",
"HttpServerPortNumber": e.cfg.App.Port,
"HttpServerPortNumber": port,
"HttpsPortNumber": 0,
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
+8 -2
View File
@@ -41,7 +41,10 @@ var (
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
// Returns (0, 0) when nothing recognisable is found.
func ParseEpisode(path string) (season, episode int) {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
name := mediaSidecarBase(path)
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
season = mustAtoi(m[1])
@@ -96,7 +99,10 @@ type episodeRef struct {
}
func episodeRefsFromTitle(path string) []episodeRef {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
name := mediaSidecarBase(path)
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if refs := parseSEpisodeRange(name); len(refs) > 0 {
return refs
}
+62 -6
View File
@@ -17,6 +17,7 @@ import (
"net"
"net/http"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -42,6 +43,13 @@ type ImageProxy struct {
libRootsMu sync.Mutex
libRootsCache []string
libRootsAt time.Time
// allowedRemoteHostsFn returns hostnames or IPs of explicitly configured
// upstream services (e.g. remote Emby mounts) that should bypass SSRF private IP checks.
allowedRemoteHostsFn func() []string
allowedHostsMu sync.Mutex
allowedHostsCache map[string]bool
allowedHostsAt time.Time
}
const (
@@ -51,6 +59,12 @@ const (
// NewImageProxy is the constructor.
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
proxy := &ImageProxy{
cfg: cfg,
log: log,
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
}
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
// from image.tmdb.org via their HTTP proxy without extra config. On
// Windows we also honor the current user's system proxy settings.
@@ -63,6 +77,7 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
// 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到
// 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次
// 校验(含重定向后的每条连接)堵住该旁路。
// 用户明确配置的远程挂载源(如内网 Emby)豁免该私网限制。
dialer := &net.Dialer{
Timeout: 15 * time.Second,
Control: func(_, address string, _ syscall.RawConn) error {
@@ -70,6 +85,9 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
if err != nil {
return err
}
if proxy.isAllowedRemoteHost(host) {
return nil
}
ip := net.ParseIP(host)
if ip == nil {
return errors.New("image proxy: refusing non-IP dial target")
@@ -82,12 +100,9 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
}
transport.DialContext = dialer.DialContext
}
return &ImageProxy{
cfg: cfg,
log: log,
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
client: &http.Client{Timeout: 30 * time.Second, Transport: transport},
}
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
return proxy
}
// proxyConfiguredForImageFetch 探测环境变量或系统代理是否会影响图片抓取。
@@ -125,6 +140,47 @@ func (p *ImageProxy) libraryRoots() []string {
return p.libRootsCache
}
// SetAllowedRemoteHostsProvider injects a callback that returns hostnames or IPs
// of explicitly configured remote services (e.g. remote Emby mounts). Requests to
// these hosts bypass SSRF private-IP restrictions.
func (p *ImageProxy) SetAllowedRemoteHostsProvider(fn func() []string) {
p.allowedRemoteHostsFn = fn
}
func (p *ImageProxy) isAllowedRemoteHost(host string) bool {
if p == nil || p.allowedRemoteHostsFn == nil {
return false
}
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
// Strip port if present
if h, _, err := net.SplitHostPort(host); err == nil {
host = strings.ToLower(strings.TrimSpace(h))
}
p.allowedHostsMu.Lock()
defer p.allowedHostsMu.Unlock()
if p.allowedHostsCache == nil || time.Since(p.allowedHostsAt) >= 30*time.Second {
rawList := p.allowedRemoteHostsFn()
cache := make(map[string]bool, len(rawList))
for _, item := range rawList {
item = strings.ToLower(strings.TrimSpace(item))
if item == "" {
continue
}
if h, _, err := net.SplitHostPort(item); err == nil {
item = strings.ToLower(strings.TrimSpace(h))
}
cache[item] = true
}
p.allowedHostsCache = cache
p.allowedHostsAt = time.Now()
}
return p.allowedHostsCache[host]
}
// Prune removes oldest cached images until disk usage is within the configured limit.
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
+1 -1
View File
@@ -22,7 +22,7 @@ func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
if scheme != "http" && scheme != "https" {
return nil, errors.New("unsupported scheme")
}
if isPrivateHost(u.Hostname()) {
if !p.isAllowedRemoteHost(u.Hostname()) && isPrivateHost(u.Hostname()) {
return nil, errors.New("requests to private/internal hosts are not allowed")
}
return u, nil
+12 -8
View File
@@ -51,7 +51,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
return nil, "", "", errImageProxyRequestSetup
}
applyRemoteImageHeaders(req, host)
applyRemoteImageHeaders(req, host, raw)
resp, err := candidate.client.Do(req)
if err != nil {
@@ -79,7 +79,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
return data, ctype, resp.Header.Get("Content-Length"), nil
}
func applyRemoteImageHeaders(req *http.Request, host string) {
func applyRemoteImageHeaders(req *http.Request, host, raw string) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
@@ -88,7 +88,7 @@ func applyRemoteImageHeaders(req *http.Request, host string) {
if cookie := remoteImageCookie(host); cookie != "" {
req.Header.Set("Cookie", cookie)
}
if referer := remoteImageReferer(host); referer != "" {
if referer := remoteImageReferer(host, raw); referer != "" {
req.Header.Set("Referer", referer)
}
}
@@ -105,7 +105,7 @@ func remoteImageCookie(host string) string {
}
}
func remoteImageReferer(host string) string {
func remoteImageReferer(host, raw string) string {
h := strings.ToLower(strings.TrimSpace(host))
switch {
case strings.Contains(h, "doubanio.com"):
@@ -125,7 +125,11 @@ func remoteImageReferer(host string) string {
case strings.Contains(h, "fc2.com"):
return "https://adult.contents.fc2.com/"
case h != "":
return "https://" + h + "/"
scheme := "https"
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "http://") {
scheme = "http"
}
return scheme + "://" + h + "/"
default:
return ""
}
@@ -158,9 +162,9 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st
"--header", "Cache-Control: no-cache",
"--header", "Pragma: no-cache",
}
if referer := remoteImageReferer(host); referer != "" {
args = append(args, "--referer", referer)
}
if referer := remoteImageReferer(host, raw); referer != "" {
args = append(args, "--referer", referer)
}
if cookie := remoteImageCookie(host); cookie != "" {
args = append(args, "--cookie", cookie)
}
+9 -4
View File
@@ -317,9 +317,14 @@ func TestRemoteImageRefererForAdultHosts(t *testing.T) {
{"example.com", "https://example.com/"},
{"", ""},
}
for _, tt := range tests {
if got := remoteImageReferer(tt.host); got != tt.want {
t.Errorf("remoteImageReferer(%q) = %q, want %q", tt.host, got, tt.want)
for _, tt := range tests {
if got := remoteImageReferer(tt.host, "https://"+tt.host+"/img.jpg"); got != tt.want {
t.Errorf("remoteImageReferer(%q) = %q, want %q", tt.host, got, tt.want)
}
}
// Also verify HTTP protocol preservation for generic hosts
if got := remoteImageReferer("192.168.1.100", "http://192.168.1.100:8096/image"); got != "http://192.168.1.100/" {
t.Errorf("remoteImageReferer for HTTP host = %q, want http://192.168.1.100/", got)
}
}
}
+26 -3
View File
@@ -117,9 +117,32 @@ func TestIsPrivateHost(t *testing.T) {
// Hostnames must NOT be blocked even though GFW DNS poisoning may resolve
// them to private/loopback IPs — blocking them broke legitimate posters.
allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"}
for _, h := range allowed {
if isPrivateHost(h) {
t.Errorf("isPrivateHost(%q) = true, want false", h)
for _, h := range allowed {
if isPrivateHost(h) {
t.Errorf("isPrivateHost(%q) = true, want false", h)
}
}
}
func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) {
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
rawURL := "http://192.168.1.100:8096/emby/Items/123/Images/Primary"
// Before setting allowed remote hosts, private host is rejected by validateURL
if _, err := proxy.validateURL(rawURL); err == nil {
t.Fatal("expected validateURL to reject private IP before whitelist")
}
// After configuring whitelist with the Emby host
proxy.SetAllowedRemoteHostsProvider(func() []string {
return []string{"192.168.1.100:8096"}
})
u, err := proxy.validateURL(rawURL)
if err != nil {
t.Fatalf("expected validateURL to allow whitelisted host, got: %v", err)
}
if u.Hostname() != "192.168.1.100" {
t.Fatalf("hostname = %s, want 192.168.1.100", u.Hostname())
}
}
+48 -28
View File
@@ -54,39 +54,59 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
}
func localPosterCandidates(mediaPath string) []string {
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
names := []string{
base + "-poster",
base + ".poster",
"poster",
"folder",
"cover",
"movie",
"show",
base + "-cover",
base + ".cover",
base,
base + "-thumb",
base + ".thumb",
"thumb",
names := make([]string, 0, 24)
seen := map[string]struct{}{}
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
names = append(names, name)
}
for _, base := range mediaSidecarBaseVariants(mediaPath) {
add(base + "-poster")
add(base + ".poster")
add(base + "-cover")
add(base + ".cover")
add(base)
add(base + "-thumb")
add(base + ".thumb")
}
for _, name := range []string{"poster", "folder", "cover", "movie", "show", "thumb"} {
add(name)
}
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
}
func localBackdropCandidates(mediaPath string) []string {
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
names := []string{
base + "-fanart",
base + ".fanart",
base + "-backdrop",
base + ".backdrop",
base + "-background",
"fanart",
"backdrop",
"background",
"landscape",
"banner",
"clearart",
names := make([]string, 0, 24)
seen := map[string]struct{}{}
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
names = append(names, name)
}
for _, base := range mediaSidecarBaseVariants(mediaPath) {
add(base + "-fanart")
add(base + ".fanart")
add(base + "-backdrop")
add(base + ".backdrop")
add(base + "-background")
}
for _, name := range []string{"fanart", "backdrop", "background", "landscape", "banner", "clearart"} {
add(name)
}
return append(adultArtworkNameCandidates(mediaPath, "backdrop"), names...)
}
+4 -5
View File
@@ -28,13 +28,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
mediaDir := filepath.Dir(mediaPath)
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
adultCode := AdultCodeFromMediaPath(mediaPath)
names := []string{
base + ".nfo",
"movie.nfo",
filepath.Base(mediaDir) + ".nfo",
names := make([]string, 0, 8)
for _, base := range mediaSidecarBaseVariants(mediaPath) {
names = append(names, base+".nfo")
}
names = append(names, "movie.nfo", filepath.Base(mediaDir)+".nfo")
if adultCode != "" {
names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...)
}
+62
View File
@@ -2,6 +2,8 @@ package service
import (
"context"
"sort"
"strings"
"time"
"go.uber.org/zap"
@@ -111,3 +113,63 @@ func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, e
*media = items[0]
return media, nil
}
// GetMediaItem 返回媒体详情,并附带同片多版本列表(用于详情页/播放器切换)。
func (s *MediaService) GetMediaItem(ctx context.Context, id string) (*MediaItem, error) {
media, err := s.GetMedia(ctx, id)
if err != nil || media == nil {
return nil, err
}
versions, err := s.listVersionSiblings(ctx, media)
if err != nil {
return nil, err
}
item := &MediaItem{Media: *media}
if len(versions) > 1 {
item.Versions = versions
}
return item, nil
}
// listVersionSiblings 查找与当前条目同属一个版本组的全部媒体(含自身)。
func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Media) ([]model.Media, error) {
if media == nil || strings.TrimSpace(media.ID) == "" {
return nil, nil
}
key := mediaVersionGroupKey(*media)
if key == "" {
return []model.Media{*media}, nil
}
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, media.LibraryID)
if err != nil {
return nil, err
}
if len(libraryIDs) == 0 {
libraryIDs = []string{media.LibraryID}
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
candidates, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
if err != nil {
return nil, err
}
s.attachLibraryMetadata(ctx, candidates)
matched := make([]model.Media, 0, 4)
for _, row := range candidates {
if mediaVersionGroupKey(row) == key {
matched = append(matched, row)
}
}
if len(matched) == 0 {
return []model.Media{*media}, nil
}
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].ID == media.ID {
return true
}
if matched[j].ID == media.ID {
return false
}
return betterMediaVersion(matched[i], matched[j])
})
return matched, nil
}
+62
View File
@@ -0,0 +1,62 @@
package service
import (
"path/filepath"
"strings"
)
// mediaSidecarBase 返回媒体文件用于配对 NFO/海报/字幕等元数据的共享基名。
// 同片多版本(含 keep_ext 的 name.mkv.strm / name.mp4.strm)应落到同一词干,
// 从而忽略中间的视频扩展,按「同一影片」匹配边车文件。
//
// movie.mkv → movie
// movie.strm → movie
// movie.mkv.strm → movie
// Show.S01E01.mp4.strm → Show.S01E01
func mediaSidecarBase(mediaPath string) string {
clean := strings.ReplaceAll(strings.TrimSpace(mediaPath), "\\", "/")
if clean == "" {
return ""
}
return mediaFileStem(filepath.Base(clean))
}
// mediaFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。
func mediaFileStem(name string) string {
name = strings.TrimSpace(name)
if name == "" || name == "." {
return ""
}
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
if strings.EqualFold(ext, ".strm") {
if second := strings.ToLower(filepath.Ext(stem)); second != "" {
if _, ok := videoExtensions[second]; ok && second != ".strm" {
stem = strings.TrimSuffix(stem, filepath.Ext(stem))
}
}
}
return strings.TrimSpace(stem)
}
// mediaSidecarBaseVariants 返回匹配用的基名候选:共享词干优先,其次保留单层剥扩展
// (兼容历史上写成 name.mkv.nfo / name.mkv-poster.jpg 的边车)。
func mediaSidecarBaseVariants(mediaPath string) []string {
stem := mediaSidecarBase(mediaPath)
single := strings.TrimSuffix(filepath.Base(strings.TrimSpace(mediaPath)), filepath.Ext(mediaPath))
single = strings.TrimSpace(single)
out := make([]string, 0, 2)
seen := map[string]struct{}{}
for _, item := range []string{stem, single} {
if item == "" || item == "." {
continue
}
key := strings.ToLower(item)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, item)
}
return out
}
+107
View File
@@ -0,0 +1,107 @@
package service
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestMediaSidecarBaseIgnoresKeepExt(t *testing.T) {
cases := map[string]string{
"/media/竞女01.mkv.strm": "竞女01",
"/media/竞女01.mp4.strm": "竞女01",
"/media/竞女01.strm": "竞女01",
"/media/竞女01.mkv": "竞女01",
"/lib/Show.S01E02.mkv.strm": "Show.S01E02",
`C:/lib/Show.S01E02.mkv.strm`: "Show.S01E02",
}
for in, want := range cases {
if got := mediaSidecarBase(in); got != want {
t.Fatalf("mediaSidecarBase(%q)=%q want %q", in, got, want)
}
}
}
func TestNFOPathUsesSharedStemForKeepExt(t *testing.T) {
got := nfoPath("/strm/竞女01.mkv.strm")
want := filepath.Join("/strm", "竞女01.nfo")
if got != want {
t.Fatalf("nfoPath keep_ext = %q want %q", got, want)
}
if nfoPath("/strm/竞女01.mp4.strm") != want {
t.Fatalf("mkv/mp4 versions should share nfo path")
}
}
func TestFindMovieNFOMatchesSharedStemBesideKeepExtStrm(t *testing.T) {
dir := t.TempDir()
media := filepath.Join(dir, "竞女01.mkv.strm")
nfo := filepath.Join(dir, "竞女01.nfo")
writeFileContent(t, media, "http://example/play")
writeFileContent(t, nfo, `<movie><title>竞女01</title></movie>`)
doc, path, err := findMovieNFO(media, dir)
if err != nil {
t.Fatal(err)
}
if path != nfo {
t.Fatalf("path=%q want %q", path, nfo)
}
if doc == nil || strings.TrimSpace(doc.Title) != "竞女01" {
t.Fatalf("unexpected nfo doc: %#v", doc)
}
}
func TestLocalPosterCandidatesIncludeSharedStem(t *testing.T) {
cands := localPosterCandidates("/strm/竞女01.mkv.strm")
found := false
for _, name := range cands {
if name == "竞女01-poster" || name == "竞女01" {
found = true
break
}
}
if !found {
t.Fatalf("expected shared stem poster candidates, got %#v", cands)
}
}
func TestSubtitleDiscoveryMatchesSharedStemBesideKeepExt(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
subPath := filepath.Join(dir, "竞女01.zh.srt")
writeFileContent(t, mediaPath, "http://example/play")
writeFileContent(t, subPath, "1\n00:00:01,000 --> 00:00:02,000\nhi\n")
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
media := model.Media{Title: "竞女01", Path: mediaPath}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
svc := &SubtitleService{repo: repos}
tracks, err := svc.discoverUncached(t.Context(), media.ID)
if err != nil {
t.Fatal(err)
}
if len(tracks) != 1 {
t.Fatalf("tracks=%d want 1 (%#v)", len(tracks), tracks)
}
if tracks[0].Path != subPath {
t.Fatalf("track path=%q want %q", tracks[0].Path, subPath)
}
}
func writeFileContent(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
+118
View File
@@ -2,6 +2,7 @@ package service
import (
"fmt"
"path/filepath"
"sort"
"strings"
@@ -179,6 +180,7 @@ func mediaVersionGroupKey(m model.Media) string {
}
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
}
title := firstNonEmpty(m.OriginalName, m.Title)
titleYear := 0
if title == "" {
@@ -187,6 +189,10 @@ func mediaVersionGroupKey(m model.Media) string {
title, titleYear = mediaVersionTitleKey(title)
}
if title == "" {
// 无标题时退回同目录词干(覆盖 keep_ext 的 name.mkv.strm / name.mp4.strm)
if stemKey := mediaVersionStemGroupKey(m, libKey); stemKey != "" {
return stemKey
}
return ""
}
year := m.Year
@@ -202,6 +208,114 @@ func mediaVersionGroupKey(m model.Media) string {
return fmt.Sprintf("movie:%s:%d", title, year)
}
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
path := strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/")
if path == "" || strings.HasPrefix(strings.ToLower(path), "cloud://") {
return ""
}
dir := ""
base := path
if idx := strings.LastIndex(path, "/"); idx >= 0 {
dir = path[:idx]
base = path[idx+1:]
}
stem := mediaVersionFileStem(base)
if stem == "" {
return ""
}
stem = normalizeMediaVersionText(stem)
if stem == "" {
return ""
}
if libKey == "" {
libKey = "_"
}
return fmt.Sprintf("stem:%s:%s:%s", libKey, strings.ToLower(dir), stem)
}
// mediaVersionFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。
func mediaVersionFileStem(name string) string {
return mediaFileStem(name)
}
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
func MediaVersionLabel(m model.Media) string {
parts := make([]string, 0, 4)
if m.Height > 0 {
parts = append(parts, fmt.Sprintf("%dp", m.Height))
} else if m.Width > 0 {
parts = append(parts, fmt.Sprintf("%dw", m.Width))
}
container := strings.Trim(strings.ToLower(strings.TrimSpace(m.Container)), ". ")
if container == "" || container == "strm" {
container = mediaVersionContainerFromPath(m.Path, m.STRMURL)
}
if container != "" && container != "strm" {
parts = append(parts, strings.ToUpper(container))
}
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
parts = append(parts, strings.ToUpper(codec))
}
if m.SizeBytes > 0 {
parts = append(parts, formatMediaSize(m.SizeBytes))
}
if len(parts) > 0 {
return strings.Join(parts, " · ")
}
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
if base == "" || base == "." {
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
}
return base
}
func mediaVersionContainerFromPath(path, strmURL string) string {
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
ext := strings.ToLower(filepath.Ext(base))
name := strings.TrimSuffix(base, ext)
if ext == ".strm" {
if second := strings.ToLower(filepath.Ext(name)); second != "" {
if _, ok := videoExtensions[second]; ok {
return strings.TrimPrefix(second, ".")
}
}
// 从 strm 播放 URL 的 /video.mkv 推断
u := strings.ToLower(strmURL)
if idx := strings.LastIndex(u, "/video."); idx >= 0 {
rest := u[idx+len("/video."):]
if end := strings.IndexAny(rest, "?#&/"); end >= 0 {
rest = rest[:end]
}
rest = strings.Trim(rest, ".")
if rest != "" {
return rest
}
}
return "strm"
}
if ext != "" {
if _, ok := videoExtensions[ext]; ok {
return strings.TrimPrefix(ext, ".")
}
}
return strings.TrimPrefix(ext, ".")
}
func formatMediaSize(bytes int64) string {
if bytes < 1024 {
return fmt.Sprintf("%d B", bytes)
}
const unit = 1024
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
func mediaVersionTitleKey(value string) (string, int) {
cleaned, year := CleanQuery(value)
if strings.TrimSpace(cleaned) == "" {
@@ -232,6 +346,10 @@ func normalizeMediaVersionText(value string) string {
if _, noise := noiseTokenSet[field]; noise {
continue
}
// 去掉视频容器词干残留(keep_ext / 旧标题「竞女01 mkv」)
if _, ok := videoExtensions["."+field]; ok {
continue
}
out = append(out, field)
}
return strings.Join(out, " ")
+41
View File
@@ -1,6 +1,7 @@
package service
import (
"strings"
"testing"
"time"
@@ -264,3 +265,43 @@ func TestGroupMediaVersionsDoesNotMergeAcrossDifferentLibrariesForMovies(t *test
t.Fatalf("expected 2 separate groups for movies in different libraries, got %d", len(grouped))
}
}
func TestGroupMediaVersionsMergesKeepExtStrmVariants(t *testing.T) {
mkv := model.Media{
LibraryID: "movies",
Title: "竞女01",
Path: "/strm/竞女01.mkv.strm",
SizeBytes: 500,
STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a",
}
mp4 := model.Media{
LibraryID: "movies",
Title: "竞女01",
Path: "/strm/竞女01.mp4.strm",
SizeBytes: 100,
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=b",
}
grouped := groupMediaVersions([]model.Media{mkv, mp4})
if len(grouped) != 1 {
t.Fatalf("grouped len = %d, want 1", len(grouped))
}
if len(grouped[0].Versions) != 2 {
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
}
if grouped[0].Path != mkv.Path {
t.Fatalf("primary should be larger mkv, got %q", grouped[0].Path)
}
}
func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
label := MediaVersionLabel(model.Media{
Title: "竞女01",
Path: "/strm/竞女01.mkv.strm",
Height: 1080,
SizeBytes: 1024 * 1024 * 1200,
STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a",
})
if !strings.Contains(label, "1080p") || !strings.Contains(strings.ToUpper(label), "MKV") {
t.Fatalf("unexpected label %q", label)
}
}
+4 -1
View File
@@ -115,7 +115,10 @@ func (s *NFOService) ExportLibrary(ctx context.Context, libraryID string) (int,
func nfoPath(media string) string {
dir := filepath.Dir(media)
base := strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
base := mediaSidecarBase(media)
if base == "" {
base = strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
}
return filepath.Join(dir, fmt.Sprintf("%s.nfo", base))
}
@@ -278,16 +278,21 @@ func randomSuffix() string {
// moveStagedArtwork renames artwork sidecars staged alongside `stage` into
// their final names next to `dst`.
func moveStagedArtwork(stage, dst string) {
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
dstBase := strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
stageBases := mediaSidecarBaseVariants(stage)
dstBase := mediaSidecarBase(dst)
if dstBase == "" {
dstBase = strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
}
stageDir := filepath.Dir(stage)
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
if _, err := os.Stat(srcPath); err != nil {
continue
for _, stageBase := range stageBases {
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
if _, err := os.Stat(srcPath); err != nil {
continue
}
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
}
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
}
}
}
@@ -306,12 +311,13 @@ func moveSidecarRename(from, to string) {
// removeStagedArtwork removes artwork sidecars that were staged alongside
// `stage`, used when a replace fails and its staged outputs must be cleaned up.
func removeStagedArtwork(stage string) {
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
stageBases := mediaSidecarBaseVariants(stage)
stageDir := filepath.Dir(stage)
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
path := filepath.Join(stageDir, stageBase+suffix+ext)
_ = os.Remove(path)
for _, stageBase := range stageBases {
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
_ = os.Remove(filepath.Join(stageDir, stageBase+suffix+ext))
}
}
}
}
+43 -20
View File
@@ -51,23 +51,25 @@ var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif"
// behind in the old folder; this keeps artwork with the organized file.
func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error {
srcDir := filepath.Dir(srcMedia)
base := strings.TrimSuffix(filepath.Base(srcMedia), filepath.Ext(srcMedia))
if base == "" || base == "." {
bases := mediaSidecarBaseVariants(srcMedia)
if len(bases) == 0 {
return nil
}
// Find every existing sidecar by probing suffix + extension combinations.
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions))
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions)*len(bases))
seen := map[string]struct{}{}
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
path := filepath.Join(srcDir, base+suffix+ext)
key := strings.ToLower(filepath.Clean(path))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if _, err := os.Stat(path); err == nil {
sources = append(sources, path)
for _, base := range bases {
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
path := filepath.Join(srcDir, base+suffix+ext)
key := strings.ToLower(filepath.Clean(path))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if _, err := os.Stat(path); err == nil {
sources = append(sources, path)
}
}
}
}
@@ -78,15 +80,36 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users.
return err
}
var firstErr error
dstBase := mediaSidecarBase(dstMedia)
if dstBase == "" {
dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia))
}
for _, src := range sources {
dst := filepath.Join(dstDir, filepath.Base(src))
if _, err := os.Stat(dst); err == nil {
continue // never clobber an existing artwork at the destination
name := filepath.Base(src)
// Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem.
suffix := ""
lowerName := strings.ToLower(name)
for _, base := range bases {
prefix := strings.ToLower(base)
if strings.HasPrefix(lowerName, prefix) {
suffix = name[len(base):]
break
}
}
if err := transferFile(src, dst, mode); err != nil && firstErr == nil {
firstErr = err
dstName := name
if suffix != "" && dstBase != "" {
dstName = dstBase + suffix
}
dst := filepath.Join(dstDir, dstName)
if strings.EqualFold(filepath.Clean(src), filepath.Clean(dst)) {
continue
}
if _, err := os.Stat(dst); err == nil {
continue
}
if err := transferFile(src, dst, mode); err != nil {
return err
}
}
return firstErr
return nil
}
+4 -1
View File
@@ -138,7 +138,10 @@ type localScanMediaInput struct {
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path)
if title == "" {
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
title = mediaSidecarBase(in.path)
if title == "" {
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
}
}
title, year = preferISOParentScrapeIdentity(in.path, in.lib.Path, title, year)
+6
View File
@@ -75,6 +75,12 @@ func CleanQuery(raw string) (title string, year int) {
base = strings.TrimSpace(raw)
}
name := strings.TrimSuffix(base, filepath.Ext(base))
// .strm 可能保留视频扩展名(name.mkv.strm),再剥一层真实视频扩展,
// 避免「竞女01.mkv」与「竞女01.mp4」被当成不同标题。
name = mediaFileStem(base)
if name == "" {
name = strings.TrimSuffix(base, filepath.Ext(base))
}
lower := strings.ToLower(name)
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
@@ -38,9 +38,9 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
if dir == "" || dir == "." {
return
}
// Scope sidecar names by the media file's base name (e.g. A.mp4 -> A-poster.jpg)
// so that multiple movies sharing one directory (A.mp4 + B.mp4) never clash.
base := strings.TrimSuffix(filepath.Base(refreshed.Path), filepath.Ext(refreshed.Path))
// Scope sidecar names by the shared media stem (e.g. A.mkv.strm / A.mp4.strm -> A-poster.jpg)
// so multi-version files in one folder share artwork and never diverge by container.
base := mediaSidecarBase(refreshed.Path)
if base == "" || base == "." {
return
}
+9
View File
@@ -121,6 +121,10 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
b.c.Transcoder.SetProbe(b.c.FFprobe)
b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
if b.c.EmbyRemote != nil {
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
@@ -177,6 +181,11 @@ func (b *serviceContainerBuilder) initIdentityServices() {
func (b *serviceContainerBuilder) initImageProxy() {
b.c.ImageProxy = NewImageProxy(b.cfg, b.log)
b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots)
if b.c.EmbyRemote != nil {
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
})
}
b.c.Scan.SetImageProxy(b.c.ImageProxy)
b.c.Scraper.SetImageProxy(b.c.ImageProxy)
}
+1 -1
View File
@@ -87,7 +87,7 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
}
// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
// container=strm 或路径以 .strm 结尾。网页播放默认直连,失败后可转码。
func IsStrmMediaRow(m *model.Media) bool {
if m == nil {
return false
+87 -3
View File
@@ -6,23 +6,36 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
// We block (with a 30s timeout) until the playlist file shows up.
//
// Optional query `start` (seconds) restarts ffmpeg from that source offset so
// the web player can scrub the full timeline without waiting for a full
// head-to-tail transcode.
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error {
// 「客户端直连解码」模式下宿主机不提供转码,HLS 一律拒绝,
// 迫使播放器走 direct play 本地解码。
if s.directPlayOnly(r.Context()) {
return ErrTranscodeDisabled
}
if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil {
startSec := parseHLSStartSec(r)
seekGen := parseHLSSeekGen(r)
subtitleStream := parseHLSSubtitleStream(r)
if _, err := s.transcoder.EnsureJobFromSubtitle(r.Context(), mediaID, startSec, seekGen, subtitleStream); err != nil {
return err
}
s.transcoder.TouchJob(mediaID)
if !s.transcoder.WaitReady(r.Context(), mediaID, 30*time.Second) {
readyTimeout := 45 * time.Second
if startSec > 0.05 {
// Mid-file HTTP seeks (esp. WMV) need longer before the first segment appears.
readyTimeout = 120 * time.Second
}
if !s.transcoder.WaitReady(r.Context(), mediaID, readyTimeout) {
return errors.New("hls playlist not ready")
}
playlist := s.transcoder.PlaylistPath(mediaID)
@@ -48,10 +61,61 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
return nil
}
func parseHLSSubtitleStream(r *http.Request) int {
if r == nil {
return -1
}
raw := strings.TrimSpace(r.URL.Query().Get("subtitle"))
if raw == "" {
return -1
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return -1
}
return v
}
func parseHLSStartSec(r *http.Request) float64 {
if r == nil {
return 0
}
raw := strings.TrimSpace(r.URL.Query().Get("start"))
if raw == "" {
return 0
}
v, err := strconv.ParseFloat(raw, 64)
if err != nil || v < 0 {
return 0
}
return v
}
func parseHLSSeekGen(r *http.Request) int64 {
if r == nil {
return 0
}
raw := strings.TrimSpace(r.URL.Query().Get("_seek"))
if raw == "" {
return 0
}
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v < 0 {
return 0
}
return v
}
func appendQueryToHLSSegments(playlist, rawQuery string) string {
if strings.TrimSpace(rawQuery) == "" {
return playlist
}
// Segment fetches do not need start=, but must keep _seek as a cache-busting
// generation because every transcode restart reuses seg_00000.ts names.
q := filterHLSSegmentQuery(rawQuery)
if q == "" {
return playlist
}
lines := strings.SplitAfter(playlist, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
@@ -65,12 +129,32 @@ func appendQueryToHLSSegments(playlist, rawQuery string) string {
} else if strings.HasSuffix(line, "\n") {
lineEnding = "\n"
}
lines[i] = strings.TrimRight(line, "\r\n") + "?" + rawQuery + lineEnding
lines[i] = strings.TrimRight(line, "\r\n") + "?" + q + lineEnding
}
}
return strings.Join(lines, "")
}
func filterHLSSegmentQuery(rawQuery string) string {
parts := strings.Split(rawQuery, "&")
kept := make([]string, 0, len(parts))
for _, part := range parts {
if part == "" {
continue
}
key := part
if i := strings.IndexByte(part, '='); i >= 0 {
key = part[:i]
}
switch strings.ToLower(key) {
case "start":
continue
}
kept = append(kept, part)
}
return strings.Join(kept, "&")
}
// ServeHLSSegment writes a single .ts segment from the on-disk cache.
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error {
s.transcoder.TouchJob(mediaID)
+6 -3
View File
@@ -302,9 +302,12 @@ func TestRequestTokenFromMediaBrowserAuthorizationHeader(t *testing.T) {
func TestAppendQueryToHLSSegments(t *testing.T) {
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
got := appendQueryToHLSSegments(in, "token=abc")
if !strings.Contains(got, "seg_00000.ts?token=abc") {
t.Fatalf("missing tokenized segment: %q", got)
got := appendQueryToHLSSegments(in, "token=abc&start=120.5&_seek=1001")
if !strings.Contains(got, "seg_00000.ts?token=abc&_seek=1001") {
t.Fatalf("missing token or seek generation on segment: %q", got)
}
if strings.Contains(got, "start=120.5") {
t.Fatalf("segment URL must not contain transcode start: %q", got)
}
if !strings.Contains(got, "seg_00001.ts?old=1") {
t.Fatalf("existing query should be preserved: %q", got)
+14 -3
View File
@@ -293,7 +293,11 @@ func (s *StrmService) save115OAuthToken(ctx context.Context, session *strm115Aut
acct.LastTestAt = &now
acct.LastTestResult = "授权成功"
acct.LastTestOK = true
return s.repo.StrmAccount.Update(ctx, acct)
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
return err
}
s.invalidate115Provider(acct.ID)
return nil
}
func (s *StrmService) drop115AuthSession(sessionID string) {
@@ -382,8 +386,15 @@ func (s *StrmService) refresh115TokensOnce(ctx context.Context) {
}
}
}
client := cloud115.NewOpenClient(cfg["app_id"], cfg["access_token"], cfg["refresh_token"])
token, err := client.RefreshToken(cfg["refresh_token"])
provider, err := s.providerFor(ctx, acct)
if err != nil {
continue
}
openProvider, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
if !ok || openProvider.OpenClient() == nil {
continue
}
token, err := openProvider.OpenClient().RefreshToken("")
if err != nil {
msg := "令牌刷新失败:" + err.Error()
if cloud115.IsRefreshTokenDead(err) {
+85
View File
@@ -10,6 +10,7 @@ import (
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
func TestStrmAccountConfigPreviewOf(t *testing.T) {
@@ -71,6 +72,90 @@ func TestUpdateStrmAccountMergesConfigWithoutClearingSecrets(t *testing.T) {
}
}
func TestProviderForReuses115ClientUntilCredentialsChange(t *testing.T) {
svc := testStrmService(t)
ctx := context.Background()
acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at-1",
"refresh_token": "rt-1",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
first, err := svc.providerFor(ctx, acct)
if err != nil {
t.Fatalf("first provider: %v", err)
}
second, err := svc.providerFor(ctx, acct)
if err != nil {
t.Fatalf("second provider: %v", err)
}
if first != second {
t.Fatal("115 provider should be shared per account")
}
updated, err := svc.UpdateStrmAccount(ctx, acct.ID, "", nil, map[string]string{
"access_token": "at-2",
"refresh_token": "rt-2",
})
if err != nil {
t.Fatalf("update account: %v", err)
}
third, err := svc.providerFor(ctx, updated)
if err != nil {
t.Fatalf("provider after credential update: %v", err)
}
if first == third {
t.Fatal("credential update must invalidate the shared provider")
}
oldClient := first.(interface{ OpenClient() *cloud115.OpenClient }).OpenClient()
oldClient.OnTokenRefreshed("at-stale", "rt-stale")
fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID)
if err != nil || fresh == nil {
t.Fatalf("reload account: %v", err)
}
freshCfg, err := svc.strmAccountConfig(fresh)
if err != nil {
t.Fatalf("decode refreshed account: %v", err)
}
if freshCfg["access_token"] != "at-2" || freshCfg["refresh_token"] != "rt-2" {
t.Fatalf("stale client overwrote new credentials: %#v", freshCfg)
}
}
func TestPersist115TokensKeepsSharedClient(t *testing.T) {
svc := testStrmService(t)
ctx := context.Background()
acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at-1",
"refresh_token": "rt-1",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
first, err := svc.providerFor(ctx, acct)
if err != nil {
t.Fatalf("first provider: %v", err)
}
svc.persist115Tokens(acct.ID, "at-2", "rt-2")
fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID)
if err != nil || fresh == nil {
t.Fatalf("reload account: %v", err)
}
second, err := svc.providerFor(ctx, fresh)
if err != nil {
t.Fatalf("second provider: %v", err)
}
if first != second {
t.Fatal("automatic token persistence must keep the in-memory shared client")
}
}
func TestDeleteStrmAccountCascadesEmbyMounts(t *testing.T) {
ctx := context.Background()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{})
+98 -28
View File
@@ -26,6 +26,8 @@ import (
const (
strmMaxTaskRetry = 3
// strmRecentUploadSkipWindow:上传已成功但 115 列表尚未反映时,同步扫描跳过同路径同大小再入队的宽限窗口。
strmRecentUploadSkipWindow = 30 * time.Minute
)
// downloadWorker 下载队列 worker:认领 → 解析直链 → 下载 → 落盘。
@@ -332,18 +334,28 @@ func (s *StrmService) processUploadTask(ctx context.Context, task *model.StrmUpl
}
// processUpload115 115 元数据上传:task.RemotePath 存的是父目录 cid,FileName 为远端文件名。
//
// 幂等要点:
// 1. 上传/重试前按父目录 + 文件名 + SHA1 探活:远端已有同内容副本则跳过上传,仅清理其它脏副本;
// 2. 真正上传成功后把新 file_id 写回 RemoteRef,供下次同步/重试识别;
// 3. 115 上传不保证同名覆盖,内容不同时仍先删旧再传。
func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUploadTask) {
finish := func(status, message string) {
finish := func(status, message, remoteRef string) {
now := time.Now()
task.Status = status
task.Error = message
task.FinishedAt = &now
// 条件化收尾:与下载侧一致,防止覆盖已取消任务。
if ok, err := s.repo.StrmUpload.UpdateIfRunning(context.Background(), task.ID, map[string]any{
updates := map[string]any{
"status": status,
"error": message,
"finished_at": &now,
}); err != nil {
}
if remoteRef != "" {
task.RemoteRef = remoteRef
updates["remote_ref"] = remoteRef
}
// 条件化收尾:与下载侧一致,防止覆盖已取消任务。
if ok, err := s.repo.StrmUpload.UpdateIfRunning(context.Background(), task.ID, updates); err != nil {
s.log.Warn("update strm upload task failed", zap.Error(err))
} else if !ok {
s.log.Info("strm upload task already closed elsewhere", zap.String("id", task.ID))
@@ -351,7 +363,7 @@ func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUplo
}
acct, err := s.repo.StrmAccount.FindByID(ctx, task.AccountID)
if err != nil || acct == nil {
finish(model.StrmTaskFailed, "网盘账号不存在")
finish(model.StrmTaskFailed, "网盘账号不存在", "")
return
}
provider, err := s.providerFor(ctx, acct)
@@ -359,39 +371,97 @@ func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUplo
s.uploadTaskFailWithRetry(task, err.Error())
return
}
named, ok := provider.(interface {
PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error
})
open115, ok := provider.(cloud.OpenAPI115Provider)
if !ok {
finish(model.StrmTaskFailed, "该网盘不支持元数据上传")
finish(model.StrmTaskFailed, "该网盘不支持元数据上传", "")
return
}
// 以本地为准:网盘端已有同名但内容不同的旧元数据时,先删除旧文件再上传。
// 115 的上传接口不保证同名覆盖,直接上传可能产生同名重复文件;删除失败则
// 任务重试(旧文件 ID 失效的场景会在下次同步后自动修复)。
client := open115.OpenClient()
localSHA1, shaErr := cloud115.FileSHA1(task.LocalPath)
if shaErr != nil {
s.uploadTaskFailWithRetry(task, "计算本地 SHA1 失败:"+shaErr.Error())
return
}
info, statErr := os.Stat(task.LocalPath)
if statErr != nil {
s.uploadTaskFailWithRetry(task, "读取本地文件失败:"+statErr.Error())
return
}
localSize := info.Size()
// ── 探活:父目录下是否已有同名同内容副本(覆盖「上传成功但本地当失败重试」)──
matched, sameName, probeErr := client.FindNamedContentInParent(ctx, task.RemotePath, task.FileName, localSHA1, localSize)
if probeErr != nil {
// 探活失败不阻断上传:按原路径继续,避免列表接口抖动导致任务永久卡住
s.log.Warn("115 上传前探活失败,继续上传",
zap.String("task_id", task.ID),
zap.String("local_path", task.LocalPath),
zap.Error(probeErr))
} else if matched != nil && matched.FileId != "" {
staleIDs := collectStale115FileIDs(task.RemoteRef, sameName, matched.FileId)
if len(staleIDs) > 0 {
if err := client.DeleteFiles(ctx, task.RemotePath, staleIDs...); err != nil {
s.log.Warn("探活命中后清理 115 脏副本失败(已跳过上传)",
zap.String("task_id", task.ID),
zap.String("matched_id", matched.FileId),
zap.Error(err))
}
}
s.log.Info("115 元数据已存在同内容副本,跳过上传",
zap.String("task_id", task.ID),
zap.String("local_path", task.LocalPath),
zap.String("file_id", matched.FileId))
finish(model.StrmTaskDone, "", matched.FileId)
return
}
// ── 需要上传:先尽量删掉任务携带的旧副本,再真正上传 ──
// 删除失败时不中止——继续上传新文件,旧副本交由下次同步 cleanupBatchRedundantFiles。
if task.RemoteRef != "" {
open115, ok := provider.(cloud.OpenAPI115Provider)
if !ok {
finish(model.StrmTaskFailed, "该网盘不支持删除远端旧元数据")
return
}
if err := open115.OpenClient().DeleteFiles(ctx, task.RemotePath, task.RemoteRef); err != nil {
s.uploadTaskFailWithRetry(task, "删除网盘旧元数据失败:"+err.Error())
return
refs := strings.Split(task.RemoteRef, ",")
if err := client.DeleteFiles(ctx, task.RemotePath, refs...); err != nil {
s.log.Warn("删除网盘旧元数据失败,跳过删除继续上传新文件",
zap.String("task_id", task.ID),
zap.String("local_path", task.LocalPath),
zap.Error(err))
}
}
f, err := os.Open(task.LocalPath)
result, err := client.Upload(ctx, task.LocalPath, task.RemotePath, "", "")
if err != nil {
s.uploadTaskFailWithRetry(task, "打开本地文件失败:"+err.Error())
return
}
if err := named.PutFileNamed(ctx, task.RemotePath, task.FileName, f); err != nil {
_ = f.Close()
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
return
}
_ = f.Close()
finish(model.StrmTaskDone, "")
newID := ""
if result != nil {
newID = strings.TrimSpace(result.FileId)
}
finish(model.StrmTaskDone, "", newID)
}
// collectStale115FileIDs 汇总待删脏副本:任务 RemoteRef + 探活所见同名文件,排除 keepID。
func collectStale115FileIDs(remoteRef string, sameName []cloud115.RemoteFile, keepID string) []string {
seen := map[string]struct{}{}
var out []string
add := func(id string) {
id = strings.TrimSpace(id)
if id == "" || id == keepID {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
out = append(out, id)
}
for _, id := range strings.Split(remoteRef, ",") {
add(id)
}
for _, f := range sameName {
add(f.FileId)
}
return out
}
// downloadTaskFailWithRetry 下载失败任务按退避重试,超过上限标记 failed。
+104 -4
View File
@@ -3,10 +3,12 @@ package service
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -174,8 +176,8 @@ func TestRequeueDownloadTask(t *testing.T) {
}
// TestProcessUpload115DeletesStaleRemoteMetaFirst 验证 115 覆盖上传语义(以本地为准):
// 任务携带网盘旧文件 ID 时,必须先调用 /open/ufile/delete 删除旧元数据再上传本地文件,
// 避免 115 出现同名重复文件;删除请求应携带 file_ids 与父目录 cid。
// 探活未命中时,任务携带网盘旧文件 ID 必须先 /open/ufile/delete 再上传;
// 上传成功后 RemoteRef 回写为新 file_id。
func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
@@ -197,6 +199,10 @@ func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
mu.Lock()
defer mu.Unlock()
switch r.URL.Path {
case "/open/ufile/files":
calls = append(calls, "probe")
// 探活:目录为空 / 无同内容副本 → 继续删旧上传
w.Write([]byte(`{"state":true,"data":[]}`))
case "/open/ufile/delete":
calls = append(calls, "delete")
deleteForm["file_ids"] = r.FormValue("file_ids")
@@ -232,10 +238,13 @@ func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
if task.Status != model.StrmTaskDone {
t.Fatalf("upload task should succeed, status = %s, error = %s", task.Status, task.Error)
}
if task.RemoteRef != "new-1" {
t.Fatalf("successful upload should record new file_id in RemoteRef, got %q", task.RemoteRef)
}
mu.Lock()
defer mu.Unlock()
if len(calls) != 2 || calls[0] != "delete" || calls[1] != "upload" {
t.Fatalf("expected delete before upload, got calls = %v", calls)
if len(calls) != 3 || calls[0] != "probe" || calls[1] != "delete" || calls[2] != "upload" {
t.Fatalf("expected probe→delete→upload, got calls = %v", calls)
}
if deleteForm["file_ids"] != "old-file-1" {
t.Fatalf("delete file_ids = %q, want old-file-1", deleteForm["file_ids"])
@@ -245,6 +254,97 @@ func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
}
}
// TestProcessUpload115SkipsWhenProbeFindsSameContent 验证上传/重试前探活命中同内容副本时跳过上传,
// 并清理其它脏副本,RemoteRef 回写为已存在的 file_id。
func TestProcessUpload115SkipsWhenProbeFindsSameContent(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
localFile := filepath.Join(localDir, "movie.nfo")
content := []byte("already-on-115")
if err := os.WriteFile(localFile, content, 0o644); err != nil {
t.Fatal(err)
}
sha, err := cloud115.FileSHA1(localFile)
if err != nil {
t.Fatal(err)
}
acct := &model.StrmAccount{Name: "fake115", Provider: "cloud115", Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var calls []string
deleteForm := map[string]string{}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
mu.Lock()
defer mu.Unlock()
switch r.URL.Path {
case "/open/ufile/files":
calls = append(calls, "probe")
w.Write([]byte(fmt.Sprintf(
`{"state":true,"data":[
{"fid":"keep-1","fc":"1","fn":"movie.nfo","fs":%d,"sha1":%q,"fta":"1"},
{"fid":"dirty-2","fc":"1","fn":"movie.nfo","fs":9,"sha1":"DEADBEEF","fta":"1"}
]}`, len(content), sha)))
case "/open/ufile/delete":
calls = append(calls, "delete")
deleteForm["file_ids"] = r.FormValue("file_ids")
w.Write([]byte(`{"state":true,"data":[]}`))
case "/open/upload/init":
calls = append(calls, "upload")
w.Write([]byte(`{"state":true,"data":{"status":2,"file_id":"should-not","pick_code":"x","callback":null}}`))
default:
t.Errorf("unexpected 115 api path %s", r.URL.Path)
w.Write([]byte(`{"state":false,"message":"unexpected path"}`))
}
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
task := &model.StrmUploadTask{
Base: model.Base{ID: "up-skip-1"},
SyncPathID: "p1",
AccountID: acct.ID,
Provider: model.StrmProvider115,
FileName: "movie.nfo",
LocalPath: localFile,
RemotePath: "777",
RemoteRef: "old-ref",
Size: int64(len(content)),
Status: model.StrmTaskRunning,
}
svc.processUpload115(context.Background(), task)
if task.Status != model.StrmTaskDone {
t.Fatalf("probe hit should finish done, status=%s err=%s", task.Status, task.Error)
}
if task.RemoteRef != "keep-1" {
t.Fatalf("RemoteRef should be matched file_id keep-1, got %q", task.RemoteRef)
}
mu.Lock()
defer mu.Unlock()
if len(calls) != 2 || calls[0] != "probe" || calls[1] != "delete" {
t.Fatalf("expected probe→delete (no upload), got %v", calls)
}
// 脏副本 dirty-2 与任务旧 ref 都应被清理,keep-1 不得出现
ids := strings.Split(deleteForm["file_ids"], ",")
idSet := map[string]bool{}
for _, id := range ids {
idSet[strings.TrimSpace(id)] = true
}
if !idSet["dirty-2"] || !idSet["old-ref"] {
t.Fatalf("delete should include dirty-2 and old-ref, got %q", deleteForm["file_ids"])
}
if idSet["keep-1"] {
t.Fatalf("must not delete matched keep-1, got %q", deleteForm["file_ids"])
}
}
// TestBatchResolve115Links 验证下载队列的批量换链:同账号多个 115 任务的
// pickcode 合并为一次 downurl 请求(官方接口支持逗号分隔多 pick_code),
// 重复引用去重、非 115 任务不参与、直链携带绑定 UA。
+59 -10
View File
@@ -42,6 +42,7 @@ const (
StrmSettingDownloadMeta = "strm.download_meta"
StrmSettingUploadMeta = "strm.upload_meta"
StrmSettingDeleteDir = "strm.delete_dir"
StrmSettingKeepExt = "strm.keep_ext"
StrmSettingDownloadThreads = "strm.download_threads"
StrmSettingUploadThreads = "strm.upload_threads"
)
@@ -69,6 +70,7 @@ var StrmSettingDefs = map[string]struct {
StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"},
StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端;本地与网盘元数据不同时以本地为准覆盖(需网盘支持写入)"},
StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"},
StrmSettingKeepExt: {Default: "false", Label: "保留视频扩展名(多版本)", Kind: "bool", Help: "关闭(默认):同名不同扩展(如 竞女01.mkv / 竞女01.mp4)按体积→mtime→扩展名优先级择优生成一条 name.strm;开启:分别生成 name.mkv.strm / name.mp4.strm,保留全部版本供播放切换"},
Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"},
StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"},
StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"},
@@ -93,6 +95,9 @@ type StrmService struct {
oauthSessions map[string]*strm115AuthSession
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
providerMu sync.Mutex
provider115Cache map[string]cloud.Provider // account ID -> shared provider/OpenClient
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
downloadSemOnce sync.Once
@@ -156,15 +161,16 @@ func (s *StrmService) releaseDownloadSlot(provider string) {
// NewStrmService constructs the STRM service.
func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Container, crypto *CryptoService) *StrmService {
return &StrmService{
log: log,
repo: repos,
cfg: cfg,
crypto: crypto,
http: &http.Client{Timeout: 90 * time.Second},
stopCh: make(chan struct{}),
baseCtx: context.Background(),
running: map[string]context.CancelFunc{},
oauthSessions: map[string]*strm115AuthSession{},
log: log,
repo: repos,
cfg: cfg,
crypto: crypto,
http: &http.Client{Timeout: 90 * time.Second},
stopCh: make(chan struct{}),
baseCtx: context.Background(),
running: map[string]context.CancelFunc{},
oauthSessions: map[string]*strm115AuthSession{},
provider115Cache: map[string]cloud.Provider{},
}
}
@@ -407,6 +413,9 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
return nil, err
}
if acct.Provider == model.StrmProvider115 && len(config) > 0 {
s.invalidate115Provider(acct.ID)
}
return acct, nil
}
@@ -424,6 +433,7 @@ func (s *StrmService) DeleteStrmAccount(ctx context.Context, id string) error {
if err := s.repo.StrmAccount.Delete(ctx, id); err != nil {
return err
}
s.invalidate115Provider(id)
// 级联清理远程 Emby 挂载:否则留下孤儿挂载,挂载计数/列表仍会显示。
// 账号已删,挂载清理失败只记日志,不让删除请求报错。
if _, err := s.repo.EmbyMount.DeleteByAccountID(ctx, id); err != nil && s.log != nil {
@@ -462,6 +472,23 @@ func (s *StrmService) ListAccounts(ctx context.Context) ([]model.StrmAccount, er
// providerFor 依据账号配置构建网盘驱动。
func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
if acct != nil && acct.Provider == model.StrmProvider115 {
s.providerMu.Lock()
defer s.providerMu.Unlock()
if provider := s.provider115Cache[acct.ID]; provider != nil {
return provider, nil
}
provider, err := s.newProvider(ctx, acct)
if err != nil {
return nil, err
}
s.provider115Cache[acct.ID] = provider
return provider, nil
}
return s.newProvider(ctx, acct)
}
func (s *StrmService) newProvider(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
cfg, err := s.strmAccountConfig(acct)
if err != nil {
return nil, err
@@ -480,13 +507,33 @@ func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount)
// refresh_token 再刷(一次性轮转),两者互相作废,最终把有效账号
// 标成“授权已失效”。
if oc, ok := provider.(interface{ OpenClient() *cloud115.OpenClient }); ok {
oc.OpenClient().OnTokenRefreshed = func(accessToken, refreshToken string) {
client := oc.OpenClient()
client.OnTokenRefreshed = func(accessToken, refreshToken string) {
// 账号重新授权/修改凭据后,旧客户端可能仍有在途请求。旧请求
// 刷新的令牌不能覆盖新授权写入的凭据。
if !s.isCurrent115Client(acct.ID, client) {
return
}
s.persist115Tokens(acct.ID, accessToken, refreshToken)
}
}
return provider, nil
}
func (s *StrmService) invalidate115Provider(accountID string) {
s.providerMu.Lock()
delete(s.provider115Cache, accountID)
s.providerMu.Unlock()
}
func (s *StrmService) isCurrent115Client(accountID string, client *cloud115.OpenClient) bool {
s.providerMu.Lock()
defer s.providerMu.Unlock()
provider := s.provider115Cache[accountID]
openProvider, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
return ok && openProvider.OpenClient() == client
}
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
// GetStrmSettings 返回全局 strm 设置(含默认值)。
@@ -731,6 +778,7 @@ func (s *StrmService) strmEffectiveConfig(ctx context.Context, p *model.StrmSync
cfg.DownloadMeta = p.DownloadMeta
cfg.UploadMeta = p.UploadMeta
cfg.DeleteDir = p.DeleteDir
cfg.KeepExt = p.KeepExt
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return cfg, nil
}
@@ -964,6 +1012,7 @@ type strmPathConfig struct {
DownloadMeta bool
UploadMeta bool
DeleteDir bool
KeepExt bool
}
// ─── 本地目录浏览(添加同步目录用,兼容 Windows/Linux) ─────────────────────────
File diff suppressed because it is too large Load Diff
+495 -50
View File
@@ -368,19 +368,18 @@ func TestScanLocalMetaForUpload(t *testing.T) {
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 模拟远端已存在 poster.jpg(与本地同一文件)和 tvshow.nfo(与本地不同)
st.remoteMeta["m:动漫/poster.jpg"] = int64(len("poster-data"))
st.remoteMeta["m:动漫/tvshow.nfo"] = 999
st.remoteMeta["m:动漫/poster.jpg"] = []remoteMetaItem{{ID: "f1", Size: int64(len("poster-data"))}}
st.remoteMeta["m:动漫/tvshow.nfo"] = []remoteMetaItem{{ID: "f2", Size: 999}}
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
@@ -442,17 +441,15 @@ func TestScanLocalMetaForUpload115CarriesRemoteRef(t *testing.T) {
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
st.remoteMeta["m:movie.nfo"] = 1
st.remoteMetaRef["m:movie.nfo"] = "file-42"
st.remoteMeta["m:movie.nfo"] = []remoteMetaItem{{ID: "file-42", Size: 1}}
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
@@ -522,7 +519,7 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 本次远端扫描既没有看到视频,也没有看到任何元数据
if err := st.pruneLocal(); err != nil {
@@ -569,8 +566,7 @@ func TestHandleMetaKeepsLocalWhenUploadEnabled(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
}
@@ -630,22 +626,21 @@ func TestScanLocalMetaForUploadSha1Identity(t *testing.T) {
UploadMeta: true,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 115 返回大写 SHA1,本地计算为小写:同时验证大小写不敏感比对
st.remoteMeta["m:same.nfo"] = int64(len("same-content"))
st.remoteMetaSha1["m:same.nfo"] = strings.ToUpper(sameSha)
st.remoteMeta["m:diff.nfo"] = int64(len("diff-content"))
st.remoteMetaSha1["m:diff.nfo"] = strings.ToUpper(otherSha)
st.remoteMetaRef["m:diff.nfo"] = "old-diff-1"
st.remoteMeta["m:same.nfo"] = []remoteMetaItem{
{ID: "same-1", Size: int64(len("same-content")), Sha1: strings.ToUpper(sameSha)},
}
st.remoteMeta["m:diff.nfo"] = []remoteMetaItem{
{ID: "old-diff-1", Size: int64(len("diff-content")), Sha1: strings.ToUpper(otherSha)},
}
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
@@ -703,9 +698,7 @@ func TestHandleMetaSha1Identity(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: false, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
}
@@ -802,7 +795,7 @@ func TestWalkRemoteConcurrent(t *testing.T) {
rec: &model.StrmSyncRecord{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMeta: map[string][]remoteMetaItem{},
}
if err := st.walkRemote(); err != nil {
t.Fatalf("walkRemote failed: %v", err)
@@ -911,7 +904,7 @@ func TestStrmDuplicateFileConflictResolution(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
@@ -950,7 +943,7 @@ func TestStrmDuplicateFileConflictResolution(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
@@ -1019,7 +1012,7 @@ func TestWalk115FlatAbortsOnDirResolveFailure(t *testing.T) {
dirCache: sync.Map{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMeta: map[string][]remoteMetaItem{},
}
err := st.walk115Flat(oc)
if err == nil {
@@ -1088,9 +1081,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
dirCache: sync.Map{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
@@ -1145,9 +1136,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
dirCache: sync.Map{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
@@ -1206,3 +1195,459 @@ func TestCloud115FullPath(t *testing.T) {
t.Fatalf("root full path = %q, want empty", got)
}
}
// TestScanLocalMetaForUploadMultipleCopiesBestEffortMatch 验证:
// 当远端同一路径存在多个副本(1个与本地一致的副本 + 1个脏副本)时,
// 能够择优识别出匹配的副本,跳过上传(uploaded = 0),避免盲盒覆盖导致的重复上传。
func TestScanLocalMetaForUploadMultipleCopiesBestEffortMatch(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
nfoPath := filepath.Join(localDir, "test.nfo")
writeFile(t, nfoPath, "correct-content")
correctSha, err := cloud115.FileSHA1(nfoPath)
if err != nil {
t.Fatal(err)
}
p := &model.StrmSyncPath{
Base: model.Base{ID: "multi-copy-match-path"},
Provider: model.StrmProvider115,
RemotePath: "root-cid",
LocalPath: localDir,
UploadMeta: true,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 模拟远端存在两个同名副本:一个脏副本(较早),一个正确副本(较晚)
st.recordRemoteMeta(cloud.FileEntry{
ID: "stale-id",
Name: "test.nfo",
Size: int64(len("stale-dirty-content")),
Sha1: "STALE_SHA1",
MTime: 1000,
}, "test.nfo")
st.recordRemoteMeta(cloud.FileEntry{
ID: "correct-id",
Name: "test.nfo",
Size: int64(len("correct-content")),
Sha1: correctSha,
MTime: 2000,
}, "test.nfo")
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
}
// 择优匹配:命中 correct-id,不产生上传任务
tasks, _, err := svc.repo.StrmUpload.List(context.Background(), "", 1, 10)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 0 {
t.Fatalf("expected 0 upload tasks when a matching copy exists, got %d: %v", len(tasks), taskNames(tasks))
}
}
// TestScanLocalMetaForUploadMultipleCopiesAllStale 验证:
// 当远端同一路径存在多个副本,且所有副本均与本地内容不一致时,
// 上传任务应携带所有旧副本的 ID(逗号分隔),以便上传前批量清理所有旧副本。
func TestScanLocalMetaForUploadMultipleCopiesAllStale(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
nfoPath := filepath.Join(localDir, "test.nfo")
writeFile(t, nfoPath, "brand-new-content")
p := &model.StrmSyncPath{
Base: model.Base{ID: "multi-copy-stale-path"},
Provider: model.StrmProvider115,
RemotePath: "root-cid",
LocalPath: localDir,
UploadMeta: true,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 模拟远端存在两个不同大小和哈希的旧副本
st.recordRemoteMeta(cloud.FileEntry{
ID: "old-1",
Name: "test.nfo",
Size: 10,
Sha1: "OLD_SHA1",
MTime: 1000,
}, "test.nfo")
st.recordRemoteMeta(cloud.FileEntry{
ID: "old-2",
Name: "test.nfo",
Size: 20,
Sha1: "OLD_SHA2",
MTime: 2000,
}, "test.nfo")
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
}
tasks, _, err := svc.repo.StrmUpload.List(context.Background(), "", 1, 10)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 {
t.Fatalf("expected 1 upload task, got %d", len(tasks))
}
if tasks[0].RemoteRef != "old-1,old-2" {
t.Fatalf("expected RemoteRef to be 'old-1,old-2', got %q", tasks[0].RemoteRef)
}
}
// TestRecordRemoteMetaDeduplication 验证当 download_meta 与 upload_meta 同时开启时,
// 同一远端元数据被多次入账不会在 remoteMeta 中生成重复副本,杜绝误判自杀式删除。
func TestRecordRemoteMetaDeduplication(t *testing.T) {
st := &strmSyncState{
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
entry := cloud.FileEntry{
ID: "unique-fid-1",
Name: "test.nfo",
Size: 100,
Sha1: "AAAABBBBCCCC",
MTime: 12345,
}
// 连续记录两次同一文件
st.recordRemoteMeta(entry, "dir/test.nfo")
st.recordRemoteMeta(entry, "dir/test.nfo")
items := st.remoteMeta["m:dir/test.nfo"]
if len(items) != 1 {
t.Fatalf("expected 1 item after duplicate record, got %d", len(items))
}
}
// TestWalk115AdaptiveHierarchicalFlatScan 验证自适应分治扁平化扫描:
// 当根目录探测总数 >= 9500 时,系统自动分治展开单层直接子项,对各子目录分别执行扁平拉取,
// 正确合并根目录直属文件与各子目录深层文件,突破 115 开放平台 10000 深度限制。
func TestWalk115AdaptiveHierarchicalFlatScan(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
acct := &model.StrmAccount{Name: "fake115-adaptive", Provider: "cloud115", Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
p := &model.StrmSyncPath{
Base: model.Base{ID: "adaptive-path"},
AccountID: acct.ID,
Provider: model.StrmProvider115,
RemotePath: "0",
LocalPath: localDir,
}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
cid := q.Get("cid")
cur := q.Get("cur")
switch r.URL.Path {
case "/open/ufile/files":
if cid == "0" && cur == "0" {
// 根目录扁平探测:模拟总文件数 12000 超限 (>= 9500)
w.Write([]byte(`{"state":true,"count":12000,"data":[]}`))
return
}
if cid == "0" && cur == "1" {
// 根目录单层列举:返回 1 个直属视频和 2 个子目录
w.Write([]byte(`{"state":true,"count":3,"data":[
{"fid":"100","pid":"0","fc":"1","fn":"root.mkv","pc":"pcr","upt":1700000000,"fs":1024},
{"fid":"1001","pid":"0","fc":"0","fn":"Heyzo","upt":1700000001,"fs":0},
{"fid":"1002","pid":"0","fc":"0","fn":"S1","upt":1700000002,"fs":0}]}`))
return
}
if cid == "1001" {
// 子目录 Heyzo 扁平拉取:文件数安全 (< 9500)
w.Write([]byte(`{"state":true,"count":2,"data":[
{"fid":"201","pid":"1001","fc":"1","fn":"h1.mkv","pc":"pc201","upt":1700000001,"fs":1024},
{"fid":"202","pid":"1001","fc":"1","fn":"h2.mkv","pc":"pc202","upt":1700000002,"fs":2048}]}`))
return
}
if cid == "1002" {
// 子目录 S1 扁平拉取:文件数安全 (< 9500)
w.Write([]byte(`{"state":true,"count":1,"data":[
{"fid":"301","pid":"1002","fc":"1","fn":"s1.mkv","pc":"pc301","upt":1700000003,"fs":4096}]}`))
return
}
t.Errorf("unexpected files query: %s", r.URL.RawQuery)
case "/open/folder/get_info":
fileID := q.Get("file_id")
switch fileID {
case "1001":
w.Write([]byte(`{"state":true,"data":{"file_id":"1001","file_name":"Heyzo","file_category":"0",
"paths":[{"file_id":"0","file_name":"根目录"},{"file_id":"1001","file_name":"Heyzo"}]}}`))
case "1002":
w.Write([]byte(`{"state":true,"data":{"file_id":"1002","file_name":"S1","file_category":"0",
"paths":[{"file_id":"0","file_name":"根目录"},{"file_id":"1002","file_name":"S1"}]}}`))
default:
t.Errorf("unexpected get_info file_id: %s", fileID)
}
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
oc := cloud115.NewOpenClient("app", "at", "rt")
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
provider: cloud.NewOpenAPI115("app", "at", "rt"),
cfg: &strmPathConfig{VideoExt: []string{"mkv"}, MetaExt: []string{"nfo"}, AddPath: 1},
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
dirCache: sync.Map{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
if err := st.walk115Flat(oc); err != nil {
t.Fatalf("walk115Flat adaptive failed: %v", err)
}
// 1个根目录视频 + 2个Heyzo视频 + 1个S1视频 = 共4个视频成功生成 .strm
if st.rec.NewStrm != 4 {
t.Fatalf("expected 4 strm created, got %d", st.rec.NewStrm)
}
expectedFiles := []string{
filepath.Join(localDir, "root.strm"),
filepath.Join(localDir, "Heyzo", "h1.strm"),
filepath.Join(localDir, "Heyzo", "h2.strm"),
filepath.Join(localDir, "S1", "s1.strm"),
}
for _, f := range expectedFiles {
if _, err := os.Stat(f); err != nil {
t.Fatalf("expected strm %s to exist, err: %v", f, err)
}
}
}
// TestScanLocalMetaForUploadSkipsRecentDoneSameSize 验证近期已成功上传且大小未变时,
// 即使远端列表尚未反映,也不再入队,缩短「done 但列表滞后」窗口。
func TestScanLocalMetaForUploadSkipsRecentDoneSameSize(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
nfoPath := filepath.Join(localDir, "movie.nfo")
content := []byte("uploaded-recently")
writeFile(t, nfoPath, string(content))
p := &model.StrmSyncPath{
Base: model.Base{ID: "recent-done-skip"},
Provider: model.StrmProvider115,
RemotePath: "root-cid",
LocalPath: localDir,
UploadMeta: true,
}
now := time.Now()
doneTask := &model.StrmUploadTask{
SyncPathID: p.ID,
Provider: model.StrmProvider115,
FileName: "movie.nfo",
LocalPath: nfoPath,
RemotePath: "parent-cid",
RemoteRef: "new-file-id",
Size: int64(len(content)),
Status: model.StrmTaskDone,
FinishedAt: &now,
}
if err := svc.repo.StrmUpload.Create(context.Background(), doneTask); err != nil {
t.Fatal(err)
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{}, // 远端列表空 = 滞后
dirPathToID: map[string]string{"": "parent-cid"},
activeUploadPaths: map[string]bool{},
recentDoneUploadSizes: nil, // 让 scan 自行从 DB 加载
}
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
}
if st.rec.Uploaded != 0 {
t.Fatalf("recent done same-size should skip enqueue, uploaded=%d", st.rec.Uploaded)
}
if len(st.pendingUploads) != 0 {
t.Fatalf("expected no pending uploads, got %d", len(st.pendingUploads))
}
}
// TestScanLocalMetaForUploadRequeuesWhenRecentDoneSizeChanged 本地大小变化后不应被近期 done 窗口挡住。
func TestScanLocalMetaForUploadRequeuesWhenRecentDoneSizeChanged(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
nfoPath := filepath.Join(localDir, "movie.nfo")
writeFile(t, nfoPath, "new-longer-content-xxx")
p := &model.StrmSyncPath{
Base: model.Base{ID: "recent-done-resize"},
Provider: model.StrmProvider115,
RemotePath: "root-cid",
LocalPath: localDir,
UploadMeta: true,
}
now := time.Now()
doneTask := &model.StrmUploadTask{
SyncPathID: p.ID,
Provider: model.StrmProvider115,
FileName: "movie.nfo",
LocalPath: nfoPath,
RemotePath: "parent-cid",
Size: 3, // 旧大小
Status: model.StrmTaskDone,
FinishedAt: &now,
}
if err := svc.repo.StrmUpload.Create(context.Background(), doneTask); err != nil {
t.Fatal(err)
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
dirPathToID: map[string]string{"": "parent-cid"},
activeUploadPaths: map[string]bool{},
}
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
}
if st.rec.Uploaded != 1 {
t.Fatalf("size changed should re-enqueue, uploaded=%d", st.rec.Uploaded)
}
tasks, _, err := svc.repo.StrmUpload.List(context.Background(), model.StrmTaskPending, 1, 20)
if err != nil {
t.Fatal(err)
}
found := false
for _, task := range tasks {
if task.LocalPath == nfoPath && task.Status == model.StrmTaskPending {
found = true
break
}
}
if !found {
t.Fatal("expected a pending upload task for resized local file")
}
}
func TestHandleVideoPreferPicksLargestAndSkipsOthers(t *testing.T) {
svc := testStrmService(t)
local := t.TempDir()
p := syncPathRecord(t, svc, model.StrmProviderLocal, t.TempDir(), local, true)
p.KeepExt = false
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{BaseURL: "http://test.local:8096", VideoExt: csvSplit(StrmDefaultVideoExt), AddPath: 1, KeepExt: false},
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenVideoTarget: map[string]cloud.FileEntry{},
remoteVideos: map[string][]remoteVideoCandidate{},
}
st.handleVideo(cloud.FileEntry{ID: "1", Name: "竞女01.mp4", Size: 100, PickCode: "pc-mp4", MTime: 1000}, "竞女01.mp4", ".mp4")
st.handleVideo(cloud.FileEntry{ID: "2", Name: "竞女01.mkv", Size: 500, PickCode: "pc-mkv", MTime: 900}, "竞女01.mkv", ".mkv")
st.flushPreferredVideos()
preferPath := filepath.Join(local, "竞女01.strm")
if _, err := os.Stat(preferPath); err != nil {
t.Fatalf("expected prefer strm at %s: %v", preferPath, err)
}
data, err := os.ReadFile(preferPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, "video.mkv") {
t.Fatalf("prefer should pick larger mkv, content=%s", content)
}
if !strings.Contains(content, "%E7%AB%9E%E5%A5%B301.mkv") && !strings.Contains(content, "竞女01.mkv") {
t.Fatalf("prefer strm should point at winner path, content=%s", content)
}
if _, err := os.Stat(filepath.Join(local, "竞女01.mkv.strm")); !os.IsNotExist(err) {
t.Fatal("prefer mode must not write keep_ext names")
}
if st.rec.NewStrm != 1 {
t.Fatalf("NewStrm=%d want 1", st.rec.NewStrm)
}
if st.rec.Skipped < 1 {
t.Fatalf("Skipped=%d want >=1 for loser", st.rec.Skipped)
}
}
func TestHandleVideoKeepExtWritesAllVersions(t *testing.T) {
svc := testStrmService(t)
local := t.TempDir()
p := syncPathRecord(t, svc, model.StrmProviderLocal, t.TempDir(), local, true)
p.KeepExt = true
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{BaseURL: "http://test.local:8096", VideoExt: csvSplit(StrmDefaultVideoExt), AddPath: 1, KeepExt: true},
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenVideoTarget: map[string]cloud.FileEntry{},
remoteVideos: map[string][]remoteVideoCandidate{},
}
st.handleVideo(cloud.FileEntry{ID: "1", Name: "竞女01.mp4", Size: 100, PickCode: "pc-mp4"}, "竞女01.mp4", ".mp4")
st.handleVideo(cloud.FileEntry{ID: "2", Name: "竞女01.mkv", Size: 500, PickCode: "pc-mkv"}, "竞女01.mkv", ".mkv")
st.flushPreferredVideos()
mkvPath := filepath.Join(local, "竞女01.mkv.strm")
mp4Path := filepath.Join(local, "竞女01.mp4.strm")
for _, path := range []string{mkvPath, mp4Path} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected keep_ext strm %s: %v", path, err)
}
}
if _, err := os.Stat(filepath.Join(local, "竞女01.strm")); !os.IsNotExist(err) {
t.Fatal("keep_ext mode must not write stripped name.strm")
}
if st.rec.NewStrm != 2 {
t.Fatalf("NewStrm=%d want 2", st.rec.NewStrm)
}
}
+217 -20
View File
@@ -16,12 +16,15 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -29,14 +32,16 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// SubtitleService is the discovery + conversion entry point.
type SubtitleService struct {
log *zap.Logger
repo *repository.Container
cfg *config.Config
log *zap.Logger
repo *repository.Container
cfg *config.Config
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
// 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次
// os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。
@@ -61,11 +66,14 @@ func NewSubtitleService(cfg *config.Config, log *zap.Logger, repo *repository.Co
// SubtitleTrack describes one external subtitle file.
type SubtitleTrack struct {
Lang string `json:"lang"`
Label string `json:"label"`
Path string `json:"path"`
URL string `json:"url"`
Codec string `json:"codec"`
Lang string `json:"lang"`
Label string `json:"label"`
Path string `json:"path"`
URL string `json:"url"`
Codec string `json:"codec"`
Source string `json:"source"`
Delivery string `json:"delivery"`
StreamIndex int `json:"stream_index,omitempty"`
}
// extToCodec maps the file extension to the inner codec name.
@@ -86,7 +94,17 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
return s.discover(ctx, mediaID)
tracks, err := s.discover(ctx, mediaID)
if err != nil {
return nil, err
}
out := make([]SubtitleTrack, 0, len(tracks))
for _, track := range tracks {
if track.Source != "embedded" {
out = append(out, track)
}
}
return out, nil
}
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
@@ -142,7 +160,10 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
return nil, errors.New("media not found")
}
dir := filepath.Dir(m.Path)
base := strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
bases := mediaSidecarBaseVariants(m.Path)
if len(bases) == 0 {
bases = []string{strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))}
}
candidates := make([]string, 0, 16)
candidates = append(candidates, dir)
@@ -166,24 +187,171 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
continue
}
fullName := strings.TrimSuffix(e.Name(), ext)
if !strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) &&
c == dir {
// In the same directory we require a basename match;
// inside subs/ subdirs we accept anything.
continue
matchedBase := ""
if c == dir {
for _, base := range bases {
if strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) {
matchedBase = base
break
}
}
if matchedBase == "" {
// In the same directory we require a basename match;
// inside subs/ subdirs we accept anything.
continue
}
} else if len(bases) > 0 {
matchedBase = bases[0]
}
lang := detectLang(fullName, base)
lang := detectLang(fullName, matchedBase)
tracks = append(tracks, SubtitleTrack{
Lang: lang,
Label: lang,
Path: filepath.Join(c, e.Name()),
Codec: codec,
Lang: lang,
Label: lang,
Path: filepath.Join(c, e.Name()),
Codec: codec,
Source: "external",
Delivery: "webvtt",
})
}
}
embedded, err := s.discoverEmbedded(ctx, m)
if err != nil {
if s.log != nil {
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
}
} else {
tracks = append(tracks, embedded...)
}
return tracks, nil
}
type embeddedSubtitleProbe struct {
Streams []struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
} `json:"tags"`
Disposition struct {
Default int `json:"default"`
Forced int `json:"forced"`
} `json:"disposition"`
} `json:"streams"`
}
var imageSubtitleCodecs = map[string]bool{
"hdmv_pgs_subtitle": true,
"dvd_subtitle": true,
"dvb_subtitle": true,
"xsub": true,
}
func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Media) ([]SubtitleTrack, error) {
if s == nil || s.cfg == nil {
return nil, errors.New("subtitle probe unavailable")
}
input, err := s.resolveInput(ctx, media)
if err != nil {
return nil, err
}
bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
if err != nil {
return nil, err
}
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
args := []string{"-v", "error"}
if headers := ffmpegHeaderText(input.Headers); headers != "" {
args = append(args, "-headers", headers)
}
args = append(args,
"-select_streams", "s",
"-show_entries", "stream=index,codec_name:stream_tags=language,title:stream_disposition=default,forced",
"-of", "json", input.Source,
)
out, err := exec.CommandContext(probeCtx, bin, args...).Output() // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
if err != nil {
return nil, err
}
var probe embeddedSubtitleProbe
if err := json.Unmarshal(out, &probe); err != nil {
return nil, err
}
return subtitleTracksFromProbe(probe), nil
}
func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
tracks := make([]SubtitleTrack, 0, len(probe.Streams))
for _, stream := range probe.Streams {
codec := strings.ToLower(strings.TrimSpace(stream.CodecName))
lang := strings.ToLower(strings.TrimSpace(stream.Tags.Language))
if lang == "" {
lang = "und"
}
label := strings.TrimSpace(stream.Tags.Title)
if label == "" {
label = lang
}
if stream.Disposition.Forced != 0 {
label += "(强制)"
} else if stream.Disposition.Default != 0 {
label += "(默认)"
}
delivery := "webvtt"
if imageSubtitleCodecs[codec] {
delivery = "burn"
}
sourceLabel := "(内嵌)"
if delivery == "burn" {
sourceLabel = "(内嵌·图片)"
}
tracks = append(tracks, SubtitleTrack{
Lang: lang,
Label: label + sourceLabel,
Path: "embedded:" + strconv.Itoa(stream.Index),
Codec: codec,
Source: "embedded",
Delivery: delivery,
StreamIndex: stream.Index,
})
}
return tracks
}
func (s *SubtitleService) SetStrmPlayTargetResolver(resolve func(context.Context, string) (*StrmPlayResult, error)) {
if s != nil {
s.strmResolve = resolve
}
}
func (s *SubtitleService) resolveInput(ctx context.Context, media *model.Media) (transcodeInput, error) {
if media == nil {
return transcodeInput{}, ErrMediaNotFound
}
if !isStrmMediaRow(media) {
if _, err := os.Stat(media.Path); err != nil {
return transcodeInput{}, ErrMediaNotFound
}
return transcodeInput{Source: media.Path}, nil
}
raw := strings.TrimSpace(media.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(media.Path), ".strm") {
raw, _ = readLocalSTRMTarget(media.Path)
}
if s.strmResolve != nil {
resolved, err := s.strmResolve(ctx, raw)
if err != nil {
return transcodeInput{}, err
}
return transcodeInputFromPlayResult(resolved)
}
if isHTTPPlaybackTarget(raw) {
return transcodeInput{Source: raw}, nil
}
return transcodeInput{}, errors.New("subtitle source unavailable")
}
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
@@ -207,6 +375,13 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
if err != nil || m == nil {
return errors.New("media not found")
}
if strings.HasPrefix(sub, "embedded:") {
index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:"))
if err != nil || index < 0 {
return errors.New("invalid embedded subtitle")
}
return s.serveEmbedded(ctx, m, index, w)
}
abs, err := filepath.Abs(sub)
if err != nil {
return err
@@ -239,6 +414,28 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
return err
}
func (s *SubtitleService) serveEmbedded(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
input, err := s.resolveInput(ctx, media)
if err != nil {
return err
}
bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
if err != nil {
return err
}
args := []string{"-hide_banner", "-loglevel", "error"}
args = append(args, ffmpegHTTPInputArgs(input)...)
args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-f", "webvtt", "-")
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
cmd.Stdout = w
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("extract embedded subtitle: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
// ServeRaw writes the subtitle file in its original format without any
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
+20 -4
View File
@@ -27,6 +27,7 @@ func srtToVTT(body string) string {
func assToVTT(body string) string {
out := strings.Builder{}
out.WriteString("WEBVTT\n\n")
seen := make(map[string]struct{})
for i, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Dialogue:") {
@@ -36,11 +37,22 @@ func assToVTT(body string) string {
if len(parts) < 10 {
continue
}
start := normaliseTimecode(parts[1])
end := normaliseTimecode(parts[2])
text := stripASSTags(parts[9])
if text == "" {
continue
}
key := start + "\x00" + end + "\x00" + text
if _, duplicate := seen[key]; duplicate {
continue
}
seen[key] = struct{}{}
fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n",
i,
normaliseTimecode(parts[1]),
normaliseTimecode(parts[2]),
stripASSTags(parts[9]),
start,
end,
text,
)
}
return out.String()
@@ -86,5 +98,9 @@ func normaliseTimecode(t string) string {
var assTag = regexp.MustCompile(`\{[^}]*\}`)
func stripASSTags(s string) string {
return assTag.ReplaceAllString(s, "")
s = assTag.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, `\N`, "\n")
s = strings.ReplaceAll(s, `\n`, "\n")
s = strings.ReplaceAll(s, `\h`, "\u00a0")
return strings.TrimSpace(s)
}
+42
View File
@@ -2,8 +2,10 @@ package service
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
@@ -46,6 +48,27 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
}
}
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
var probe embeddedSubtitleProbe
raw := []byte(`{"streams":[
{"index":2,"codec_name":"ass","tags":{"language":"chi","title":"中文"},"disposition":{"default":1}},
{"index":4,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"eng"},"disposition":{"forced":1}}
]}`)
if err := json.Unmarshal(raw, &probe); err != nil {
t.Fatal(err)
}
tracks := subtitleTracksFromProbe(probe)
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
}
if tracks[0].Delivery != "webvtt" || tracks[0].Path != "embedded:2" {
t.Fatalf("text track = %#v", tracks[0])
}
if tracks[1].Delivery != "burn" || tracks[1].StreamIndex != 4 {
t.Fatalf("bitmap track = %#v", tracks[1])
}
}
func TestNormaliseTimecode(t *testing.T) {
cases := map[string]string{
"0:00:01": "00:00:01",
@@ -62,6 +85,25 @@ func TestNormaliseTimecode(t *testing.T) {
}
}
func TestAssToVTTDeduplicatesDialogueAndNormalisesLineBreaks(t *testing.T) {
body := strings.Join([]string{
`Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,{\an2}第一行\N第二行`,
`Dialogue: 1,0:00:01.00,0:00:02.00,Copy,,0,0,0,,{\bord2}第一行\N第二行`,
`Dialogue: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,{\i1}`,
}, "\n")
got := assToVTT(body)
if strings.Count(got, "第一行\n第二行") != 1 {
t.Fatalf("duplicate ASS dialogue was not collapsed:\n%s", got)
}
if strings.Contains(got, `\N`) || strings.Contains(got, `\an2`) {
t.Fatalf("ASS control sequences leaked into WebVTT:\n%s", got)
}
if strings.Contains(got, "00:00:03.000 --> 00:00:04.000") {
t.Fatalf("empty styled dialogue should be omitted:\n%s", got)
}
}
func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
+310 -28
View File
@@ -25,8 +25,10 @@ package service
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -34,6 +36,7 @@ import (
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -44,8 +47,13 @@ type TranscoderService struct {
repo *repository.Container
hub *Hub
mu sync.Mutex
jobs map[string]*hlsJob
mu sync.Mutex
jobs map[string]*hlsJob
// startGates serializes EnsureJobFrom / StopJob per media so concurrent
// playlist hits cannot spawn multiple ffmpeg writers into one HLS dir.
startGates sync.Map // mediaID -> *sync.Mutex
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
probe *FFprobeService
}
// hlsJob holds the live state of one ffmpeg run.
@@ -57,6 +65,17 @@ type hlsJob struct {
lastAccess time.Time
playlistOK bool
encoder string
// startSec is the source seek offset fed to ffmpeg (-ss). The HLS
// playlist itself always starts at t=0 for that session.
startSec float64
// seekGen is the client `_seek` token for this job. Newer gens win;
// older/missing gens must not cancel a mid-file restart.
seekGen int64
// subtitleStream is an absolute ffmpeg stream index to burn into the
// picture, or -1 when no bitmap subtitle is selected.
subtitleStream int
// done is closed when the ffmpeg goroutine fully exits (after process death).
done chan struct{}
}
var (
@@ -88,13 +107,35 @@ func (t *TranscoderService) PlaylistPath(mediaID string) string {
return filepath.Join(t.HLSDir(mediaID), "index.m3u8")
}
// EnsureJob makes sure a transcode is running for mediaID. The function is
// non-blocking: it returns the playlist path immediately. The caller is
// expected to poll until WaitReady reports true.
// EnsureJob makes sure a transcode is running for mediaID from the start of
// the source. Prefer EnsureJobFrom when the player seeks into the middle.
func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) {
return t.EnsureJobFrom(ctx, mediaID, 0, 0)
}
// EnsureJobFrom starts (or reuses) an HLS job that seeks the source to
// startSec before encoding. Reusing only happens when an active job already
// matches that offset; otherwise the previous job is cancelled and the HLS
// cache dir is wiped so the player can jump without waiting for a full
// head-to-tail transcode.
//
// seekGen is the client `_seek` query (unix ms). A newer gen replaces an older
// job; an older or missing gen must not clobber a mid-file restart — hls.js
// in-flight playlist refreshes from a destroyed player commonly arrive as
// start=0 right after a scrub and would otherwise reset playback to the head.
func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, startSec float64, seekGen int64) (string, error) {
return t.EnsureJobFromSubtitle(ctx, mediaID, startSec, seekGen, -1)
}
// EnsureJobFromSubtitle is EnsureJobFrom with an optional bitmap subtitle
// stream. Selecting or closing a burned subtitle creates a new HLS generation.
func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int) (string, error) {
if !t.cfg.Transcoder.Enabled {
return "", ErrTranscodeDisabled
}
if startSec < 0 {
startSec = 0
}
m, err := t.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return "", err
@@ -102,49 +143,290 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
if m == nil {
return "", ErrMediaNotFound
}
// .strm 媒体(STRMURL 或 container=strm / *.strm 路径)的内容是远程
// 直链文本,ffmpeg 无法读取,转码必然失败且白白消耗资源。直接拒绝
// 转码,迫使播放器走 /api/stream 302 直连播放。
if isStrmMediaRow(m) {
return "", ErrTranscodeDisabled
gate := t.mediaStartGate(mediaID)
gate.Lock()
defer gate.Unlock()
t.mu.Lock()
if existing, ok := t.jobs[mediaID]; ok {
if sameHLSConfiguration(existing, startSec, subtitleStream) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
prev := t.detachJobLocked(mediaID)
t.mu.Unlock()
waitJobExit(prev, 12*time.Second)
} else {
t.mu.Unlock()
}
if _, err := os.Stat(m.Path); err != nil {
return "", ErrMediaNotFound
input, err := t.resolveTranscodeInput(ctx, m)
if err != nil {
return "", err
}
input.StartSec = startSec
if subtitleStream >= 0 {
input.SubtitleStream = &subtitleStream
}
t.maybeFillDuration(ctx, m, input)
if _, err := t.resolveFFmpegPath(); err != nil {
return "", err
}
outDir := t.HLSDir(mediaID)
// Wipe prior segments so a mid-file restart cannot serve stale early chunks.
// Only safe after the previous ffmpeg has exited (waited above / via gate).
if err := resetHLSDir(outDir); err != nil {
return "", err
}
t.mu.Lock()
if _, ok := t.jobs[mediaID]; ok {
t.touchJobLocked(mediaID)
if existing, ok := t.jobs[mediaID]; ok {
if sameHLSConfiguration(existing, startSec, subtitleStream) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
prev := t.detachJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
waitJobExit(prev, 12*time.Second)
t.mu.Lock()
}
if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max {
t.mu.Unlock()
return "", ErrTranscodeBusy
}
outDir := t.HLSDir(mediaID)
if err := os.MkdirAll(outDir, 0o750); err != nil {
t.mu.Unlock()
return "", err
}
jobCtx, cancel := context.WithCancel(context.Background())
jobEncoder := t.effectiveEncoder()
if subtitleStream >= 0 {
jobEncoder = "software (subtitle burn-in)"
}
job := &hlsJob{
mediaID: mediaID,
outputDir: outDir,
cancel: cancel,
startedAt: time.Now(),
lastAccess: time.Now(),
encoder: t.effectiveEncoder(),
mediaID: mediaID,
outputDir: outDir,
cancel: cancel,
startedAt: time.Now(),
lastAccess: time.Now(),
encoder: jobEncoder,
startSec: startSec,
seekGen: seekGen,
subtitleStream: subtitleStream,
done: make(chan struct{}),
}
t.jobs[mediaID] = job
t.mu.Unlock()
helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) })
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, m.Path) })
helper.Go(t.log, "transcoder.ffmpeg", func() {
defer close(job.done)
t.runFFmpeg(jobCtx, job, input)
})
return t.PlaylistPath(mediaID), nil
}
// shouldReplaceHLSJob reports whether an incoming playlist request may cancel
// the running job. Stale hls.js refreshes (older/missing `_seek`) must not win.
func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool {
if existing == nil {
return true
}
if sameHLSStart(existing.startSec, startSec) {
return false
}
return newerHLSGenerationMayReplace(existing, seekGen)
}
func shouldReplaceHLSJobConfiguration(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int) bool {
if existing == nil {
return true
}
if sameHLSConfiguration(existing, startSec, subtitleStream) {
return false
}
return newerHLSGenerationMayReplace(existing, seekGen)
}
func newerHLSGenerationMayReplace(existing *hlsJob, seekGen int64) bool {
if seekGen > 0 && existing.seekGen > 0 && seekGen < existing.seekGen {
return false
}
// Untagged request while a seek-tagged job is active: treat as stale.
if seekGen == 0 && existing.seekGen > 0 {
return false
}
return true
}
func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int) bool {
return existing != nil &&
existing.subtitleStream == subtitleStream &&
sameHLSStart(existing.startSec, startSec)
}
func (t *TranscoderService) mediaStartGate(mediaID string) *sync.Mutex {
v, _ := t.startGates.LoadOrStore(mediaID, &sync.Mutex{})
return v.(*sync.Mutex)
}
// detachJobLocked cancels and removes a job from the map without waiting.
// Caller must hold t.mu and must waitJobExit afterwards before wiping the HLS dir.
func (t *TranscoderService) detachJobLocked(mediaID string) *hlsJob {
j, ok := t.jobs[mediaID]
if !ok {
return nil
}
j.cancel()
delete(t.jobs, mediaID)
return j
}
func waitJobExit(job *hlsJob, timeout time.Duration) {
if job == nil || job.done == nil {
return
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-job.done:
case <-timer.C:
}
}
func sameHLSStart(a, b float64) bool {
const tol = 0.75
if a < b {
return b-a < tol
}
return a-b < tol
}
func resetHLSDir(dir string) error {
var lastErr error
for i := 0; i < 6; i++ {
lastErr = os.RemoveAll(dir)
if lastErr == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
// Best-effort: if RemoveAll kept failing on Windows locks, at least drop the playlist
// so WaitReady cannot treat the stale file as belonging to the new job.
_ = os.Remove(filepath.Join(dir, "index.m3u8"))
return nil
}
// SetStrmPlayTargetResolver wires STRM URL resolution so ffmpeg can transcode
// remote .strm media (HTTP 直链 or local source path) after direct play fails.
func (t *TranscoderService) SetStrmPlayTargetResolver(resolve func(ctx context.Context, raw string) (*StrmPlayResult, error)) {
if t == nil {
return
}
t.strmResolve = resolve
}
// SetProbe wires ffprobe so STRM/HLS jobs can persist source duration when the
// media row still has duration_sec=0 (common for .strm that was never probed).
func (t *TranscoderService) SetProbe(probe *FFprobeService) {
if t == nil {
return
}
t.probe = probe
}
func (t *TranscoderService) maybeFillDuration(ctx context.Context, m *model.Media, input transcodeInput) {
if t == nil || t.probe == nil || m == nil || m.DurationSec > 0 || strings.TrimSpace(input.Source) == "" {
return
}
var (
res *ProbeResult
err error
)
if isHTTPSource(input.Source) {
res, err = t.probe.ProbeHTTP(ctx, input.Source, input.Headers)
} else {
res, err = t.probe.Probe(ctx, input.Source)
}
if err != nil || res == nil || res.DurationSec <= 0 {
return
}
m.DurationSec = res.DurationSec
if t.repo == nil || t.repo.DB == nil {
return
}
if err := t.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).Update("duration_sec", res.DurationSec).Error; err != nil && t.log != nil {
t.log.Debug("persist probed duration failed", zap.String("media_id", m.ID), zap.Error(err))
}
}
func (t *TranscoderService) resolveTranscodeInput(ctx context.Context, m *model.Media) (transcodeInput, error) {
if m == nil {
return transcodeInput{}, ErrMediaNotFound
}
if !isStrmMediaRow(m) {
if _, err := os.Stat(m.Path); err != nil {
return transcodeInput{}, ErrMediaNotFound
}
return transcodeInput{Source: m.Path}, nil
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
parsed, err := readLocalSTRMTarget(m.Path)
if err != nil || strings.TrimSpace(parsed) == "" {
return transcodeInput{}, fmt.Errorf("strm play target missing")
}
raw = parsed
}
if raw == "" {
return transcodeInput{}, fmt.Errorf("strm play target missing")
}
if t != nil && t.strmResolve != nil {
src, err := t.strmResolve(ctx, raw)
if err != nil {
return transcodeInput{}, err
}
return transcodeInputFromPlayResult(src)
}
if isHTTPPlaybackTarget(raw) {
return transcodeInput{Source: raw}, nil
}
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
}
func transcodeInputFromPlayResult(src *StrmPlayResult) (transcodeInput, error) {
if src == nil {
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
}
if path := strings.TrimSpace(src.LocalPath); path != "" {
if _, err := os.Stat(path); err != nil {
return transcodeInput{}, ErrMediaNotFound
}
return transcodeInput{Source: path}, nil
}
if url := strings.TrimSpace(src.RedirectURL); url != "" {
in := transcodeInput{Source: url}
if src.Link != nil {
in.Headers = src.Link.Headers
}
return in, nil
}
if src.Link != nil && strings.TrimSpace(src.Link.URL) != "" {
return transcodeInput{Source: src.Link.URL, Headers: src.Link.Headers}, nil
}
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
}
+14
View File
@@ -0,0 +1,14 @@
//go:build unix
package service
import (
"os/exec"
"syscall"
)
// setFFmpegSysProcAttr puts ffmpeg in its own process group so cancel can
// tear down the whole group (and any helpers) reliably on Linux/macOS.
func setFFmpegSysProcAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
@@ -0,0 +1,9 @@
//go:build windows
package service
import "os/exec"
func setFFmpegSysProcAttr(cmd *exec.Cmd) {
// Windows: CommandContext cancel is enough for the single ffmpeg process.
}
+89 -4
View File
@@ -2,11 +2,21 @@ package service
import (
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/config"
)
type transcodeInput struct {
Source string
Headers map[string]string
StartSec float64
SubtitleStream *int
}
type ffmpegArgSettings struct {
encoder string
bitrate string
@@ -31,11 +41,25 @@ type ffmpegVideoPlan struct {
// encoder. The function is package-level so the unit test can pin its
// behaviour without spawning a real ffmpeg process.
func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string {
return buildFFmpegArgsForInput(cfg, transcodeInput{Source: source}, playlist, segments)
}
func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string {
settings := ffmpegArgSettingsFromConfig(cfg)
video := ffmpegVideoPlanForSettings(settings)
if input.SubtitleStream != nil {
// Bitmap subtitles must be composited in software. Keeping CUDA/QSV/
// VAAPI frames here would require a download/upload filter chain that
// differs by driver and is considerably less portable.
settings.encoder = ""
video = ffmpegVideoPlanForSettings(settings)
}
args := baseFFmpegArgs(video.preInput, settings.realtime)
args = appendInputAndVideoArgs(args, source, settings, video)
// Mid-file restarts must not use -re: output -ss would otherwise crawl to the
// seek point at 1x wall-clock before emitting the first HLS segment.
realtime := settings.realtime && input.StartSec <= 0.05
args := baseFFmpegArgs(video.preInput, realtime)
args = appendInputAndVideoArgs(args, input, settings, video)
args = appendOutputHLSArgs(args, settings, segments, playlist)
return args
}
@@ -111,8 +135,29 @@ func baseFFmpegArgs(preInput string, realtime bool) []string {
return args
}
func appendInputAndVideoArgs(args []string, source string, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
args = append(args, "-i", source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
args = append(args, ffmpegHTTPInputArgs(input)...)
ss := ""
if input.StartSec > 0.05 {
ss = strconv.FormatFloat(input.StartSec, 'f', 3, 64)
}
// Always use input -ss (before -i). Output -ss on HTTP/WMV decodes from
// byte 0 up to the offset and cannot meet playlist WaitReady for deep
// scrubbing; CDNs with Range support jump via demuxer seek instead.
if ss != "" {
args = append(args, "-ss", ss)
}
args = append(args, "-i", input.Source)
if input.SubtitleStream != nil {
filter := fmt.Sprintf(
"[0:v:0][0:%d]overlay=0:0:eof_action=pass,scale=-2:min(%d\\,ih)[v]",
*input.SubtitleStream,
settings.height,
)
args = append(args, "-filter_complex", filter, "-map", "[v]", "-map", "0:a:0?", "-c:v", video.codec)
} else {
args = append(args, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
}
if settings.threads > 0 && video.codec == "libx264" {
args = append(args, "-threads", strconv.Itoa(settings.threads))
}
@@ -164,3 +209,43 @@ func splitNonEmptyArgs(s string) []string {
flush()
return out
}
func ffmpegHTTPInputArgs(input transcodeInput) []string {
if !isHTTPSource(input.Source) {
return nil
}
args := []string{"-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2"}
if len(input.Headers) == 0 {
return args
}
keys := make([]string, 0, len(input.Headers))
for key := range input.Headers {
keys = append(keys, key)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
value := strings.TrimSpace(input.Headers[key])
if strings.TrimSpace(key) == "" || value == "" {
continue
}
lines = append(lines, key+": "+value)
}
if len(lines) == 0 {
return args
}
return append(args, "-headers", strings.Join(lines, "\r\n")+"\r\n")
}
func isHTTPSource(source string) bool {
u, err := url.Parse(strings.TrimSpace(source))
if err != nil || u == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
case "http", "https":
return true
default:
return false
}
}
+14 -7
View File
@@ -13,7 +13,7 @@ import (
"go.uber.org/zap"
)
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source string) {
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input transcodeInput) {
bin, err := t.resolveFFmpegPath()
if err != nil {
t.log.Warn("ffmpeg unavailable", zap.String("media_id", job.mediaID), zap.Error(err))
@@ -31,31 +31,38 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
playlist := filepath.Join(job.outputDir, "index.m3u8")
segments := filepath.Join(job.outputDir, "seg_%05d.ts")
args := buildFFmpegArgs(t.cfg, source, playlist, segments)
args := buildFFmpegArgsForInput(t.cfg, input, playlist, segments)
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- bin is resolved by resolveFFmpegPath and args are passed without a shell.
setFFmpegSysProcAttr(cmd)
cmd.Stderr = os.Stderr
t.log.Info("transcode started",
zap.String("media_id", job.mediaID),
zap.String("encoder", job.encoder),
zap.String("source", source),
zap.String("source", input.Source),
zap.Float64("start_sec", input.StartSec),
)
t.hub.Publish("transcode", map[string]any{
"media_id": job.mediaID,
"encoder": job.encoder,
"status": "started",
"media_id": job.mediaID,
"encoder": job.encoder,
"status": "started",
"start_sec": input.StartSec,
})
if err := cmd.Run(); err != nil && !errors.Is(ctx.Err(), context.Canceled) {
t.log.Warn("ffmpeg exited",
zap.String("media_id", job.mediaID),
zap.Float64("start_sec", input.StartSec),
zap.Error(err),
)
}
t.mu.Lock()
delete(t.jobs, job.mediaID)
// Only drop the map entry if we are still the registered generation.
if cur, ok := t.jobs[job.mediaID]; ok && cur == job {
delete(t.jobs, job.mediaID)
}
t.mu.Unlock()
t.hub.Publish("transcode", map[string]any{
+37 -17
View File
@@ -9,17 +9,30 @@ import (
)
// WaitReady blocks (with a deadline) until the playlist file shows up on
// disk. Returns true on success.
// disk for the *current* job generation. Stale playlists left behind by a
// failed RemoveAll / still-exiting ffmpeg must not unblock a mid-file restart.
func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
if _, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
t.mu.Lock()
if j, ok := t.jobs[mediaID]; ok {
j.playlistOK = true
t.mu.Lock()
job, ok := t.jobs[mediaID]
var started time.Time
if ok {
started = job.startedAt
}
t.mu.Unlock()
if ok {
if info, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
// Allow a small clock skew; reject anything older than this job.
if !info.ModTime().Before(started.Add(-2 * time.Second)) {
t.mu.Lock()
if j, exists := t.jobs[mediaID]; exists {
j.playlistOK = true
}
t.mu.Unlock()
return true
}
}
t.mu.Unlock()
return true
}
if time.Now().After(deadline) || ctx.Err() != nil {
return false
@@ -32,14 +45,18 @@ func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeo
}
}
// StopJob cancels a running ffmpeg process for mediaID, if any.
// StopJob cancels a running ffmpeg process for mediaID, if any, and waits
// briefly for it to exit so a subsequent EnsureJobFrom cannot race-write the
// same HLS directory.
func (t *TranscoderService) StopJob(mediaID string) {
gate := t.mediaStartGate(mediaID)
gate.Lock()
defer gate.Unlock()
t.mu.Lock()
defer t.mu.Unlock()
if j, ok := t.jobs[mediaID]; ok {
j.cancel()
delete(t.jobs, mediaID)
}
prev := t.detachJobLocked(mediaID)
t.mu.Unlock()
waitJobExit(prev, 12*time.Second)
}
// TouchJob records client activity for the HLS playlist or segment. The idle
@@ -60,10 +77,13 @@ func (t *TranscoderService) touchJobLocked(mediaID string) {
// StopAll terminates every running transcode (called on graceful shutdown).
func (t *TranscoderService) StopAll() {
t.mu.Lock()
defer t.mu.Unlock()
for id, j := range t.jobs {
j.cancel()
delete(t.jobs, id)
pending := make([]*hlsJob, 0, len(t.jobs))
for id := range t.jobs {
pending = append(pending, t.detachJobLocked(id))
}
t.mu.Unlock()
for _, j := range pending {
waitJobExit(j, 5*time.Second)
}
}
+224
View File
@@ -1,10 +1,13 @@
package service
import (
"context"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
)
func TestBuildFFmpegArgs(t *testing.T) {
@@ -107,3 +110,224 @@ func TestHasFFmpegListEntry(t *testing.T) {
t.Fatal("must match whole ffmpeg list entries only")
}
}
func TestResolveTranscodeInputHTTPSTRM(t *testing.T) {
svc := &TranscoderService{}
got, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
Container: "strm",
STRMURL: "https://cdn.example.com/a.wmv",
})
if err != nil {
t.Fatal(err)
}
if got.Source != "https://cdn.example.com/a.wmv" {
t.Fatalf("source = %q", got.Source)
}
}
func TestResolveTranscodeInputUsesResolver(t *testing.T) {
svc := &TranscoderService{}
svc.SetStrmPlayTargetResolver(func(_ context.Context, raw string) (*StrmPlayResult, error) {
if raw != "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x" {
t.Fatalf("raw = %q", raw)
}
return &StrmPlayResult{
RedirectURL: "https://cdn.example.com/a.wmv",
Link: &cloud.DirectLink{
URL: "https://cdn.example.com/a.wmv",
Headers: map[string]string{"User-Agent": "Mozilla/5.0"},
},
}, nil
})
got, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
Container: "strm",
STRMURL: "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x",
})
if err != nil {
t.Fatal(err)
}
if got.Source != "https://cdn.example.com/a.wmv" {
t.Fatalf("source = %q", got.Source)
}
if got.Headers["User-Agent"] != "Mozilla/5.0" {
t.Fatalf("headers = %#v", got.Headers)
}
}
func TestResolveTranscodeInputRejectsUnresolvedRelativeSTRM(t *testing.T) {
svc := &TranscoderService{}
_, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
Container: "strm",
STRMURL: "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x",
})
if err == nil {
t.Fatal("expected unresolved relative strm to fail")
}
}
func TestBuildFFmpegArgsHTTPInputReconnect(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "https://cdn.example.com/a.wmv",
Headers: map[string]string{"User-Agent": "MeBox", "Referer": "https://cdn.example.com/"},
}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-reconnect") || !strings.Contains(joined, "-headers") {
t.Fatalf("expected http reconnect/headers, got: %s", joined)
}
if !strings.Contains(joined, "User-Agent: MeBox") || !strings.Contains(joined, "Referer: https://cdn.example.com/") {
t.Fatalf("expected request headers, got: %s", joined)
}
idxI, idxH := -1, -1
for i, arg := range args {
if arg == "-i" && idxI < 0 {
idxI = i
}
if arg == "-headers" {
idxH = i
}
}
if idxI < 0 || idxH < 0 || idxH > idxI {
t.Fatalf("http flags must come before -i, args=%v", args)
}
}
func TestBuildFFmpegArgsInputSeekBeforeDashI(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "/x.mkv",
StartSec: 125.5,
}, "/o/x.m3u8", "/o/seg_%05d.ts")
idxSS, idxI := -1, -1
for i, arg := range args {
if arg == "-ss" {
idxSS = i
}
if arg == "-i" && idxI < 0 {
idxI = i
}
}
if idxSS < 0 || idxI < 0 || idxSS > idxI {
t.Fatalf("expected local -ss before -i, args=%v", args)
}
if args[idxSS+1] != "125.500" {
t.Fatalf("start = %q", args[idxSS+1])
}
}
func TestBuildFFmpegArgsHTTPSeekAfterDashI(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
cfg.Transcoder.Realtime = true
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "https://cdn.example.com/a.wmv",
StartSec: 90,
}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined := " " + strings.Join(args, " ") + " "
if strings.Contains(joined, " -re ") {
t.Fatalf("seek restart must disable -re, got: %s", joined)
}
idxSS, idxI, ssCount := -1, -1, 0
for i, arg := range args {
if arg == "-ss" {
ssCount++
if idxSS < 0 {
idxSS = i
}
}
if arg == "-i" && idxI < 0 {
idxI = i
}
}
if idxSS < 0 || idxI < 0 || idxSS > idxI {
t.Fatalf("expected http -ss before -i, args=%v", args)
}
if ssCount != 1 {
t.Fatalf("expected a single -ss, got %d in %v", ssCount, args)
}
if args[idxSS+1] != "90.000" {
t.Fatalf("start = %q", args[idxSS+1])
}
}
func TestBuildFFmpegArgsBurnsBitmapSubtitleInSoftware(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.HardwareAccel = true
cfg.Transcoder.Encoder = "nvenc"
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
stream := 3
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "/x.mkv",
SubtitleStream: &stream,
}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined := strings.Join(args, " ")
if !strings.Contains(joined, "[0:v:0][0:3]overlay=0:0:eof_action=pass") {
t.Fatalf("bitmap subtitle overlay missing: %s", joined)
}
if !strings.Contains(joined, "-map [v]") || !strings.Contains(joined, "-c:v libx264") {
t.Fatalf("burn-in should use the filtered software video stream: %s", joined)
}
if strings.Contains(joined, "cuda") || strings.Contains(joined, "h264_nvenc") {
t.Fatalf("burn-in must not retain hardware-only frames: %s", joined)
}
}
func TestSameHLSStart(t *testing.T) {
if !sameHLSStart(10, 10.2) {
t.Fatal("expected close starts to match")
}
if sameHLSStart(10, 12) {
t.Fatal("expected distant starts to differ")
}
}
func TestShouldReplaceHLSJob(t *testing.T) {
existing := &hlsJob{startSec: 120, seekGen: 1000}
if shouldReplaceHLSJob(existing, 0, 0) {
t.Fatal("untagged start=0 must not clobber seek-tagged job")
}
if shouldReplaceHLSJob(existing, 0, 900) {
t.Fatal("older _seek must not clobber newer job")
}
if !shouldReplaceHLSJob(existing, 200, 1001) {
t.Fatal("newer _seek should replace")
}
if !shouldReplaceHLSJob(&hlsJob{startSec: 0, seekGen: 0}, 120, 1000) {
t.Fatal("seek should replace untagged head job")
}
if shouldReplaceHLSJob(existing, 120.2, 1001) {
t.Fatal("same start should not replace")
}
}
func TestBitmapSubtitleChangeReplacesHLSJob(t *testing.T) {
existing := &hlsJob{startSec: 120, seekGen: 1000, subtitleStream: 2}
if !shouldReplaceHLSJobConfiguration(existing, 120, 1001, 3) {
t.Fatal("changing bitmap subtitle must replace the HLS generation")
}
if !shouldReplaceHLSJobConfiguration(existing, 120, 1001, -1) {
t.Fatal("closing bitmap subtitle must replace the HLS generation")
}
if shouldReplaceHLSJobConfiguration(existing, 120.2, 1001, 2) {
t.Fatal("same subtitle and nearby start should reuse the HLS generation")
}
}
func TestFilterHLSSegmentQueryDropsStart(t *testing.T) {
got := filterHLSSegmentQuery("token=abc&start=120.5&_seek=1001&profile_id=1")
if strings.Contains(got, "start=") {
t.Fatalf("start should be stripped, got %q", got)
}
if !strings.Contains(got, "token=abc") || !strings.Contains(got, "profile_id=1") {
t.Fatalf("auth/profile query should remain, got %q", got)
}
if !strings.Contains(got, "_seek=1001") {
t.Fatalf("_seek must remain to isolate cached segment generations, got %q", got)
}
}
+3
View File
@@ -19,4 +19,7 @@ export const authAPI = {
api
.post('/me/password', { old_password: oldPassword, new_password: newPassword })
.then((r) => r.data),
createTemporaryPassword: () =>
api.post<{ code: string; expires_in: number }>('/me/temporary-password').then((r) => r.data),
}
+26 -2
View File
@@ -146,8 +146,32 @@ export function streamURL(mediaId: string): string {
}
// hlsURL returns the m3u8 playlist URL fed into hls.js.
export function hlsURL(mediaId: string): string {
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}`
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number): string {
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
// Always send start= (including 0) so the server can tell an intentional
// restart-from-head apart from a missing query on a stale refresh.
const start = `&start=${encodeURIComponent(String(safeStart))}`
// Monotonic-ish client generation: newer seeks win; older in-flight playlist
// requests must not cancel the active ffmpeg job back to t=0.
const bust = `&_seek=${Date.now()}`
const subtitle =
subtitleStream !== undefined && subtitleStream >= 0
? `&subtitle=${encodeURIComponent(String(subtitleStream))}`
: ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${bust}`
}
// Stop an on-demand HLS job. keepalive makes the request survive page
// navigation/tab close, where an axios promise can be discarded by browsers.
export function stopHLSJob(mediaId: string): void {
const url = `/api/hls/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
void fetch(url, {
method: 'DELETE',
credentials: 'same-origin',
keepalive: true,
cache: 'no-store',
}).catch(() => undefined)
}
// imageURL converts a remote poster URL into a same-origin proxy URL so it
+3
View File
@@ -7,6 +7,9 @@ export interface SubtitleTrack {
path: string
url: string
codec: string
source: 'external' | 'embedded'
delivery: 'webvtt' | 'burn'
stream_index?: number
}
export const subtitlesAPI = {
+5 -2
View File
@@ -28,6 +28,8 @@ type DanmakuStageProps = {
search?: string | null
/** Explicit danmaku library chosen by the user; null = auto-resolve. */
episodeId?: number | string | null
/** Counter or token changed to trigger refetch even when search stays identical. */
searchTrigger?: number
/** Called after each fetch attempt (success or error) finishes with metadata. */
onLoaded?: (info: DanmakuLoadedInfo | null) => void
/** Called when multiple anime matched and the user must pick one. */
@@ -50,6 +52,7 @@ export function DanmakuStage({
area = 1,
search = null,
episodeId = null,
searchTrigger = 0,
onLoaded,
onCandidates,
}: DanmakuStageProps) {
@@ -252,8 +255,8 @@ export function DanmakuStage({
manager.unmount()
managerRef.current = null
}
// search / episodeId 变化时重新拉取弹幕(含媒体/开关切换)。
}, [media, videoRef, enabled, search, episodeId, onLoaded, onCandidates])
// search / episodeId / searchTrigger 变化时重新拉取弹幕(含媒体/开关切换)。
}, [media, videoRef, enabled, search, episodeId, searchTrigger, onLoaded, onCandidates])
// Live renderer knobs: opacity / area / font size without recreating the
// engine. font size additionally rescales currently visible comments.
+1 -1
View File
@@ -137,7 +137,7 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
}
return (
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto [overflow-anchor:none] px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<div className="max-w-7xl mx-auto">
<AnimatePresence mode="wait">
<motion.div
+20 -6
View File
@@ -2,11 +2,12 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNo
import { createPortal } from 'react-dom'
import { Link, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog } from 'lucide-react'
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, Tv, UserCog } from 'lucide-react'
import clsx from 'clsx'
import type { PlayProfile } from '../types'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { TemporaryPasswordDialog } from './TemporaryPasswordDialog'
import type { ThemeMode } from './useThemeMode'
type MenuPosition = {
@@ -52,6 +53,7 @@ export function LayoutUserMenu({
const triggerRef = useRef<HTMLButtonElement>(null)
const onCloseRef = useRef(onClose)
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)
const [isOtpOpen, setIsOtpOpen] = useState(false)
onCloseRef.current = onClose
@@ -121,11 +123,22 @@ export function LayoutUserMenu({
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
onPointerDown={(event) => event.stopPropagation()}
>
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onNavigate={onClose} />
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onNavigate={onClose} />
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onNavigate={onClose} />
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onNavigate={onClose} />
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onNavigate={onClose} />
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onNavigate={onClose} />
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onNavigate={onClose} />
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onNavigate={onClose} />
<button
type="button"
onClick={() => {
onClose()
setIsOtpOpen(true)
}}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
<Tv size={16} />
<span>电视端临时登录码</span>
</button>
{themeMode && onThemeChange ? (
<div className="px-3 py-2 sm:hidden">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
@@ -203,6 +216,7 @@ export function LayoutUserMenu({
<ChevronDown size={14} className="text-[var(--app-muted)]" />
</button>
{menuPortal}
<TemporaryPasswordDialog isOpen={isOtpOpen} onClose={() => setIsOtpOpen(false)} />
</div>
)
}
@@ -0,0 +1,72 @@
import { Layers } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { Media } from '../types'
import { mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
type MediaVersionSwitcherProps = {
media: Media
/** 详情页:用 Link 跳转播放;播放页:回调切换 */
mode?: 'detail' | 'player'
onSelect?: (version: Media) => void
className?: string
}
export function MediaVersionSwitcher({
media,
mode = 'detail',
onSelect,
className = '',
}: MediaVersionSwitcherProps) {
const versions = mediaVersionsOf(media)
if (versions.length <= 1) return null
const dark = mode === 'player'
return (
<div className={`space-y-2 ${className}`.trim()}>
<div className={`flex items-center gap-2 text-sm font-semibold ${dark ? 'text-white/90' : 'text-ink-600'}`}>
<Layers size={14} className={dark ? 'text-white/60' : 'text-sand-500'} />
<span>版本({versions.length})</span>
</div>
<div className="flex flex-wrap gap-2">
{versions.map((version) => {
const active = version.id === media.id
const label = mediaVersionLabel(version)
const className = dark
? active
? 'border-white/40 bg-white/20 text-white'
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15'
: active
? 'border-brand-500/40 bg-brand-50 text-[#b07d35]'
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
if (mode === 'player') {
return (
<button
key={version.id}
type="button"
disabled={active}
onClick={() => onSelect?.(version)}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${className}`}
title={version.path}
>
{label}
</button>
)
}
return (
<Link
key={version.id}
to={`/play/${version.id}`}
state={{ from: `/media/${media.id}` }}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${className}`}
title={version.path}
>
{label}
{active ? ' · 当前' : ''}
</Link>
)
})}
</div>
</div>
)
}
+64 -22
View File
@@ -30,6 +30,8 @@ function formatTime(s: number): string {
type PlayerControlsProps = {
videoRef: React.RefObject<HTMLVideoElement>
uiVisible: boolean
onUiVisibleChange: (visible: boolean) => void
subs: SubtitleTrack[]
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
subtitleIndex: number
@@ -46,10 +48,18 @@ type PlayerControlsProps = {
playlistOpen?: boolean
hasPlaylist?: boolean
onTogglePlaylist?: () => void
/** Media metadata duration (seconds). Used when HLS only knows transcoded length. */
knownDuration?: number
/** Absolute source offset of the current HLS session (seconds). */
streamOffset?: number
/** Absolute seek on the full timeline; return true when handled (e.g. HLS restart). */
onSeekAbsolute?: (seconds: number) => boolean
}
export function PlayerControls({
videoRef,
uiVisible,
onUiVisibleChange,
subs,
subtitleIndex,
onSelectSubtitle,
@@ -65,6 +75,9 @@ export function PlayerControls({
playlistOpen = false,
hasPlaylist = false,
onTogglePlaylist,
knownDuration = 0,
streamOffset = 0,
onSeekAbsolute,
}: PlayerControlsProps) {
const video = () => videoRef.current
const container = () =>
@@ -79,7 +92,6 @@ export function PlayerControls({
const [muted, setMuted] = useState(false)
const [fullscreen, setFullscreen] = useState(false)
const [pip, setPip] = useState(false)
const [uiVisible, setUiVisible] = useState(true)
const [controlsHovered, setControlsHovered] = useState(false)
const [isScrubbing, setIsScrubbing] = useState(false)
const [scrubValue, setScrubValue] = useState<number | null>(null)
@@ -91,6 +103,7 @@ export function PlayerControls({
const subtitleMenuOpenRef = useRef(false)
const danmakuOpenRef = useRef(false)
const playlistOpenRef = useRef(false)
const pendingSeekRef = useRef<number | null>(null)
useEffect(() => {
controlsHoveredRef.current = controlsHovered
@@ -149,14 +162,14 @@ export function PlayerControls({
!danmakuOpenRef.current &&
!playlistOpenRef.current
) {
setUiVisible(false)
onUiVisibleChange(false)
}
}, 3000)
}
}
const onMove = () => {
setUiVisible(true)
onUiVisibleChange(true)
resetTimer()
}
@@ -167,7 +180,7 @@ export function PlayerControls({
}
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
setUiVisible(false)
onUiVisibleChange(false)
}
const syncPlay = () => {
@@ -175,15 +188,28 @@ export function PlayerControls({
onMove()
}
const syncTime = () => {
if (!isScrubbingRef.current) {
setCurrentTime(el.currentTime)
if (isScrubbingRef.current) return
if (pendingSeekRef.current !== null) {
const curAbs = streamOffset + el.currentTime
if (Math.abs(curAbs - pendingSeekRef.current) < 3 && el.currentTime > 0.1) {
pendingSeekRef.current = null
setCurrentTime(curAbs)
} else {
setCurrentTime(pendingSeekRef.current)
}
return
}
setCurrentTime(streamOffset + el.currentTime)
}
const syncMeta = () => {
setDuration(el.duration || 0)
if (!isScrubbingRef.current) {
setCurrentTime(el.currentTime)
const streamDur = Number.isFinite(el.duration) ? el.duration : 0
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
if (isScrubbingRef.current) return
if (pendingSeekRef.current !== null) {
setCurrentTime(pendingSeekRef.current)
return
}
setCurrentTime(streamOffset + el.currentTime)
}
const syncVolume = () => {
setVolume(el.volume)
@@ -225,21 +251,28 @@ export function PlayerControls({
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoRef])
}, [videoRef, knownDuration, streamOffset, onUiVisibleChange])
// Keep the scrubber max in sync when metadata duration arrives after mount.
useEffect(() => {
const el = video()
const streamDur = el && Number.isFinite(el.duration) ? el.duration : 0
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
}, [knownDuration, streamOffset])
// 当悬停或菜单状态改变时,更新控制栏计时器
useEffect(() => {
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
setUiVisible(true)
onUiVisibleChange(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
} else {
const el = video()
if (el && !el.paused) {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
}
}
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen])
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange])
const togglePlay = () => {
const el = video()
@@ -248,27 +281,35 @@ export function PlayerControls({
else el.pause()
}
const applyAbsoluteSeek = (absolute: number) => {
const el = video()
if (!el) return
if (onSeekAbsolute?.(absolute)) {
pendingSeekRef.current = absolute
setCurrentTime(absolute)
return
}
const local = Math.max(0, absolute - streamOffset)
el.currentTime = local
setCurrentTime(streamOffset + local)
}
const handleSeekChange = (v: number) => {
setScrubValue(v)
setCurrentTime(v)
const el = video()
if (el && !isScrubbing) {
el.currentTime = v
if (!isScrubbing) {
applyAbsoluteSeek(v)
}
}
const handleSeekStart = () => {
setIsScrubbing(true)
setUiVisible(true)
onUiVisibleChange(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
}
const handleSeekEnd = (v: number) => {
const el = video()
if (el) {
el.currentTime = v
setCurrentTime(v)
}
applyAbsoluteSeek(v)
setIsScrubbing(false)
setScrubValue(null)
}
@@ -323,6 +364,7 @@ export function PlayerControls({
onMouseEnter={() => setControlsHovered(true)}
onMouseLeave={() => setControlsHovered(false)}
onClick={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
>
<div className="flex flex-wrap items-center gap-2 text-white sm:flex-nowrap sm:gap-2.5">
{/* 上一集 */}
+18 -3
View File
@@ -25,6 +25,8 @@ type PlayerDanmakuPanelProps = {
candidates: DanmakuAnime[]
/** Human-readable label of the currently selected library. */
selectedSource?: string
/** Title used by auto-matching (e.g. anime title, media title or filename). */
autoMatchTitle?: string
/** Loaded danmaku metadata (title, episode, count, match mode). */
danmakuInfo?: DanmakuLoadedInfo | null
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
@@ -47,6 +49,7 @@ export function PlayerDanmakuPanel({
onFontSizeChange,
candidates,
selectedSource,
autoMatchTitle,
danmakuInfo,
onSelectEpisode,
onResetAuto,
@@ -203,7 +206,19 @@ export function PlayerDanmakuPanel({
{/* 搜索弹幕 */}
<div className="mb-4">
<div className="mb-1 text-xs text-white/60">搜索弹幕(留空 = 按视频名自动匹配)</div>
<div className="mb-1 flex items-center justify-between text-xs text-white/60">
<span>搜索弹幕(留空 = 按视频名自动匹配)</span>
{autoMatchTitle && (
<button
type="button"
onClick={() => setDraft(autoMatchTitle)}
className="text-[11px] text-rose-300 transition hover:text-rose-200"
title="填入当前识别到的视频名"
>
填入当前名
</button>
)}
</div>
<div className="flex items-center gap-1.5">
<input
value={draft}
@@ -211,8 +226,8 @@ export function PlayerDanmakuPanel({
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch(draft.trim())
}}
placeholder="输入番剧或电影名…"
className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs outline-none placeholder:text-white/35 focus:border-rose-400/60"
placeholder={autoMatchTitle ? `自动匹配:${autoMatchTitle}` : '输入番剧或电影名…'}
className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs outline-none placeholder:text-white/40 focus:border-rose-400/60"
/>
<button
onClick={() => onSearch(draft.trim())}
@@ -0,0 +1,191 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, Copy, Loader2, RefreshCw, Tv, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { authAPI } from '../api/auth'
import { useAuthStore } from '../stores/auth'
export function TemporaryPasswordDialog({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
const user = useAuthStore((s) => s.user)
const [code, setCode] = useState('')
const [expiresIn, setExpiresIn] = useState(0)
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const fetchCode = async () => {
setLoading(true)
try {
const res = await authAPI.createTemporaryPassword()
setCode(res.code)
setExpiresIn(res.expires_in)
setCopied(false)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'获取临时密码失败'
toast.error(msg)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isOpen) {
fetchCode()
} else {
setCode('')
setExpiresIn(0)
}
}, [isOpen])
useEffect(() => {
if (expiresIn <= 0) return
const timer = setInterval(() => {
setExpiresIn((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [expiresIn])
const copyCode = async () => {
if (!code) return
try {
await navigator.clipboard.writeText(code)
setCopied(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error('复制失败,请手动长按复制')
}
}
if (!isOpen || typeof document === 'undefined') return null
return createPortal(
<div
className="fixed inset-0 z-[999] flex items-center justify-center bg-black/65 p-3 sm:p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="w-full max-w-[calc(100vw-2rem)] sm:max-w-md max-h-[90dvh] flex flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4 sm:p-6 shadow-2xl transition-all"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-3 sm:pb-4 shrink-0">
<div className="flex items-center gap-2.5 sm:gap-3 min-w-0">
<div className="flex h-9 w-9 sm:h-10 sm:w-10 shrink-0 items-center justify-center rounded-2xl bg-brand-500/10 text-brand-500">
<Tv size={20} className="sm:w-[22px] sm:h-[22px]" />
</div>
<div className="min-w-0">
<h3 className="font-display text-base sm:text-lg font-bold text-[var(--app-text)] truncate">
电视端临时登录码 (OTP)
</h3>
<p className="text-[11px] sm:text-xs text-[var(--app-muted)] truncate">
适用于 Emby / Jellyfin / Infuse 客户端快速免密登录
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] shrink-0 ml-2"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="my-4 space-y-3.5 sm:space-y-4 overflow-y-auto flex-1 pr-0.5">
<div className="flex items-center justify-between text-[11px] sm:text-xs text-[var(--app-muted)]">
<div>
<span>登录账号:</span>
<span className="font-mono font-bold text-[var(--app-text)]">{user?.username}</span>
</div>
{expiresIn > 0 ? (
<div>
<span>有效时间剩余:</span>
<span className={`font-mono font-bold ${expiresIn < 60 ? 'text-red-500' : 'text-brand-500'}`}>
{Math.floor(expiresIn / 60).toString().padStart(2, '0')}:{(expiresIn % 60).toString().padStart(2, '0')}
</span>
</div>
) : null}
</div>
<div className="flex items-center justify-between rounded-2xl border border-[var(--app-border)] bg-[var(--app-bg)] p-3 sm:p-4">
{loading ? (
<div className="flex h-10 w-full items-center justify-center text-[var(--app-muted)]">
<Loader2 size={24} className="animate-spin text-brand-500" />
</div>
) : code && expiresIn > 0 ? (
<>
<div className="font-mono text-2xl sm:text-3xl font-black tracking-widest text-brand-500 select-all">
{code}
</div>
<button
type="button"
onClick={copyCode}
className="flex items-center gap-1.5 rounded-xl bg-brand-500/10 px-3 sm:px-3.5 py-1.5 sm:py-2 text-xs font-semibold text-brand-500 transition-colors hover:bg-brand-500/20 shrink-0"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? '已复制' : '复制密码'}
</button>
</>
) : (
<div className="flex w-full items-center justify-between text-xs sm:text-sm text-[var(--app-muted)]">
<span>登录码已失效或未生成</span>
<button
type="button"
onClick={fetchCode}
className="flex items-center gap-1.5 rounded-xl bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white shrink-0"
>
<RefreshCw size={13} />
立即生成
</button>
</div>
)}
</div>
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-elevated)] p-3 text-[11px] sm:text-xs leading-relaxed text-[var(--app-muted)]">
<span className="font-semibold text-[var(--app-text)]">使用方法:</span>
在电视端或外部设备的 Emby 登录界面输入用户名 <code className="font-bold text-brand-500">{user?.username}</code> 和上方 6 位临时码。登录后临时码立即作废(一次性阅后即焚),客户端将自动获取长期持久令牌。
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-[var(--app-border)] pt-3 sm:pt-4 shrink-0">
<button
type="button"
onClick={fetchCode}
disabled={loading}
className="flex items-center gap-1.5 rounded-xl px-2.5 sm:px-3 py-1.5 sm:py-2 text-xs font-medium text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-50"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
重新生成
</button>
<button
type="button"
onClick={onClose}
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-elevated)] px-3.5 sm:px-4 py-1.5 sm:py-2 text-xs font-semibold text-[var(--app-text)] transition-colors hover:bg-[var(--app-hover)]"
>
完成并关闭
</button>
</div>
</div>
</div>,
document.body
)
}
+103 -47
View File
@@ -1,37 +1,24 @@
import { forwardRef, useEffect, useState, type CSSProperties, type ReactNode } from 'react'
import { VirtuosoGrid } from 'react-virtuoso'
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { Virtuoso } from 'react-virtuoso'
import clsx from 'clsx'
// 与 LibraryMediaSections 等处的海报网格保持同一套响应式列配置。
export const MEDIA_GRID_CLASS =
'grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
// 首帧滚动容器尚未就绪时的占位渲染数量(约一屏)。
const FIRST_PAINT_COUNT = 28
function getFallbackColumns(width: number): number {
if (width >= 1536) return 8
if (width >= 1280) return 7
if (width >= 1024) return 6
if (width >= 768) return 5
if (width >= 640) return 4
return 3
}
const GridList = forwardRef<HTMLDivElement, { style?: CSSProperties; children?: ReactNode }>(
function GridList({ children, style, ...rest }, ref) {
return (
<div ref={ref} style={style} {...rest} className={MEDIA_GRID_CLASS}>
{children}
</div>
)
},
)
const GridItem = forwardRef<HTMLDivElement, { style?: CSSProperties; children?: ReactNode }>(
function GridItem({ children, style, ...rest }, ref) {
return (
<div ref={ref} style={style} {...rest}>
{children}
</div>
)
},
)
// VirtualMediaGrid 大库性能优化:无论库里有 1 千还是 5 万张卡片,
// 实际挂载的只有视口内及邻近一屏的节点。滚动容器是 Layout 的
// <main id="app-main-scroll">(非 window 滚动),通过 customScrollParent 接入;
// 元素尚未挂载时先渲染一屏卡片,挂载后切换为虚拟列表。
// VirtualMediaGrid 大库性能优化:
// 采用按行虚拟滚动(Row-based Virtualization)。相比 VirtuosoGrid 强制要求所有网格项
// 绝对等高且易受 CSS Grid 亚像素尺寸扰动引发死循环闪烁,按行使用基础 Virtuoso 组件
// 天然支持每行真实高度,并且每一行内部保持原生的响应式 CSS Grid 布局。
export function VirtualMediaGrid({
totalCount,
renderItem,
@@ -39,29 +26,98 @@ export function VirtualMediaGrid({
totalCount: number
renderItem: (index: number) => ReactNode
}) {
const [scrollParent, setScrollParent] = useState<HTMLElement | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const probeRef = useRef<HTMLDivElement>(null)
const [columns, setColumns] = useState(() => {
if (typeof window !== 'undefined') {
return getFallbackColumns(window.innerWidth)
}
return 4
})
useEffect(() => {
setScrollParent(document.getElementById('app-main-scroll'))
const updateColumns = useCallback(() => {
if (probeRef.current) {
const computed = window.getComputedStyle(probeRef.current).gridTemplateColumns
if (computed && computed !== 'none') {
const count = computed.trim().split(/\s+/).filter(Boolean).length
if (count > 0) {
setColumns((prev) => (prev !== count ? count : prev))
return
}
}
}
const width = containerRef.current?.clientWidth || (typeof window !== 'undefined' ? window.innerWidth : 0)
if (width > 0) {
const fallback = getFallbackColumns(width)
setColumns((prev) => (prev !== fallback ? fallback : prev))
}
}, [])
if (!scrollParent) {
return (
<div className={MEDIA_GRID_CLASS}>
{Array.from({ length: Math.min(totalCount, FIRST_PAINT_COUNT) }, (_, index) => (
<div key={index}>{renderItem(index)}</div>
))}
</div>
)
}
useLayoutEffect(() => {
updateColumns()
}, [updateColumns])
useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateColumns)
return () => window.removeEventListener('resize', updateColumns)
}
const observer = new ResizeObserver(() => {
updateColumns()
})
observer.observe(el)
return () => observer.disconnect()
}, [updateColumns])
const [scrollParent, setScrollParent] = useState<HTMLElement | null>(() => {
return typeof document !== 'undefined' ? document.getElementById('app-main-scroll') : null
})
useEffect(() => {
if (!scrollParent) {
setScrollParent(document.getElementById('app-main-scroll'))
}
}, [scrollParent])
const rowCount = Math.ceil(totalCount / columns)
return (
<VirtuosoGrid
customScrollParent={scrollParent}
totalCount={totalCount}
overscan={800}
components={{ List: GridList, Item: GridItem }}
itemContent={renderItem}
/>
<div ref={containerRef} className="relative w-full">
{/* 隐藏探针节点:跟随 Tailwind MEDIA_GRID_CLASS 响应式断点自动计算当前列数 */}
<div
ref={probeRef}
className={clsx(MEDIA_GRID_CLASS, 'pointer-events-none invisible absolute h-0 w-full overflow-hidden')}
aria-hidden="true"
/>
{!scrollParent ? (
<div className={MEDIA_GRID_CLASS}>
{Array.from({ length: Math.min(totalCount, columns * 4) }, (_, index) => (
<div key={index}>{renderItem(index)}</div>
))}
</div>
) : (
<Virtuoso
customScrollParent={scrollParent}
totalCount={rowCount}
overscan={800}
itemContent={(rowIndex) => {
const start = rowIndex * columns
return (
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-4')}>
{Array.from({ length: columns }, (_, colIndex) => {
const itemIndex = start + colIndex
if (itemIndex >= totalCount) {
return <div key={colIndex} aria-hidden="true" />
}
return <div key={itemIndex}>{renderItem(itemIndex)}</div>
})}
</div>
)
}}
/>
)}
</div>
)
}
+14 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type Reac
import { createPortal } from 'react-dom'
import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
import { imageURL } from '../api/client'
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
import type { Library, LibraryRoot } from '../types'
import type { RootDraft } from './adminLibraryPanelModel'
@@ -169,7 +170,19 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
</td>
<td className="py-2 pr-3 font-medium text-ink-600">
<div className="flex items-center gap-2">
{library.cover_url && <img src={library.cover_url} alt="" loading="lazy" decoding="async" className="h-10 w-8 rounded object-cover" />}
{library.cover_url && (
<img
src={imageURL(library.cover_url, library.updated_at)}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
className="h-10 w-8 rounded object-cover"
onError={(e) => {
e.currentTarget.style.visibility = 'hidden'
}}
/>
)}
<span>{library.name}</span>
</div>
</td>
@@ -5,6 +5,7 @@ import { PageBackButton } from '../components/PageBackButton'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
import type { Media } from '../types'
@@ -163,6 +164,7 @@ export function MediaDetailMainContent({
onToggleFavourite={onToggleFavourite}
playTargetId={playTargetId}
/>
<MediaVersionSwitcher media={media} mode="detail" />
{isAdmin && (
<MediaDetailAdminPanel
media={media}
+364 -96
View File
@@ -4,7 +4,7 @@ import type Hls from 'hls.js'
import toast from 'react-hot-toast'
import { mediaAPI, libraryAPI } from '../api/library'
import { api, hlsURL, streamURL } from '../api/client'
import { hlsURL, stopHLSJob, streamURL } from '../api/client'
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
@@ -13,11 +13,14 @@ import type { Media } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError } from './directPlayError'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
import { mediaVersionsOf } from '../utils/mediaVersion'
// Fullscreen, dark-themed video page.
//
@@ -26,27 +29,11 @@ import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
//
// We pick a sensible default based on the source codec: H.264 + AAC in
// MP4 / WebM containers play directly; everything else (HEVC, MKV, AV1,
// AC3 audio, …) gets routed through ffmpeg → HLS.
// AC3 audio, …) gets routed through ffmpeg → HLS. STRM / 云盘直链默认直连,
// 浏览器播不了时再切 HLS。远程 Emby 挂载只能直连。
//
// External subtitles next to the source file are auto-discovered and
// attached as <track> elements.
const SUBTITLE_STORAGE_KEY = 'mebox.subtitle'
// 初始字幕偏好:localStorage 记录上次选择的轨道(-1=关闭);没有偏好时
// 默认 0(自动加载第一条字幕)。
function initialSubtitleIndex(): number {
try {
const saved = localStorage.getItem(SUBTITLE_STORAGE_KEY)
if (saved !== null && saved !== '') {
const n = parseInt(saved, 10)
if (Number.isFinite(n)) return n
}
} catch {
// ignore
}
return 0
}
export function PlayerPage() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
@@ -56,24 +43,31 @@ export function PlayerPage() {
const ref = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const lastSentRef = useRef(0)
const directRetryRef = useRef(false)
const retryingDirectRef = useRef(false)
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [media, setMedia] = useState<Media | null>(null)
const [mode, setMode] = useState<PlayerMode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(initialSubtitleIndex)
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
const [hlsUnavailable, setHlsUnavailable] = useState(false)
const [playerError, setPlayerError] = useState('')
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
const [loadError, setLoadError] = useState('')
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
const [directOnly, setDirectOnly] = useState(false)
const [directOnlyKnown, setDirectOnlyKnown] = useState(false)
const [resumePosition, setResumePosition] = useState(0)
const [initialSeekDone, setInitialSeekDone] = useState(false)
// HLS session source offset: playlist t=0 maps to this absolute second.
const [hlsStartSec, setHlsStartSec] = useState(0)
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false)
const [danmakuEnabled, setDanmakuEnabled] = useState(true)
const [danmakuSearch, setDanmakuSearch] = useState<string | null>(null)
const [danmakuSearchTrigger, setDanmakuSearchTrigger] = useState(0)
const [danmakuSearching, setDanmakuSearching] = useState(false)
// 用户从候选列表选定的弹幕库;null = 自动匹配。
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState<number | string | null>(null)
@@ -91,14 +85,11 @@ export function PlayerPage() {
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
const [playlistOpen, setPlaylistOpen] = useState(false)
const teardownHls = useCallback((mediaId?: string, stopServer = false) => {
const teardownHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
if (stopServer && mediaId) {
api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined)
}
}, [])
const backTarget = useCallback(() => {
@@ -118,6 +109,7 @@ export function PlayerPage() {
.info()
.then((info) => setDirectOnly(Boolean(info.direct_play_only)))
.catch(() => setDirectOnly(false))
.finally(() => setDirectOnlyKnown(true))
}, [])
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
@@ -141,6 +133,7 @@ export function PlayerPage() {
setDanmakuEpisodeId(null)
setDanmakuInfo(null)
setDanmakuSearch(kw || null)
setDanmakuSearchTrigger((prev) => prev + 1)
}, [])
const danmakuLoaded = useCallback((info: DanmakuLoadedInfo | null) => {
@@ -164,6 +157,7 @@ export function PlayerPage() {
setDanmakuSearching(true)
// 展示当前所选来源(面板标题处可见)。
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
setDanmakuSearchTrigger((prev) => prev + 1)
}, [])
// 回到自动匹配(清除用户手动选择)。
@@ -174,23 +168,54 @@ export function PlayerPage() {
setDanmakuSearch(null)
setDanmakuSelectedSource('')
setDanmakuInfo(null)
setDanmakuSearchTrigger((prev) => prev + 1)
}, [])
// 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕
useEffect(() => {
setMedia(null)
setLoadError('')
setHlsStartSec(0)
setDanmakuEpisodeId(null)
setDanmakuCandidates([])
setDanmakuSearch(null)
setDanmakuSelectedSource('')
setDanmakuInfo(null)
setDanmakuSearching(true)
directRetryRef.current = false
retryingDirectRef.current = false
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
fallbackTimerRef.current = null
}
return () => {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
fallbackTimerRef.current = null
}
}
}, [id])
// 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据
const modeParam = params.get('mode') as PlayerMode | null
const setPlaybackMode = useCallback(
(next: PlayerMode) => {
setMode(next)
const nextParams = new URLSearchParams(window.location.search)
nextParams.set('mode', next)
setParams(nextParams, { replace: true })
},
[setParams],
)
const clearFallbackTimer = useCallback(() => {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
fallbackTimerRef.current = null
}
}, [])
// Load metadata and pick a default mode.
useEffect(() => {
if (!id) return
@@ -202,7 +227,7 @@ export function PlayerPage() {
setMedia(m)
const isDirect = isDirectStreamMedia(m)
const auto = pickPlayerMode(m)
// 直连解码模式以及 STRM / Emby 挂载等直连媒体,忽略 ?mode=hls,始终 direct play。
// 直连解码模式以及远程 Emby 挂载忽略 ?mode=hls。STRM 默认直连,但允许手动/失败后切 HLS。
setMode(directOnly || isDirect ? 'direct' : (modeParam ?? auto))
setPlayerError('')
setLoadError('')
@@ -218,9 +243,8 @@ export function PlayerPage() {
if (cancelled) return
const list = tracks ?? []
setSubs(list)
// 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同),
// 越界时回退到第一条;无字幕则关闭。
setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur))
// 服务端始终把外挂字幕排在内嵌字幕前面,因此第一条就是默认优先轨。
setSubtitleIndex(list.length > 0 ? 0 : -1)
})
.catch(() => {
if (cancelled) return
@@ -232,52 +256,143 @@ export function PlayerPage() {
}, [id, modeParam, directOnly])
// Wire up the actual <video> element when we know the mode.
// Depend on media.id (not the media object): refreshing duration after
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
const mediaId = media?.id
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const burnedSubtitleStream =
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
// 直连不使用烧录字幕参数。字幕列表通常比媒体信息晚返回,若把该参数直接
// 作为播放 effect 的依赖,会在 STRM 的 302 直链仍在建立时重复设置 src,
// Chromium 会把被中断的首次加载报告成播放错误并误触发 HLS 回退。
const activeBurnedSubtitleStream = mode === 'hls' ? burnedSubtitleStream : undefined
const mediaRef = useRef(media)
mediaRef.current = media
useEffect(() => {
if (!media || !ref.current) return
if (!mediaId || !ref.current) return
const currentMedia = mediaRef.current
if (!currentMedia) return
let cancelled = false
teardownHls()
const video = ref.current
const durationSec = currentMedia.duration_sec || 0
if (mode === 'hls') {
const url = hlsURL(media.id)
const url = hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream)
void import('hls.js').then(({ default: HlsCtor }) => {
if (cancelled || !ref.current) return
if (HlsCtor.isSupported()) {
const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false })
const hls = new HlsCtor({
enableWorker: true,
lowLatencyMode: false,
// The server waits up to 45s for the first segment. Slow two-core
// hosts and remote STRM sources regularly need more than hls.js's
// 10s default, which otherwise aborts a healthy transcode.
manifestLoadingTimeOut: 60_000,
manifestLoadingMaxRetry: 1,
})
try {
video.currentTime = 0
} catch {
// ignore
}
hls.loadSource(url)
hls.attachMedia(video)
hls.on(HlsCtor.Events.MANIFEST_PARSED, () => {
void video.play().catch(() => undefined)
// .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。
if (durationSec > 0) return
mediaAPI
.get(mediaId)
.then((fresh) => {
if (cancelled || fresh.id !== mediaId) return
if ((fresh.duration_sec || 0) > 0) setMedia(fresh)
})
.catch(() => undefined)
})
hls.on(HlsCtor.Events.ERROR, (_, data) => {
if (data.fatal) {
setHlsUnavailable(true)
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
toast.error('HLS 转码失败,尝试切换到直接播放')
setMode('direct')
params.set('mode', 'direct')
setParams(params, { replace: true })
setPlaybackMode('direct')
}
})
if (cancelled) {
hls.destroy()
return
}
hlsRef.current = hls
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
try {
video.currentTime = 0
} catch {
// ignore
}
video.src = url
void video.play().catch(() => undefined)
} else {
setHlsUnavailable(true)
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
toast.error('当前浏览器不支持 HLS,降级到直接播放')
setMode('direct')
setPlaybackMode('direct')
}
void video.play().catch(() => undefined)
}).catch(() => {
if (cancelled) return
setHlsUnavailable(true)
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
setMode('direct')
setPlaybackMode('direct')
})
} else {
video.src = streamURL(media.id)
if (hlsUnavailable && needsTranscodeForBrowser(media)) {
const url = streamURL(mediaId)
const absoluteURL = new URL(url, window.location.href).href
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
if (video.src !== absoluteURL) {
directRetryRef.current = false
clearFallbackTimer()
video.src = url
void video.play().catch(() => undefined)
}
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
}
void video.play().catch(() => undefined)
}
return () => teardownHls(media.id, mode === 'hls')
}, [hlsUnavailable, media, mode, params, setParams, teardownHls])
const onPlaying = () => clearFallbackTimer()
video.addEventListener('playing', onPlaying)
return () => {
cancelled = true
video.removeEventListener('playing', onPlaying)
teardownHls()
}
}, [
activeBurnedSubtitleStream,
clearFallbackTimer,
hlsUnavailable,
hlsStartSec,
mediaId,
mode,
setPlaybackMode,
teardownHls,
])
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
// survives route navigation while the component is being torn down.
useEffect(() => {
if (!mediaId || mode !== 'hls') return
return () => {
stopHLSJob(mediaId)
}
}, [mediaId, mode])
// React cleanup is not guaranteed when a tab/window closes. pagehide fires
// while the document can still dispatch a keepalive request.
useEffect(() => {
if (!mediaId || mode !== 'hls') return
const stopOnPageExit = () => stopHLSJob(mediaId)
window.addEventListener('pagehide', stopOnPageExit)
return () => window.removeEventListener('pagehide', stopOnPageExit)
}, [mediaId, mode])
// 自动拉取已有的播放进度并恢复播放位置
useEffect(() => {
@@ -295,38 +410,75 @@ export function PlayerPage() {
}, [id])
useEffect(() => {
if (!resumePosition || initialSeekDone) return
if (mode === 'hls') {
// Restart transcode near the resume point instead of seeking a short partial playlist.
if (Math.abs(hlsStartSec - resumePosition) > 2) {
setHlsStartSec(resumePosition)
}
setInitialSeekDone(true)
const m = Math.floor(resumePosition / 60)
const s = Math.floor(resumePosition % 60)
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
return
}
const video = ref.current
if (!video || !resumePosition || initialSeekDone) return
if (!video) return
let onSeeked: (() => void) | undefined
const applyResume = () => {
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
onSeeked = () => {
setInitialSeekDone(true)
const m = Math.floor(resumePosition / 60)
const s = Math.floor(resumePosition % 60)
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
}
video.addEventListener('seeked', onSeeked, { once: true })
video.currentTime = resumePosition
setInitialSeekDone(true)
const m = Math.floor(resumePosition / 60)
const s = Math.floor(resumePosition % 60)
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
return
}
setInitialSeekDone(true)
}
if (video.readyState >= 1) {
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
applyResume()
} else {
video.addEventListener('loadedmetadata', applyResume, { once: true })
return () => video.removeEventListener('loadedmetadata', applyResume)
video.addEventListener('canplay', applyResume, { once: true })
}
}, [resumePosition, initialSeekDone])
return () => {
video.removeEventListener('canplay', applyResume)
if (onSeeked) video.removeEventListener('seeked', onSeeked)
}
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
const hlsStartSecRef = useRef(hlsStartSec)
hlsStartSecRef.current = hlsStartSec
const modeRef = useRef(mode)
modeRef.current = mode
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
useEffect(() => {
if (!media || !ref.current) return
if (!id || !ref.current) return
const video = ref.current
const absolutePositionMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
return Math.floor((currentStartSec + (video.currentTime || 0)) * 1000)
}
const absoluteDurationMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const mediaDur = mediaRef.current?.duration_sec || 0
return Math.floor(Math.max(mediaDur, currentStartSec + (video.duration || 0)) * 1000)
}
const handler = () => {
const now = Date.now()
if (now - lastSentRef.current < 10_000) return
lastSentRef.current = now
const positionMs = Math.floor(video.currentTime * 1000)
const durationMs = Math.floor((video.duration || 0) * 1000)
if (positionMs > 0) {
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
const positionMs = absolutePositionMs()
const durationMs = absoluteDurationMs()
if (positionMs > 0 && mediaRef.current) {
playbackAPI.recordProgress(mediaRef.current.id, positionMs, durationMs).catch(() => undefined)
}
}
video.addEventListener('timeupdate', handler)
@@ -334,13 +486,13 @@ export function PlayerPage() {
return () => {
video.removeEventListener('timeupdate', handler)
video.removeEventListener('pause', handler)
const positionMs = Math.floor(video.currentTime * 1000)
const durationMs = Math.floor((video.duration || 0) * 1000)
if (positionMs > 0 && media) {
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
const positionMs = absolutePositionMs()
const durationMs = absoluteDurationMs()
if (positionMs > 0 && mediaRef.current) {
playbackAPI.recordProgress(mediaRef.current.id, positionMs, durationMs).catch(() => undefined)
}
}
}, [media])
}, [id])
// 加载剧集/播放列表
useEffect(() => {
@@ -416,6 +568,19 @@ export function PlayerPage() {
[navigate, location.search, location.state],
)
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本入口。
const versionList = useMemo(
() => (media?.id === id ? mediaVersionsOf(media) : []),
[id, media],
)
const switchVersion = useCallback(
(version: Media) => {
if (!version?.id || version.id === media?.id) return
playEpisode(version)
},
[media?.id, playEpisode],
)
const handlePrevEpisode = useCallback(() => {
if (prevEpisode) {
playEpisode(prevEpisode)
@@ -499,37 +664,131 @@ export function PlayerPage() {
const isDirectStream = isDirectStreamMedia(media)
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
useEffect(() => {
if (
!directOnlyKnown ||
directOnly ||
isDirectStream ||
selectedSubtitle?.delivery !== 'burn' ||
mode === 'hls'
) {
return
}
setHlsStartSec(ref.current?.currentTime || 0)
setPlaybackMode('hls')
}, [
directOnly,
directOnlyKnown,
isDirectStream,
mode,
selectedSubtitle?.delivery,
setPlaybackMode,
])
const toggleMode = useCallback(() => {
if (isDirectStream) {
toast('该媒体为直连播放,无需且不支持转码')
return
}
const next = mode === 'hls' ? 'direct' : 'hls'
setMode(next)
params.set('mode', next)
setParams(params, { replace: true })
}, [isDirectStream, mode, params, setParams])
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
const selectSubtitle = useCallback((index: number) => {
setSubtitleIndex(index)
try {
localStorage.setItem(SUBTITLE_STORAGE_KEY, String(index))
} catch {
// ignore
if (next === 'hls') {
setHlsStartSec(0)
}
}, [])
setPlaybackMode(next)
}, [isDirectStream, mode, setPlaybackMode])
const handleSeekAbsolute = useCallback(
(absoluteSec: number) => {
if (mode !== 'hls') return false
const video = ref.current
if (!video) return false
const target = Math.max(0, absoluteSec)
const local = target - hlsStartSec
// Live/EVENT HLS during transcoding often reports duration=Infinity.
// Never treat that as "already buffered" or currentTime seeks reset to 0.
const finiteDuration = Number.isFinite(video.duration) ? video.duration : 0
let seekableEnd = 0
if (video.seekable && video.seekable.length > 0) {
try {
seekableEnd = video.seekable.end(video.seekable.length - 1)
} catch {
seekableEnd = 0
}
}
if (!Number.isFinite(seekableEnd)) seekableEnd = 0
const windowEnd = Math.max(finiteDuration, seekableEnd)
if (local >= 0 && windowEnd > 0.5 && local <= windowEnd - 0.5) {
video.currentTime = local
return true
}
setPlayerError('')
toast('正在从该位置重新转码…', { duration: 2000 })
setHlsStartSec(target)
return true
},
[hlsStartSec, mode],
)
// 用户切换图片字幕时从当前位置创建新的 HLS 烧录任务;文本字幕只在网页层切换。
const selectSubtitle = useCallback((index: number) => {
const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const nextTrack = index >= 0 ? subs[index] : undefined
if (nextTrack?.delivery === 'burn' && (directOnly || isDirectStream)) {
toast.error('图片字幕需要开启 HLS 转码后才能显示')
return
}
const burnChanged =
oldTrack?.delivery === 'burn' || nextTrack?.delivery === 'burn'
if (burnChanged && mode === 'hls' && ref.current) {
setHlsStartSec(hlsStartSec + (ref.current.currentTime || 0))
}
setSubtitleIndex(index)
if (nextTrack?.delivery === 'burn' && mode !== 'hls' && !directOnly && !isDirectStream) {
setHlsStartSec(ref.current?.currentTime || 0)
setPlaybackMode('hls')
}
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
const handleVideoError = useCallback(() => {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
if (mode === 'direct') {
if (isRemoteEmbyID(media?.id)) {
const video = ref.current
if (mode !== 'direct') {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
return
}
if (retryingDirectRef.current) return
const expectedSrc = mediaId ? new URL(streamURL(mediaId), window.location.href).href : ''
const action = classifyDirectPlayError({
errorCode: video?.error?.code,
readyState: video?.readyState ?? 0,
elementSrc: video?.src ?? '',
expectedSrc,
alreadyRetried: directRetryRef.current,
})
if (action === 'ignore') return
if (action === 'retry' && video && mediaId) {
directRetryRef.current = true
retryingDirectRef.current = true
try {
video.load()
void video.play().catch(() => undefined)
} finally {
retryingDirectRef.current = false
}
return
}
const fallbackDirectPlay = () => {
if (modeRef.current !== 'direct') return
const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
toast.error('直接播放失败,建议使用外部播放器')
} else if (isDirectStreamMedia(media)) {
setPlayerError('直接播放失败。该媒体为 STRM 远程直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器播放。')
toast.error('直接播放失败,建议使用外部播放器')
} else if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
@@ -538,33 +797,37 @@ export function PlayerPage() {
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
setPlaybackMode('hls')
}
return
}
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
}, [directOnly, hlsUnavailable, media, mode, params, setParams])
clearFallbackTimer()
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
const danmakuAutoTitle =
danmakuInfo?.animeTitle ||
media?.original_name?.trim() ||
media?.title?.trim() ||
''
return (
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar
directOnly={directOnly}
isDirectStream={isDirectStream}
directStreamLabel={
isRemoteEmbyID(media?.id)
? 'Emby 直连播放'
: isDirectStream
? 'STRM 直连播放'
: undefined
}
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
mode={mode}
onBack={goBack}
onToggleMode={toggleMode}
/>
{versionList.length > 1 && (
<div className="pointer-events-none absolute inset-x-0 top-16 z-20 flex justify-center px-4 sm:top-20">
<div className="pointer-events-auto max-w-3xl rounded-2xl border border-white/15 bg-black/70 px-3 py-2 shadow-xl backdrop-blur">
<MediaVersionSwitcher media={media!} mode="player" onSelect={switchVersion} className="text-white" />
</div>
</div>
)}
<PlayerVideoStage
media={media}
loadError={loadError}
@@ -580,6 +843,7 @@ export function PlayerPage() {
danmakuArea={danmakuArea}
danmakuSearch={danmakuSearch}
danmakuEpisodeId={danmakuEpisodeId}
danmakuSearchTrigger={danmakuSearchTrigger}
danmakuOpen={danmakuOpen}
onToggleDanmaku={toggleDanmakuOpen}
onDanmakuLoaded={danmakuLoaded}
@@ -593,6 +857,9 @@ export function PlayerPage() {
playlistOpen={playlistOpen}
hasPlaylist={playlistEpisodes.length > 0}
onTogglePlaylist={togglePlaylistOpen}
knownDuration={media?.duration_sec || 0}
streamOffset={mode === 'hls' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' ? handleSeekAbsolute : undefined}
playlistPanel={
<PlayerPlaylistPanel
open={playlistOpen}
@@ -619,6 +886,7 @@ export function PlayerPage() {
onFontSizeChange={setDanmakuFontSize}
candidates={danmakuCandidates}
selectedSource={danmakuSelectedSource}
autoMatchTitle={danmakuAutoTitle}
danmakuInfo={danmakuInfo}
onSelectEpisode={danmakuSelectEpisode}
onResetAuto={danmakuResetAuto}
+190 -27
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import type { PointerEvent, ReactNode, RefObject } from 'react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
@@ -7,6 +7,54 @@ import type { Media } from '../types'
import { DanmakuStage } from '../components/DanmakuStage'
import { PlayerControls } from '../components/PlayerControls'
type SubtitleCue = {
startTime: number
endTime: number
text: string
}
function parseVTTTimestamp(value: string): number {
const parts = value.trim().replace(',', '.').split(':')
if (parts.length !== 2 && parts.length !== 3) return Number.NaN
const seconds = Number(parts.pop())
const minutes = Number(parts.pop())
const hours = parts.length > 0 ? Number(parts.pop()) : 0
if (![hours, minutes, seconds].every(Number.isFinite)) return Number.NaN
return hours * 3600 + minutes * 60 + seconds
}
function parseWebVTTCues(body: string): SubtitleCue[] {
const blocks = body
.replace(/^\uFEFF/, '')
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
const cues: SubtitleCue[] = []
for (const block of blocks) {
const lines = block.split('\n')
const timingIndex = lines.findIndex((line) => line.includes('-->'))
if (timingIndex < 0) continue
const [rawStart, rawEnd] = lines[timingIndex].split('-->', 2)
const startTime = parseVTTTimestamp(rawStart)
const endTime = parseVTTTimestamp(rawEnd.trim().split(/\s+/, 1)[0])
const text = lines.slice(timingIndex + 1).join('\n').trim()
if (Number.isFinite(startTime) && Number.isFinite(endTime) && endTime >= startTime && text) {
cues.push({ startTime, endTime, text })
}
}
return cues
}
function uniqueSubtitleTexts(texts: string[]): string[] {
const unique = [...new Set(texts.map((text) => text.trim()).filter(Boolean))]
// ponytail: WebVTT cannot preserve ASS layers/positions. Keep at most two
// simultaneous blocks for bilingual subtitles; use libass if full ASS
// typesetting support is added later.
return unique.slice(-2)
}
type PlayerVideoStageProps = {
media: Media | null
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
@@ -24,6 +72,7 @@ type PlayerVideoStageProps = {
danmakuArea: number
danmakuSearch: string | null
danmakuEpisodeId: number | string | null
danmakuSearchTrigger?: number
danmakuOpen: boolean
onToggleDanmaku: () => void
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
@@ -41,6 +90,9 @@ type PlayerVideoStageProps = {
playlistOpen?: boolean
hasPlaylist?: boolean
onTogglePlaylist?: () => void
knownDuration?: number
streamOffset?: number
onSeekAbsolute?: (seconds: number) => boolean
}
export function PlayerVideoStage({
@@ -58,6 +110,7 @@ export function PlayerVideoStage({
danmakuArea,
danmakuSearch,
danmakuEpisodeId,
danmakuSearchTrigger = 0,
danmakuOpen,
onToggleDanmaku,
onDanmakuLoaded,
@@ -73,12 +126,72 @@ export function PlayerVideoStage({
playlistOpen,
hasPlaylist,
onTogglePlaylist,
knownDuration,
streamOffset,
onSeekAbsolute,
}: PlayerVideoStageProps) {
const stageRef = useRef<HTMLDivElement>(null)
const [videoRatio, setVideoRatio] = useState<number | null>(null)
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
const [controlsVisible, setControlsVisible] = useState(true)
const revealControlsOnlyRef = useRef(false)
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
const [activeCueText, setActiveCueText] = useState<string>('')
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
// <track>.track.cues;独立时间轴不受 MediaSource 重挂载和轨道 mode 切换影响。
const [subtitleTimeline, setSubtitleTimeline] = useState<{
path: string
cues: SubtitleCue[]
} | null>(null)
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
const [tracksArmed, setTracksArmed] = useState(false)
useEffect(() => {
setTracksArmed(false)
const video = videoRef.current
if (!video || !media?.id) return
const arm = () => setTracksArmed(true)
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
arm()
return
}
video.addEventListener('canplay', arm)
video.addEventListener('playing', arm)
return () => {
video.removeEventListener('canplay', arm)
video.removeEventListener('playing', arm)
}
}, [media?.id, videoRef])
useEffect(() => {
const selectedTrack = subs[subtitleIndex]
if (!media || subtitleIndex < 0 || !selectedTrack || selectedTrack.delivery !== 'webvtt') {
setSubtitleTimeline(null)
return
}
const controller = new AbortController()
setSubtitleTimeline(null)
fetch(subtitlesAPI.url(media.id, selectedTrack.path), { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`subtitle request failed: ${response.status}`)
return response.text()
})
.then((body) => {
setSubtitleTimeline({
path: selectedTrack.path,
cues: parseWebVTTCues(body),
})
})
.catch((error: unknown) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
// 保留原生 TextTrack 作为请求失败时的降级路径。
setSubtitleTimeline(null)
}
})
return () => controller.abort()
}, [media, subs, subtitleIndex])
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
useEffect(() => {
@@ -115,13 +228,26 @@ export function PlayerVideoStage({
}
}, [videoRef, media])
// 点击视频切换播放/暂停;双击切换全屏(控制栏事件自行阻止冒泡)。
// 桌面端点击直接切换播放;移动端控制栏隐藏时首次轻触只唤出控制栏,
// 控制栏已显示时再次轻触才切换播放/暂停。
const togglePlay = () => {
const video = videoRef.current
if (!video) return
if (video.paused) void video.play()?.catch(() => undefined)
else video.pause()
}
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
}
const handleStageClick = () => {
if (revealControlsOnlyRef.current) {
revealControlsOnlyRef.current = false
setControlsVisible(true)
return
}
revealControlsOnlyRef.current = false
togglePlay()
}
const toggleFullscreen = () => {
const stage = stageRef.current
if (!stage) return
@@ -134,15 +260,34 @@ export function PlayerVideoStage({
// 由下方的 React 自定义层输出 100% 纯透明背景、高清晰文字阴影的字幕。
useEffect(() => {
const video = videoRef.current
if (!video || subs.length === 0 || subtitleIndex < 0 || !subs[subtitleIndex]) {
const selectedTrack = subs[subtitleIndex]
if (
!video ||
!tracksArmed ||
subs.length === 0 ||
subtitleIndex < 0 ||
!selectedTrack ||
selectedTrack.delivery === 'burn'
) {
setActiveCueText('')
return
}
const trackIdx = subtitleIndex
const updateCue = () => {
const trackEls = Array.from(video.querySelectorAll('track'))
const selectedEl = trackEls[trackIdx]
const absoluteTime = video.currentTime + (streamOffset ?? 0)
if (subtitleTimeline?.path === selectedTrack.path) {
const texts = uniqueSubtitleTexts(
subtitleTimeline.cues
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
.map((cue) => cue.text),
)
setActiveCueText(texts.join('\n'))
return
}
const selectedEl = video.querySelector<HTMLTrackElement>(
`track[data-subtitle-index="${subtitleIndex}"]`,
)
const tt = selectedEl?.track
if (!tt) {
setActiveCueText('')
@@ -152,35 +297,42 @@ export function PlayerVideoStage({
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
const texts: string[] = []
if (tt.activeCues && tt.activeCues.length > 0) {
if ((!streamOffset || streamOffset <= 0.05) && tt.activeCues && tt.activeCues.length > 0) {
for (let i = 0; i < tt.activeCues.length; i++) {
const cue = tt.activeCues[i] as VTTCue
if (cue && cue.text) texts.push(cue.text)
}
} else if (tt.cues && tt.cues.length > 0) {
const cur = video.currentTime
for (let i = 0; i < tt.cues.length; i++) {
const cue = tt.cues[i] as VTTCue
if (cue && cur >= cue.startTime && cur <= cue.endTime && cue.text) {
if (
cue &&
absoluteTime >= cue.startTime &&
absoluteTime <= cue.endTime &&
cue.text
) {
texts.push(cue.text)
}
}
}
setActiveCueText(texts.join('\n'))
setActiveCueText(uniqueSubtitleTexts(texts).join('\n'))
}
const apply = () => {
const trackEls = Array.from(video.querySelectorAll('track'))
if (trackEls.length === 0) return
trackEls.forEach((el, i) => {
trackEls.forEach((el) => {
const tt = el.track
if (tt) {
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
tt.mode = i === trackIdx ? 'hidden' : 'disabled'
tt.mode =
el.dataset.subtitleIndex === String(subtitleIndex) ? 'hidden' : 'disabled'
}
})
const selected = trackEls[trackIdx]
const selected = video.querySelector<HTMLTrackElement>(
`track[data-subtitle-index="${subtitleIndex}"]`,
)
if (!selected) return
const tt = selected.track
@@ -208,8 +360,9 @@ export function PlayerVideoStage({
video.removeEventListener('seeking', updateCue)
video.removeEventListener('seeked', updateCue)
video.removeEventListener('playing', updateCue)
const trackEls = Array.from(video.querySelectorAll('track'))
const selected = trackEls[trackIdx]
const selected = video.querySelector<HTMLTrackElement>(
`track[data-subtitle-index="${subtitleIndex}"]`,
)
if (selected) {
selected.removeEventListener('load', updateCue)
if (selected.track) {
@@ -217,7 +370,7 @@ export function PlayerVideoStage({
}
}
}
}, [subtitleIndex, subs, videoRef, media])
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline, tracksArmed])
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
const isWiderThanStage =
@@ -243,7 +396,8 @@ export function PlayerVideoStage({
ref={stageRef}
data-player-stage
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
onClick={togglePlay}
onPointerDown={handleStagePointerDown}
onClick={handleStageClick}
onDoubleClick={toggleFullscreen}
>
{media ? (
@@ -259,16 +413,19 @@ export function PlayerVideoStage({
className="h-full w-full object-contain bg-black"
onError={onVideoError}
>
{subs.map((track, index) => (
<track
key={track.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
default={subtitleIndex === index}
/>
))}
{tracksArmed &&
subs.map((track, index) =>
track.delivery === 'burn' ? null : (
<track
key={track.path}
data-subtitle-index={index}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
/>
),
)}
</video>
<DanmakuStage
key={media.id}
@@ -280,6 +437,7 @@ export function PlayerVideoStage({
area={danmakuArea}
search={danmakuSearch}
episodeId={danmakuEpisodeId}
searchTrigger={danmakuSearchTrigger}
onLoaded={onDanmakuLoaded}
onCandidates={onDanmakuCandidates}
/>
@@ -301,6 +459,8 @@ export function PlayerVideoStage({
</div>
<PlayerControls
videoRef={videoRef}
uiVisible={controlsVisible}
onUiVisibleChange={setControlsVisible}
subs={subs}
subtitleIndex={subtitleIndex}
onSelectSubtitle={onSelectSubtitle}
@@ -316,6 +476,9 @@ export function PlayerVideoStage({
playlistOpen={playlistOpen}
hasPlaylist={hasPlaylist}
onTogglePlaylist={onTogglePlaylist}
knownDuration={knownDuration}
streamOffset={streamOffset}
onSeekAbsolute={onSeekAbsolute}
/>
{danmakuPanel}
{playlistPanel}
+119 -2
View File
@@ -1,6 +1,6 @@
import { FormEvent, useState } from 'react'
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { EyeOff, KeyRound, Loader2, Save } from 'lucide-react'
import { Check, Copy, EyeOff, KeyRound, Loader2, Save, Tv } from 'lucide-react'
import { authAPI } from '../api/auth'
import { profileAPI } from '../api/profile'
@@ -21,6 +21,65 @@ export function ProfilePage() {
const [savingProfile, setSavingProfile] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
const [otpCode, setOtpCode] = useState('')
const [otpExpiresIn, setOtpExpiresIn] = useState(0)
const [generatingOtp, setGeneratingOtp] = useState(false)
const [copiedOtp, setCopiedOtp] = useState(false)
useEffect(() => {
if (otpExpiresIn <= 0) return
const timer = setInterval(() => {
setOtpExpiresIn((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [otpExpiresIn])
useEffect(() => {
if (window.location.hash === '#tv-otp') {
const el = document.getElementById('tv-otp')
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
}
}
}, [])
const onGenerateOTP = async () => {
if (generatingOtp) return
setGeneratingOtp(true)
try {
const res = await authAPI.createTemporaryPassword()
setOtpCode(res.code)
setOtpExpiresIn(res.expires_in)
setCopiedOtp(false)
toast.success('已生成 6 位临时登录码')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'生成临时密码失败'
toast.error(msg)
} finally {
setGeneratingOtp(false)
}
}
const copyOTP = async () => {
if (!otpCode) return
try {
await navigator.clipboard.writeText(otpCode)
setCopiedOtp(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopiedOtp(false), 2000)
} catch {
toast.error('复制失败,请手动长按复制')
}
}
const onProfile = async (e: FormEvent) => {
e.preventDefault()
if (savingProfile) return
@@ -171,6 +230,64 @@ export function ProfilePage() {
更新密码
</button>
</form>
<section id="tv-otp" className="glass-panel space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600 flex items-center gap-2">
<Tv size={20} className="text-brand-500" />
Emby / 电视端临时登录码 (OTP)
</h2>
<p className="mt-1 text-sm text-ink-50">
适用于电视盒子、Apple TV、车机或朋友设备上的 Emby / Infuse / Jellyfin 客户端快速登录。无需使用遥控器输入长密码。
</p>
</div>
<button
type="button"
onClick={onGenerateOTP}
disabled={generatingOtp}
className="neon-button shrink-0"
>
{generatingOtp ? <Loader2 size={16} className="animate-spin" /> : <KeyRound size={16} />}
{otpCode && otpExpiresIn > 0 ? '重新生成' : '获取 6 位临时登录码'}
</button>
</div>
{otpCode && otpExpiresIn > 0 ? (
<div className="rounded-2xl border border-brand-200 bg-brand-50/40 p-4 space-y-3">
<div className="flex items-center justify-between text-xs text-ink-50">
<div>
<span>登录账号:</span>
<span className="font-mono font-bold text-ink-600">{user?.username}</span>
</div>
<div>
<span>有效时间剩余:</span>
<span className={`font-mono font-bold ${otpExpiresIn < 60 ? 'text-red-500' : 'text-brand-600'}`}>
{Math.floor(otpExpiresIn / 60).toString().padStart(2, '0')}:{(otpExpiresIn % 60).toString().padStart(2, '0')}
</span>
</div>
</div>
<div className="flex items-center justify-between rounded-xl bg-white/90 p-4 border border-brand-100 shadow-sm">
<div className="font-mono text-3xl font-black tracking-widest text-brand-600 select-all">
{otpCode}
</div>
<button
type="button"
onClick={copyOTP}
className="flex items-center gap-1.5 rounded-lg bg-brand-100 px-3 py-1.5 text-xs font-semibold text-brand-700 hover:bg-brand-200 transition-colors"
>
{copiedOtp ? <Check size={14} /> : <Copy size={14} />}
{copiedOtp ? '已复制' : '复制密码'}
</button>
</div>
<p className="text-xs text-ink-50 leading-relaxed">
在客户端的 Emby / Jellyfin 登录界面输入用户名 <code className="font-bold text-ink-600">{user?.username}</code> 和上方 6 位数字临时密码即可完成登录。登录成功后临时密码立即作废(一次性使用),客户端将自动换取长期持久令牌。
</p>
</div>
) : null}
</section>
</div>
)
}
+25
View File
@@ -509,6 +509,12 @@ const SETTING_DEFS: SettingDef[] = [
{ value: '3', label: '不带 path' },
],
},
{
key: 'strm.keep_ext',
label: '保留视频扩展名(多版本)',
type: 'toggle',
hint: '关闭(默认):同名不同扩展(如 竞女01.mkv / 竞女01.mp4)择优生成一条 name.strm;开启:分别生成 name.mkv.strm / name.mp4.strm,保留全部版本供播放切换',
},
{ key: 'strm.115_relay_key', label: '115 中继授权共享密钥', type: 'text', hint: 'QMediaSync/MQFamily 中继授权的共享 AES 密钥;不配置则中继授权不可用' },
{ key: 'strm.download_threads', label: '下载队列线程数', type: 'number', hint: '元数据下载并发数' },
{ key: 'strm.upload_threads', label: '上传队列线程数', type: 'number', hint: '元数据上传并发数' },
@@ -610,6 +616,7 @@ export function StrmSyncPathDialog({
download_meta: existing?.download_meta ?? true,
upload_meta: existing?.upload_meta ?? false,
delete_dir: existing?.delete_dir ?? false,
keep_ext: existing?.keep_ext ?? false,
cron: existing?.cron ?? '',
enable_cron: existing?.enable_cron ?? false,
sync_mode: existing?.sync_mode ?? 'incremental',
@@ -628,6 +635,19 @@ export function StrmSyncPathDialog({
// 兼容两类历史数据:新版保存的 local_path 末段是目录名,旧版是目录 ID。
const prevRemoteTailRef = useRef(existing ? initRemoteTail(existing) : '')
useEffect(() => {
if (existing) return
strmAPI
.getSettings()
.then((settings) => {
const keepExt = settings['strm.keep_ext']
if (keepExt === 'true' || keepExt === '1') {
setForm((f) => ({ ...f, keep_ext: true }))
}
})
.catch(() => undefined)
}, [existing])
const set = <K extends keyof StrmSyncPathInput>(key: K, value: StrmSyncPathInput[K]) =>
setForm((f) => ({ ...f, [key]: value }))
@@ -917,6 +937,11 @@ export function StrmSyncPathDialog({
<ToggleRow label="下载元数据" checked={form.download_meta ?? true} onChange={(v) => set('download_meta', v)} />
<ToggleRow label="上传元数据" checked={form.upload_meta ?? false} onChange={(v) => set('upload_meta', v)} />
<ToggleRow label="清理空目录" checked={form.delete_dir ?? false} onChange={(v) => set('delete_dir', v)} />
<ToggleRow
label="保留视频扩展名(多版本)"
checked={form.keep_ext ?? false}
onChange={(v) => set('keep_ext', v)}
/>
<ToggleRow label="启用定时同步" checked={form.enable_cron ?? false} onChange={(v) => set('enable_cron', v)} />
<ToggleRow label="启用该目录" checked={form.enabled ?? true} onChange={(v) => set('enabled', v)} />
</div>
+24
View File
@@ -0,0 +1,24 @@
export type DirectPlayErrorAction = 'ignore' | 'retry' | 'fallback'
const MEDIA_ERR_ABORTED = 1
const HAVE_CURRENT_DATA = 2
/**
* Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek)
* 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。
*/
export function classifyDirectPlayError(input: {
errorCode: number | undefined | null
readyState: number
elementSrc: string
expectedSrc: string
alreadyRetried: boolean
}): DirectPlayErrorAction {
const code = input.errorCode ?? 0
if (code === MEDIA_ERR_ABORTED) return 'ignore'
if (input.readyState >= HAVE_CURRENT_DATA) return 'ignore'
if (!input.elementSrc) return 'ignore'
if (input.expectedSrc && input.elementSrc !== input.expectedSrc) return 'ignore'
if (!input.alreadyRetried) return 'retry'
return 'fallback'
}
+75
View File
@@ -0,0 +1,75 @@
import { classifyDirectPlayError } from './directPlayError.ts'
function check(name: string, cond: boolean) {
if (!cond) throw new Error(`classifyDirectPlayError: ${name}`)
}
const src = 'http://nas.local/api/stream/m1?token=abc'
check(
'aborted load is ignored',
classifyDirectPlayError({
errorCode: 1,
readyState: 0,
elementSrc: src,
expectedSrc: src,
alreadyRetried: false,
}) === 'ignore',
)
check(
'already-playing error is ignored',
classifyDirectPlayError({
errorCode: 4,
readyState: 3,
elementSrc: src,
expectedSrc: src,
alreadyRetried: false,
}) === 'ignore',
)
check(
'stale src error is ignored',
classifyDirectPlayError({
errorCode: 4,
readyState: 0,
elementSrc: 'http://nas.local/old',
expectedSrc: src,
alreadyRetried: false,
}) === 'ignore',
)
check(
'empty src during reload is ignored',
classifyDirectPlayError({
errorCode: 4,
readyState: 0,
elementSrc: '',
expectedSrc: src,
alreadyRetried: false,
}) === 'ignore',
)
check(
'first decode/network miss retries',
classifyDirectPlayError({
errorCode: 4,
readyState: 0,
elementSrc: src,
expectedSrc: src,
alreadyRetried: false,
}) === 'retry',
)
check(
'second failure falls back to HLS',
classifyDirectPlayError({
errorCode: 4,
readyState: 0,
elementSrc: src,
expectedSrc: src,
alreadyRetried: true,
}) === 'fallback',
)
console.log('playerPageModel.test.ts ok')
+11 -6
View File
@@ -8,13 +8,18 @@ const directVideoCodecs = ['h264', 'avc', 'avc1']
const directAudioCodecs = ['aac', 'mp3', 'opus']
/**
* 判断媒体是否为远程直链或挂载直连流媒体(STRM 或 Emby 远程挂载)。
* 这类媒体服务端 302 重定向到直链或进行原生流中继,本地不具备原始文件,
* 无法也不应该进行 ffmpeg 转码,恒走直连播放。
* 远程 Emby 挂载:本地没有原始文件,网页端只能直连,不能转码。
*/
export function isDirectStreamMedia(media?: Media | null): boolean {
if (!media) return false
if (isRemoteEmbyID(media.id)) return true
return isRemoteEmbyID(media.id)
}
/**
* STRM / 云盘直链:默认仍走直连;浏览器解不了时再回退 HLS 转码。
*/
export function isStrmMedia(media?: Media | null): boolean {
if (!media) return false
const container = (media.container ?? '').toLowerCase()
return container.includes('strm') || String(media.strm_url ?? '').trim() !== ''
}
@@ -24,8 +29,8 @@ export function pickPlayerMode(media: Media): PlayerMode {
}
export function needsTranscodeForBrowser(media: Media): boolean {
// Emby 远程挂载与 .strm 媒体一样,均为直连流,无法进行本地转码,恒走 direct play。
if (isDirectStreamMedia(media)) return false
// Emby 远程挂载无法本地转码。STRM 先直连,失败后再由播放器切 HLS。
if (isDirectStreamMedia(media) || isStrmMedia(media)) return false
const container = (media.container ?? '').toLowerCase()
const videoCodec = (media.video_codec ?? '').toLowerCase()
+3
View File
@@ -70,6 +70,7 @@ export interface StrmSyncPath {
download_meta: boolean
upload_meta: boolean
delete_dir: boolean
keep_ext: boolean
cron: string
enable_cron: boolean
sync_mode?: 'incremental' | 'full'
@@ -98,6 +99,7 @@ export interface StrmSyncPathInput {
download_meta?: boolean
upload_meta?: boolean
delete_dir?: boolean
keep_ext?: boolean
cron?: string
enable_cron?: boolean
sync_mode?: 'incremental' | 'full'
@@ -167,6 +169,7 @@ export interface StrmSettingsMap {
'strm.download_meta': string
'strm.upload_meta': string
'strm.delete_dir': string
'strm.keep_ext': string
'strm.download_threads': string
'strm.upload_threads': string
[key: string]: string
+60
View File
@@ -0,0 +1,60 @@
import type { Media } from '../types'
/** 从路径 / strm URL 推断容器扩展名(mkv/mp4…)。 */
export function mediaVersionContainer(media: Media): string {
const path = (media.path || '').replace(/\\/g, '/')
const base = path.split('/').pop() || ''
const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')).toLowerCase() : ''
let name = ext ? base.slice(0, -ext.length) : base
if (ext === '.strm') {
const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : ''
if (second && second !== '.strm') {
return second.replace(/^\./, '')
}
const strm = (media.strm_url || '').toLowerCase()
const marker = '/video.'
const idx = strm.lastIndexOf(marker)
if (idx >= 0) {
let rest = strm.slice(idx + marker.length)
const end = rest.search(/[?#&/]/)
if (end >= 0) rest = rest.slice(0, end)
rest = rest.replace(/^\./, '').trim()
if (rest) return rest
}
return 'strm'
}
return ext.replace(/^\./, '')
}
function formatSize(bytes: number): string {
if (!bytes || bytes < 1024) return bytes ? `${bytes} B` : ''
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = -1
do {
value /= 1024
unit += 1
} while (value >= 1024 && unit < units.length - 1)
return `${value.toFixed(1)} ${units[unit]}`
}
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积,缺省回退文件名。 */
export function mediaVersionLabel(media: Media): string {
const parts: string[] = []
if (media.height > 0) parts.push(`${media.height}p`)
else if (media.width > 0) parts.push(`${media.width}w`)
const container = mediaVersionContainer(media)
if (container && container !== 'strm') parts.push(container.toUpperCase())
if (media.video_codec?.trim()) parts.push(media.video_codec.trim().toUpperCase())
const size = formatSize(media.size_bytes)
if (size) parts.push(size)
if (parts.length > 0) return parts.join(' · ')
const base = (media.path || '').replace(/\\/g, '/').split('/').pop()
return base || media.title || media.id
}
export function mediaVersionsOf(media: Media | null | undefined): Media[] {
if (!media) return []
if (media.versions && media.versions.length > 1) return media.versions
return [media]
}
+2 -1
View File
@@ -20,5 +20,6 @@
"@/*": ["src/*"]
}
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}