Меньше памяти на тэгах с обложкой: фрейм заполняется прямо в массиве, тэг собирается в буфер нужного размера, «только тэг» пишется без копии, буферы — 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
frames []Frame
audio_start u64
// Байты тэга из файла: фреймы — срезы этого буфера. После записи / чтения — free(), чтобы тэг
// с обложкой в несколько МБ не оседал в куче (при параллельной записи — по копии на поток)
raw []u8
}
// Запас в конце тэга при полной перезаписи: следующие правки (длиннее описание, другие названия)
@@ -136,6 +139,7 @@ fn parse_id3v2(path string) !ParsedTag {
}
bytes = unsafe { clean }
}
parsed.raw = bytes
mut pos := u64(10)
// Расширенный заголовок
@@ -170,9 +174,10 @@ fn parse_id3v2(path string) !ParsedTag {
pos = end
continue
}
mut frame := Frame{}
frame.fill(bytes, pos, version)
parsed.frames << frame
// Фрейм заполняется уже в массиве: `parsed.frames << frame` скопировал бы его срезы буфера
// (V клонирует срезы при копировании структуры в массив) — у обложки это ещё два её размера
parsed.frames << Frame{}
parsed.frames[parsed.frames.len - 1].fill(bytes, pos, version)
pos = end
}
return parsed
@@ -290,7 +295,11 @@ fn (frame Frame) apic_value() (string, u8, []u8) {
// Чтение тегов файла. Если ID3v2 нет — смотрим ID3v1
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{
version: parsed.version
}
@@ -539,7 +548,17 @@ fn is_managed(frame Frame) bool {
// Сборка тэга целиком (заголовок + фреймы + запас нулей)
fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
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 := [
@@ -585,10 +604,12 @@ fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
frames << []u8{len: book_tag_padding}
mut out := [u8(`I`), `D`, `3`, version, 0, 0]
out << encode_header_size(u64(frames.len))
out << frames
return out
// Размер тэга (без 10 байт заголовка) — на своё место в заголовке
size := encode_header_size(u64(frames.len - 10))
for i in 0 .. 4 {
frames[6 + i] = size[i]
}
return frames
}
// Запись тэгов: новый тэг + аудио из исходного файла собираются во временном файле,
@@ -596,12 +617,20 @@ 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 {
parsed := parse_id3v2(path)!
tag := build_tag(parsed, t, opts)
mut parsed := parse_id3v2(path)!
mut tag := build_tag(parsed, t, opts)
// Старый и новый тэг (с обложкой — мегабайты) сразу сборщику: при параллельной записи иначе
// куча вырастает на несколько копий на поток и сборщик её системе не возвращает
defer {
unsafe {
parsed.raw.free()
tag.free()
}
}
// Новый тэг помещается на место старого — переписать только его, аудио не трогать
if fitted := fit_tag(parsed, tag) {
if write_header(path, fitted) {
if tag_fits(parsed, tag) {
if write_header(path, parsed, tag) {
return WriteReport{
in_place: true
header: true
@@ -611,6 +640,9 @@ fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) !WriteReport
tmp_path := path + '.tagtmp'
mut buf := []u8{len: 1024 * 1024}
defer {
unsafe { buf.free() }
}
{
mut src := os.open(path)!
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 {
return none
return false
}
body := tag.len - book_tag_padding
if 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
return body >= 10 && u64(body) <= parsed.audio_start
}
// Перезапись начала файла тем же числом байт; 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 }
defer {
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()
return true
}