diff --git a/metadata/frame.v b/metadata/frame.v index 7f808ac..82957b5 100644 --- a/metadata/frame.v +++ b/metadata/frame.v @@ -19,6 +19,8 @@ pub struct Frame { apic ?[]u8 encoded_size []u8 modified bool + // Фрейм целиком в том виде, в каком он был прочитан из файла + raw []u8 } // Во фрейме первый байт указывает кодировку 0 - ISO-8859-1 (Latin-1), 1 - UTF-16LE @@ -38,6 +40,9 @@ fn (mut frame Frame) fill(bytes []u8, start_position u64, idv3_version u8) { // Конец фрейма frame.end = frame.start + frame.cur_size + 10 + frame.raw = bytes[frame.start..frame.end] + frame.modified = false + // Содержимое фрейма frame.encoding_name = match bytes[frame.start + 10] { 0 { "ISO-8859-1" } @@ -72,11 +77,21 @@ fn (mut frame Frame) fill(bytes []u8, start_position u64, idv3_version u8) { } } -pub fn (mut frame Frame) init(tag_name string) { +pub fn (mut frame Frame) init(tag_name string, idv3_version u8) { frame.name = tag_name.to_upper() + frame.set_unicode_encoding(idv3_version) +} - frame.encoding_name = "UTF-8" - frame.encoding_name_idx = 3 +// Кодировка, в которой представим любой текст +fn (mut frame Frame) set_unicode_encoding(idv3_version u8) { + // В v2.3 нет UTF-8, там Юникод только UTF-16 с BOM + if idv3_version == 3 { + frame.encoding_name = "UTF-16LE" + frame.encoding_name_idx = 1 + } else { + frame.encoding_name = "UTF-8" + frame.encoding_name_idx = 3 + } } // Кодируем длину фрейма с нужным сдвигом @@ -99,9 +114,27 @@ pub fn (mut frame Frame) encode_size(idv3_version u8) { // Кодируем кадр для дальнейшей записи pub fn (mut frame Frame) encode_frame(idv3_version u8) { - encoded_data := iconv.vstring_to_encoding(frame.data, frame.encoding_name) or { []u8{} } + // В ISO-8859-1 есть только символы до U+00FF, остальные iconv молча заменяет на '?' + if frame.encoding_name_idx == 0 && frame.data.runes().any(it > 0xFF) { + frame.set_unicode_encoding(idv3_version) + } - frame.encoded_data = encoded_data + encoded_data := iconv.vstring_to_encoding(frame.data, frame.encoding_name) or { + // Не удалось закодировать в исходной кодировке фрейма, переходим на Юникод + frame.set_unicode_encoding(idv3_version) + iconv.vstring_to_encoding(frame.data, frame.encoding_name) or { + panic("не удалось закодировать фрейм ${frame.name}: ${err}") + } + } + + // Для UTF-16 с BOM (01) перед текстом нужен маркер порядка байт + frame.encoded_data = if frame.encoding_name_idx == 1 { + mut bom := if frame.encoding_name == "UTF-16BE" { [u8(0xFE), 0xFF] } else { [u8(0xFF), 0xFE] } + bom << encoded_data + bom + } else { + encoded_data + } // Размер тела фрейма: байт кодировки + данные + замыкающие нули frame.cur_size = u64(1 + frame.encoded_data.len + frame.terminator_len()) @@ -137,6 +170,17 @@ pub fn (mut frame Frame) get_encoded_frame_tag(idv3_version u8) []u8 { return encoded_tag } +// Байты фрейма для записи в файл +pub fn (mut frame Frame) to_bytes(idv3_version u8) []u8 { + // Картинка и нетронутые фреймы пишутся как были прочитаны, + // чтобы не потерять то, что не разбирается (язык в COMM, значение в TXXX, бинарные фреймы) + if frame.raw.len > 0 && (!frame.modified || frame.name == "APIC") { + return frame.raw + } + + return frame.get_encoded_frame_tag(idv3_version) +} + // Запись данных в тэг @[inline] pub fn (mut frame Frame) set_data(data string) { diff --git a/metadata/id3v23.v b/metadata/id3v23.v index aacb75c..7122b3e 100644 --- a/metadata/id3v23.v +++ b/metadata/id3v23.v @@ -1,7 +1,6 @@ module metadata import os { File } -import io { read_all } // 00: ISO-8859-1 (1 байт на символ). // 01: UTF-16 с BOM (2 байта на символ + 2 байта маркер). @@ -104,13 +103,7 @@ pub fn (id3v23 ID3v23) to_v1() ID3v1 { } } -pub fn (mut id3v23 ID3v23) insert_tags_into_audio(mut audio File) { - full_part := read_all(reader: audio) or { panic(err) } - - // Размер старого тэга берётся из файла, id3v23.size уже хранит размер нового. - // В заголовке тэга размер syncsafe даже в v2.3, поэтому get_size_v24 - old_size := get_size_v24(full_part[6..10]) - sound_part := full_part[int(old_size) + 10 .. ] - - id3v23.encoded_header << sound_part +// Добавление аудио после нового тэга +pub fn (mut id3v23 ID3v23) insert_tags_into_audio(mut audio File) ! { + insert_tags_into_audio[ID3v23](mut id3v23, mut audio)! } diff --git a/metadata/id3v24.v b/metadata/id3v24.v index d3a9c82..f5099e0 100644 --- a/metadata/id3v24.v +++ b/metadata/id3v24.v @@ -1,7 +1,6 @@ module metadata import os { File } -import io { read_all } pub fn (mut tag ID3v24) add_frame(frame Frame) { @@ -79,12 +78,7 @@ pub fn (id3v24 ID3v24) to_v1() ID3v1 { } -pub fn (mut id3v24 ID3v24) insert_tags_into_audio(mut audio File) { - full_part := read_all(reader: audio) or { panic(err) } - - // Размер старого тэга берётся из файла, id3v24.size уже хранит размер нового - old_size := get_size_v24(full_part[6..10]) - sound_part := full_part[int(old_size) + 10 .. ] - - id3v24.encoded_header << sound_part +// Добавление аудио после нового тэга +pub fn (mut id3v24 ID3v24) insert_tags_into_audio(mut audio File) ! { + insert_tags_into_audio[ID3v24](mut id3v24, mut audio)! } diff --git a/metadata/utils.v b/metadata/utils.v index b09b32d..6fd98ee 100644 --- a/metadata/utils.v +++ b/metadata/utils.v @@ -72,22 +72,16 @@ pub fn get_current_tag_size[T](mut tag T, mut audio os.File) { tag.size = (u64(first_ten_bytes[6]) << 21) | (u64(first_ten_bytes[7]) << 14) | (u64(first_ten_bytes[8]) << 7) | u64(first_ten_bytes[9]) } -// Кодирование размера заголовка со всеми тэгами -pub fn encode_header_size(size u64, idv3_version u8) []u8 { - stride := if idv3_version == 3 { - [u64(24), u64(16), u64(8), u64(0xFF)] - } else { - [u64(21), u64(14), u64(7), u64(0x7F)] - } - +// Кодирование размера заголовка со всеми тэгами. +// В заголовке размер всегда syncsafe (по 7 бит в байте), в том числе в v2.3 +pub fn encode_header_size(size u64) []u8 { mut encoded_size := []u8{len: 4} - encoded_size[0] = u8((size >> stride[0]) & stride[3]) - encoded_size[1] = u8((size >> stride[1]) & stride[3]) - encoded_size[2] = u8((size >> stride[2]) & stride[3]) - encoded_size[3] = u8(size & stride[3]) + encoded_size[0] = u8((size >> 21) & 0x7F) + encoded_size[1] = u8((size >> 14) & 0x7F) + encoded_size[2] = u8((size >> 7) & 0x7F) + encoded_size[3] = u8(size & 0x7F) return encoded_size - } // Кодирование первых 10 байт ID3 @@ -99,8 +93,7 @@ pub fn encode_header[T](mut id3v T) { // Минорная версия и спец. флаги id3v.encoded_header << []u8{len: 2} - // Размер всего тэга в заголовке всегда syncsafe (по 7 бит в байте), в том числе в v2.3 - id3v.encoded_header << metadata.encode_header_size(id3v.size, 4) + id3v.encoded_header << metadata.encode_header_size(id3v.size) } // Количество нулей в конце тэга @@ -116,16 +109,23 @@ pub fn encode_embed_tags[T](mut id3v2 T) { $if field.typ is Frame { // Читаются только заполненные тэги if id3v2.$(field.name).cur_size > 0 { - if id3v2.$(field.name).name.to_lower() != "apic" { - // Кодируем значения полей - tag_bytes << id3v2.$(field.name).get_encoded_frame_tag(id3v2.version) - } else { - // Картинка не трогается - tag_bytes << id3v2.$(field.name).apic or { []u8{} } + // Фрейма не было в файле и init не вызывался: имя берётся из поля структуры + if id3v2.$(field.name).name == "" && field.name.len == 4 { + id3v2.$(field.name).init(field.name, id3v2.version) + } + + // Без 4-символьного ID фрейм испортит весь тэг + if id3v2.$(field.name).name.len == 4 { + tag_bytes << id3v2.$(field.name).to_bytes(id3v2.version) } } } } + + // Фреймы, для которых нет отдельного поля в структуре + for mut frame in id3v2.others { + tag_bytes << frame.to_bytes(id3v2.version) + } // Запас нулей чтобы не пересчитывать весь тэг при маленьком редактировании tag_bytes << []u8{len: tag_padding} @@ -136,3 +136,41 @@ pub fn encode_embed_tags[T](mut id3v2 T) { encode_header(mut id3v2) id3v2.encoded_header << tag_bytes } + +// Добавляет аудио из файла после нового тэга в encoded_header, старый тэг пропускается +pub fn insert_tags_into_audio[T](mut id3v2 T, mut audio os.File) ! { + header := get_bytes(mut audio, 0, 10) + if header.len < 10 || header[..3].bytestr() != "ID3" { + return error("в файле нет тэга ID3v2") + } + + // Размер старого тэга берётся из файла, id3v2.size уже хранит размер нового. + // В заголовке он syncsafe в любой версии, плюс 10 байт самого заголовка + mut audio_start := get_size_v24(header[6..10]) + 10 + // В v2.4 после тэга может идти футер ещё на 10 байт + if header[3] == 4 && header[5] & 0x10 != 0 { + audio_start += 10 + } + + audio.seek(0, .end)! + file_size := u64(audio.tell()!) + if audio_start > file_size { + return error("размер тэга (${audio_start}) больше размера файла (${file_size})") + } + + // Аудио читается сразу в конец encoded_header, без промежуточной копии всего файла + sound_len := int(file_size - audio_start) + header_len := id3v2.encoded_header.len + unsafe { id3v2.encoded_header.grow_len(sound_len) } + + audio.seek(i64(audio_start), .start)! + mut read := 0 + for read < sound_len { + n := unsafe { audio.read_into_ptr(&u8(id3v2.encoded_header.data) + header_len + read, sound_len - read)! } + if n == 0 { + return error("файл закончился раньше ожидаемого") + } + read += n + } + audio.seek(0, .start)! +} diff --git a/test/id3v2_test.v b/test/id3v2_test.v index 7f2ea14..d287dcd 100644 --- a/test/id3v2_test.v +++ b/test/id3v2_test.v @@ -10,8 +10,8 @@ fn test_idv3v23() { mut wav := metadata.Audio{} wav.detect_tags(mut audio) - assert wav.id3v1.embedded == false, 'Тег показан false, а должен быть true' - assert wav.id3v23.embedded == true, 'Тег показан true, а должен быть false' + assert wav.id3v1.embedded == false, 'Тег показан true, а должен быть false' + assert wav.id3v23.embedded == true, 'Тег показан false, а должен быть true' assert wav.id3v24.embedded == false, 'Тег показан true, а должен быть false' wav.id3v23.get_full_size(mut audio) wav.id3v23.fill_tags(mut audio) @@ -45,8 +45,8 @@ fn test_writeidv3v23() { mut wav := metadata.Audio{} wav.detect_tags(mut audio) - assert wav.id3v1.embedded == false, 'Тег показан false, а должен быть true' - assert wav.id3v23.embedded == true, 'Тег показан true, а должен быть false' + assert wav.id3v1.embedded == false, 'Тег показан true, а должен быть false' + assert wav.id3v23.embedded == true, 'Тег показан false, а должен быть true' assert wav.id3v24.embedded == false, 'Тег показан true, а должен быть false' wav.id3v23.get_full_size(mut audio) wav.id3v23.fill_tags(mut audio) @@ -63,7 +63,7 @@ fn test_writeidv3v23() { wav.id3v23.txxx.set_data("comment2") wav.id3v23.encode_embed_tags() - wav.id3v23.insert_tags_into_audio(mut audio) + wav.id3v23.insert_tags_into_audio(mut audio) or { panic(err) } os.write_bytes("./data/temp_v23.mp3", wav.id3v23.encoded_header) or { panic(err) } @@ -76,8 +76,8 @@ fn test_writeidv3v23() { mut temp_wav := metadata.Audio{} temp_wav.detect_tags(mut temp_audio) - assert temp_wav.id3v1.embedded == false, 'Тег показан false, а должен быть true' - assert temp_wav.id3v23.embedded == true, 'Тег показан true, а должен быть false' + assert temp_wav.id3v1.embedded == false, 'Тег показан true, а должен быть false' + assert temp_wav.id3v23.embedded == true, 'Тег показан false, а должен быть true' assert temp_wav.id3v24.embedded == false, 'Тег показан true, а должен быть false' temp_wav.id3v23.get_full_size(mut temp_audio) temp_wav.id3v23.fill_tags(mut temp_audio) @@ -92,7 +92,8 @@ fn test_writeidv3v23() { assert temp_wav.id3v23.tcop.data == "Test2" assert temp_wav.id3v23.txxx.data == "comment2" - id3v1 := wav.id3v23.to_v1() + // Проверяется прочитанный с диска тэг, а не тот, что в памяти + id3v1 := temp_wav.id3v23.to_v1() assert id3v1.genre == "Rock2" assert id3v1.title == "Test Title2" @@ -112,9 +113,9 @@ fn test_idv3v24() { mut wav := metadata.Audio{} wav.detect_tags(mut audio) - assert wav.id3v1.embedded == false, 'Тег показан false, а должен быть true' + assert wav.id3v1.embedded == false, 'Тег показан true, а должен быть false' assert wav.id3v23.embedded == false, 'Тег показан true, а должен быть false' - assert wav.id3v24.embedded == true, 'Тег показан true, а должен быть false' + assert wav.id3v24.embedded == true, 'Тег показан false, а должен быть true' wav.id3v24.get_full_size(mut audio) wav.id3v24.fill_tags(mut audio) @@ -139,3 +140,59 @@ fn test_idv3v24() { assert id3v1.comment == "comment" } +// Кириллица во фрейме с кодировкой ISO-8859-1 не должна превращаться в '?' +fn test_write_unicode_into_latin1_frame() { + mut audio := os.open("./data/test_id3v23.mp3") or { panic(err) } + defer { + audio.close() + } + mut wav := metadata.Audio{} + wav.detect_tags(mut audio) + wav.id3v23.get_full_size(mut audio) + wav.id3v23.fill_tags(mut audio) + old_size := int(wav.id3v23.size) + + wav.id3v23.tit2.set_data("Глава 2") + wav.id3v23.encode_embed_tags() + wav.id3v23.insert_tags_into_audio(mut audio) or { panic(err) } + + temp_path := os.join_path(os.temp_dir(), "id3_editor_unicode_test.mp3") + os.write_bytes(temp_path, wav.id3v23.encoded_header) or { panic(err) } + defer { + os.rm(temp_path) or {} + } + + mut temp_audio := os.open(temp_path) or { panic(err) } + defer { + temp_audio.close() + } + mut temp_wav := metadata.Audio{} + temp_wav.detect_tags(mut temp_audio) + temp_wav.id3v23.get_full_size(mut temp_audio) + temp_wav.id3v23.fill_tags(mut temp_audio) + + assert temp_wav.id3v23.tit2.data == "Глава 2" + // Нетронутые фреймы и аудио сохраняются без изменений + assert temp_wav.id3v23.tpe1.data == "Test Author" + assert temp_wav.id3v23.others.len == 1 + + original := os.read_bytes("./data/test_id3v23.mp3") or { panic(err) } + written := os.read_bytes(temp_path) or { panic(err) } + assert written[int(temp_wav.id3v23.size) + 10..] == original[old_size + 10..] +} + +// Вставка в файл без тэга ID3v2 возвращает ошибку, а не падает +fn test_insert_without_id3v2() { + mut audio := os.open("./data/test_id3v1.mp3") or { panic(err) } + defer { + audio.close() + } + mut wav := metadata.Audio{} + wav.id3v23.encode_embed_tags() + + wav.id3v23.insert_tags_into_audio(mut audio) or { + assert err.msg() == "в файле нет тэга ID3v2" + return + } + assert false, 'Ошибка не возвращена' +}