@@ -8,9 +8,11 @@ import (
88 "mime"
99 "net/http"
1010 stdpath "path"
11+ "regexp"
1112 "strings"
1213 "sync"
1314 "time"
15+ "unicode"
1416
1517 log "github.com/sirupsen/logrus"
1618
@@ -133,15 +135,19 @@ func doScan(cfg *model.MediaConfig, p *ScanProgress) error {
133135 ctx := context .Background ()
134136
135137 if cfg .PathMerge {
136- // 路径合并模式:每个子文件夹作为一个条目
137- // 先刷新根目录缓存,再扫描
138+ // 路径合并模式:
139+ // - 子文件夹 → 作为一个合并条目(带 Episodes 选集信息)
140+ // - 直接放在根目录下的单个媒体文件 → 正常作为独立条目扫描
138141 entries , err := fs .List (ctx , scanRoot , & fs.ListArgs {NoLog : true , Refresh : true })
139142 if err != nil {
140143 return err
141144 }
142145 for _ , e := range entries {
146+ childPath := stdpath .Join (scanRoot , e .GetName ())
143147 if e .IsDir () {
144- targets = append (targets , stdpath .Join (scanRoot , e .GetName ()))
148+ targets = append (targets , childPath )
149+ } else if isMediaFile (e .GetName (), cfg .MediaType ) {
150+ targets = append (targets , childPath )
145151 }
146152 }
147153 } else {
@@ -165,6 +171,15 @@ func doScan(cfg *model.MediaConfig, p *ScanProgress) error {
165171 // 书籍类型:扫描阶段只记录基本信息,不读取文件内容,不刮削
166172 // 封面提取和豆瓣刮削在用户手动触发刮削时进行
167173
174+ // 路径合并模式下,扫描文件夹内的文件,填充选集信息
175+ if cfg .PathMerge && item .IsFolder {
176+ if episodes , err := buildEpisodesFromFolder (ctx , target , cfg .MediaType ); err == nil {
177+ item .Episodes = episodes
178+ } else {
179+ log .Warnf ("build episodes error [%s]: %v" , target , err )
180+ }
181+ }
182+
168183 if err := db .CreateOrUpdateMediaItem (item ); err != nil {
169184 log .Warnf ("save media item error [%s]: %v" , target , err )
170185 }
@@ -315,6 +330,102 @@ func buildMediaItemFromVFS(ctx context.Context, vfsPath string, cfg *model.Media
315330 return item , nil
316331}
317332
333+ // episodeNumRe 匹配文件名开头的数字序号,支持 "1、" "2." "3-" "4 " 等分隔符
334+ var episodeNumRe = regexp .MustCompile (`^(\d+)[、.\-\s_]+(.*)` )
335+
336+ // EpisodeInfo 选集信息
337+ type EpisodeInfo struct {
338+ FileName string `json:"file_name"` // 原始文件名(含扩展名)
339+ Index int `json:"index"` // 序号,默认0,文件名开头有数字则取该数字
340+ Title string `json:"title"` // 选集标题(去掉序号后的文件名,不含扩展名)
341+ }
342+
343+ // buildEpisodesFromFolder 扫描文件夹内的媒体文件,构建选集信息 JSON 字符串
344+ func buildEpisodesFromFolder (ctx context.Context , folderPath string , mediaType model.MediaType ) (string , error ) {
345+ entries , err := fs .List (ctx , folderPath , & fs.ListArgs {NoLog : true })
346+ if err != nil {
347+ return "" , err
348+ }
349+
350+ var episodes []EpisodeInfo
351+ for _ , e := range entries {
352+ if e .IsDir () {
353+ continue
354+ }
355+ name := e .GetName ()
356+ if ! isMediaFile (name , mediaType ) {
357+ continue
358+ }
359+
360+ // 去掉扩展名得到裸文件名
361+ ext := stdpath .Ext (name )
362+ baseName := strings .TrimSuffix (name , ext )
363+
364+ ep := EpisodeInfo {
365+ FileName : name ,
366+ Index : 0 ,
367+ Title : baseName ,
368+ }
369+
370+ // 尝试从文件名开头提取数字序号
371+ if m := episodeNumRe .FindStringSubmatch (baseName ); len (m ) == 3 {
372+ if idx := parseLeadingInt (m [1 ]); idx > 0 {
373+ ep .Index = idx
374+ ep .Title = strings .TrimSpace (m [2 ])
375+ }
376+ } else {
377+ // 文件名直接以纯数字开头(无分隔符),也尝试提取
378+ if idx , rest := splitLeadingNumber (baseName ); idx > 0 {
379+ ep .Index = idx
380+ ep .Title = strings .TrimSpace (rest )
381+ }
382+ }
383+
384+ episodes = append (episodes , ep )
385+ }
386+
387+ if len (episodes ) == 0 {
388+ return "" , nil
389+ }
390+
391+ b , err := json .Marshal (episodes )
392+ if err != nil {
393+ return "" , err
394+ }
395+ return string (b ), nil
396+ }
397+
398+ // parseLeadingInt 将纯数字字符串解析为 int,失败返回 0
399+ func parseLeadingInt (s string ) int {
400+ v := 0
401+ for _ , c := range s {
402+ if c < '0' || c > '9' {
403+ return 0
404+ }
405+ v = v * 10 + int (c - '0' )
406+ }
407+ return v
408+ }
409+
410+ // splitLeadingNumber 从字符串开头提取连续数字,返回 (数字值, 剩余字符串)
411+ // 仅当开头确实有数字时才返回非零值
412+ func splitLeadingNumber (s string ) (int , string ) {
413+ i := 0
414+ for i < len (s ) && s [i ] >= '0' && s [i ] <= '9' {
415+ i ++
416+ }
417+ if i == 0 || i == len (s ) {
418+ // 没有数字,或者整个字符串都是数字(没有标题部分)
419+ return 0 , s
420+ }
421+ // 剩余部分必须以非字母数字字符开头,避免把 "1080p" 之类的误识别
422+ if unicode .IsLetter (rune (s [i ])) || unicode .IsDigit (rune (s [i ])) {
423+ return 0 , s
424+ }
425+ v := parseLeadingInt (s [:i ])
426+ return v , s [i :]
427+ }
428+
318429// isMediaFile 判断文件名是否为指定媒体类型(按扩展名判断)
319430func isMediaFile (name string , mediaType model.MediaType ) bool {
320431 ext := strings .ToLower (stdpath .Ext (name ))
0 commit comments