@@ -37,6 +37,78 @@ var supportedExts = map[string]bool{
3737 ".strm" : true , // STRM 远程流文件
3838}
3939
40+ // isHiddenOrCacheDirName 判断目录/文件名是否为隐藏项或 NAS 系统生成的缓存目录。
41+ //
42+ // NAS 系统(飞牛 fnOS、群晖、威联通等)会在媒体目录内生成缩略图/预览缓存目录,
43+ // 例如 fnOS 的 .@__thumb(其中文件名保留了原视频的 .mp4 扩展名,但内容是 JPEG 图片)、
44+ // 群晖的 @eaDir、#recycle 等。这些缓存绝不能当作正片入库,否则会产生大量
45+ // "mjpeg/100p" 之类的幽灵剧集并被刮削误配元数据。
46+ // 对齐 Jellyfin/Emby 的标准行为:隐藏目录一律跳过。
47+ func isHiddenOrCacheDirName (name string ) bool {
48+ n := strings .TrimSpace (name )
49+ if n == "" {
50+ return false
51+ }
52+ // "." 开头:.@__thumb / .Trashes / .Spotlight-V100 / .fseventsd 等;
53+ // "@" 开头:群晖 @eaDir 等元数据目录;
54+ // "#" 开头:群晖 #recycle / #snapshot 等回收站与快照目录
55+ if strings .HasPrefix (n , "." ) || strings .HasPrefix (n , "@" ) || strings .HasPrefix (n , "#" ) {
56+ return true
57+ }
58+ return nasCacheDirNames [strings .ToLower (n )]
59+ }
60+
61+ // nasCacheDirNames 常见 NAS/系统的缓存与元数据目录名(小写,不含前缀规则的兜底)
62+ var nasCacheDirNames = map [string ]bool {
63+ "@eadir" : true , // 群晖缩略图/元数据
64+ "$recycle.bin" : true , // Windows 回收站
65+ "system volume information" : true , // Windows 卷信息
66+ "lost+found" : true , // Linux 文件系统
67+ }
68+
69+ // pathHasHiddenOrCacheSegment 判断路径中任一路径段命中隐藏/缓存目录。
70+ // 用于文件监听(fsnotify)等拿不到遍历 SkipDir 控制权的场景。
71+ func pathHasHiddenOrCacheSegment (path string ) bool {
72+ for _ , seg := range strings .FieldsFunc (filepath .ToSlash (path ), func (r rune ) bool { return r == '/' || r == '\\' }) {
73+ if isHiddenOrCacheDirName (seg ) {
74+ return true
75+ }
76+ }
77+ return false
78+ }
79+
80+ // stillImageVideoCodecs ffprobe 报告的"静态图片"编解码器集合。
81+ // JPEG/PNG 等图片(含 NAS 缩略图缓存改名为 .mp4 的文件)被 ffprobe 探测时就是这些 codec。
82+ var stillImageVideoCodecs = map [string ]bool {
83+ "mjpeg" : true , "mjpegb" : true , "jpeg" : true , "jpg" : true ,
84+ "png" : true , "bmp" : true , "webp" : true , "tiff" : true ,
85+ "gif" : true , "avif" : true , "heif" : true ,
86+ }
87+
88+ // isStillImageMedia 判断探测结果是否为静态图片而非真实视频。
89+ // 仅当视频流 codec 命中图片编码且时长近乎为零(单帧图片的特征)时判定为图片,
90+ // 不会误杀正常时长的 Motion-JPEG 视频文件。
91+ // 注意:探测失败(codec 为空,如 ffprobe 不可用)时返回 false,避免误清整个媒体库。
92+ func isStillImageMedia (media * model.Media ) bool {
93+ if media == nil || media .VideoCodec == "" {
94+ return false
95+ }
96+ return stillImageVideoCodecs [strings .ToLower (media .VideoCodec )] && media .Duration <= 0.1
97+ }
98+
99+ // isConfiguredLibraryRoot protects a user-selected hidden root while excluding hidden children.
100+ func isConfiguredLibraryRoot (library * model.Library , path string ) bool {
101+ if library == nil {
102+ return false
103+ }
104+ for _ , root := range library .AllPaths () {
105+ if filepath .Clean (root ) == filepath .Clean (path ) {
106+ return true
107+ }
108+ }
109+ return false
110+ }
111+
40112// extrasExcludeDirs Emby/Kodi 标准的非正片内容目录名(小写)
41113var extrasExcludeDirs = map [string ]bool {
42114 "extras" : true ,
@@ -187,6 +259,9 @@ func (s *ScannerService) looksLikeSeriesFolder(path string) bool {
187259 var hasEpisodicVideo bool
188260 for _ , e := range entries {
189261 name := e .Name ()
262+ if isHiddenOrCacheDirName (name ) {
263+ continue
264+ }
190265 if ! e .IsDir () {
191266 lower := strings .ToLower (name )
192267 if lower == "tvshow.nfo" {
@@ -502,7 +577,7 @@ func (s *ScannerService) collectMediaRoots(root string, kind string) []string {
502577 seen [path ] = true
503578
504579 base := filepath .Base (path )
505- if isXiaoyaSkipDir (base ) || extrasExcludeDirs [strings .ToLower (base )] {
580+ if ( depth > 0 && isHiddenOrCacheDirName ( base )) || isXiaoyaSkipDir (base ) || extrasExcludeDirs [strings .ToLower (base )] {
506581 s .logger .Debugf ("[xiaoya] 跳过特殊目录: %s" , path )
507582 return
508583 }
@@ -574,7 +649,7 @@ func (s *ScannerService) collectMediaRoots(root string, kind string) []string {
574649 }
575650 for i := 0 ; i < sampleN ; i ++ {
576651 sd := subDirs [i ]
577- if isXiaoyaSkipDir (sd .Name ()) || extrasExcludeDirs [strings .ToLower (sd .Name ())] {
652+ if isHiddenOrCacheDirName ( sd . Name ()) || isXiaoyaSkipDir (sd .Name ()) || extrasExcludeDirs [strings .ToLower (sd .Name ())] {
578653 continue
579654 }
580655 childPath := vfsJoin (path , sd .Name ())
@@ -603,7 +678,7 @@ func (s *ScannerService) collectMediaRoots(root string, kind string) []string {
603678 // 穿透:把每个子目录递归展开
604679 expandedCount := 0
605680 for _ , sd := range subDirs {
606- if isXiaoyaSkipDir (sd .Name ()) || extrasExcludeDirs [strings .ToLower (sd .Name ())] {
681+ if isHiddenOrCacheDirName ( sd . Name ()) || isXiaoyaSkipDir (sd .Name ()) || extrasExcludeDirs [strings .ToLower (sd .Name ())] {
607682 continue
608683 }
609684 // [tvshow 扫描] 子目录层级过滤非剧集分类(综艺/演唱会/音乐/MV/每日更新)
@@ -764,6 +839,11 @@ func (s *ScannerService) scanMovieLibrary(library *model.Library) (int, error) {
764839 return nil
765840 }
766841 if info .IsDir () {
842+ // 跳过隐藏目录与 NAS 缓存目录(如 fnOS .@__thumb、群晖 @eaDir),
843+ // 其中的"视频文件"实际是缩略图/预览缓存
844+ if isHiddenOrCacheDirName (filepath .Base (path )) && ! isConfiguredLibraryRoot (library , path ) {
845+ return filepath .SkipDir
846+ }
767847 // 跳过 extras/trailers 等非正片目录(P0: 兼容 Emby 标准)
768848 if extrasExcludeDirs [strings .ToLower (filepath .Base (path ))] {
769849 return filepath .SkipDir
@@ -990,6 +1070,11 @@ func (s *ScannerService) scanMovieLibrary(library *model.Library) (int, error) {
9901070 // 【火力全开 A】处理已更新文件:probe 已在上面并行完成,这里只做字幕扫描 + DB 更新
9911071 if len (updateList ) > 0 {
9921072 for _ , pm := range updateList {
1073+ // Never overwrite a valid indexed record with a disguised still image; manual cleanup of previously indexed ghosts remains separate.
1074+ if isStillImageMedia (pm .media ) {
1075+ s .logger .Warnf ("跳过疑似静态图片的已有媒体更新,保留原记录待人工核查: %s" , pm .path )
1076+ continue
1077+ }
9931078 s .scanExternalSubtitles (pm .media )
9941079 if err := s .mediaRepo .Update (pm .media ); err != nil {
9951080 s .logger .Warnf ("更新媒体失败: %s, 错误: %v" , pm .path , err )
@@ -1042,6 +1127,12 @@ func (s *ScannerService) scanMovieLibrary(library *model.Library) (int, error) {
10421127 }
10431128 }
10441129
1130+ // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库
1131+ if isStillImageMedia (pm .media ) {
1132+ s .logger .Warnf ("跳过静态图片文件(疑似缩略图缓存): %s [%s]" , pm .media .FilePath , pm .media .VideoCodec )
1133+ continue
1134+ }
1135+
10451136 if err := s .mediaRepo .Create (pm .media ); err != nil {
10461137 s .logger .Warnf ("保存媒体失败: %s, 错误: %v" , pm .path , err )
10471138 continue
@@ -1266,7 +1357,7 @@ func (s *ScannerService) scanMixedLibrary(library *model.Library) (int, error) {
12661357 if e .IsDir () {
12671358 if isSeasonOnlyDirName (e .Name ()) {
12681359 seasonChildCount ++
1269- } else if ! isXiaoyaSkipDir (e .Name ()) && ! extrasExcludeDirs [strings .ToLower (e .Name ())] {
1360+ } else if ! isHiddenOrCacheDirName ( e . Name ()) && ! isXiaoyaSkipDir (e .Name ()) && ! extrasExcludeDirs [strings .ToLower (e .Name ())] {
12701361 nonSeasonChildCount ++
12711362 }
12721363 }
@@ -1308,7 +1399,8 @@ func (s *ScannerService) scanMixedLibrary(library *model.Library) (int, error) {
13081399
13091400 dirName := entry .Name ()
13101401 // [xiaoya 适配] 跳过特殊目录(ISO/json/画质演示/extras 等)
1311- if isXiaoyaSkipDir (dirName ) || extrasExcludeDirs [strings .ToLower (dirName )] {
1402+ // 同时跳过隐藏目录与 NAS 缓存目录(fnOS .@__thumb、群晖 @eaDir 等)
1403+ if isHiddenOrCacheDirName (dirName ) || isXiaoyaSkipDir (dirName ) || extrasExcludeDirs [strings .ToLower (dirName )] {
13121404 s .logger .Debugf ("[xiaoya] 混合库扫描跳过特殊目录: %s" , dirName )
13131405 continue
13141406 }
@@ -1369,7 +1461,13 @@ func (s *ScannerService) scanMixedLibrary(library *model.Library) (int, error) {
13691461 for _ , entry := range movieDirs {
13701462 folderPath := vfsJoin (entry .rootPath , entry .entry .Name ())
13711463 err := s .walkLibraryPath (folderPath , func (path string , info os.FileInfo , err error ) error {
1372- if err != nil || info .IsDir () {
1464+ if err != nil {
1465+ return nil
1466+ }
1467+ if info .IsDir () {
1468+ if isHiddenOrCacheDirName (filepath .Base (path )) {
1469+ return filepath .SkipDir
1470+ }
13731471 return nil
13741472 }
13751473 ext := strings .ToLower (filepath .Ext (path ))
@@ -1393,6 +1491,11 @@ func (s *ScannerService) scanMixedLibrary(library *model.Library) (int, error) {
13931491 }
13941492 s .probeMediaInfo (media )
13951493 s .scanExternalSubtitles (media )
1494+ // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库
1495+ if isStillImageMedia (media ) {
1496+ s .logger .Warnf ("跳过静态图片文件(疑似缩略图缓存): %s [%s]" , media .FilePath , media .VideoCodec )
1497+ return nil
1498+ }
13961499 if err := s .mediaRepo .Create (media ); err != nil {
13971500 s .logger .Warnf ("保存媒体失败: %s, 错误: %v" , path , err )
13981501 return nil
@@ -1437,6 +1540,11 @@ func (s *ScannerService) scanMixedLibrary(library *model.Library) (int, error) {
14371540 }
14381541 s .probeMediaInfo (media )
14391542 s .scanExternalSubtitles (media )
1543+ // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库
1544+ if isStillImageMedia (media ) {
1545+ s .logger .Warnf ("跳过静态图片文件(疑似缩略图缓存): %s [%s]" , media .FilePath , media .VideoCodec )
1546+ continue
1547+ }
14401548 if err := s .mediaRepo .Create (media ); err != nil {
14411549 s .logger .Warnf ("保存媒体失败: %s, 错误: %v" , filePath , err )
14421550 continue
@@ -1502,6 +1610,9 @@ func (s *ScannerService) isTVShowFolder(folderPath string) bool {
15021610 // 规则2: 包含 Season 子目录
15031611 var videoFiles []string
15041612 for _ , entry := range entries {
1613+ if isHiddenOrCacheDirName (entry .Name ()) {
1614+ continue
1615+ }
15051616 if entry .IsDir () {
15061617 for _ , pattern := range seasonDirPatterns {
15071618 if pattern .MatchString (entry .Name ()) {
@@ -1678,6 +1789,9 @@ func (s *ScannerService) scanTVShowLibrary(library *model.Library) (int, error)
16781789 s .logger .Infof ("剧集根 %s 包含 %d 个条目" , root , len (entries ))
16791790
16801791 for _ , entry := range entries {
1792+ if isHiddenOrCacheDirName (entry .Name ()) {
1793+ continue
1794+ }
16811795 if ! entry .IsDir () {
16821796 // 根目录下的视频文件
16831797 ext := strings .ToLower (filepath .Ext (entry .Name ()))
@@ -1706,7 +1820,9 @@ func (s *ScannerService) scanTVShowLibrary(library *model.Library) (int, error)
17061820
17071821 dirName := entry .Name ()
17081822 // [xiaoya 适配] 跳过特殊目录(ISO/json/画质演示/extras 等)
1709- if isXiaoyaSkipDir (dirName ) || extrasExcludeDirs [strings .ToLower (dirName )] {
1823+ // 同时跳过隐藏目录与 NAS 缓存目录(fnOS .@__thumb、群晖 @eaDir 等),
1824+ // 否则缓存目录会被当作剧集目录,缩略图伪 .mp4 会以剧集身份入库
1825+ if isHiddenOrCacheDirName (dirName ) || isXiaoyaSkipDir (dirName ) || extrasExcludeDirs [strings .ToLower (dirName )] {
17101826 s .logger .Debugf ("[xiaoya] 剧集扫描跳过特殊目录: %s" , dirName )
17111827 continue
17121828 }
@@ -1817,6 +1933,11 @@ func (s *ScannerService) scanTVShowLibrary(library *model.Library) (int, error)
18171933 media .SeasonNum = ep .SeasonNum
18181934 media .EpisodeNum = ep .EpisodeNum
18191935 media .EpisodeTitle = ep .EpisodeTitle
1936+ // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库
1937+ if isStillImageMedia (media ) {
1938+ s .logger .Warnf ("跳过静态图片文件(疑似缩略图缓存): %s [%s]" , media .FilePath , media .VideoCodec )
1939+ continue
1940+ }
18201941 if err := s .mediaRepo .Create (media ); err != nil {
18211942 s .logger .Warnf ("保存媒体失败: %s, 错误: %v" , filePath , err )
18221943 }
@@ -1870,6 +1991,12 @@ func (s *ScannerService) scanTVShowLibrary(library *model.Library) (int, error)
18701991 s .probeMediaInfo (media )
18711992 s .scanExternalSubtitles (media )
18721993
1994+ // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库
1995+ if isStillImageMedia (media ) {
1996+ s .logger .Warnf ("跳过静态图片文件(疑似缩略图缓存): %s [%s]" , media .FilePath , media .VideoCodec )
1997+ continue
1998+ }
1999+
18732000 if err := s .mediaRepo .Create (media ); err != nil {
18742001 s .logger .Warnf ("保存剧集失败: %s, 错误: %v" , filePath , err )
18752002 continue
@@ -2213,6 +2340,12 @@ func (s *ScannerService) scanMultiSeasonSeries(library *model.Library, seriesTit
22132340 s .probeMediaInfo (media )
22142341 s .scanExternalSubtitles (media )
22152342
2343+ // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库
2344+ if isStillImageMedia (media ) {
2345+ s .logger .Warnf ("跳过静态图片文件(疑似缩略图缓存): %s [%s]" , media .FilePath , media .VideoCodec )
2346+ continue
2347+ }
2348+
22162349 if err := s .mediaRepo .Create (media ); err != nil {
22172350 s .logger .Warnf ("保存剧集失败: %s, 错误: %v" , ep .FilePath , err )
22182351 continue
@@ -2358,6 +2491,12 @@ func (s *ScannerService) scanSeriesFolder(library *model.Library, folderPath, se
23582491 s .probeMediaInfo (media )
23592492 s .scanExternalSubtitles (media )
23602493
2494+ // 防御:ffprobe 判定为静态图片的文件(如 NAS 缩略图缓存伪 .mp4)不入库
2495+ if isStillImageMedia (media ) {
2496+ s .logger .Warnf ("跳过静态图片文件(疑似缩略图缓存): %s [%s]" , media .FilePath , media .VideoCodec )
2497+ continue
2498+ }
2499+
23612500 if err := s .mediaRepo .Create (media ); err != nil {
23622501 s .logger .Warnf ("保存剧集失败: %s, 错误: %v" , ep .FilePath , err )
23632502 continue
@@ -2393,7 +2532,18 @@ func (s *ScannerService) collectEpisodes(folderPath string) []EpisodeInfo {
23932532 var episodes []EpisodeInfo
23942533
23952534 s .walkLibraryPath (folderPath , func (path string , info os.FileInfo , err error ) error {
2396- if err != nil || info .IsDir () {
2535+ if err != nil {
2536+ return nil
2537+ }
2538+ if info .IsDir () {
2539+ // 跳过隐藏目录与 NAS 缓存目录(fnOS .@__thumb、群晖 @eaDir 等):
2540+ // 缓存缩略图保留原视频的 .mp4 扩展名但内容是 JPEG,入库后即"mjpeg 幽灵剧集"
2541+ if isHiddenOrCacheDirName (filepath .Base (path )) {
2542+ return filepath .SkipDir
2543+ }
2544+ return nil
2545+ }
2546+ if isHiddenOrCacheDirName (filepath .Base (path )) {
23972547 return nil
23982548 }
23992549 ext := strings .ToLower (filepath .Ext (path ))
0 commit comments