mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
9 Commits
mebox-v0.1.210
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| a7b24e1bf1 | |||
| 0b68397265 | |||
| 90bfac3b09 | |||
| f1974a179e | |||
| f2357200be | |||
| ca9a80b862 | |||
| 4f19e2210e | |||
| cd6b37c6a0 | |||
| ef8b017c63 |
@@ -80,6 +80,11 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// stream_url 跳转,行为与改动前一致。
|
// stream_url 跳转,行为与改动前一致。
|
||||||
directURL, hasDirectURL := "", false
|
directURL, hasDirectURL := "", false
|
||||||
if svc.Stream != nil {
|
if svc.Stream != nil {
|
||||||
|
// `?refresh=1`:客户端手上的直链已失效(CDN 403),要求丢掉缓存
|
||||||
|
// 重新换链,别把同一条死链再发一次。
|
||||||
|
if strings.EqualFold(strings.TrimSpace(c.Query("refresh")), "1") {
|
||||||
|
service.InvalidateMediaLinkCache(m)
|
||||||
|
}
|
||||||
directURL, hasDirectURL = svc.Stream.ResolveDirectPlayURL(c.Request, m)
|
directURL, hasDirectURL = svc.Stream.ResolveDirectPlayURL(c.Request, m)
|
||||||
}
|
}
|
||||||
payload := gin.H{
|
payload := gin.H{
|
||||||
|
|||||||
@@ -46,16 +46,20 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a
|
// RevokeOldestActiveLegacyByUserID keeps at most limit active refresh tokens
|
||||||
// user by revoking the oldest non-expired, non-revoked tokens.
|
// for a user among tokens that are not bound to a device auth session
|
||||||
func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error {
|
// (legacy web clients). Device sessions are independent: trimming one
|
||||||
|
// device's token because another device logged in would silently sign the
|
||||||
|
// first device out even though its session is still valid.
|
||||||
|
func (r *RefreshTokenRepository) RevokeOldestActiveLegacyByUserID(ctx context.Context, userID string, limit int) error {
|
||||||
if limit < 1 {
|
if limit < 1 {
|
||||||
limit = 1
|
limit = 1
|
||||||
}
|
}
|
||||||
return withSQLiteBusyRetry(ctx, func() error {
|
return withSQLiteBusyRetry(ctx, func() error {
|
||||||
var tokens []model.RefreshToken
|
var tokens []model.RefreshToken
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
|
Where("user_id = ? AND revoked = ? AND expires_at > ? AND (auth_session_id IS NULL OR auth_session_id = '')",
|
||||||
|
userID, false, time.Now()).
|
||||||
Order("created_at desc, id desc").
|
Order("created_at desc, id desc").
|
||||||
Find(&tokens).Error; err != nil {
|
Find(&tokens).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -163,6 +164,52 @@ func TestRefreshKeepsSessionAssociation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMobileDeviceSessionsDoNotTrimEachOther 覆盖同一账号多设备登录:
|
||||||
|
// 每台设备有独立认证会话与刷新令牌,新设备登录或任一设备刷新都不应撤销
|
||||||
|
// 其他设备的刷新令牌。历史实现把「最大同时登录终端」直接作用在刷新令牌
|
||||||
|
// 上,第 N+1 台设备登录会把最早那台设备的刷新令牌标记为已撤销,那台设备
|
||||||
|
// 下一次刷新就会收到 SESSION_REVOKED,被强制退回登录页。
|
||||||
|
func TestMobileDeviceSessionsDoNotTrimEachOther(t *testing.T) {
|
||||||
|
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
user := seedSessionUser(t, repos)
|
||||||
|
|
||||||
|
// 默认上限是 3,这里故意登录 4 台设备。
|
||||||
|
const devices = 4
|
||||||
|
pairs := make([]*TokenPair, 0, devices)
|
||||||
|
for i := 0; i < devices; i++ {
|
||||||
|
session, err := sessions.Create(ctx, CreateAuthSessionInput{
|
||||||
|
UserID: user.ID,
|
||||||
|
DeviceID: fmt.Sprintf("device-%02d", i),
|
||||||
|
DeviceName: fmt.Sprintf("Phone %d", i+1),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session %d: %v", i+1, err)
|
||||||
|
}
|
||||||
|
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue pair %d: %v", i+1, err)
|
||||||
|
}
|
||||||
|
pairs = append(pairs, pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, pair := range pairs {
|
||||||
|
row, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load device %d token: %v", i+1, err)
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
t.Fatalf("device %d refresh token missing", i+1)
|
||||||
|
}
|
||||||
|
if row.Revoked {
|
||||||
|
t.Fatalf("device %d refresh token was revoked by another device", i+1)
|
||||||
|
}
|
||||||
|
if _, err := tokens.Refresh(ctx, pair.RefreshToken); err != nil {
|
||||||
|
t.Fatalf("device %d refresh failed: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServerIdentityPersistsAcrossInstances(t *testing.T) {
|
func TestServerIdentityPersistsAcrossInstances(t *testing.T) {
|
||||||
db := newServiceTestDB(t, &model.Setting{})
|
db := newServiceTestDB(t, &model.Setting{})
|
||||||
repos := repository.New(db)
|
repos := repository.New(db)
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
// 下载直链进程级缓存。
|
// 下载直链进程级缓存。
|
||||||
//
|
//
|
||||||
// 115 的 /open/ufile/downurl 换取结果在一段时间内有效,且换取请求是
|
// 115 的 /open/ufile/downurl 换取结果带 `t=` 过期时间戳(实测为「换取时刻 +
|
||||||
// WAF 风控重点关注的密集接口。缓存按 pickcode 存直链、避免对同一文件
|
// 1800 秒」,即 30 分钟),且换取请求是 WAF 风控重点关注的密集接口。缓存按
|
||||||
// 反复换取;下载失败(http 403/404 等)时由调用方主动清除对应条目,
|
// pickcode 存直链、避免对同一文件反复换取;下载失败(http 403/404 等)时由
|
||||||
// 下一轮重试会重新换取链接。
|
// 调用方主动清除对应条目,下一轮重试会重新换取链接。
|
||||||
package cloud115
|
package cloud115
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// downloadURLCacheTTL 直链缓存时长。115 直链一般有效 1 小时左右,
|
// downloadURLCacheMaxTTL 直链缓存上限:仅在解析不出直链自带过期时间(`t=`)
|
||||||
// 取 45 分钟留出余量。QMediaSync 同类缓存使用 50 分钟。
|
// 时使用。115 直链实际有效 30 分钟,这里取 45 分钟只是兜底上限,正常情况下
|
||||||
const downloadURLCacheTTL = 45 * time.Minute
|
// 由 linkExpirySafety 提前失效,避免把已经过期的直链发给客户端。
|
||||||
|
const downloadURLCacheMaxTTL = 45 * time.Minute
|
||||||
|
|
||||||
|
// linkExpirySafety 提前量:直链自身的过期时间戳到达前就停止使用,留出客户端
|
||||||
|
// 建连与传输的余量,防止出现「服务端认为还有效、CDN 已经 403」的窗口。
|
||||||
|
const linkExpirySafety = 2 * time.Minute
|
||||||
|
|
||||||
// maxCachedURLs 超过该数量时顺带清理过期条目,防止 map 无限膨胀。
|
// maxCachedURLs 超过该数量时顺带清理过期条目,防止 map 无限膨胀。
|
||||||
const maxCachedURLs = 10000
|
const maxCachedURLs = 10000
|
||||||
|
|
||||||
type urlCacheEntry struct {
|
type urlCacheEntry struct {
|
||||||
url string
|
url string
|
||||||
|
createdAt time.Time
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +45,43 @@ func urlCacheKey(pickCode, ua string) string {
|
|||||||
return pickCode + "@" + ua
|
return pickCode + "@" + ua
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkExpiry 解析 115 直链自带的过期时间戳(`t=` 参数,单位秒)。
|
||||||
|
// 解析不出时返回零值,调用方按兜底 TTL 处理。
|
||||||
|
func LinkExpiry(rawURL string) time.Time {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||||
|
if err != nil || u == nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(u.Query().Get("t"))
|
||||||
|
if raw == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
sec, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || sec <= 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return time.Unix(sec, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheExpiry 计算写入缓存时应使用的过期时刻:跟随直链自身的 `t=` 并提前
|
||||||
|
// linkExpirySafety,解析不出时退回 downloadURLCacheMaxTTL。返回零值表示这条
|
||||||
|
// 直链已经过期,不应写入缓存。
|
||||||
|
func cacheExpiry(rawURL string, now time.Time) time.Time {
|
||||||
|
maxExpiry := now.Add(downloadURLCacheMaxTTL)
|
||||||
|
linkExpiry := LinkExpiry(rawURL)
|
||||||
|
if linkExpiry.IsZero() {
|
||||||
|
return maxExpiry
|
||||||
|
}
|
||||||
|
expiry := linkExpiry.Add(-linkExpirySafety)
|
||||||
|
if !expiry.After(now) {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
if expiry.After(maxExpiry) {
|
||||||
|
return maxExpiry
|
||||||
|
}
|
||||||
|
return expiry
|
||||||
|
}
|
||||||
|
|
||||||
// GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
|
// GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
|
||||||
func GetDownloadURLCache(pickCode string, uas ...string) string {
|
func GetDownloadURLCache(pickCode string, uas ...string) string {
|
||||||
if pickCode == "" {
|
if pickCode == "" {
|
||||||
@@ -59,11 +104,17 @@ func GetDownloadURLCache(pickCode string, uas ...string) string {
|
|||||||
return entry.url
|
return entry.url
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadURLCache 写入直链缓存。
|
// SetDownloadURLCache 写入直链缓存。过期时刻跟随直链自身的 `t=`,已经过期的
|
||||||
|
// 直链直接丢弃,避免把死链缓存下来。
|
||||||
func SetDownloadURLCache(pickCode string, url string, uas ...string) {
|
func SetDownloadURLCache(pickCode string, url string, uas ...string) {
|
||||||
if pickCode == "" || url == "" {
|
if pickCode == "" || url == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
|
expiresAt := cacheExpiry(url, now)
|
||||||
|
if expiresAt.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
ua := ""
|
ua := ""
|
||||||
if len(uas) > 0 {
|
if len(uas) > 0 {
|
||||||
ua = uas[0]
|
ua = uas[0]
|
||||||
@@ -71,7 +122,6 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
|
|||||||
urlCacheMu.Lock()
|
urlCacheMu.Lock()
|
||||||
defer urlCacheMu.Unlock()
|
defer urlCacheMu.Unlock()
|
||||||
if len(urlCache) >= maxCachedURLs {
|
if len(urlCache) >= maxCachedURLs {
|
||||||
now := time.Now()
|
|
||||||
for k, e := range urlCache {
|
for k, e := range urlCache {
|
||||||
if now.After(e.expiresAt) {
|
if now.After(e.expiresAt) {
|
||||||
delete(urlCache, k)
|
delete(urlCache, k)
|
||||||
@@ -79,7 +129,7 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
key := urlCacheKey(pickCode, ua)
|
key := urlCacheKey(pickCode, ua)
|
||||||
urlCache[key] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
|
urlCache[key] = urlCacheEntry{url: url, createdAt: now, expiresAt: expiresAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearDownloadURLCache 删除指定 pickcode 的所有缓存(下载得到非 2xx 时调用)。
|
// ClearDownloadURLCache 删除指定 pickcode 的所有缓存(下载得到非 2xx 时调用)。
|
||||||
@@ -95,3 +145,32 @@ func ClearDownloadURLCache(pickCode string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearDownloadURLCacheIfOlderThan 删除指定 pickcode 下「已经存在超过 minAge」
|
||||||
|
// 的缓存条目,返回是否删除了至少一条。
|
||||||
|
//
|
||||||
|
// 客户端上报「直链被 CDN 拒绝」时用它强制换链:既让下一次解析拿到新地址,
|
||||||
|
// 又不会让同一秒内的重试风暴把 115 换链接口打出突发。
|
||||||
|
func ClearDownloadURLCacheIfOlderThan(pickCode string, minAge time.Duration) bool {
|
||||||
|
if pickCode == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if minAge < 0 {
|
||||||
|
minAge = 0
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
removed := false
|
||||||
|
urlCacheMu.Lock()
|
||||||
|
defer urlCacheMu.Unlock()
|
||||||
|
for k, e := range urlCache {
|
||||||
|
if k != pickCode && !strings.HasPrefix(k, pickCode+"@") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(e.createdAt) < minAge {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(urlCache, k)
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cloud115
|
package cloud115
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -29,3 +30,72 @@ func TestDownloadURLCache(t *testing.T) {
|
|||||||
t.Fatalf("cleared entry should be a miss, got %q", got)
|
t.Fatalf("cleared entry should be a miss, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 115 直链自带 `t=` 过期时间戳(实测为换取时刻 + 1800 秒)。缓存必须跟随它,
|
||||||
|
// 否则会出现「服务端认为还有效、CDN 已经 403」的窗口:直链被缓存到 45 分钟,
|
||||||
|
// 但第 30 分钟起客户端就必然拿到 403。
|
||||||
|
func TestDownloadURLCacheExpiryFollowsLinkTimestamp(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d&u=1&k=abc", now.Add(30*time.Minute).Unix())
|
||||||
|
|
||||||
|
got := cacheExpiry(link, now)
|
||||||
|
want := now.Add(30*time.Minute - linkExpirySafety)
|
||||||
|
if diff := got.Sub(want); diff > time.Second || diff < -time.Second {
|
||||||
|
t.Fatalf("cacheExpiry = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析不出 t= 时退回兜底 TTL,保持旧行为。
|
||||||
|
fallback := cacheExpiry("https://cdn/1/a.mkv", now)
|
||||||
|
if diff := fallback.Sub(now.Add(downloadURLCacheMaxTTL)); diff > time.Second || diff < -time.Second {
|
||||||
|
t.Fatalf("fallback cacheExpiry = %v, want %v", fallback, now.Add(downloadURLCacheMaxTTL))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直链本身已经过期(或只剩不足提前量的寿命)时不进缓存。
|
||||||
|
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(-time.Minute).Unix()), now); !got.IsZero() {
|
||||||
|
t.Fatalf("expired link must not be cached, got expiry %v", got)
|
||||||
|
}
|
||||||
|
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(time.Minute).Unix()), now); !got.IsZero() {
|
||||||
|
t.Fatalf("link expiring inside the safety margin must not be cached, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端上报「直链被 CDN 拒绝」时按 pickcode 清缓存,但要挡住刚写入的条目,
|
||||||
|
// 避免重试风暴把 115 换链接口打出突发。
|
||||||
|
func TestClearDownloadURLCacheIfOlderThan(t *testing.T) {
|
||||||
|
urlCacheMu.Lock()
|
||||||
|
urlCache = map[string]urlCacheEntry{}
|
||||||
|
urlCacheMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
urlCacheMu.Lock()
|
||||||
|
urlCache = map[string]urlCacheEntry{}
|
||||||
|
urlCacheMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
future := time.Now().Add(30 * time.Minute)
|
||||||
|
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d", future.Unix())
|
||||||
|
SetDownloadURLCache("pick-fresh", link, "ua-a")
|
||||||
|
if ClearDownloadURLCacheIfOlderThan("pick-fresh", time.Minute) {
|
||||||
|
t.Fatal("fresh entry must survive the min-age guard")
|
||||||
|
}
|
||||||
|
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
|
||||||
|
t.Fatalf("fresh entry lost, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
SetDownloadURLCache("pick-old", link, "ua-a")
|
||||||
|
urlCacheMu.Lock()
|
||||||
|
entry := urlCache[urlCacheKey("pick-old", "ua-a")]
|
||||||
|
entry.createdAt = time.Now().Add(-10 * time.Minute)
|
||||||
|
urlCache[urlCacheKey("pick-old", "ua-a")] = entry
|
||||||
|
urlCacheMu.Unlock()
|
||||||
|
|
||||||
|
if !ClearDownloadURLCacheIfOlderThan("pick-old", time.Minute) {
|
||||||
|
t.Fatal("stale entry should be cleared")
|
||||||
|
}
|
||||||
|
if got := GetDownloadURLCache("pick-old", "ua-a"); got != "" {
|
||||||
|
t.Fatalf("stale entry should be gone, got %q", got)
|
||||||
|
}
|
||||||
|
// 无关 pickcode 不受影响。
|
||||||
|
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
|
||||||
|
t.Fatalf("unrelated pickcode lost, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// IntroDBBaseURL is the public read endpoint. Overridable on the service
|
// IntroDBBaseURL is the public read endpoint. Overridable on the service
|
||||||
// for tests and for pointing at a mirror.
|
// for tests and for pointing at a mirror. v1 已删除,v2/v3 冻结在只读的
|
||||||
IntroDBBaseURL = "https://api.theintrodb.org/v3"
|
// GET /media 上并被官方标记为弃用,所以这里直接跟 v4。
|
||||||
|
IntroDBBaseURL = "https://api.theintrodb.org/v4"
|
||||||
// IntroDBSource tags rows that came from this provider.
|
// IntroDBSource tags rows that came from this provider.
|
||||||
IntroDBSource = "theintrodb"
|
IntroDBSource = "theintrodb"
|
||||||
|
|
||||||
@@ -92,8 +93,10 @@ type introDBRange struct {
|
|||||||
EndMs *int64 `json:"end_ms"`
|
EndMs *int64 `json:"end_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// introDBResponse 对应 v4 的 MediaResponse。v4 以 TVDB 为身份,响应只带
|
||||||
|
// tvdb_id —— tmdb_id 字段已被官方移除,需要 tmdb_id 只能从请求侧自己记。
|
||||||
type introDBResponse struct {
|
type introDBResponse struct {
|
||||||
TMDbID int `json:"tmdb_id"`
|
TVDBID int `json:"tvdb_id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Intro []introDBRange `json:"intro"`
|
Intro []introDBRange `json:"intro"`
|
||||||
Recap []introDBRange `json:"recap"`
|
Recap []introDBRange `json:"recap"`
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 这两段响应是从 api.theintrodb.org/v3/media 实测抓下来的原文,
|
// 这两段响应是从 api.theintrodb.org/v4/media 实测抓下来的原文(v4 以 TVDB 为身份,
|
||||||
// 用来锁住 null 语义:start_ms: null = 从片头开始,end_ms: null = 一直到片尾。
|
// 响应里只有 tvdb_id),用来锁住 null 语义:start_ms: null = 从片头开始,
|
||||||
|
// end_ms: null = 一直到片尾。
|
||||||
const (
|
const (
|
||||||
introDBTVPayload = `{"tmdb_id":1396,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":228664,"end_ms":246143}],"credits":[{"start_ms":3431000,"end_ms":null}]}`
|
introDBTVPayload = `{"tvdb_id":81189,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":228664,"end_ms":246143}],"credits":[{"start_ms":3431000,"end_ms":null}]}`
|
||||||
introDBMoviePayload = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
|
introDBMoviePayload = `{"tvdb_id":113,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
|
func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
|
||||||
@@ -48,7 +49,7 @@ func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
|
func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
|
||||||
body := `{"tmdb_id":1,"type":"movie",
|
body := `{"tvdb_id":1,"type":"movie",
|
||||||
"intro":[{"start_ms":5000,"end_ms":5000},{"start_ms":9000,"end_ms":8000},{"start_ms":1000,"end_ms":2000}],
|
"intro":[{"start_ms":5000,"end_ms":5000},{"start_ms":9000,"end_ms":8000},{"start_ms":1000,"end_ms":2000}],
|
||||||
"recap":[],"credits":[],"preview":[]}`
|
"recap":[],"credits":[],"preview":[]}`
|
||||||
spans, err := parseIntroDBResponse([]byte(body))
|
spans, err := parseIntroDBResponse([]byte(body))
|
||||||
@@ -62,7 +63,7 @@ func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseIntroDBResponseOrdersByType(t *testing.T) {
|
func TestParseIntroDBResponseOrdersByType(t *testing.T) {
|
||||||
body := `{"tmdb_id":1,"type":"tv","credits":[{"start_ms":900,"end_ms":1000}],
|
body := `{"tvdb_id":1,"type":"tv","credits":[{"start_ms":900,"end_ms":1000}],
|
||||||
"intro":[{"start_ms":100,"end_ms":200}],"recap":[{"start_ms":50,"end_ms":60}]}`
|
"intro":[{"start_ms":100,"end_ms":200}],"recap":[{"start_ms":50,"end_ms":60}]}`
|
||||||
spans, err := parseIntroDBResponse([]byte(body))
|
spans, err := parseIntroDBResponse([]byte(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,12 +83,12 @@ func TestParseIntroDBResponseOrdersByType(t *testing.T) {
|
|||||||
func TestIntroDBMediaURLOnlyAddsSeasonEpisodeForTV(t *testing.T) {
|
func TestIntroDBMediaURLOnlyAddsSeasonEpisodeForTV(t *testing.T) {
|
||||||
svc := NewIntroDBService(zap.NewNop())
|
svc := NewIntroDBService(zap.NewNop())
|
||||||
if got, want := svc.mediaURL(1396, 1, 1),
|
if got, want := svc.mediaURL(1396, 1, 1),
|
||||||
"https://api.theintrodb.org/v3/media?tmdb_id=1396&season=1&episode=1"; got != want {
|
"https://api.theintrodb.org/v4/media?tmdb_id=1396&season=1&episode=1"; got != want {
|
||||||
t.Fatalf("tv url = %q, want %q", got, want)
|
t.Fatalf("tv url = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
// 电影(season/episode 为 0)不能带季集参数,否则会被当成剧集查不到。
|
// 电影(season/episode 为 0)不能带季集参数,否则会被当成剧集查不到。
|
||||||
if got, want := svc.mediaURL(27205, 0, 0),
|
if got, want := svc.mediaURL(27205, 0, 0),
|
||||||
"https://api.theintrodb.org/v3/media?tmdb_id=27205"; got != want {
|
"https://api.theintrodb.org/v4/media?tmdb_id=27205"; got != want {
|
||||||
t.Fatalf("movie url = %q, want %q", got, want)
|
t.Fatalf("movie url = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,85 @@ func TestReadAdultMetadataDerivesDMMPosterFromRemoteFanart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadAdultArtworkIgnoresLoosePosterInLibraryRoot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
// 云下载这类库把不同影片平铺在同一个根目录里,根目录中残留的松散海报
|
||||||
|
// 属于别的影片,不能被抓来当作当前影片的封面。
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mediaPath := filepath.Join(root, "BBB-002.mp4")
|
||||||
|
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != nil && (got.PosterURL != "" || got.BackdropURL != "") {
|
||||||
|
t.Fatalf("library-root artwork must not be inherited: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadAdultArtworkIgnoresLoosePosterInSharedFolder(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "shared")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"AAA-001.mp4", "BBB-002.mp4"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ReadLocalMetadata(filepath.Join(dir, "BBB-002.mp4"), root, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != nil && (got.PosterURL != "" || got.BackdropURL != "") {
|
||||||
|
t.Fatalf("sibling artwork must not be inherited in a shared folder: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadAdultArtworkKeepsLooseFallbackInItemFolder(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "BBB-002")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mediaPath := filepath.Join(dir, "video.mp4")
|
||||||
|
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
poster := filepath.Join(dir, "japanese-title-cover.jpg")
|
||||||
|
fanart := filepath.Join(dir, "japanese-title-fanart.jpg")
|
||||||
|
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fanart, []byte("jpg"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got == nil || got.PosterURL != poster || got.BackdropURL != fanart {
|
||||||
|
t.Fatalf("single-item folder should keep loose artwork fallback: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadAdultArtworkByCodeWithoutNFO(t *testing.T) {
|
func TestReadAdultArtworkByCodeWithoutNFO(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
||||||
|
|||||||
@@ -11,23 +11,23 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func metadataFromArtwork(mediaPath, showBaseDir string) *LocalMetadata {
|
func metadataFromArtwork(mediaPath, showBaseDir, libraryRoot string) *LocalMetadata {
|
||||||
meta := &LocalMetadata{}
|
meta := &LocalMetadata{}
|
||||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
|
||||||
if meta.PosterURL == "" && meta.BackdropURL == "" {
|
if meta.PosterURL == "" && meta.BackdropURL == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir, libraryRoot string) {
|
||||||
if meta == nil {
|
if meta == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mediaDir := filepath.Dir(mediaPath)
|
mediaDir := filepath.Dir(mediaPath)
|
||||||
if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" {
|
if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" {
|
||||||
meta.PosterURL = localPoster
|
meta.PosterURL = localPoster
|
||||||
} else if meta.PosterURL == "" {
|
} else if meta.PosterURL == "" && looseArtworkFallbackAllowed(mediaDir, libraryRoot) {
|
||||||
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
|
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
|
||||||
}
|
}
|
||||||
dirs := []string{mediaDir, showBaseDir}
|
dirs := []string{mediaDir, showBaseDir}
|
||||||
@@ -39,7 +39,7 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
|||||||
meta.BackdropURL = img
|
meta.BackdropURL = img
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if meta.BackdropURL == "" {
|
if meta.BackdropURL == "" && looseArtworkFallbackAllowed(dir, libraryRoot) {
|
||||||
meta.BackdropURL = firstAdultLooseImage(dir, "backdrop")
|
meta.BackdropURL = firstAdultLooseImage(dir, "backdrop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +245,48 @@ func firstExistingPosterImage(dir string, names ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// looseArtworkFallbackAllowed reports whether an unmatched image sitting in dir
|
||||||
|
// can be attributed to mediaPath. Such an image carries no filename relation to
|
||||||
|
// a specific video, so it is only trustworthy when the folder holds a single
|
||||||
|
// media file and that folder is not the library root. Download tools routinely
|
||||||
|
// drop unrelated titles flat into one directory (a 115 cloud-download library,
|
||||||
|
// for example); claiming the first "*-poster.jpg" there made every item show the
|
||||||
|
// same artwork. Mirrors the root-level guard findMovieNFO already applies to
|
||||||
|
// loose NFO files.
|
||||||
|
func looseArtworkFallbackAllowed(dir, libraryRoot string) bool {
|
||||||
|
if dir == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if libraryRoot != "" && samePath(dir, libraryRoot) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return countMediaFilesInDir(dir) <= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// countMediaFilesInDir counts the video files directly inside dir, stopping once
|
||||||
|
// more than one is found. It walks the real directory rather than reusing the
|
||||||
|
// loose-image glob because a shared folder may contain no images at all.
|
||||||
|
func countMediaFilesInDir(dir string) int {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := videoExtensions[strings.ToLower(filepath.Ext(entry.Name()))]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if count > 1 {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
func firstAdultLooseImage(dir, kind string) string {
|
func firstAdultLooseImage(dir, kind string) string {
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
|
|||||||
doc, path, err := findMovieNFO(mediaPath, libraryRoot)
|
doc, path, err := findMovieNFO(mediaPath, libraryRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return metadataFromArtwork(mediaPath, ""), nil
|
return metadataFromArtwork(mediaPath, "", libraryRoot), nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
meta := metadataFromDoc(doc, filepath.Dir(path), false)
|
meta := metadataFromDoc(doc, filepath.Dir(path), false)
|
||||||
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
|
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path), libraryRoot)
|
||||||
// A sidecar written while resolution tokens were misread as SxxExx must not
|
// A sidecar written while resolution tokens were misread as SxxExx must not
|
||||||
// reintroduce the bogus season/episode on rescan.
|
// reintroduce the bogus season/episode on rescan.
|
||||||
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
|
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
|
||||||
@@ -115,9 +115,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if meta == nil {
|
if meta == nil {
|
||||||
meta = metadataFromArtwork(mediaPath, showBaseDir)
|
meta = metadataFromArtwork(mediaPath, showBaseDir, libraryRoot)
|
||||||
} else {
|
} else {
|
||||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
|
||||||
}
|
}
|
||||||
// A sidecar written while resolution tokens were misread as SxxExx must not
|
// A sidecar written while resolution tokens were misread as SxxExx must not
|
||||||
// reintroduce the bogus season/episode on rescan.
|
// reintroduce the bogus season/episode on rescan.
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||||
)
|
)
|
||||||
|
|
||||||
// directRedirectTestRepo 建一个带网盘账号表的库:normalizeCloudPlayTarget 需要
|
// directRedirectTestRepo 建一个带网盘账号表的库:normalizeCloudPlayTarget 需要
|
||||||
@@ -145,6 +148,53 @@ func TestServeFileFallsBackWhenDirectResolveUnavailable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `?refresh=1`:客户端手上的直链已被 CDN 403,服务端必须丢掉缓存重新换链,
|
||||||
|
// 不能把同一条死链再发一次;不带该参数时缓存照旧命中,保住快路径。
|
||||||
|
func TestServeFileRefreshDropsCachedDirectLink(t *testing.T) {
|
||||||
|
repos := directRedirectTestRepo(t)
|
||||||
|
seedCloudSTRMMedia(t, repos, "cloud-refresh", "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc-refresh")
|
||||||
|
cached := fmt.Sprintf("https://cdn.example/stale.mkv?t=%d", time.Now().Add(30*time.Minute).Unix())
|
||||||
|
fresh := "https://cdn.example/fresh.mkv?t=2"
|
||||||
|
cloud115.SetDownloadURLCache("pc-refresh", cached, "RodelPlayer/2.0")
|
||||||
|
t.Cleanup(func() { cloud115.ClearDownloadURLCache("pc-refresh") })
|
||||||
|
oldMinAge := directLinkRefreshMinAge
|
||||||
|
directLinkRefreshMinAge = 0
|
||||||
|
defer func() { directLinkRefreshMinAge = oldMinAge }()
|
||||||
|
|
||||||
|
run := func(t *testing.T, query string) (cachedAtResolve, location string) {
|
||||||
|
t.Helper()
|
||||||
|
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil).
|
||||||
|
SetStrmPlayTargetResolver(func(_ context.Context, _ string, userAgent string) (*StrmPlayResult, error) {
|
||||||
|
cachedAtResolve = cloud115.GetDownloadURLCache("pc-refresh", userAgent)
|
||||||
|
return &StrmPlayResult{
|
||||||
|
RedirectURL: fresh,
|
||||||
|
Link: &cloud.DirectLink{URL: fresh, Headers: map[string]string{"User-Agent": userAgent}},
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-refresh?token=jwt123"+query, nil)
|
||||||
|
req.Header.Set("User-Agent", "RodelPlayer/2.0")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
if err := svc.ServeFile(w, req, "cloud-refresh"); err != nil {
|
||||||
|
t.Fatalf("ServeFile: %v", err)
|
||||||
|
}
|
||||||
|
return cachedAtResolve, w.Header().Get("Location")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常播放:缓存里的直链照旧命中,服务端不该白跑一次换链。
|
||||||
|
if got, loc := run(t, ""); got != cached {
|
||||||
|
t.Fatalf("cached link = %q, want the cached %q", got, cached)
|
||||||
|
} else if loc != fresh {
|
||||||
|
t.Fatalf("Location = %q, want the resolved direct link", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端报告直链失效:换链前必须先清掉缓存。
|
||||||
|
if got, loc := run(t, "&refresh=1"); got != "" {
|
||||||
|
t.Fatalf("cached link must be dropped before resolving, got %q", got)
|
||||||
|
} else if loc != fresh {
|
||||||
|
t.Fatalf("Location = %q, want the freshly resolved direct link", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 没有注入解析器(测试/精简部署)时保持原有跳转,不受本次优化影响。
|
// 没有注入解析器(测试/精简部署)时保持原有跳转,不受本次优化影响。
|
||||||
func TestServeFileKeepsSTRMEndpointWithoutResolver(t *testing.T) {
|
func TestServeFileKeepsSTRMEndpointWithoutResolver(t *testing.T) {
|
||||||
repos := directRedirectTestRepo(t)
|
repos := directRedirectTestRepo(t)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServeFile streams the file backing the given media ID using
|
// ServeFile streams the file backing the given media ID using
|
||||||
@@ -22,6 +23,47 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
|||||||
return s.ServeFileWithCloudMode(w, r, mediaID, "")
|
return s.ServeFileWithCloudMode(w, r, mediaID, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PickCodeFromPlayTarget 从 /api/strm/play/... 形式的播放目标里取出 pickcode。
|
||||||
|
// 115 直链缓存按 pickcode 分键,客户端要求强制换链时用它定位缓存条目。
|
||||||
|
func PickCodeFromPlayTarget(raw string) string {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || u == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(u.Query().Get("pickcode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// directLinkRefreshMinAge 是「强制换链」的最小间隔:缓存条目刚写入不久就
|
||||||
|
// 收到换链请求时忽略它,避免客户端重试风暴把 115 换链接口打出突发。
|
||||||
|
// 测试会临时改成 0,让「CDN 拒绝 → 立刻换链」的路径可验证。
|
||||||
|
var directLinkRefreshMinAge = 30 * time.Second
|
||||||
|
|
||||||
|
// wantsFreshLink 判断客户端是否要求跳过直链缓存重新换链(`?refresh=1`)。
|
||||||
|
//
|
||||||
|
// 115 直链只有 30 分钟有效期,客户端拿到过期直链被 CDN 403 后需要一条新链;
|
||||||
|
// 正常路径下缓存会先于直链过期,这里是客户端上报失败时的兜底。
|
||||||
|
func wantsFreshLink(r *http.Request) bool {
|
||||||
|
if r == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) {
|
||||||
|
case "1", "true", "yes":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateMediaLinkCache 清除媒体播放目标对应的云盘直链缓存,让下一次解析
|
||||||
|
// 重新换链。非云盘/无 pickcode 的媒体是空操作。
|
||||||
|
func InvalidateMediaLinkCache(m *model.Media) {
|
||||||
|
pickCode := PickCodeFromPlayTarget(MediaSTRMTarget(m))
|
||||||
|
if pickCode == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cloud115.ClearDownloadURLCacheIfOlderThan(pickCode, directLinkRefreshMinAge)
|
||||||
|
}
|
||||||
|
|
||||||
// MediaSTRMTarget 返回媒体行固化的 STRM 播放目标。STRMURL 为空时回读本地
|
// MediaSTRMTarget 返回媒体行固化的 STRM 播放目标。STRMURL 为空时回读本地
|
||||||
// .strm 文件内容兜底(扫描时内容解析失败的行只剩 Container=strm + Path),
|
// .strm 文件内容兜底(扫描时内容解析失败的行只剩 Container=strm + Path),
|
||||||
// 仍拿不到返回空串。
|
// 仍拿不到返回空串。
|
||||||
@@ -57,6 +99,11 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
|
|||||||
// 能在服务端换到最终直链就直接 302 过去:客户端少跟随一次 302,等于
|
// 能在服务端换到最终直链就直接 302 过去:客户端少跟随一次 302,等于
|
||||||
// 省掉一次「DNS+TCP+TLS+请求」的往返。解析结果同时写进 strm 层直链
|
// 省掉一次「DNS+TCP+TLS+请求」的往返。解析结果同时写进 strm 层直链
|
||||||
// 缓存,后续 /api/strm/play 请求直接命中。
|
// 缓存,后续 /api/strm/play 请求直接命中。
|
||||||
|
// `?refresh=1` 表示客户端手上的直链已经失效(被 CDN 403),要求丢掉
|
||||||
|
// 缓存重新换链。
|
||||||
|
if wantsFreshLink(r) {
|
||||||
|
InvalidateMediaLinkCache(m)
|
||||||
|
}
|
||||||
if direct, ok := s.resolveDirectPlayTargetURL(r, strmURL); ok {
|
if direct, ok := s.resolveDirectPlayTargetURL(r, strmURL); ok {
|
||||||
setCloudRedirectNoStore(w)
|
setCloudRedirectNoStore(w)
|
||||||
http.Redirect(w, r, direct, http.StatusFound)
|
http.Redirect(w, r, direct, http.StatusFound)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrStrmPlayNotFound 表示 strm 播放目标不存在(handler 返回 404)。
|
// ErrStrmPlayNotFound 表示 strm 播放目标不存在(handler 返回 404)。
|
||||||
@@ -250,21 +251,87 @@ func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWrite
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
switch {
|
link := directLinkOfPlayResult(result)
|
||||||
case result.Link != nil && result.Link.URL != "":
|
if link == nil {
|
||||||
return s.ProxyDirect(ctx, w, r, result.Link)
|
|
||||||
case result.RedirectURL != "":
|
|
||||||
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
|
|
||||||
default:
|
|
||||||
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
|
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
|
||||||
return ErrStrmProxyNotApplicable
|
return ErrStrmProxyNotApplicable
|
||||||
}
|
}
|
||||||
|
resp, method, err := s.openDirectUpstream(ctx, r, link)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 直链被 CDN 拒绝:115 直链带 30 分钟过期时间戳,缓存命中也可能拿到已经
|
||||||
|
// 失效的地址。这里清掉缓存重新换链再试一次,避免客户端只看到 403。
|
||||||
|
if directLinkRejected(resp.StatusCode) {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if fresh := s.refreshDirectLink(ctx, raw); fresh != nil {
|
||||||
|
if retry, retryMethod, retryErr := s.openDirectUpstream(ctx, r, fresh); retryErr == nil {
|
||||||
|
resp, method = retry, retryMethod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return writeDirectUpstream(w, resp, method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// directLinkOfPlayResult 取出可反向代理的直链;本地文件返回 nil。
|
||||||
|
func directLinkOfPlayResult(result *StrmPlayResult) *cloud.DirectLink {
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case result.Link != nil && result.Link.URL != "":
|
||||||
|
return result.Link
|
||||||
|
case result.RedirectURL != "":
|
||||||
|
return &cloud.DirectLink{URL: result.RedirectURL}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// directLinkRejected 判断上游状态码是否属于「直链本身失效」(而不是文件/权限
|
||||||
|
// 之类与链接无关的问题)。
|
||||||
|
func directLinkRejected(status int) bool {
|
||||||
|
switch status {
|
||||||
|
case http.StatusForbidden, http.StatusNotFound, http.StatusGone:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshDirectLink 丢弃当前直链缓存并重新换链,返回新的直链;无法换链时返回
|
||||||
|
// nil,调用方继续用原来那条。
|
||||||
|
func (s *StrmService) refreshDirectLink(ctx context.Context, raw string) *cloud.DirectLink {
|
||||||
|
pickCode := PickCodeFromPlayTarget(raw)
|
||||||
|
if pickCode == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !cloud115.ClearDownloadURLCacheIfOlderThan(pickCode, directLinkRefreshMinAge) {
|
||||||
|
// 缓存刚写入不久:说明失败与链接过期无关,重换一次也会拿到同一条。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fresh, err := s.ResolvePlayTarget(ctx, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return directLinkOfPlayResult(fresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||||
|
resp, method, err := s.openDirectUpstream(ctx, r, link)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return writeDirectUpstream(w, resp, method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openDirectUpstream 向网盘直链发起一次请求,保留 Range 与 UA 语义。
|
||||||
|
func (s *StrmService) openDirectUpstream(ctx context.Context, r *http.Request, link *cloud.DirectLink) (*http.Response, string, error) {
|
||||||
if link == nil || link.URL == "" {
|
if link == nil || link.URL == "" {
|
||||||
return errors.New("空直链")
|
return nil, "", errors.New("空直链")
|
||||||
}
|
}
|
||||||
method := http.MethodGet
|
method := http.MethodGet
|
||||||
if r != nil && r.Method == http.MethodHead {
|
if r != nil && r.Method == http.MethodHead {
|
||||||
@@ -272,7 +339,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
|
|||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
|
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
for k, v := range link.Headers {
|
for k, v := range link.Headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
@@ -288,9 +355,13 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
|
|||||||
}
|
}
|
||||||
resp, err := s.streamHTTP.Do(req)
|
resp, err := s.streamHTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
return resp, method, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDirectUpstream 把上游响应原样写给客户端。
|
||||||
|
func writeDirectUpstream(w http.ResponseWriter, resp *http.Response, method string) error {
|
||||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
||||||
if value := resp.Header.Get(header); value != "" {
|
if value := resp.Header.Get(header); value != "" {
|
||||||
w.Header().Set(header, value)
|
w.Header().Set(header, value)
|
||||||
@@ -310,7 +381,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
|
|||||||
if flusher, ok := w.(http.Flusher); ok {
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
writer = &flushWriter{writer: w, flusher: flusher}
|
writer = &flushWriter{writer: w, flusher: flusher}
|
||||||
}
|
}
|
||||||
_, err = io.Copy(writer, resp.Body)
|
_, err := io.Copy(writer, resp.Body)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
|
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
|
||||||
@@ -55,6 +59,134 @@ func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 115 直链只有 30 分钟寿命,缓存命中也可能拿到已经失效的地址。同源转发遇到
|
||||||
|
// CDN 403 时必须丢掉缓存重新换链再试一次,而不是把 403 直接透给客户端。
|
||||||
|
func TestProxyMediaDirectRetriesWithFreshLinkWhenCDNRejects(t *testing.T) {
|
||||||
|
svc := testStrmService(t)
|
||||||
|
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
|
||||||
|
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/fresh.mkv" {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "video/x-matroska")
|
||||||
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
_, _ = w.Write([]byte("fresh-bytes"))
|
||||||
|
}))
|
||||||
|
defer cdn.Close()
|
||||||
|
|
||||||
|
// 第一次换链给出「已过期」的旧地址,重新换链才给新地址。
|
||||||
|
var mu sync.Mutex
|
||||||
|
var calls int
|
||||||
|
expiry := time.Now().Add(30 * time.Minute).Unix()
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
calls++
|
||||||
|
attempt := calls
|
||||||
|
mu.Unlock()
|
||||||
|
link := cdn.URL + "/old.mkv"
|
||||||
|
if attempt > 1 {
|
||||||
|
link = cdn.URL + "/fresh.mkv"
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"state":true,"data":{"1":{"pick_code":"pc-retry","url":{"url":%q}}}}`,
|
||||||
|
fmt.Sprintf("%s?t=%d&u=1&k=abc", link, expiry))
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
oldPro := cloud115.ProAPIBase
|
||||||
|
cloud115.ProAPIBase = api.URL
|
||||||
|
defer func() { cloud115.ProAPIBase = oldPro }()
|
||||||
|
defer cloud115.ClearDownloadURLCache("pc-retry")
|
||||||
|
oldMinAge := directLinkRefreshMinAge
|
||||||
|
directLinkRefreshMinAge = 0
|
||||||
|
defer func() { directLinkRefreshMinAge = oldMinAge }()
|
||||||
|
|
||||||
|
media := &model.Media{
|
||||||
|
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + acct.ID + "&pickcode=pc-retry",
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||||
|
req.Header.Set("User-Agent", "MeBoxTest/1.0")
|
||||||
|
|
||||||
|
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
|
||||||
|
t.Fatalf("ProxyMediaDirect: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusPartialContent {
|
||||||
|
t.Fatalf("status = %d, want 206 (body=%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Body.String() != "fresh-bytes" {
|
||||||
|
t.Fatalf("body = %q, want the fresh link payload", rec.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("downurl calls = %d, want 2 (one retry after the CDN rejected the stale link)", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 换链后依然是同一条失效直链时,保持原有行为:原样透传 CDN 的状态码。
|
||||||
|
func TestProxyMediaDirectPassesRejectedStatusWhenRefreshCannotHelp(t *testing.T) {
|
||||||
|
svc := testStrmService(t)
|
||||||
|
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
|
||||||
|
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
}))
|
||||||
|
defer cdn.Close()
|
||||||
|
|
||||||
|
expiry := time.Now().Add(30 * time.Minute).Unix()
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = fmt.Fprintf(w, `{"state":true,"data":{"1":{"pick_code":"pc-dead","url":{"url":%q}}}}`,
|
||||||
|
fmt.Sprintf("%s/dead.mkv?t=%d&u=1&k=abc", cdn.URL, expiry))
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
oldPro := cloud115.ProAPIBase
|
||||||
|
cloud115.ProAPIBase = api.URL
|
||||||
|
defer func() { cloud115.ProAPIBase = oldPro }()
|
||||||
|
defer cloud115.ClearDownloadURLCache("pc-dead")
|
||||||
|
oldMinAge := directLinkRefreshMinAge
|
||||||
|
directLinkRefreshMinAge = 0
|
||||||
|
defer func() { directLinkRefreshMinAge = oldMinAge }()
|
||||||
|
|
||||||
|
media := &model.Media{
|
||||||
|
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + acct.ID + "&pickcode=pc-dead",
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||||
|
|
||||||
|
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
|
||||||
|
t.Fatalf("ProxyMediaDirect: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want the upstream 403 passed through", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制换链只认 /api/strm/play 形式播放目标里的 pickcode。
|
||||||
|
func TestPickCodeFromPlayTarget(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1", "pc1"},
|
||||||
|
{"https://nas.local/api/strm/play/cloud115/video.mkv?pickcode=pc2&acct=a1", "pc2"},
|
||||||
|
{"/api/strm/play/cloud115/video.mkv?acct=a1", ""},
|
||||||
|
{"https://cdn.example.test/Movie.mkv?sign=1", ""},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := PickCodeFromPlayTarget(tc.raw); got != tc.want {
|
||||||
|
t.Fatalf("PickCodeFromPlayTarget(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
|
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
|
||||||
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
|
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
|
||||||
svc := testStrmService(t)
|
svc := testStrmService(t)
|
||||||
|
|||||||
@@ -182,6 +182,77 @@ func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID
|
|||||||
return discoverExternalSubtitleTracks(m), nil
|
return discoverExternalSubtitleTracks(m), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subtitleLanguageNames 把文件名后缀/容器里的语言标记折成可读名称。
|
||||||
|
//
|
||||||
|
// 外挂字幕只能从文件名拿到语言(`movie.chs.ass` → `chs`),直接展示给用户
|
||||||
|
// 就是「chs」这种看不懂的值;内嵌轨没有 title 标签时同理。认不出来的标记
|
||||||
|
// 原样保留,交给调用方(例如 Emby 的「字幕 (ASS)」兜底)处理。
|
||||||
|
var subtitleLanguageNames = map[string]string{
|
||||||
|
"zh": "中文",
|
||||||
|
"chi": "中文",
|
||||||
|
"zho": "中文",
|
||||||
|
"cn": "中文",
|
||||||
|
|
||||||
|
"chs": "简体中文",
|
||||||
|
"sc": "简体中文",
|
||||||
|
"zh-cn": "简体中文",
|
||||||
|
"zh-hans": "简体中文",
|
||||||
|
"zh-sg": "简体中文",
|
||||||
|
|
||||||
|
"cht": "繁体中文",
|
||||||
|
"tc": "繁体中文",
|
||||||
|
"zh-tw": "繁体中文",
|
||||||
|
"zh-hk": "繁体中文",
|
||||||
|
"zh-mo": "繁体中文",
|
||||||
|
"zh-hant": "繁体中文",
|
||||||
|
|
||||||
|
"jpn": "日本語",
|
||||||
|
"ja": "日本語",
|
||||||
|
"jp": "日本語",
|
||||||
|
|
||||||
|
"eng": "English",
|
||||||
|
"en": "English",
|
||||||
|
|
||||||
|
"kor": "한국어",
|
||||||
|
"ko": "한국어",
|
||||||
|
|
||||||
|
"yue": "粤语",
|
||||||
|
}
|
||||||
|
|
||||||
|
// subtitleLanguageName 返回语言标记的可读名称,认不出来时返回空串。
|
||||||
|
func subtitleLanguageName(raw string) string {
|
||||||
|
tag := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "_", "-")))
|
||||||
|
if tag == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if name, ok := subtitleLanguageNames[tag]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
// `chs[bilibiliid_danmu]`、`chs.ass` 这类带附加信息的标记先取前缀。
|
||||||
|
head := tag
|
||||||
|
if i := strings.IndexAny(head, "[.@"); i > 0 {
|
||||||
|
head = strings.TrimSpace(head[:i])
|
||||||
|
}
|
||||||
|
return subtitleLanguageNames[head]
|
||||||
|
}
|
||||||
|
|
||||||
|
// subtitleDisplayLabel 把语言标记换成可读名称;认不出来时保留原值(含 und)。
|
||||||
|
func subtitleDisplayLabel(raw string) string {
|
||||||
|
if name := subtitleLanguageName(raw); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDanmakuSubtitle 报告字幕文件是否是弹幕转存(`xxx[BilibiliID_danmu].ass`)。
|
||||||
|
// 这类轨道是飘过的弹幕而不是对白字幕,名称里要标出来,避免和对白字幕混淆。
|
||||||
|
func isDanmakuSubtitle(name string) bool {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
return strings.Contains(lower, "danmu") ||
|
||||||
|
strings.Contains(lower, "danmaku") ||
|
||||||
|
strings.Contains(name, "弹幕")
|
||||||
|
}
|
||||||
|
|
||||||
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
||||||
dir := filepath.Dir(m.Path)
|
dir := filepath.Dir(m.Path)
|
||||||
bases := mediaSidecarBaseVariants(m.Path)
|
bases := mediaSidecarBaseVariants(m.Path)
|
||||||
@@ -228,9 +299,13 @@ func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
|||||||
matchedBase = bases[0]
|
matchedBase = bases[0]
|
||||||
}
|
}
|
||||||
lang := detectLang(fullName, matchedBase)
|
lang := detectLang(fullName, matchedBase)
|
||||||
|
label := subtitleDisplayLabel(lang)
|
||||||
|
if isDanmakuSubtitle(e.Name()) {
|
||||||
|
label += " · 弹幕"
|
||||||
|
}
|
||||||
tracks = append(tracks, SubtitleTrack{
|
tracks = append(tracks, SubtitleTrack{
|
||||||
Lang: lang,
|
Lang: lang,
|
||||||
Label: lang,
|
Label: label,
|
||||||
Path: filepath.Join(c, e.Name()),
|
Path: filepath.Join(c, e.Name()),
|
||||||
Codec: codec,
|
Codec: codec,
|
||||||
Source: "external",
|
Source: "external",
|
||||||
@@ -319,7 +394,11 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
|
|||||||
}
|
}
|
||||||
label := strings.TrimSpace(stream.Tags.Title)
|
label := strings.TrimSpace(stream.Tags.Title)
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = lang
|
// 没有 title 标签时用可读语言名(chi → 中文)代替裸语言码。
|
||||||
|
label = subtitleDisplayLabel(lang)
|
||||||
|
}
|
||||||
|
if label == "" {
|
||||||
|
label = "字幕"
|
||||||
}
|
}
|
||||||
if stream.Disposition.Forced != 0 {
|
if stream.Disposition.Forced != 0 {
|
||||||
label += "(强制)"
|
label += "(强制)"
|
||||||
|
|||||||
@@ -202,3 +202,103 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
|
|||||||
t.Fatal("ServeRaw accepted a non-subtitle file")
|
t.Fatal("ServeRaw accepted a non-subtitle file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubtitleLanguageNameMapsCommonTags(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"chs": "简体中文",
|
||||||
|
"CHS": "简体中文",
|
||||||
|
"zh-Hans": "简体中文",
|
||||||
|
"cht": "繁体中文",
|
||||||
|
"chi": "中文",
|
||||||
|
"zh": "中文",
|
||||||
|
"jpn": "日本語",
|
||||||
|
"eng": "English",
|
||||||
|
"kor": "한국어",
|
||||||
|
"chs[bilibiliid_danmu]": "简体中文",
|
||||||
|
"und": "",
|
||||||
|
"": "",
|
||||||
|
}
|
||||||
|
for raw, want := range cases {
|
||||||
|
if got := subtitleLanguageName(raw); got != want {
|
||||||
|
t.Errorf("subtitleLanguageName(%q) = %q, want %q", raw, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubtitleDisplayLabelKeepsUnknownTags(t *testing.T) {
|
||||||
|
// 认不出来的标记原样返回:Emby 的 DisplayTitle 依赖 `und` 走「字幕 (ASS)」兜底。
|
||||||
|
if got := subtitleDisplayLabel("und"); got != "und" {
|
||||||
|
t.Fatalf("subtitleDisplayLabel(und) = %q, want und", got)
|
||||||
|
}
|
||||||
|
if got := subtitleDisplayLabel("weird"); got != "weird" {
|
||||||
|
t.Fatalf("subtitleDisplayLabel(weird) = %q, want weird", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDanmakuSubtitle(t *testing.T) {
|
||||||
|
if !isDanmakuSubtitle("S01E01.chs[BilibiliID_danmu].ass") {
|
||||||
|
t.Fatal("danmaku sidecar was not detected")
|
||||||
|
}
|
||||||
|
if !isDanmakuSubtitle("弹幕.ass") {
|
||||||
|
t.Fatal("Chinese danmaku name was not detected")
|
||||||
|
}
|
||||||
|
if isDanmakuSubtitle("S01E01.chs.ass") {
|
||||||
|
t.Fatal("normal subtitle was misdetected as danmaku")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverExternalSubtitleLabelsAreReadable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
videoPath := filepath.Join(dir, "S01E01.mkv")
|
||||||
|
files := map[string]string{
|
||||||
|
"S01E01.chs.ass": "[Script Info]\n",
|
||||||
|
"S01E01.chs[BilibiliID_danmu].ass": "[Script Info]\n",
|
||||||
|
}
|
||||||
|
for name, body := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &model.Media{Path: videoPath}
|
||||||
|
tracks := discoverExternalSubtitleTracks(m)
|
||||||
|
if len(tracks) != 2 {
|
||||||
|
t.Fatalf("len(tracks) = %d, want 2: %#v", len(tracks), tracks)
|
||||||
|
}
|
||||||
|
labels := map[string]string{}
|
||||||
|
for _, track := range tracks {
|
||||||
|
labels[filepath.Base(track.Path)] = track.Label
|
||||||
|
}
|
||||||
|
if got := labels["S01E01.chs.ass"]; got != "简体中文" {
|
||||||
|
t.Fatalf("chs.ass label = %q, want 简体中文", got)
|
||||||
|
}
|
||||||
|
if got := labels["S01E01.chs[BilibiliID_danmu].ass"]; got != "简体中文 · 弹幕" {
|
||||||
|
t.Fatalf("danmaku label = %q, want 简体中文 · 弹幕", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbeddedSubtitleProbeUsesReadableLanguage(t *testing.T) {
|
||||||
|
var probe embeddedSubtitleProbe
|
||||||
|
raw := []byte(`{"streams":[
|
||||||
|
{"index":2,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"chi"},"disposition":{"default":1}},
|
||||||
|
{"index":3,"codec_name":"subrip","tags":{"language":"jpn"}},
|
||||||
|
{"index":4,"codec_name":"subrip","tags":{"language":"chi","title":"简日双语"}}
|
||||||
|
]}`)
|
||||||
|
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tracks := subtitleTracksFromProbe(probe)
|
||||||
|
if len(tracks) != 3 {
|
||||||
|
t.Fatalf("len(tracks) = %d, want 3", len(tracks))
|
||||||
|
}
|
||||||
|
if got := tracks[0].Label; got != "中文(默认)(内嵌·图片)" {
|
||||||
|
t.Fatalf("bitmap label = %q", got)
|
||||||
|
}
|
||||||
|
if got := tracks[1].Label; got != "日本語(内嵌)" {
|
||||||
|
t.Fatalf("text label = %q", got)
|
||||||
|
}
|
||||||
|
// 容器自带 title 时优先用 title。
|
||||||
|
if got := tracks[2].Label; got != "简日双语(内嵌)" {
|
||||||
|
t.Fatalf("titled label = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -170,8 +170,16 @@ func (s *TokenService) storeRefreshToken(ctx context.Context, rt *model.RefreshT
|
|||||||
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
|
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.repo.RefreshToken.RevokeOldestActiveByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
|
// 「最大同时登录终端」只约束未绑定设备会话的旧客户端。
|
||||||
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
|
//
|
||||||
|
// 移动端每台设备有独立认证会话,刷新令牌归属该会话;如果仍按用户统一
|
||||||
|
// 裁剪活跃令牌,第 N+1 台设备登录(或任一设备刷新)就会把最早那台设备
|
||||||
|
// 的刷新令牌标记为已撤销,那台设备下次刷新收到 SESSION_REVOKED,被迫
|
||||||
|
// 重新登录。设备数量由设备策略单独约束,不能靠撤销刷新令牌实现。
|
||||||
|
if rt.AuthSessionID == "" {
|
||||||
|
if err := s.repo.RefreshToken.RevokeOldestActiveLegacyByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
|
||||||
|
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user