677 lines
17 KiB
V
677 lines
17 KiB
V
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)
|
||
|
||
// Владелец и права исходного файла: перезапись через временный файл иначе их потеряет
|
||
// (на NAS файлы библиотеки принадлежат пользователю приложения, а агент может работать от root)
|
||
orig := os.stat(path) or { os.Stat{} }
|
||
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
|
||
if orig.mode != 0 {
|
||
os.chmod(tmp_path, int(orig.mode & 0o7777)) or {}
|
||
$if !windows {
|
||
os.chown(tmp_path, int(orig.uid), int(orig.gid)) or {}
|
||
}
|
||
}
|
||
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)!
|
||
}
|