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)! }