Compare commits

...
7 Commits
Author SHA1 Message Date
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
K1ndWha1eandClaude Opus 5.5 6161a7faaa vfmt: весь код в формате V master (без изменений логики)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:31:02 +03:00
K1ndWha1eandClaude Opus 5.5 4c9184aeb2 Чтение тэгов без обложки: с диска только нужные текстовые фреймы, у APIC — лишь признак
read_book_tags читал тэг целиком вместе с картинкой (у книг с обложкой 3 МБ — 3 МБ на файл
при каждом открытии книги). Теперь фреймы читаются по одному, обложка и ненужные фреймы
пропускаются; тэг с unsynchronisation (v2.3) — целиком, как раньше. Тест: на тэге с обложкой
3 МБ выделяется меньше 1 МБ, теги и has_cover те же.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:26:44 +03:00
K1ndWha1eandClaude Opus 5.5 dc13ae43b8 Меньше памяти на тэгах с обложкой: фрейм заполняется прямо в массиве, тэг собирается в буфер нужного размера, «только тэг» пишется без копии, буферы — free()
parsed.frames << frame клонировал срезы буфера (raw, apic) — у обложки 3 МБ это +5,7 МБ на файл.
Замер на тэге 2,9 МБ: чтение 8,6 → 2,9 МБ выделений, запись 11,6 → 5,8 МБ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:26:29 +03:00
K1ndWha1eandClaude Opus 5.5 71b838e7d4 Frame снова @[heap]: баг кодогенерации V master (heap-структура из generic-функции в []T) исправлен в 03e12f3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:43:22 +03:00
K1ndWha1eandClaude Opus 5.5 a76bbe18f1 *чтение: пустые или испорченные («�») название, альбом и автор в ID3v2 берутся из ID3v1 (старые файлы хранят там cp1251)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:58:08 +03:00
K1ndWha1eandClaude Opus 5.5 a3e244205a *повторная запись: если новый тэг помещается на место старого — переписывается только он, аудио не трогается; запас в тэге 4 КБ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:07:45 +03:00
13 changed files with 768 additions and 321 deletions

No files matched your search

