Files
id3_editor/metadata/audiobook.v
T
K1ndWha1eandClaude Opus 5.5 8a03ad3cd4 *добавлен 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>
2026-10-08 15:17:53 +03:00

668 lines
17 KiB
V
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)!
}