Меньше памяти на тэгах с обложкой: фрейм заполняется прямо в массиве, тэг собирается в буфер нужного размера, «только тэг» пишется без копии, буферы — free()

parsed.frames << frame клонировал срезы буфера (raw, apic) — у обложки 3 МБ это +5,7 МБ на файл.
Замер на тэге 2,9 МБ: чтение 8,6 → 2,9 МБ выделений, запись 11,6 → 5,8 МБ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
K1ndWha1eandClaude Opus 5.5 committed 2026-10-11 11:26:29 +03:00
1 parent 71b838e7d4
commit dc13ae43b8
1 file changed
+64 -28
+64 -28
View File
@@ -75,6 +75,9 @@ mut:
version u8 version u8
frames []Frame frames []Frame
audio_start u64 audio_start u64
// Байты тэга из файла: фреймы — срезы этого буфера. После записи / чтения — free(), чтобы тэг
// с обложкой в несколько МБ не оседал в куче (при параллельной записи — по копии на поток)
raw []u8
} }
// Запас в конце тэга при полной перезаписи: следующие правки (длиннее описание, другие названия) // Запас в конце тэга при полной перезаписи: следующие правки (длиннее описание, другие названия)
@@ -136,6 +139,7 @@ fn parse_id3v2(path string) !ParsedTag {
} }
bytes = unsafe { clean } bytes = unsafe { clean }
} }
parsed.raw = bytes
mut pos := u64(10) mut pos := u64(10)
// Расширенный заголовок // Расширенный заголовок
@@ -170,9 +174,10 @@ fn parse_id3v2(path string) !ParsedTag {
pos = end pos = end
continue continue
} }
mut frame := Frame{} // Фрейм заполняется уже в массиве: `parsed.frames << frame` скопировал бы его срезы буфера
frame.fill(bytes, pos, version) // (V клонирует срезы при копировании структуры в массив) — у обложки это ещё два её размера
parsed.frames << frame parsed.frames << Frame{}
parsed.frames[parsed.frames.len - 1].fill(bytes, pos, version)
pos = end pos = end
} }
return parsed return parsed
@@ -290,7 +295,11 @@ fn (frame Frame) apic_value() (string, u8, []u8) {
// Чтение тегов файла. Если ID3v2 нет — смотрим ID3v1 // Чтение тегов файла. Если ID3v2 нет — смотрим ID3v1
fn read_book_tags_impl(path string) !FileTags { fn read_book_tags_impl(path string) !FileTags {
parsed := parse_id3v2(path)! mut parsed := parse_id3v2(path)!
// Тексты уже декодированы в свои строки — байты тэга больше не нужны
defer {
unsafe { parsed.raw.free() }
}
mut res := FileTags{ mut res := FileTags{
version: parsed.version version: parsed.version
} }
@@ -539,7 +548,17 @@ fn is_managed(frame Frame) bool {
// Сборка тэга целиком (заголовок + фреймы + запас нулей) // Сборка тэга целиком (заголовок + фреймы + запас нулей)
fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 { fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
version := if parsed.version in [u8(3), 4] { parsed.version } else { u8(3) } version := if parsed.version in [u8(3), 4] { parsed.version } else { u8(3) }
mut frames := []u8{} // Сразу нужной ёмкости: тэг с обложкой — это мегабайты, а рост массива удвоениями оставлял
// в куче копии на каждом шаге. Текстовые фреймы — с запасом
mut capacity := 10 + book_tag_padding + 4096 + t.comment.len * 2
for frame in parsed.frames {
capacity += frame.raw.len
}
if c := opts.cover {
capacity += c.data.len + 64
}
mut frames := []u8{cap: capacity}
frames << [u8(`I`), `D`, `3`, version, 0, 0, 0, 0, 0, 0]
// Порядок как в большинстве редакторов: текстовые сверху, картинка в конце // Порядок как в большинстве редакторов: текстовые сверху, картинка в конце
pairs := [ pairs := [
@@ -585,10 +604,12 @@ fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
frames << []u8{len: book_tag_padding} frames << []u8{len: book_tag_padding}
mut out := [u8(`I`), `D`, `3`, version, 0, 0] // Размер тэга (без 10 байт заголовка) — на своё место в заголовке
out << encode_header_size(u64(frames.len)) size := encode_header_size(u64(frames.len - 10))
out << frames for i in 0 .. 4 {
return out frames[6 + i] = size[i]
}
return frames
} }
// Запись тэгов: новый тэг + аудио из исходного файла собираются во временном файле, // Запись тэгов: новый тэг + аудио из исходного файла собираются во временном файле,
@@ -596,12 +617,20 @@ fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
// Audiobookshelf опознаёт файлы по inode, и замена файла новым путает его библиотеку. // Audiobookshelf опознаёт файлы по inode, и замена файла новым путает его библиотеку.
// Файл без ID3v2 тоже поддерживается — тэг просто ставится в начало // Файл без ID3v2 тоже поддерживается — тэг просто ставится в начало
fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) !WriteReport { fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) !WriteReport {
parsed := parse_id3v2(path)! mut parsed := parse_id3v2(path)!
tag := build_tag(parsed, t, opts) mut tag := build_tag(parsed, t, opts)
// Старый и новый тэг (с обложкой — мегабайты) сразу сборщику: при параллельной записи иначе
// куча вырастает на несколько копий на поток и сборщик её системе не возвращает
defer {
unsafe {
parsed.raw.free()
tag.free()
}
}
// Новый тэг помещается на место старого — переписать только его, аудио не трогать // Новый тэг помещается на место старого — переписать только его, аудио не трогать
if fitted := fit_tag(parsed, tag) { if tag_fits(parsed, tag) {
if write_header(path, fitted) { if write_header(path, parsed, tag) {
return WriteReport{ return WriteReport{
in_place: true in_place: true
header: true header: true
@@ -611,6 +640,9 @@ fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) !WriteReport
tmp_path := path + '.tagtmp' tmp_path := path + '.tagtmp'
mut buf := []u8{len: 1024 * 1024} mut buf := []u8{len: 1024 * 1024}
defer {
unsafe { buf.free() }
}
{ {
mut src := os.open(path)! mut src := os.open(path)!
defer { defer {
@@ -659,30 +691,34 @@ fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) !WriteReport
} }
} }
// Тэг (без запаса), дополненный нулями ровно до размера старого тэга; none — не помещается // Новый тэг (без запаса) помещается на место старого
fn fit_tag(parsed ParsedTag, tag []u8) ?[]u8 { fn tag_fits(parsed ParsedTag, tag []u8) bool {
if parsed.version !in [u8(3), 4] || parsed.audio_start < 10 { if parsed.version !in [u8(3), 4] || parsed.audio_start < 10 {
return none return false
} }
body := tag.len - book_tag_padding body := tag.len - book_tag_padding
if body < 10 || u64(body) > parsed.audio_start { return body >= 10 && u64(body) <= parsed.audio_start
return none
}
mut out := []u8{cap: int(parsed.audio_start)}
out << tag[..6]
out << encode_header_size(parsed.audio_start - 10)
out << tag[10..body]
out << []u8{len: int(parsed.audio_start) - body}
return out
} }
// Перезапись начала файла тем же числом байт; false — файл не открылся на запись // Перезапись начала файла тем же числом байт: тэг без запаса, размер в заголовке — как у старого
fn write_header(path string, data []u8) bool { // тэга, остаток до начала аудио — нулями. Пишется прямо из буфера тэга, без копии;
// false — файл не открылся на запись
fn write_header(path string, parsed ParsedTag, tag []u8) bool {
body := tag.len - book_tag_padding
mut f := os.open_file(path, 'r+') or { return false } mut f := os.open_file(path, 'r+') or { return false }
defer { defer {
f.close() f.close()
} }
f.write(data) or { return false } f.write(tag[..6]) or { return false }
f.write(encode_header_size(parsed.audio_start - 10)) or { return false }
f.write(tag[10..body]) or { return false }
mut left := int(parsed.audio_start) - body
zeros := []u8{len: if left < 65536 { left } else { 65536 }}
for left > 0 {
n := if left < zeros.len { left } else { zeros.len }
f.write(zeros[..n]) or { return false }
left -= n
}
f.flush() f.flush()
return true return true
} }