Skip to content

Commit e33ff8d

Browse files
committed
feat(func): media library support select
1 parent 7571afc commit e33ff8d

File tree

2 files changed

+115
-3
lines changed

2 files changed

+115
-3
lines changed

internal/media/scanner.go

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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 判断文件名是否为指定媒体类型(按扩展名判断)
319430
func isMediaFile(name string, mediaType model.MediaType) bool {
320431
ext := strings.ToLower(stdpath.Ext(name))

internal/model/media.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ type MediaItem struct {
6565
// 目录合并模式
6666
IsFolder bool `gorm:"default:false" json:"is_folder"` // 是否为文件夹模式条目
6767
FolderPath string `json:"folder_path"` // 所属文件夹路径
68+
Episodes string `gorm:"type:text" json:"episodes"` // 选集信息,JSON数组,格式:[[文件名,序号,选集标题],...]
6869

6970
ScrapedAt *time.Time `json:"scraped_at"`
7071
}

0 commit comments

Comments
 (0)