Skip to content

Commit ae6f05a

Browse files
committed
fix(media): fix lrc file display & music id3
1 parent 8dbcd0f commit ae6f05a

8 files changed

Lines changed: 440 additions & 35 deletions

File tree

internal/db/media.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ func ClearMediaScrapedData(mediaType model.MediaType) (int64, error) {
257257
"album_artist": "",
258258
"publisher": "",
259259
"isbn": "",
260+
"lyrics": "",
260261
"scraped_at": nil,
261262
}
262263
result := tx.Updates(updates)

internal/media/id3.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type MusicTag struct {
1818
Genre string // TCON
1919
CoverData []byte // 封面图片原始字节(APIC / PICTURE)
2020
CoverMIME string // 封面图片 MIME 类型(如 image/jpeg)
21+
Lyrics string // 歌词内容(LRC 文本或纯文本):来自 USLT / SYLT / Vorbis LYRICS
2122
}
2223

2324
// ParseID3v2 从 io.Reader 中解析 ID3v2 标签(只读取文件头部,不需要 Seek)
@@ -112,11 +113,168 @@ func ParseID3v2(r io.Reader) (*MusicTag, error) {
112113
if frameID == "APIC" && tag.CoverData == nil && len(frameData) > 1 {
113114
tag.CoverData, tag.CoverMIME = parseAPICFrame(frameData)
114115
}
116+
117+
// 解析 USLT 帧(非同步歌词)
118+
// 格式:编码字节(1) + 语言(3字节 ASCII) + 描述字符串(null 结尾) + 歌词文本
119+
if frameID == "USLT" && tag.Lyrics == "" && len(frameData) > 5 {
120+
tag.Lyrics = parseUSLTFrame(frameData)
121+
}
122+
123+
// 解析 SYLT 帧(同步歌词,二进制时间戳格式),仅在没有 USLT 时尝试转 LRC
124+
if frameID == "SYLT" && tag.Lyrics == "" && len(frameData) > 6 {
125+
if lrc := parseSYLTFrame(frameData); lrc != "" {
126+
tag.Lyrics = lrc
127+
}
128+
}
115129
}
116130

117131
return tag, nil
118132
}
119133

