Compare commits
9
Commits
1e686ba304
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
095499b0fe | ||
|
|
6161a7faaa | ||
|
|
4c9184aeb2 | ||
|
|
dc13ae43b8 | ||
|
|
71b838e7d4 | ||
|
|
a76bbe18f1 | ||
|
|
a3e244205a | ||
|
|
b037345b7b | ||
|
|
a6b4f0fbcf |
No files matched your search
+296
-60
@@ -60,13 +60,30 @@ pub:
|
||||
remove_cover bool
|
||||
}
|
||||
|
||||
// Как прошла запись: на месте (тот же файл) или новым файлом поверх старого
|
||||
pub struct WriteReport {
|
||||
pub:
|
||||
in_place bool
|
||||
// Переписан только тэг в начале файла (новый поместился на место старого), аудио не тронуто
|
||||
header bool
|
||||
// Почему не удалось переписать на месте (пусто, если на месте)
|
||||
fallback string
|
||||
}
|
||||
|
||||
struct ParsedTag {
|
||||
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']
|
||||
@@ -122,6 +139,7 @@ fn parse_id3v2(path string) !ParsedTag {
|
||||
}
|
||||
bytes = unsafe { clean }
|
||||
}
|
||||
parsed.raw = bytes
|
||||
|
||||
mut pos := u64(10)
|
||||
// Расширенный заголовок
|
||||
@@ -156,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 {
|
||||
@@ -276,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
|
||||
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
|
||||
}
|
||||
@@ -325,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(')') {
|
||||
@@ -478,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 {
|
||||
@@ -503,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 := [
|
||||
@@ -536,52 +675,152 @@ 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 frame.name == 'APIC' && (new_cover != none || opts.remove_cover) {
|
||||
continue
|
||||
}
|
||||
if kept_frame(frame, drop_cover) {
|
||||
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
|
||||
}
|
||||
|
||||
// Запись тэгов: новый тэг + аудио из исходного файла, через временный файл.
|
||||
// Запись тэгов: новый тэг + аудио из исходного файла собираются во временном файле,
|
||||
// затем копируются обратно в исходный. Файл остаётся тем же (inode, владелец, права):
|
||||
// Audiobookshelf опознаёт файлы по inode, и замена файла новым путает его библиотеку.
|
||||
// Файл без ID3v2 тоже поддерживается — тэг просто ставится в начало
|
||||
fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) ! {
|
||||
parsed := parse_id3v2(path)!
|
||||
tag := build_tag(parsed, t, opts)
|
||||
fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) !WriteReport {
|
||||
// Старая обложка заменяется или удаляется — её с диска не читать, остальные фреймы переносятся как есть
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Владелец и права исходного файла: перезапись через временный файл иначе их потеряет
|
||||
// (на NAS файлы библиотеки принадлежат пользователю приложения, а агент может работать от root)
|
||||
orig := os.stat(path) or { os.Stat{} }
|
||||
tmp_path := path + '.tagtmp'
|
||||
mut buf := []u8{len: 1024 * 1024}
|
||||
defer {
|
||||
unsafe { buf.free() }
|
||||
}
|
||||
{
|
||||
mut src := os.open(path)!
|
||||
mut dst := os.create(tmp_path)!
|
||||
mut ok := false
|
||||
defer {
|
||||
src.close()
|
||||
}
|
||||
mut dst := os.create(tmp_path)!
|
||||
defer {
|
||||
dst.close()
|
||||
if !ok {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
dst.write(tag)!
|
||||
src.seek(i64(parsed.audio_start), .start)!
|
||||
mut buf := []u8{len: 1024 * 1024}
|
||||
// Новый тэг (без запаса) помещается на место старого
|
||||
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)!
|
||||
defer {
|
||||
src.close()
|
||||
}
|
||||
mut dst := os.open_file(to, 'w')!
|
||||
defer {
|
||||
dst.close()
|
||||
}
|
||||
for {
|
||||
n := src.read(mut buf) or { 0 }
|
||||
if n <= 0 {
|
||||
@@ -589,16 +828,7 @@ fn write_book_tags_impl(path string, t BookTags, opts WriteOptions) ! {
|
||||
}
|
||||
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)!
|
||||
dst.flush()
|
||||
}
|
||||
|
||||
// ---------- публичный API с защитой от паники ----------
|
||||
@@ -635,8 +865,13 @@ pub fn read_cover(path string) !Cover {
|
||||
|
||||
// Запись тэгов книги в файл
|
||||
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{}
|
||||
guard_write_tags(path, t, opts, mut box) or {
|
||||
report := guard_write_tags(path, t, opts, mut box) or {
|
||||
return if box.msg != '' {
|
||||
box.error()
|
||||
} else {
|
||||
@@ -646,6 +881,7 @@ pub fn write_book_tags(path string, t BookTags, opts WriteOptions) ! {
|
||||
if box.msg != '' {
|
||||
return box.error()
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
fn guard_read_tags(path string, mut box PanicBox) !FileTags {
|
||||
@@ -666,11 +902,11 @@ fn guard_read_cover(path string, mut box PanicBox) !Cover {
|
||||
return read_cover_impl(path)!
|
||||
}
|
||||
|
||||
fn guard_write_tags(path string, t BookTags, opts WriteOptions, mut box PanicBox) ! {
|
||||
fn guard_write_tags(path string, t BookTags, opts WriteOptions, mut box PanicBox) !WriteReport {
|
||||
defer {
|
||||
if msg := recover() {
|
||||
box.msg = msg
|
||||
}
|
||||
}
|
||||
write_book_tags_impl(path, t, opts)!
|
||||
return write_book_tags_impl(path, t, opts)!
|
||||
}
|
||||
+17
-13
@@ -2,7 +2,7 @@ module metadata
|
||||
|
||||
import encoding.iconv
|
||||
|
||||
// Без @[heap]: на V master `tag.others << frame` для heap-структуры кладёт в []Frame указатель
|
||||
@[heap]
|
||||
pub struct Frame {
|
||||
pub mut:
|
||||
data string
|
||||
@@ -45,15 +45,15 @@ fn (mut frame Frame) fill(bytes []u8, start_position u64, idv3_version u8) {
|
||||
|
||||
// Содержимое фрейма
|
||||
frame.encoding_name = match bytes[frame.start + 10] {
|
||||
0 { "ISO-8859-1" }
|
||||
1 { "UTF-16LE" }
|
||||
2 { "UTF-16BE" }
|
||||
else { "UTF-8" }
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+150
-28
@@ -156,41 +156,163 @@ enum Genre as u8 {
|
||||
}
|
||||
|
||||
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 {
|
||||
match genre {
|
||||
.unknown {
|
||||
return "Unknown"
|
||||
} else {
|
||||
return 'Unknown'
|
||||
}
|
||||
else {
|
||||
return genres_name[u8(genre)]
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -4,11 +4,10 @@ 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
|
||||
@@ -22,10 +21,11 @@ pub fn (mut tag ID3v1) get_metadata(last_bytes []u8) {
|
||||
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))
|
||||
|
||||
+1
-3
@@ -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)
|
||||
@@ -97,7 +95,7 @@ pub fn (id3v23 ID3v23) to_v1() ID3v1 {
|
||||
title: id3v23.tit2.data
|
||||
artist: id3v23.tpe2.data
|
||||
album: id3v23.talb.data
|
||||
track: id3v23.trck.data.split("/")[0]
|
||||
track: id3v23.trck.data.split('/')[0]
|
||||
year: id3v23.tyer.data
|
||||
comment: id3v23.txxx.data
|
||||
}
|
||||
|
||||
@@ -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)!
|
||||
|
||||
+3
-6
@@ -1,6 +1,5 @@
|
||||
module metadata
|
||||
|
||||
|
||||
@[heap; minify]
|
||||
pub struct ID3v1 {
|
||||
pub mut:
|
||||
@@ -56,7 +55,7 @@ pub struct ID3v23 {
|
||||
embedded bool
|
||||
// Версия тэга
|
||||
version u8 = 3
|
||||
title string = "ID3"
|
||||
title string = 'ID3'
|
||||
}
|
||||
|
||||
@[heap; minify]
|
||||
@@ -94,16 +93,14 @@ pub struct ID3v24 {
|
||||
embedded bool
|
||||
// Закодированный хэдер
|
||||
encoded_header []u8
|
||||
title string = "ID3"
|
||||
title string = 'ID3'
|
||||
}
|
||||
|
||||
|
||||
pub struct Audio {
|
||||
pub mut:
|
||||
id3v1 ID3v1
|
||||
id3v23 ID3v23
|
||||
id3v24 ID3v24
|
||||
path string
|
||||
extension string = ".mp3"
|
||||
extension string = '.mp3'
|
||||
}
|
||||
|
||||
+7
-8
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
@@ -131,3 +199,99 @@ fn test_write_keeps_mode() {
|
||||
assert after.mode & 0o777 == before.mode & 0o777
|
||||
assert after.uid == before.uid
|
||||
}
|
||||
|
||||
// Файл остаётся тем же: Audiobookshelf опознаёт файлы по inode
|
||||
fn test_write_keeps_inode() {
|
||||
path := tmp_copy('test_id3v23.mp3')
|
||||
defer {
|
||||
os.rm(path) or {}
|
||||
}
|
||||
before := os.stat(path) or { panic(err) }
|
||||
metadata.write_book_tags(path, metadata.BookTags{
|
||||
title: 'Тот же файл'
|
||||
comment: 'длинное описание '.repeat(500)
|
||||
}) or { panic(err) }
|
||||
after := os.stat(path) or { panic(err) }
|
||||
$if !windows {
|
||||
assert after.inode == before.inode
|
||||
}
|
||||
assert !os.exists(path + '.tagtmp')
|
||||
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
@@ -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
@@ -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, 'Ошибка не возвращена'
|
||||
|
||||
Reference in new issue
Block a user