From 7556af33f1983b33200a729d7496fa5e54d98d17 Mon Sep 17 00:00:00 2001 From: klmmlk Date: Tue, 8 Sep 2026 22:40:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=89=E5=8D=93?= =?UTF-8?q?=E7=AB=AF=E5=89=A7=E9=9B=86=E8=AF=A6=E6=83=85=E9=A1=B5=20404=20?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=9A=E5=8C=BA=E5=88=86=20series/media=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nowen/video/v2/feature/main/HomeScreen.kt | 12 ++++++------ .../nowen/video/v2/feature/main/LibraryScreen.kt | 16 ++++++++-------- .../com/nowen/video/v2/feature/main/MainShell.kt | 10 ++++++++-- .../video/v2/feature/main/MediaDetailScreen.kt | 4 ++-- .../video/v2/feature/main/PagedSocialScreens.kt | 8 ++++---- .../video/v2/feature/main/SecondaryScreens.kt | 4 ++-- .../v2/feature/main/SocialCatalogScreens.kt | 16 ++++++++-------- .../video/v2/feature/main/WebMobileHomeScreen.kt | 8 ++++---- 8 files changed, 42 insertions(+), 36 deletions(-) diff --git a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/HomeScreen.kt b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/HomeScreen.kt index e412a83c..48954b43 100644 --- a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/HomeScreen.kt +++ b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/HomeScreen.kt @@ -140,7 +140,7 @@ class HomeViewModel @Inject constructor( @Composable fun HomeScreen( modifier: Modifier = Modifier, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, onPlay: (String) -> Unit, onRestart: (String) -> Unit = onPlay, onLibraryClick: () -> Unit, @@ -243,7 +243,7 @@ fun HomeScreen( LandscapeMediaCard( media = media, imageUrl = resolveHomeArtwork(baseUrl, media, preferBackdrop = true), - onClick = { onMediaClick(media.resolvedId) }, + onClick = { onMediaClick(media.resolvedId, media.isSeries) }, ) } } @@ -287,7 +287,7 @@ fun HomeScreen( private fun HomeHeroCarousel( items: List, baseUrl: String?, - onOpen: (String) -> Unit, + onOpen: (String, Boolean) -> Unit, onPlay: (String) -> Unit, onRestart: (String) -> Unit, onFavoritesClick: () -> Unit, @@ -484,7 +484,7 @@ private fun HomeHeroCarousel( } Surface( - onClick = { onOpen(item.resolvedId) }, + onClick = { onOpen(item.resolvedId, item.isSeries) }, modifier = Modifier .align(Alignment.TopEnd) .padding(12.dp), @@ -677,7 +677,7 @@ private fun PosterShelf( items: List, baseUrl: String?, onMore: () -> Unit, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, ) { Column { HomeSectionHeader(title = title, action = "更多", onAction = onMore) @@ -687,7 +687,7 @@ private fun PosterShelf( PosterMediaCard( media = media, imageUrl = resolveHomeArtwork(baseUrl, media, preferBackdrop = false), - onClick = { onMediaClick(media.resolvedId) }, + onClick = { onMediaClick(media.resolvedId, media.isSeries) }, ) } } diff --git a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/LibraryScreen.kt b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/LibraryScreen.kt index e41b004b..55ec4068 100644 --- a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/LibraryScreen.kt +++ b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/LibraryScreen.kt @@ -167,7 +167,7 @@ class LibraryViewModel @Inject constructor( fun resetFilter() = applyFilter(LibraryFilter()) - fun selectMedia(id: String) { + fun selectMedia(id: String, isSeries: Boolean = false) { if (id.isBlank()) return if (_state.value.selectedMediaId == id && _state.value.selectedDetail != null) return selectionJob?.cancel() @@ -204,7 +204,7 @@ class LibraryViewModel @Inject constructor( @Composable fun LibraryScreen( modifier: Modifier = Modifier, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, onPlay: (String) -> Unit, viewModel: LibraryViewModel = hiltViewModel(), ) { @@ -295,7 +295,7 @@ private fun LibraryCatalogPane( onFilterClick: () -> Unit, onRefresh: () -> Unit, onFilterChange: (LibraryFilter) -> Unit, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -364,7 +364,7 @@ private fun LibraryGrid( media: LazyPagingItems, state: LibraryUiState, baseUrl: String?, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, ) { LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 132.dp), @@ -399,7 +399,7 @@ private fun LibraryGrid( subtitle = item.year?.toString(), imageUrl = resolveImage(baseUrl, item.resolvedPoster), progress = item.normalizedProgress, - onClick = { onMediaClick(item.resolvedId) }, + onClick = { onMediaClick(item.resolvedId, item.isSeries) }, modifier = Modifier.padding(3.dp), ) } @@ -534,7 +534,7 @@ private fun LibraryDetailPane( state: LibraryUiState, baseUrl: String?, onRetry: (String) -> Unit, - onOpenDetail: (String) -> Unit, + onOpenDetail: (String, Boolean) -> Unit, onPlay: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -574,7 +574,7 @@ private fun LibraryDetailPane( private fun LibraryDetailContent( media: MediaDetail, baseUrl: String?, - onOpenDetail: (String) -> Unit, + onOpenDetail: (String, Boolean) -> Unit, onPlay: (String) -> Unit, ) { Column( @@ -637,7 +637,7 @@ private fun LibraryDetailContent( Text("立即播放") } OutlinedButton( - onClick = { onOpenDetail(media.id) }, + onClick = { onOpenDetail(media.id, media.mediaType == "series") }, modifier = Modifier .fillMaxWidth() .padding(top = 10.dp), diff --git a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/MainShell.kt b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/MainShell.kt index 1ca45fe2..32d7889f 100644 --- a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/MainShell.kt +++ b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/MainShell.kt @@ -153,8 +153,14 @@ fun MainShell(viewModel: MainShellViewModel = hiltViewModel()) { } } - fun openDetail(mediaId: String) { - if (mediaId.isNotBlank()) navController.navigate("detail/${Uri.encode(mediaId)}") + fun openDetail(mediaId: String, isSeries: Boolean = false) { + if (mediaId.isNotBlank()) { + if (isSeries) { + navController.navigate("series/${Uri.encode(mediaId)}") + } else { + navController.navigate("detail/${Uri.encode(mediaId)}") + } + } } fun openSeries(seriesId: String) { diff --git a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/MediaDetailScreen.kt b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/MediaDetailScreen.kt index 4355b127..44dc1d66 100644 --- a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/MediaDetailScreen.kt +++ b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/MediaDetailScreen.kt @@ -374,7 +374,7 @@ fun MediaDetailScreen( onHighlightPlay: (String, Double) -> Unit = { id, _ -> onPlay(id) }, onPersonClick: (String) -> Unit, onCollectionClick: (String) -> Unit, - onMediaClick: (String) -> Unit = onPlay, + onMediaClick: (String, Boolean) -> Unit = { id, _ -> onPlay(id) }, viewModel: MediaDetailViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() @@ -528,7 +528,7 @@ fun MediaDetailScreen( SimilarPosterCard( media = item, imageUrl = resolveImage(baseUrl, item.resolvedPoster), - onClick = { onMediaClick(item.resolvedId) }, + onClick = { onMediaClick(item.resolvedId, item.isSeries) }, ) } } diff --git a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/PagedSocialScreens.kt b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/PagedSocialScreens.kt index e4ce3fa8..ca59bcc0 100644 --- a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/PagedSocialScreens.kt +++ b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/PagedSocialScreens.kt @@ -146,7 +146,7 @@ data class PagedSocialAction( @Composable fun PagedFavoritesScreen( onBack: () -> Unit, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, viewModel: PagedFavoritesViewModel = hiltViewModel(), ) { val favorites = viewModel.favorites.collectAsLazyPagingItems() @@ -215,7 +215,7 @@ fun PagedFavoritesScreen( media = media, imageUrl = resolveImage(session.activeServer?.baseUrl, media.resolvedPoster), removing = action.runningId == mediaId, - onClick = { onMediaClick(mediaId) }, + onClick = { onMediaClick(mediaId, media.isSeries) }, onRemove = { viewModel.remove(mediaId, favorites::refresh) }, ) } @@ -236,7 +236,7 @@ fun PagedFavoritesScreen( @Composable fun PagedHistoryScreen( onBack: () -> Unit, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, onPlay: (String) -> Unit, viewModel: PagedHistoryViewModel = hiltViewModel(), ) { @@ -314,7 +314,7 @@ fun PagedHistoryScreen( imageUrl = mediaBackdropUrl(session.activeServer?.baseUrl, mediaId), deleting = action.runningId == mediaId, onPlay = { onPlay(mediaId) }, - onDetail = { onMediaClick(mediaId) }, + onDetail = { onMediaClick(mediaId, media.isSeries) }, onDelete = { viewModel.delete(mediaId, historyItems::refresh) }, ) } diff --git a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/SecondaryScreens.kt b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/SecondaryScreens.kt index bbdb0f6b..687e73ed 100644 --- a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/SecondaryScreens.kt +++ b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/SecondaryScreens.kt @@ -134,7 +134,7 @@ class SearchViewModel @Inject constructor( @Composable fun SearchScreen( modifier: Modifier = Modifier, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, onPersonClick: (String) -> Unit, onCollectionClick: (String) -> Unit, viewModel: SearchViewModel = hiltViewModel(), @@ -183,7 +183,7 @@ fun SearchScreen( subtitle = media.year?.toString(), imageUrl = resolveImage(session.activeServer?.baseUrl, media.resolvedPoster), progress = media.normalizedProgress, - onClick = { onMediaClick(media.resolvedId) }, + onClick = { onMediaClick(media.resolvedId, media.isSeries) }, ) } } diff --git a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/SocialCatalogScreens.kt b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/SocialCatalogScreens.kt index b3c39eee..13e22875 100644 --- a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/SocialCatalogScreens.kt +++ b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/SocialCatalogScreens.kt @@ -104,7 +104,7 @@ class FavoritesViewModel @Inject constructor( @Composable fun FavoritesScreen( onBack: () -> Unit, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, viewModel: FavoritesViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() @@ -124,7 +124,7 @@ fun FavoritesScreen( media = media, imageUrl = resolveImage(session.activeServer?.baseUrl, media.resolvedPoster), subtitle = listOfNotNull(media.year?.toString(), "已收藏").joinToString(" · "), - onClick = { onMediaClick(media.resolvedId.ifBlank { favorite.mediaId }) }, + onClick = { onMediaClick(media.resolvedId.ifBlank { favorite.mediaId }, media.isSeries) }, action = { IconButton( onClick = { viewModel.remove(favorite.mediaId.ifBlank { media.resolvedId }) }, @@ -199,7 +199,7 @@ class HistoryViewModel @Inject constructor( @Composable fun HistoryScreen( onBack: () -> Unit, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, viewModel: HistoryViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() @@ -229,7 +229,7 @@ fun HistoryScreen( imageUrl = resolveImage(session.activeServer?.baseUrl, media.resolvedPoster), subtitle = history.progressLabel, progress = history.normalizedProgress, - onClick = { onMediaClick(media.resolvedId.ifBlank { history.mediaId }) }, + onClick = { onMediaClick(media.resolvedId.ifBlank { history.mediaId }, media.isSeries) }, action = { IconButton( onClick = { viewModel.delete(history.mediaId) }, @@ -381,7 +381,7 @@ class CollectionDetailViewModel @Inject constructor( fun CollectionDetailScreen( collectionId: String, onBack: () -> Unit, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, viewModel: CollectionDetailViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() @@ -449,7 +449,7 @@ fun CollectionDetailScreen( media.rating.takeIf { it > 0 }?.let { "★ %.1f".format(it) }, media.resolution.takeIf(String::isNotBlank), ).joinToString(" · "), - onClick = { onMediaClick(media.id) }, + onClick = { onMediaClick(media.id, false) }, ) } } @@ -497,7 +497,7 @@ class PersonDetailViewModel @Inject constructor( fun PersonDetailScreen( personId: String, onBack: () -> Unit, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, viewModel: PersonDetailViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() @@ -553,7 +553,7 @@ fun PersonDetailScreen( media = media, imageUrl = resolveImage(session.activeServer?.baseUrl, media.resolvedPoster), subtitle = media.year?.toString().orEmpty(), - onClick = { onMediaClick(media.resolvedId) }, + onClick = { onMediaClick(media.resolvedId, media.isSeries) }, ) } } diff --git a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/WebMobileHomeScreen.kt b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/WebMobileHomeScreen.kt index c85becfe..bdc4da2c 100644 --- a/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/WebMobileHomeScreen.kt +++ b/android/feature/main/src/main/java/com/nowen/video/v2/feature/main/WebMobileHomeScreen.kt @@ -81,7 +81,7 @@ private val WEB_MOBILE_HOME_GENRES = listOf("动画", "喜剧", "冒险", "家 @Composable fun WebMobileHomeScreen( modifier: Modifier = Modifier, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, onPlay: (String) -> Unit, onRestart: (String) -> Unit = onPlay, onLibraryClick: () -> Unit, @@ -172,7 +172,7 @@ fun WebMobileHomeScreen( media = media, imageUrl = webMobileArtwork(baseUrl, media, preferBackdrop = true), width = cardWidth, - onClick = { onMediaClick(media.resolvedId) }, + onClick = { onMediaClick(media.resolvedId, media.isSeries) }, ) } } @@ -553,7 +553,7 @@ private fun WebMobilePosterShelf( items: List, baseUrl: String?, onMore: () -> Unit, - onMediaClick: (String) -> Unit, + onMediaClick: (String, Boolean) -> Unit, ) { WebMobileSectionSurface { WebMobileSectionHeader(title = title, action = "更多", onAction = onMore) @@ -566,7 +566,7 @@ private fun WebMobilePosterShelf( media = media, imageUrl = webMobileArtwork(baseUrl, media, preferBackdrop = false), width = cardWidth, - onClick = { onMediaClick(media.resolvedId) }, + onClick = { onMediaClick(media.resolvedId, media.isSeries) }, ) } } From a978aa047a9e79999f166b88931eb43d049b6466 Mon Sep 17 00:00:00 2001 From: klmmlk Date: Wed, 9 Sep 2026 23:54:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E5=99=A8=E8=AF=AF=E5=AF=BC=E5=85=A5=20NAS=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=9B=BE=E4=BA=A7=E7=94=9F=20mjpeg=20?= =?UTF-8?q?=E5=B9=BD=E7=81=B5=E5=89=A7=E9=9B=86=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:NAS(飞牛 fnOS 等)会在媒体目录内生成缩略图缓存目录(如 .@__thumb), 其中的缩略图内容是 JPEG 但文件名保留了原视频的 .mp4 扩展名。扫描器遍历 时不跳过隐藏目录、仅按扩展名过滤,导致这些缓存被当作剧集入库 —— ffprobe 探出 codec=mjpeg、按图片尺寸归一化为 100p/400p/720p,并被刮削误配出 完全无关的幽灵剧集。 修复: - 所有遍历路径(电影库 walkFn、collectEpisodes、混合库、剧集库目录收集、 isTVShowFolder、looksLikeSeriesFolder、collectMediaRoots)跳过隐藏目录 与 NAS 缓存目录,整棵子树 SkipDir(对齐 Jellyfin/Emby 标准行为) - 7 个入库点统一加静态图防御:ffprobe 视频流为 mjpeg/png 等图片编码且 时长近零(单帧特征)才拦截,不误杀正常时长的 Motion-JPEG 视频; 探测失败(codec 为空)不拦截,避免 ffprobe 不可用时清空媒体库 - 文件监听(fsnotify)忽略隐藏/缓存目录内的事件 - 补充回归测试覆盖 fnOS 缩略图场景、深层嵌套隐藏目录、Motion-JPEG 不误杀 - server-ci PR 路径过滤补齐 scanner.go/scanner_test.go/file_watcher.go, 避免相关修复的 PR 跳过 Go 测试与 Docker 构建冒烟 --- .github/workflows/server-ci.yml | 3 + internal/service/file_watcher.go | 5 ++ internal/service/scanner.go | 148 +++++++++++++++++++++++++++++-- internal/service/scanner_test.go | 100 +++++++++++++++++++++ 4 files changed, 248 insertions(+), 8 deletions(-) diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 4321456e..5ce7205b 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -22,6 +22,9 @@ on: - 'internal/service/stream_artifacts.go' - 'internal/service/ondemand.go' - 'internal/service/media_analysis_worker*.go' + - 'internal/service/scanner.go' + - 'internal/service/scanner_test.go' + - 'internal/service/file_watcher.go' - 'internal/handler/lite.go' - 'internal/handler/handler.go' - 'internal/handler/stream_artifacts.go' diff --git a/internal/service/file_watcher.go b/internal/service/file_watcher.go index 32d79ea5..f79c158e 100644 --- a/internal/service/file_watcher.go +++ b/internal/service/file_watcher.go @@ -267,6 +267,11 @@ func (fw *FileWatcherService) handleEvent(event fsnotify.Event) { // 判断是否为视频文件或目录 ext := strings.ToLower(filepath.Ext(event.Name)) isVideo := supportedExts[ext] + // 隐藏目录与 NAS 缓存目录(fnOS .@__thumb、群晖 @eaDir 等)内的文件 + // 是缩略图/预览缓存,不参与媒体导入 + if isVideo && pathHasHiddenOrCacheSegment(event.Name) { + return + } isDir := false if info, err := os.Stat(event.Name); err == nil { isDir = info.IsDir() diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 8f89bca1..7c6794e4 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -37,6 +37,65 @@ var supportedExts = map[string]bool{ ".strm": true, // STRM 远程流文件 } +// isHiddenOrCacheDirName 判断目录/文件名是否为隐藏项或 NAS 系统生成的缓存目录。 +// +// NAS 系统(飞牛 fnOS、群晖、威联通等)会在媒体目录内生成缩略图/预览缓存目录, +// 例如 fnOS 的 .@__thumb(其中文件名保留了原视频的 .mp4 扩展名,但内容是 JPEG 图片)、 +// 群晖的 @eaDir、#recycle 等。这些缓存绝不能当作正片入库,否则会产生大量 +// "mjpeg/100p" 之类的幽灵剧集并被刮削误配元数据。 +// 对齐 Jellyfin/Emby 的标准行为:隐藏目录一律跳过。 +func isHiddenOrCacheDirName(name string) bool { + n := strings.TrimSpace(name) + if n == "" { + return false + } + // "." 开头:.@__thumb / .Trashes / .Spotlight-V100 / .fseventsd 等; + // "@" 开头:群晖 @eaDir 等元数据目录; + // "#" 开头:群晖 #recycle / #snapshot 等回收站与快照目录 + if strings.HasPrefix(n, ".") || strings.HasPrefix(n, "@") || strings.HasPrefix(n, "#") { + return true + } + return nasCacheDirNames[strings.ToLower(n)] +} + +// nasCacheDirNames 常见 NAS/系统的缓存与元数据目录名(小写,不含前缀规则的兜底) +var nasCacheDirNames = map[string]bool{ + "@eadir": true, // 群晖缩略图/元数据 + "$recycle.bin": true, // Windows 回收站 + "system volume information": true, // Windows 卷信息 + "lost+found": true, // Linux 文件系统 +} + +// pathHasHiddenOrCacheSegment 判断路径中任一路径段命中隐藏/缓存目录。 +// 用于文件监听(fsnotify)等拿不到遍历 SkipDir 控制权的场景。 +func pathHasHiddenOrCacheSegment(path string) bool { + for _, seg := range strings.FieldsFunc(filepath.ToSlash(path), func(r rune) bool { return r == '/' || r == '\\' }) { + if isHiddenOrCacheDirName(seg) { + return true + } + } + return false +} + +// stillImageVideoCodecs ffprobe 报告的"静态图片"编解码器集合。 +// JPEG/PNG 等图片(含 NAS 缩略图缓存改名为 .mp4 的文件)被 ffprobe 探测时就是这些 codec。 +var stillImageVideoCodecs = map[string]bool{ + "mjpeg": true, "mjpegb": true, "jpeg": true, "jpg": true, + "png": true, "bmp": true, "webp": true, "tiff": true, + "gif": true, "avif": true, "heif": true, +} + +// isStillImageMedia 判断探测结果是否为静态图片而非真实视频。 +// 仅当视频流 codec 命中图片编码且时长近乎为零(单帧图片的特征)时判定为图片, +// 不会误杀正常时长的 Motion-JPEG 视频文件。 +// 注意:探测失败(codec 为空,如 ffprobe 不可用)时返回 false,避免误清整个媒体库。 +func isStillImageMedia(media *model.Media) bool { + if media == nil || media.VideoCodec == "" { + return false + } + return stillImageVideoCodecs[strings.ToLower(media.VideoCodec)] && media.Duration <= 0.1 +} + // extrasExcludeDirs Emby/Kodi 标准的非正片内容目录名(小写) var extrasExcludeDirs = map[string]bool{ "extras": true, @@ -187,6 +246,9 @@ func (s *ScannerService) looksLikeSeriesFolder(path string) bool { var hasEpisodicVideo bool for _, e := range entries { name := e.Name() + if isHiddenOrCacheDirName(name) { + continue + } if !e.IsDir() { lower := strings.ToLower(name) if lower == "tvshow.nfo" { @@ -502,7 +564,7 @@ func (s *ScannerService) collectMediaRoots(root string, kind string) []string { seen[path] = true base := filepath.Base(path) - if isXiaoyaSkipDir(base) || extrasExcludeDirs[strings.ToLower(base)] { + if isHiddenOrCacheDirName(base) || isXiaoyaSkipDir(base) || extrasExcludeDirs[strings.ToLower(base)] { s.logger.Debugf("[xiaoya] 跳过特殊目录: %s", path) return } @@ -574,7 +636,7 @@ func (s *ScannerService) collectMediaRoots(root string, kind string) []string { } for i := 0; i < sampleN; i++ { sd := subDirs[i] - if isXiaoyaSkipDir(sd.Name()) || extrasExcludeDirs[strings.ToLower(sd.Name())] { + if isHiddenOrCacheDirName(sd.Name()) || isXiaoyaSkipDir(sd.Name()) || extrasExcludeDirs[strings.ToLower(sd.Name())] { continue } childPath := vfsJoin(path, sd.Name()) @@ -603,7 +665,7 @@ func (s *ScannerService) collectMediaRoots(root string, kind string) []string { // 穿透:把每个子目录递归展开 expandedCount := 0 for _, sd := range subDirs { - if isXiaoyaSkipDir(sd.Name()) || extrasExcludeDirs[strings.ToLower(sd.Name())] { + if isHiddenOrCacheDirName(sd.Name()) || isXiaoyaSkipDir(sd.Name()) || extrasExcludeDirs[strings.ToLower(sd.Name())] { continue } // [tvshow 扫描] 子目录层级过滤非剧集分类(综艺/演唱会/音乐/MV/每日更新) @@ -764,6 +826,11 @@ func (s *ScannerService) scanMovieLibrary(library *model.Library) (int, error) { return nil } if info.IsDir() { + // 跳过隐藏目录与 NAS 缓存目录(如 fnOS .@__thumb、群晖 @eaDir), + // 其中的"视频文件"实际是缩略图/预览缓存 + if isHiddenOrCacheDirName(filepath.Base(path)) { + return filepath.SkipDir + } // 跳过 extras/trailers 等非正片目录(P0: 兼容 Emby 标准) if extrasExcludeDirs[strings.ToLower(filepath.Base(path))] { return filepath.SkipDir @@ -1042,6 +1109,12 @@ func (s *ScannerService) scanMovieLibrary(library *model.Library) (int, error) { } } + // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库 + if isStillImageMedia(pm.media) { + s.logger.Warnf("跳过静态图片文件(疑似缩略图缓存): %s [%s]", pm.media.FilePath, pm.media.VideoCodec) + continue + } + if err := s.mediaRepo.Create(pm.media); err != nil { s.logger.Warnf("保存媒体失败: %s, 错误: %v", pm.path, err) continue @@ -1266,7 +1339,7 @@ func (s *ScannerService) scanMixedLibrary(library *model.Library) (int, error) { if e.IsDir() { if isSeasonOnlyDirName(e.Name()) { seasonChildCount++ - } else if !isXiaoyaSkipDir(e.Name()) && !extrasExcludeDirs[strings.ToLower(e.Name())] { + } else if !isHiddenOrCacheDirName(e.Name()) && !isXiaoyaSkipDir(e.Name()) && !extrasExcludeDirs[strings.ToLower(e.Name())] { nonSeasonChildCount++ } } @@ -1308,7 +1381,8 @@ func (s *ScannerService) scanMixedLibrary(library *model.Library) (int, error) { dirName := entry.Name() // [xiaoya 适配] 跳过特殊目录(ISO/json/画质演示/extras 等) - if isXiaoyaSkipDir(dirName) || extrasExcludeDirs[strings.ToLower(dirName)] { + // 同时跳过隐藏目录与 NAS 缓存目录(fnOS .@__thumb、群晖 @eaDir 等) + if isHiddenOrCacheDirName(dirName) || isXiaoyaSkipDir(dirName) || extrasExcludeDirs[strings.ToLower(dirName)] { s.logger.Debugf("[xiaoya] 混合库扫描跳过特殊目录: %s", dirName) continue } @@ -1369,7 +1443,13 @@ func (s *ScannerService) scanMixedLibrary(library *model.Library) (int, error) { for _, entry := range movieDirs { folderPath := vfsJoin(entry.rootPath, entry.entry.Name()) err := s.walkLibraryPath(folderPath, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { + if err != nil { + return nil + } + if info.IsDir() { + if isHiddenOrCacheDirName(filepath.Base(path)) { + return filepath.SkipDir + } return nil } ext := strings.ToLower(filepath.Ext(path)) @@ -1393,6 +1473,11 @@ func (s *ScannerService) scanMixedLibrary(library *model.Library) (int, error) { } s.probeMediaInfo(media) s.scanExternalSubtitles(media) + // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库 + if isStillImageMedia(media) { + s.logger.Warnf("跳过静态图片文件(疑似缩略图缓存): %s [%s]", media.FilePath, media.VideoCodec) + return nil + } if err := s.mediaRepo.Create(media); err != nil { s.logger.Warnf("保存媒体失败: %s, 错误: %v", path, err) return nil @@ -1437,6 +1522,11 @@ func (s *ScannerService) scanMixedLibrary(library *model.Library) (int, error) { } s.probeMediaInfo(media) s.scanExternalSubtitles(media) + // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库 + if isStillImageMedia(media) { + s.logger.Warnf("跳过静态图片文件(疑似缩略图缓存): %s [%s]", media.FilePath, media.VideoCodec) + continue + } if err := s.mediaRepo.Create(media); err != nil { s.logger.Warnf("保存媒体失败: %s, 错误: %v", filePath, err) continue @@ -1502,6 +1592,9 @@ func (s *ScannerService) isTVShowFolder(folderPath string) bool { // 规则2: 包含 Season 子目录 var videoFiles []string for _, entry := range entries { + if isHiddenOrCacheDirName(entry.Name()) { + continue + } if entry.IsDir() { for _, pattern := range seasonDirPatterns { if pattern.MatchString(entry.Name()) { @@ -1678,6 +1771,9 @@ func (s *ScannerService) scanTVShowLibrary(library *model.Library) (int, error) s.logger.Infof("剧集根 %s 包含 %d 个条目", root, len(entries)) for _, entry := range entries { + if isHiddenOrCacheDirName(entry.Name()) { + continue + } if !entry.IsDir() { // 根目录下的视频文件 ext := strings.ToLower(filepath.Ext(entry.Name())) @@ -1706,7 +1802,9 @@ func (s *ScannerService) scanTVShowLibrary(library *model.Library) (int, error) dirName := entry.Name() // [xiaoya 适配] 跳过特殊目录(ISO/json/画质演示/extras 等) - if isXiaoyaSkipDir(dirName) || extrasExcludeDirs[strings.ToLower(dirName)] { + // 同时跳过隐藏目录与 NAS 缓存目录(fnOS .@__thumb、群晖 @eaDir 等), + // 否则缓存目录会被当作剧集目录,缩略图伪 .mp4 会以剧集身份入库 + if isHiddenOrCacheDirName(dirName) || isXiaoyaSkipDir(dirName) || extrasExcludeDirs[strings.ToLower(dirName)] { s.logger.Debugf("[xiaoya] 剧集扫描跳过特殊目录: %s", dirName) continue } @@ -1817,6 +1915,11 @@ func (s *ScannerService) scanTVShowLibrary(library *model.Library) (int, error) media.SeasonNum = ep.SeasonNum media.EpisodeNum = ep.EpisodeNum media.EpisodeTitle = ep.EpisodeTitle + // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库 + if isStillImageMedia(media) { + s.logger.Warnf("跳过静态图片文件(疑似缩略图缓存): %s [%s]", media.FilePath, media.VideoCodec) + continue + } if err := s.mediaRepo.Create(media); err != nil { s.logger.Warnf("保存媒体失败: %s, 错误: %v", filePath, err) } @@ -1870,6 +1973,12 @@ func (s *ScannerService) scanTVShowLibrary(library *model.Library) (int, error) s.probeMediaInfo(media) s.scanExternalSubtitles(media) + // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库 + if isStillImageMedia(media) { + s.logger.Warnf("跳过静态图片文件(疑似缩略图缓存): %s [%s]", media.FilePath, media.VideoCodec) + continue + } + if err := s.mediaRepo.Create(media); err != nil { s.logger.Warnf("保存剧集失败: %s, 错误: %v", filePath, err) continue @@ -2213,6 +2322,12 @@ func (s *ScannerService) scanMultiSeasonSeries(library *model.Library, seriesTit s.probeMediaInfo(media) s.scanExternalSubtitles(media) + // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库 + if isStillImageMedia(media) { + s.logger.Warnf("跳过静态图片文件(疑似缩略图缓存): %s [%s]", media.FilePath, media.VideoCodec) + continue + } + if err := s.mediaRepo.Create(media); err != nil { s.logger.Warnf("保存剧集失败: %s, 错误: %v", ep.FilePath, err) continue @@ -2358,6 +2473,12 @@ func (s *ScannerService) scanSeriesFolder(library *model.Library, folderPath, se s.probeMediaInfo(media) s.scanExternalSubtitles(media) + // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库 + if isStillImageMedia(media) { + s.logger.Warnf("跳过静态图片文件(疑似缩略图缓存): %s [%s]", media.FilePath, media.VideoCodec) + continue + } + if err := s.mediaRepo.Create(media); err != nil { s.logger.Warnf("保存剧集失败: %s, 错误: %v", ep.FilePath, err) continue @@ -2393,7 +2514,18 @@ func (s *ScannerService) collectEpisodes(folderPath string) []EpisodeInfo { var episodes []EpisodeInfo s.walkLibraryPath(folderPath, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { + if err != nil { + return nil + } + if info.IsDir() { + // 跳过隐藏目录与 NAS 缓存目录(fnOS .@__thumb、群晖 @eaDir 等): + // 缓存缩略图保留原视频的 .mp4 扩展名但内容是 JPEG,入库后即"mjpeg 幽灵剧集" + if isHiddenOrCacheDirName(filepath.Base(path)) { + return filepath.SkipDir + } + return nil + } + if isHiddenOrCacheDirName(filepath.Base(path)) { return nil } ext := strings.ToLower(filepath.Ext(path)) diff --git a/internal/service/scanner_test.go b/internal/service/scanner_test.go index 49bb0702..9ef41559 100644 --- a/internal/service/scanner_test.go +++ b/internal/service/scanner_test.go @@ -2,7 +2,11 @@ package service import ( "fmt" + "os" + "path/filepath" "testing" + + "github.com/nowen-video/nowen-video/internal/model" ) func TestParseEpisodeInfo(t *testing.T) { @@ -512,3 +516,99 @@ func TestSeasonZeroSupport(t *testing.T) { }) } } + +// TestIsHiddenOrCacheDirName 隐藏目录与 NAS 缓存目录名判断 +func TestIsHiddenOrCacheDirName(t *testing.T) { + cases := map[string]bool{ + ".@__thumb": true, // 飞牛 fnOS 缩略图缓存 + "@eaDir": true, // 群晖缩略图/元数据 + "#recycle": true, // 群晖回收站 + ".Trashes": true, // macOS + ".Spotlight-V100": true, // macOS 索引 + "$RECYCLE.BIN": true, // Windows 回收站 + "System Volume Information": true, + "Season 01": false, + "布鲁伊第一季": false, + "extras": false, // 由 extrasExcludeDirs 单独处理 + "": false, + } + for name, want := range cases { + if got := isHiddenOrCacheDirName(name); got != want { + t.Errorf("isHiddenOrCacheDirName(%q) = %v, want %v", name, got, want) + } + } +} + +// TestPathHasHiddenOrCacheSegment 路径中任一段命中隐藏/缓存目录 +func TestPathHasHiddenOrCacheSegment(t *testing.T) { + if !pathHasHiddenOrCacheSegment(`/media/剧/.@__thumb/s800《剧》 第1集.mp4`) { + t.Error("包含 .@__thumb 段的路径应命中") + } + if !pathHasHiddenOrCacheSegment(`/media/@eaDir/剧.mp4`) { + t.Error("包含 @eaDir 段的路径应命中") + } + if pathHasHiddenOrCacheSegment(`/media/布鲁伊第一季/《剧》 第1集.mp4`) { + t.Error("正常媒体路径不应命中") + } +} + +// TestCollectEpisodesSkipsHiddenCacheDirs 剧集收集必须跳过 NAS 缓存目录 +// (回归:fnOS .@__thumb 内 JPEG 缩略图伪 .mp4 被当作剧集入库,产生 mjpeg 幽灵剧集) +func TestCollectEpisodesSkipsHiddenCacheDirs(t *testing.T) { + s := &ScannerService{} + root := t.TempDir() + + write := func(rel string) { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + write("S01E01.mp4") + write("S01E02.mp4") + write("子目录/S01E03.mp4") + // fnOS 缩略图缓存:隐藏目录 + 保留原视频 .mp4 扩展名的 JPEG + write(".@__thumb/s800《剧》 第1集.mp4") + write(".@__thumb/s100《剧》 第2集.mp4") + // 群晖 @eaDir + write("@eaDir/S01E01.mp4") + // 深层嵌套的隐藏目录 + write("子目录/.Spotlight-V100/S01E04.mp4") + + eps := s.collectEpisodes(root) + if len(eps) != 3 { + t.Fatalf("应只收集 3 个正片, 实际 %d 个", len(eps)) + } + for _, ep := range eps { + if pathHasHiddenOrCacheSegment(ep.FilePath) { + t.Errorf("不应收集缓存目录内文件: %s", ep.FilePath) + } + } +} + +// TestIsStillImageMedia 静态图片防御过滤(不得误杀正常时长的 Motion-JPEG 视频) +func TestIsStillImageMedia(t *testing.T) { + cases := []struct { + desc string + codec string + duration float64 + want bool + }{ + {"fnOS 缩略图(JPEG 内容, 单帧时长)", "mjpeg", 0.04, true}, + {"无时长的 JPEG", "mjpeg", 0, true}, + {"PNG 图片", "png", 0, true}, + {"正常时长的 Motion-JPEG 视频", "mjpeg", 1420.5, false}, + {"h264 正片", "h264", 1420.5, false}, + {"探测失败(codec 为空, 如 ffprobe 不可用)", "", 0, false}, + } + for _, c := range cases { + media := &model.Media{VideoCodec: c.codec, Duration: c.duration} + if got := isStillImageMedia(media); got != c.want { + t.Errorf("%s: isStillImageMedia(codec=%q, dur=%v) = %v, want %v", c.desc, c.codec, c.duration, got, c.want) + } + } +}