diff --git a/src/core/collection/album.rs b/src/core/collection/album.rs index 926d2b2..7a35591 100644 --- a/src/core/collection/album.rs +++ b/src/core/collection/album.rs @@ -1,21 +1,19 @@ use std::mem; -use serde::{Deserialize, Serialize}; - use crate::core::collection::{ merge::{Merge, MergeSorted}, track::Track, }; /// An album is a collection of tracks that were released together. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Album { pub id: AlbumId, pub tracks: Vec, } /// The album identifier. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq, Hash)] pub struct AlbumId { pub year: u32, pub title: String, diff --git a/src/core/collection/artist.rs b/src/core/collection/artist.rs index ee58257..437ab71 100644 --- a/src/core/collection/artist.rs +++ b/src/core/collection/artist.rs @@ -4,7 +4,6 @@ use std::{ mem, }; -use serde::{Deserialize, Serialize}; use url::Url; use uuid::Uuid; @@ -15,7 +14,7 @@ use crate::core::collection::{ }; /// An artist. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Artist { pub id: ArtistId, pub sort: Option, @@ -25,7 +24,7 @@ pub struct Artist { } /// The artist identifier. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ArtistId { pub name: String, } @@ -185,7 +184,7 @@ pub trait IMbid { } /// MusicBrainz reference. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct MusicBrainz(Url); impl MusicBrainz { diff --git a/src/core/collection/track.rs b/src/core/collection/track.rs index 0fc4e1b..5853de1 100644 --- a/src/core/collection/track.rs +++ b/src/core/collection/track.rs @@ -1,9 +1,7 @@ -use serde::{Deserialize, Serialize}; - use crate::core::collection::merge::Merge; /// A single track on an album. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Track { pub id: TrackId, pub artist: Vec, @@ -11,14 +9,14 @@ pub struct Track { } /// The track identifier. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct TrackId { pub number: u32, pub title: String, } /// The track quality. Combines format and bitrate information. -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Quality { pub format: Format, pub bitrate: u32, @@ -31,7 +29,7 @@ impl Track { } /// The track file format. -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Format { Flac, Mp3, diff --git a/src/core/database/json/mod.rs b/src/core/database/json/mod.rs index 2ece5a2..233bf9e 100644 --- a/src/core/database/json/mod.rs +++ b/src/core/database/json/mod.rs @@ -10,6 +10,8 @@ use crate::core::{ database::{IDatabase, LoadError, SaveError}, }; +use super::serde::{deserialize::DeserializeDatabase, serialize::SerializeDatabase}; + impl From for LoadError { fn from(err: serde_json::Error) -> LoadError { LoadError::SerDeError(err.to_string()) @@ -48,11 +50,13 @@ impl JsonDatabase { impl IDatabase for JsonDatabase { fn load(&self) -> Result { let serialized = self.backend.read()?; - Ok(serde_json::from_str(&serialized)?) + let database: DeserializeDatabase = serde_json::from_str(&serialized)?; + database.try_into() } fn save(&mut self, collection: &Collection) -> Result<(), SaveError> { - let serialized = serde_json::to_string(&collection)?; + let database: SerializeDatabase = collection.into(); + let serialized = serde_json::to_string(&database)?; self.backend.write(&serialized)?; Ok(()) } @@ -65,20 +69,42 @@ pub mod testmod; mod tests { use std::collections::HashMap; + use mockall::predicate; + use crate::core::{ - collection::{ - artist::{Artist, ArtistId}, - Collection, - }, + collection::{artist::Artist, Collection}, testmod::FULL_COLLECTION, }; use super::*; use testmod::DATABASE_JSON; + fn expected() -> Collection { + let mut expected = FULL_COLLECTION.to_owned(); + for artist in expected.iter_mut() { + artist.albums.clear(); + } + expected + } + + #[test] + fn save() { + let write_data = FULL_COLLECTION.to_owned(); + let input = DATABASE_JSON.to_owned(); + + let mut backend = MockIJsonDatabaseBackend::new(); + backend + .expect_write() + .with(predicate::eq(input)) + .times(1) + .return_once(|_| Ok(())); + + JsonDatabase::new(backend).save(&write_data).unwrap(); + } + #[test] fn load() { - let expected = FULL_COLLECTION.to_owned(); + let expected = expected(); let result = Ok(DATABASE_JSON.to_owned()); let mut backend = MockIJsonDatabaseBackend::new(); @@ -91,37 +117,31 @@ mod tests { #[test] fn reverse() { - // Saving is non-deterministic due to HashMap, but regardless of how the data ends up being - // saved, loading it again should always yield the exact same data as was input. - struct MockIJsonDatabaseBackend { - data: Option, - } + let input = DATABASE_JSON.to_owned(); + let result = Ok(input.clone()); - impl IJsonDatabaseBackend for MockIJsonDatabaseBackend { - fn write(&mut self, json: &str) -> Result<(), std::io::Error> { - let _ = self.data.insert(json.to_owned()); - Ok(()) - } - - fn read(&self) -> Result { - Ok(self.data.as_ref().unwrap().clone()) - } - } - - let backend = MockIJsonDatabaseBackend { data: None }; + let mut backend = MockIJsonDatabaseBackend::new(); + backend + .expect_write() + .with(predicate::eq(input)) + .times(1) + .return_once(|_| Ok(())); + backend.expect_read().times(1).return_once(|| result); let mut database = JsonDatabase::new(backend); let write_data = FULL_COLLECTION.to_owned(); database.save(&write_data).unwrap(); let read_data: Vec = database.load().unwrap(); - assert_eq!(write_data, read_data); + // Album information is not saved to disk. + let expected = expected(); + assert_eq!(read_data, expected); } #[test] fn load_errors() { let json = String::from(""); - let serde_err = serde_json::from_str::(&json); + let serde_err = serde_json::from_str::(&json); assert!(serde_err.is_err()); let serde_err: LoadError = serde_err.unwrap_err().into(); @@ -131,11 +151,9 @@ mod tests { #[test] fn save_errors() { - let mut object = HashMap::::new(); - object.insert( - ArtistId::new(String::from("artist")), - String::from("string"), - ); + // serde_json will raise an error as it requires keys to be strings. + let mut object = HashMap::, String>::new(); + object.insert(Some(String::from("artist")), String::from("string")); let serde_err = serde_json::to_string(&object); assert!(serde_err.is_err()); diff --git a/src/core/database/json/testmod.rs b/src/core/database/json/testmod.rs index 319ccd2..de0a457 100644 --- a/src/core/database/json/testmod.rs +++ b/src/core/database/json/testmod.rs @@ -1,57 +1,17 @@ -pub static DATABASE_JSON: &str = "[\ +pub static DATABASE_JSON: &str = "{\ + \"V20240210\":\ + [\ {\ - \"id\":{\"name\":\"album_artist a\"},\ + \"name\":\"album_artist a\",\ \"sort\":null,\ \"musicbrainz\":\"https://musicbrainz.org/artist/00000000-0000-0000-0000-000000000000\",\ \"properties\":{\ \"MusicButler\":[\"https://www.musicbutler.io/artist-page/000000000\"],\ \"Qobuz\":[\"https://www.qobuz.com/nl-nl/interpreter/artist-a/download-streaming-albums\"]\ - },\ - \"albums\":[\ - {\ - \"id\":{\"year\":1998,\"title\":\"album_title a.a\"},\ - \"tracks\":[\ - {\ - \"id\":{\"number\":1,\"title\":\"track a.a.1\"},\ - \"artist\":[\"artist a.a.1\"],\ - \"quality\":{\"format\":\"Flac\",\"bitrate\":992}\ - },\ - {\ - \"id\":{\"number\":2,\"title\":\"track a.a.2\"},\ - \"artist\":[\"artist a.a.2.1\",\"artist a.a.2.2\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":320}\ - },\ - {\ - \"id\":{\"number\":3,\"title\":\"track a.a.3\"},\ - \"artist\":[\"artist a.a.3\"],\ - \"quality\":{\"format\":\"Flac\",\"bitrate\":1061}\ - },\ - {\ - \"id\":{\"number\":4,\"title\":\"track a.a.4\"},\ - \"artist\":[\"artist a.a.4\"],\ - \"quality\":{\"format\":\"Flac\",\"bitrate\":1042}\ }\ - ]\ },\ {\ - \"id\":{\"year\":2015,\"title\":\"album_title a.b\"},\ - \"tracks\":[\ - {\ - \"id\":{\"number\":1,\"title\":\"track a.b.1\"},\ - \"artist\":[\"artist a.b.1\"],\ - \"quality\":{\"format\":\"Flac\",\"bitrate\":1004}\ - },\ - {\ - \"id\":{\"number\":2,\"title\":\"track a.b.2\"},\ - \"artist\":[\"artist a.b.2\"],\ - \"quality\":{\"format\":\"Flac\",\"bitrate\":1077}\ - }\ - ]\ - }\ - ]\ - },\ - {\ - \"id\":{\"name\":\"album_artist b\"},\ + \"name\":\"album_artist b\",\ \"sort\":null,\ \"musicbrainz\":\"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111\",\ \"properties\":{\ @@ -61,144 +21,19 @@ pub static DATABASE_JSON: &str = "[\ \"https://www.musicbutler.io/artist-page/111111112\"\ ],\ \"Qobuz\":[\"https://www.qobuz.com/nl-nl/interpreter/artist-b/download-streaming-albums\"]\ - },\ - \"albums\":[\ - {\ - \"id\":{\"year\":2003,\"title\":\"album_title b.a\"},\ - \"tracks\":[\ - {\ - \"id\":{\"number\":1,\"title\":\"track b.a.1\"},\ - \"artist\":[\"artist b.a.1\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":190}\ - },\ - {\ - \"id\":{\"number\":2,\"title\":\"track b.a.2\"},\ - \"artist\":[\"artist b.a.2.1\",\"artist b.a.2.2\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":120}\ }\ - ]\ },\ {\ - \"id\":{\"year\":2008,\"title\":\"album_title b.b\"},\ - \"tracks\":[\ - {\ - \"id\":{\"number\":1,\"title\":\"track b.b.1\"},\ - \"artist\":[\"artist b.b.1\"],\ - \"quality\":{\"format\":\"Flac\",\"bitrate\":1077}\ - },\ - {\ - \"id\":{\"number\":2,\"title\":\"track b.b.2\"},\ - \"artist\":[\"artist b.b.2.1\",\"artist b.b.2.2\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":320}\ - }\ - ]\ - },\ - {\ - \"id\":{\"year\":2009,\"title\":\"album_title b.c\"},\ - \"tracks\":[\ - {\ - \"id\":{\"number\":1,\"title\":\"track b.c.1\"},\ - \"artist\":[\"artist b.c.1\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":190}\ - },\ - {\ - \"id\":{\"number\":2,\"title\":\"track b.c.2\"},\ - \"artist\":[\"artist b.c.2.1\",\"artist b.c.2.2\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":120}\ - }\ - ]\ - },\ - {\ - \"id\":{\"year\":2015,\"title\":\"album_title b.d\"},\ - \"tracks\":[\ - {\ - \"id\":{\"number\":1,\"title\":\"track b.d.1\"},\ - \"artist\":[\"artist b.d.1\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":190}\ - },\ - {\ - \"id\":{\"number\":2,\"title\":\"track b.d.2\"},\ - \"artist\":[\"artist b.d.2.1\",\"artist b.d.2.2\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":120}\ - }\ - ]\ - }\ - ]\ - },\ - {\ - \"id\":{\"name\":\"album_artist c\"},\ + \"name\":\"album_artist c\",\ \"sort\":null,\ \"musicbrainz\":\"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111\",\ - \"properties\":{},\ - \"albums\":[\ - {\ - \"id\":{\"year\":1985,\"title\":\"album_title c.a\"},\ - \"tracks\":[\ - {\ - \"id\":{\"number\":1,\"title\":\"track c.a.1\"},\ - \"artist\":[\"artist c.a.1\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":320}\ + \"properties\":{}\ },\ {\ - \"id\":{\"number\":2,\"title\":\"track c.a.2\"},\ - \"artist\":[\"artist c.a.2.1\",\"artist c.a.2.2\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":120}\ - }\ - ]\ - },\ - {\ - \"id\":{\"year\":2018,\"title\":\"album_title c.b\"},\ - \"tracks\":[\ - {\ - \"id\":{\"number\":1,\"title\":\"track c.b.1\"},\ - \"artist\":[\"artist c.b.1\"],\ - \"quality\":{\"format\":\"Flac\",\"bitrate\":1041}\ - },\ - {\ - \"id\":{\"number\":2,\"title\":\"track c.b.2\"},\ - \"artist\":[\"artist c.b.2.1\",\"artist c.b.2.2\"],\ - \"quality\":{\"format\":\"Flac\",\"bitrate\":756}\ - }\ - ]\ - }\ - ]\ - },\ - {\ - \"id\":{\"name\":\"album_artist d\"},\ + \"name\":\"album_artist d\",\ \"sort\":null,\ \"musicbrainz\":null,\ - \"properties\":{},\ - \"albums\":[\ - {\ - \"id\":{\"year\":1995,\"title\":\"album_title d.a\"},\ - \"tracks\":[\ - {\ - \"id\":{\"number\":1,\"title\":\"track d.a.1\"},\ - \"artist\":[\"artist d.a.1\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":120}\ - },\ - {\ - \"id\":{\"number\":2,\"title\":\"track d.a.2\"},\ - \"artist\":[\"artist d.a.2.1\",\"artist d.a.2.2\"],\ - \"quality\":{\"format\":\"Mp3\",\"bitrate\":120}\ + \"properties\":{}\ }\ ]\ - },\ - {\ - \"id\":{\"year\":2028,\"title\":\"album_title d.b\"},\ - \"tracks\":[\ - {\ - \"id\":{\"number\":1,\"title\":\"track d.b.1\"},\ - \"artist\":[\"artist d.b.1\"],\ - \"quality\":{\"format\":\"Flac\",\"bitrate\":841}\ - },\ - {\ - \"id\":{\"number\":2,\"title\":\"track d.b.2\"},\ - \"artist\":[\"artist d.b.2.1\",\"artist d.b.2.2\"],\ - \"quality\":{\"format\":\"Flac\",\"bitrate\":756}\ - }\ - ]\ - }\ - ]\ - }\ - ]"; + }"; diff --git a/src/core/database/mod.rs b/src/core/database/mod.rs index c0de380..b3882c1 100644 --- a/src/core/database/mod.rs +++ b/src/core/database/mod.rs @@ -2,13 +2,15 @@ #[cfg(feature = "database-json")] pub mod json; +#[cfg(feature = "database-json")] +mod serde; use std::fmt; #[cfg(test)] use mockall::automock; -use crate::core::collection::Collection; +use crate::core::collection::{self, Collection}; /// Trait for interacting with the database. #[cfg_attr(test, automock)] @@ -59,6 +61,14 @@ impl From for LoadError { } } +impl From for LoadError { + fn from(err: collection::Error) -> Self { + match err { + collection::Error::UrlError(e) => LoadError::SerDeError(e), + } + } +} + /// Error type for database calls. #[derive(Debug)] pub enum SaveError { @@ -109,6 +119,10 @@ mod tests { assert!(!io_err.to_string().is_empty()); assert!(!format!("{:?}", io_err).is_empty()); + let col_err: LoadError = collection::Error::UrlError(String::from("get rekt")).into(); + assert!(!col_err.to_string().is_empty()); + assert!(!format!("{:?}", col_err).is_empty()); + let io_err: SaveError = io::Error::new(io::ErrorKind::Interrupted, "error").into(); assert!(!io_err.to_string().is_empty()); assert!(!format!("{:?}", io_err).is_empty()); diff --git a/src/core/database/serde/deserialize.rs b/src/core/database/serde/deserialize.rs new file mode 100644 index 0000000..a4f0e16 --- /dev/null +++ b/src/core/database/serde/deserialize.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; + +use serde::Deserialize; + +use crate::{ + collection::artist::{ArtistId, MusicBrainz}, + core::{ + collection::{artist::Artist, Collection}, + database::{serde::Database, LoadError}, + }, +}; + +pub type DeserializeDatabase = Database; + +#[derive(Debug, Deserialize)] +pub struct DeserializeArtist { + name: String, + sort: Option, + musicbrainz: Option, + properties: HashMap>, +} + +impl TryFrom for Collection { + type Error = LoadError; + + fn try_from(database: DeserializeDatabase) -> Result { + match database { + Database::V20240210(collection) => collection + .into_iter() + .map(|artist| artist.try_into()) + .collect(), + } + } +} + +impl TryFrom for Artist { + type Error = LoadError; + + fn try_from(artist: DeserializeArtist) -> Result { + Ok(Artist { + id: ArtistId::new(artist.name), + sort: artist.sort.map(ArtistId::new), + musicbrainz: artist.musicbrainz.map(MusicBrainz::new).transpose()?, + properties: artist.properties, + albums: vec![], + }) + } +} diff --git a/src/core/database/serde/mod.rs b/src/core/database/serde/mod.rs new file mode 100644 index 0000000..453b017 --- /dev/null +++ b/src/core/database/serde/mod.rs @@ -0,0 +1,11 @@ +//! Helper module for backends that can use serde for (de)serialisation. + +pub mod deserialize; +pub mod serialize; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub enum Database { + V20240210(Vec), +} diff --git a/src/core/database/serde/serialize.rs b/src/core/database/serde/serialize.rs new file mode 100644 index 0000000..6043ee2 --- /dev/null +++ b/src/core/database/serde/serialize.rs @@ -0,0 +1,39 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::core::{ + collection::{artist::Artist, Collection}, + database::serde::Database, +}; + +pub type SerializeDatabase<'a> = Database>; + +#[derive(Debug, Serialize)] +pub struct SerializeArtist<'a> { + name: &'a str, + sort: Option<&'a str>, + musicbrainz: Option<&'a str>, + properties: BTreeMap<&'a str, &'a Vec>, +} + +impl<'a> From<&'a Collection> for SerializeDatabase<'a> { + fn from(collection: &'a Collection) -> Self { + Database::V20240210(collection.iter().map(|artist| artist.into()).collect()) + } +} + +impl<'a> From<&'a Artist> for SerializeArtist<'a> { + fn from(artist: &'a Artist) -> Self { + SerializeArtist { + name: &artist.id.name, + sort: artist.sort.as_ref().map(|id| id.name.as_ref()), + musicbrainz: artist.musicbrainz.as_ref().map(|mb| mb.as_ref()), + properties: artist + .properties + .iter() + .map(|(k, v)| (k.as_ref(), v)) + .collect(), + } + } +} diff --git a/tests/database/json.rs b/tests/database/json.rs index 95c8a99..0f9d6b2 100644 --- a/tests/database/json.rs +++ b/tests/database/json.rs @@ -4,7 +4,7 @@ use once_cell::sync::Lazy; use tempfile::NamedTempFile; use musichoard::{ - collection::artist::Artist, + collection::{artist::Artist, Collection}, database::{ json::{backend::JsonDatabaseFileBackend, JsonDatabase}, IDatabase, @@ -16,6 +16,30 @@ use crate::testlib::COLLECTION; pub static DATABASE_TEST_FILE: Lazy = Lazy::new(|| fs::canonicalize("./tests/files/database/database.json").unwrap()); +fn expected() -> Collection { + let mut expected = COLLECTION.to_owned(); + for artist in expected.iter_mut() { + artist.albums.clear(); + } + expected +} + +#[test] +fn save() { + let file = NamedTempFile::new().unwrap(); + + let backend = JsonDatabaseFileBackend::new(file.path()); + let mut database = JsonDatabase::new(backend); + + let write_data = COLLECTION.to_owned(); + database.save(&write_data).unwrap(); + + let expected = fs::read_to_string(&*DATABASE_TEST_FILE).unwrap(); + let actual = fs::read_to_string(file.path()).unwrap(); + + assert_eq!(actual, expected); +} + #[test] fn load() { let backend = JsonDatabaseFileBackend::new(&*DATABASE_TEST_FILE); @@ -23,14 +47,12 @@ fn load() { let read_data: Vec = database.load().unwrap(); - let expected = COLLECTION.to_owned(); + let expected = expected(); assert_eq!(read_data, expected); } #[test] fn reverse() { - // Saving is non-deterministic due to HashMap, but regardless of how the data ends up being - // saved, loading it again should always yield the exact same data as was input. let file = NamedTempFile::new().unwrap(); let backend = JsonDatabaseFileBackend::new(file.path()); @@ -40,5 +62,7 @@ fn reverse() { database.save(&write_data).unwrap(); let read_data: Vec = database.load().unwrap(); - assert_eq!(write_data, read_data); + // Album data is not saved into database. + let expected = expected(); + assert_eq!(read_data, expected); } diff --git a/tests/files/database/database.json b/tests/files/database/database.json index c5fe56c..465e2b9 100644 --- a/tests/files/database/database.json +++ b/tests/files/database/database.json @@ -1 +1 @@ -[{"id":{"name":"Аркона"},"sort":{"name":"Arkona"},"musicbrainz":"https://musicbrainz.org/artist/baad262d-55ef-427a-83c7-f7530964f212","properties":{"Bandcamp":["https://arkonamoscow.bandcamp.com/"],"MusicButler":["https://www.musicbutler.io/artist-page/283448581"],"Qobuz":["https://www.qobuz.com/nl-nl/interpreter/arkona/download-streaming-albums"]},"albums":[{"id":{"year":2011,"title":"Slovo"},"tracks":[{"id":{"number":1,"title":"Az’"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":992}},{"id":{"number":2,"title":"Arkaim"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":1061}},{"id":{"number":3,"title":"Bol’no mne"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":1004}},{"id":{"number":4,"title":"Leshiy"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":1077}},{"id":{"number":5,"title":"Zakliatie"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":1041}},{"id":{"number":6,"title":"Predok"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":756}},{"id":{"number":7,"title":"Nikogda"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":1059}},{"id":{"number":8,"title":"Tam za tumanami"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":1023}},{"id":{"number":9,"title":"Potomok"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":838}},{"id":{"number":10,"title":"Slovo"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":1028}},{"id":{"number":11,"title":"Odna"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":991}},{"id":{"number":12,"title":"Vo moiom sadochke…"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":919}},{"id":{"number":13,"title":"Stenka na stenku"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":1039}},{"id":{"number":14,"title":"Zimushka"},"artist":["Аркона"],"quality":{"format":"Flac","bitrate":974}}]}]},{"id":{"name":"Eluveitie"},"sort":null,"musicbrainz":"https://musicbrainz.org/artist/8000598a-5edb-401c-8e6d-36b167feaf38","properties":{"MusicButler":["https://www.musicbutler.io/artist-page/269358403"],"Qobuz":["https://www.qobuz.com/nl-nl/interpreter/eluveitie/download-streaming-albums"]},"albums":[{"id":{"year":2004,"title":"Vên [re‐recorded]"},"tracks":[{"id":{"number":1,"title":"Verja Urit an Bitus"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":961}},{"id":{"number":2,"title":"Uis Elveti"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1067}},{"id":{"number":3,"title":"Ôrô"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":933}},{"id":{"number":4,"title":"Lament"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1083}},{"id":{"number":5,"title":"Druid"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1073}},{"id":{"number":6,"title":"Jêzaïg"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1002}}]},{"id":{"year":2008,"title":"Slania"},"tracks":[{"id":{"number":1,"title":"Samon"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":953}},{"id":{"number":2,"title":"Primordial Breath"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1103}},{"id":{"number":3,"title":"Inis Mona"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1117}},{"id":{"number":4,"title":"Gray Sublime Archon"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1092}},{"id":{"number":5,"title":"Anagantios"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":923}},{"id":{"number":6,"title":"Bloodstained Ground"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1098}},{"id":{"number":7,"title":"The Somber Lay"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1068}},{"id":{"number":8,"title":"Slanias Song"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1098}},{"id":{"number":9,"title":"Giamonios"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":825}},{"id":{"number":10,"title":"Tarvos"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1115}},{"id":{"number":11,"title":"Calling the Rain"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1096}},{"id":{"number":12,"title":"Elembivos"},"artist":["Eluveitie"],"quality":{"format":"Flac","bitrate":1059}}]}]},{"id":{"name":"Frontside"},"sort":null,"musicbrainz":"https://musicbrainz.org/artist/3a901353-fccd-4afd-ad01-9c03f451b490","properties":{"MusicButler":["https://www.musicbutler.io/artist-page/826588800"],"Qobuz":["https://www.qobuz.com/nl-nl/interpreter/frontside/download-streaming-albums"]},"albums":[{"id":{"year":2001,"title":"…nasze jest królestwo, potęga i chwała na wieki…"},"tracks":[{"id":{"number":1,"title":"Intro = Chaos"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":1024}},{"id":{"number":2,"title":"Modlitwa"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":1073}},{"id":{"number":3,"title":"Długa droga z piekła"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":1058}},{"id":{"number":4,"title":"Synowie ognia"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":1066}},{"id":{"number":5,"title":"1902"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":1074}},{"id":{"number":6,"title":"Krew za krew"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":1080}},{"id":{"number":7,"title":"Kulminacja"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":992}},{"id":{"number":8,"title":"Judasz"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":1018}},{"id":{"number":9,"title":"Więzy"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":1077}},{"id":{"number":10,"title":"Zagubione dusze"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":1033}},{"id":{"number":11,"title":"Linia życia"},"artist":["Frontside"],"quality":{"format":"Flac","bitrate":987}}]}]},{"id":{"name":"Heaven’s Basement"},"sort":{"name":"Heaven’s Basement"},"musicbrainz":"https://musicbrainz.org/artist/c2c4d56a-d599-4a18-bd2f-ae644e2198cc","properties":{"MusicButler":["https://www.musicbutler.io/artist-page/291158685"],"Qobuz":["https://www.qobuz.com/nl-nl/interpreter/heaven-s-basement/download-streaming-albums"]},"albums":[{"id":{"year":2011,"title":"Paper Plague"},"tracks":[{"id":{"number":0,"title":"Paper Plague"},"artist":["Heaven’s Basement"],"quality":{"format":"Mp3","bitrate":320}}]},{"id":{"year":2011,"title":"Unbreakable"},"tracks":[{"id":{"number":1,"title":"Unbreakable"},"artist":["Heaven’s Basement"],"quality":{"format":"Mp3","bitrate":208}},{"id":{"number":2,"title":"Guilt Trips and Sins"},"artist":["Heaven’s Basement"],"quality":{"format":"Mp3","bitrate":205}},{"id":{"number":3,"title":"The Long Goodbye"},"artist":["Heaven’s Basement"],"quality":{"format":"Mp3","bitrate":227}},{"id":{"number":4,"title":"Close Encounters"},"artist":["Heaven’s Basement"],"quality":{"format":"Mp3","bitrate":213}},{"id":{"number":5,"title":"Paranoia"},"artist":["Heaven’s Basement"],"quality":{"format":"Mp3","bitrate":218}},{"id":{"number":6,"title":"Let Me Out of Here"},"artist":["Heaven’s Basement"],"quality":{"format":"Mp3","bitrate":207}},{"id":{"number":7,"title":"Leeches"},"artist":["Heaven’s Basement"],"quality":{"format":"Mp3","bitrate":225}}]}]},{"id":{"name":"Metallica"},"sort":null,"musicbrainz":"https://musicbrainz.org/artist/65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab","properties":{"MusicButler":["https://www.musicbutler.io/artist-page/3996865"],"Qobuz":["https://www.qobuz.com/nl-nl/interpreter/metallica/download-streaming-albums"]},"albums":[{"id":{"year":1984,"title":"Ride the Lightning"},"tracks":[{"id":{"number":1,"title":"Fight Fire with Fire"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":954}},{"id":{"number":2,"title":"Ride the Lightning"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":951}},{"id":{"number":3,"title":"For Whom the Bell Tolls"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":889}},{"id":{"number":4,"title":"Fade to Black"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":939}},{"id":{"number":5,"title":"Trapped under Ice"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":955}},{"id":{"number":6,"title":"Escape"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":941}},{"id":{"number":7,"title":"Creeping Death"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":958}},{"id":{"number":8,"title":"The Call of Ktulu"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":888}}]},{"id":{"year":1999,"title":"S&M"},"tracks":[{"id":{"number":1,"title":"The Ecstasy of Gold"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":875}},{"id":{"number":2,"title":"The Call of Ktulu"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1030}},{"id":{"number":3,"title":"Master of Puppets"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1082}},{"id":{"number":4,"title":"Of Wolf and Man"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1115}},{"id":{"number":5,"title":"The Thing That Should Not Be"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1029}},{"id":{"number":6,"title":"Fuel"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1057}},{"id":{"number":7,"title":"The Memory Remains"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1080}},{"id":{"number":8,"title":"No Leaf Clover"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1004}},{"id":{"number":9,"title":"Hero of the Day"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":962}},{"id":{"number":10,"title":"Devil’s Dance"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1076}},{"id":{"number":11,"title":"Bleeding Me"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":993}},{"id":{"number":12,"title":"Nothing Else Matters"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":875}},{"id":{"number":13,"title":"Until It Sleeps"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1038}},{"id":{"number":14,"title":"For Whom the Bell Tolls"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1072}},{"id":{"number":15,"title":"−Human"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1029}},{"id":{"number":16,"title":"Wherever I May Roam"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1035}},{"id":{"number":17,"title":"Outlaw Torn"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1042}},{"id":{"number":18,"title":"Sad but True"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1082}},{"id":{"number":19,"title":"One"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":1017}},{"id":{"number":20,"title":"Enter Sandman"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":993}},{"id":{"number":21,"title":"Battery"},"artist":["Metallica"],"quality":{"format":"Flac","bitrate":967}}]}]}] \ No newline at end of file +{"V20240210":[{"name":"Аркона","sort":"Arkona","musicbrainz":"https://musicbrainz.org/artist/baad262d-55ef-427a-83c7-f7530964f212","properties":{"Bandcamp":["https://arkonamoscow.bandcamp.com/"],"MusicButler":["https://www.musicbutler.io/artist-page/283448581"],"Qobuz":["https://www.qobuz.com/nl-nl/interpreter/arkona/download-streaming-albums"]}},{"name":"Eluveitie","sort":null,"musicbrainz":"https://musicbrainz.org/artist/8000598a-5edb-401c-8e6d-36b167feaf38","properties":{"MusicButler":["https://www.musicbutler.io/artist-page/269358403"],"Qobuz":["https://www.qobuz.com/nl-nl/interpreter/eluveitie/download-streaming-albums"]}},{"name":"Frontside","sort":null,"musicbrainz":"https://musicbrainz.org/artist/3a901353-fccd-4afd-ad01-9c03f451b490","properties":{"MusicButler":["https://www.musicbutler.io/artist-page/826588800"],"Qobuz":["https://www.qobuz.com/nl-nl/interpreter/frontside/download-streaming-albums"]}},{"name":"Heaven’s Basement","sort":"Heaven’s Basement","musicbrainz":"https://musicbrainz.org/artist/c2c4d56a-d599-4a18-bd2f-ae644e2198cc","properties":{"MusicButler":["https://www.musicbutler.io/artist-page/291158685"],"Qobuz":["https://www.qobuz.com/nl-nl/interpreter/heaven-s-basement/download-streaming-albums"]}},{"name":"Metallica","sort":null,"musicbrainz":"https://musicbrainz.org/artist/65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab","properties":{"MusicButler":["https://www.musicbutler.io/artist-page/3996865"],"Qobuz":["https://www.qobuz.com/nl-nl/interpreter/metallica/download-streaming-albums"]}}]} \ No newline at end of file