id3-json 0.3.1

Read and write ID3 tags in a machine-readable way
Documentation
use anyhow::anyhow;
use id3::TagLike;
use id3::frame::{Frame, Content, Picture, PictureType};
use base64::prelude::*;

use crate::input::Args;

pub fn read_from_tag(tag: &id3::Tag, args: &Args) -> serde_json::Value {
    // There could be many comments, but in my music library, it seems like it's common to just
    // have one with a "description" set to an empty string. So let's have a single "comment" field
    // that reads and writes there.
    let comment = tag.comments().
        find(|c| c.description.is_empty()).
        map(|c| remove_nul_byte(&c.text).to_string());

    let covers = tag.pictures().
        filter(|p| is_cover(p)).
        map(|p| if args.with_covers {
            serde_json::json!({
                "mime_type":   p.mime_type,
                "type":        cover_type(p),
                "description": p.description,
                "size":        p.data.len(),
                "data":        BASE64_STANDARD.encode(&p.data),
            })
        } else {
            serde_json::json!({
                "mime_type":   p.mime_type,
                "type":        cover_type(p),
                "description": p.description,
                "size":        p.data.len(),
            })
        }).
        collect::<Vec<_>>();

    if tag.version() == id3::Version::Id3v24 {
        serde_json::json!({
            "version": format!("{}", tag.version()),
            "data": {
                "title": tag.title().map(remove_nul_byte),
                "artist": tag.artist().map(remove_nul_byte),
                "album": tag.album().map(remove_nul_byte),
                "track": tag.track(),
                "date": tag.date_recorded().map(|ts| format!("{}", ts)),
                "genre": tag.genre().map(remove_nul_byte),
                "comment": comment,
                "covers": covers,
            },
        })
    } else {
        serde_json::json!({
            "version": format!("{}", tag.version()),
            "data": {
                "title": tag.title().map(remove_nul_byte),
                "artist": tag.artist().map(remove_nul_byte),
                "album": tag.album().map(remove_nul_byte),
                "track": tag.track(),
                "year": tag.year(),
                "genre": tag.genre().map(remove_nul_byte),
                "comment": comment,
                "covers": covers,
            },
        })
    }
}

pub fn write_to_tag(
    json_map: &serde_json::Map<String, serde_json::Value>,
    tag: &mut id3::Tag,
    version: Option<id3::Version>,
) -> anyhow::Result<()> {
    // Check for a nested "data" key to read fields from
    if let Some(serde_json::Value::Object(fields_map)) = json_map.get("data") {
        return write_to_tag(fields_map, tag, version);
    };

    let version = version.unwrap_or_else(|| tag.version());

    for (key, value) in json_map {
        match key.as_str() {
            "title" => {
                if let Some(title) = extract_string("title", value)? {
                    tag.set_title(title);
                } else {
                    tag.remove_title();
                }
            },
            "artist" => {
                if let Some(artist) = extract_string("artist", value)? {
                    tag.set_artist(artist);
                } else {
                    tag.remove_artist();
                }
            },
            "album" => {
                if let Some(album) = extract_string("album", value)? {
                    tag.set_album(album);
                } else {
                    tag.remove_album();
                }
            },
            "track" => {
                if let Some(track) = extract_u32("track", value)? {
                    tag.set_track(track);
                } else {
                    tag.remove_track();
                }
            },
            "year" if version < id3::Version::Id3v24 => {
                if let Some(year) = extract_u32("year", value)? {
                    tag.set_year(year.try_into()?);
                } else {
                    tag.remove_year();
                }
            },
            "date" if version >= id3::Version::Id3v24 => {
                if let Some(date) = extract_string("date", value)? {
                    tag.set_date_recorded(date.parse()?);
                } else {
                    tag.remove_date_recorded();
                }
            },
            "genre" => {
                if let Some(genre) = extract_string("genre", value)? {
                    tag.set_genre(genre);
                } else {
                    tag.remove_genre();
                }
            },
            "comment" => {
                let mut comment_frames = tag.remove("COMM");
                let existing_index = comment_frames.iter().
                    position(|c| c.content().comment().unwrap().description.is_empty());
                let new_comment_body = extract_string("comment", value)?;

                match (existing_index, new_comment_body) {
                    (Some(index), None) => {
                        comment_frames.remove(index);
                    },
                    (Some(index), Some(text)) => {
                        let existing_comment = comment_frames[index].content().comment().unwrap();
                        let mut new_comment = existing_comment.clone();
                        new_comment.text = text;

                        let new_frame = id3::Frame::with_content("COMM", id3::Content::Comment(new_comment));
                        comment_frames[index] = new_frame;
                    },
                    (None, Some(text)) => {
                        let new_comment = id3::frame::Comment {
                            lang: String::new(),
                            description: String::new(),
                            text,
                        };
                        let new_frame = id3::Frame::with_content("COMM", id3::Content::Comment(new_comment));

                        comment_frames.push(new_frame);
                    }
                    (None, None) => continue,
                }

                for frame in comment_frames {
                    tag.add_frame(frame);
                }
            },
            "covers" => {
                let covers = value.as_array().
                    ok_or_else(|| anyhow!("The `covers` key needs to be an array of entries"))?;

                tag.remove("APIC");

                for cover_data in covers {
                    let cover_data = cover_data.as_object().
                        ok_or_else(|| anyhow!("Entries in the `covers` array need to be objects"))?;

                    let mime_type = cover_data.get("mime_type").
                        and_then(serde_json::Value::as_str).
                        map(String::from).
                        unwrap_or_else(|| String::from("image/jpeg"));

                    let picture_type = match cover_data.get("type").and_then(serde_json::Value::as_str) {
                        Some("front") => PictureType::CoverFront,
                        Some("back")  => PictureType::CoverBack,
                        None          => PictureType::CoverFront,
                        _             => PictureType::Other,
                    };

                    let data_base64 = cover_data.get("data").
                        and_then(serde_json::Value::as_str).
                        map(String::from).
                        ok_or_else(|| anyhow!("Entries in the `covers` array need to have a base64-encoded `data` field"))?;
                    let data = BASE64_STANDARD.decode(&data_base64)?;

                    let description = cover_data.get("description").
                        and_then(serde_json::Value::as_str).
                        map(String::from).
                        unwrap_or_else(String::new);

                    let picture = Picture { mime_type, picture_type, data, description };

                    tag.add_frame(Frame::with_content("APIC", Content::Picture(picture)));
                }
            },
            _ => (),
        }
    }

    Ok(())
}


