diff --git a/TODO.md b/TODO.md index 2d7d3d4..74e1f2c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,10 @@ - [x] Извлечение ID3 v1 - [x] Запись ID3 v1 - [x] Извлечение ID3 v2.3 -- [ ] Запись ID3 v2.3 +- [x] Запись ID3 v2.3 - [x] Извлечение ID3 v2.4 -- [ ] Запись ID3 v2.4 +- [x] Запись ID3 v2.4 - [x] Конвертация из ID3 v1 в v2.3 и обратно -1 \ No newline at end of file +1 +- [x] API для аудиокниг (metadata/audiobook.v): TCOM, TIT1, COMM, TXXX, APIC, файлы без ID3v2, cp1251 +- [x] Совместимость с V master (iconv сохраняет NUL, баг @[heap] в массивах) diff --git a/metadata/audiobook.v b/metadata/audiobook.v new file mode 100644 index 0000000..61c6eed --- /dev/null +++ b/metadata/audiobook.v @@ -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)! +} diff --git a/metadata/frame.v b/metadata/frame.v index 82957b5..e0875c9 100644 --- a/metadata/frame.v +++ b/metadata/frame.v @@ -2,7 +2,7 @@ module metadata import encoding.iconv -@[heap] +// Без @[heap]: на V master `tag.others << frame` для heap-структуры кладёт в []Frame указатель pub struct Frame { pub mut: 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] } diff --git a/metadata/id3v1.v b/metadata/id3v1.v index 3d42c80..3c76b16 100644 --- a/metadata/id3v1.v +++ b/metadata/id3v1.v @@ -9,7 +9,7 @@ import utils @[inline] fn decode_bytes(bytes []u8) string { 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 } @@ -106,7 +106,7 @@ pub fn (mut id3v1 ID3v1) insert(mut audio File, position i8) []u8 { // Положение указателя size := audio.tell() or { panic(err) } // Чтение всех байта, кроме последних 128 - mut all_bytes := audio.read_bytes(int(size))#[..position] + mut all_bytes := audio.read_bytes(int(size))#[..position].clone() all_bytes << bytes_tag // } diff --git a/test/audiobook_test.v b/test/audiobook_test.v new file mode 100644 index 0000000..df92458 --- /dev/null +++ b/test/audiobook_test.v @@ -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, 'ошибка не возвращена' +}