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 } // Запись тэгов: новый тэг + аудио из исходного файла собираются во временном файле, // затем копируются обратно в исходный. Файл остаётся тем же (inode, владелец, права): // Audiobookshelf опознаёт файлы по inode, и замена файла новым путает его библиотеку. // Файл без 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 buf := []u8{len: 1024 * 1024} { 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 { // Исходный файл мог остаться наполовину переписанным — полная копия во временном 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 } os.rm(tmp_path) or {} } // Содержимое 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) ! { 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)! }