feat: we can now do things
The most crappy basic implementation, but: - It can list albums - It can list a single album's tracks - It can play a song Closes #1
This commit is contained in:
parent
67244e63b3
commit
453a496377
14 changed files with 710 additions and 43 deletions
|
|
@ -2,7 +2,7 @@ use std::{collections::HashMap, fmt::Display, str::FromStr, string::ToString};
|
||||||
|
|
||||||
use color_eyre::Report;
|
use color_eyre::Report;
|
||||||
use poem::{Error, FromRequest, IntoResponse, Request, RequestBody, Result};
|
use poem::{Error, FromRequest, IntoResponse, Request, RequestBody, Result};
|
||||||
use tracing::debug;
|
use tracing::trace;
|
||||||
|
|
||||||
use crate::subsonic::{self, SubsonicResponse};
|
use crate::subsonic::{self, SubsonicResponse};
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ impl<'a> FromRequest<'a> for Authentication {
|
||||||
.filter_map(|q| q.split_once('='))
|
.filter_map(|q| q.split_once('='))
|
||||||
.collect::<HashMap<_, _>>();
|
.collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
debug!("Query: {query:?}");
|
trace!("Query: {query:?}");
|
||||||
|
|
||||||
let user = {
|
let user = {
|
||||||
let user = query.get("u").map(ToString::to_string);
|
let user = query.get("u").map(ToString::to_string);
|
||||||
|
|
@ -43,7 +43,7 @@ impl<'a> FromRequest<'a> for Authentication {
|
||||||
user.expect("Missing username")
|
user.expect("Missing username")
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!("User: {user}");
|
trace!("User: {user}");
|
||||||
|
|
||||||
let password = query.get("p").map(ToString::to_string);
|
let password = query.get("p").map(ToString::to_string);
|
||||||
if password.is_some() {
|
if password.is_some() {
|
||||||
|
|
@ -55,7 +55,7 @@ impl<'a> FromRequest<'a> for Authentication {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Password: {password:?}");
|
trace!("Password: {password:?}");
|
||||||
|
|
||||||
let token = query.get("t").map(ToString::to_string);
|
let token = query.get("t").map(ToString::to_string);
|
||||||
let salt = query.get("s").map(ToString::to_string);
|
let salt = query.get("s").map(ToString::to_string);
|
||||||
|
|
@ -68,9 +68,9 @@ impl<'a> FromRequest<'a> for Authentication {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let token = token.expect("Missing token");
|
let token = token.expect("Missing token");
|
||||||
debug!("Token: {token}");
|
trace!("Token: {token}");
|
||||||
let salt = salt.expect("Missing salt");
|
let salt = salt.expect("Missing salt");
|
||||||
debug!("Salt: {salt}");
|
trace!("Salt: {salt}");
|
||||||
|
|
||||||
let version = {
|
let version = {
|
||||||
let version = query.get("v").map(ToString::to_string);
|
let version = query.get("v").map(ToString::to_string);
|
||||||
|
|
@ -95,7 +95,7 @@ impl<'a> FromRequest<'a> for Authentication {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}?;
|
}?;
|
||||||
debug!("Version: {version}");
|
trace!("Version: {version}");
|
||||||
|
|
||||||
let client = {
|
let client = {
|
||||||
let client = query.get("c").map(ToString::to_string);
|
let client = query.get("c").map(ToString::to_string);
|
||||||
|
|
@ -111,13 +111,22 @@ impl<'a> FromRequest<'a> for Authentication {
|
||||||
|
|
||||||
client.expect("Missing client")
|
client.expect("Missing client")
|
||||||
};
|
};
|
||||||
debug!("Client: {client}");
|
trace!("Client: {client}");
|
||||||
|
|
||||||
let format = query
|
let format = query
|
||||||
.get("f")
|
.get("f")
|
||||||
.map_or_else(|| "xml".to_string(), ToString::to_string);
|
.map_or_else(|| "xml".to_string(), ToString::to_string);
|
||||||
|
|
||||||
debug!("Format: {format}");
|
if format != "xml" {
|
||||||
|
return Err(Error::from_response(
|
||||||
|
SubsonicResponse::new_error(subsonic::Error::Generic(Some(
|
||||||
|
"only xml format is supported".to_string(),
|
||||||
|
)))
|
||||||
|
.into_response(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
trace!("Format: {format}");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
username: user,
|
username: user,
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,11 @@ use tracing::info;
|
||||||
use tracing_subscriber::{fmt, EnvFilter};
|
use tracing_subscriber::{fmt, EnvFilter};
|
||||||
|
|
||||||
mod authentication;
|
mod authentication;
|
||||||
|
mod random_types;
|
||||||
mod rest;
|
mod rest;
|
||||||
mod subsonic;
|
mod subsonic;
|
||||||
mod user;
|
mod user;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
const LISTEN: &str = "0.0.0.0:1234";
|
const LISTEN: &str = "0.0.0.0:1234";
|
||||||
|
|
||||||
|
|
|
||||||
3
src/random_types.rs
Normal file
3
src/random_types.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
mod sort_type;
|
||||||
|
|
||||||
|
pub use sort_type::SortType;
|
||||||
55
src/random_types/sort_type.rs
Normal file
55
src/random_types/sort_type.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::subsonic::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SortType {
|
||||||
|
Random,
|
||||||
|
Newest,
|
||||||
|
Highest,
|
||||||
|
Frequent,
|
||||||
|
Recent,
|
||||||
|
AlphabeticalByName,
|
||||||
|
AlphabeticalByArtist,
|
||||||
|
Starred,
|
||||||
|
ByYear,
|
||||||
|
ByGenre,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SortType {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Self::from_str(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for SortType {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.to_lowercase().as_ref() {
|
||||||
|
"random" => Ok(Self::Random),
|
||||||
|
"newest" => Ok(Self::Newest),
|
||||||
|
"highest" => Ok(Self::Highest),
|
||||||
|
"frequent" => Ok(Self::Frequent),
|
||||||
|
"recent" => Ok(Self::Recent),
|
||||||
|
"alphabeticalbyname" => Ok(Self::AlphabeticalByName),
|
||||||
|
"alphabeticalbyartist" => Ok(Self::AlphabeticalByArtist),
|
||||||
|
"starred" => Ok(Self::Starred),
|
||||||
|
"byyear" => Ok(Self::ByYear),
|
||||||
|
"bygenre" => Ok(Self::ByGenre),
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
warn!("got invalid type parameter {s}");
|
||||||
|
Err(Error::Generic(Some(
|
||||||
|
"type parameter is invalid".to_string(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/rest.rs
23
src/rest.rs
|
|
@ -1,7 +1,28 @@
|
||||||
use poem::{Endpoint, EndpointExt, Route};
|
use poem::{Endpoint, EndpointExt, Route};
|
||||||
|
|
||||||
|
// rest/getLicense
|
||||||
|
mod get_license;
|
||||||
|
// rest/getMusicFolders
|
||||||
|
mod get_music_folders;
|
||||||
|
// rest/ping
|
||||||
mod ping;
|
mod ping;
|
||||||
|
// rest/getAlbumList
|
||||||
|
mod get_album_list;
|
||||||
|
// rest/getAlbumList2
|
||||||
|
mod get_album_list2;
|
||||||
|
// rest/getAlbum
|
||||||
|
mod get_album;
|
||||||
|
// rest/stream
|
||||||
|
mod stream;
|
||||||
|
|
||||||
pub fn build() -> Box<dyn Endpoint<Output = poem::Response>> {
|
pub fn build() -> Box<dyn Endpoint<Output = poem::Response>> {
|
||||||
Route::new().at("/ping", ping::ping).boxed()
|
Route::new()
|
||||||
|
.at("/ping", ping::ping)
|
||||||
|
.at("/getLicense", get_license::get_license)
|
||||||
|
.at("/getMusicFolders", get_music_folders::get_music_folders)
|
||||||
|
.at("/getAlbumList", get_album_list::get_album_list)
|
||||||
|
.at("/getAlbumList2", get_album_list2::get_album_list2)
|
||||||
|
.at("/getAlbum", get_album::get_album)
|
||||||
|
.at("/stream", stream::stream)
|
||||||
|
.boxed()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
122
src/rest/get_album.rs
Normal file
122
src/rest/get_album.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
use poem::web::{Data, Query};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
authentication::Authentication,
|
||||||
|
subsonic::{AlbumId3, Child, Error, MediaType, SubsonicResponse},
|
||||||
|
utils,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[poem::handler]
|
||||||
|
pub async fn get_album(
|
||||||
|
Data(pool): Data<&SqlitePool>,
|
||||||
|
auth: Authentication,
|
||||||
|
Query(params): Query<GetAlbumParams>,
|
||||||
|
) -> SubsonicResponse {
|
||||||
|
let u = utils::verify_user(pool, auth).await;
|
||||||
|
|
||||||
|
match u {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => return e,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
let album = match params.id {
|
||||||
|
11 => AlbumId3 {
|
||||||
|
id: 11,
|
||||||
|
name: "Example".to_string(),
|
||||||
|
artist: Some("Example".to_string()),
|
||||||
|
song_count: 5,
|
||||||
|
duration: 100,
|
||||||
|
songs: vec![
|
||||||
|
Child {
|
||||||
|
id: 111,
|
||||||
|
title: "Example - 1".to_string(),
|
||||||
|
album: Some("Example".to_string()),
|
||||||
|
duration: Some(20),
|
||||||
|
content_type: Some("audio/mpeg".to_string()),
|
||||||
|
r#type: Some(MediaType::Music),
|
||||||
|
track: Some({
|
||||||
|
count += 1;
|
||||||
|
count
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Child {
|
||||||
|
id: 112,
|
||||||
|
title: "Example - 2".to_string(),
|
||||||
|
album: Some("Example".to_string()),
|
||||||
|
duration: Some(20),
|
||||||
|
content_type: Some("audio/mpeg".to_string()),
|
||||||
|
r#type: Some(MediaType::Music),
|
||||||
|
track: Some({
|
||||||
|
count += 1;
|
||||||
|
count
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Child {
|
||||||
|
id: 113,
|
||||||
|
title: "Example - 3".to_string(),
|
||||||
|
album: Some("Example".to_string()),
|
||||||
|
duration: Some(20),
|
||||||
|
content_type: Some("audio/mpeg".to_string()),
|
||||||
|
r#type: Some(MediaType::Music),
|
||||||
|
track: Some({
|
||||||
|
count += 1;
|
||||||
|
count
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Child {
|
||||||
|
id: 114,
|
||||||
|
title: "Example - 4".to_string(),
|
||||||
|
album: Some("Example".to_string()),
|
||||||
|
duration: Some(20),
|
||||||
|
content_type: Some("audio/mpeg".to_string()),
|
||||||
|
r#type: Some(MediaType::Music),
|
||||||
|
track: Some({
|
||||||
|
count += 1;
|
||||||
|
count
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Child {
|
||||||
|
id: 115,
|
||||||
|
title: "Example - 5".to_string(),
|
||||||
|
album: Some("Example".to_string()),
|
||||||
|
duration: Some(20),
|
||||||
|
content_type: Some("audio/mpeg".to_string()),
|
||||||
|
r#type: Some(MediaType::Music),
|
||||||
|
track: Some({
|
||||||
|
count += 1;
|
||||||
|
count
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
12 => AlbumId3 {
|
||||||
|
id: 12,
|
||||||
|
name: "Example 2".to_string(),
|
||||||
|
artist: Some("Example 2".to_string()),
|
||||||
|
song_count: 7,
|
||||||
|
duration: 200,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return SubsonicResponse::new_error(Error::RequestedDataWasNotFound(Some(
|
||||||
|
"Album does not exist".to_string(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SubsonicResponse::new_album(album)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct GetAlbumParams {
|
||||||
|
pub id: i32,
|
||||||
|
}
|
||||||
99
src/rest/get_album_list.rs
Normal file
99
src/rest/get_album_list.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
use poem::web::{Data, Query};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
authentication::Authentication,
|
||||||
|
random_types::SortType,
|
||||||
|
subsonic::{Child, Error, SubsonicResponse},
|
||||||
|
utils,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[poem::handler]
|
||||||
|
pub async fn get_album_list(
|
||||||
|
Data(pool): Data<&SqlitePool>,
|
||||||
|
auth: Authentication,
|
||||||
|
Query(params): Query<GetAlbumListParams>,
|
||||||
|
) -> SubsonicResponse {
|
||||||
|
let u = utils::verify_user(pool, auth).await;
|
||||||
|
|
||||||
|
match u {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => return e,
|
||||||
|
}
|
||||||
|
|
||||||
|
let _params = match params.verify() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return e,
|
||||||
|
};
|
||||||
|
|
||||||
|
let album_list = vec![
|
||||||
|
Child {
|
||||||
|
id: 11,
|
||||||
|
parent: Some(1),
|
||||||
|
title: "Example".to_string(),
|
||||||
|
artist: Some("Example".to_string()),
|
||||||
|
is_dir: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Child {
|
||||||
|
id: 12,
|
||||||
|
parent: Some(1),
|
||||||
|
title: "Example 2".to_string(),
|
||||||
|
artist: Some("Example 2".to_string()),
|
||||||
|
is_dir: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
SubsonicResponse::new_album_list(album_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct GetAlbumListParams {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub r#type: SortType,
|
||||||
|
#[serde(default = "default_size")]
|
||||||
|
pub size: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub from_year: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub to_year: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub genre: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub music_folder_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GetAlbumListParams {
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
pub fn verify(self) -> Result<Self, SubsonicResponse> {
|
||||||
|
if self.r#type == SortType::ByYear {
|
||||||
|
if self.from_year.is_none() || self.to_year.is_none() {
|
||||||
|
return Err(SubsonicResponse::new_error(
|
||||||
|
Error::RequiredParameterMissing(Some(
|
||||||
|
"Missing required parameter: fromYear or toYear".to_string(),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if self.r#type == SortType::ByGenre && self.genre.is_none() {
|
||||||
|
return Err(SubsonicResponse::new_error(
|
||||||
|
Error::RequiredParameterMissing(Some(
|
||||||
|
"Missing required parameter: genre".to_string(),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
} else if self.size > 500 || self.size < 1 {
|
||||||
|
return Err(SubsonicResponse::new_error(Error::Generic(Some(
|
||||||
|
"size must be between 1 and 500".to_string(),
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_size() -> i32 {
|
||||||
|
10
|
||||||
|
}
|
||||||
53
src/rest/get_album_list2.rs
Normal file
53
src/rest/get_album_list2.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
use poem::web::{Data, Query};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
authentication::Authentication,
|
||||||
|
rest::get_album_list::GetAlbumListParams,
|
||||||
|
subsonic::{AlbumId3, SubsonicResponse},
|
||||||
|
utils,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[poem::handler]
|
||||||
|
pub async fn get_album_list2(
|
||||||
|
Data(pool): Data<&SqlitePool>,
|
||||||
|
auth: Authentication,
|
||||||
|
Query(params): Query<GetAlbumListParams>,
|
||||||
|
) -> SubsonicResponse {
|
||||||
|
let u = utils::verify_user(pool, auth).await;
|
||||||
|
|
||||||
|
match u {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => return e,
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = match params.verify() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return e,
|
||||||
|
};
|
||||||
|
|
||||||
|
if params.offset > 0 {
|
||||||
|
return SubsonicResponse::new_album_list2(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let album_list = vec![
|
||||||
|
AlbumId3 {
|
||||||
|
id: 11,
|
||||||
|
name: "Example".to_string(),
|
||||||
|
artist: Some("Example".to_string()),
|
||||||
|
song_count: 5,
|
||||||
|
duration: 100,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
AlbumId3 {
|
||||||
|
id: 12,
|
||||||
|
name: "Example 2".to_string(),
|
||||||
|
artist: Some("Example 2".to_string()),
|
||||||
|
song_count: 7,
|
||||||
|
duration: 200,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
SubsonicResponse::new_album_list2(album_list)
|
||||||
|
}
|
||||||
16
src/rest/get_license.rs
Normal file
16
src/rest/get_license.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
use poem::web::Data;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{authentication::Authentication, subsonic::SubsonicResponse, utils};
|
||||||
|
|
||||||
|
#[poem::handler]
|
||||||
|
pub async fn get_license(Data(pool): Data<&SqlitePool>, auth: Authentication) -> SubsonicResponse {
|
||||||
|
let u = utils::verify_user(pool, auth).await;
|
||||||
|
|
||||||
|
match u {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => return e,
|
||||||
|
}
|
||||||
|
|
||||||
|
SubsonicResponse::new(crate::subsonic::SubResponseType::License { valid: true })
|
||||||
|
}
|
||||||
34
src/rest/get_music_folders.rs
Normal file
34
src/rest/get_music_folders.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
use poem::web::Data;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
authentication::Authentication,
|
||||||
|
subsonic::{MusicFolder, SubsonicResponse},
|
||||||
|
utils,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[poem::handler]
|
||||||
|
pub async fn get_music_folders(
|
||||||
|
Data(pool): Data<&SqlitePool>,
|
||||||
|
auth: Authentication,
|
||||||
|
) -> SubsonicResponse {
|
||||||
|
let u = utils::verify_user(pool, auth).await;
|
||||||
|
|
||||||
|
match u {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => return e,
|
||||||
|
}
|
||||||
|
|
||||||
|
let folders = vec![
|
||||||
|
MusicFolder {
|
||||||
|
id: 0,
|
||||||
|
name: "Music".to_string(),
|
||||||
|
},
|
||||||
|
MusicFolder {
|
||||||
|
id: 1,
|
||||||
|
name: "Podcasts".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
SubsonicResponse::new_music_folders(folders)
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,14 @@
|
||||||
use poem::web::Data;
|
use poem::web::Data;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::{
|
use crate::{authentication::Authentication, subsonic::SubsonicResponse, utils};
|
||||||
authentication::Authentication,
|
|
||||||
subsonic::{self, SubsonicResponse},
|
|
||||||
user,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[poem::handler]
|
#[poem::handler]
|
||||||
pub async fn ping(Data(pool): Data<&SqlitePool>, auth: Authentication) -> SubsonicResponse {
|
pub async fn ping(Data(pool): Data<&SqlitePool>, auth: Authentication) -> SubsonicResponse {
|
||||||
let user = user::get_user(pool, &auth.username).await;
|
let u = utils::verify_user(pool, auth).await;
|
||||||
|
|
||||||
match user {
|
match u {
|
||||||
Ok(Some(u)) => {
|
Ok(_) => SubsonicResponse::new_empty(),
|
||||||
if u.verify(&auth.token, &auth.salt) {
|
Err(e) => e,
|
||||||
SubsonicResponse::new_empty()
|
|
||||||
} else {
|
|
||||||
SubsonicResponse::new_error(subsonic::Error::WrongUsernameOrPassword(None))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => SubsonicResponse::new_error(subsonic::Error::WrongUsernameOrPassword(None)),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Error getting user: {}", e);
|
|
||||||
SubsonicResponse::new_error(subsonic::Error::WrongUsernameOrPassword(None))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
src/rest/stream.rs
Normal file
47
src/rest/stream.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
use poem::{
|
||||||
|
http::StatusCode,
|
||||||
|
web::{Data, Query},
|
||||||
|
IntoResponse, Response,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{authentication::Authentication, utils};
|
||||||
|
|
||||||
|
const SONG: &[u8] = include_bytes!("../../../data.mp3");
|
||||||
|
|
||||||
|
#[poem::handler]
|
||||||
|
pub async fn stream(
|
||||||
|
Data(pool): Data<&SqlitePool>,
|
||||||
|
auth: Authentication,
|
||||||
|
Query(_params): Query<StreamParams>,
|
||||||
|
) -> Response {
|
||||||
|
let u = utils::verify_user(pool, auth).await;
|
||||||
|
|
||||||
|
match u {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => return e.into_response(),
|
||||||
|
}
|
||||||
|
|
||||||
|
poem::Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "audio/mpeg")
|
||||||
|
.body(SONG)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
pub struct StreamParams {
|
||||||
|
pub id: i32,
|
||||||
|
#[serde(rename = "maxBitRate", default)]
|
||||||
|
pub max_bit_rate: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub format: Option<String>,
|
||||||
|
#[serde(rename = "timeOffset", default)]
|
||||||
|
pub time_offset: Option<i32>,
|
||||||
|
#[serde(rename = "size", default)]
|
||||||
|
pub size: Option<String>,
|
||||||
|
#[serde(rename = "estimateContentLength", default)]
|
||||||
|
pub estimate_content_length: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub converted: bool,
|
||||||
|
}
|
||||||
212
src/subsonic.rs
212
src/subsonic.rs
|
|
@ -1,18 +1,22 @@
|
||||||
#![allow(dead_code)] // TODO: Remove this
|
#![allow(dead_code)] // TODO: Remove this
|
||||||
|
|
||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
use poem::{http::StatusCode, IntoResponse, Response};
|
use poem::{http::StatusCode, IntoResponse, Response};
|
||||||
use serde::{ser::SerializeStruct, Serialize};
|
use serde::{ser::SerializeStruct, Serialize};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::authentication::VersionTriple;
|
use crate::authentication::VersionTriple;
|
||||||
|
|
||||||
impl IntoResponse for SubsonicResponse {
|
impl IntoResponse for SubsonicResponse {
|
||||||
fn into_response(self) -> poem::Response {
|
fn into_response(self) -> poem::Response {
|
||||||
let body = quick_xml::se::to_string(&self).expect("Failed to serialize response body");
|
let body = quick_xml::se::to_string(&self).expect("Failed to serialize response");
|
||||||
Response::builder().status(StatusCode::OK).body(body)
|
Response::builder().status(StatusCode::OK).body(body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename = "subsonic-response")]
|
||||||
pub struct SubsonicResponse {
|
pub struct SubsonicResponse {
|
||||||
#[serde(rename = "@xmlns")]
|
#[serde(rename = "@xmlns")]
|
||||||
pub xmlns: String,
|
pub xmlns: String,
|
||||||
|
|
@ -21,7 +25,7 @@ pub struct SubsonicResponse {
|
||||||
#[serde(rename = "@version")]
|
#[serde(rename = "@version")]
|
||||||
pub version: VersionTriple,
|
pub version: VersionTriple,
|
||||||
#[serde(rename = "$value")]
|
#[serde(rename = "$value")]
|
||||||
pub inner: SubResponseType,
|
pub value: Box<SubResponseType>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubsonicResponse {
|
impl SubsonicResponse {
|
||||||
|
|
@ -30,17 +34,28 @@ impl SubsonicResponse {
|
||||||
xmlns: "http://subsonic.org/restapi".to_string(),
|
xmlns: "http://subsonic.org/restapi".to_string(),
|
||||||
status: ResponseStatus::Ok,
|
status: ResponseStatus::Ok,
|
||||||
version: VersionTriple(1, 16, 1),
|
version: VersionTriple(1, 16, 1),
|
||||||
inner,
|
value: Box::new(inner),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_empty() -> Self {
|
pub fn new_music_folders(music_folders: Vec<MusicFolder>) -> Self {
|
||||||
Self {
|
Self::new(SubResponseType::MusicFolders { music_folders })
|
||||||
xmlns: "http://subsonic.org/restapi".to_string(),
|
|
||||||
status: ResponseStatus::Ok,
|
|
||||||
version: VersionTriple(1, 16, 1),
|
|
||||||
inner: SubResponseType::Empty,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn new_album_list(albums: Vec<Child>) -> Self {
|
||||||
|
Self::new(SubResponseType::AlbumList { albums })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_album_list2(albums: Vec<AlbumId3>) -> Self {
|
||||||
|
Self::new(SubResponseType::AlbumList2 { albums })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_album(album: AlbumId3) -> Self {
|
||||||
|
Self::new(SubResponseType::Album(album))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_empty() -> Self {
|
||||||
|
Self::new(SubResponseType::Empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_error(inner: Error) -> Self {
|
pub fn new_error(inner: Error) -> Self {
|
||||||
|
|
@ -48,7 +63,7 @@ impl SubsonicResponse {
|
||||||
xmlns: "http://subsonic.org/restapi".to_string(),
|
xmlns: "http://subsonic.org/restapi".to_string(),
|
||||||
status: ResponseStatus::Failed,
|
status: ResponseStatus::Failed,
|
||||||
version: VersionTriple(1, 16, 1),
|
version: VersionTriple(1, 16, 1),
|
||||||
inner: SubResponseType::Error(inner),
|
value: Box::new(SubResponseType::Error(inner)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,14 +71,151 @@ impl SubsonicResponse {
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub enum SubResponseType {
|
pub enum SubResponseType {
|
||||||
#[serde(rename = "musicFolders")]
|
#[serde(rename = "musicFolders")]
|
||||||
MusicFolders(MusicFolders),
|
MusicFolders {
|
||||||
|
#[serde(rename = "musicFolder")]
|
||||||
|
music_folders: Vec<MusicFolder>,
|
||||||
|
},
|
||||||
#[serde(rename = "error")]
|
#[serde(rename = "error")]
|
||||||
Error(Error),
|
Error(Error),
|
||||||
|
#[serde(rename = "license")]
|
||||||
|
License {
|
||||||
|
#[serde(rename = "valid")]
|
||||||
|
valid: bool,
|
||||||
|
},
|
||||||
|
#[serde(rename = "albumList")]
|
||||||
|
AlbumList {
|
||||||
|
#[serde(rename = "album")]
|
||||||
|
albums: Vec<Child>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "albumList2")]
|
||||||
|
AlbumList2 {
|
||||||
|
#[serde(rename = "album")]
|
||||||
|
albums: Vec<AlbumId3>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "album")]
|
||||||
|
Album(AlbumId3),
|
||||||
Empty,
|
Empty,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Default)]
|
||||||
|
pub struct AlbumId3 {
|
||||||
|
#[serde(rename = "@id")]
|
||||||
|
pub id: i32,
|
||||||
|
#[serde(rename = "@parent")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "@artist", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
#[serde(rename = "@artistId", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub artist_id: Option<i32>,
|
||||||
|
#[serde(rename = "@coverArt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cover_art: Option<String>,
|
||||||
|
#[serde(rename = "@songCount")]
|
||||||
|
pub song_count: i32,
|
||||||
|
#[serde(rename = "@duration")]
|
||||||
|
pub duration: i32,
|
||||||
|
#[serde(rename = "@playCount", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub play_count: Option<i64>,
|
||||||
|
#[serde(rename = "@created", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created: Option<OffsetDateTime>,
|
||||||
|
#[serde(rename = "@starred", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub starred: Option<OffsetDateTime>,
|
||||||
|
#[serde(rename = "@year", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub year: Option<i32>,
|
||||||
|
#[serde(rename = "@genre", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub genre: Option<String>,
|
||||||
|
#[serde(rename = "song", skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub songs: Vec<Child>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Child {
|
||||||
|
#[serde(rename = "@id")]
|
||||||
|
pub id: i32,
|
||||||
|
#[serde(rename = "@parent", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent: Option<i32>,
|
||||||
|
#[serde(rename = "@isDir")]
|
||||||
|
pub is_dir: bool,
|
||||||
|
#[serde(rename = "@title")]
|
||||||
|
pub title: String,
|
||||||
|
#[serde(rename = "@album", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub album: Option<String>,
|
||||||
|
#[serde(rename = "@artist", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
#[serde(rename = "@track", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub track: Option<i32>,
|
||||||
|
#[serde(rename = "@year", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub year: Option<i32>,
|
||||||
|
#[serde(rename = "@genre", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub genre: Option<String>,
|
||||||
|
#[serde(rename = "@coverArt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cover_art: Option<String>,
|
||||||
|
#[serde(rename = "@size", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub size: Option<i32>,
|
||||||
|
#[serde(rename = "@contentType", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
#[serde(rename = "@suffix", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub suffix: Option<String>,
|
||||||
|
#[serde(
|
||||||
|
rename = "@transcodedContentType",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub transcoded_content_type: Option<String>,
|
||||||
|
#[serde(rename = "@transcodedSuffix", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub transcoded_suffix: Option<String>,
|
||||||
|
#[serde(rename = "@duration", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration: Option<i32>,
|
||||||
|
#[serde(rename = "@bitRate", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bit_rate: Option<i32>,
|
||||||
|
#[serde(rename = "@path", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(rename = "@isVideo", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub is_video: Option<bool>,
|
||||||
|
#[serde(rename = "@userRating", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub user_rating: Option<i32>,
|
||||||
|
#[serde(rename = "@averageRating", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub average_rating: Option<f32>,
|
||||||
|
#[serde(rename = "@playCount", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub play_count: Option<i32>,
|
||||||
|
#[serde(rename = "@discNumber", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub disc_number: Option<i32>,
|
||||||
|
#[serde(rename = "@created", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created: Option<OffsetDateTime>,
|
||||||
|
#[serde(rename = "@starred", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub starred: Option<OffsetDateTime>,
|
||||||
|
#[serde(rename = "@albumId", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub album_id: Option<String>,
|
||||||
|
#[serde(rename = "@artistId", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub artist_id: Option<String>,
|
||||||
|
#[serde(rename = "@type", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub r#type: Option<MediaType>,
|
||||||
|
#[serde(rename = "@bookmarkPosition", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bookmark_position: Option<i32>,
|
||||||
|
#[serde(rename = "@originalWidth", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub original_width: Option<i32>,
|
||||||
|
#[serde(rename = "@originalHeight", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub original_height: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||||
|
pub enum MediaType {
|
||||||
|
#[serde(rename = "music")]
|
||||||
|
Music,
|
||||||
|
#[serde(rename = "video")]
|
||||||
|
Video,
|
||||||
|
#[serde(rename = "audiobook")]
|
||||||
|
Audiobook,
|
||||||
|
#[serde(rename = "podcast")]
|
||||||
|
Podcast,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct MusicFolders {}
|
pub struct MusicFolder {
|
||||||
|
#[serde(rename = "@id")]
|
||||||
|
pub id: i32,
|
||||||
|
#[serde(rename = "@name")]
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum ResponseStatus {
|
pub enum ResponseStatus {
|
||||||
|
|
@ -96,14 +248,46 @@ pub enum Error {
|
||||||
RequestedDataWasNotFound(Option<String>),
|
RequestedDataWasNotFound(Option<String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for Error {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let message = self.message();
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Self::Generic(_) => write!(f, "Generic error: {message}"),
|
||||||
|
Self::RequiredParameterMissing(_) => {
|
||||||
|
write!(f, "Required parameter missing: {message}")
|
||||||
|
}
|
||||||
|
Self::IncompatibleClientVersion(_) => {
|
||||||
|
write!(f, "Incompatible client version: {message}")
|
||||||
|
}
|
||||||
|
Self::IncompatibleServerVersion(_) => {
|
||||||
|
write!(f, "Incompatible server version: {message}")
|
||||||
|
}
|
||||||
|
Self::WrongUsernameOrPassword(_) => {
|
||||||
|
write!(f, "Wrong username or password: {message}")
|
||||||
|
}
|
||||||
|
Self::TokenAuthenticationNotSupportedForLDAP(_) => {
|
||||||
|
write!(f, "Token authentication not supported for LDAP: {message}")
|
||||||
|
}
|
||||||
|
Self::UserIsNotAuthorizedForGivenOperation(_) => {
|
||||||
|
write!(f, "User is not authorized for given operation: {message}")
|
||||||
|
}
|
||||||
|
Self::TrialPeriodExpired(_) => write!(f, "Trial period expired: {message}"),
|
||||||
|
Self::RequestedDataWasNotFound(_) => {
|
||||||
|
write!(f, "Requested data was not found: {message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Serialize for Error {
|
impl Serialize for Error {
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
where
|
where
|
||||||
S: serde::Serializer,
|
S: serde::Serializer,
|
||||||
{
|
{
|
||||||
let mut error = serializer.serialize_struct("error", 2)?;
|
let mut error = serializer.serialize_struct("error", 2)?;
|
||||||
error.serialize_field("@code", &self.code())?;
|
error.serialize_field("code", &self.code())?;
|
||||||
error.serialize_field("@message", &self.message())?;
|
error.serialize_field("message", &self.message())?;
|
||||||
error.end()
|
error.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
src/utils.rs
Normal file
36
src/utils.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
authentication::Authentication,
|
||||||
|
subsonic::{Error, SubsonicResponse},
|
||||||
|
user::{get_user, User},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn verify_user(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
auth: Authentication,
|
||||||
|
) -> Result<User, SubsonicResponse> {
|
||||||
|
let user = get_user(pool, &auth.username).await;
|
||||||
|
|
||||||
|
match user {
|
||||||
|
Ok(Some(u)) => {
|
||||||
|
if u.verify(&auth.token, &auth.salt) {
|
||||||
|
Ok(u)
|
||||||
|
} else {
|
||||||
|
Err(SubsonicResponse::new_error(Error::WrongUsernameOrPassword(
|
||||||
|
None,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => Err(SubsonicResponse::new_error(Error::WrongUsernameOrPassword(
|
||||||
|
None,
|
||||||
|
))),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error getting user: {e}");
|
||||||
|
Err(SubsonicResponse::new_error(Error::WrongUsernameOrPassword(
|
||||||
|
None,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue