*добавлен API для аудиокниг (metadata/audiobook.v): TCOM, TIT1, COMM, TXXX, APIC, запись в файл без ID3v2, cp1251, потоковая перезапись
*публичные функции перехватывают панику (recover) и возвращают ошибку, проверка размера тэга *совместимость с V master: iconv больше не обрезает NUL, у Frame убран @[heap] (баг кодогенерации) *тесты test/audiobook_test.v Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
078e68efa8
commit
8a03ad3cd4
5 files changed
+791
-7
No files matched your search
@@ -1,8 +1,10 @@
|
|||||||
- [x] Извлечение ID3 v1
|
- [x] Извлечение ID3 v1
|
||||||
- [x] Запись ID3 v1
|
- [x] Запись ID3 v1
|
||||||
- [x] Извлечение ID3 v2.3
|
- [x] Извлечение ID3 v2.3
|
||||||
- [ ] Запись ID3 v2.3
|
- [x] Запись ID3 v2.3
|
||||||
- [x] Извлечение ID3 v2.4
|
- [x] Извлечение ID3 v2.4
|
||||||
- [ ] Запись ID3 v2.4
|
- [x] Запись ID3 v2.4
|
||||||
- [x] Конвертация из ID3 v1 в v2.3 и обратно
|
- [x] Конвертация из ID3 v1 в v2.3 и обратно
|
||||||
1
|
1
|
||||||
|
- [x] API для аудиокниг (metadata/audiobook.v): TCOM, TIT1, COMM, TXXX, APIC, файлы без ID3v2, cp1251
|
||||||
|
- [x] Совместимость с V master (iconv сохраняет NUL, баг @[heap] в массивах)
|
||||||
@@ -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)!
|
||||||
|
}
|
||||||
+2
-2
@@ -2,7 +2,7 @@ module metadata
|
|||||||
|
|
||||||
import encoding.iconv
|
import encoding.iconv
|
||||||
|
|
||||||
@[heap]
|
// Без @[heap]: на V master `tag.others << frame` для heap-структуры кладёт в []Frame указатель
|
||||||
pub struct Frame {
|
pub struct Frame {
|
||||||
pub mut:
|
pub mut:
|
||||||
data string
|
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]
|
frame.addition = bytes[frame.start..frame.start + 10]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ import utils
|
|||||||
@[inline]
|
@[inline]
|
||||||
fn decode_bytes(bytes []u8) string {
|
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" }
|
text := (iconv.encoding_to_vstring(bytes, format) or { "Can't decode" }).all_before('\x00')
|
||||||
|
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ pub fn (mut id3v1 ID3v1) insert(mut audio File, position i8) []u8 {
|
|||||||
// Положение указателя
|
// Положение указателя
|
||||||
size := audio.tell() or { panic(err) }
|
size := audio.tell() or { panic(err) }
|
||||||
// Чтение всех байта, кроме последних 128
|
// Чтение всех байта, кроме последних 128
|
||||||
mut all_bytes := audio.read_bytes(int(size))#[..position]
|
mut all_bytes := audio.read_bytes(int(size))#[..position].clone()
|
||||||
|
|
||||||
all_bytes << bytes_tag
|
all_bytes << bytes_tag
|
||||||
// }
|
// }
|
||||||
|
|||||||
@@ -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, 'ошибка не возвращена'
|
||||||
|
}
|
||||||
Reference in new issue
Block a user