Skip to content

Commit 6f94401

Browse files
cropflreklmmlk
andcommitted
fix(server): ignore NAS cache files without losing configured roots
Extract and harden the NAS scanner contribution from PR #82. Preserve explicitly configured hidden roots, skip NAS cache directories in scanning and watching, reject still images and protect existing indexed records from invalid updates. Historical ghost-record deletion remains a separate reviewed migration. Validated against the exact final tree with go test ./internal/service ./internal/handler and go build ./cmd/server ./cmd/server-lite. Co-authored-by: klmmlk <jiong-lin@live.com>
1 parent 6deeadc commit 6f94401

3 files changed

Lines changed: 327 additions & 9 deletions

File tree

internal/service/file_watcher.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ func (fw *FileWatcherService) WatchLibrary(lib *model.Library) {
148148
return nil
149149
}
150150
// 忽略隐藏目录
151-
if strings.HasPrefix(filepath.Base(path), ".") && path != libPath {
151+
if isHiddenOrCacheDirName(filepath.Base(path)) && path != libPath {
152152
return filepath.SkipDir
153153
}
154154
if watchErr := fw.watcher.Add(path); watchErr != nil {
@@ -267,6 +267,8 @@ func (fw *FileWatcherService) handleEvent(event fsnotify.Event) {
267267
// 判断是否为视频文件或目录
268268
ext := strings.ToLower(filepath.Ext(event.Name))
269269
isVideo := supportedExts[ext]
270+
// 隐藏目录与 NAS 缓存目录(fnOS .@__thumb、群晖 @eaDir 等)内的文件
271+
// 是缩略图/预览缓存,不参与媒体导入
270272
isDir := false
271273
if info, err := os.Stat(event.Name); err == nil {
272274
isDir = info.IsDir()
@@ -283,6 +285,10 @@ func (fw *FileWatcherService) handleEvent(event fsnotify.Event) {
283285
if libraryID == "" {
284286
return
285287
}
288+
// Check descendants relative to configured roots: the user may deliberately choose a hidden library root.
289+
if fw.pathIsInExcludedSubdir(event.Name) {
290+
return
291+
}
286292

287293
// 如果是新创建的目录,自动添加到监听列表并同步维护内存索引,
288294
// 确保后续删除媒体库时无需重新遍历磁盘即可完整注销。
@@ -329,6 +335,25 @@ func (fw *FileWatcherService) handleEvent(event fsnotify.Event) {
329335
fw.debounceScan(libraryID)
330336
}
331337

338+
// pathIsInExcludedSubdir checks only segments below a configured library root.
339+
// An explicitly configured hidden root is always allowed, as are its normal descendants.
340+
func (fw *FileWatcherService) pathIsInExcludedSubdir(path string) bool {
341+
fw.mu.Lock()
342+
defer fw.mu.Unlock()
343+
found := false
344+
for root := range fw.watching {
345+
if !pathWithinRoot(path, root) {
346+
continue
347+
}
348+
found = true
349+
rel, err := filepath.Rel(root, path)
350+
if err == nil && (rel == "." || !pathHasHiddenOrCacheSegment(rel)) {
351+
return false
352+
}
353+
}
354+
return found
355+
}
356+
332357
// findLibraryID 根据文件路径查找所属媒体库 ID
333358
func (fw *FileWatcherService) findLibraryID(filePath string) string {
334359
fw.mu.Lock()

internal/service/scanner.go

Lines changed: 158 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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 标准的非正片内容目录名(小写)
41113
var 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

Comments
 (0)