Files
id3_editor/test/audiobook_test.v
T
K1ndWha1eandClaude Opus 5.5 095499b0fe Запись тэга с обложкой: старая обложка не читается, новая копируется один раз
- build_tag: ёмкость — только по переносимым фреймам (заменяемая обложка не в счёт), фрейм
  обложки пишется прямо в буфер тэга (write_apic) вместо двух промежуточных массивов
- замена или удаление обложки: старый тэг читается по фреймам без APIC (parse_id3v2_lazy)
- тест: перезапись с обложкой 3 МБ выделяет < 1,5 размера обложки (было 17 МБ, стало 3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:50:55 +03:00

298 lines
10 KiB
V

import os
import metadata
const data_dir = os.join_path(@VMODROOT, 'data')
fn tmp_copy(name string) string {
dst := os.join_path(os.temp_dir(), 'id3_editor_${os.getpid()}_${name}')
os.cp(os.join_path(data_dir, name), dst) or { panic(err) }
return dst
}
fn test_read_v23() {
t := metadata.read_book_tags(os.join_path(data_dir, 'test_id3v23.mp3')) or { panic(err) }
assert t.version == 3
assert t.tags.title == 'Test Title'
assert t.tags.author == 'Test Author'
assert t.tags.album == 'Test Album'
assert t.tags.year == '2026'
assert t.tags.track == '6/7'
}
fn test_read_v24() {
t := metadata.read_book_tags(os.join_path(data_dir, 'test_id3v24.mp3')) or { panic(err) }
assert t.version == 4
assert t.tags.title == 'Test Title'
assert t.tags.year == '2026'
}
fn check_roundtrip(name string) {
path := tmp_copy(name)
defer {
os.rm(path) or {}
}
before := metadata.read_book_tags(path) or { panic(err) }
tags := metadata.BookTags{
title: 'Глава третья'
album: 'Тень ветра'
author: 'Карлос Руис Сафон'
narrator: 'Игорь Князев'
series: 'Кладбище забытых книг'
series_part: '1'
year: '2004'
track: '3/24'
genre: 'Аудиокнига'
comment: 'Даниель Семпере, сын букиниста…'
}
cover := metadata.Cover{
mime: 'image/jpeg'
data: [u8(0xFF), 0xD8, 0xFF, 0xE0, 1, 2, 3, 4]
}
metadata.write_book_tags(path, tags, cover: cover) or { panic(err) }
after := metadata.read_book_tags(path) or { panic(err) }
assert after.tags.title == tags.title
assert after.tags.album == tags.album
assert after.tags.author == tags.author
assert after.tags.narrator == tags.narrator
assert after.tags.series == tags.series
assert after.tags.series_part == tags.series_part
assert after.tags.year == tags.year
assert after.tags.track == tags.track
assert after.tags.genre == tags.genre
assert after.tags.comment == tags.comment
assert after.has_cover
// Аудио после тэга не должно измениться
assert after.audio_size == before.audio_size
c := metadata.read_cover(path) or { panic(err) }
assert c.data == cover.data
// Повторная запись без обложки сохраняет старую обложку
metadata.write_book_tags(path, metadata.BookTags{ ...tags, title: 'Пролог' }) or { panic(err) }
again := metadata.read_book_tags(path) or { panic(err) }
assert again.tags.title == 'Пролог'
assert again.has_cover
assert again.audio_size == before.audio_size
}
fn test_roundtrip_v23() {
check_roundtrip('test_id3v23.mp3')
}
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
}
}
// Замена обложки: в памяти только новый тэг — старая обложка не читается, новая копируется один раз
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')
defer {
os.rm(path) or {}
}
size := os.file_size(path)
metadata.write_book_tags(path, metadata.BookTags{ title: 'Глава 1', album: 'Книга' }) or {
panic(err)
}
after := metadata.read_book_tags(path) or { panic(err) }
assert after.version == 3
assert after.tags.title == 'Глава 1'
assert after.audio_size == size
}
// Тэг, размер которого больше файла, — ошибка, а не паника
fn test_broken_tag_is_error() {
path := os.join_path(os.temp_dir(), 'id3_editor_${os.getpid()}_broken.mp3')
os.write_file_array(path, [u8(`I`), `D`, `3`, 3, 0, 0, 0x7F, 0x7F, 0x7F, 0x7F, `T`, `I`]) or {
panic(err)
}
defer {
os.rm(path) or {}
}
metadata.read_book_tags(path) or {
assert err.msg() != ''
return
}
assert false, 'ошибка не возвращена'
}
// Права файла сохраняются после перезаписи
fn test_write_keeps_mode() {
path := tmp_copy('test_id3v23.mp3')
defer {
os.rm(path) or {}
}
$if !windows {
os.chmod(path, 0o640) or { panic(err) }
}
before := os.stat(path) or { panic(err) }
metadata.write_book_tags(path, metadata.BookTags{
title: 'Права'
}) or { panic(err) }
after := os.stat(path) or { panic(err) }
assert after.mode & 0o777 == before.mode & 0o777
assert after.uid == before.uid
}
// Файл остаётся тем же: Audiobookshelf опознаёт файлы по inode
fn test_write_keeps_inode() {
path := tmp_copy('test_id3v23.mp3')
defer {
os.rm(path) or {}
}
before := os.stat(path) or { panic(err) }
metadata.write_book_tags(path, metadata.BookTags{
title: 'Тот же файл'
comment: 'длинное описание '.repeat(500)
}) or { panic(err) }
after := os.stat(path) or { panic(err) }
$if !windows {
assert after.inode == before.inode
}
assert !os.exists(path + '.tagtmp')
t := metadata.read_book_tags(path) or { panic(err) }
assert t.tags.title == 'Тот же файл'
}
// Повторная запись: тэг помещается на место старого — переписывается только он, аудио не меняется
fn test_rewrite_header_only() {
path := tmp_copy('test_id3v23.mp3')
defer {
os.rm(path) or {}
}
original := os.read_bytes(path) or { panic(err) }
tail := 2048
audio := original[original.len - tail..].clone()
// Описание больше старого тэга — полная перезапись, в тэге остаётся запас
first := metadata.write_book_tags_report(path, metadata.BookTags{
title: 'Первая запись'
comment: 'описание '.repeat(300)
}) or { panic(err) }
assert first.in_place
assert !first.header
size := os.file_size(path)
before := os.stat(path) or { panic(err) }
// Правка в пределах запаса — только тэг
second := metadata.write_book_tags_report(path, metadata.BookTags{
title: 'Вторая запись, длиннее первой'
album: 'Книга'
comment: 'описание '.repeat(300) + 'и ещё немного'
}) or { panic(err) }
assert second.header
assert os.file_size(path) == size
after := os.stat(path) or { panic(err) }
assert after.inode == before.inode
t := metadata.read_book_tags(path) or { panic(err) }
assert t.tags.title == 'Вторая запись, длиннее первой'
assert t.tags.album == 'Книга'
assert t.tags.comment.ends_with('и ещё немного')
now := os.read_bytes(path) or { panic(err) }
assert now[now.len - tail..] == audio
// Не помещается — снова полная перезапись
third := metadata.write_book_tags_report(path, metadata.BookTags{
title: 'Третья'
comment: 'длинное описание '.repeat(1000)
}) or { panic(err) }
assert !third.header
t3 := metadata.read_book_tags(path) or { panic(err) }
assert t3.tags.title == 'Третья'
end := os.read_bytes(path) or { panic(err) }
assert end[end.len - tail..] == audio
}
// Испорченное название в ID3v2 («�») — берётся исходное из ID3v1 (cp1251)
fn test_broken_v2_title_falls_back_to_v1() {
path := tmp_copy('test_id3v23.mp3')
defer {
os.rm(path) or {}
}
metadata.write_book_tags(path, metadata.BookTags{
title: '\uFFFD\uFFFD\uFFFD 1'
album: 'Книга'
}) or { panic(err) }
// ID3v1 в конце файла: «Глава 1» в cp1251
mut v1 := []u8{len: 128}
v1[0] = `T`
v1[1] = `A`
v1[2] = `G`
title := [u8(0xc3), 0xeb, 0xe0, 0xe2, 0xe0, 0x20, 0x31]
for i, c in title {
v1[3 + i] = c
}
mut f := os.open_append(path) or { panic(err) }
f.write(v1) or { panic(err) }
f.close()
t := metadata.read_book_tags(path) or { panic(err) }
assert t.tags.title == 'Глава 1'
assert t.tags.album == 'Книга'
}