Запись тэга с обложкой: старая обложка не читается, новая копируется один раз

- build_tag: ёмкость — только по переносимым фреймам (заменяемая обложка не в счёт), фрейм
  обложки пишется прямо в буфер тэга (write_apic) вместо двух промежуточных массивов
- замена или удаление обложки: старый тэг читается по фреймам без APIC (parse_id3v2_lazy)
- тест: перезапись с обложкой 3 МБ выделяет < 1,5 размера обложки (было 17 МБ, стало 3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
K1ndWha1eandClaude Opus 5.5 committed 2026-10-11 18:50:55 +03:00
1 parent 6161a7faaa
commit 095499b0fe
2 files changed
+78 -31

No files matched your search

+45 -31
View File
@@ -187,10 +187,11 @@ fn parse_id3v2(path string) !ParsedTag {
const read_frames = ['TIT2', 'TALB', 'TPE1', 'TPE2', 'TCOM', 'TIT1', 'TRCK', 'TYER', 'TDRC', 'TCON',
'TLAN', 'COMM', 'TXXX']
// Чтение для read_book_tags: с диска берутся только нужные текстовые фреймы, обложка и прочие фреймы
// пропускаются — у тэга с обложкой в мегабайты читается несколько КБ. Второе значение — есть ли обложка.
// Чтение тэга по фреймам без обложки: картинка в мегабайты с диска не читается, второе значение —
// есть ли она. texts_only — только текстовые фреймы для read_book_tags (несколько КБ на файл),
// иначе все фреймы, кроме обложки, — для записи, когда обложка заменяется или удаляется.
// Тэг с unsynchronisation (v2.3) по фреймам не прочитать — он читается целиком, как parse_id3v2
fn parse_id3v2_texts(path string) !(ParsedTag, bool) {
fn parse_id3v2_lazy(path string, texts_only bool) !(ParsedTag, bool) {
mut f := os.open(path)!
defer {
f.close()
@@ -244,8 +245,8 @@ fn parse_id3v2_texts(path string) !(ParsedTag, bool) {
if id == 'APIC' {
// Как в parse_id3v2: слишком короткий APIC — не обложка
has_cover = has_cover || size >= 14
} else if size > 0 && id in read_frames {
// Каждый нужный фрейм — в свой маленький буфер
} else if size > 0 && (!texts_only || id in read_frames) {
// Каждый нужный фрейм — в свой буфер
mut buf := []u8{len: int(size + 10)}
if f.read_bytes_into(pos, mut buf)! < buf.len {
break
@@ -371,7 +372,7 @@ fn (frame Frame) apic_value() (string, u8, []u8) {
// Чтение тегов файла. Если ID3v2 нет — смотрим ID3v1
fn read_book_tags_impl(path string) !FileTags {
// Обложка не читается — только отмечается, есть ли она
mut parsed, has_cover := parse_id3v2_texts(path)!
mut parsed, has_cover := parse_id3v2_lazy(path, true)!
// Тексты уже декодированы в свои строки — байты тэга больше не нужны
defer {
unsafe { parsed.raw.free() }
@@ -594,18 +595,21 @@ fn comm_frame(text string, lang string, version u8) []u8 {
return out
}
fn apic_frame(cover Cover, version u8) []u8 {
mut body := [u8(0)]
body << normalize_mime(cover.mime, cover.data).bytes()
body << 0
// Тип 3 — передняя обложка
body << 3
// Пустое описание в ISO-8859-1
body << 0
body << cover.data
mut out := frame_header('APIC', body.len, version)
out << body
return out
// Фрейм обложки — сразу в конец out: картинка в мегабайты копируется один раз, без промежуточных буферов
fn write_apic(mut out []u8, cover Cover, version u8) {
mime := normalize_mime(cover.mime, cover.data)
// Кодировка, mime с нулём, тип 3 (передняя обложка), пустое описание в ISO-8859-1
size := 1 + mime.len + 1 + 1 + 1 + cover.data.len
out << frame_header('APIC', size, version)
out << u8(0)
out << mime.bytes()
out << [u8(0), 3, 0]
out << cover.data
}
// Сколько байт займёт фрейм обложки
fn apic_frame_len(cover Cover) int {
return 10 + 4 + normalize_mime(cover.mime, cover.data).len + cover.data.len
}
fn is_managed(frame Frame) bool {
@@ -619,17 +623,26 @@ fn is_managed(frame Frame) bool {
return false
}
// Старый фрейм переносится в новый тэг как есть: не пересобираемый и не заменяемая обложка
fn kept_frame(frame Frame, drop_cover bool) bool {
return !is_managed(frame) && !(frame.name == 'APIC' && drop_cover)
}
// Сборка тэга целиком (заголовок + фреймы + запас нулей)
fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
version := if parsed.version in [u8(3), 4] { parsed.version } else { u8(3) }
new_cover := opts.cover
drop_cover := new_cover != none || opts.remove_cover
// Сразу нужной ёмкости: тэг с обложкой — это мегабайты, а рост массива удвоениями оставлял
// в куче копии на каждом шаге. Текстовые фреймы — с запасом
// в куче копии на каждом шаге. Текстовые фреймы — с запасом; заменяемая обложка не в счёт
mut capacity := 10 + book_tag_padding + 4096 + t.comment.len * 2
for frame in parsed.frames {
capacity += frame.raw.len
if kept_frame(frame, drop_cover) {
capacity += frame.raw.len
}
}
if c := opts.cover {
capacity += c.data.len + 64
if c := new_cover {
capacity += apic_frame_len(c)
}
mut frames := []u8{cap: capacity}
frames << [u8(`I`), `D`, `3`, version, 0, 0, 0, 0, 0, 0]
@@ -662,18 +675,13 @@ fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
frames << comm_frame(t.comment.trim_space(), 'rus', version)
}
new_cover := opts.cover
for frame in parsed.frames {
if is_managed(frame) {
continue
if kept_frame(frame, drop_cover) {
frames << frame.raw
}
if frame.name == 'APIC' && (new_cover != none || opts.remove_cover) {
continue
}
frames << frame.raw
}
if c := new_cover {
frames << apic_frame(c, version)
write_apic(mut frames, c, version)
}
frames << []u8{len: book_tag_padding}
@@ -691,7 +699,13 @@ fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
// Audiobookshelf опознаёт файлы по inode, и замена файла новым путает его библиотеку.
// Файл без ID3v2 тоже поддерживается — тэг просто ставится в начало
fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) !WriteReport {
mut parsed := parse_id3v2(path)!
// Старая обложка заменяется или удаляется — её с диска не читать, остальные фреймы переносятся как есть
mut parsed := ParsedTag{}
if opts.cover != none || opts.remove_cover {
parsed, _ = parse_id3v2_lazy(path, false)!
} else {
parsed = parse_id3v2(path)!
}
mut tag := build_tag(parsed, t, opts)
// Старый и новый тэг (с обложкой — мегабайты) сразу сборщику: при параллельной записи иначе
// куча вырастает на несколько копий на поток и сборщик её системе не возвращает
+33
View File
@@ -117,6 +117,39 @@ fn test_read_skips_cover() {
}
}
// Замена обложки: в памяти только новый тэг — старая обложка не читается, новая копируется один раз
fn test_write_cover_allocations() {
path := tmp_copy('test_id3v23.mp3')
defer {
os.rm(path) or {}
}
mut data := []u8{len: 3 * 1024 * 1024, init: u8(index % 251)}
data[0] = 0xFF
data[1] = 0xD8
cover := metadata.Cover{
mime: 'image/jpeg'
data: data
}
tags := metadata.BookTags{
title: 'Глава 1'
album: 'Тень ветра'
}
metadata.write_book_tags(path, tags, cover: cover) or { panic(err) }
before := u64(gc_heap_usage().total_bytes)
metadata.write_book_tags(path, metadata.BookTags{ ...tags, title: 'Глава 2' },
cover: cover
) or {
panic(err)
}
allocated := u64(gc_heap_usage().total_bytes) - before
eprintln(' перезапись с обложкой 3 МБ: выделено ${f64(allocated) / 1048576:.1f} МБ')
assert allocated < u64(data.len) * 3 / 2, 'выделено ${allocated} Б'
t := metadata.read_book_tags(path) or { panic(err) }
assert t.tags.title == 'Глава 2'
assert t.has_cover
assert (metadata.read_cover(path) or { panic(err) }).data == data
}
// В файл без ID3v2 тэг добавляется в начало
fn test_write_without_id3v2() {
path := tmp_copy('test_id3v1.mp3')