From 095499b0fe334ce6a61fc0178d39bde6915a1c11 Mon Sep 17 00:00:00 2001 From: K1ndWha1e Date: Sun, 11 Oct 2026 18:50:55 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D1=82?= =?UTF-8?q?=D1=8D=D0=B3=D0=B0=20=D1=81=20=D0=BE=D0=B1=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9:=20=D1=81=D1=82=D0=B0=D1=80=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BB=D0=BE=D0=B6=D0=BA=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=D1=81=D1=8F,=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=BA=D0=BE=D0=BF=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_tag: ёмкость — только по переносимым фреймам (заменяемая обложка не в счёт), фрейм обложки пишется прямо в буфер тэга (write_apic) вместо двух промежуточных массивов - замена или удаление обложки: старый тэг читается по фреймам без APIC (parse_id3v2_lazy) - тест: перезапись с обложкой 3 МБ выделяет < 1,5 размера обложки (было 17 МБ, стало 3) Co-Authored-By: Claude Opus 5.5 --- metadata/audiobook.v | 76 +++++++++++++++++++++++++------------------ test/audiobook_test.v | 33 +++++++++++++++++++ 2 files changed, 78 insertions(+), 31 deletions(-) diff --git a/metadata/audiobook.v b/metadata/audiobook.v index a1efc8a..7cdd785 100644 --- a/metadata/audiobook.v +++ b/metadata/audiobook.v @@ -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) // Старый и новый тэг (с обложкой — мегабайты) сразу сборщику: при параллельной записи иначе // куча вырастает на несколько копий на поток и сборщик её системе не возвращает diff --git a/test/audiobook_test.v b/test/audiobook_test.v index adec371..c0100c3 100644 --- a/test/audiobook_test.v +++ b/test/audiobook_test.v @@ -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')