From 4c9184aeb2021b052a6eb579084fa5e7cbc4f702 Mon Sep 17 00:00:00 2001 From: K1ndWha1e Date: Sun, 11 Oct 2026 17:26:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A7=D1=82=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82?= =?UTF-8?q?=D1=8D=D0=B3=D0=BE=D0=B2=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=BA=D0=B8:=20=D1=81=20=D0=B4=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BD?= =?UTF-8?q?=D1=83=D0=B6=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=84=D1=80=D0=B5=D0=B9=D0=BC=D1=8B?= =?UTF-8?q?,=20=D1=83=20APIC=20=E2=80=94=20=D0=BB=D0=B8=D1=88=D1=8C=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B7=D0=BD=D0=B0=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_book_tags читал тэг целиком вместе с картинкой (у книг с обложкой 3 МБ — 3 МБ на файл при каждом открытии книги). Теперь фреймы читаются по одному, обложка и ненужные фреймы пропускаются; тэг с unsynchronisation (v2.3) — целиком, как раньше. Тест: на тэге с обложкой 3 МБ выделяется меньше 1 МБ, теги и has_cover те же. Co-Authored-By: Claude Opus 5.5 --- metadata/audiobook.v | 86 ++++++++++++++++++++++++++++++++++++++++--- test/audiobook_test.v | 33 +++++++++++++++++ 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/metadata/audiobook.v b/metadata/audiobook.v index ee85f78..a1efc8a 100644 --- a/metadata/audiobook.v +++ b/metadata/audiobook.v @@ -183,6 +183,81 @@ fn parse_id3v2(path string) !ParsedTag { return parsed } +// Фреймы, которые нужны чтению тэгов книги (read_book_tags) +const read_frames = ['TIT2', 'TALB', 'TPE1', 'TPE2', 'TCOM', 'TIT1', 'TRCK', 'TYER', 'TDRC', 'TCON', + 'TLAN', 'COMM', 'TXXX'] + +// Чтение для read_book_tags: с диска берутся только нужные текстовые фреймы, обложка и прочие фреймы +// пропускаются — у тэга с обложкой в мегабайты читается несколько КБ. Второе значение — есть ли обложка. +// Тэг с unsynchronisation (v2.3) по фреймам не прочитать — он читается целиком, как parse_id3v2 +fn parse_id3v2_texts(path string) !(ParsedTag, bool) { + mut f := os.open(path)! + defer { + f.close() + } + + mut head := []u8{len: 10} + n := f.read_bytes_into(0, mut head) or { 0 } + if n < 10 || head[..3].bytestr() != 'ID3' { + return ParsedTag{}, false + } + version := head[3] + flags := head[5] + if version !in [u8(3), 4] || (version == 3 && flags & 0x80 != 0) { + parsed := parse_id3v2(path)! + return parsed, parsed.frames.any(it.name == 'APIC') + } + + tag_size := get_size_v24(head[6..10]) + mut parsed := ParsedTag{ + version: version + audio_start: tag_size + 10 + if version == 4 && flags & 0x10 != 0 { u64(10) } else { 0 } + } + tag_end := tag_size + 10 + if tag_end > os.file_size(path) { + return error('размер тэга (${tag_size}) больше размера файла') + } + + mut pos := u64(10) + // Расширенный заголовок + if flags & 0x40 != 0 { + if f.read_bytes_into(pos, mut head)! < 4 { + return parsed, false + } + pos += if version == 3 { get_size_v23(head[..4]) + 4 } else { get_size_v24(head[..4]) } + } + + mut has_cover := false + for pos + 10 <= tag_end { + if f.read_bytes_into(pos, mut head)! < 10 || head[0] == 0 { + break + } + id := head[..4].bytestr() + if !is_frame_id(id) { + break + } + size := if version == 3 { get_size_v23(head[4..8]) } else { get_size_v24(head[4..8]) } + end := pos + 10 + size + if end > tag_end { + break + } + if id == 'APIC' { + // Как в parse_id3v2: слишком короткий APIC — не обложка + has_cover = has_cover || size >= 14 + } else if size > 0 && id in read_frames { + // Каждый нужный фрейм — в свой маленький буфер + mut buf := []u8{len: int(size + 10)} + if f.read_bytes_into(pos, mut buf)! < buf.len { + break + } + parsed.frames << Frame{} + parsed.frames[parsed.frames.len - 1].fill(buf, 0, version) + } + pos = end + } + return parsed, has_cover +} + @[inline] fn is_frame_id(id string) bool { for c in id { @@ -295,18 +370,20 @@ fn (frame Frame) apic_value() (string, u8, []u8) { // Чтение тегов файла. Если ID3v2 нет — смотрим ID3v1 fn read_book_tags_impl(path string) !FileTags { - mut parsed := parse_id3v2(path)! + // Обложка не читается — только отмечается, есть ли она + mut parsed, has_cover := parse_id3v2_texts(path)! // Тексты уже декодированы в свои строки — байты тэга больше не нужны defer { unsafe { parsed.raw.free() } } mut res := FileTags{ - version: parsed.version + version: parsed.version + has_cover: has_cover } size := os.file_size(path) res.audio_size = if size > parsed.audio_start { size - parsed.audio_start } else { 0 } - if parsed.frames.len == 0 { + if parsed.frames.len == 0 && !has_cover { res.tags = read_v1(path) or { BookTags{} } return res } @@ -348,9 +425,6 @@ fn read_book_tags_impl(path string) !FileTags { else {} } } - 'APIC' { - res.has_cover = true - } else {} } } diff --git a/test/audiobook_test.v b/test/audiobook_test.v index 6a31413..3c07a2f 100644 --- a/test/audiobook_test.v +++ b/test/audiobook_test.v @@ -82,6 +82,39 @@ fn test_roundtrip_v24() { check_roundtrip('test_id3v24.mp3') } +// Чтение тэгов не загружает обложку: у тэга с картинкой 3 МБ — только текстовые фреймы +fn test_read_skips_cover() { + for name in ['test_id3v23.mp3', 'test_id3v24.mp3'] { + path := tmp_copy(name) + defer { + os.rm(path) or {} + } + mut data := []u8{len: 3 * 1024 * 1024, init: u8(index % 251)} + data[0] = 0xFF + data[1] = 0xD8 + tags := metadata.BookTags{ + title: 'Глава 1' + album: 'Тень ветра' + author: 'Сафон' + series: 'Кладбище забытых книг' + } + metadata.write_book_tags(path, tags, cover: metadata.Cover{ + mime: 'image/jpeg' + data: data + }) or { panic(err) } + before := u64(gc_heap_usage().total_bytes) + t := metadata.read_book_tags(path) or { panic(err) } + allocated := u64(gc_heap_usage().total_bytes) - before + assert t.has_cover + assert t.tags.title == 'Глава 1' + assert t.tags.album == 'Тень ветра' + assert t.tags.series == 'Кладбище забытых книг' + assert allocated < 1024 * 1024, '${name}: при чтении выделено ${allocated} Б' + // Обложку по-прежнему отдаёт read_cover + assert (metadata.read_cover(path) or { panic(err) }).data.len == data.len + } +} + // В файл без ID3v2 тэг добавляется в начало fn test_write_without_id3v2() { path := tmp_copy('test_id3v1.mp3')