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 } } // В файл без 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 == 'Книга' }