134+
// parseUSLTFrame 解析 ID3v2 USLT 帧(非同步歌词)
135+
// 格式:编码字节(1) + 语言(3字节 ASCII) + 描述字符串(null 结尾) + 歌词文本
136+
func parseUSLTFrame(data []byte) string {
137+
if len(data) < 5 {
138+
return ""
139+
}
140+
encoding := data[0]
141+
// 跳过编码(1) + 语言(3) = 4 字节
142+
pos := 4
143+
144+
// 跳过描述字符串
145+
if encoding == 0x01 || encoding == 0x02 {
146+
// UTF-16:找 \x00\x00 结尾
147+
for pos+1 < len(data) {
148+
if data[pos] == 0 && data[pos+1] == 0 {
149+
pos += 2
150+
break
151+
}
152+
pos += 2
153+
}
154+
} else {
155+
// ISO-8859-1 / UTF-8:找单个 \x00 结尾
156+
for pos < len(data) {
157+
if data[pos] == 0 {
158+
pos++
159+
break
160+
}
161+
pos++
162+
}
163+
}
164+
165+
if pos >= len(data) {
166+
return ""
167+
}
168+
169+
// 剩余字节就是歌词文本,按编码解码(复用 decodeID3Text 但需要把 encoding 字节加回头部)
170+
lyricsRaw := make([]byte, 1+len(data)-pos)
171+
lyricsRaw[0] = encoding
172+
copy(lyricsRaw[1:], data[pos:])
173+
return strings.TrimSpace(decodeID3Text(lyricsRaw))
174+
}
175+
176+
// parseSYLTFrame 解析 ID3v2 SYLT 帧(同步歌词),转换为 LRC 格式
177+
// 格式:编码(1) + 语言(3) + 时间戳格式(1) + 内容类型(1) + 描述(null 结尾) + 多组 (歌词文本 + null + 4字节时间戳)
178+
// 时间戳格式:1=MPEG帧数(不支持),2=毫秒
179+
func parseSYLTFrame(data []byte) string {
180+
if len(data) < 6 {
181+
return ""
182+
}
183+
encoding := data[0]
184+
timestampFormat := data[4] // 1=MPEG帧, 2=ms
185+
if timestampFormat != 2 {
186+
return "" // 只支持毫秒时间戳
187+
}
188+
pos := 6 // 跳过 encoding(1) + lang(3) + timestamp_fmt(1) + content_type(1)
189+
190+
// 跳过描述字符串
191+
if encoding == 0x01 || encoding == 0x02 {
192+
for pos+1 < len(data) {
193+
if data[pos] == 0 && data[pos+1] == 0 {
194+
pos += 2
195+
break
196+
}
197+
pos += 2
198+
}
199+
} else {
200+
for pos < len(data) {
201+
if data[pos] == 0 {
202+
pos++
203+
break
204+
}
205+
pos++
206+
}
207+
}
208+
209+
var sb strings.Builder
210+
wide := encoding == 0x01 || encoding == 0x02
211+
for pos < len(data) {
212+
// 找文本终止符
213+
textStart := pos
214+
textEnd := -1
215+
if wide {
216+
for i := pos; i+1 < len(data); i += 2 {
217+
if data[i] == 0 && data[i+1] == 0 {
218+
textEnd = i
219+
break
220+
}
221+
}
222+
} else {
223+
for i := pos; i < len(data); i++ {
224+
if data[i] == 0 {
225+
textEnd = i
226+
break
227+
}
228+
}
229+
}
230+
if textEnd < 0 {
231+
break
232+
}
233+
// 解码歌词文本
234+
lineRaw := make([]byte, 1+textEnd-textStart)
235+
lineRaw[0] = encoding
236+
copy(lineRaw[1:], data[textStart:textEnd])
237+
lineText := decodeID3Text(lineRaw)
238+
239+
// 跳过文本终止符
240+
if wide {
241+
pos = textEnd + 2
242+
} else {
243+
pos = textEnd + 1
244+
}
245+
// 4 字节时间戳(毫秒)
246+
if pos+4 > len(data) {
247+
break
248+
}
249+
ms := int(binary.BigEndian.Uint32(data[pos : pos+4]))
250+
pos += 4
251+
252+
minutes := ms / 60000
253+
seconds := (ms % 60000) / 1000
254+
cs := (ms % 1000) / 10
255+
// LRC 标准格式 [mm:ss.xx]
256+
sb.WriteString("[")
257+
if minutes < 10 {
258+
sb.WriteString("0")
259+
}
260+
sb.WriteString(strconv.Itoa(minutes))
261+
sb.WriteString(":")
262+
if seconds < 10 {
263+
sb.WriteString("0")
264+
}
265+
sb.WriteString(strconv.Itoa(seconds))
266+
sb.WriteString(".")
267+
if cs < 10 {
268+
sb.WriteString("0")
269+
}
270+
sb.WriteString(strconv.Itoa(cs))
271+
sb.WriteString("]")
272+
sb.WriteString(lineText)
273+
sb.WriteString("\n")
274+
}
275+
return sb.String()
276+
}
277+
120278
// parseAPICFrame 解析 ID3v2 APIC 帧,返回图片字节和 MIME 类型
121279
// APIC 格式:编码字节(1) + MIME字符串(null结尾) + 图片类型(1) + 描述字符串(null结尾) + 图片数据
122280
func parseAPICFrame(data []byte) ([]byte, string) {
@@ -493,6 +651,11 @@ func parseVorbisCommentData(data []byte) *MusicTag {
493651
}
494652
case "GENRE":
495653
tag.Genre = value
654+
case "LYRICS", "UNSYNCEDLYRICS", "UNSYNCED LYRICS", "SYNCEDLYRICS", "SYNCED LYRICS":
655+
// FLAC/Vorbis 内嵌歌词字段(不同打标软件使用不同 key,全部兼容)
656+
if tag.Lyrics == "" {
657+
tag.Lyrics = value
658+
}
496659
}
497660
}
498661

