Media

Audio tags

Audio tags written as well as read, for MP3, FLAC, Ogg, M4A, WAV and AIFF: title, artist, album, track, year, genre, composer, lyrics, BPM and more, cover art set, and every tag stripped; written to a copy.

Version 1.0.0 · 4 functions · 2 crates

#audio#files#podcasts#privacy

Functions

What use audio_tags.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use audio_tags_read, audio_tags_write from audio_tags (Quillo 0.6).

audio_tags_read(path: str) → anyor an error

Every tag of an audio file: { format, tags, title, artist, album, album_artist, year, track, track_total, disc, disc_total, genre, comment, composer, lyricist, lyrics, copyright, publisher, isrc, bpm, encoded_by, pictures }; tags names the tag kinds in the file (ID3v2, Vorbis comments...), pictures lists { type, mime, bytes }. What is not there is null.

audio_tags_write(path: str, out_path: str, tags: any) → anyor an error

A copy of the audio file at out_path (the same file when they are equal) with tags set: any of title, artist, album, album_artist, year, track, track_total, disc, disc_total, genre, comment, composer, lyricist, lyrics, copyright, publisher, isrc, bpm, encoded_by; a null value removes that tag. An MP3's old ID3v1 tag is dropped, as it holds too little. The tags then read back.

audio_tags_set_cover(path: str, out_path: str, image_path: str) → stror an error

A copy of the audio file at out_path with the picture at image_path (JPEG or PNG) as its front cover, in place of any front cover it had. The path written.

audio_tags_strip(path: str, out_path: str) → listor an error

