Compare commits

..

7 Commits

Author SHA1 Message Date
truewhile a7b24e1bf1 Merge pull request #47 from truewhile/main
拉齐代码
2026-10-20 11:33:06 +08:00
truewhile 0b68397265 Merge pull request #46 from truewhile/fix/introdb-api-v4
迁移 TheIntroDB 接口到 v4
2026-10-19 22:26:33 +08:00
truewhile 90bfac3b09 迁移 TheIntroDB 接口到 v4
v1 已删除、v2/v3 已弃用,仅保留只读的 GET /media。本次把 base URL 切到 v4,并适配响应以 tvdb_id 为身份(tmdb_id 字段已移除)。请求仍用 tmdb_id 寻址,v4 仍接受并由服务端解析季集映射。
2026-10-19 22:23:06 +08:00
truewhile f1974a179e 优化 2026-10-19 21:38:55 +08:00
truewhile f2357200be 优化 2026-10-19 21:11:35 +08:00
truewhile cd6b37c6a0 Merge pull request #35 from truewhile/feature/reading
Feature/reading
2026-10-01 21:59:11 +08:00
truewhile ef8b017c63 Merge pull request #19 from truewhile/main
拉取最新代码
2026-09-02 17:13:21 +08:00
8 changed files with 210 additions and 26 deletions
@@ -46,16 +46,20 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri
})
}
// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a
// user by revoking the oldest non-expired, non-revoked tokens.
func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error {
// RevokeOldestActiveLegacyByUserID keeps at most limit active refresh tokens
// for a user among tokens that are not bound to a device auth session
// (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 {
limit = 1
}
return withSQLiteBusyRetry(ctx, func() error {
var tokens []model.RefreshToken
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").
Find(&tokens).Error; err != nil {
return err
+47
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"fmt"
"testing"
"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) {
db := newServiceTestDB(t, &model.Setting{})
repos := repository.New(db)
+6 -3
View File
@@ -22,8 +22,9 @@ import (
const (
// IntroDBBaseURL is the public read endpoint. Overridable on the service
// for tests and for pointing at a mirror.
IntroDBBaseURL = "https://api.theintrodb.org/v3"
// for tests and for pointing at a mirror. v1 已删除,v2/v3 冻结在只读的
// GET /media 上并被官方标记为弃用,所以这里直接跟 v4。
IntroDBBaseURL = "https://api.theintrodb.org/v4"
// IntroDBSource tags rows that came from this provider.
IntroDBSource = "theintrodb"
@@ -92,8 +93,10 @@ type introDBRange struct {
EndMs *int64 `json:"end_ms"`
}
// introDBResponse 对应 v4 的 MediaResponse。v4 以 TVDB 为身份,响应只带
// tvdb_id —— tmdb_id 字段已被官方移除,需要 tmdb_id 只能从请求侧自己记。
type introDBResponse struct {
TMDbID int `json:"tmdb_id"`
TVDBID int `json:"tvdb_id"`
Type string `json:"type"`
Intro []introDBRange `json:"intro"`
Recap []introDBRange `json:"recap"`
+9 -8
View File
@@ -11,11 +11,12 @@ import (
"go.uber.org/zap"
)
// 这两段响应是从 api.theintrodb.org/v3/media 实测抓下来的原文,
// 用来锁住 null 语义:start_ms: null = 从片头开始,end_ms: null = 一直到片尾。
// 这两段响应是从 api.theintrodb.org/v4/media 实测抓下来的原文(v4 以 TVDB 为身份,
// 响应里只有 tvdb_id),用来锁住 null 语义:start_ms: null = 从片头开始,
// end_ms: null = 一直到片尾。
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}]}`
introDBMoviePayload = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
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 = `{"tvdb_id":113,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
)
func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
@@ -48,7 +49,7 @@ func TestParseIntroDBResponseResolvesNullBounds(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}],
"recap":[],"credits":[],"preview":[]}`
spans, err := parseIntroDBResponse([]byte(body))
@@ -62,7 +63,7 @@ func TestParseIntroDBResponseDropsEmptyRanges(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}]}`
spans, err := parseIntroDBResponse([]byte(body))
if err != nil {
@@ -82,12 +83,12 @@ func TestParseIntroDBResponseOrdersByType(t *testing.T) {
func TestIntroDBMediaURLOnlyAddsSeasonEpisodeForTV(t *testing.T) {
svc := NewIntroDBService(zap.NewNop())
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)
}
// 电影(season/episode 为 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)
}
}
@@ -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) {
root := t.TempDir()
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
+47 -5
View File
@@ -11,23 +11,23 @@ import (
"strings"
)
func metadataFromArtwork(mediaPath, showBaseDir string) *LocalMetadata {
func metadataFromArtwork(mediaPath, showBaseDir, libraryRoot string) *LocalMetadata {
meta := &LocalMetadata{}
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
if meta.PosterURL == "" && meta.BackdropURL == "" {
return nil
}
return meta
}
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir, libraryRoot string) {
if meta == nil {
return
}
mediaDir := filepath.Dir(mediaPath)
if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" {
meta.PosterURL = localPoster
} else if meta.PosterURL == "" {
} else if meta.PosterURL == "" && looseArtworkFallbackAllowed(mediaDir, libraryRoot) {
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
}
dirs := []string{mediaDir, showBaseDir}
@@ -39,7 +39,7 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
meta.BackdropURL = img
break
}
if meta.BackdropURL == "" {
if meta.BackdropURL == "" && looseArtworkFallbackAllowed(dir, libraryRoot) {
meta.BackdropURL = firstAdultLooseImage(dir, "backdrop")
}
}
@@ -245,6 +245,48 @@ func firstExistingPosterImage(dir string, names ...string) string {
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 {
if dir == "" {
return ""
+4 -4
View File
@@ -17,12 +17,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
doc, path, err := findMovieNFO(mediaPath, libraryRoot)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return metadataFromArtwork(mediaPath, ""), nil
return metadataFromArtwork(mediaPath, "", libraryRoot), nil
}
return nil, err
}
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
// reintroduce the bogus season/episode on rescan.
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
@@ -115,9 +115,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
}
}
if meta == nil {
meta = metadataFromArtwork(mediaPath, showBaseDir)
meta = metadataFromArtwork(mediaPath, showBaseDir, libraryRoot)
} else {
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
}
// A sidecar written while resolution tokens were misread as SxxExx must not
// reintroduce the bogus season/episode on rescan.
+10 -2
View File
@@ -170,8 +170,16 @@ func (s *TokenService) storeRefreshToken(ctx context.Context, rt *model.RefreshT
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
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
}