internal/media/local_fill.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package media
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
stdpath "path"
8+
"strings"
9+
"time"
10+
11+
"github.com/OpenListTeam/OpenList/v4/internal/fs"
12+
"github.com/OpenListTeam/OpenList/v4/internal/model"
13+
)
14+
15+
// FillMusicFromLocal 在刮削前/失败时,使用音乐文件本身的元数据(ID3 / Vorbis Comment)
16+
// 以及同目录 .lrc 歌词,**只填充 item 中为空的字段**。
17+
//
18+
// 适用场景:
19+
// 1. 用户清空刮削后重新刮削,原本由扫描阶段填入的本地数据(封面/歌词等)已丢失
20+
// 2. 在线刮削失败,退而求其次用文件本地信息补全
21+
//
22+
// 行为:
23+
// - is_folder=true 的合并文件夹模式:使用 episodes 中第一首音乐文件读取
24+
// - is_folder=false:使用 folder_path/file_name 直接读取
25+
//
26+
// 错误以静默忽略为主——本地兜底失败不应阻塞主流程。
27+
func FillMusicFromLocal(ctx context.Context, item *model.MediaItem) {
28+
if item == nil || item.MediaType != model.MediaTypeMusic {
29+
return
30+
}
31+
32+
musicPath := pickMusicFilePath(item)
33+
if musicPath == "" {
34+
return
35+
}
36+
37+
// 解析 tag(mp3 / flac / 其它)
38+
ext := strings.ToLower(stdpath.Ext(musicPath))
39+
var tag *MusicTag
40+
readCtx, readCancel := context.WithTimeout(ctx, 15*time.Second)
41+
if reader := FetchFileReader(readCtx, musicPath); reader != nil {
42+
switch ext {
43+
case ".flac":
44+
tag, _ = ParseFLACVorbisComment(reader)
45+
default:
46+
tag, _ = ParseID3v2(reader)
47+
}
48+
_ = reader.Close()
49+
}
50+
readCancel()
51+
52+
// 应用 tag 到空字段
53+
if tag != nil {
54+
if item.AlbumName == "" && tag.Album != "" {
55+
item.AlbumName = tag.Album
56+
}
57+
if item.AlbumArtist == "" {
58+
if tag.AlbumArtist != "" {
59+
item.AlbumArtist = tag.AlbumArtist
60+
} else if tag.Artist != "" {
61+
item.AlbumArtist = tag.Artist
62+
}
63+
}
64+
if item.ScrapedName == "" {
65+
if tag.Title != "" {
66+
item.ScrapedName = tag.Title
67+
} else if item.AlbumName != "" {
68+
item.ScrapedName = item.AlbumName
69+
}
70+
}
71+
if item.TrackNumber == 0 && tag.TrackNumber > 0 {
72+
item.TrackNumber = tag.TrackNumber
73+
}
74+
if item.ReleaseDate == "" && len(tag.Year) >= 4 {
75+
item.ReleaseDate = tag.Year[:4] + "-01-01"
76+
}
77+
if item.Genre == "" && tag.Genre != "" {
78+
item.Genre = tag.Genre
79+
}
80+
if item.Authors == "" && tag.Artist != "" {
81+
if b, err := json.Marshal([]string{tag.Artist}); err == nil {
82+
item.Authors = string(b)
83+
}
84+
}
85+
if item.Cover == "" && len(tag.CoverData) > 0 {
86+
mimeType := tag.CoverMIME
87+
if mimeType == "" {
88+
mimeType = "image/jpeg"
89+
}
90+
item.Cover = "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(tag.CoverData)
91+
}
92+
}
93+
94+
// 歌词:若为空则尝试 .lrc 同名文件 / 内嵌歌词
95+
if item.Lyrics == "" {
96+
if lrc := loadLyricsForMusic(ctx, musicPath, tag); lrc != "" {
97+
item.Lyrics = lrc
98+
}
99+
}
100+
}
101+
102+
// pickMusicFilePath 从 MediaItem 推断出可读取的音乐文件 VFS 路径
103+
// 合并模式 (is_folder=true): 取 episodes 中第一首;解析失败回退到 folder_path/file_name
104+
// 普通模式: folder_path/file_name
105+
func pickMusicFilePath(item *model.MediaItem) string {
106+
if item == nil {
107+
return ""
108+
}
109+
if item.IsFolder {
110+
// episodes 是 JSON 数组:[{file_name,index,title,...}]
111+
if item.Episodes != "" {
112+
var eps []EpisodeInfo
113+
if err := json.Unmarshal([]byte(item.Episodes), &eps); err == nil && len(eps) > 0 {
114+
// 文件夹模式下 folder_path 是扫描根,file_name 是文件夹名
115+
return stdpath.Join(item.FolderPath, item.FileName, eps[0].FileName)
116+
}
117+
}
118+
// 回退:尝试列出文件夹内第一首音乐
119+
folder := stdpath.Join(item.FolderPath, item.FileName)
120+
if entries, err := fs.List(context.Background(), folder, &fs.ListArgs{NoLog: true}); err == nil {
121+
for _, e := range entries {
122+
if !e.IsDir() && isMediaFile(e.GetName(), model.MediaTypeMusic) {
123+
return stdpath.Join(folder, e.GetName())
124+
}
125+
}
126+
}
127+
return ""
128+
}
129+
return stdpath.Join(item.FolderPath, item.FileName)
130+
}

0 commit comments

Comments
 (0)