*добавлен API для аудиокниг (metadata/audiobook.v): TCOM, TIT1, COMM, TXXX, APIC, запись в файл без ID3v2, cp1251, потоковая перезапись

*публичные функции перехватывают панику (recover) и возвращают ошибку, проверка размера тэга
*совместимость с V master: iconv больше не обрезает NUL, у Frame убран @[heap] (баг кодогенерации)
*тесты test/audiobook_test.v

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
K1ndWha1eandClaude Opus 5.5 committed 2026-10-08 15:17:53 +03:00
1 parent 078e68efa8
commit 8a03ad3cd4
5 files changed
+791 -7

No files matched your search

+5 -3
View File
@@ -1,8 +1,10 @@
- [x] Извлечение ID3 v1 - [x] Извлечение ID3 v1
- [x] Запись ID3 v1 - [x] Запись ID3 v1
- [x] Извлечение ID3 v2.3 - [x] Извлечение ID3 v2.3
- [ ] Запись ID3 v2.3 - [x] Запись ID3 v2.3
- [x] Извлечение ID3 v2.4 - [x] Извлечение ID3 v2.4
- [ ] Запись ID3 v2.4 - [x] Запись ID3 v2.4
- [x] Конвертация из ID3 v1 в v2.3 и обратно - [x] Конвертация из ID3 v1 в v2.3 и обратно
1 1
- [x] API для аудиокниг (metadata/audiobook.v): TCOM, TIT1, COMM, TXXX, APIC, файлы без ID3v2, cp1251
- [x] Совместимость с V master (iconv сохраняет NUL, баг @[heap] в массивах)
+667
View File
@@ -0,0 +1,667 @@
module metadata
// Высокоуровневый API для аудиокниг (используется веб-редактором «Фонотека»):
// чтение всех фреймов списком, COMM/TXXX/APIC, запись в файл без ID3v2,
// сохранение незнакомых фреймов, cp1251 в «латинских» фреймах и потоковая перезапись файла.
// Публичные функции перехватывают панику (recover) на битых файлах и возвращают ошибку.
import os
import encoding.iconv
// Теги аудиокниги в раскладке, которую понимает Audiobookshelf
pub struct BookTags {
pub mut:
// TIT2 — название главы (файла)
title string
// TALB — название книги
album string
// TPE1 + TPE2 — автор
author string
// TCOM — чтец
narrator string
// TIT1 + TXXX:series — серия
series string
// TXXX:series-part — номер в серии
series_part string
// TYER (v2.3) / TDRC (v2.4)
year string
// TRCK — "3/24"
track string
// TCON
genre string
// COMM — описание
comment string
// TLAN
language string
}
pub struct Cover {
pub mut:
mime string
data []u8
}
// Сводка по тегам файла для API
pub struct FileTags {
pub mut:
tags BookTags
version u8
has_cover bool
// Размер аудио без тега
audio_size u64
}
@[params]
pub struct WriteOptions {
pub:
// Новая обложка; none — оставить как есть
cover ?Cover
// Удалить обложку
remove_cover bool
}
struct ParsedTag {
mut:
version u8
frames []Frame
audio_start u64
}
// Фреймы, которые редактор пересобирает сам, остальные переносятся без изменений
const managed_frames = ['TIT2', 'TALB', 'TPE1', 'TPE2', 'TCOM', 'TIT1', 'TRCK', 'TYER', 'TDRC',
'TCON', 'COMM', 'TLAN']
// Пользовательские TXXX, которые пересобираются
const managed_txxx = ['series', 'series-part']
// Чтение ID3v2 целиком: версия, список фреймов, начало аудио
fn parse_id3v2(path string) !ParsedTag {
mut f := os.open(path)!
defer {
f.close()
}
mut header := []u8{len: 10}
n := f.read_bytes_into(0, mut header) or { 0 }
if n < 10 || header[..3].bytestr() != 'ID3' {
return ParsedTag{}
}
version := header[3]
flags := header[5]
tag_size := get_size_v24(header[6..10])
mut audio_start := tag_size + 10
if version == 4 && flags & 0x10 != 0 {
audio_start += 10
}
mut parsed := ParsedTag{
version: version
audio_start: audio_start
}
// v2.2 и раньше не разбираем: тег будет создан заново в v2.3
if version !in [u8(3), 4] {
parsed.version = 0
return parsed
}
// Повреждённый заголовок не должен заставлять читать (и выделять) больше, чем есть в файле
if tag_size + 10 > os.file_size(path) {
return error('размер тэга (${tag_size}) больше размера файла')
}
mut bytes := []u8{len: int(tag_size + 10)}
f.read_bytes_into(0, mut bytes)!
// Unsynchronisation всего тэга в v2.3: 0xFF 0x00 -> 0xFF
if version == 3 && flags & 0x80 != 0 {
mut clean := []u8{cap: bytes.len}
for i := 0; i < bytes.len; i++ {
clean << bytes[i]
if bytes[i] == 0xFF && i + 1 < bytes.len && bytes[i + 1] == 0 {
i++
}
}
bytes = unsafe { clean }
}
mut pos := u64(10)
// Расширенный заголовок
if flags & 0x40 != 0 && bytes.len >= 14 {
pos += if version == 3 {
get_size_v23(bytes[10..14]) + 4
} else {
get_size_v24(bytes[10..14])
}
}
tag_end := u64(bytes.len)
for pos + 10 <= tag_end {
if bytes[pos] == 0 {
break
}
id := bytes[pos..pos + 4].bytestr()
if !is_frame_id(id) {
break
}
size := if version == 3 {
get_size_v23(bytes[pos + 4..pos + 8])
} else {
get_size_v24(bytes[pos + 4..pos + 8])
}
end := pos + 10 + size
if end > tag_end {
break
}
// Пустые и слишком короткие фреймы Frame.fill не переварит
if size == 0 || (id == 'APIC' && size < 14) {
pos = end
continue
}
mut frame := Frame{}
frame.fill(bytes, pos, version)
parsed.frames << frame
pos = end
}
return parsed
}
@[inline]
fn is_frame_id(id string) bool {
for c in id {
if !((c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`)) {
return false
}
}
return true
}
// Тело фрейма без 10 байт заголовка
@[inline]
fn (frame Frame) body() []u8 {
return if frame.raw.len > 10 { frame.raw[10..] } else { []u8{} }
}
// Декодирование текста по байту кодировки ID3.
// В ISO-8859-1 русские тэги почти всегда на самом деле cp1251
fn decode_text(enc u8, bytes []u8) string {
mut text := match enc {
0 {
if bytes.any(it >= 0x80) {
iconv.encoding_to_vstring(bytes, 'CP1251') or { bytes.bytestr() }
} else {
bytes.bytestr()
}
}
1 {
if bytes.len >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
iconv.encoding_to_vstring(bytes[2..], 'UTF-16BE') or { '' }
} else if bytes.len >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
iconv.encoding_to_vstring(bytes[2..], 'UTF-16LE') or { '' }
} else {
iconv.encoding_to_vstring(bytes, 'UTF-16LE') or { '' }
}
}
2 {
iconv.encoding_to_vstring(bytes, 'UTF-16BE') or { '' }
}
else {
bytes.bytestr()
}
}
text = text.trim_right('\x00').replace('\x00', ' / ')
return text.trim_space()
}
// Делит байты по первому терминатору строки (0 или 00 00 для UTF-16)
fn split_terminated(enc u8, bytes []u8) ([]u8, []u8) {
if enc == 1 || enc == 2 {
for i := 0; i + 1 < bytes.len; i += 2 {
if bytes[i] == 0 && bytes[i + 1] == 0 {
return bytes[..i], bytes[i + 2..]
}
}
return bytes, []u8{}
}
idx := bytes.index(0)
if idx < 0 {
return bytes, []u8{}
}
return bytes[..idx], bytes[idx + 1..]
}
// Текстовый фрейм T***
fn (frame Frame) text_value() string {
b := frame.body()
if b.len < 1 {
return ''
}
return decode_text(b[0], b[1..])
}
// TXXX: описание и значение
fn (frame Frame) txxx_value() (string, string) {
b := frame.body()
if b.len < 1 {
return '', ''
}
desc, value := split_terminated(b[0], b[1..])
return decode_text(b[0], desc), decode_text(b[0], value)
}
// COMM: язык, описание, текст
fn (frame Frame) comm_value() (string, string) {
b := frame.body()
if b.len < 4 {
return '', ''
}
desc, text := split_terminated(b[0], b[4..])
return decode_text(b[0], desc), decode_text(b[0], text)
}
// APIC: mime, тип картинки, данные
fn (frame Frame) apic_value() (string, u8, []u8) {
b := frame.body()
if b.len < 4 {
return '', 0, []u8{}
}
enc := b[0]
mime_end := b[1..].index(0)
if mime_end < 0 {
return '', 0, []u8{}
}
mime := b[1..1 + mime_end].bytestr()
pic_type := b[1 + mime_end + 1] or { 0 }
_, data := split_terminated(enc, b[1 + mime_end + 2..])
return mime, pic_type, data
}
// Чтение тегов файла. Если ID3v2 нет — смотрим ID3v1
fn read_book_tags_impl(path string) !FileTags {
parsed := parse_id3v2(path)!
mut res := FileTags{
version: parsed.version
}
size := os.file_size(path)
res.audio_size = if size > parsed.audio_start { size - parsed.audio_start } else { 0 }
if parsed.frames.len == 0 {
res.tags = read_v1(path) or { BookTags{} }
return res
}
mut t := BookTags{}
mut tit1 := ''
for frame in parsed.frames {
match frame.name {
'TIT2' { t.title = frame.text_value() }
'TALB' { t.album = frame.text_value() }
'TPE1' { t.author = frame.text_value() }
'TPE2' {
if t.author == '' {
t.author = frame.text_value()
}
}
'TCOM' { t.narrator = frame.text_value() }
'TIT1' { tit1 = frame.text_value() }
'TRCK' { t.track = frame.text_value() }
'TYER', 'TDRC' {
if t.year == '' {
t.year = frame.text_value()
}
}
'TCON' { t.genre = clean_genre(frame.text_value()) }
'TLAN' { t.language = frame.text_value() }
'COMM' {
desc, text := frame.comm_value()
// Технические комментарии iTunes пропускаем
if !desc.starts_with('iTun') && (t.comment == '' || desc == '') {
t.comment = text
}
}
'TXXX' {
desc, value := frame.txxx_value()
match desc.to_lower() {
'series', 'mvnm' { t.series = value }
'series-part', 'mvin' { t.series_part = value }
else {}
}
}
'APIC' {
res.has_cover = true
}
else {}
}
}
if t.series == '' {
t.series = tit1
}
// TPE1 мог быть не задан, тогда автор остаётся из TPE2
res.tags = t
return res
}
// "(101)" и "(101)Speech" из старых тэгов превращаем в название
fn clean_genre(g string) string {
if g.starts_with('(') && g.contains(')') {
rest := g.all_after(')')
if rest != '' {
return rest
}
num := g.all_before(')').trim_left('(').int()
genre := unsafe { Genre(u8(num)) }
return genre.str()
}
return g
}
fn read_v1(path string) !BookTags {
mut f := os.open(path)!
defer {
f.close()
}
size := os.file_size(path)
if size < 128 {
return error('нет тэга')
}
mut last := []u8{len: 128}
f.read_bytes_into(size - 128, mut last)!
if last[..3].bytestr() != 'TAG' {
return error('нет тэга')
}
mut v1 := ID3v1{}
v1.get_metadata(last)
return BookTags{
title: v1.title.trim('\x00 ')
author: v1.artist.trim('\x00 ')
album: v1.album.trim('\x00 ')
year: v1.year.trim('\x00 ')
track: v1.track
comment: v1.comment.trim('\x00 ')
}
}
// Обложка из APIC (предпочтительно тип 3 — front cover)
fn read_cover_impl(path string) !Cover {
parsed := parse_id3v2(path)!
mut found := ?Cover(none)
for frame in parsed.frames {
if frame.name != 'APIC' {
continue
}
mime, pic_type, data := frame.apic_value()
if data.len == 0 {
continue
}
c := Cover{
mime: normalize_mime(mime, data)
data: data
}
if pic_type == 3 {
return c
}
if found == none {
found = c
}
}
return found or { error('обложки нет') }
}
fn normalize_mime(mime string, data []u8) string {
if mime.contains('/') {
return mime
}
if data.len > 3 && data[0] == 0x89 && data[1] == `P` {
return 'image/png'
}
return 'image/jpeg'
}
// Заголовок фрейма: ID, размер, пустые флаги
fn frame_header(id string, size int, version u8) []u8 {
mut h := id.bytes()
if version == 4 {
h << encode_header_size(u64(size))
} else {
h << [u8(size >> 24), u8(size >> 16), u8(size >> 8), u8(size)]
}
h << [u8(0), 0]
return h
}
// Байт кодировки для Юникода и кодированная строка с терминатором
fn encode_unicode(s string, version u8, terminate bool) []u8 {
if version == 4 {
mut b := s.bytes()
if terminate {
b << 0
}
return b
}
mut b := [u8(0xFF), 0xFE]
b << iconv.vstring_to_encoding(s, 'UTF-16LE') or { []u8{} }
if terminate {
b << [u8(0), 0]
}
return b
}
@[inline]
fn unicode_enc_byte(version u8) u8 {
return if version == 4 { u8(3) } else { u8(1) }
}
// Простой текстовый фрейм — через кодировщик из id3_editor
fn text_frame(id string, value string, version u8) []u8 {
mut frame := Frame{}
frame.init(id, version)
frame.data = value
frame.modified = true
return frame.get_encoded_frame_tag(version)
}
fn txxx_frame(desc string, value string, version u8) []u8 {
mut body := [unicode_enc_byte(version)]
body << encode_unicode(desc, version, true)
body << encode_unicode(value, version, false)
mut out := frame_header('TXXX', body.len, version)
out << body
return out
}
fn comm_frame(text string, lang string, version u8) []u8 {
mut body := [unicode_enc_byte(version)]
body << (if lang.len == 3 { lang.bytes() } else { 'rus'.bytes() })
// Пустое описание
body << encode_unicode('', version, true)
body << encode_unicode(text, version, false)
mut out := frame_header('COMM', body.len, version)
out << body
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
}
fn is_managed(frame Frame) bool {
if frame.name in managed_frames {
return true
}
if frame.name == 'TXXX' {
desc, _ := frame.txxx_value()
return desc.to_lower() in managed_txxx
}
return false
}
// Сборка тэга целиком (заголовок + фреймы + запас нулей)
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{}
// Порядок как в большинстве редакторов: текстовые сверху, картинка в конце
pairs := [
['TIT2', t.title],
['TALB', t.album],
['TPE1', t.author],
['TPE2', t.author],
['TCOM', t.narrator],
['TIT1', t.series],
['TRCK', t.track],
[if version == 4 { 'TDRC' } else { 'TYER' }, t.year],
['TCON', t.genre],
['TLAN', t.language],
]
for p in pairs {
if p[1].trim_space() != '' {
frames << text_frame(p[0], p[1].trim_space(), version)
}
}
if t.series.trim_space() != '' {
frames << txxx_frame('series', t.series.trim_space(), version)
}
if t.series_part.trim_space() != '' {
frames << txxx_frame('series-part', t.series_part.trim_space(), version)
}
if t.comment.trim_space() != '' {
frames << comm_frame(t.comment.trim_space(), 'rus', version)
}
new_cover := opts.cover
for frame in parsed.frames {
if is_managed(frame) {
continue
}
if frame.name == 'APIC' && (new_cover != none || opts.remove_cover) {
continue
}
frames << frame.raw
}
if c := new_cover {
frames << apic_frame(c, version)
}
frames << []u8{len: tag_padding}
mut out := [u8(`I`), `D`, `3`, version, 0, 0]
out << encode_header_size(u64(frames.len))
out << frames
return out
}
// Запись тэгов: новый тэг + аудио из исходного файла, через временный файл.
// Файл без ID3v2 тоже поддерживается — тэг просто ставится в начало
fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) ! {
parsed := parse_id3v2(path)!
tag := build_tag(parsed, t, opts)
tmp_path := path + '.tagtmp'
mut src := os.open(path)!
mut dst := os.create(tmp_path)!
mut ok := false
defer {
src.close()
dst.close()
if !ok {
os.rm(tmp_path) or {}
}
}
dst.write(tag)!
src.seek(i64(parsed.audio_start), .start)!
mut buf := []u8{len: 1024 * 1024}
for {
n := src.read(mut buf) or { 0 }
if n <= 0 {
break
}
dst.write(buf[..n])!
}
src.close()
dst.close()
ok = true
os.mv(tmp_path, path, overwrite: true)!
}
// ---------- публичный API с защитой от паники ----------
// Сообщение перехваченной паники
struct PanicBox {
mut:
msg string
}
fn (b PanicBox) error() IError {
return error('повреждённый тэг: ${b.msg}')
}
// Тэги файла. Паника на повреждённом файле превращается в ошибку
pub fn read_book_tags(path string) !FileTags {
mut box := PanicBox{}
res := guard_read_tags(path, mut box) or { return if box.msg != '' { box.error() } else { err } }
if box.msg != '' {
return box.error()
}
return res
}
// Обложка из APIC
pub fn read_cover(path string) !Cover {
mut box := PanicBox{}
res := guard_read_cover(path, mut box) or { return if box.msg != '' { box.error() } else { err } }
if box.msg != '' {
return box.error()
}
return res
}
// Запись тэгов книги в файл
pub fn write_book_tags(path string, t BookTags, opts WriteOptions) ! {
mut box := PanicBox{}
guard_write_tags(path, t, opts, mut box) or {
return if box.msg != '' {
box.error()
} else {
err
}
}
if box.msg != '' {
return box.error()
}
}
fn guard_read_tags(path string, mut box PanicBox) !FileTags {
defer {
if msg := recover() {
box.msg = msg
}
}
return read_book_tags_impl(path)!
}
fn guard_read_cover(path string, mut box PanicBox) !Cover {
defer {
if msg := recover() {
box.msg = msg
}
}
return read_cover_impl(path)!
}
fn guard_write_tags(path string, t BookTags, opts WriteOptions, mut box PanicBox) ! {
defer {
if msg := recover() {
box.msg = msg
}
}
write_book_tags_impl(path, t, opts)!
}
+2 -2
View File
@@ -2,7 +2,7 @@ module metadata
import encoding.iconv import encoding.iconv
@[heap] // Без @[heap]: на V master `tag.others << frame` для heap-структуры кладёт в []Frame указатель
pub struct Frame { pub struct Frame {
pub mut: pub mut:
data string data string
@@ -71,7 +71,7 @@ fn (mut frame Frame) fill(bytes []u8, start_position u64, idv3_version u8) {
} }
// Замыкающие нули не относятся к данным // Замыкающие нули не относятся к данным
frame.data = (iconv.encoding_to_vstring(bytes[data_start..frame.end], frame.encoding_name) or { "" }).trim_right('\x00') frame.data = (iconv.encoding_to_vstring(bytes[data_start..frame.end], frame.encoding_name) or { "" }).all_before('\x00')
frame.addition = bytes[frame.start..frame.start + 10] frame.addition = bytes[frame.start..frame.start + 10]
} }
+2 -2
View File
@@ -9,7 +9,7 @@ import utils
@[inline] @[inline]
fn decode_bytes(bytes []u8) string { fn decode_bytes(bytes []u8) string {
format := if utils.is_utf8(bytes) { "UTF-8" } else { "CP1251" } format := if utils.is_utf8(bytes) { "UTF-8" } else { "CP1251" }
text := iconv.encoding_to_vstring(bytes, format) or { "Can't decode" } text := (iconv.encoding_to_vstring(bytes, format) or { "Can't decode" }).all_before('\x00')
return text return text
} }
@@ -106,7 +106,7 @@ pub fn (mut id3v1 ID3v1) insert(mut audio File, position i8) []u8 {
// Положение указателя // Положение указателя
size := audio.tell() or { panic(err) } size := audio.tell() or { panic(err) }
// Чтение всех байта, кроме последних 128 // Чтение всех байта, кроме последних 128
mut all_bytes := audio.read_bytes(int(size))#[..position] mut all_bytes := audio.read_bytes(int(size))#[..position].clone()
all_bytes << bytes_tag all_bytes << bytes_tag
// } // }
+115
View File
@@ -0,0 +1,115 @@
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')
}
// В файл без 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, 'ошибка не возвращена'
}