From 585434010cac8ece0fd9554443c5b152aa59383a Mon Sep 17 00:00:00 2001
From: truewhile <62226914+truewhile@users.noreply.github.com>
Date: Tue, 25 Aug 2026 22:12:05 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
优化
---
internal/handler/media.go | 4 +-
internal/service/danmaku_match_test.go | 79 ++++++++++++++++++++-----
internal/service/danmaku_service.go | 4 +-
internal/service/visibility.go | 18 ++++--
web/src/pages/LibrariesPage.tsx | 66 +++++++++++----------
web/src/pages/LibrariesPageSections.tsx | 5 +-
6 files changed, 121 insertions(+), 55 deletions(-)
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 ? '正在启动…' : '全库修复+重刮'}
-