diff --git a/internal/handler/media.go b/internal/handler/media.go index 25ad78a..1cf8266 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -171,6 +171,22 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc { func searchMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { q := c.Query("q") + if c.Query("page") != "" || c.Query("page_size") != "" { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + items, total, err := svc.Media.SearchMediaVisiblePage(c.Request.Context(), q, page, size, mediaVisibilityForRequest(c, svc)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": total, + "page": page, + "page_size": size, + }) + return + } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc)) if err != nil { diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 4484a3c..6ec7250 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -461,46 +461,58 @@ func (r *MediaRepository) Search(ctx context.Context, query string, limit int) ( } func (r *MediaRepository) SearchFiltered(ctx context.Context, query string, limit int, filter MediaQueryFilter) ([]model.Media, error) { + items, _, err := r.SearchFilteredPage(ctx, query, 0, limit, filter) + return items, err +} + +func (r *MediaRepository) SearchFilteredPage(ctx context.Context, query string, offset, limit int, filter MediaQueryFilter) ([]model.Media, int64, error) { query = strings.TrimSpace(query) if limit <= 0 { limit = 50 } if query != "" { - if items, ok := r.searchFilteredFTS(ctx, query, limit, filter); ok { - if len(items) > 0 { - return items, nil + if items, total, ok := r.searchFilteredFTS(ctx, query, offset, limit, filter); ok { + if total > 0 { + return items, total, nil } } } - return r.searchFilteredLIKE(ctx, query, limit, filter) + return r.searchFilteredLIKE(ctx, query, offset, limit, filter) } -func (r *MediaRepository) searchFilteredFTS(ctx context.Context, query string, limit int, filter MediaQueryFilter) ([]model.Media, bool) { +func (r *MediaRepository) searchFilteredFTS(ctx context.Context, query string, offset, limit int, filter MediaQueryFilter) ([]model.Media, int64, bool) { if !r.searchIndexEnabled(ctx) { - return nil, false + return nil, 0, false } ftsQuery := mediaFTSQuery(query) if ftsQuery == "" { - return nil, false + return nil, 0, false } + var total int64 var items []model.Media q := r.db.WithContext(ctx). Table("media"). - Select("media.*"). Joins("JOIN media_search_fts ON media_search_fts.media_id = media.id"). Where("media.deleted_at IS NULL"). Where("media_search_fts MATCH ?", ftsQuery) q = applyQualifiedMediaQueryFilter(q, filter) - err := q.Order("bm25(media_search_fts), media.created_at DESC").Limit(limit).Find(&items).Error - if err != nil { - return nil, false + if err := q.Count(&total).Error; err != nil { + return nil, 0, false } - return items, true + if total == 0 { + return items, 0, true + } + err := q.Select("media.*").Order("bm25(media_search_fts), media.created_at DESC").Offset(offset).Limit(limit).Find(&items).Error + if err != nil { + return nil, 0, false + } + return items, total, true } -func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string, limit int, filter MediaQueryFilter) ([]model.Media, error) { +func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string, offset, limit int, filter MediaQueryFilter) ([]model.Media, int64, error) { var items []model.Media - q := r.db.WithContext(ctx).Model(&model.Media{}).Limit(limit) + var total int64 + q := r.db.WithContext(ctx).Model(&model.Media{}) q = applyMediaQueryFilter(q, filter) terms := mediaSearchTerms(query) for _, term := range terms { @@ -510,6 +522,9 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string, like, like, like, like, ) } + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } if query != "" { prefix := escapeLike(query) + "%" exact := query @@ -520,8 +535,8 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string, } else { q = q.Order("created_at desc") } - err := q.Find(&items).Error - return items, err + err := q.Offset(offset).Limit(limit).Find(&items).Error + return items, total, err } func applyQualifiedMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB { diff --git a/internal/service/cloud_mount_filter_test.go b/internal/service/cloud_mount_filter_test.go index 59e3e69..67c2f79 100644 --- a/internal/service/cloud_mount_filter_test.go +++ b/internal/service/cloud_mount_filter_test.go @@ -145,6 +145,17 @@ func TestListMediaVisibleIncludesMergedCloudLibraryItems(t *testing.T) { if total != 2 || !slices.Equal(mediaTitles(items), []string{"云盘剧", "本地剧"}) { t.Fatalf("profile-limited merged list total=%d items=%#v", total, mediaTitles(items)) } + + searchItems, err := svc.SearchMediaVisible(t.Context(), "剧", 20, MediaVisibility{ + IncludeNSFW: true, + AllowedLibraryIDs: []string{local.ID}, + }) + if err != nil { + t.Fatal(err) + } + if got := mediaTitles(searchItems); !slices.Equal(got, []string{"云盘剧", "本地剧"}) { + t.Fatalf("profile-limited merged search items=%#v, want local+hidden cloud", got) + } } func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) { diff --git a/internal/service/media.go b/internal/service/media.go index 5356778..8f08321 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -29,6 +29,9 @@ type MediaVisibility struct { HiddenLibraryIDs []string } +const maxMediaSearchLimit = 50000 +const maxMediaSearchPageSize = 2000 + func (v MediaVisibility) Allows(media *model.Media) bool { if media == nil { return false @@ -281,8 +284,8 @@ func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]model.Media, error) { if limit <= 0 { limit = 50 - } else if limit > 2000 { - limit = 2000 + } else if limit > maxMediaSearchLimit { + limit = maxMediaSearchLimit } visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) return s.repo.Media.SearchFiltered(ctx, query, limit, repository.MediaQueryFilter{ @@ -292,6 +295,24 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim }) } +func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) { + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > maxMediaSearchPageSize { + pageSize = maxMediaSearchPageSize + } + if page < 1 { + page = 1 + } + visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) + return s.repo.Media.SearchFilteredPage(ctx, query, (page-1)*pageSize, pageSize, repository.MediaQueryFilter{ + IncludeNSFW: visibility.IncludeNSFW, + AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, + }) +} + // GetMedia returns a single media row. func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) { return s.repo.Media.FindByID(ctx, id) diff --git a/internal/service/media_visibility_test.go b/internal/service/media_visibility_test.go index f97b571..cadc319 100644 --- a/internal/service/media_visibility_test.go +++ b/internal/service/media_visibility_test.go @@ -176,6 +176,61 @@ func TestSearchMediaVisibleHonorsLargePosterWallLimit(t *testing.T) { } } +func TestSearchMediaVisibleCanReturnHugeLibraryResultsWhenRequested(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + lib := model.Library{Name: "海量剧集", Path: "/media/huge", Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + const total = 2505 + rows := make([]model.Media, total) + for i := range rows { + rows[i] = model.Media{ + LibraryID: lib.ID, + Title: fmt.Sprintf("海量剧集 %04d", i), + Path: fmt.Sprintf("/media/huge/show-%04d.mkv", i), + SeasonNum: 1, + EpisodeNum: i + 1, + } + } + if err := db.CreateInBatches(&rows, 500).Error; err != nil { + t.Fatal(err) + } + + items, err := NewMediaService(&config.Config{}, zap.NewNop(), repos). + SearchMediaVisible(t.Context(), "海量剧集", total, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if len(items) != total { + t.Fatalf("huge search returned %d rows, want %d", len(items), total) + } + + firstPage, totalRows, err := NewMediaService(&config.Config{}, zap.NewNop(), repos). + SearchMediaVisiblePage(t.Context(), "海量剧集", 1, 2000, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if totalRows != total || len(firstPage) != 2000 { + t.Fatalf("huge search page 1 len=%d total=%d, want len=2000 total=%d", len(firstPage), totalRows, total) + } + secondPage, totalRows, err := NewMediaService(&config.Config{}, zap.NewNop(), repos). + SearchMediaVisiblePage(t.Context(), "海量剧集", 2, 2000, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if totalRows != total || len(secondPage) != total-2000 { + t.Fatalf("huge search page 2 len=%d total=%d, want len=%d total=%d", len(secondPage), totalRows, total-2000, total) + } +} + func sortedMediaTitles(rows []model.Media) []string { out := make([]string, 0, len(rows)) for _, row := range rows { diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 1cc6180..5254916 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -8,6 +8,13 @@ export interface MediaPage { page_size: number } +export interface MediaSearchPage { + items: Media[] + total?: number + page?: number + page_size?: number +} + export const libraryAPI = { list: (options?: { includeHidden?: boolean }) => api @@ -38,7 +45,15 @@ export const libraryAPI = { export const mediaAPI = { search: (q: string, limit = 50) => - api.get<{ items: Media[] }>('/media', { params: { q, limit } }).then((r) => r.data), + api.get('/media', { params: { q, limit } }).then((r) => r.data), + + searchPage: (q: string, page = 1, pageSize = 50) => + api + .get('/media', { + params: { q, page, page_size: pageSize }, + timeout: LONG_REQUEST_TIMEOUT, + }) + .then((r) => r.data), get: (id: string) => api.get(`/media/${id}`).then((r) => r.data), } diff --git a/web/src/components/GlobalEvents.tsx b/web/src/components/GlobalEvents.tsx index 5a62a0e..36a7a7b 100644 --- a/web/src/components/GlobalEvents.tsx +++ b/web/src/components/GlobalEvents.tsx @@ -2,15 +2,18 @@ import { useCallback } from 'react' import toast from 'react-hot-toast' import { useWebSocket } from '../hooks/useWebSocket' +import { useAuthStore } from '../stores/auth' // GlobalEvents subscribes to the WS hub and surfaces interesting events // as toasts. Lives at the top of the component tree so every page sees // the same stream without re-opening connections. export function GlobalEvents() { + const role = useAuthStore((state) => state.user?.role) const onEvent = useCallback((topic: string, payload: unknown) => { if (!payload || typeof payload !== 'object') return const p = payload as Record if (topic === 'scan') { + if (role !== 'admin') return const id = `scan-${String(p.library_id ?? 'global')}` if (p.error) { toast.error(`扫描失败:${String(p.error)}`, { id }) @@ -40,7 +43,7 @@ export function GlobalEvents() { const queued = (p.queued as number | undefined) ?? 0 if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`) } - }, []) + }, [role]) useWebSocket(onEvent) return null diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 315447f..65b358b 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -13,8 +13,6 @@ import { useAuthStore } from '../stores/auth' import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries' import { useWebSocket } from '../hooks/useWebSocket' -const MAX_LIBRARY_ITEMS_IN_BROWSER = 3_000 - export function LibraryPage() { const { id = '' } = useParams() const [searchParams, setSearchParams] = useSearchParams() @@ -24,6 +22,7 @@ export function LibraryPage() { const [items, setItems] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) + const [loadingAll, setLoadingAll] = useState(false) const [scanning, setScanning] = useState(false) const [scanProgress, setScanProgress] = useState('') const [scraping, setScraping] = useState(false) @@ -77,12 +76,12 @@ export function LibraryPage() { if (!id || !library) return let cancelled = false setLoading(true) + setLoadingAll(true) setItems([]) const loadAll = async () => { - const pageSize = 500 + const pageSize = 2000 let page = 1 let collected: Media[] = [] - let warnedLargeLibrary = false try { for (;;) { const d = await libraryAPI.listMedia(id, page, pageSize) @@ -90,18 +89,15 @@ export function LibraryPage() { collected = collected.concat(d.items) setItems(collected) setTotal(d.total) + if (page === 1) setLoading(false) if (collected.length >= d.total || d.items.length < pageSize) break - if (collected.length >= MAX_LIBRARY_ITEMS_IN_BROWSER) { - if (!warnedLargeLibrary) { - warnedLargeLibrary = true - toast(`媒体库条目较多,已先加载前 ${MAX_LIBRARY_ITEMS_IN_BROWSER} 条,避免浏览器卡死。请使用搜索或更细的媒体库目录浏览。`) - } - break - } page += 1 } } finally { - if (!cancelled) setLoading(false) + if (!cancelled) { + setLoading(false) + setLoadingAll(false) + } } } loadAll().catch(() => { @@ -118,6 +114,7 @@ export function LibraryPage() { }, []) const onRealtimeEvent = useCallback((topic: string, payload: unknown) => { + if (role !== 'admin') return if (topic !== 'scan' || !payload || typeof payload !== 'object') return const p = payload as Record if (p.library_id !== id) return @@ -146,7 +143,7 @@ export function LibraryPage() { setScanning(true) setScanProgress(`${stage}:目录 ${p.dirs ?? 0} · 已发现 ${p.discovered ?? 0} · 已入库 ${p.visited ?? 0}${speedText}`) } - }, [id, reloadCurrentLibrary]) + }, [id, reloadCurrentLibrary, role]) useWebSocket(onRealtimeEvent) @@ -249,6 +246,9 @@ export function LibraryPage() { ({isSeries ? seriesCards.length : total}) {library &&

{library.type} · {library.path}

} + {loadingAll && !loading && total > items.length && ( +

正在继续加载全部条目:{items.length} / {total}

+ )} {scanProgress &&

{scanProgress}

} {role === 'admin' && ( diff --git a/web/src/pages/SearchPage.tsx b/web/src/pages/SearchPage.tsx index 8b1ea54..330f828 100644 --- a/web/src/pages/SearchPage.tsx +++ b/web/src/pages/SearchPage.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useState } from 'react' +import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import toast from 'react-hot-toast' import { CheckCircle2, Info, Rss, Sparkles } from 'lucide-react' @@ -10,6 +10,8 @@ import { MediaCard } from '../components/MediaCard' import type { Media } from '../types' import { groupSeries, seriesCardLink } from '../utils/groupSeries' +const LOCAL_SEARCH_PAGE_SIZE = 2000 + export function SearchPage() { const [q, setQ] = useState('') const [items, setItems] = useState([]) @@ -21,6 +23,8 @@ export function SearchPage() { const [hasSearched, setHasSearched] = useState(false) const [externalItems, setExternalItems] = useState([]) const [subscribing, setSubscribing] = useState('') + const [searchTotal, setSearchTotal] = useState(0) + const searchSeq = useRef(0) const localCards = useMemo(() => groupSeries(items), [items]) useEffect(() => { @@ -31,29 +35,45 @@ export function SearchPage() { }, []) const doQuickSearch = useCallback((query: string) => { + const seq = ++searchSeq.current if (!query.trim()) { setItems([]) + setSearchTotal(0) setHasSearched(false) setLoading(false) return } setHasSearched(true) setError('') - mediaAPI - .search(query, 60) - .then((d) => { - setItems(d.items ?? []) - setExternalItems([]) - setIntent(null) - }) + const loadAll = async () => { + let page = 1 + let collected: Media[] = [] + for (;;) { + const d = await mediaAPI.searchPage(query, page, LOCAL_SEARCH_PAGE_SIZE) + if (seq !== searchSeq.current) return + const pageItems = d.items ?? [] + collected = collected.concat(pageItems) + const total = d.total ?? collected.length + setItems(collected) + setSearchTotal(total) + if (collected.length >= total || pageItems.length < LOCAL_SEARCH_PAGE_SIZE) break + page += 1 + } + setExternalItems([]) + setIntent(null) + } + loadAll() .catch((err) => { + if (seq !== searchSeq.current) return const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败' setError(msg) toast.error(msg) }) - .finally(() => setLoading(false)) + .finally(() => { + if (seq === searchSeq.current) setLoading(false) + }) }, []) // Fast LIKE search-as-you-type when AI mode is OFF. @@ -67,12 +87,14 @@ export function SearchPage() { const onAISubmit = async (e: FormEvent) => { e.preventDefault() if (!q.trim()) return + ++searchSeq.current setLoading(true) setError('') setHasSearched(true) try { const data = await aiAPI.smartSearch(q) setItems(data.items ?? []) + setSearchTotal((data.items ?? []).length) setExternalItems(data.external_items ?? []) setIntent(data.intent) } catch (err) { @@ -166,6 +188,7 @@ export function SearchPage() { <>
本地媒体库 · {localCards.length} 个合集 / {items.length} 个条目 + {loading && searchTotal > items.length ? ` · 正在加载全部结果 ${items.length}/${searchTotal}` : ''}
{localCards.map((card) => (