Decide carefully where external::musicbrainz
belongs
#196
@ -1,7 +1,10 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
use musichoard::{
|
use musichoard::{
|
||||||
external::musicbrainz::{http::MusicBrainzHttp, LookupArtistRequest, MusicBrainzClient},
|
external::musicbrainz::{
|
||||||
|
api::{lookup::LookupArtistRequest, MusicBrainzClient},
|
||||||
|
http::MusicBrainzHttp,
|
||||||
|
},
|
||||||
interface::musicbrainz::Mbid,
|
interface::musicbrainz::Mbid,
|
||||||
};
|
};
|
||||||
use structopt::StructOpt;
|
use structopt::StructOpt;
|
||||||
|
@ -4,7 +4,9 @@ use std::{num::ParseIntError, str::FromStr};
|
|||||||
|
|
||||||
use musichoard::{
|
use musichoard::{
|
||||||
collection::album::AlbumDate,
|
collection::album::AlbumDate,
|
||||||
external::musicbrainz::{http::MusicBrainzHttp, MusicBrainzClient, SearchReleaseGroupRequest},
|
external::musicbrainz::{
|
||||||
|
api::search::SearchReleaseGroupRequest, api::MusicBrainzClient, http::MusicBrainzHttp,
|
||||||
|
},
|
||||||
interface::musicbrainz::Mbid,
|
interface::musicbrainz::Mbid,
|
||||||
};
|
};
|
||||||
use structopt::StructOpt;
|
use structopt::StructOpt;
|
||||||
|
105
src/external/musicbrainz/api/lookup.rs
vendored
Normal file
105
src/external/musicbrainz/api/lookup.rs
vendored
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
Error, MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType,
|
||||||
|
SerdeAlbumSecondaryType, SerdeMbid, MB_BASE_URL,
|
||||||
|
},
|
||||||
|
IMusicBrainzHttp,
|
||||||
|
},
|
||||||
|
interface::musicbrainz::Mbid,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||||
|
pub fn lookup_artist(
|
||||||
|
&mut self,
|
||||||
|
request: LookupArtistRequest,
|
||||||
|
) -> Result<LookupArtistResponse, Error> {
|
||||||
|
let mut include: Vec<String> = vec![];
|
||||||
|
|
||||||
|
let mbid: String = request.mbid.uuid().as_hyphenated().to_string();
|
||||||
|
|
||||||
|
if request.release_groups {
|
||||||
|
include.push(String::from("release-groups"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let include: String =
|
||||||
|
form_urlencoded::byte_serialize(include.join("+").as_bytes()).collect();
|
||||||
|
let url = format!("{MB_BASE_URL}/artist/{mbid}?inc={include}");
|
||||||
|
|
||||||
|
let response: DeserializeLookupArtistResponse = self.http.get(&url)?;
|
||||||
|
Ok(response.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LookupArtistRequest<'a> {
|
||||||
|
mbid: &'a Mbid,
|
||||||
|
release_groups: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> LookupArtistRequest<'a> {
|
||||||
|
pub fn new(mbid: &'a Mbid) -> Self {
|
||||||
|
LookupArtistRequest {
|
||||||
|
mbid,
|
||||||
|
release_groups: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn include_release_groups(&mut self) -> &mut Self {
|
||||||
|
self.release_groups = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LookupArtistResponse {
|
||||||
|
pub release_groups: Vec<LookupArtistResponseReleaseGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
struct DeserializeLookupArtistResponse {
|
||||||
|
release_groups: Vec<DeserializeLookupArtistResponseReleaseGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeLookupArtistResponse> for LookupArtistResponse {
|
||||||
|
fn from(value: DeserializeLookupArtistResponse) -> Self {
|
||||||
|
LookupArtistResponse {
|
||||||
|
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LookupArtistResponseReleaseGroup {
|
||||||
|
pub id: Mbid,
|
||||||
|
pub title: String,
|
||||||
|
pub first_release_date: AlbumDate,
|
||||||
|
pub primary_type: AlbumPrimaryType,
|
||||||
|
pub secondary_types: Vec<AlbumSecondaryType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
struct DeserializeLookupArtistResponseReleaseGroup {
|
||||||
|
id: SerdeMbid,
|
||||||
|
title: String,
|
||||||
|
first_release_date: SerdeAlbumDate,
|
||||||
|
primary_type: SerdeAlbumPrimaryType,
|
||||||
|
secondary_types: Vec<SerdeAlbumSecondaryType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeLookupArtistResponseReleaseGroup> for LookupArtistResponseReleaseGroup {
|
||||||
|
fn from(value: DeserializeLookupArtistResponseReleaseGroup) -> Self {
|
||||||
|
LookupArtistResponseReleaseGroup {
|
||||||
|
id: value.id.into(),
|
||||||
|
title: value.title,
|
||||||
|
first_release_date: value.first_release_date.into(),
|
||||||
|
primary_type: value.primary_type.into(),
|
||||||
|
secondary_types: value.secondary_types.into_iter().map(Into::into).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
222
src/external/musicbrainz/api/mod.rs
vendored
Normal file
222
src/external/musicbrainz/api/mod.rs
vendored
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
use std::{fmt, num};
|
||||||
|
|
||||||
|
use serde::{de::Visitor, Deserialize, Deserializer};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
external::musicbrainz::HttpError,
|
||||||
|
interface::musicbrainz::{Mbid, MbidError},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod lookup;
|
||||||
|
pub mod search;
|
||||||
|
|
||||||
|
const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2";
|
||||||
|
const MB_RATE_LIMIT_CODE: u16 = 503;
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Error {
|
||||||
|
/// The HTTP client failed.
|
||||||
|
Http(String),
|
||||||
|
/// The client reached the API rate limit.
|
||||||
|
RateLimit,
|
||||||
|
/// The API response could not be understood.
|
||||||
|
Unknown(u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Error::Http(s) => write!(f, "the HTTP client failed: {s}"),
|
||||||
|
Error::RateLimit => write!(f, "the API rate limit has been reached"),
|
||||||
|
Error::Unknown(u) => write!(f, "the API response could not be understood: status {u}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HttpError> for Error {
|
||||||
|
fn from(err: HttpError) -> Self {
|
||||||
|
match err {
|
||||||
|
HttpError::Client(s) => Error::Http(s),
|
||||||
|
HttpError::Status(status) => match status {
|
||||||
|
MB_RATE_LIMIT_CODE => Error::RateLimit,
|
||||||
|
_ => Error::Unknown(status),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MusicBrainzClient<Http> {
|
||||||
|
http: Http,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Http> MusicBrainzClient<Http> {
|
||||||
|
pub fn new(http: Http) -> Self {
|
||||||
|
MusicBrainzClient { http }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_album_date(date: &AlbumDate) -> Option<String> {
|
||||||
|
match date.year {
|
||||||
|
Some(year) => match date.month {
|
||||||
|
Some(month) => match date.day {
|
||||||
|
Some(day) => Some(format!("{year}-{month:02}-{day:02}")),
|
||||||
|
None => Some(format!("{year}-{month:02}")),
|
||||||
|
},
|
||||||
|
None => Some(format!("{year}")),
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SerdeMbid(Mbid);
|
||||||
|
|
||||||
|
impl From<SerdeMbid> for Mbid {
|
||||||
|
fn from(value: SerdeMbid) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SerdeMbidVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for SerdeMbidVisitor {
|
||||||
|
type Value = SerdeMbid;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a valid MusicBrainz identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(SerdeMbid(
|
||||||
|
v.try_into()
|
||||||
|
.map_err(|e: MbidError| E::custom(e.to_string()))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SerdeMbid {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
deserializer.deserialize_str(SerdeMbidVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SerdeAlbumDate(AlbumDate);
|
||||||
|
|
||||||
|
impl From<SerdeAlbumDate> for AlbumDate {
|
||||||
|
fn from(value: SerdeAlbumDate) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SerdeAlbumDateVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for SerdeAlbumDateVisitor {
|
||||||
|
type Value = SerdeAlbumDate;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a valid YYYY(-MM-(-DD)) date")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
let mut elems = v.split('-');
|
||||||
|
|
||||||
|
let elem = elems.next();
|
||||||
|
let year = elem
|
||||||
|
.and_then(|s| if s.is_empty() { None } else { Some(s.parse()) })
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||||
|
|
||||||
|
let elem = elems.next();
|
||||||
|
let month = elem
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||||
|
|
||||||
|
let elem = elems.next();
|
||||||
|
let day = elem
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(SerdeAlbumDate(AlbumDate::new(year, month, day)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SerdeAlbumDate {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
deserializer.deserialize_str(SerdeAlbumDateVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(remote = "AlbumPrimaryType")]
|
||||||
|
pub enum AlbumPrimaryTypeDef {
|
||||||
|
Album,
|
||||||
|
Single,
|
||||||
|
#[serde(rename = "EP")]
|
||||||
|
Ep,
|
||||||
|
Broadcast,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
|
||||||
|
|
||||||
|
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
||||||
|
fn from(value: SerdeAlbumPrimaryType) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AlbumPrimaryType> for SerdeAlbumPrimaryType {
|
||||||
|
fn from(value: AlbumPrimaryType) -> Self {
|
||||||
|
SerdeAlbumPrimaryType(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(remote = "AlbumSecondaryType")]
|
||||||
|
pub enum AlbumSecondaryTypeDef {
|
||||||
|
Compilation,
|
||||||
|
Soundtrack,
|
||||||
|
Spokenword,
|
||||||
|
Interview,
|
||||||
|
Audiobook,
|
||||||
|
#[serde(rename = "Audio drama")]
|
||||||
|
AudioDrama,
|
||||||
|
Live,
|
||||||
|
Remix,
|
||||||
|
#[serde(rename = "DJ-mix")]
|
||||||
|
DjMix,
|
||||||
|
#[serde(rename = "Mixtape/Street")]
|
||||||
|
MixtapeStreet,
|
||||||
|
Demo,
|
||||||
|
#[serde(rename = "Field recording")]
|
||||||
|
FieldRecording,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
|
||||||
|
|
||||||
|
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
||||||
|
fn from(value: SerdeAlbumSecondaryType) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AlbumSecondaryType> for SerdeAlbumSecondaryType {
|
||||||
|
fn from(value: AlbumSecondaryType) -> Self {
|
||||||
|
SerdeAlbumSecondaryType(value)
|
||||||
|
}
|
||||||
|
}
|
144
src/external/musicbrainz/api/search.rs
vendored
Normal file
144
src/external/musicbrainz/api/search.rs
vendored
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::album::AlbumDate,
|
||||||
|
core::{
|
||||||
|
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
interface::musicbrainz::Mbid,
|
||||||
|
},
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
Error, MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType,
|
||||||
|
SerdeAlbumSecondaryType, SerdeMbid, MB_BASE_URL,
|
||||||
|
},
|
||||||
|
IMusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||||
|
pub fn search_release_group(
|
||||||
|
&mut self,
|
||||||
|
request: SearchReleaseGroupRequest,
|
||||||
|
) -> Result<SearchReleaseGroupResponse, Error> {
|
||||||
|
let mut query: Vec<String> = vec![];
|
||||||
|
|
||||||
|
if let Some(arid) = request.arid {
|
||||||
|
query.push(format!("arid:{}", arid.uuid().as_hyphenated().to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(date) = request.first_release_date {
|
||||||
|
if let Some(date_string) = Self::format_album_date(date) {
|
||||||
|
query.push(format!("firstreleasedate:{date_string}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(release_group) = request.release_group {
|
||||||
|
query.push(format!("releasegroup:\"{release_group}\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rgid) = request.rgid {
|
||||||
|
query.push(format!("rgid:{}", rgid.uuid().as_hyphenated().to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let query: String =
|
||||||
|
form_urlencoded::byte_serialize(query.join(" AND ").as_bytes()).collect();
|
||||||
|
let url = format!("{MB_BASE_URL}/release-group?query={query}");
|
||||||
|
|
||||||
|
let response: DeserializeSearchReleaseGroupResponse = self.http.get(&url)?;
|
||||||
|
Ok(response.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SearchReleaseGroupRequest<'a> {
|
||||||
|
arid: Option<&'a Mbid>,
|
||||||
|
first_release_date: Option<&'a AlbumDate>,
|
||||||
|
release_group: Option<&'a str>,
|
||||||
|
rgid: Option<&'a Mbid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SearchReleaseGroupRequest<'a> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn arid(&mut self, arid: &'a Mbid) -> &mut Self {
|
||||||
|
self.arid = Some(arid);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn first_release_date(&mut self, first_release_date: &'a AlbumDate) -> &mut Self {
|
||||||
|
self.first_release_date = Some(first_release_date);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_group(&mut self, release_group: &'a str) -> &mut Self {
|
||||||
|
self.release_group = Some(release_group);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rgid(&mut self, rgid: &'a Mbid) -> &mut Self {
|
||||||
|
self.rgid = Some(rgid);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SearchReleaseGroupResponse {
|
||||||
|
pub release_groups: Vec<SearchReleaseGroupResponseReleaseGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
struct DeserializeSearchReleaseGroupResponse {
|
||||||
|
release_groups: Vec<DeserializeSearchReleaseGroupResponseReleaseGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeSearchReleaseGroupResponse> for SearchReleaseGroupResponse {
|
||||||
|
fn from(value: DeserializeSearchReleaseGroupResponse) -> Self {
|
||||||
|
SearchReleaseGroupResponse {
|
||||||
|
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SearchReleaseGroupResponseReleaseGroup {
|
||||||
|
pub score: u8,
|
||||||
|
pub id: Mbid,
|
||||||
|
pub title: String,
|
||||||
|
pub first_release_date: AlbumDate,
|
||||||
|
pub primary_type: AlbumPrimaryType,
|
||||||
|
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
struct DeserializeSearchReleaseGroupResponseReleaseGroup {
|
||||||
|
score: u8,
|
||||||
|
id: SerdeMbid,
|
||||||
|
title: String,
|
||||||
|
first_release_date: SerdeAlbumDate,
|
||||||
|
primary_type: SerdeAlbumPrimaryType,
|
||||||
|
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeSearchReleaseGroupResponseReleaseGroup>
|
||||||
|
for SearchReleaseGroupResponseReleaseGroup
|
||||||
|
{
|
||||||
|
fn from(value: DeserializeSearchReleaseGroupResponseReleaseGroup) -> Self {
|
||||||
|
SearchReleaseGroupResponseReleaseGroup {
|
||||||
|
score: value.score,
|
||||||
|
id: value.id.into(),
|
||||||
|
title: value.title,
|
||||||
|
first_release_date: value.first_release_date.into(),
|
||||||
|
primary_type: value.primary_type.into(),
|
||||||
|
secondary_types: value
|
||||||
|
.secondary_types
|
||||||
|
.map(|v| v.into_iter().map(Into::into).collect()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
443
src/external/musicbrainz/mod.rs
vendored
443
src/external/musicbrainz/mod.rs
vendored
@ -1,25 +1,11 @@
|
|||||||
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
|
||||||
use std::{fmt, num::ParseIntError};
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use mockall::automock;
|
use mockall::automock;
|
||||||
use serde::{
|
use serde::de::DeserializeOwned;
|
||||||
de::{DeserializeOwned, Visitor},
|
|
||||||
Deserialize, Deserializer,
|
|
||||||
};
|
|
||||||
use url::form_urlencoded;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
collection::album::AlbumDate,
|
|
||||||
core::{
|
|
||||||
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
|
||||||
interface::musicbrainz::Mbid,
|
|
||||||
},
|
|
||||||
interface::musicbrainz::MbidError,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg_attr(test, automock)]
|
#[cfg_attr(test, automock)]
|
||||||
pub trait IMusicBrainzHttp {
|
pub trait IMusicBrainzHttp {
|
||||||
@ -32,431 +18,6 @@ pub enum HttpError {
|
|||||||
Status(u16),
|
Status(u16),
|
||||||
}
|
}
|
||||||
|
|
||||||
const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2";
|
|
||||||
const MB_RATE_LIMIT_CODE: u16 = 503;
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
pub enum Error {
|
|
||||||
/// The HTTP client failed.
|
|
||||||
Http(String),
|
|
||||||
/// The client reached the API rate limit.
|
|
||||||
RateLimit,
|
|
||||||
/// The API response could not be understood.
|
|
||||||
Unknown(u16),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for Error {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Error::Http(s) => write!(f, "the HTTP client failed: {s}"),
|
|
||||||
Error::RateLimit => write!(f, "the API rate limit has been reached"),
|
|
||||||
Error::Unknown(u) => write!(f, "the API response could not be understood: status {u}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<HttpError> for Error {
|
|
||||||
fn from(err: HttpError) -> Self {
|
|
||||||
match err {
|
|
||||||
HttpError::Client(s) => Error::Http(s),
|
|
||||||
HttpError::Status(status) => match status {
|
|
||||||
MB_RATE_LIMIT_CODE => Error::RateLimit,
|
|
||||||
_ => Error::Unknown(status),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct MusicBrainzClient<Http> {
|
|
||||||
http: Http,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Http> MusicBrainzClient<Http> {
|
|
||||||
pub fn new(http: Http) -> Self {
|
|
||||||
MusicBrainzClient { http }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
|
||||||
fn format_album_date(date: &AlbumDate) -> Option<String> {
|
|
||||||
match date.year {
|
|
||||||
Some(year) => match date.month {
|
|
||||||
Some(month) => match date.day {
|
|
||||||
Some(day) => Some(format!("{year}-{month:02}-{day:02}")),
|
|
||||||
None => Some(format!("{year}-{month:02}")),
|
|
||||||
},
|
|
||||||
None => Some(format!("{year}")),
|
|
||||||
},
|
|
||||||
None => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn lookup_artist(
|
|
||||||
&mut self,
|
|
||||||
request: LookupArtistRequest,
|
|
||||||
) -> Result<LookupArtistResponse, Error> {
|
|
||||||
let mut include: Vec<String> = vec![];
|
|
||||||
|
|
||||||
let mbid: String = request.mbid.uuid().as_hyphenated().to_string();
|
|
||||||
|
|
||||||
if request.release_groups {
|
|
||||||
include.push(String::from("release-groups"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let include: String =
|
|
||||||
form_urlencoded::byte_serialize(include.join("+").as_bytes()).collect();
|
|
||||||
let url = format!("{MB_BASE_URL}/artist/{mbid}?inc={include}");
|
|
||||||
|
|
||||||
let response: DeserializeLookupArtistResponse = self.http.get(&url)?;
|
|
||||||
Ok(response.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn search_release_group(
|
|
||||||
&mut self,
|
|
||||||
request: SearchReleaseGroupRequest,
|
|
||||||
) -> Result<SearchReleaseGroupResponse, Error> {
|
|
||||||
let mut query: Vec<String> = vec![];
|
|
||||||
|
|
||||||
if let Some(arid) = request.arid {
|
|
||||||
query.push(format!("arid:{}", arid.uuid().as_hyphenated().to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(date) = request.first_release_date {
|
|
||||||
if let Some(date_string) = Self::format_album_date(date) {
|
|
||||||
query.push(format!("firstreleasedate:{date_string}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(release_group) = request.release_group {
|
|
||||||
query.push(format!("releasegroup:\"{release_group}\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(rgid) = request.rgid {
|
|
||||||
query.push(format!("rgid:{}", rgid.uuid().as_hyphenated().to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let query: String =
|
|
||||||
form_urlencoded::byte_serialize(query.join(" AND ").as_bytes()).collect();
|
|
||||||
let url = format!("{MB_BASE_URL}/release-group?query={query}");
|
|
||||||
|
|
||||||
let response: DeserializeSearchReleaseGroupResponse = self.http.get(&url)?;
|
|
||||||
Ok(response.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LookupArtistRequest<'a> {
|
|
||||||
mbid: &'a Mbid,
|
|
||||||
release_groups: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> LookupArtistRequest<'a> {
|
|
||||||
pub fn new(mbid: &'a Mbid) -> Self {
|
|
||||||
LookupArtistRequest {
|
|
||||||
mbid,
|
|
||||||
release_groups: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn include_release_groups(&mut self) -> &mut Self {
|
|
||||||
self.release_groups = true;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct LookupArtistResponse {
|
|
||||||
pub release_groups: Vec<LookupArtistResponseReleaseGroup>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
|
||||||
struct DeserializeLookupArtistResponse {
|
|
||||||
release_groups: Vec<DeserializeLookupArtistResponseReleaseGroup>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DeserializeLookupArtistResponse> for LookupArtistResponse {
|
|
||||||
fn from(value: DeserializeLookupArtistResponse) -> Self {
|
|
||||||
LookupArtistResponse {
|
|
||||||
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct LookupArtistResponseReleaseGroup {
|
|
||||||
pub id: Mbid,
|
|
||||||
pub title: String,
|
|
||||||
pub first_release_date: AlbumDate,
|
|
||||||
pub primary_type: AlbumPrimaryType,
|
|
||||||
pub secondary_types: Vec<AlbumSecondaryType>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
|
||||||
struct DeserializeLookupArtistResponseReleaseGroup {
|
|
||||||
id: SerdeMbid,
|
|
||||||
title: String,
|
|
||||||
first_release_date: SerdeAlbumDate,
|
|
||||||
primary_type: SerdeAlbumPrimaryType,
|
|
||||||
secondary_types: Vec<SerdeAlbumSecondaryType>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DeserializeLookupArtistResponseReleaseGroup> for LookupArtistResponseReleaseGroup {
|
|
||||||
fn from(value: DeserializeLookupArtistResponseReleaseGroup) -> Self {
|
|
||||||
LookupArtistResponseReleaseGroup {
|
|
||||||
id: value.id.into(),
|
|
||||||
title: value.title,
|
|
||||||
first_release_date: value.first_release_date.into(),
|
|
||||||
primary_type: value.primary_type.into(),
|
|
||||||
secondary_types: value.secondary_types.into_iter().map(Into::into).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct SearchReleaseGroupRequest<'a> {
|
|
||||||
arid: Option<&'a Mbid>,
|
|
||||||
first_release_date: Option<&'a AlbumDate>,
|
|
||||||
release_group: Option<&'a str>,
|
|
||||||
rgid: Option<&'a Mbid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> SearchReleaseGroupRequest<'a> {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn arid(&mut self, arid: &'a Mbid) -> &mut Self {
|
|
||||||
self.arid = Some(arid);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn first_release_date(&mut self, first_release_date: &'a AlbumDate) -> &mut Self {
|
|
||||||
self.first_release_date = Some(first_release_date);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn release_group(&mut self, release_group: &'a str) -> &mut Self {
|
|
||||||
self.release_group = Some(release_group);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rgid(&mut self, rgid: &'a Mbid) -> &mut Self {
|
|
||||||
self.rgid = Some(rgid);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct SearchReleaseGroupResponse {
|
|
||||||
pub release_groups: Vec<SearchReleaseGroupResponseReleaseGroup>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
|
||||||
struct DeserializeSearchReleaseGroupResponse {
|
|
||||||
release_groups: Vec<DeserializeSearchReleaseGroupResponseReleaseGroup>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DeserializeSearchReleaseGroupResponse> for SearchReleaseGroupResponse {
|
|
||||||
fn from(value: DeserializeSearchReleaseGroupResponse) -> Self {
|
|
||||||
SearchReleaseGroupResponse {
|
|
||||||
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct SearchReleaseGroupResponseReleaseGroup {
|
|
||||||
pub score: u8,
|
|
||||||
pub id: Mbid,
|
|
||||||
pub title: String,
|
|
||||||
pub first_release_date: AlbumDate,
|
|
||||||
pub primary_type: AlbumPrimaryType,
|
|
||||||
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
|
||||||
struct DeserializeSearchReleaseGroupResponseReleaseGroup {
|
|
||||||
score: u8,
|
|
||||||
id: SerdeMbid,
|
|
||||||
title: String,
|
|
||||||
first_release_date: SerdeAlbumDate,
|
|
||||||
primary_type: SerdeAlbumPrimaryType,
|
|
||||||
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DeserializeSearchReleaseGroupResponseReleaseGroup>
|
|
||||||
for SearchReleaseGroupResponseReleaseGroup
|
|
||||||
{
|
|
||||||
fn from(value: DeserializeSearchReleaseGroupResponseReleaseGroup) -> Self {
|
|
||||||
SearchReleaseGroupResponseReleaseGroup {
|
|
||||||
score: value.score,
|
|
||||||
id: value.id.into(),
|
|
||||||
title: value.title,
|
|
||||||
first_release_date: value.first_release_date.into(),
|
|
||||||
primary_type: value.primary_type.into(),
|
|
||||||
secondary_types: value
|
|
||||||
.secondary_types
|
|
||||||
.map(|v| v.into_iter().map(Into::into).collect()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SerdeMbid(Mbid);
|
|
||||||
|
|
||||||
impl From<SerdeMbid> for Mbid {
|
|
||||||
fn from(value: SerdeMbid) -> Self {
|
|
||||||
value.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SerdeMbidVisitor;
|
|
||||||
|
|
||||||
impl<'de> Visitor<'de> for SerdeMbidVisitor {
|
|
||||||
type Value = SerdeMbid;
|
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
formatter.write_str("a valid MusicBrainz identifier")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
|
||||||
where
|
|
||||||
E: serde::de::Error,
|
|
||||||
{
|
|
||||||
Ok(SerdeMbid(
|
|
||||||
v.try_into()
|
|
||||||
.map_err(|e: MbidError| E::custom(e.to_string()))?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for SerdeMbid {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
deserializer.deserialize_str(SerdeMbidVisitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SerdeAlbumDate(AlbumDate);
|
|
||||||
|
|
||||||
impl From<SerdeAlbumDate> for AlbumDate {
|
|
||||||
fn from(value: SerdeAlbumDate) -> Self {
|
|
||||||
value.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SerdeAlbumDateVisitor;
|
|
||||||
|
|
||||||
impl<'de> Visitor<'de> for SerdeAlbumDateVisitor {
|
|
||||||
type Value = SerdeAlbumDate;
|
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
formatter.write_str("a valid YYYY(-MM-(-DD)) date")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
|
||||||
where
|
|
||||||
E: serde::de::Error,
|
|
||||||
{
|
|
||||||
let mut elems = v.split('-');
|
|
||||||
|
|
||||||
let elem = elems.next();
|
|
||||||
let year = elem
|
|
||||||
.and_then(|s| if s.is_empty() { None } else { Some(s.parse()) })
|
|
||||||
.transpose()
|
|
||||||
.map_err(|e: ParseIntError| E::custom(e.to_string()))?;
|
|
||||||
|
|
||||||
let elem = elems.next();
|
|
||||||
let month = elem
|
|
||||||
.map(|s| s.parse())
|
|
||||||
.transpose()
|
|
||||||
.map_err(|e: ParseIntError| E::custom(e.to_string()))?;
|
|
||||||
|
|
||||||
let elem = elems.next();
|
|
||||||
let day = elem
|
|
||||||
.map(|s| s.parse())
|
|
||||||
.transpose()
|
|
||||||
.map_err(|e: ParseIntError| E::custom(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(SerdeAlbumDate(AlbumDate::new(year, month, day)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for SerdeAlbumDate {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
deserializer.deserialize_str(SerdeAlbumDateVisitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(remote = "AlbumPrimaryType")]
|
|
||||||
pub enum AlbumPrimaryTypeDef {
|
|
||||||
Album,
|
|
||||||
Single,
|
|
||||||
#[serde(rename = "EP")]
|
|
||||||
Ep,
|
|
||||||
Broadcast,
|
|
||||||
Other,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
|
|
||||||
|
|
||||||
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
|
||||||
fn from(value: SerdeAlbumPrimaryType) -> Self {
|
|
||||||
value.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<AlbumPrimaryType> for SerdeAlbumPrimaryType {
|
|
||||||
fn from(value: AlbumPrimaryType) -> Self {
|
|
||||||
SerdeAlbumPrimaryType(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(remote = "AlbumSecondaryType")]
|
|
||||||
pub enum AlbumSecondaryTypeDef {
|
|
||||||
Compilation,
|
|
||||||
Soundtrack,
|
|
||||||
Spokenword,
|
|
||||||
Interview,
|
|
||||||
Audiobook,
|
|
||||||
#[serde(rename = "Audio drama")]
|
|
||||||
AudioDrama,
|
|
||||||
Live,
|
|
||||||
Remix,
|
|
||||||
#[serde(rename = "DJ-mix")]
|
|
||||||
DjMix,
|
|
||||||
#[serde(rename = "Mixtape/Street")]
|
|
||||||
MixtapeStreet,
|
|
||||||
Demo,
|
|
||||||
#[serde(rename = "Field recording")]
|
|
||||||
FieldRecording,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
|
|
||||||
|
|
||||||
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
|
||||||
fn from(value: SerdeAlbumSecondaryType) -> Self {
|
|
||||||
value.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<AlbumSecondaryType> for SerdeAlbumSecondaryType {
|
|
||||||
fn from(value: AlbumSecondaryType) -> Self {
|
|
||||||
SerdeAlbumSecondaryType(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// #[cfg(test)]
|
// #[cfg(test)]
|
||||||
// mod tests {
|
// mod tests {
|
||||||
// use mockall::{predicate, Sequence};
|
// use mockall::{predicate, Sequence};
|
||||||
|
@ -16,7 +16,7 @@ use musichoard::{
|
|||||||
executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor},
|
executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor},
|
||||||
BeetsLibrary,
|
BeetsLibrary,
|
||||||
},
|
},
|
||||||
musicbrainz::{http::MusicBrainzHttp, MusicBrainzClient},
|
musicbrainz::{api::MusicBrainzClient, http::MusicBrainzHttp},
|
||||||
},
|
},
|
||||||
interface::{
|
interface::{
|
||||||
database::{IDatabase, NullDatabase},
|
database::{IDatabase, NullDatabase},
|
||||||
|
7
src/tui/lib/external/musicbrainz/mod.rs
vendored
7
src/tui/lib/external/musicbrainz/mod.rs
vendored
@ -3,8 +3,11 @@
|
|||||||
use musichoard::{
|
use musichoard::{
|
||||||
collection::album::{Album, AlbumDate},
|
collection::album::{Album, AlbumDate},
|
||||||
external::musicbrainz::{
|
external::musicbrainz::{
|
||||||
IMusicBrainzHttp, MusicBrainzClient, SearchReleaseGroupRequest,
|
api::{
|
||||||
SearchReleaseGroupResponseReleaseGroup,
|
search::{SearchReleaseGroupRequest, SearchReleaseGroupResponseReleaseGroup},
|
||||||
|
MusicBrainzClient,
|
||||||
|
},
|
||||||
|
IMusicBrainzHttp,
|
||||||
},
|
},
|
||||||
interface::musicbrainz::Mbid,
|
interface::musicbrainz::Mbid,
|
||||||
};
|
};
|
||||||
|
@ -27,4 +27,4 @@ impl<T> Match<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Error = musichoard::external::musicbrainz::Error;
|
pub type Error = musichoard::external::musicbrainz::api::Error;
|
||||||
|
Loading…
Reference in New Issue
Block a user