A copy of the audio file at out_path with every tag and picture removed (names, comments, the encoder's notes): for publishing recordings without what is written in them. The tag kinds removed.

Get it

$ quillo install audio_tags               # audio_tags.qwer into this folder$ quillo install audio_tags --example     # and its example, audio_tags.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/audio_tags.qwer$ curl -O https://quillolang.org/plugins/files/audio_tags.qwe$ curl -O https://quillolang.org/plugins/files/audio_tags_sample.mp3$ curl -O https://quillolang.org/plugins/files/audio_tags_sample.png

Then, in your program, all of it or only what you call:

main.qwe
use audio_tags.qweruse audio_tags_read, audio_tags_write from audio_tags      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
lofty = { version = "0.22", default-features = false }serde_json = { version = "1", features = ["preserve_order"] }

Example

audio_tags.qwe
// A label's upload page: each master's tags filled in from the catalogue before release, the cover art set, and a stripped copy for the press.////   quillo run audio_tags.qwe      then open http://localhost:8080/releaseuse "audio_tags.qwer"let MASTER = "audio_tags_sample.mp3"route GET /release {    let before = audio_tags_read(MASTER)?    let after = audio_tags_write(MASTER, "data/audio_tags/release.mp3", {        title: "Morning Tone",        artist: "The Quillos",        album: "First Light",        album_artist: "The Quillos",        year: 2026,        track: 3,        track_total: 10,        genre: "Ambient",        composer: "Ana Lima",        bpm: 96,        comment: null,    })?    let covered = audio_tags_set_cover("data/audio_tags/release.mp3", "data/audio_tags/release.mp3", "audio_tags_sample.png")?    {        before: { title: before.title, artist: before.artist, tags: before.tags },        after: after,        cover: audio_tags_read(covered)?.pictures,        press: audio_tags_strip(covered, "data/audio_tags/press.mp3")?,        press_after: audio_tags_read("data/audio_tags/press.mp3")?.tags,        unknown: audio_tags_write(MASTER, "data/audio_tags/x.mp3", { mood: "calm" }).error,    }}
$ quillo run audio_tags.qwe$ curl 'localhost:8080/release'

Source

audio_tags.qwer
//! Audio tags read and written, for MP3 (ID3), FLAC and Ogg (Vorbis comments), M4A (MP4), WAV and AIFF: title, artist, album, track and disc, year, genre, composer, comment, lyrics, BPM and the rest, cover art set, and every tag stripped. Written to a copy, the original left alone.//! version: 1.0.0//!//! [dependencies]//! lofty = { version = "0.22", default-features = false }//! serde_json = { version = "1", features = ["preserve_order"] }use lofty::config::WriteOptions;use lofty::file::{AudioFile, TaggedFile, TaggedFileExt};use lofty::picture::{MimeType, Picture, PictureType};use lofty::prelude::*;use lofty::tag::{ItemKey, Tag, TagType};use serde_json::{json, Map, Value};const MAX_COVER: u64 = 16 * 1024 * 1024;const TEXT_KEYS: [(&str, ItemKey); 9] = [    ("album_artist", ItemKey::AlbumArtist), ("composer", ItemKey::Composer), ("lyricist", ItemKey::Lyricist), ("lyrics", ItemKey::Lyrics),    ("copyright", ItemKey::CopyrightMessage), ("publisher", ItemKey::Publisher), ("isrc", ItemKey::Isrc), ("bpm", ItemKey::IntegerBpm), ("encoded_by", ItemKey::EncodedBy),];fn open(path: &str) -> Result<TaggedFile, String> {    let p = path.to_string();    match std::panic::catch_unwind(move || lofty::read_from_path(&p)) {        Ok(Ok(f)) => Ok(f),        Ok(Err(e)) => Err(format!("{}: {}", path, e)),        Err(_) => Err(format!("{}: the file could not be read", path)),    }}fn copy_to(path: &str, out_path: &str) -> Result<(), String> {    if path == out_path { return Ok(()) }    if let Some(d) = std::path::Path::new(out_path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }    std::fs::copy(path, out_path).map_err(|e| format!("{} to {}: {}", path, out_path, e))?;    Ok(())}fn tag_type_name(t: TagType) -> &'static str {    match t { TagType::Ape => "APE", TagType::Id3v1 => "ID3v1", TagType::Id3v2 => "ID3v2", TagType::Mp4Ilst => "MP4", TagType::VorbisComments => "Vorbis comments", TagType::RiffInfo => "RIFF INFO", TagType::AiffText => "AIFF text", _ => "other" }}fn main_tag(f: &mut TaggedFile) -> &mut Tag {    if f.primary_tag_mut().is_none() {        let t = f.primary_tag_type();        f.insert_tag(Tag::new(t));    }    f.primary_tag_mut().unwrap()}/// Every tag of an audio file: `{ format, tags, title, artist, album, album_artist, year, track, track_total, disc, disc_total, genre, comment, composer, lyricist, lyrics, copyright, publisher, isrc, bpm, encoded_by, pictures }`; `tags` names the tag kinds in the file (ID3v2, Vorbis comments...), `pictures` lists `{ type, mime, bytes }`. What is not there is null.pub fn audio_tags_read(path: &str) -> Result<Value, String> {    let f = open(path)?;    let tag = f.primary_tag().or_else(|| f.first_tag());    let mut m = Map::new();    m.insert("format".into(), json!(format!("{:?}", f.file_type()).to_lowercase()));    m.insert("tags".into(), json!(f.tags().iter().map(|t| tag_type_name(t.tag_type())).collect::<Vec<_>>()));    let s = |v: Option<std::borrow::Cow<'_, str>>| v.map(|x| x.trim().to_string()).filter(|x| !x.is_empty());    m.insert("title".into(), json!(tag.and_then(|t| s(t.title()))));    m.insert("artist".into(), json!(tag.and_then(|t| s(t.artist()))));    m.insert("album".into(), json!(tag.and_then(|t| s(t.album()))));    m.insert("year".into(), json!(tag.and_then(|t| t.year())));    m.insert("track".into(), json!(tag.and_then(|t| t.track())));    m.insert("track_total".into(), json!(tag.and_then(|t| t.track_total())));    m.insert("disc".into(), json!(tag.and_then(|t| t.disk())));    m.insert("disc_total".into(), json!(tag.and_then(|t| t.disk_total())));    m.insert("genre".into(), json!(tag.and_then(|t| s(t.genre()))));    m.insert("comment".into(), json!(tag.and_then(|t| s(t.comment()))));    for (k, key) in TEXT_KEYS.iter() { m.insert(k.to_string(), json!(tag.and_then(|t| t.get_string(key)).map(|x| x.to_string()))); }    let pics: Vec<Value> = f.tags().iter().flat_map(|t| t.pictures().iter()).map(|p| json!({ "type": format!("{:?}", p.pic_type()), "mime": p.mime_type().map(|m| m.as_str().to_string()), "bytes": p.data().len() })).collect();    m.insert("pictures".into(), Value::Array(pics));    Ok(Value::Object(m))}/// A copy of the audio file at `out_path` (the same file when they are equal) with `tags` set: any of `title`, `artist`, `album`, `album_artist`, `year`, `track`, `track_total`, `disc`, `disc_total`, `genre`, `comment`, `composer`, `lyricist`, `lyrics`, `copyright`, `publisher`, `isrc`, `bpm`, `encoded_by`; a null value removes that tag. An MP3's old ID3v1 tag is dropped, as it holds too little. The tags then read back.pub fn audio_tags_write(path: &str, out_path: &str, tags: serde_json::Value) -> Result<Value, String> {    let o = tags.as_object().ok_or("tags are a map")?;    copy_to(path, out_path)?;    let mut f = open(out_path)?;    {        let t = main_tag(&mut f);        for (k, v) in o {            let text = match v { Value::Null => None, Value::String(s) => Some(s.clone()), other => Some(other.to_string()) };            let num = |v: &Value| -> Result<Option<u32>, String> { match v { Value::Null => Ok(None), Value::Number(n) => n.as_u64().map(|x| Some(x as u32)).ok_or_else(|| format!("`{}` is a whole number", k)), Value::String(s) => s.trim().parse().map(Some).map_err(|_| format!("`{}` is a whole number", k)), _ => Err(format!("`{}` is a whole number", k)) } };            match k.as_str() {                "title" => match text { Some(x) => t.set_title(x), None => t.remove_title() },                "artist" => match text { Some(x) => t.set_artist(x), None => t.remove_artist() },                "album" => match text { Some(x) => t.set_album(x), None => t.remove_album() },                "genre" => match text { Some(x) => t.set_genre(x), None => t.remove_genre() },                "comment" => match text { Some(x) => t.set_comment(x), None => t.remove_comment() },                "year" => match num(v)? { Some(x) => t.set_year(x), None => t.remove_year() },                "track" => match num(v)? { Some(x) => t.set_track(x), None => t.remove_track() },                "track_total" => match num(v)? { Some(x) => t.set_track_total(x), None => t.remove_track_total() },                "disc" => match num(v)? { Some(x) => t.set_disk(x), None => t.remove_disk() },                "disc_total" => match num(v)? { Some(x) => t.set_disk_total(x), None => t.remove_disk_total() },                other => {                    let key = TEXT_KEYS.iter().find(|(n, _)| *n == other).map(|(_, k)| k.clone()).ok_or_else(|| format!("`{}` is not a tag this writes", other))?;                    match text { Some(x) => { t.insert_text(key, x); } None => t.remove_key(&key) }                }            }        }    }    f.save_to_path(out_path, WriteOptions::default()).map_err(|e| format!("{}: {}", out_path, e))?;    // An old ID3v1 tag would keep the old title for some players: it goes.    if f.tags().iter().any(|t| t.tag_type() == TagType::Id3v1) && f.primary_tag_type() != TagType::Id3v1 {        TagType::Id3v1.remove_from_path(out_path).map_err(|e| format!("{}: {}", out_path, e))?;    }    audio_tags_read(out_path)}/// A copy of the audio file at `out_path` with the picture at `image_path` (JPEG or PNG) as its front cover, in place of any front cover it had. The path written.pub fn audio_tags_set_cover(path: &str, out_path: &str, image_path: &str) -> Result<String, String> {    let meta = std::fs::metadata(image_path).map_err(|e| format!("{}: {}", image_path, e))?;    if meta.len() > MAX_COVER { return Err(format!("{}: a cover is at most 16 MB", image_path)) }    let data = std::fs::read(image_path).map_err(|e| format!("{}: {}", image_path, e))?;    let mime = if data.starts_with(&[0x89, b'P', b'N', b'G']) { MimeType::Png } else if data.starts_with(&[0xFF, 0xD8]) { MimeType::Jpeg } else { return Err(format!("{}: a cover is a JPEG or a PNG", image_path)) };    copy_to(path, out_path)?;    let mut f = open(out_path)?;    {        let t = main_tag(&mut f);        t.remove_picture_type(PictureType::CoverFront);        t.push_picture(Picture::new_unchecked(PictureType::CoverFront, Some(mime), None, data));    }    f.save_to_path(out_path, WriteOptions::default()).map_err(|e| format!("{}: {}", out_path, e))?;    Ok(out_path.to_string())}/// A copy of the audio file at `out_path` with every tag and picture removed (names, comments, the encoder's notes): for publishing recordings without what is written in them. The tag kinds removed.pub fn audio_tags_strip(path: &str, out_path: &str) -> Result<Vec<String>, String> {    copy_to(path, out_path)?;    let f = open(out_path)?;    let mut removed = Vec::new();    for t in f.tags() {        let tt = t.tag_type();        tt.remove_from_path(out_path).map_err(|e| format!("{}: {}", out_path, e))?;        removed.push(tag_type_name(tt).to_string());    }    Ok(removed)}
Thread
87Useful? Like it, or add to its thread below.

Thread 0 posts

On Audio tags: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.