mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac546f1197 | |||
| 93c7487c96 | |||
| 2e8b1cb7a1 | |||
| cdb1564826 | |||
| 90c10137e9 | |||
| 20ceef1fcf | |||
| 43fdfe8202 |
@@ -40,15 +40,15 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
|
||||
t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
|
||||
}
|
||||
if cfg.Cache.RedisPrefix != "mmtl" {
|
||||
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
|
||||
}
|
||||
if cfg.Cache.MediaTTLSeconds != 15 {
|
||||
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
|
||||
}
|
||||
if cfg.Search.Index != "mmtl_media" {
|
||||
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
|
||||
}
|
||||
if cfg.Cache.RedisPrefix != "mmtl" {
|
||||
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
|
||||
}
|
||||
if cfg.Cache.MediaTTLSeconds != 15 {
|
||||
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
|
||||
}
|
||||
if cfg.Search.Index != "mmtl_media" {
|
||||
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
|
||||
}
|
||||
if cfg.Database.MaxIdleConns != defaultDatabaseMaxIdleConns {
|
||||
t.Fatalf("expected default MaxIdleConns %d, got %d", defaultDatabaseMaxIdleConns, cfg.Database.MaxIdleConns)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ func (h *ApiConfigHandler) TestApiConfig(c *gin.Context) {
|
||||
_ = h.svc.ApiConfig.UpdateTestResult(c.Request.Context(), provider, result)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": gin.H{
|
||||
"result": result,
|
||||
|
||||
@@ -30,4 +30,4 @@ func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,4 +144,4 @@ func TestEmbyLoginWithAuthorizationHeaderToken(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Authorization-token request = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,16 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
||||
routes[route.Method+" "+route.Path] = true
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"GET /api/me",
|
||||
"GET /api/auth/permissions",
|
||||
"GET /api/libraries",
|
||||
"GET /api/media",
|
||||
"GET /api/stream/:id",
|
||||
"GET /api/storage",
|
||||
"GET /api/watch-history",
|
||||
"GET /api/playback/:id/info",
|
||||
} {
|
||||
for _, want := range []string{
|
||||
"GET /api/me",
|
||||
"GET /api/auth/permissions",
|
||||
"GET /api/libraries",
|
||||
"GET /api/media",
|
||||
"GET /api/stream/:id",
|
||||
"GET /api/storage",
|
||||
"GET /api/watch-history",
|
||||
"GET /api/playback/:id/info",
|
||||
} {
|
||||
if !routes[want] {
|
||||
t.Fatalf("%s route is not registered", want)
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ func statsUserHandler(svc *service.Container) gin.HandlerFunc {
|
||||
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
|
||||
Where("user_id = ?", uid).Count(&total).Error
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": uid,
|
||||
"watched_ms": watched,
|
||||
"plays": total,
|
||||
"user_id": uid,
|
||||
"watched_ms": watched,
|
||||
"plays": total,
|
||||
"watched_hours": float64(watched) / 1000.0 / 3600.0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -410,7 +410,11 @@ func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func strmPlayHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
provider := strings.TrimSpace(c.Param("provider"))
|
||||
result, err := svc.Strm.ResolvePlay(c.Request.Context(), provider, url.Values(c.Request.URL.Query()))
|
||||
q := url.Values(c.Request.URL.Query())
|
||||
if userAgent := c.GetHeader("User-Agent"); userAgent != "" {
|
||||
q.Set("__ua", userAgent)
|
||||
}
|
||||
result, err := svc.Strm.ResolvePlay(c.Request.Context(), provider, q)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStrmPlayNotFound) {
|
||||
c.Status(http.StatusNotFound)
|
||||
|
||||
@@ -58,12 +58,12 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"groups": []gin.H{
|
||||
{
|
||||
"key": "general",
|
||||
"label": "常规",
|
||||
"items": []gin.H{
|
||||
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
|
||||
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
|
||||
{
|
||||
"key": "general",
|
||||
"label": "常规",
|
||||
"items": []gin.H{
|
||||
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
|
||||
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
|
||||
{"key": "transcode.hw_accel", "type": "select", "label": "硬件编码器"},
|
||||
{"key": "transcode.hw_enabled", "type": "toggle", "label": "启用硬件加速"},
|
||||
{"key": "transcode.max_jobs", "type": "number", "label": "最大并发"},
|
||||
@@ -120,10 +120,10 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
{"key": "qbittorrent.password", "type": "text"},
|
||||
{"key": "qbittorrent.savepath", "type": "text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "system-update",
|
||||
"label": "系统更新",
|
||||
},
|
||||
{
|
||||
"key": "system-update",
|
||||
"label": "系统更新",
|
||||
"items": []gin.H{
|
||||
{"key": "system.update.image", "type": "text", "label": "应用镜像"},
|
||||
{"key": "system.update.compose_dir", "type": "text", "label": "Docker Compose 安装目录"},
|
||||
|
||||
@@ -7,4 +7,4 @@ func finishHTTPTask(task *service.TaskHandle, err error, stage, message string,
|
||||
return
|
||||
}
|
||||
task.Finish(err, service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +42,14 @@ func EmbyAuthRequired(secret string) gin.HandlerFunc {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil || !parsed.Valid || claims.UserID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"Code": 40101,
|
||||
"Message": "Invalid token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if err != nil || !parsed.Valid || claims.UserID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"Code": 40101,
|
||||
"Message": "Invalid token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(EmbyCtxUserID, claims.UserID)
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
|
||||
@@ -106,13 +106,13 @@ func TestAuthRequiredSyncsAccessTokenCookieFromBearer(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired(secret))
|
||||
router.GET("/api/test-auth-cookie", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired(secret))
|
||||
router.GET("/api/test-auth-cookie", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test-auth-cookie", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test-auth-cookie", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
@@ -39,7 +39,7 @@ func PredefinedProviders() []ApiProvider {
|
||||
{ID: "openai", Name: "OpenAI", Description: "GPT 系列模型", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "deepseek", Name: "DeepSeek", Description: "DeepSeek 大模型", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "siliconflow", Name: "SiliconFlow", Description: "AI 模型聚合 API", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "adult", Name: "Adult / 番号", Description: "JavDB/JavBus 成人内容元数据与 Cookie 凭据", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "metatube", Name: "MetaTube", Description: "MetaTube Server 番号元数据后端服务", HasAPIKey: true, HasBaseURL: true},
|
||||
}
|
||||
{ID: "adult", Name: "Adult / 番号", Description: "JavDB/JavBus 成人内容元数据与 Cookie 凭据", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "metatube", Name: "MetaTube", Description: "MetaTube Server 番号元数据后端服务", HasAPIKey: true, HasBaseURL: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type UserPermission struct {
|
||||
UserID string `gorm:"uniqueIndex;size:36;not null" json:"user_id"`
|
||||
|
||||
// 默认开启(6项)- Basic
|
||||
CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
|
||||
CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
|
||||
CanPlayMedia bool `gorm:"default:true" json:"can_play_media"`
|
||||
CanCast bool `gorm:"default:true" json:"can_cast"`
|
||||
CanExternalPlayer bool `gorm:"default:true" json:"can_external_player"`
|
||||
@@ -24,18 +24,18 @@ type UserPermission struct {
|
||||
CanViewHistory bool `gorm:"default:true" json:"can_view_history"`
|
||||
|
||||
// 默认关闭(13项)- Advanced
|
||||
CanEditMedia bool `gorm:"default:false" json:"can_edit_media"`
|
||||
CanRescrape bool `gorm:"default:false" json:"can_rescrape"`
|
||||
CanUseAI bool `gorm:"default:false" json:"can_use_ai"`
|
||||
CanCaptureFrames bool `gorm:"default:false" json:"can_capture_frames"`
|
||||
CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"`
|
||||
CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"`
|
||||
CanManageSites bool `gorm:"default:false" json:"can_manage_sites"`
|
||||
CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"`
|
||||
CanManageUsers bool `gorm:"default:false" json:"can_manage_users"`
|
||||
CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
|
||||
CanManageStrm bool `gorm:"default:false" json:"can_manage_strm"`
|
||||
CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
|
||||
CanEditMedia bool `gorm:"default:false" json:"can_edit_media"`
|
||||
CanRescrape bool `gorm:"default:false" json:"can_rescrape"`
|
||||
CanUseAI bool `gorm:"default:false" json:"can_use_ai"`
|
||||
CanCaptureFrames bool `gorm:"default:false" json:"can_capture_frames"`
|
||||
CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"`
|
||||
CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"`
|
||||
CanManageSites bool `gorm:"default:false" json:"can_manage_sites"`
|
||||
CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"`
|
||||
CanManageUsers bool `gorm:"default:false" json:"can_manage_users"`
|
||||
CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
|
||||
CanManageStrm bool `gorm:"default:false" json:"can_manage_strm"`
|
||||
CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -52,26 +52,26 @@ func (p *UserPermission) BeforeCreate(_ *gorm.DB) error {
|
||||
// NewDefaultPermission 创建带有默认权限的 UserPermission。
|
||||
func NewDefaultPermission(userID string) *UserPermission {
|
||||
return &UserPermission{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
CanViewDashboard: true,
|
||||
CanPlayMedia: true,
|
||||
CanCast: true,
|
||||
CanExternalPlayer: true,
|
||||
CanFavorite: true,
|
||||
CanViewHistory: true,
|
||||
CanEditMedia: false,
|
||||
CanRescrape: false,
|
||||
CanUseAI: false,
|
||||
CanCaptureFrames: false,
|
||||
CanManageDownloads: false,
|
||||
CanManageSubscriptions: false,
|
||||
CanManageSites: false,
|
||||
CanUseAIAssistant: false,
|
||||
CanManageUsers: false,
|
||||
CanManageFiles: false,
|
||||
CanManageStrm: false,
|
||||
CanAccessSettings: false,
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
CanViewDashboard: true,
|
||||
CanPlayMedia: true,
|
||||
CanCast: true,
|
||||
CanExternalPlayer: true,
|
||||
CanFavorite: true,
|
||||
CanViewHistory: true,
|
||||
CanEditMedia: false,
|
||||
CanRescrape: false,
|
||||
CanUseAI: false,
|
||||
CanCaptureFrames: false,
|
||||
CanManageDownloads: false,
|
||||
CanManageSubscriptions: false,
|
||||
CanManageSites: false,
|
||||
CanUseAIAssistant: false,
|
||||
CanManageUsers: false,
|
||||
CanManageFiles: false,
|
||||
CanManageStrm: false,
|
||||
CanAccessSettings: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,13 +88,13 @@ func (p *UserPermission) PermissionMap() map[string]bool {
|
||||
"can_rescrape": p.CanRescrape,
|
||||
"can_use_ai": p.CanUseAI,
|
||||
"can_capture_frames": p.CanCaptureFrames,
|
||||
"can_manage_downloads": p.CanManageDownloads,
|
||||
"can_manage_subscriptions": p.CanManageSubscriptions,
|
||||
"can_manage_downloads": p.CanManageDownloads,
|
||||
"can_manage_subscriptions": p.CanManageSubscriptions,
|
||||
"can_manage_sites": p.CanManageSites,
|
||||
"can_use_ai_assistant": p.CanUseAIAssistant,
|
||||
"can_use_ai_assistant": p.CanUseAIAssistant,
|
||||
"can_manage_users": p.CanManageUsers,
|
||||
"can_manage_files": p.CanManageFiles,
|
||||
"can_manage_strm": p.CanManageStrm,
|
||||
"can_access_settings": p.CanAccessSettings,
|
||||
"can_access_settings": p.CanAccessSettings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,6 @@ func (r *StorageConfigRepository) Upsert(ctx context.Context, c *model.StorageCo
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
// Delete removes a storage config by ID.
|
||||
func (r *StorageConfigRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StorageConfig{}).Error
|
||||
|
||||
@@ -77,7 +77,11 @@ func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntr
|
||||
}
|
||||
|
||||
func (p *openAPI115Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
|
||||
url, err := p.c.GetDownloadURL(ctx, fileRef)
|
||||
return p.ResolveWithUA(ctx, fileRef, "")
|
||||
}
|
||||
|
||||
func (p *openAPI115Provider) ResolveWithUA(ctx context.Context, fileRef, ua string) (*DirectLink, error) {
|
||||
url, err := p.c.GetDownloadURLWithUA(ctx, fileRef, ua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -90,11 +90,15 @@ type RespBase struct {
|
||||
}
|
||||
|
||||
// doJSON 执行 HTTP 请求并解析为统一响应;带 AccessToken(access=true 时)。
|
||||
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int) (*RespBase, error) {
|
||||
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int, uas ...string) (*RespBase, error) {
|
||||
executor := c.executor
|
||||
if executor == nil {
|
||||
executor = GetGlobalExecutor()
|
||||
}
|
||||
ua := ""
|
||||
if len(uas) > 0 {
|
||||
ua = uas[0]
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
@@ -103,7 +107,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := c.buildRequest(ctx, method, rawURL, form, access)
|
||||
req, err := c.buildRequestWithUA(ctx, method, rawURL, form, access, ua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -187,6 +191,10 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
}
|
||||
|
||||
func (c *OpenClient) buildRequest(ctx context.Context, method, rawURL string, form map[string]string, access bool) (*http.Request, error) {
|
||||
return c.buildRequestWithUA(ctx, method, rawURL, form, access, "")
|
||||
}
|
||||
|
||||
func (c *OpenClient) buildRequestWithUA(ctx context.Context, method, rawURL string, form map[string]string, access bool, ua string) (*http.Request, error) {
|
||||
method = strings.ToUpper(method)
|
||||
var body io.Reader
|
||||
if method == http.MethodPost && len(form) > 0 {
|
||||
@@ -211,7 +219,11 @@ func (c *OpenClient) buildRequest(ctx context.Context, method, rawURL string, fo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", DefaultUA)
|
||||
targetUA := DefaultUA
|
||||
if strings.TrimSpace(ua) != "" {
|
||||
targetUA = strings.TrimSpace(ua)
|
||||
}
|
||||
req.Header.Set("User-Agent", targetUA)
|
||||
if method == http.MethodPost && len(form) > 0 {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
@@ -226,6 +238,11 @@ func (c *OpenClient) doAuthJSON(ctx context.Context, method, rawURL string, form
|
||||
return c.doJSON(ctx, method, rawURL, form, true, retries)
|
||||
}
|
||||
|
||||
// doAuthJSONWithUA 带自定义 User-Agent 的业务请求(换取直链等防盗链接口使用)。
|
||||
func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string, form map[string]string, retries int, ua string) (*RespBase, error) {
|
||||
return c.doJSON(ctx, method, rawURL, form, true, retries, ua)
|
||||
}
|
||||
|
||||
// IsThrottleCode 判断是否为限流错误码。
|
||||
func IsThrottleCode(code int) bool {
|
||||
return code == RequestMaxLimitCode || code == RequestRateLimitCode
|
||||
|
||||
@@ -127,11 +127,17 @@ type downloadURLData struct {
|
||||
// GetDownloadURL 获取下载直链(pickcode)。命中缓存直接返回,
|
||||
// 避免对同一文件反复换取直链触发 115 风控。
|
||||
func (c *OpenClient) GetDownloadURL(ctx context.Context, pickCode string) (string, error) {
|
||||
if cached := GetDownloadURLCache(pickCode); cached != "" {
|
||||
return c.GetDownloadURLWithUA(ctx, pickCode, "")
|
||||
}
|
||||
|
||||
// GetDownloadURLWithUA 支持按调用方/播放器 User-Agent 换取对应的 115 CDN 直链(用于 115 防盗链白名单校验)。
|
||||
func (c *OpenClient) GetDownloadURLWithUA(ctx context.Context, pickCode, ua string) (string, error) {
|
||||
ua = strings.TrimSpace(ua)
|
||||
if cached := GetDownloadURLCache(pickCode, ua); cached != "" {
|
||||
return cached, nil
|
||||
}
|
||||
params := map[string]string{"pick_code": pickCode}
|
||||
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1)
|
||||
resp, err := c.doAuthJSONWithUA(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1, ua)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -143,7 +149,7 @@ func (c *OpenClient) GetDownloadURL(ctx context.Context, pickCode string) (strin
|
||||
if first.URL.URL == "" {
|
||||
return "", fmt.Errorf("115: 下载地址为空(文件可能未上传完成或已被删除)")
|
||||
}
|
||||
SetDownloadURLCache(pickCode, first.URL.URL)
|
||||
SetDownloadURLCache(pickCode, first.URL.URL, ua)
|
||||
return first.URL.URL, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -28,17 +29,30 @@ var (
|
||||
urlCache = map[string]urlCacheEntry{}
|
||||
)
|
||||
|
||||
// urlCacheKey 构造缓存键名(pickCode + UA,实现直链防盗链按客户端 UA 独立缓存)。
|
||||
func urlCacheKey(pickCode, ua string) string {
|
||||
if ua == "" {
|
||||
return pickCode
|
||||
}
|
||||
return pickCode + "@" + ua
|
||||
}
|
||||
|
||||
// GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
|
||||
func GetDownloadURLCache(pickCode string) string {
|
||||
func GetDownloadURLCache(pickCode string, uas ...string) string {
|
||||
if pickCode == "" {
|
||||
return ""
|
||||
}
|
||||
ua := ""
|
||||
if len(uas) > 0 {
|
||||
ua = uas[0]
|
||||
}
|
||||
key := urlCacheKey(pickCode, ua)
|
||||
urlCacheMu.Lock()
|
||||
defer urlCacheMu.Unlock()
|
||||
entry, ok := urlCache[pickCode]
|
||||
entry, ok := urlCache[key]
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
if ok {
|
||||
delete(urlCache, pickCode)
|
||||
delete(urlCache, key)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -46,10 +60,14 @@ func GetDownloadURLCache(pickCode string) string {
|
||||
}
|
||||
|
||||
// SetDownloadURLCache 写入直链缓存。
|
||||
func SetDownloadURLCache(pickCode, url string) {
|
||||
func SetDownloadURLCache(pickCode string, url string, uas ...string) {
|
||||
if pickCode == "" || url == "" {
|
||||
return
|
||||
}
|
||||
ua := ""
|
||||
if len(uas) > 0 {
|
||||
ua = uas[0]
|
||||
}
|
||||
urlCacheMu.Lock()
|
||||
defer urlCacheMu.Unlock()
|
||||
if len(urlCache) >= maxCachedURLs {
|
||||
@@ -60,15 +78,20 @@ func SetDownloadURLCache(pickCode, url string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
urlCache[pickCode] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
|
||||
key := urlCacheKey(pickCode, ua)
|
||||
urlCache[key] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
|
||||
}
|
||||
|
||||
// ClearDownloadURLCache 删除指定 pickcode 的缓存(下载得到非 2xx 时调用)。
|
||||
// ClearDownloadURLCache 删除指定 pickcode 的所有缓存(下载得到非 2xx 时调用)。
|
||||
func ClearDownloadURLCache(pickCode string) {
|
||||
if pickCode == "" {
|
||||
return
|
||||
}
|
||||
urlCacheMu.Lock()
|
||||
defer urlCacheMu.Unlock()
|
||||
delete(urlCache, pickCode)
|
||||
for k := range urlCache {
|
||||
if k == pickCode || strings.HasPrefix(k, pickCode+"@") {
|
||||
delete(urlCache, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,21 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
|
||||
return STRMPlaybackEnabled(ctx, repo)
|
||||
}
|
||||
|
||||
// isStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
|
||||
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
|
||||
func isStrmMediaRow(m *model.Media) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(m.STRMURL) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(m.Container), "strm") {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm")
|
||||
}
|
||||
|
||||
func isHTTPPlaybackTarget(raw string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil || !u.IsAbs() {
|
||||
|
||||
@@ -66,7 +66,15 @@ func (s *StrmService) resolveCloudPlay(ctx context.Context, provider string, q u
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
link, err := p.Resolve(ctx, ref)
|
||||
var link *cloud.DirectLink
|
||||
ua := q.Get("__ua")
|
||||
if uaProvider, ok := p.(interface {
|
||||
ResolveWithUA(ctx context.Context, fileRef, ua string) (*cloud.DirectLink, error)
|
||||
}); ok && ua != "" {
|
||||
link, err = uaProvider.ResolveWithUA(ctx, ref, ua)
|
||||
} else {
|
||||
link, err = p.Resolve(ctx, ref)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -101,6 +101,12 @@ 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
|
||||
}
|
||||
if _, err := os.Stat(m.Path); err != nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
|
||||
@@ -59,8 +59,26 @@ export function HomePage() {
|
||||
})
|
||||
|
||||
// Fetch media items for all libraries in parallel
|
||||
const isSeriesType = (type?: string) => type === 'tv' || type === 'anime' || type === 'variety'
|
||||
const results = await Promise.allSettled(
|
||||
libs.map(async (lib) => {
|
||||
// 剧集类媒体库(tv/anime/variety):后端 /series 已按剧聚合,
|
||||
// 首页若用 episode 级 /media 的前 30 行再 groupSeries,同一部剧的
|
||||
// 多集会折叠成 1 张卡,导致整行只显示 1 个条目。
|
||||
// 改用 /series 分页拉取全部聚合后的剧集卡片。
|
||||
if (isSeriesType(lib.type)) {
|
||||
const cards: SeriesCard[] = []
|
||||
let total = 0
|
||||
const pageSize = 200
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const data = await libraryAPI.listSeries(lib.id, page, pageSize)
|
||||
const pageItems = asArray<SeriesCard>(data?.items)
|
||||
cards.push(...pageItems)
|
||||
total = data?.total ?? cards.length
|
||||
if (cards.length >= total || pageItems.length < pageSize) break
|
||||
}
|
||||
return { id: lib.id, cards, items: [], total }
|
||||
}
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 30)
|
||||
const items = asArray<Media>(page?.items)
|
||||
const cards = groupSeries(items)
|
||||
|
||||
@@ -12,6 +12,10 @@ export function pickPlayerMode(media: Media): PlayerMode {
|
||||
|
||||
export function needsTranscodeForBrowser(media: Media): boolean {
|
||||
const container = (media.container ?? '').toLowerCase()
|
||||
// .strm 媒体内容是远程直链(服务端 302 到播放 CDN 或反向代理),
|
||||
// 浏览器直接播放该远程流即可,转码无意义且必然失败(ffmpeg 无法读取
|
||||
// 文本 strm),恒走 direct play。
|
||||
if (container.includes('strm') || String(media.strm_url ?? '').trim() !== '') return false
|
||||
const videoCodec = (media.video_codec ?? '').toLowerCase()
|
||||
const audioCodec = (media.audio_codec ?? '').toLowerCase()
|
||||
const containerOK = directContainers.some((item) => container.includes(item))
|
||||
|
||||
Reference in New Issue
Block a user