Compare commits

...

6 Commits

Author SHA1 Message Date
github-actions[bot] 389cb99bcf chore: bump version to 0.0.103 [skip ci] 2026-09-03 14:48:19 +00:00
truewhile 1b611a6181 bug处理 2026-09-03 22:48:02 +08:00
github-actions[bot] 25c03f2b0d chore: bump version to 0.0.102 [skip ci] 2026-09-03 13:18:35 +00:00
truewhile a56b1801f9 bug处理 2026-09-03 21:18:20 +08:00
github-actions[bot] 99c755dc29 chore: bump version to 0.0.101 [skip ci] 2026-09-03 12:51:41 +00:00
truewhile 086c0307c3 bug处理 2026-09-03 20:51:24 +08:00
9 changed files with 131 additions and 39 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.100
0.0.103
+32 -11
View File
@@ -189,19 +189,40 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
}
}
// 如果没有聚合到多集,尝试同父目录匹配
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path != "" && filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) == targetDir {
dirMatches = append(dirMatches, row)
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
parentBase := filepath.Base(targetDir)
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
targetTitleNorm := normalizeSeriesTitle(target.Title)
targetDirNorm := normalizeSeriesTitle(parentBase)
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
continue
}
if row.ID == target.ID {
dirMatches = append(dirMatches, row)
continue
}
rowTitleNorm := normalizeSeriesTitle(row.Title)
allowMatch := false
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
allowMatch = true
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
allowMatch = true
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
allowMatch = true
}
if allowMatch {
dirMatches = append(dirMatches, row)
}
}
if len(dirMatches) > 1 {
out = dirMatches
}
}
}
if len(dirMatches) > 1 {
out = dirMatches
}
}
if len(out) == 0 {
out = []model.Media{*target}
+15 -1
View File
@@ -11,6 +11,16 @@ import (
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
var genericMovieTitleRE = regexp.MustCompile(`(?i)^(?:cd\s*\d+|part\s*\d+|disc\s*\d+|disk\s*\d+|dvd\s*\d+|movie|film|video|main|feature|track\s*\d+|preview|sample|trailer|\d{3,4}p|4k|2160p|1080p|720p)$`)
func isGenericMovieTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return true
}
return genericMovieTitleRE.MatchString(title)
}
func mediaSeriesKey(media model.Media) string {
return compactSeriesKey(mediaSeriesRawKey(media))
}
@@ -54,7 +64,11 @@ func mediaSeriesRawKey(media model.Media) string {
return seriesFingerprint("movie-external", fmt.Sprintf("bgm:%d", media.BangumiID))
}
if fromPath != "" && !mediaParentLooksLikeCollection(media.Path) {
return seriesFingerprint("library-path", media.LibraryID, fromPath)
titleNorm := normalizeSeriesTitle(media.Title)
fromPathNorm := normalizeSeriesTitle(fromPath)
if titleNorm == "" || isGenericMovieTitle(titleNorm) || titleNorm == fromPathNorm {
return seriesFingerprint("library-path", media.LibraryID, fromPath)
}
}
return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title))
}
+64
View File
@@ -445,3 +445,67 @@ func TestGroupMediaSeriesCardsBridgesReleaseFoldersByMatchedSeriesTitle(t *testi
t.Fatalf("cards=%#v, want one series bridged by matched title", cards)
}
}
func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory(t *testing.T) {
// 同一分类子目录下存放多部不同标题的独立电影,不应被强制折叠成 1 部
items := []model.Media{
{LibraryID: "movies", Title: "老师2024偷窥篇", Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`},
{LibraryID: "movies", Title: "紫光灯下的肉体诱惑", Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`},
{LibraryID: "movies", Title: "修洗衣机", Path: `/media/小姐姐/国产/nana/修洗衣机.strm`},
}
cards := groupMediaSeriesCards(items)
if len(cards) != 3 {
t.Fatalf("got %d cards, want 3 independent movie cards", len(cards))
}
// 但同一部电影的 CD1 和 CD2 仍应正确折叠为 1 部
cdItems := []model.Media{
{LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`},
{LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`},
}
cdCards := groupMediaSeriesCards(cdItems)
if len(cdCards) != 1 {
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
}
}
func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Type: "movies", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
m1 := model.Media{
Base: model.Base{ID: "m1"},
LibraryID: lib.ID,
Title: "老师2024偷窥篇",
Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`,
}
m2 := model.Media{
Base: model.Base{ID: "m2"},
LibraryID: lib.ID,
Title: "紫光灯下的肉体诱惑",
Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`,
}
m3 := model.Media{
Base: model.Base{ID: "m3"},
LibraryID: lib.ID,
Title: "修洗衣机",
Path: `/media/小姐姐/国产/nana/修洗衣机.strm`,
}
if err := repos.DB.Create(&[]model.Media{m1, m2, m3}).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
eps, err := svc.ListMediaEpisodes(t.Context(), "m1", MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(eps) != 1 || eps[0].ID != "m1" {
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
}
}
+5 -4
View File
@@ -6,6 +6,7 @@ import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-rea
import { imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import type { Media, PlayProfile, User } from '../types'
import { favouriteMediaLink } from '../utils/mediaNavigation'
import { resolveHeaderBack } from './layoutNavigation'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
@@ -149,17 +150,17 @@ function LayoutHeaderSearch() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelect = (mediaId: string) => {
const handleSelect = (item: Media) => {
setIsOpen(false)
setQuery('')
navigate(`/media/${mediaId}`)
navigate(favouriteMediaLink(item))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false)
} else if (e.key === 'Enter' && results.length > 0) {
handleSelect(results[0].id)
handleSelect(results[0])
}
}
@@ -220,7 +221,7 @@ function LayoutHeaderSearch() {
{results.map((item) => (
<button
key={item.id}
onClick={() => handleSelect(item.id)}
onClick={() => handleSelect(item)}
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
>
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
+6 -10
View File
@@ -80,11 +80,11 @@ export function HomePage() {
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
// 按需拉取卡片预览管理
const [fetchedLibIds, setFetchedLibIds] = useState<Set<string>>(new Set())
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
const fetchingRef = useRef<Set<string>>(new Set())
const fetchPreviews = useCallback(async (ids: string[]) => {
const targets = ids.filter((id) => !fetchedLibIds.has(id) && !fetchingRef.current.has(id))
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
if (targets.length === 0) return
targets.forEach((id) => fetchingRef.current.add(id))
@@ -104,16 +104,12 @@ export function HomePage() {
} catch {
// 容错
} finally {
setFetchedLibIds((prev) => {
const next = new Set(prev)
targets.forEach((id) => {
next.add(id)
fetchingRef.current.delete(id)
})
return next
targets.forEach((id) => {
fetchedLibIdsRef.current.add(id)
fetchingRef.current.delete(id)
})
}
}, [fetchedLibIds])
}, [])
// 3. 首屏优先加载:轮播图库 + 媒体库卡片区前 20 个库 + 首屏前 3 个内容行
useEffect(() => {
+6 -10
View File
@@ -23,11 +23,11 @@ export function LibrariesPage() {
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
const [repairMsg, setRepairMsg] = useState('')
const [fetchedLibIds, setFetchedLibIds] = useState<Set<string>>(new Set())
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
const fetchingRef = useRef<Set<string>>(new Set())
const fetchPreviews = useCallback(async (ids: string[]) => {
const targets = ids.filter((id) => !fetchedLibIds.has(id) && !fetchingRef.current.has(id))
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
if (targets.length === 0) return
targets.forEach((id) => fetchingRef.current.add(id))
@@ -46,16 +46,12 @@ export function LibrariesPage() {
} catch {
// 容错
} finally {
setFetchedLibIds((prev) => {
const next = new Set(prev)
targets.forEach((id) => {
next.add(id)
fetchingRef.current.delete(id)
})
return next
targets.forEach((id) => {
fetchedLibIdsRef.current.add(id)
fetchingRef.current.delete(id)
})
}
}, [fetchedLibIds])
}, [])
const loadLibraries = useCallback(async () => {
setLoading(true)
+1 -1
View File
@@ -32,7 +32,7 @@ export function MediaDetailPage() {
media={media}
isAdmin={role === 'admin'}
favourite={detail.favourite}
playTargetId={detail.firstPlayableEpisode?.id}
playTargetId={media.id || detail.firstPlayableEpisode?.id}
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
onToggleFavourite={detail.toggleFavourite}
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
+1 -1
View File
@@ -96,7 +96,7 @@ export function MediaDetailPlaybackActions({
onToggleFavourite,
playTargetId,
}: MediaDetailPlaybackActionsProps) {
const targetId = playTargetId || media.id
const targetId = media.id || playTargetId || ''
return (
<div className="flex flex-wrap gap-3">
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">