fn extract_string(label: &str, json_value: &serde_json::Value) -> anyhow::Result<Option<String>> {
    match json_value {
        serde_json::Value::Null          => Ok(None),
        serde_json::Value::String(value) => Ok(Some(value.clone())),
        _ => Err(anyhow!("Invalid string value for \"{}\": {:?}", label, json_value)),
    }
}

fn extract_u32(label: &str, json_value: &serde_json::Value) -> anyhow::Result<Option<u32>> {
    let invalid_number = || anyhow!("Invalid numeric value for \"{}\": {:?}", label, json_value);

    match json_value {
        serde_json::Value::Null => Ok(None),
        serde_json::Value::String(value) => Ok(Some(value.parse()?)),
        serde_json::Value::Number(number) => {
            let value = number.as_u64().ok_or_else(invalid_number)?.try_into()?;
            Ok(Some(value))
        },
        _ => Err(invalid_number()),
    }
}

fn remove_nul_byte(input: &str) -> &str {
    input.trim_end_matches('\u{0000}')
}

fn is_cover(picture: &Picture) -> bool {
    matches!(
        picture.picture_type,
        PictureType::CoverFront | PictureType::CoverBack | PictureType::Other
    )
}

fn cover_type(picture: &Picture) -> &'static str {
    match picture.picture_type {
        PictureType::CoverFront => "front",
        PictureType::CoverBack => "back",
        _ => "other",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_extract_string() {
        let json = serde_json::json!("String!");
        let value = extract_string("_", &json).unwrap();
        assert_eq!(value, Some(String::from("String!")));

        let json = serde_json::json!({ "key": "String!" });
        let value = extract_string("key", &json.get("key").unwrap()).unwrap();
        assert_eq!(value, Some(String::from("String!")));

        let json = serde_json::json!({ "key": None::<String> });
        let value = extract_string("key", &json.get("key").unwrap()).unwrap();
        assert_eq!(value, None);

        let json = serde_json::json!({ "key": 13 });
        assert!(extract_string("key", &json.get("key").unwrap()).is_err());

        let json = serde_json::json!({ "key": ["String!"] });
        assert!(extract_string("key", &json.get("key").unwrap()).is_err());
    }

    #[test]
    fn test_extract_u32() {
        let json = serde_json::json!(42);
        let value = extract_u32("_", &json).unwrap();
        assert_eq!(value, Some(42));

        let json = serde_json::json!(None::<u64>);
        let value = extract_u32("_", &json).unwrap();
        assert_eq!(value, None);

        let json = serde_json::json!({ "key": "13" });
        let value = extract_u32("key", &json.get("key").unwrap()).unwrap();
        assert_eq!(value, Some(13));

        let json = serde_json::json!({ "key": "String!" });
        assert!(extract_u32("key", &json.get("key").unwrap()).is_err());

        let json = serde_json::json!({ "key": ["String!"] });
        assert!(extract_u32("key", &json.get("key").unwrap()).is_err());

        let json = serde_json::json!({ "key": u64::MAX });
        assert!(extract_u32("key", &json.get("key").unwrap()).is_err());
    }
}