Files
id3_editor/metadata/audiobook.v
T

789 lines
21 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
}
// Как прошла запись: на месте (тот же файл) или новым файлом поверх старого
pub struct WriteReport {
pub:
in_place bool
// Переписан только тэг в начале файла (новый поместился на место старого), аудио не тронуто
header bool
// Почему не удалось переписать на месте (пусто, если на месте)
fallback string
}
struct ParsedTag {
mut:
version u8
frames []Frame
audio_start u64
}
// Запас в конце тэга при полной перезаписи: следующие правки (длиннее описание, другие названия)
// поместятся на место старого тэга, и аудио переписывать не придётся
const book_tag_padding = 4096
// Фреймы, которые редактор пересобирает сам, остальные переносятся без изменений
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
}
// Пустые или испорченные («�») поля — из 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(')') {
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: book_tag_padding}
mut out := [u8(`I`), `D`, `3`, version, 0, 0]
out << encode_header_size(u64(frames.len))
out << frames
return out
}
// Запись тэгов: новый тэг + аудио из исходного файла собираются во временном файле,
// затем копируются обратно в исходный. Файл остаётся тем же (inode, владелец, права):
// 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)
// Новый тэг помещается на место старого — переписать только его, аудио не трогать
if fitted := fit_tag(parsed, tag) {
if write_header(path, fitted) {
return WriteReport{
in_place: true
header: true
}
}
}
tmp_path := path + '.tagtmp'
mut buf := []u8{len: 1024 * 1024}
{
mut src := os.open(path)!
defer {
src.close()
}
mut dst := os.create(tmp_path)!
defer {
dst.close()
}
dst.write(tag) or {
os.rm(tmp_path) or {}
return err
}
src.seek(i64(parsed.audio_start), .start)!
for {
n := src.read(mut buf) or { 0 }
if n <= 0 {
break
}
dst.write(buf[..n]) or {
os.rm(tmp_path) or {}
return err
}
}
}
copy_back(tmp_path, path, mut buf) or {
// Исходный файл мог остаться наполовину переписанным — полная копия во временном
reason := err.msg()
orig := os.stat(path) or { os.Stat{} }
os.mv(tmp_path, path, overwrite: true) or {
return error('${err}; новое содержимое сохранено в ${tmp_path}')
}
if orig.mode != 0 {
os.chmod(path, int(orig.mode & 0o7777)) or {}
$if !windows {
os.chown(path, int(orig.uid), int(orig.gid)) or {}
}
}
return WriteReport{
fallback: reason
}
}
os.rm(tmp_path) or {}
return WriteReport{
in_place: true
}
}
// Тэг (без запаса), дополненный нулями ровно до размера старого тэга; none — не помещается
fn fit_tag(parsed ParsedTag, tag []u8) ?[]u8 {
if parsed.version !in [u8(3), 4] || parsed.audio_start < 10 {
return none
}
body := tag.len - book_tag_padding
if body < 10 || u64(body) > parsed.audio_start {
return none
}
mut out := []u8{cap: int(parsed.audio_start)}
out << tag[..6]
out << encode_header_size(parsed.audio_start - 10)
out << tag[10..body]
out << []u8{len: int(parsed.audio_start) - body}
return out
}
// Перезапись начала файла тем же числом байт; false — файл не открылся на запись
fn write_header(path string, data []u8) bool {
mut f := os.open_file(path, 'r+') or { return false }
defer {
f.close()
}
f.write(data) or { return false }
f.flush()
return true
}
// Содержимое from — в существующий файл to (с обрезкой), не заменяя сам файл
fn copy_back(from string, to string, mut buf []u8) ! {
mut src := os.open(from)!
defer {
src.close()
}
mut dst := os.open_file(to, 'w')!
defer {
dst.close()
}
for {
n := src.read(mut buf) or { 0 }
if n <= 0 {
break
}
dst.write(buf[..n])!
}
dst.flush()
}
// ---------- публичный 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) ! {
write_book_tags_report(path, t, opts)!
}
// То же, что write_book_tags, плюс отчёт: переписан ли файл на месте
pub fn write_book_tags_report(path string, t BookTags, opts WriteOptions) !WriteReport {
mut box := PanicBox{}
report := guard_write_tags(path, t, opts, mut box) or {
return if box.msg != '' {
box.error()
} else {
err
}
}
if box.msg != '' {
return box.error()
}
return report
}
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) !WriteReport {
defer {
if msg := recover() {
box.msg = msg
}
}
return write_book_tags_impl(path, t, opts)!
}