Files
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

913 lines
27 KiB
V
Raw Permalink 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']
// Чтение тэга по фреймам без обложки: картинка в мегабайты с диска не читается, второе значение —
// есть ли она. texts_only — только текстовые фреймы для read_book_tags (несколько КБ на файл),
// иначе все фреймы, кроме обложки, — для записи, когда обложка заменяется или удаляется.
// Тэг с unsynchronisation (v2.3) по фреймам не прочитать — он читается целиком, как parse_id3v2
fn parse_id3v2_lazy(path string, texts_only bool) !(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 && (!texts_only || 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_lazy(path, true)!
// Тексты уже декодированы в свои строки — байты тэга больше не нужны
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
}
// Фрейм обложки — сразу в конец out: картинка в мегабайты копируется один раз, без промежуточных буферов
fn write_apic(mut out []u8, cover Cover, version u8) {
mime := normalize_mime(cover.mime, cover.data)
// Кодировка, mime с нулём, тип 3 (передняя обложка), пустое описание в ISO-8859-1
size := 1 + mime.len + 1 + 1 + 1 + cover.data.len
out << frame_header('APIC', size, version)
out << u8(0)
out << mime.bytes()
out << [u8(0), 3, 0]
out << cover.data
}
// Сколько байт займёт фрейм обложки
fn apic_frame_len(cover Cover) int {
return 10 + 4 + normalize_mime(cover.mime, cover.data).len + cover.data.len
}
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 kept_frame(frame Frame, drop_cover bool) bool {
return !is_managed(frame) && !(frame.name == 'APIC' && drop_cover)
}
// Сборка тэга целиком (заголовок + фреймы + запас нулей)
fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
version := if parsed.version in [u8(3), 4] { parsed.version } else { u8(3) }
new_cover := opts.cover
drop_cover := new_cover != none || opts.remove_cover
// Сразу нужной ёмкости: тэг с обложкой — это мегабайты, а рост массива удвоениями оставлял
// в куче копии на каждом шаге. Текстовые фреймы — с запасом; заменяемая обложка не в счёт
mut capacity := 10 + book_tag_padding + 4096 + t.comment.len * 2
for frame in parsed.frames {
if kept_frame(frame, drop_cover) {
capacity += frame.raw.len
}
}
if c := new_cover {
capacity += apic_frame_len(c)
}
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)
}
for frame in parsed.frames {
if kept_frame(frame, drop_cover) {
frames << frame.raw
}
}
if c := new_cover {
write_apic(mut frames, 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 := ParsedTag{}
if opts.cover != none || opts.remove_cover {
parsed, _ = parse_id3v2_lazy(path, false)!
} else {
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)!
}