mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b23b1d728e | |||
| ad427fc0d2 | |||
| c489374b61 | |||
| 5a47e6f52d | |||
| c4b8114115 | |||
| 0e592105c4 | |||
| f20c9c3234 | |||
| 38b85bb487 | |||
| 6bf8a6878a | |||
| 2e636ce43f | |||
| 28aa466107 | |||
| f1b8010496 | |||
| 394d1a3cda |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ssh": {
|
||||
"command": "cmd.exe",
|
||||
"args": [
|
||||
"/c",
|
||||
"npx",
|
||||
"-y",
|
||||
"@aiondadotcom/mcp-ssh"
|
||||
],
|
||||
"env": {
|
||||
"ProgramData": "C:\\ProgramData"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -488,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
|
||||
@@ -497,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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, " ")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ 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)
|
||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,23 +6,35 @@ 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)
|
||||
if _, err := s.transcoder.EnsureJobFrom(r.Context(), mediaID, startSec, seekGen); 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 +60,46 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
return nil
|
||||
}
|
||||
|
||||
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 only need auth/profile tokens; drop start= so a seek
|
||||
// restart does not keep forcing EnsureJobFrom on every .ts hit.
|
||||
q := filterHLSSegmentQuery(rawQuery)
|
||||
if q == "" {
|
||||
return playlist
|
||||
}
|
||||
lines := strings.SplitAfter(playlist, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
@@ -65,12 +113,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", "_seek":
|
||||
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)
|
||||
|
||||
+104
-52
@@ -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,58 +371,98 @@ 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 的上传接口不保证同名覆盖,直接上传可能产生同名重复文件。
|
||||
// 删除失败时不中止任务——继续上传新文件,旧副本交由下次同步的 cleanupBatchRedundantFiles
|
||||
// 按目录批量清理(下次同步会看到新旧两个版本,命中新版本后把旧版本 cid 收入 pendingDeletes
|
||||
// 异步删除)。这样避免了「删旧失败 → 任务重试 → 再次删旧失败 → 永远无法上传」的死循环。
|
||||
if task.RemoteRef != "" {
|
||||
open115, ok := provider.(cloud.OpenAPI115Provider)
|
||||
if !ok {
|
||||
finish(model.StrmTaskFailed, "该网盘不支持删除远端旧元数据")
|
||||
return
|
||||
}
|
||||
refs := strings.Split(task.RemoteRef, ",")
|
||||
if err := open115.OpenClient().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))
|
||||
// 不 return:继续上传新文件,旧副本由下次同步清理
|
||||
}
|
||||
}
|
||||
// 优先使用直接本地文件上传接口,零拷贝且彻底根除并发临时文件同名碰撞
|
||||
if localUploader, ok := provider.(interface {
|
||||
PutLocalFile(ctx context.Context, parentCID, localPath string) error
|
||||
}); ok {
|
||||
if err := localUploader.PutLocalFile(ctx, task.RemotePath, task.LocalPath); err != nil {
|
||||
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
finish(model.StrmTaskDone, "")
|
||||
return
|
||||
}
|
||||
client := open115.OpenClient()
|
||||
|
||||
f, err := os.Open(task.LocalPath)
|
||||
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, "")
|
||||
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 != "" {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
result, err := client.Upload(ctx, task.LocalPath, task.RemotePath, "", "")
|
||||
if err != nil {
|
||||
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
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。
|
||||
func (s *StrmService) downloadTaskFailWithRetry(task *model.StrmDownloadTask, message string) {
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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: "元数据上传并发数"},
|
||||
@@ -731,6 +733,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 +967,7 @@ type strmPathConfig struct {
|
||||
DownloadMeta bool
|
||||
UploadMeta bool
|
||||
DeleteDir bool
|
||||
KeepExt bool
|
||||
}
|
||||
|
||||
// ─── 本地目录浏览(添加同步目录用,兼容 Windows/Linux) ─────────────────────────
|
||||
|
||||
+177
-73
@@ -49,15 +49,19 @@ type strmSyncState struct {
|
||||
mu sync.Mutex
|
||||
processed int // 已处理文件计数(用于定期落库进度)
|
||||
lastProgressFlush time.Time // 上次进度落库时间
|
||||
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
|
||||
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
|
||||
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
|
||||
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
|
||||
seenMetaTarget map[string]cloud.FileEntry
|
||||
seenVideoTarget map[string]cloud.FileEntry
|
||||
// remoteVideos:prefer 模式下同名(去扩展名)视频候选列表,walk 结束后择优写盘
|
||||
remoteVideos map[string][]remoteVideoCandidate
|
||||
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
|
||||
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
|
||||
pendingDownloads []*model.StrmDownloadTask
|
||||
pendingUploads []*model.StrmUploadTask
|
||||
// recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队
|
||||
recentDoneUploadSizes map[string]int64
|
||||
pendingDownloads []*model.StrmDownloadTask
|
||||
pendingUploads []*model.StrmUploadTask
|
||||
dirCache sync.Map // dirID (string) -> relativePath (string)
|
||||
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
|
||||
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
|
||||
@@ -65,6 +69,13 @@ type strmSyncState struct {
|
||||
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
|
||||
}
|
||||
|
||||
// remoteVideoCandidate 记录同名视频的一个远端副本(不同扩展名/体积)。
|
||||
type remoteVideoCandidate struct {
|
||||
entry cloud.FileEntry
|
||||
rel string
|
||||
ext string
|
||||
}
|
||||
|
||||
// StartSync 启动一次同步(异步执行,同一目录同时只允许一个任务)。
|
||||
// syncType 支持 "incremental"(默认增量)和 "full"(全量同步)。
|
||||
func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...string) error {
|
||||
@@ -274,6 +285,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
|
||||
remoteMeta: map[string][]remoteMetaItem{},
|
||||
seenMetaTarget: map[string]cloud.FileEntry{},
|
||||
seenVideoTarget: map[string]cloud.FileEntry{},
|
||||
remoteVideos: map[string][]remoteVideoCandidate{},
|
||||
}
|
||||
if p.Provider != model.StrmProviderLocal {
|
||||
acct, err := s.repo.StrmAccount.FindByID(ctx, p.AccountID)
|
||||
@@ -350,6 +362,14 @@ func (st *strmSyncState) run() error {
|
||||
} else {
|
||||
st.activeUploadPaths = map[string]bool{}
|
||||
}
|
||||
since := time.Now().Add(-strmRecentUploadSkipWindow)
|
||||
if recent, err := st.s.repo.StrmUpload.GetRecentDoneUploadSizeMap(st.ctx, st.p.ID, since); err == nil {
|
||||
st.recentDoneUploadSizes = recent
|
||||
} else {
|
||||
st.recentDoneUploadSizes = map[string]int64{}
|
||||
st.s.log.Warn("加载近期已完成上传任务失败,跳过 done 窗口去重",
|
||||
zap.String("path_id", st.p.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if st.provider != nil {
|
||||
@@ -376,6 +396,7 @@ func (st *strmSyncState) run() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
st.flushPreferredVideos()
|
||||
st.flushPendingDownloads()
|
||||
st.flushProgress()
|
||||
if st.cfg.UploadMeta && st.provider != nil {
|
||||
@@ -1185,21 +1206,123 @@ func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *clo
|
||||
return allFiles, nil
|
||||
}
|
||||
|
||||
// handleVideo 生成/更新 .strm 文件。
|
||||
// handleVideo 收集/生成 .strm 文件。
|
||||
// KeepExt=true:每个视频写 name.ext.strm,保留全部版本。
|
||||
// KeepExt=false(默认):同名候选先入队,walk 结束后按体积→mtime→扩展名优先级择优写一条 name.strm。
|
||||
func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
relSansExt := rel[:len(rel)-len(ext)]
|
||||
st.mu.Lock()
|
||||
if st.seenVideo["v:"+relSansExt] {
|
||||
st.mu.Unlock()
|
||||
if st.cfg.KeepExt {
|
||||
st.writeVideoStrm(entry, rel, ext, rel+".strm", true)
|
||||
return
|
||||
}
|
||||
st.seenVideo["v:"+relSansExt] = true
|
||||
st.mu.Lock()
|
||||
if st.remoteVideos == nil {
|
||||
st.remoteVideos = map[string][]remoteVideoCandidate{}
|
||||
}
|
||||
st.remoteVideos[relSansExt] = append(st.remoteVideos[relSansExt], remoteVideoCandidate{
|
||||
entry: entry,
|
||||
rel: rel,
|
||||
ext: ext,
|
||||
})
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
}
|
||||
|
||||
// flushPreferredVideos 在 prefer 模式下对同名多版本择优写盘,并记录冲突日志。
|
||||
func (st *strmSyncState) flushPreferredVideos() {
|
||||
if st.cfg.KeepExt {
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
pending := st.remoteVideos
|
||||
st.remoteVideos = map[string][]remoteVideoCandidate{}
|
||||
st.mu.Unlock()
|
||||
for base, cands := range pending {
|
||||
if len(cands) == 0 {
|
||||
continue
|
||||
}
|
||||
winnerIdx := 0
|
||||
for i := 1; i < len(cands); i++ {
|
||||
if betterStrmVideoCandidate(cands[i], cands[winnerIdx], st.cfg.VideoExt) {
|
||||
winnerIdx = i
|
||||
}
|
||||
}
|
||||
winner := cands[winnerIdx]
|
||||
if len(cands) > 1 {
|
||||
skippedRels := make([]string, 0, len(cands)-1)
|
||||
skippedRefs := make([]string, 0, len(cands)-1)
|
||||
for i, c := range cands {
|
||||
if i == winnerIdx {
|
||||
continue
|
||||
}
|
||||
skippedRels = append(skippedRels, c.rel)
|
||||
ref := strings.TrimSpace(c.entry.PickCode)
|
||||
if ref == "" {
|
||||
ref = strings.TrimSpace(c.entry.ID)
|
||||
}
|
||||
if ref != "" {
|
||||
skippedRefs = append(skippedRefs, ref)
|
||||
}
|
||||
}
|
||||
st.s.log.Info("strm multi-version prefer",
|
||||
zap.String("base", base),
|
||||
zap.String("winner", winner.rel),
|
||||
zap.String("winner_ref", firstNonEmpty(winner.entry.PickCode, winner.entry.ID)),
|
||||
zap.Int64("winner_size", winner.entry.Size),
|
||||
zap.Strings("skipped", skippedRels),
|
||||
zap.Strings("skipped_refs", skippedRefs),
|
||||
)
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped += int64(len(cands) - 1)
|
||||
st.mu.Unlock()
|
||||
}
|
||||
// 候选已在 handleVideo 计入 Total,此处不再重复 touchProgress
|
||||
st.writeVideoStrm(winner.entry, winner.rel, winner.ext, base+".strm", false)
|
||||
}
|
||||
}
|
||||
|
||||
// betterStrmVideoCandidate 决定 prefer 模式下的赢家:体积更大 → mtime 更新 → video_ext 列表更靠前。
|
||||
func betterStrmVideoCandidate(candidate, current remoteVideoCandidate, videoExtOrder []string) bool {
|
||||
if candidate.entry.Size != current.entry.Size {
|
||||
return candidate.entry.Size > current.entry.Size
|
||||
}
|
||||
if candidate.entry.MTime != current.entry.MTime {
|
||||
return candidate.entry.MTime > current.entry.MTime
|
||||
}
|
||||
return strmExtPriority(candidate.ext, videoExtOrder) < strmExtPriority(current.ext, videoExtOrder)
|
||||
}
|
||||
|
||||
func strmExtPriority(ext string, order []string) int {
|
||||
ext = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
|
||||
for i, item := range order {
|
||||
if strings.TrimPrefix(strings.ToLower(strings.TrimSpace(item)), ".") == ext {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(order) + 1
|
||||
}
|
||||
|
||||
// writeVideoStrm 把单个视频写成目标 .strm(targetRel 相对本地输出根)。
|
||||
// countProgress 控制是否计入 Total(prefer 模式下候选已在收集阶段计数)。
|
||||
func (st *strmSyncState) writeVideoStrm(entry cloud.FileEntry, rel, ext, targetRel string, countProgress bool) {
|
||||
seenKey := "v:" + strings.TrimSuffix(targetRel, ".strm")
|
||||
st.mu.Lock()
|
||||
if st.seenVideo[seenKey] {
|
||||
st.mu.Unlock()
|
||||
if countProgress {
|
||||
st.touchProgress()
|
||||
}
|
||||
return
|
||||
}
|
||||
st.seenVideo[seenKey] = true
|
||||
st.mu.Unlock()
|
||||
|
||||
targetRel := relSansExt + ".strm"
|
||||
target, err := joinLocalRel(st.p.LocalPath, targetRel)
|
||||
if err != nil {
|
||||
st.s.log.Warn("strm target path out of root", zap.String("rel", targetRel), zap.Error(err))
|
||||
if countProgress {
|
||||
st.touchProgress()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1209,28 +1332,35 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
}
|
||||
if _, exists := st.seenVideoTarget[target]; exists {
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
if countProgress {
|
||||
st.touchProgress()
|
||||
}
|
||||
return
|
||||
}
|
||||
st.seenVideoTarget[target] = entry
|
||||
st.mu.Unlock()
|
||||
|
||||
done := func() {
|
||||
if countProgress {
|
||||
st.touchProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 增量同步模式快速检查:本地 strm 文件存在、非空且修改时间与远端 mtime 一致,直接跳过无需读磁盘
|
||||
if st.syncType == model.StrmSyncTypeIncremental && entry.MTime > 0 {
|
||||
if info, err := os.Stat(target); err == nil && info.Size() > 0 && info.ModTime().Unix() == entry.MTime {
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
done()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
content, err := st.strmContent(entry, rel, ext)
|
||||
if err != nil {
|
||||
// 并发 worker 下 rec.Message 无锁写会有数据竞争,这里仅记录日志;
|
||||
// 最终同步结果的 message 由 finishSync 统一填充。
|
||||
st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err))
|
||||
done()
|
||||
return
|
||||
}
|
||||
existing := ""
|
||||
@@ -1238,7 +1368,6 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
existing = string(data)
|
||||
}
|
||||
if existing == content {
|
||||
// 对齐本地 strm 修改时间为远端 mtime,便于后续秒级比对
|
||||
if entry.MTime > 0 {
|
||||
mTime := time.Unix(entry.MTime, 0)
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
@@ -1246,21 +1375,24 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
done()
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
st.s.log.Warn("mkdir strm dir failed", zap.String("dir", filepath.Dir(target)), zap.Error(err))
|
||||
done()
|
||||
return
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
|
||||
st.s.log.Warn("write strm tmp failed", zap.String("file", target), zap.Error(err))
|
||||
done()
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
st.s.log.Warn("rename strm failed", zap.String("file", target), zap.Error(err))
|
||||
done()
|
||||
return
|
||||
}
|
||||
if entry.MTime > 0 {
|
||||
@@ -1270,7 +1402,7 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
st.mu.Lock()
|
||||
st.rec.NewStrm++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
done()
|
||||
}
|
||||
|
||||
// strmContent 构建 strm 文件内容(一行指向本服务播放端点的 URL)。
|
||||
@@ -1524,50 +1656,8 @@ func (st *strmSyncState) walkLocalSource() error {
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
relSansExt := rel[:len(rel)-len(ext)]
|
||||
st.mu.Lock()
|
||||
st.seenVideo["v:"+relSansExt] = true
|
||||
st.mu.Unlock()
|
||||
content, err := st.strmContent(cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size()}, rel, ext)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
target, err := joinLocalRel(st.p.LocalPath, relSansExt+".strm")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mTime := info.ModTime()
|
||||
if st.syncType == model.StrmSyncTypeIncremental {
|
||||
if tInfo, err := os.Stat(target); err == nil && tInfo.Size() > 0 && tInfo.ModTime().Unix() == mTime.Unix() {
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile(target); err == nil && string(data) == content {
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return nil
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o644); err == nil {
|
||||
_ = os.Rename(tmp, target)
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
} else {
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.rec.NewStrm++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
entry := cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size(), MTime: info.ModTime().Unix()}
|
||||
st.handleVideo(entry, rel, ext)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1575,6 +1665,7 @@ func (st *strmSyncState) walkLocalSource() error {
|
||||
// scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。
|
||||
// 以本地为准:网盘端不存在、同名不同大小、或同名同大小但 SHA1 不同(115 提供
|
||||
// 远端哈希时做内容级比对)均入队覆盖上传;同名同大小同内容视为同一文件跳过。
|
||||
// 另:近期已成功上传且大小未变的路径跳过入队,缩短「任务 done 但 115 列表滞后」窗口。
|
||||
func (st *strmSyncState) scanLocalMetaForUpload() error {
|
||||
defer st.flushPendingUploads()
|
||||
if st.activeUploadPaths == nil {
|
||||
@@ -1584,6 +1675,14 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
|
||||
st.activeUploadPaths = map[string]bool{}
|
||||
}
|
||||
}
|
||||
if st.recentDoneUploadSizes == nil {
|
||||
since := time.Now().Add(-strmRecentUploadSkipWindow)
|
||||
if recent, err := st.s.repo.StrmUpload.GetRecentDoneUploadSizeMap(st.ctx, st.p.ID, since); err == nil {
|
||||
st.recentDoneUploadSizes = recent
|
||||
} else {
|
||||
st.recentDoneUploadSizes = map[string]int64{}
|
||||
}
|
||||
}
|
||||
var pendingDeletes map[string][]string
|
||||
if st.p.Provider == model.StrmProvider115 {
|
||||
pendingDeletes = map[string][]string{}
|
||||
@@ -1632,23 +1731,28 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchedIdx >= 0 {
|
||||
// 远端已存在完全一致的副本,跳过上传!
|
||||
// 若远端还存在其他同名脏副本(副本总数 > 1),在 115 下收集待删除 ID,稍后按目录批量删除。
|
||||
// 严禁将已命中的最新副本 ID (matchedID) 放入待删列表,杜绝误杀唯一有效副本。
|
||||
if len(entries) > 1 && st.p.Provider == model.StrmProvider115 {
|
||||
parentCID := st.uploadRemoteTarget(rel)
|
||||
if parentCID != "" {
|
||||
matchedID := entries[matchedIdx].ID
|
||||
for i, it := range entries {
|
||||
if i != matchedIdx && it.ID != "" && it.ID != matchedID {
|
||||
pendingDeletes[parentCID] = append(pendingDeletes[parentCID], it.ID)
|
||||
}
|
||||
if matchedIdx >= 0 {
|
||||
// 远端已存在完全一致的副本,跳过上传!
|
||||
// 若远端还存在其他同名脏副本(副本总数 > 1),在 115 下收集待删除 ID,稍后按目录批量删除。
|
||||
// 严禁将已命中的最新副本 ID (matchedID) 放入待删列表,杜绝误杀唯一有效副本。
|
||||
if len(entries) > 1 && st.p.Provider == model.StrmProvider115 {
|
||||
parentCID := st.uploadRemoteTarget(rel)
|
||||
if parentCID != "" {
|
||||
matchedID := entries[matchedIdx].ID
|
||||
for i, it := range entries {
|
||||
if i != matchedIdx && it.ID != "" && it.ID != matchedID {
|
||||
pendingDeletes[parentCID] = append(pendingDeletes[parentCID], it.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 近期已成功上传且大小未变:115 列表可能尚未反映,避免重复入队
|
||||
if doneSize, ok := st.recentDoneUploadSizes[path]; ok && doneSize == info.Size() {
|
||||
return nil
|
||||
}
|
||||
|
||||
remoteTarget := st.uploadRemoteTarget(rel)
|
||||
|
||||
@@ -1453,3 +1453,201 @@ func TestWalk115AdaptiveHierarchicalFlatScan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,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,13 +169,23 @@ 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,
|
||||
|
||||
+267
-22
@@ -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,14 @@ 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
|
||||
// done is closed when the ffmpeg goroutine fully exits (after process death).
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -88,13 +104,29 @@ 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) {
|
||||
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,36 +134,69 @@ 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 sameHLSStart(existing.startSec, startSec) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJob(existing, startSec, seekGen) {
|
||||
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
|
||||
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 sameHLSStart(existing.startSec, startSec) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJob(existing, startSec, seekGen) {
|
||||
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())
|
||||
job := &hlsJob{
|
||||
mediaID: mediaID,
|
||||
@@ -140,11 +205,191 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
startedAt: time.Now(),
|
||||
lastAccess: time.Now(),
|
||||
encoder: t.effectiveEncoder(),
|
||||
startSec: startSec,
|
||||
seekGen: seekGen,
|
||||
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
|
||||
}
|
||||
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 (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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -2,11 +2,20 @@ 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
|
||||
}
|
||||
|
||||
type ffmpegArgSettings struct {
|
||||
encoder string
|
||||
bitrate string
|
||||
@@ -31,11 +40,18 @@ 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)
|
||||
|
||||
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 +127,20 @@ 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)
|
||||
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 +192,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,185 @@ 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 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 TestFilterHLSSegmentQueryDropsStart(t *testing.T) {
|
||||
got := filterHLSSegmentQuery("token=abc&start=120.5&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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
+22
-2
@@ -146,8 +146,28 @@ 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): 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()}`
|
||||
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -46,6 +46,12 @@ 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({
|
||||
@@ -65,6 +71,9 @@ export function PlayerControls({
|
||||
playlistOpen = false,
|
||||
hasPlaylist = false,
|
||||
onTogglePlaylist,
|
||||
knownDuration = 0,
|
||||
streamOffset = 0,
|
||||
onSeekAbsolute,
|
||||
}: PlayerControlsProps) {
|
||||
const video = () => videoRef.current
|
||||
const container = () =>
|
||||
@@ -91,6 +100,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
|
||||
@@ -175,15 +185,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,7 +248,14 @@ export function PlayerControls({
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoRef])
|
||||
}, [videoRef, knownDuration, streamOffset])
|
||||
|
||||
// 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(() => {
|
||||
@@ -248,12 +278,24 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,11 +306,7 @@ export function PlayerControls({
|
||||
}
|
||||
|
||||
const handleSeekEnd = (v: number) => {
|
||||
const el = video()
|
||||
if (el) {
|
||||
el.currentTime = v
|
||||
setCurrentTime(v)
|
||||
}
|
||||
applyAbsoluteSeek(v)
|
||||
setIsScrubbing(false)
|
||||
setScrubValue(null)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
+182
-37
@@ -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'
|
||||
@@ -18,6 +18,8 @@ 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,7 +28,8 @@ 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.
|
||||
@@ -69,11 +72,14 @@ export function PlayerPage() {
|
||||
const [directOnly, setDirectOnly] = 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 +97,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(() => {
|
||||
@@ -141,6 +144,7 @@ export function PlayerPage() {
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuInfo(null)
|
||||
setDanmakuSearch(kw || null)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
}, [])
|
||||
|
||||
const danmakuLoaded = useCallback((info: DanmakuLoadedInfo | null) => {
|
||||
@@ -164,6 +168,7 @@ export function PlayerPage() {
|
||||
setDanmakuSearching(true)
|
||||
// 展示当前所选来源(面板标题处可见)。
|
||||
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
}, [])
|
||||
|
||||
// 回到自动匹配(清除用户手动选择)。
|
||||
@@ -174,12 +179,14 @@ export function PlayerPage() {
|
||||
setDanmakuSearch(null)
|
||||
setDanmakuSelectedSource('')
|
||||
setDanmakuInfo(null)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
}, [])
|
||||
|
||||
// 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕
|
||||
useEffect(() => {
|
||||
setMedia(null)
|
||||
setLoadError('')
|
||||
setHlsStartSec(0)
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuSearch(null)
|
||||
@@ -202,7 +209,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('')
|
||||
@@ -232,18 +239,44 @@ 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 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)
|
||||
void import('hls.js').then(({ default: HlsCtor }) => {
|
||||
if (cancelled || !ref.current) return
|
||||
if (HlsCtor.isSupported()) {
|
||||
const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false })
|
||||
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 ((fresh.duration_sec || 0) > 0) setMedia(fresh)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
hls.on(HlsCtor.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
setHlsUnavailable(true)
|
||||
@@ -254,30 +287,61 @@ export function PlayerPage() {
|
||||
setParams(params, { replace: true })
|
||||
}
|
||||
})
|
||||
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')
|
||||
}
|
||||
void video.play().catch(() => undefined)
|
||||
}).catch(() => {
|
||||
if (cancelled) return
|
||||
setHlsUnavailable(true)
|
||||
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
|
||||
setMode('direct')
|
||||
})
|
||||
} else {
|
||||
video.src = streamURL(media.id)
|
||||
if (hlsUnavailable && needsTranscodeForBrowser(media)) {
|
||||
video.src = streamURL(mediaId)
|
||||
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
|
||||
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
||||
}
|
||||
void video.play().catch(() => undefined)
|
||||
}
|
||||
return () => teardownHls(media.id, mode === 'hls')
|
||||
}, [hlsUnavailable, media, mode, params, setParams, teardownHls])
|
||||
return () => {
|
||||
cancelled = true
|
||||
teardownHls()
|
||||
}
|
||||
}, [hlsUnavailable, hlsStartSec, mediaId, mode, params, setParams, 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,8 +359,21 @@ 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
|
||||
const applyResume = () => {
|
||||
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
|
||||
video.currentTime = resumePosition
|
||||
@@ -313,20 +390,35 @@ export function PlayerPage() {
|
||||
video.addEventListener('loadedmetadata', applyResume, { once: true })
|
||||
return () => video.removeEventListener('loadedmetadata', applyResume)
|
||||
}
|
||||
}, [resumePosition, initialSeekDone])
|
||||
}, [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 +426,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 +508,15 @@ export function PlayerPage() {
|
||||
[navigate, location.search, location.state],
|
||||
)
|
||||
|
||||
const versionList = useMemo(() => mediaVersionsOf(media), [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)
|
||||
@@ -505,11 +606,46 @@ export function PlayerPage() {
|
||||
return
|
||||
}
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
if (next === 'hls') {
|
||||
setHlsStartSec(0)
|
||||
}
|
||||
setMode(next)
|
||||
params.set('mode', next)
|
||||
setParams(params, { replace: true })
|
||||
}, [isDirectStream, mode, params, setParams])
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
|
||||
const selectSubtitle = useCallback((index: number) => {
|
||||
setSubtitleIndex(index)
|
||||
@@ -524,12 +660,9 @@ export function PlayerPage() {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
if (mode === 'direct') {
|
||||
if (isRemoteEmbyID(media?.id)) {
|
||||
if (isRemoteEmbyID(media?.id) || isDirectStreamMedia(media)) {
|
||||
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
|
||||
toast.error('直接播放失败,建议使用外部播放器')
|
||||
} else if (isDirectStreamMedia(media)) {
|
||||
setPlayerError('直接播放失败。该媒体为 STRM 远程直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器播放。')
|
||||
toast.error('直接播放失败,建议使用外部播放器')
|
||||
} else if (directOnly) {
|
||||
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
|
||||
toast.error('直接播放失败(客户端直连解码模式)')
|
||||
@@ -549,22 +682,29 @@ export function PlayerPage() {
|
||||
toast.error('视频播放失败,请检查文件是否存在')
|
||||
}, [directOnly, hlsUnavailable, media, mode, params, setParams])
|
||||
|
||||
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 +720,7 @@ export function PlayerPage() {
|
||||
danmakuArea={danmakuArea}
|
||||
danmakuSearch={danmakuSearch}
|
||||
danmakuEpisodeId={danmakuEpisodeId}
|
||||
danmakuSearchTrigger={danmakuSearchTrigger}
|
||||
danmakuOpen={danmakuOpen}
|
||||
onToggleDanmaku={toggleDanmakuOpen}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
@@ -593,6 +734,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 +763,7 @@ export function PlayerPage() {
|
||||
onFontSizeChange={setDanmakuFontSize}
|
||||
candidates={danmakuCandidates}
|
||||
selectedSource={danmakuSelectedSource}
|
||||
autoMatchTitle={danmakuAutoTitle}
|
||||
danmakuInfo={danmakuInfo}
|
||||
onSelectEpisode={danmakuSelectEpisode}
|
||||
onResetAuto={danmakuResetAuto}
|
||||
|
||||
@@ -24,6 +24,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 +42,9 @@ type PlayerVideoStageProps = {
|
||||
playlistOpen?: boolean
|
||||
hasPlaylist?: boolean
|
||||
onTogglePlaylist?: () => void
|
||||
knownDuration?: number
|
||||
streamOffset?: number
|
||||
onSeekAbsolute?: (seconds: number) => boolean
|
||||
}
|
||||
|
||||
export function PlayerVideoStage({
|
||||
@@ -58,6 +62,7 @@ export function PlayerVideoStage({
|
||||
danmakuArea,
|
||||
danmakuSearch,
|
||||
danmakuEpisodeId,
|
||||
danmakuSearchTrigger = 0,
|
||||
danmakuOpen,
|
||||
onToggleDanmaku,
|
||||
onDanmakuLoaded,
|
||||
@@ -73,6 +78,9 @@ export function PlayerVideoStage({
|
||||
playlistOpen,
|
||||
hasPlaylist,
|
||||
onTogglePlaylist,
|
||||
knownDuration,
|
||||
streamOffset,
|
||||
onSeekAbsolute,
|
||||
}: PlayerVideoStageProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||
@@ -280,6 +288,7 @@ export function PlayerVideoStage({
|
||||
area={danmakuArea}
|
||||
search={danmakuSearch}
|
||||
episodeId={danmakuEpisodeId}
|
||||
searchTrigger={danmakuSearchTrigger}
|
||||
onLoaded={onDanmakuLoaded}
|
||||
onCandidates={onDanmakuCandidates}
|
||||
/>
|
||||
@@ -316,6 +325,9 @@ export function PlayerVideoStage({
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={hasPlaylist}
|
||||
onTogglePlaylist={onTogglePlaylist}
|
||||
knownDuration={knownDuration}
|
||||
streamOffset={streamOffset}
|
||||
onSeekAbsolute={onSeekAbsolute}
|
||||
/>
|
||||
{danmakuPanel}
|
||||
{playlistPanel}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user