diff --git a/internal/handler/media.go b/internal/handler/media.go index 8d3cce5..cbed24d 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -32,7 +32,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { return } role, _ := c.Get(middleware.CtxUserRole) - includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") + includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1") if !includeHidden { libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs) visibility := mediaVisibilityForRequest(c, svc) @@ -60,7 +60,7 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc { return } role, _ := c.Get(middleware.CtxUserRole) - includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") + includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1") if !includeHidden { libs := service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, []model.Library{*lib}) if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) { diff --git a/internal/service/danmaku_match_test.go b/internal/service/danmaku_match_test.go index 35065b5..8825ed2 100644 --- a/internal/service/danmaku_match_test.go +++ b/internal/service/danmaku_match_test.go @@ -278,18 +278,69 @@ func TestDanmakuSameBase(t *testing.T) { require.False(t, sameDanmakuBase("", "https://api.dandanplay.net")) } -// fetchCommentWithFallback:配置源与官方同源时不重复请求; -// 全失败时带出最后一跳错误。 -func TestDanmakuFetchCommentWithFallback(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - t.Cleanup(srv.Close) + // fetchCommentWithFallback:配置源与官方同源时不重复请求; + // 全失败时带出最后一跳错误。 + func TestDanmakuFetchCommentWithFallback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) - svc := newDanmakuTestService(t) - ctx := context.Background() - raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484") - require.Error(t, err) - require.Empty(t, raw) - require.Equal(t, "auto", st) -} \ No newline at end of file + svc := newDanmakuTestService(t) + ctx := context.Background() + raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484") + require.Error(t, err) + require.Empty(t, raw) + require.Equal(t, "auto", st) + } + + // 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。 + func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) { + videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv") + + // 官方服务同时提供 match 和 search: + // match 会返回 episodeId=25484(动画A) + // search 会根据关键词返回 episodeId=99999(动画B) + mux := http.NewServeMux() + var matchCalled bool + mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) { + matchCalled = true + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`) + }) + mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`) + }) + mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `自动识别弹幕`) + }) + mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `手动搜索弹幕`) + }) + official := httptest.NewServer(mux) + t.Cleanup(official.Close) + overrideDanmakuOfficialBase(t, official.URL) + + svc := newDanmakuTestService(t) + ctx := context.Background() + seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1) + + // 1) 默认自动识别:命中 Hash 识别 + resAuto, err := svc.Fetch(ctx, "mManual", "", "") + require.NoError(t, err) + require.True(t, matchCalled) + require.Equal(t, "hash", resAuto.MatchMode) + require.Equal(t, int64(25484), resAuto.EpisodeID) + require.Contains(t, resAuto.Raw, "自动识别弹幕") + + // 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B + resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "") + require.NoError(t, err) + require.Equal(t, "search", resManual.MatchMode) + require.Equal(t, int64(99999), resManual.EpisodeID) + require.Equal(t, "手动搜索动画B", resManual.AnimeTitle) + require.Contains(t, resManual.Raw, "手动搜索弹幕") + } \ No newline at end of file diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index 1735bee..a221efd 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -219,8 +219,8 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID target := "" - // 1) hash 识别:始终走官方 /api/v2/match。 - if media != nil && media.Path != "" { + // 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。 + if target == "" && !manualKeyword && media != nil && media.Path != "" { if hash, ok := s.mediaHash(ctx, media); ok { fileSize := media.SizeBytes if strings.EqualFold(filepath.Ext(media.Path), ".strm") { diff --git a/internal/service/visibility.go b/internal/service/visibility.go index 3a9bccf..9fd6384 100644 --- a/internal/service/visibility.go +++ b/internal/service/visibility.go @@ -123,12 +123,20 @@ func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib return false } if repo != nil && repo.DB != nil { - var count int64 + var totalCount int64 _ = repo.DB.WithContext(ctx).Model(&model.Media{}). - Where("library_id = ? AND nsfw = ?", lib.ID, true). - Count(&count).Error - if count > 0 { - return false + Where("library_id = ?", lib.ID). + Count(&totalCount).Error + if totalCount > 0 { + var nsfwCount int64 + _ = repo.DB.WithContext(ctx).Model(&model.Media{}). + Where("library_id = ? AND nsfw = ?", lib.ID, true). + Count(&nsfwCount).Error + // 仅当整库媒体全部为成人内容(纯成人库)时才隐藏整库; + // 含有普通内容的混合媒体库保持库本身可见,具体 NSFW 条目在媒体列表内过滤。 + if nsfwCount == totalCount { + return false + } } } return true diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 1e6f4a1..c56ae6b 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { libraryAPI } from '../api/library' import { toolsAPI } from '../api/tools' +import { openManageLibrariesDialog } from '../components/manageLibrariesDialog' import { LibrariesContent, LibrariesEmptyState, @@ -16,6 +17,32 @@ export function LibrariesPage() { const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false) const [repairMsg, setRepairMsg] = useState('') + 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) + } finally { + setLoading(false) + } + }, []) + async function handleRepairRescrape() { if (repairing) return setRepairing(true) @@ -30,36 +57,14 @@ export function LibrariesPage() { } } + const handleManageLibraries = async () => { + await openManageLibrariesDialog() + await loadLibraries() + } + useEffect(() => { - let cancelled = false - async function load() { - 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 - } - })) - if (!cancelled) setPreviews(rows) - } finally { - if (!cancelled) setLoading(false) - } - } - load() - return () => { cancelled = true } - }, []) + loadLibraries().catch(() => undefined) + }, [loadLibraries]) const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews]) @@ -77,6 +82,7 @@ export function LibrariesPage() { repairing={repairing} onRepairEpisodeArtworkChange={setRepairEpisodeArtwork} onRepairRescrape={handleRepairRescrape} + onManageLibraries={handleManageLibraries} /> {previews.length === 0 ? ( diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index 1547eec..e6929b5 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -6,7 +6,6 @@ import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle import { imageURL } from '../api/client' import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle' import { MediaCard } from '../components/MediaCard' -import { openManageLibrariesDialog } from '../components/manageLibrariesDialog' import { seriesCardLink } from '../utils/groupSeries' import { libraryDisplayPath } from './libraryDisplayModel' import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel' @@ -37,6 +36,7 @@ export function LibrariesHeader({ repairing, onRepairEpisodeArtworkChange, onRepairRescrape, + onManageLibraries, }: { previewCount: number total: number @@ -45,6 +45,7 @@ export function LibrariesHeader({ repairing: boolean onRepairEpisodeArtworkChange: (value: boolean) => void onRepairRescrape: () => void + onManageLibraries: () => void }) { return (
@@ -72,7 +73,7 @@ export function LibrariesHeader({ {repairing ? '正在启动…' : '全库修复+重刮'} -