Files
id3_editor/metadata/audiobook.v
T
K1ndWha1eandClaude Opus 5.5 4c9184aeb2 Чтение тэгов без обложки: с диска только нужные текстовые фреймы, у APIC — лишь признак
read_book_tags читал тэг целиком вместе с картинкой (у книг с обложкой 3 МБ — 3 МБ на файл
при каждом открытии книги). Теперь фреймы читаются по одному, обложка и ненужные фреймы
пропускаются; тэг с unsynchronisation (v2.3) — целиком, как раньше. Тест: на тэге с обложкой
3 МБ выделяется меньше 1 МБ, теги и has_cover те же.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:26:44 +03:00

899 lines
26 KiB
V
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// Как прошла запись: на месте (тот же файл) или новым файлом поверх старого
pub struct WriteReport {
pub:
in_place bool
// Переписан только тэг в начале файла (новый поместился на место старого), аудио не тронуто
header bool
// Почему не удалось переписать на месте (пусто, если на месте)
fallback string
}
struct ParsedTag {
mut:
version u8
frames []Frame
audio_start u64
// Байты тэга из файла: фреймы — срезы этого буфера. После записи / чтения — free(), чтобы тэг
// с обложкой в несколько МБ не оседал в куче (при параллельной записи — по копии на поток)
raw []u8
}
// Запас в конце тэга при полной перезаписи: следующие правки (длиннее описание, другие названия)
// поместятся на место старого тэга, и аудио переписывать не придётся
const book_tag_padding = 4096
// Фреймы, которые редактор пересобирает сам, остальные переносятся без изменений
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 }
}
parsed.raw = bytes
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
}
// Фрейм заполняется уже в массиве: `parsed.frames << frame` скопировал бы его срезы буфера
// (V клонирует срезы при копировании структуры в массив) — у обложки это ещё два её размера
parsed.frames << Frame{}
parsed.frames[parsed.frames.len - 1].fill(bytes, pos, version)
pos = end
}
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 {
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 {
// Обложка не читается — только отмечается, есть ли она
mut parsed, has_cover := parse_id3v2_texts(path)!
// Тексты уже декодированы в свои строки — байты тэга больше не нужны
defer {
unsafe { parsed.raw.free() }
}
mut res := FileTags{
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 && !has_cover {
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 {}
}
}
else {}
}
}
if t.series == '' {
t.series = tit1
}
// Пустые или испорченные («�») поля — из ID3v1: старые файлы часто хранят там исходный текст в cp1251,
// а в ID3v2 он мог попасть уже испорченным (прочитан не в той кодировке и записан обратно)
if needs_v1(t.title) || needs_v1(t.album) || needs_v1(t.author) {
if v1 := read_v1(path) {
if needs_v1(t.title) && !needs_v1(v1.title) {
t.title = v1.title
}
if needs_v1(t.album) && !needs_v1(v1.album) {
t.album = v1.album
}
if needs_v1(t.author) && !needs_v1(v1.author) {
t.author = v1.author
}
}
}
// TPE1 мог быть не задан, тогда автор остаётся из TPE2
res.tags = t
return res
}
fn needs_v1(s string) bool {
return s.trim_space() == '' || s.contains('\uFFFD')
}
// "(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 capacity := 10 + book_tag_padding + 4096 + t.comment.len * 2
for frame in parsed.frames {
capacity += frame.raw.len
}
if c := opts.cover {
capacity += c.data.len + 64
}
mut frames := []u8{cap: capacity}
frames << [u8(`I`), `D`, `3`, version, 0, 0, 0, 0, 0, 0]
// Порядок как в большинстве редакторов: текстовые сверху, картинка в конце
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: book_tag_padding}
// Размер тэга (без 10 байт заголовка) — на своё место в заголовке
size := encode_header_size(u64(frames.len - 10))
for i in 0 .. 4 {
frames[6 + i] = size[i]
}
return frames
}
// Запись тэгов: новый тэг + аудио из исходного файла собираются во временном файле,
// затем копируются обратно в исходный. Файл остаётся тем же (inode, владелец, права):
// Audiobookshelf опознаёт файлы по inode, и замена файла новым путает его библиотеку.
// Файл без ID3v2 тоже поддерживается — тэг просто ставится в начало
fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) !WriteReport {
mut parsed := parse_id3v2(path)!
mut tag := build_tag(parsed, t, opts)
// Старый и новый тэг (с обложкой — мегабайты) сразу сборщику: при параллельной записи иначе
// куча вырастает на несколько копий на поток и сборщик её системе не возвращает
defer {
unsafe {
parsed.raw.free()
tag.free()
}
}
// Новый тэг помещается на место старого — переписать только его, аудио не трогать
if tag_fits(parsed, tag) {
if write_header(path, parsed, tag) {
return WriteReport{
in_place: true
header: true
}
}
}
tmp_path := path + '.tagtmp'
mut buf := []u8{len: 1024 * 1024}
defer {
unsafe { buf.free() }
}
{
mut src := os.open(path)!
defer {
src.close()
}
mut dst := os.create(tmp_path)!
defer {
dst.close()
}
dst.write(tag) or {
os.rm(tmp_path) or {}
return err
}
src.seek(i64(parsed.audio_start), .start)!
for {
n := src.read(mut buf) or { 0 }
if n <= 0 {
break
}
dst.write(buf[..n]) or {
os.rm(tmp_path) or {}
return err
}
}
}
copy_back(tmp_path, path, mut buf) or {
// Исходный файл мог остаться наполовину переписанным — полная копия во временном
reason := err.msg()
orig := os.stat(path) or { os.Stat{} }
os.mv(tmp_path, path, overwrite: true) or {
return error('${err}; новое содержимое сохранено в ${tmp_path}')
}
if orig.mode != 0 {
os.chmod(path, int(orig.mode & 0o7777)) or {}
$if !windows {
os.chown(path, int(orig.uid), int(orig.gid)) or {}
}
}
return WriteReport{
fallback: reason
}
}
os.rm(tmp_path) or {}
return WriteReport{
in_place: true
}
}
// Новый тэг (без запаса) помещается на место старого
fn tag_fits(parsed ParsedTag, tag []u8) bool {
if parsed.version !in [u8(3), 4] || parsed.audio_start < 10 {
return false
}
body := tag.len - book_tag_padding
return body >= 10 && u64(body) <= parsed.audio_start
}
// Перезапись начала файла тем же числом байт: тэг без запаса, размер в заголовке — как у старого
// тэга, остаток до начала аудио — нулями. Пишется прямо из буфера тэга, без копии;
// false — файл не открылся на запись
fn write_header(path string, parsed ParsedTag, tag []u8) bool {
body := tag.len - book_tag_padding
mut f := os.open_file(path, 'r+') or { return false }
defer {
f.close()
}
f.write(tag[..6]) or { return false }
f.write(encode_header_size(parsed.audio_start - 10)) or { return false }
f.write(tag[10..body]) or { return false }
mut left := int(parsed.audio_start) - body
zeros := []u8{len: if left < 65536 { left } else { 65536 }}
for left > 0 {
n := if left < zeros.len { left } else { zeros.len }
f.write(zeros[..n]) or { return false }
left -= n
}
f.flush()
return true
}
// Содержимое from — в существующий файл to (с обрезкой), не заменяя сам файл
fn copy_back(from string, to string, mut buf []u8) ! {
mut src := os.open(from)!
defer {
src.close()
}
mut dst := os.open_file(to, 'w')!
defer {
dst.close()
}
for {
n := src.read(mut buf) or { 0 }
if n <= 0 {
break
}
dst.write(buf[..n])!
}
dst.flush()
}
// ---------- публичный 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) ! {
write_book_tags_report(path, t, opts)!
}
// То же, что write_book_tags, плюс отчёт: переписан ли файл на месте
pub fn write_book_tags_report(path string, t BookTags, opts WriteOptions) !WriteReport {
mut box := PanicBox{}
report := guard_write_tags(path, t, opts, mut box) or {
return if box.msg != '' {
box.error()
} else {
err
}
}
if box.msg != '' {
return box.error()
}
return report
}
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) !WriteReport {
defer {
if msg := recover() {
box.msg = msg
}
}
return write_book_tags_impl(path, t, opts)!
}