+224 -37
View File
@@ -64,6 +64,8 @@ pub:
pub struct WriteReport {
pub:
in_place bool
// Переписан только тэг в начале файла (новый поместился на место старого), аудио не тронуто
header bool
// Почему не удалось переписать на месте (пусто, если на месте)
fallback string
}
@@ -73,8 +75,15 @@ 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']
@@ -130,6 +139,7 @@ fn parse_id3v2(path string) !ParsedTag {
}
bytes = unsafe { clean }
}
parsed.raw = bytes
mut pos := u64(10)
// Расширенный заголовок
@@ -164,14 +174,91 @@ fn parse_id3v2(path string) !ParsedTag {
pos = end
continue
}
mut frame := Frame{}
frame.fill(bytes, pos, version)
parsed.frames << frame
// Фрейм заполняется уже в массиве: `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 {
@@ -284,14 +371,20 @@ fn (frame Frame) apic_value() (string, u8, []u8) {
// Чтение тегов файла. Если ID3v2 нет — смотрим ID3v1
fn read_book_tags_impl(path string) !FileTags {
parsed := parse_id3v2(path)!
// Обложка не читается — только отмечается, есть ли она
mut parsed, has_cover := parse_id3v2_lazy(path, true)!
// Тексты уже декодированы в свои строки — байты тэга больше не нужны
defer {
unsafe { parsed.raw.free() }
}
mut res := FileTags{
version: parsed.version
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 {
if parsed.frames.len == 0 && !has_cover {
res.tags = read_v1(path) or { BookTags{} }
return res
}
@@ -333,20 +426,36 @@ fn read_book_tags_impl(path string) !FileTags {
else {}
}
}
'APIC' {
res.has_cover = true
}
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(')') {
@@ -486,18 +595,21 @@ fn comm_frame(text string, lang string, version u8) []u8 {
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
// Фрейм обложки — сразу в конец 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 {
@@ -511,10 +623,29 @@ fn is_managed(frame Frame) bool {
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) }
mut frames := []u8{}
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 := [
@@ -544,26 +675,23 @@ fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
frames << comm_frame(t.comment.trim_space(), 'rus', version)
}
new_cover := opts.cover
for frame in parsed.frames {
if is_managed(frame) {
continue
if kept_frame(frame, drop_cover) {
frames << frame.raw
}
if frame.name == 'APIC' && (new_cover != none || opts.remove_cover) {
continue
}
frames << frame.raw
}
if c := new_cover {
frames << apic_frame(c, version)
write_apic(mut frames, c, version)
}
frames << []u8{len: tag_padding}
frames << []u8{len: book_tag_padding}
mut out := [u8(`I`), `D`, `3`, version, 0, 0]
out << encode_header_size(u64(frames.len))
out << frames
return out
// Размер тэга (без 10 байт заголовка) — на своё место в заголовке
size := encode_header_size(u64(frames.len - 10))
for i in 0 .. 4 {
frames[6 + i] = size[i]
}
return frames
}
// Запись тэгов: новый тэг + аудио из исходного файла собираются во временном файле,
@@ -571,11 +699,38 @@ fn build_tag(parsed ParsedTag, t BookTags, opts WriteOptions) []u8 {
// Audiobookshelf опознаёт файлы по inode, и замена файла новым путает его библиотеку.
// Файл без ID3v2 тоже поддерживается — тэг просто ставится в начало
fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) !WriteReport {
parsed := parse_id3v2(path)!
tag := build_tag(parsed, t, opts)
// Старая обложка заменяется или удаляется — её с диска не читать, остальные фреймы переносятся как есть
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 {
@@ -624,6 +779,38 @@ fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) !WriteReport
}
}
// Новый тэг (без запаса) помещается на место старого
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)!
+1 -1
View File
@@ -26,4 +26,4 @@ pub enum EnumID3v23 as u8 {
apic
txxx
unknown
}
}
+44 -40
View File
@@ -2,25 +2,25 @@ module metadata
import encoding.iconv
// Без @[heap]: на V master `tag.others << frame` для heap-структуры кладёт в []Frame указатель
@[heap]
pub struct Frame {
pub mut:
data string
mut:
prev_size u64
cur_size u64
start u64
end u64
encoding_name string
name string
encoding_name_idx u8
addition []u8
encoded_data []u8
apic ?[]u8
encoded_size []u8
modified bool
// Фрейм целиком в том виде, в каком он был прочитан из файла
raw []u8
pub mut:
data string
mut:
prev_size u64
cur_size u64
start u64
end u64
encoding_name string
name string
encoding_name_idx u8
addition []u8
encoded_data []u8
apic ?[]u8
encoded_size []u8
modified bool
// Фрейм целиком в том виде, в каком он был прочитан из файла
raw []u8
}
// Во фрейме первый байт указывает кодировку 0 - ISO-8859-1 (Latin-1), 1 - UTF-16LE
@@ -32,10 +32,10 @@ fn (mut frame Frame) fill(bytes []u8, start_position u64, idv3_version u8) {
frame.name = bytes[frame.start..frame.start + 4].bytestr()
// Размер фрейма
frame.cur_size = if idv3_version == 3 {
get_size_v23(bytes[frame.start + 4..frame.start + 8])
} else {
get_size_v24(bytes[frame.start + 4..frame.start + 8])
frame.cur_size = if idv3_version == 3 {
get_size_v23(bytes[frame.start + 4..frame.start + 8])
} else {
get_size_v24(bytes[frame.start + 4..frame.start + 8])
}
// Конец фрейма
frame.end = frame.start + frame.cur_size + 10
@@ -44,16 +44,16 @@ fn (mut frame Frame) fill(bytes []u8, start_position u64, idv3_version u8) {
frame.modified = false
// Содержимое фрейма
frame.encoding_name = match bytes[frame.start + 10] {
0 { "ISO-8859-1" }
1 { "UTF-16LE" }
2 { "UTF-16BE" }
else { "UTF-8" }
frame.encoding_name = match bytes[frame.start + 10] {
0 { 'ISO-8859-1' }
1 { 'UTF-16LE' }
2 { 'UTF-16BE' }
else { 'UTF-8' }
}
frame.encoding_name_idx = u8(bytes[frame.start + 10])
if frame.name.to_lower() == "apic" {
if frame.name.to_lower() == 'apic' {
// Дополнительные байты с информацией (MIME)
frame.addition = bytes[frame.start + 10..frame.start + 24]
// Фрейм целиком вместе с заголовком, чтобы записать его обратно без изменений
@@ -65,13 +65,13 @@ fn (mut frame Frame) fill(bytes []u8, start_position u64, idv3_version u8) {
// UTF-16 с BOM (01): 2 байта маркера, по ним определяется порядок байт
if frame.encoding_name_idx == 1 && data_start + 2 <= frame.end {
if bytes[data_start] == 0xFE && bytes[data_start + 1] == 0xFF {
frame.encoding_name = "UTF-16BE"
frame.encoding_name = 'UTF-16BE'
}
data_start += 2
}
// Замыкающие нули не относятся к данным
frame.data = (iconv.encoding_to_vstring(bytes[data_start..frame.end], frame.encoding_name) or { "" }).all_before('\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]
}
@@ -86,10 +86,10 @@ pub fn (mut frame Frame) init(tag_name string, idv3_version u8) {
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 = 'UTF-16LE'
frame.encoding_name_idx = 1
} else {
frame.encoding_name = "UTF-8"
frame.encoding_name = 'UTF-8'
frame.encoding_name_idx = 3
}
}
@@ -97,10 +97,10 @@ fn (mut frame Frame) set_unicode_encoding(idv3_version u8) {
// Кодируем длину фрейма с нужным сдвигом
@[inline]
pub fn (mut frame Frame) encode_size(idv3_version u8) {
stride := if idv3_version == 3 {
[u64(24), u64(16), u64(8), u64(0xFF)]
} else {
[u64(21), u64(14), u64(7), u64(0x7F)]
stride := if idv3_version == 3 {
[u64(24), u64(16), u64(8), u64(0xFF)]
} else {
[u64(21), u64(14), u64(7), u64(0x7F)]
}
mut encoded_size := []u8{len: 4}
@@ -123,13 +123,17 @@ pub fn (mut frame Frame) encode_frame(idv3_version u8) {
// Не удалось закодировать в исходной кодировке фрейма, переходим на Юникод
frame.set_unicode_encoding(idv3_version)
iconv.vstring_to_encoding(frame.data, frame.encoding_name) or {
panic("не удалось закодировать фрейм ${frame.name}: ${err}")
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] }
mut bom := if frame.encoding_name == 'UTF-16BE' {
[u8(0xFE), 0xFF]
} else {
[u8(0xFF), 0xFE]
}
bom << encoded_data
bom
} else {
@@ -174,7 +178,7 @@ pub fn (mut frame Frame) get_encoded_frame_tag(idv3_version u8) []u8 {
pub fn (mut frame Frame) to_bytes(idv3_version u8) []u8 {
// Картинка и нетронутые фреймы пишутся как были прочитаны,
// чтобы не потерять то, что не разбирается (язык в COMM, значение в TXXX, бинарные фреймы)
if frame.raw.len > 0 && (!frame.modified || frame.name == "APIC") {
if frame.raw.len > 0 && (!frame.modified || frame.name == 'APIC') {
return frame.raw
}
@@ -195,4 +199,4 @@ pub fn (mut frame Frame) set_data(data string) {
frame.data = data
frame.modified = true
}
}
+154 -32
View File
@@ -151,47 +151,169 @@ enum Genre as u8 {
anime = 145
jpop = 146
synthpop = 147
unknown = 255
unknown = 255
}
const genres_name = [
'Blues', 'Classic Rock', 'Country', 'Dance', 'Disco', 'Funk', 'Grunge',
'Hip-Hop', 'Jazz', 'Metal', 'New Age', 'Oldies', 'Other', 'Pop', 'R&B',
'Rap', 'Reggae', 'Rock', 'Techno', 'Industrial', 'Alternative', 'Ska',
'Death Metal', 'Pranks', 'Soundtrack', 'Euro-Techno', 'Ambient',
'Trip-Hop', 'Vocal', 'Jazz+Funk', 'Fusion', 'Trance', 'Classical',
'Instrumental', 'Acid', 'House', 'Game', 'Sound Clip', 'Gospel',
'Noise', 'AlternRock', 'Bass', 'Soul', 'Punk', 'Space', 'Meditative',
'Instrumental Pop', 'Instrumental Rock', 'Ethnic', 'Gothic', 'Darkwave',
'Techno-Industrial', 'Electronic', 'Pop-Folk', 'Eurodance', 'Dream',
'Southern Rock', 'Comedy', 'Cult', 'Gangsta', 'Top 40', 'Christian Rap',
'Pop/Funk', 'Jungle', 'Native American', 'Cabaret', 'New Wave',
'Psychadelic', 'Rave', 'Showtunes', 'Trailer', 'Lo-Fi', 'Tribal',
'Acid Punk', 'Acid Jazz', 'Polka', 'Retro', 'Musical', 'Rock & Roll',
'Blues',
'Classic Rock',
'Country',
'Dance',
'Disco',
'Funk',
'Grunge',
'Hip-Hop',
'Jazz',
'Metal',
'New Age',
'Oldies',
'Other',
'Pop',
'R&B',
'Rap',
'Reggae',
'Rock',
'Techno',
'Industrial',
'Alternative',
'Ska',
'Death Metal',
'Pranks',
'Soundtrack',
'Euro-Techno',
'Ambient',
'Trip-Hop',
'Vocal',
'Jazz+Funk',
'Fusion',
'Trance',
'Classical',
'Instrumental',
'Acid',
'House',
'Game',
'Sound Clip',
'Gospel',
'Noise',
'AlternRock',
'Bass',
'Soul',
'Punk',
'Space',
'Meditative',
'Instrumental Pop',
'Instrumental Rock',
'Ethnic',
'Gothic',
'Darkwave',
'Techno-Industrial',
'Electronic',
'Pop-Folk',
'Eurodance',
'Dream',
'Southern Rock',
'Comedy',
'Cult',
'Gangsta',
'Top 40',
'Christian Rap',
'Pop/Funk',
'Jungle',
'Native American',
'Cabaret',
'New Wave',
'Psychadelic',
'Rave',
'Showtunes',
'Trailer',
'Lo-Fi',
'Tribal',
'Acid Punk',
'Acid Jazz',
'Polka',
'Retro',
'Musical',
'Rock & Roll',
'Hard Rock',
// Winamp
'Folk', 'Folk-Rock', 'National Folk', 'Swing', 'Fast Fusion', 'Bebob',
'Latin', 'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic Rock',
'Progressive Rock', 'Psychedelic Rock', 'Symphonic Rock', 'Slow Rock',
'Big Band', 'Chorus', 'Easy Listening', 'Acoustic', 'Humour', 'Speech',
'Chanson', 'Opera', 'Chamber Music', 'Sonata', 'Symphony', 'Booty Bass',
'Primus', 'Porn Groove', 'Satire', 'Slow Jam', 'Club', 'Tango', 'Samba',
'Folklore', 'Ballad', 'Power Ballad', 'Rhythmic Soul', 'Freestyle', 'Duet',
'Punk Ballad', 'Drum Solo', 'A Cappella', 'Euro-House', 'Dance Hall',
'Goa', 'Drum & Bass', 'Club-House', 'Hardcore', 'Terror', 'Indie',
'BritPop', 'Negerpunk', 'Polsk Punk', 'Beat', 'Christian Gangsta',
'Heavy Metal', 'Black Metal', 'Crossover', 'Contemporary Christian',
'Christian Rock', 'Merengue', 'Salsa', 'Thrash Metal', 'Anime', 'JPop',
'Synthpop'
'Folk',
'Folk-Rock',
'National Folk',
'Swing',
'Fast Fusion',
'Bebob',
'Latin',
'Revival',
'Celtic',
'Bluegrass',
'Avantgarde',
'Gothic Rock',
'Progressive Rock',
'Psychedelic Rock',
'Symphonic Rock',
'Slow Rock',
'Big Band',
'Chorus',
'Easy Listening',
'Acoustic',
'Humour',
'Speech',
'Chanson',
'Opera',
'Chamber Music',
'Sonata',
'Symphony',
'Booty Bass',
'Primus',
'Porn Groove',
'Satire',
'Slow Jam',
'Club',
'Tango',
'Samba',
'Folklore',
'Ballad',
'Power Ballad',
'Rhythmic Soul',
'Freestyle',
'Duet',
'Punk Ballad',
'Drum Solo',
'A Cappella',
'Euro-House',
'Dance Hall',
'Goa',
'Drum & Bass',
'Club-House',
'Hardcore',
'Terror',
'Indie',
'BritPop',
'Negerpunk',
'Polsk Punk',
'Beat',
'Christian Gangsta',
'Heavy Metal',
'Black Metal',
'Crossover',
'Contemporary Christian',
'Christian Rock',
'Merengue',
'Salsa',
'Thrash Metal',
'Anime',
'JPop',
'Synthpop',
]
pub fn (genre Genre) str() string{
pub fn (genre Genre) str() string {
match genre {
.unknown {
return "Unknown"
} else {
return 'Unknown'
}
else {
return genres_name[u8(genre)]
}
}
}
}
+17 -17
View File
@@ -4,28 +4,28 @@ import os { File }
import encoding.iconv
import utils
// Определить кодировку utf-8 или cp-1251
@[inline]
fn decode_bytes(bytes []u8) string {
format := if utils.is_utf8(bytes) { "UTF-8" } else { "CP1251" }
format := if utils.is_utf8(bytes) { 'UTF-8' } else { 'CP1251' }
text := (iconv.encoding_to_vstring(bytes, format) or { "Can't decode" }).all_before('\x00')
return text
return text
}
// Извлечение метаданных из ID3 v1
pub fn (mut tag ID3v1) get_metadata(last_bytes []u8) {
tag.title = decode_bytes(last_bytes[3..33])
tag.artist = decode_bytes(last_bytes[33..63])
tag.album = decode_bytes(last_bytes[63..92])
tag.year = decode_bytes(last_bytes[93..97])
tag.comment = decode_bytes(last_bytes[97..125])
tag.track = if last_bytes[125] == u8(0) {last_bytes[126].str()} else { "" }
tag.track = if last_bytes[125] == u8(0) { last_bytes[126].str() } else { '' }
genre := Genre.from(last_bytes[127..128][0]) or { Genre.unknown }
tag.genre = genre.str()}
tag.genre = genre.str()
}
// Конвертировать ID3 v2.3 в ID3 v1
pub fn (id3v1 ID3v1) to_v23() ID3v23 {
@@ -60,7 +60,7 @@ fn cut_or_trim(data string, capacity i8) []u8 {
mut final_data := []u8{len: capacity, init: 0}
if data.len > 0 {
bytes_data := iconv.vstring_to_encoding(data, "CP1251") or { []u8{len: capacity} }
bytes_data := iconv.vstring_to_encoding(data, 'CP1251') or { []u8{len: capacity} }
diff := capacity - bytes_data.len
if diff > 0 {
@@ -79,7 +79,7 @@ fn cut_or_trim(data string, capacity i8) []u8 {
pub fn (mut id3v1 ID3v1) to_bytes() []u8 {
mut bytes := []u8{cap: 128}
bytes << "TAG".bytes()
bytes << 'TAG'.bytes()
bytes << cut_or_trim(id3v1.title, i8(30))
bytes << cut_or_trim(id3v1.artist, i8(30))
bytes << cut_or_trim(id3v1.album, i8(30))
@@ -87,7 +87,7 @@ pub fn (mut id3v1 ID3v1) to_bytes() []u8 {
bytes << cut_or_trim(id3v1.comment, i8(28))
// bytes << cut_or_trim(id3v1.track, i8(2))
bytes << if id3v1.track.len != 0 { [u8(0), id3v1.track.u8()]} else { [u8(1), u8(0)] }
bytes << if id3v1.track.len != 0 { [u8(0), id3v1.track.u8()] } else { [u8(1), u8(0)] }
id_genre := Genre.from(id3v1.genre.to_lower()) or { Genre.unknown }
bytes << u8(id_genre) // cut_or_trim([u8(id_genre)], i8(2))
@@ -100,15 +100,15 @@ pub fn (mut id3v1 ID3v1) to_bytes() []u8 {
// Добавление изменённого тэга
pub fn (mut id3v1 ID3v1) insert(mut audio File, position i8) []u8 {
// if !id3v1.embedded {
bytes_tag := id3v1.to_bytes()
// Уcтановка указателя на последний байт
audio.seek(0, os.SeekMode.end) or { panic(err) }
// Положение указателя
size := audio.tell() or { panic(err) }
// Чтение всех байта, кроме последних 128
mut all_bytes := audio.read_bytes(int(size))#[..position].clone()
bytes_tag := id3v1.to_bytes()
// Уcтановка указателя на последний байт
audio.seek(0, os.SeekMode.end) or { panic(err) }
// Положение указателя
size := audio.tell() or { panic(err) }
// Чтение всех байта, кроме последних 128
mut all_bytes := audio.read_bytes(int(size))#[..position].clone()
all_bytes << bytes_tag
all_bytes << bytes_tag
// }
return all_bytes
+6 -8
View File
@@ -27,7 +27,6 @@ import os { File }
const start_idx = 10
pub fn (mut tag ID3v23) add_frame(frame Frame) {
name := EnumID3v23.from(frame.name.to_lower()) or { EnumID3v23.unknown }
@@ -84,7 +83,6 @@ pub fn (mut id3v23 ID3v23) encode_embed_tags() {
encode_embed_tags[ID3v23](mut id3v23)
}
// Заполнение тэга
pub fn (mut id3v23 ID3v23) fill_tags(mut audio File) {
dump_data[ID3v23](mut id3v23, mut audio)
@@ -93,12 +91,12 @@ pub fn (mut id3v23 ID3v23) fill_tags(mut audio File) {
// Конвертировать ID3 v2.3 в ID3 v1
pub fn (id3v23 ID3v23) to_v1() ID3v1 {
return ID3v1{
genre: id3v23.tcon.data
title: id3v23.tit2.data
artist: id3v23.tpe2.data
album: id3v23.talb.data
track: id3v23.trck.data.split("/")[0]
year: id3v23.tyer.data
genre: id3v23.tcon.data
title: id3v23.tit2.data
artist: id3v23.tpe2.data
album: id3v23.talb.data
track: id3v23.trck.data.split('/')[0]
year: id3v23.tyer.data
comment: id3v23.txxx.data
}
}
-3
View File
@@ -2,7 +2,6 @@ module metadata
import os { File }
pub fn (mut tag ID3v24) add_frame(frame Frame) {
name := EnumID3v24.from(frame.name.to_lower()) or { EnumID3v24.unknown }
@@ -43,7 +42,6 @@ pub fn (mut tag ID3v24) add_frame(frame Frame) {
}
}
@[inline]
fn get_size_v24(size_bytes []u8) u64 {
return (u64(size_bytes[0]) << 21) | (u64(size_bytes[1]) << 14) | (u64(size_bytes[2]) << 7) | u64(size_bytes[3])
@@ -77,7 +75,6 @@ pub fn (id3v24 ID3v24) to_v1() ID3v1 {
}
}
// Добавление аудио после нового тэга
pub fn (mut id3v24 ID3v24) insert_tags_into_audio(mut audio File) ! {
insert_tags_into_audio[ID3v24](mut id3v24, mut audio)!
+90 -93
View File
@@ -1,109 +1,106 @@
module metadata
@[heap; minify]
pub struct ID3v1 {
pub mut:
// Жанр
genre string
// Название аудиодорожки
title string
// Актёр озучания
artist string
// Название альбома
album string
// Записывается как 2/5, что значит 2 трэк из 5
track string
// Год записи, может int нужно
year string
// Комментарий
comment string
// Присутвует ли тэг
embedded bool
pub mut:
// Жанр
genre string
// Название аудиодорожки
title string
// Актёр озучания
artist string
// Название альбома
album string
// Записывается как 2/5, что значит 2 трэк из 5
track string
// Год записи, может int нужно
year string
// Комментарий
comment string
// Присутвует ли тэг
embedded bool
}
@[heap; minify]
pub struct ID3v23 {
pub mut:
// Размер тэга
size u64
// Название аудиодорожки
tit2 Frame
// Автор
tpe1 Frame
// Исполнитель
tpe2 Frame
// Название альбома
talb Frame
// Год выпуска
tyer Frame
// Номер трека
trck Frame
// Жанр
tcon Frame
// Композитор (автор)
tcop Frame
// Обложка
apic Frame
// Комментарий
txxx Frame
// Затычка
unknown Frame
// Остальные фреймы
others []Frame
encoded_header []u8
// Присутвует ли тэг
embedded bool
// Версия тэга
version u8 = 3
title string = "ID3"
pub mut:
// Размер тэга
size u64
// Название аудиодорожки
tit2 Frame
// Автор
tpe1 Frame
// Исполнитель
tpe2 Frame
// Название альбома
talb Frame
// Год выпуска
tyer Frame
// Номер трека
trck Frame
// Жанр
tcon Frame
// Композитор (автор)
tcop Frame
// Обложка
apic Frame
// Комментарий
txxx Frame
// Затычка
unknown Frame
// Остальные фреймы
others []Frame
encoded_header []u8
// Присутвует ли тэг
embedded bool
// Версия тэга
version u8 = 3
title string = 'ID3'
}
@[heap; minify]
pub struct ID3v24 {
pub mut:
// Размер тэга
size u64
// Название аудиодорожки
tit2 Frame
// Автор
tpe1 Frame
// Исполнитель
tpe2 Frame
// Название альбома
talb Frame
// Год выпуска
tdrc Frame
// Номер трека
trck Frame
// Жанр
tcon Frame
// Композитор (автор)
tcop Frame
// Обложка
apic Frame
// Комментарий
txxx Frame
// Затычка
modified_frames []Frame
// Остальные фреймы
others []Frame
// Версия тэга
version u8 = 4
// Присутвует ли тэг
embedded bool
// Закодированный хэдер
encoded_header []u8
title string = "ID3"
pub mut:
// Размер тэга
size u64
// Название аудиодорожки
tit2 Frame
// Автор
tpe1 Frame
// Исполнитель
tpe2 Frame
// Название альбома
talb Frame
// Год выпуска
tdrc Frame
// Номер трека
trck Frame
// Жанр
tcon Frame
// Композитор (автор)
tcop Frame
// Обложка
apic Frame
// Комментарий
txxx Frame
// Затычка
modified_frames []Frame
// Остальные фреймы
others []Frame
// Версия тэга
version u8 = 4
// Присутвует ли тэг
embedded bool
// Закодированный хэдер
encoded_header []u8
title string = 'ID3'
}
pub struct Audio {
pub mut:
id3v1 ID3v1
id3v23 ID3v23
id3v24 ID3v24
path string
extension string = ".mp3"
pub mut:
id3v1 ID3v1
id3v23 ID3v23
id3v24 ID3v24
path string
extension string = '.mp3'
}
+9 -10
View File
@@ -2,12 +2,11 @@ module metadata
import os
pub fn (mut audio Audio) detect_tags(mut data os.File) {
// ? --- Проверка версии ID3v1 ---
last_bytes := get_bytes(mut data, -128, 3)
if last_bytes[0..3].bytestr() == "TAG" {
if last_bytes[0..3].bytestr() == 'TAG' {
audio.id3v1.embedded = true
}
// ===============================
@@ -15,7 +14,7 @@ pub fn (mut audio Audio) detect_tags(mut data os.File) {
// ? --- Проверка версии ID3v23.3 ---
first_ten_bytes := get_bytes(mut data, 0, 4)
if first_ten_bytes[..3].bytestr() == "ID3" {
if first_ten_bytes[..3].bytestr() == 'ID3' {
if first_ten_bytes[3] == 3 {
audio.id3v23.embedded = true
} else if first_ten_bytes[3] == 4 {
@@ -38,7 +37,7 @@ pub fn get_bytes(mut audio os.File, position int, size int) []u8 {
last_bytes := audio.read_bytes_at(size, u64(pos))
audio.seek(0, os.SeekMode.start) or { panic(err) }
// =========================================================
return last_bytes
}
@@ -92,7 +91,7 @@ pub fn encode_header[T](mut id3v T) {
id3v.encoded_header << [id3v.version]
// Минорная версия и спец. флаги
id3v.encoded_header << []u8{len: 2}
id3v.encoded_header << metadata.encode_header_size(id3v.size)
}
@@ -110,7 +109,7 @@ pub fn encode_embed_tags[T](mut id3v2 T) {
// Читаются только заполненные тэги
if id3v2.$(field.name).cur_size > 0 {
// Фрейма не было в файле и init не вызывался: имя берётся из поля структуры
if id3v2.$(field.name).name == "" && field.name.len == 4 {
if id3v2.$(field.name).name == '' && field.name.len == 4 {
id3v2.$(field.name).init(field.name, id3v2.version)
}
@@ -140,8 +139,8 @@ pub fn encode_embed_tags[T](mut id3v2 T) {
// Добавляет аудио из файла после нового тэга в 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")
if header.len < 10 || header[..3].bytestr() != 'ID3' {
return error('в файле нет тэга ID3v2')
}
// Размер старого тэга берётся из файла, id3v2.size уже хранит размер нового.
@@ -155,7 +154,7 @@ pub fn insert_tags_into_audio[T](mut id3v2 T, mut audio os.File) ! {
audio.seek(0, .end)!
file_size := u64(audio.tell()!)
if audio_start > file_size {
return error("размер тэга (${audio_start}) больше размера файла (${file_size})")
return error('размер тэга (${audio_start}) больше размера файла (${file_size})')
}
// Аудио читается сразу в конец encoded_header, без промежуточной копии всего файла
@@ -168,7 +167,7 @@ pub fn insert_tags_into_audio[T](mut id3v2 T, mut audio os.File) ! {
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("файл закончился раньше ожидаемого")
return error('файл закончился раньше ожидаемого')
}
read += n
}
+144
View File
@@ -82,6 +82,74 @@ fn test_roundtrip_v24() {
check_roundtrip('test_id3v24.mp3')
}
// Чтение тэгов не загружает обложку: у тэга с картинкой 3 МБ — только текстовые фреймы
fn test_read_skips_cover() {
for name in ['test_id3v23.mp3', 'test_id3v24.mp3'] {
path := tmp_copy(name)
defer {
os.rm(path) or {}
}
mut data := []u8{len: 3 * 1024 * 1024, init: u8(index % 251)}
data[0] = 0xFF
data[1] = 0xD8
tags := metadata.BookTags{
title: 'Глава 1'
album: 'Тень ветра'
author: 'Сафон'
series: 'Кладбище забытых книг'
}
metadata.write_book_tags(path, tags,
cover: metadata.Cover{
mime: 'image/jpeg'
data: data
}
) or { panic(err) }
before := u64(gc_heap_usage().total_bytes)
t := metadata.read_book_tags(path) or { panic(err) }
allocated := u64(gc_heap_usage().total_bytes) - before
assert t.has_cover
assert t.tags.title == 'Глава 1'
assert t.tags.album == 'Тень ветра'
assert t.tags.series == 'Кладбище забытых книг'
assert allocated < 1024 * 1024, '${name}: при чтении выделено ${allocated} Б'
// Обложку по-прежнему отдаёт read_cover
assert (metadata.read_cover(path) or { panic(err) }).data.len == data.len
}
}
// Замена обложки: в памяти только новый тэг — старая обложка не читается, новая копируется один раз
fn test_write_cover_allocations() {
path := tmp_copy('test_id3v23.mp3')
defer {
os.rm(path) or {}
}
mut data := []u8{len: 3 * 1024 * 1024, init: u8(index % 251)}
data[0] = 0xFF
data[1] = 0xD8
cover := metadata.Cover{
mime: 'image/jpeg'
data: data
}
tags := metadata.BookTags{
title: 'Глава 1'
album: 'Тень ветра'
}
metadata.write_book_tags(path, tags, cover: cover) or { panic(err) }
before := u64(gc_heap_usage().total_bytes)
metadata.write_book_tags(path, metadata.BookTags{ ...tags, title: 'Глава 2' },
cover: cover
) or {
panic(err)
}
allocated := u64(gc_heap_usage().total_bytes) - before
eprintln(' перезапись с обложкой 3 МБ: выделено ${f64(allocated) / 1048576:.1f} МБ')
assert allocated < u64(data.len) * 3 / 2, 'выделено ${allocated} Б'
t := metadata.read_book_tags(path) or { panic(err) }
assert t.tags.title == 'Глава 2'
assert t.has_cover
assert (metadata.read_cover(path) or { panic(err) }).data == data
}
// В файл без ID3v2 тэг добавляется в начало
fn test_write_without_id3v2() {
path := tmp_copy('test_id3v1.mp3')
@@ -151,3 +219,79 @@ fn test_write_keeps_inode() {
t := metadata.read_book_tags(path) or { panic(err) }
assert t.tags.title == 'Тот же файл'
}
// Повторная запись: тэг помещается на место старого — переписывается только он, аудио не меняется
fn test_rewrite_header_only() {
path := tmp_copy('test_id3v23.mp3')
defer {
os.rm(path) or {}
}
original := os.read_bytes(path) or { panic(err) }
tail := 2048
audio := original[original.len - tail..].clone()
// Описание больше старого тэга — полная перезапись, в тэге остаётся запас
first := metadata.write_book_tags_report(path, metadata.BookTags{
title: 'Первая запись'
comment: 'описание '.repeat(300)
}) or { panic(err) }
assert first.in_place
assert !first.header
size := os.file_size(path)
before := os.stat(path) or { panic(err) }
// Правка в пределах запаса — только тэг
second := metadata.write_book_tags_report(path, metadata.BookTags{
title: 'Вторая запись, длиннее первой'
album: 'Книга'
comment: 'описание '.repeat(300) + 'и ещё немного'
}) or { panic(err) }
assert second.header
assert os.file_size(path) == size
after := os.stat(path) or { panic(err) }
assert after.inode == before.inode
t := metadata.read_book_tags(path) or { panic(err) }
assert t.tags.title == 'Вторая запись, длиннее первой'
assert t.tags.album == 'Книга'
assert t.tags.comment.ends_with('и ещё немного')
now := os.read_bytes(path) or { panic(err) }
assert now[now.len - tail..] == audio
// Не помещается — снова полная перезапись
third := metadata.write_book_tags_report(path, metadata.BookTags{
title: 'Третья'
comment: 'длинное описание '.repeat(1000)
}) or { panic(err) }
assert !third.header
t3 := metadata.read_book_tags(path) or { panic(err) }
assert t3.tags.title == 'Третья'
end := os.read_bytes(path) or { panic(err) }
assert end[end.len - tail..] == audio
}
// Испорченное название в ID3v2 («�») — берётся исходное из ID3v1 (cp1251)
fn test_broken_v2_title_falls_back_to_v1() {
path := tmp_copy('test_id3v23.mp3')
defer {
os.rm(path) or {}
}
metadata.write_book_tags(path, metadata.BookTags{
title: '\uFFFD\uFFFD\uFFFD 1'
album: 'Книга'
}) or { panic(err) }
// ID3v1 в конце файла: «Глава 1» в cp1251
mut v1 := []u8{len: 128}
v1[0] = `T`
v1[1] = `A`
v1[2] = `G`
title := [u8(0xc3), 0xeb, 0xe0, 0xe2, 0xe0, 0x20, 0x31]
for i, c in title {
v1[3 + i] = c
}
mut f := os.open_append(path) or { panic(err) }
f.write(v1) or { panic(err) }
f.close()
t := metadata.read_book_tags(path) or { panic(err) }
assert t.tags.title == 'Глава 1'
assert t.tags.album == 'Книга'
}
+8 -9
View File
@@ -3,7 +3,7 @@ import metadata
// Проверка чтения id3v1
fn test_idv3v1() {
mut audio := os.open("./data/test_id3v1.mp3") or { panic(err) }
mut audio := os.open('./data/test_id3v1.mp3') or { panic(err) }
defer {
audio.close()
}
@@ -17,13 +17,12 @@ fn test_idv3v1() {
last_bytes := metadata.get_bytes(mut audio, -128, 128)
wav.id3v1.get_metadata(last_bytes)
assert wav.id3v1.genre == "Rock"
assert wav.id3v1.title == "Test Title"
assert wav.id3v1.artist == "Test Artist"
assert wav.id3v1.album == "Test Album"
assert wav.id3v1.track == "6"
assert wav.id3v1.year == "2026"
assert wav.id3v1.comment == "Test comment"
assert wav.id3v1.genre == 'Rock'
assert wav.id3v1.title == 'Test Title'
assert wav.id3v1.artist == 'Test Artist'
assert wav.id3v1.album == 'Test Album'
assert wav.id3v1.track == '6'
assert wav.id3v1.year == '2026'
assert wav.id3v1.comment == 'Test comment'
assert wav.id3v1.embedded == true
}
+70 -70
View File
@@ -3,7 +3,7 @@ import metadata
// Проверка чтения id3v23
fn test_idv3v23() {
mut audio := os.open("./data/test_id3v23.mp3") or { panic(err) }
mut audio := os.open('./data/test_id3v23.mp3') or { panic(err) }
defer {
audio.close()
}
@@ -16,29 +16,29 @@ fn test_idv3v23() {
wav.id3v23.get_full_size(mut audio)
wav.id3v23.fill_tags(mut audio)
assert wav.id3v23.tit2.data == "Test Title"
assert wav.id3v23.tpe1.data == "Test Author"
assert wav.id3v23.tpe2.data == "Test Narator"
assert wav.id3v23.talb.data == "Test Album"
assert wav.id3v23.tyer.data == "2026"
assert wav.id3v23.trck.data == "6/7"
assert wav.id3v23.tcon.data == "Rock"
assert wav.id3v23.tcop.data == "Test"
assert wav.id3v23.txxx.data == "comment"
assert wav.id3v23.tit2.data == 'Test Title'
assert wav.id3v23.tpe1.data == 'Test Author'
assert wav.id3v23.tpe2.data == 'Test Narator'
assert wav.id3v23.talb.data == 'Test Album'
assert wav.id3v23.tyer.data == '2026'
assert wav.id3v23.trck.data == '6/7'
assert wav.id3v23.tcon.data == 'Rock'
assert wav.id3v23.tcop.data == 'Test'
assert wav.id3v23.txxx.data == 'comment'
id3v1 := wav.id3v23.to_v1()
assert id3v1.genre == "Rock"
assert id3v1.title == "Test Title"
assert id3v1.artist == "Test Narator"
assert id3v1.album == "Test Album"
assert id3v1.track == "6"
assert id3v1.year == "2026"
assert id3v1.comment == "comment"
assert id3v1.genre == 'Rock'
assert id3v1.title == 'Test Title'
assert id3v1.artist == 'Test Narator'
assert id3v1.album == 'Test Album'
assert id3v1.track == '6'
assert id3v1.year == '2026'
assert id3v1.comment == 'comment'
}
fn test_writeidv3v23() {
mut audio := os.open("./data/test_id3v23.mp3") or { panic(err) }
mut audio := os.open('./data/test_id3v23.mp3') or { panic(err) }
defer {
audio.close()
}
@@ -52,23 +52,23 @@ fn test_writeidv3v23() {
wav.id3v23.fill_tags(mut audio)
// Изменение значений тэга
wav.id3v23.tit2.set_data("Test Title2")
wav.id3v23.tpe1.set_data("Test Author2")
wav.id3v23.tpe2.set_data("Test Narator2")
wav.id3v23.talb.set_data("Test Album2")
wav.id3v23.tyer.set_data("2027")
wav.id3v23.trck.set_data("7/7")
wav.id3v23.tcon.set_data("Rock2")
wav.id3v23.tcop.set_data("Test2")
wav.id3v23.txxx.set_data("comment2")
wav.id3v23.tit2.set_data('Test Title2')
wav.id3v23.tpe1.set_data('Test Author2')
wav.id3v23.tpe2.set_data('Test Narator2')
wav.id3v23.talb.set_data('Test Album2')
wav.id3v23.tyer.set_data('2027')
wav.id3v23.trck.set_data('7/7')
wav.id3v23.tcon.set_data('Rock2')
wav.id3v23.tcop.set_data('Test2')
wav.id3v23.txxx.set_data('comment2')
wav.id3v23.encode_embed_tags()
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) }
os.write_bytes('./data/temp_v23.mp3', wav.id3v23.encoded_header) or { panic(err) }
// Чтение файла и проверка записи
mut temp_audio := os.open("./data/temp_v23.mp3") or { panic(err) }
mut temp_audio := os.open('./data/temp_v23.mp3') or { panic(err) }
defer {
temp_audio.close()
}
@@ -82,31 +82,31 @@ fn test_writeidv3v23() {
temp_wav.id3v23.get_full_size(mut temp_audio)
temp_wav.id3v23.fill_tags(mut temp_audio)
assert temp_wav.id3v23.tit2.data == "Test Title2"
assert temp_wav.id3v23.tpe1.data == "Test Author2"
assert temp_wav.id3v23.tpe2.data == "Test Narator2"
assert temp_wav.id3v23.talb.data == "Test Album2"
assert temp_wav.id3v23.tyer.data == "2027"
assert temp_wav.id3v23.trck.data == "7/7"
assert temp_wav.id3v23.tcon.data == "Rock2"
assert temp_wav.id3v23.tcop.data == "Test2"
assert temp_wav.id3v23.txxx.data == "comment2"
assert temp_wav.id3v23.tit2.data == 'Test Title2'
assert temp_wav.id3v23.tpe1.data == 'Test Author2'
assert temp_wav.id3v23.tpe2.data == 'Test Narator2'
assert temp_wav.id3v23.talb.data == 'Test Album2'
assert temp_wav.id3v23.tyer.data == '2027'
assert temp_wav.id3v23.trck.data == '7/7'
assert temp_wav.id3v23.tcon.data == 'Rock2'
assert temp_wav.id3v23.tcop.data == 'Test2'
assert temp_wav.id3v23.txxx.data == 'comment2'
// Проверяется прочитанный с диска тэг, а не тот, что в памяти
id3v1 := temp_wav.id3v23.to_v1()
assert id3v1.genre == "Rock2"
assert id3v1.title == "Test Title2"
assert id3v1.artist == "Test Narator2"
assert id3v1.album == "Test Album2"
assert id3v1.track == "7"
assert id3v1.year == "2027"
assert id3v1.comment == "comment2"
assert id3v1.genre == 'Rock2'
assert id3v1.title == 'Test Title2'
assert id3v1.artist == 'Test Narator2'
assert id3v1.album == 'Test Album2'
assert id3v1.track == '7'
assert id3v1.year == '2027'
assert id3v1.comment == 'comment2'
}
// Проверка чтения id3v24
fn test_idv3v24() {
mut audio := os.open("./data/test_id3v24.mp3") or { panic(err) }
mut audio := os.open('./data/test_id3v24.mp3') or { panic(err) }
defer {
audio.close()
}
@@ -119,30 +119,30 @@ fn test_idv3v24() {
wav.id3v24.get_full_size(mut audio)
wav.id3v24.fill_tags(mut audio)
assert wav.id3v24.tit2.data == "Test Title"
assert wav.id3v24.tpe1.data == "Test Author"
assert wav.id3v24.tpe2.data == "Test Narator"
assert wav.id3v24.talb.data == "Test Album"
assert wav.id3v24.tdrc.data == "2026"
assert wav.id3v24.trck.data == "6/7"
assert wav.id3v24.tcon.data == "Rock"
assert wav.id3v24.tcop.data == "Test"
assert wav.id3v24.txxx.data == "comment"
assert wav.id3v24.tit2.data == 'Test Title'
assert wav.id3v24.tpe1.data == 'Test Author'
assert wav.id3v24.tpe2.data == 'Test Narator'
assert wav.id3v24.talb.data == 'Test Album'
assert wav.id3v24.tdrc.data == '2026'
assert wav.id3v24.trck.data == '6/7'
assert wav.id3v24.tcon.data == 'Rock'
assert wav.id3v24.tcop.data == 'Test'
assert wav.id3v24.txxx.data == 'comment'
id3v1 := wav.id3v24.to_v1()
assert id3v1.genre == "Rock"
assert id3v1.title == "Test Title"
assert id3v1.artist == "Test Narator"
assert id3v1.album == "Test Album"
assert id3v1.track == "6"
assert id3v1.year == "2026"
assert id3v1.comment == "comment"
assert id3v1.genre == 'Rock'
assert id3v1.title == 'Test Title'
assert id3v1.artist == 'Test Narator'
assert id3v1.album == 'Test Album'
assert id3v1.track == '6'
assert id3v1.year == '2026'
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) }
mut audio := os.open('./data/test_id3v23.mp3') or { panic(err) }
defer {
audio.close()
}
@@ -152,11 +152,11 @@ fn test_write_unicode_into_latin1_frame() {
wav.id3v23.fill_tags(mut audio)
old_size := int(wav.id3v23.size)
wav.id3v23.tit2.set_data("Глава 2")
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")
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 {}
@@ -171,19 +171,19 @@ fn test_write_unicode_into_latin1_frame() {
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.tit2.data == 'Глава 2'
// Нетронутые фреймы и аудио сохраняются без изменений
assert temp_wav.id3v23.tpe1.data == "Test Author"
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) }
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) }
mut audio := os.open('./data/test_id3v1.mp3') or { panic(err) }
defer {
audio.close()
}
@@ -191,7 +191,7 @@ fn test_insert_without_id3v2() {
wav.id3v23.encode_embed_tags()
wav.id3v23.insert_tags_into_audio(mut audio) or {
assert err.msg() == "в файле нет тэга ID3v2"
assert err.msg() == 'в файле нет тэга ID3v2'
return
}
assert false, 'Ошибка не возвращена'
+1 -1
View File
@@ -5,6 +5,6 @@ pub fn is_utf8(bytes []u8) bool {
percent := f32(first_byte_amount) / f32(bytes.len)
result := if percent >= 0.1 { true } else { false }
return result
}