mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b7d4eef46 | |||
| c30dab56a3 | |||
| e365250440 | |||
| 47d10e1f58 |
@@ -45,6 +45,16 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
|
||||
if withPreview {
|
||||
previews, err := svc.Media.ListLibrariesWithPreview(c.Request.Context(), libs, mediaVisibilityForRequest(c, svc), 10)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, previews)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, libs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,35 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
type libraryCountRow struct {
|
||||
LibraryID string `gorm:"column:library_id"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
}
|
||||
|
||||
// CountByLibraries returns a map of library_id -> total media count for the given library IDs.
|
||||
func (r *MediaRepository) CountByLibraries(ctx context.Context, libraryIDs []string, filter MediaQueryFilter) (map[string]int64, error) {
|
||||
out := make(map[string]int64, len(libraryIDs))
|
||||
if len(libraryIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []libraryCountRow
|
||||
q := r.db.WithContext(ctx).Model(&model.Media{}).
|
||||
Select("library_id, count(*) as total")
|
||||
if len(libraryIDs) == 1 {
|
||||
q = q.Where("library_id = ?", libraryIDs[0])
|
||||
} else {
|
||||
q = q.Where("library_id IN ?", libraryIDs)
|
||||
}
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if err := q.Group("library_id").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.LibraryID] = row.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteByLibrary purges all media tied to a library.
|
||||
func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) error {
|
||||
// FTS 行由 media 表上的触发器同步清理(物理删除触发 FTS 清理)。
|
||||
|
||||
@@ -185,7 +185,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
|
||||
// 避免长时间同步因 token 过期而整体失败。
|
||||
if isTokenCode(base.Code) {
|
||||
if access && c.tryRefreshTokenLocked() {
|
||||
if access && c.tryRefreshTokenLocked(ctx) {
|
||||
continue
|
||||
}
|
||||
if access {
|
||||
@@ -259,19 +259,43 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
|
||||
}
|
||||
|
||||
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
|
||||
// 应使用内存中的新 token 重试原请求)。refresh_token 已失效时也会清空内存 token。
|
||||
func (c *OpenClient) tryRefreshTokenLocked() bool {
|
||||
// 应使用内存中的新 token 重试原请求)。
|
||||
//
|
||||
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
|
||||
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
|
||||
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
|
||||
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
|
||||
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
|
||||
c.tokenMu.Lock()
|
||||
defer c.tokenMu.Unlock()
|
||||
token, err := c.RefreshToken(c.RefreshTokenStr)
|
||||
if err != nil {
|
||||
for attempt := 0; attempt < refreshAttempts; attempt++ {
|
||||
token, err := c.RefreshToken(c.RefreshTokenStr)
|
||||
if err == nil {
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
return true
|
||||
}
|
||||
if IsRefreshTokenDead(err) {
|
||||
c.SetAuthToken("", "")
|
||||
return false
|
||||
}
|
||||
// 可恢复失败:退避后重试。ctx 取消时立即放弃。
|
||||
if attempt < refreshAttempts-1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(refreshBackoff(attempt)):
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// refreshAttempts 是刷新 access_token 失败时的最大尝试次数(含首次)。
|
||||
const refreshAttempts = 3
|
||||
|
||||
// refreshBackoff 返回第 attempt 次(从 0 计)刷新失败后的退避时长(指数退避)。
|
||||
func refreshBackoff(attempt int) time.Duration {
|
||||
return time.Duration(200*(1<<attempt)) * time.Millisecond // 200ms, 400ms
|
||||
}
|
||||
|
||||
// IsThrottleCode 判断是否为限流错误码。
|
||||
|
||||
@@ -429,3 +429,46 @@ func TestRemoteFileDetailRelativePath(t *testing.T) {
|
||||
t.Errorf("d3.RelativePath = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFsListRefreshContinue 验证 access_token 在请求中途过期(40140126)时:
|
||||
// 自动用 refresh_token 刷新得到新 token,然后对原请求重试成功(同步得以继续)。
|
||||
func TestFsListRefreshContinue(t *testing.T) {
|
||||
var filesCalls int
|
||||
var refreshCalls int
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/open/refreshToken":
|
||||
refreshCalls++
|
||||
w.Write([]byte(`{"state":true,"data":{"access_token":"at2","refresh_token":"rt2","expires_in":7200}}`))
|
||||
case "/open/ufile/files":
|
||||
filesCalls++
|
||||
switch filesCalls {
|
||||
case 1:
|
||||
// 第一次用旧 access_token,返回过期错误,应触发刷新
|
||||
w.Write([]byte(`{"state":false,"code":40140126,"message":"access_token 校验失败"}`))
|
||||
default:
|
||||
// 刷新后续请求应使用新 access_token
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer at2" {
|
||||
t.Errorf("retried request auth = %q, want Bearer at2", got)
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"path":[],"data":[{"fid":"200","fc":"1","fn":"a.mkv","fs":123,"pc":"pickA"}]}`))
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
files, _, err := c.GetFsList(context.Background(), "0", 0, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("expected sync to continue after refresh, got error: %v", err)
|
||||
}
|
||||
if filesCalls != 2 {
|
||||
t.Fatalf("want 2 files calls (original + retried), got %d", filesCalls)
|
||||
}
|
||||
if refreshCalls == 0 {
|
||||
t.Fatal("expected refresh_token to be used once")
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("want 1 file, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,81 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LibraryPreviewItem struct {
|
||||
model.Library
|
||||
Total int64 `json:"total"`
|
||||
Cards []SeriesCard `json:"cards"`
|
||||
}
|
||||
|
||||
// ListLibraries returns every library configured on the server.
|
||||
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
|
||||
return s.repo.Library.List(ctx)
|
||||
}
|
||||
|
||||
// ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards.
|
||||
func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
|
||||
if cardLimit <= 0 {
|
||||
cardLimit = 10
|
||||
}
|
||||
out := make([]LibraryPreviewItem, len(libraries))
|
||||
if len(libraries) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
libIDs := make([]string, 0, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
out[i] = LibraryPreviewItem{
|
||||
Library: lib,
|
||||
Total: 0,
|
||||
Cards: []SeriesCard{},
|
||||
}
|
||||
libIDs = append(libIDs, lib.ID)
|
||||
}
|
||||
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
filter := repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
}
|
||||
|
||||
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
if total, ok := counts[out[i].ID]; ok {
|
||||
out[i].Total = total
|
||||
}
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
if out[i].Total == 0 {
|
||||
continue
|
||||
}
|
||||
items, _, err := s.repo.Media.ListByLibrariesFiltered(ctx, []string{out[i].ID}, 0, 60, filter)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, items)
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) > cardLimit {
|
||||
cards = cards[:cardLimit]
|
||||
}
|
||||
if cards == nil {
|
||||
cards = []SeriesCard{}
|
||||
}
|
||||
out[i].Cards = cards
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteLibrary removes a library and its media rows. The on-disk files are
|
||||
// left untouched.
|
||||
func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
func TestListLibrariesWithPreview(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
|
||||
lib1 := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib2 := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||||
var rows []model.Media
|
||||
|
||||
// Add 5 movies to lib1
|
||||
for i := 1; i <= 5; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("movie-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Hour)},
|
||||
LibraryID: lib1.ID,
|
||||
Title: fmt.Sprintf("电影%d", i),
|
||||
Path: fmt.Sprintf("/media/movies/电影%d/movie%d.mp4", i, i),
|
||||
PosterURL: fmt.Sprintf("/api/media/movie-%02d/poster", i),
|
||||
})
|
||||
}
|
||||
|
||||
// Add 12 episodes of 1 anime to lib2
|
||||
for i := 1; i <= 12; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("anime-ep-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
|
||||
LibraryID: lib2.ID,
|
||||
Title: fmt.Sprintf("某动漫 第%d集", i),
|
||||
Path: fmt.Sprintf("/media/anime/某动漫/Season 01/某动漫.S01E%02d.mp4", i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
PosterURL: "/api/media/anime-01/poster",
|
||||
})
|
||||
}
|
||||
|
||||
if err := repos.DB.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
|
||||
previews, err := svc.ListLibrariesWithPreview(t.Context(), []model.Library{lib1, lib2}, MediaVisibility{IncludeNSFW: true}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLibrariesWithPreview failed: %v", err)
|
||||
}
|
||||
|
||||
if len(previews) != 2 {
|
||||
t.Fatalf("got %d previews, want 2", len(previews))
|
||||
}
|
||||
|
||||
// Verify lib1 (movies)
|
||||
if previews[0].ID != lib1.ID {
|
||||
t.Errorf("preview[0].ID = %q, want %q", previews[0].ID, lib1.ID)
|
||||
}
|
||||
if previews[0].Total != 5 {
|
||||
t.Errorf("preview[0].Total = %d, want 5", previews[0].Total)
|
||||
}
|
||||
if len(previews[0].Cards) != 5 {
|
||||
t.Errorf("preview[0].Cards count = %d, want 5", len(previews[0].Cards))
|
||||
}
|
||||
|
||||
// Verify lib2 (anime)
|
||||
if previews[1].ID != lib2.ID {
|
||||
t.Errorf("preview[1].ID = %q, want %q", previews[1].ID, lib2.ID)
|
||||
}
|
||||
if previews[1].Total != 12 {
|
||||
t.Errorf("preview[1].Total = %d, want 12", previews[1].Total)
|
||||
}
|
||||
// 12 episodes should be grouped into 1 SeriesCard with Count = 12
|
||||
if len(previews[1].Cards) != 1 {
|
||||
t.Errorf("preview[1].Cards count = %d, want 1", len(previews[1].Cards))
|
||||
} else if previews[1].Cards[0].Count != 12 {
|
||||
t.Errorf("preview[1].Cards[0].Count = %d, want 12", previews[1].Cards[0].Count)
|
||||
}
|
||||
}
|
||||
+11
-3
@@ -81,11 +81,19 @@ export interface MediaMetadataUpdate {
|
||||
nsfw?: boolean
|
||||
}
|
||||
|
||||
export interface LibraryWithPreview extends Library {
|
||||
total?: number
|
||||
cards?: SeriesCard[]
|
||||
}
|
||||
|
||||
export const libraryAPI = {
|
||||
list: (options?: { includeHidden?: boolean }) =>
|
||||
list: (options?: { includeHidden?: boolean; withPreview?: boolean }) =>
|
||||
api
|
||||
.get<Library[]>('/libraries', {
|
||||
params: options?.includeHidden ? { include_hidden: 1 } : undefined,
|
||||
.get<LibraryWithPreview[]>('/libraries', {
|
||||
params: {
|
||||
...(options?.includeHidden ? { include_hidden: 1 } : {}),
|
||||
...(options?.withPreview ? { with_preview: 1 } : {}),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
LibrariesEmptyState,
|
||||
LibrariesHeader,
|
||||
} from './LibrariesPageSections'
|
||||
import { isSeriesLibraryType, latestLibraryCards, type LibraryPreview } from './librariesPageModel'
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||
@@ -20,24 +20,15 @@ export function LibrariesPage() {
|
||||
const loadLibraries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const libs = await libraryAPI.list()
|
||||
const rows = await Promise.all(libs.map(async (library) => {
|
||||
try {
|
||||
if (isSeriesLibraryType(library.type)) {
|
||||
const [seriesPage, mediaPage] = await Promise.all([
|
||||
libraryAPI.listSeries(library.id, 1, 10),
|
||||
libraryAPI.listMedia(library.id, 1, 1, { groupVersions: false }),
|
||||
])
|
||||
return { library, items: [], total: mediaPage.total, cards: seriesPage.items ?? [] } satisfies LibraryPreview
|
||||
}
|
||||
const page = await libraryAPI.listMedia(library.id, 1, 160, { groupVersions: false })
|
||||
const cards = latestLibraryCards(page.items)
|
||||
return { library, items: page.items, total: page.total, cards } satisfies LibraryPreview
|
||||
} catch {
|
||||
return { library, items: [], total: 0, cards: [] } satisfies LibraryPreview
|
||||
}
|
||||
}))
|
||||
setPreviews(rows)
|
||||
const libs = await libraryAPI.list({ withPreview: true })
|
||||
setPreviews(
|
||||
libs.map((library) => ({
|
||||
library,
|
||||
items: [],
|
||||
total: library.total ?? 0,
|
||||
cards: library.cards ?? [],
|
||||
})),
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { artworkScore, groupSeries, type SeriesCard } from '../utils/groupSeries
|
||||
|
||||
export type LibraryPreview = {
|
||||
library: Library
|
||||
items: Media[]
|
||||
items?: Media[]
|
||||
total: number
|
||||
cards: SeriesCard[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user