Range requests

This commit is contained in:
Antoine Gersant 2024-07-13 18:25:33 -07:00
parent 153943a3ae
commit 0e63f64513
4 changed files with 90 additions and 673 deletions

55
Cargo.lock generated
View file

@ -147,6 +147,44 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733"
dependencies = [
"axum",
"axum-core",
"bytes",
"futures-util",
"headers",
"http 1.1.0",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"serde",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-range"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c30398a7f716ebdd7f3c8a4f7a7a6df48a30e002007fd57b2a7a00fac864bd"
dependencies = [
"axum",
"axum-extra",
"bytes",
"futures",
"http-body",
"pin-project",
"tokio",
]
[[package]]
name = "axum-test"
version = "15.3.0"
@ -621,6 +659,20 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.30"
@ -694,6 +746,7 @@ version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@ -1511,6 +1564,8 @@ version = "0.0.0"
dependencies = [
"ape",
"axum",
"axum-extra",
"axum-range",
"axum-test",
"base64 0.21.3",
"branca",

View file

@ -10,6 +10,8 @@ ui = ["native-windows-gui", "native-windows-derive"]
[dependencies]
ape = "0.5"
axum-extra = { version = "0.9.3", features = ["typed-header"] }
axum-range = "0.4.0"
base64 = "0.21"
branca = "0.10.1"
crossbeam-channel = "0.5"

View file

@ -1,670 +0,0 @@
use actix_files::NamedFile;
use actix_web::body::BoxBody;
use actix_web::http::header::ContentEncoding;
use actix_web::{
delete,
dev::Payload,
error::{ErrorForbidden, ErrorInternalServerError, ErrorUnauthorized},
get,
http::StatusCode,
post, put,
web::{self, Data, Json, JsonConfig, ServiceConfig},
FromRequest, HttpRequest, HttpResponse, Responder, ResponseError,
};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use base64::prelude::*;
use futures_util::future::err;
use percent_encoding::percent_decode_str;
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use std::str;
use crate::app::{
config, ddns,
index::{self, Index},
lastfm, playlist, settings, thumbnail, user,
vfs::{self, MountDir},
};
use crate::server::{dto, error::*};
pub fn make_config() -> impl FnOnce(&mut ServiceConfig) + Clone {
move |cfg: &mut ServiceConfig| {
let megabyte = 1024 * 1024;
cfg.app_data(JsonConfig::default().limit(4 * megabyte)) // 4MB
.service(version)
.service(initial_setup)
.service(apply_config)
.service(get_settings)
.service(put_settings)
.service(list_mount_dirs)
.service(put_mount_dirs)
.service(get_ddns_config)
.service(put_ddns_config)
.service(list_users)
.service(create_user)
.service(update_user)
.service(delete_user)
.service(get_preferences)
.service(put_preferences)
.service(trigger_index)
.service(login)
.service(browse_root)
.service(browse)
.service(flatten_root)
.service(flatten)
.service(random)
.service(recent)
.service(search_root)
.service(search)
.service(get_audio)
.service(get_thumbnail)
.service(list_playlists)
.service(save_playlist)
.service(read_playlist)
.service(delete_playlist)
.service(lastfm_now_playing)
.service(lastfm_scrobble)
.service(lastfm_link_token)
.service(lastfm_link)
.service(lastfm_unlink);
}
}
impl ResponseError for APIError {
fn status_code(&self) -> StatusCode {
match self {
APIError::AuthorizationTokenEncoding => StatusCode::INTERNAL_SERVER_ERROR,
APIError::AdminPermissionRequired => StatusCode::UNAUTHORIZED,
APIError::AudioFileIOError => StatusCode::NOT_FOUND,
APIError::AuthenticationRequired => StatusCode::UNAUTHORIZED,
APIError::BrancaTokenEncoding => StatusCode::INTERNAL_SERVER_ERROR,
APIError::DdnsUpdateQueryFailed(s) => {
StatusCode::from_u16(*s).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}
APIError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
APIError::DeletingOwnAccount => StatusCode::CONFLICT,
APIError::EmbeddedArtworkNotFound => StatusCode::NOT_FOUND,
APIError::EmptyPassword => StatusCode::BAD_REQUEST,
APIError::EmptyUsername => StatusCode::BAD_REQUEST,
APIError::IncorrectCredentials => StatusCode::UNAUTHORIZED,
APIError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
APIError::Io(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
APIError::LastFMAccountNotLinked => StatusCode::NO_CONTENT,
APIError::LastFMLinkContentBase64DecodeError => StatusCode::BAD_REQUEST,
APIError::LastFMLinkContentEncodingError => StatusCode::BAD_REQUEST,
APIError::LastFMNowPlaying(_) => StatusCode::FAILED_DEPENDENCY,
APIError::LastFMScrobble(_) => StatusCode::FAILED_DEPENDENCY,
APIError::LastFMScrobblerAuthentication(_) => StatusCode::FAILED_DEPENDENCY,
APIError::OwnAdminPrivilegeRemoval => StatusCode::CONFLICT,
APIError::PasswordHashing => StatusCode::INTERNAL_SERVER_ERROR,
APIError::PlaylistNotFound => StatusCode::NOT_FOUND,
APIError::Settings(_) => StatusCode::INTERNAL_SERVER_ERROR,
APIError::SongMetadataNotFound => StatusCode::NOT_FOUND,
APIError::ThumbnailFlacDecoding(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
APIError::ThumbnailFileIOError => StatusCode::NOT_FOUND,
APIError::ThumbnailId3Decoding(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
APIError::ThumbnailImageDecoding(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
APIError::ThumbnailMp4Decoding(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
APIError::TomlDeserialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
APIError::UnsupportedThumbnailFormat(_) => StatusCode::INTERNAL_SERVER_ERROR,
APIError::UserNotFound => StatusCode::NOT_FOUND,
APIError::VFSPathNotFound => StatusCode::NOT_FOUND,
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
HttpResponse::new(self.status_code())
}
}
#[derive(Debug)]
struct Auth {
username: String,
}
impl FromRequest for Auth {
type Error = actix_web::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(request: &HttpRequest, payload: &mut Payload) -> Self::Future {
let user_manager = match request.app_data::<Data<user::Manager>>() {
Some(m) => m.clone(),
None => return Box::pin(err(ErrorInternalServerError(APIError::Internal))),
};
let bearer_auth_future = BearerAuth::from_request(request, payload);
let query_params_future =
web::Query::<dto::AuthQueryParameters>::from_request(request, payload);
Box::pin(async move {
// Auth via bearer token in query parameter
if let Ok(query) = query_params_future.await {
let auth_token = user::AuthToken(query.auth_token.clone());
if let Ok(auth) = user_manager
.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth)
.await
{
return Ok(Auth {
username: auth.username,
});
}
}
// Auth via bearer token in authorization header
if let Ok(bearer_auth) = bearer_auth_future.await {
let auth_token = user::AuthToken(bearer_auth.token().to_owned());
if let Ok(auth) = user_manager
.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth)
.await
{
return Ok(Auth {
username: auth.username,
});
}
}
Err(ErrorUnauthorized(APIError::AuthenticationRequired))
})
}
}
#[derive(Debug)]
struct AdminRights {
auth: Option<Auth>,
}
impl FromRequest for AdminRights {
type Error = actix_web::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(request: &HttpRequest, payload: &mut Payload) -> Self::Future {
let user_manager = match request.app_data::<Data<user::Manager>>() {
Some(m) => m.clone(),
None => return Box::pin(err(ErrorInternalServerError(APIError::Internal))),
};
let auth_future = Auth::from_request(request, payload);
Box::pin(async move {
let user_count = user_manager.count().await;
match user_count {
Err(_) => return Err(ErrorInternalServerError(APIError::Internal)),
Ok(0) => return Ok(AdminRights { auth: None }),
_ => (),
};
let auth = auth_future.await?;
match user_manager.is_admin(&auth.username).await {
Ok(true) => Ok(AdminRights { auth: Some(auth) }),
Ok(false) => Err(ErrorForbidden(APIError::AdminPermissionRequired)),
Err(_) => Err(ErrorInternalServerError(APIError::Internal)),
}
})
}
}
struct MediaFile {
named_file: NamedFile,
}
impl MediaFile {
fn new(named_file: NamedFile) -> Self {
Self { named_file }
}
}
impl Responder for MediaFile {
type Body = BoxBody;
fn respond_to(self, req: &HttpRequest) -> HttpResponse<Self::Body> {
// Intentionally turn off content encoding for media files because:
// 1. There is little value in compressing files that are already compressed (mp3, jpg, etc.)
// 2. The Content-Length header is incompatible with content encoding (other than identity), and can be valuable for clients
self.named_file
.set_content_encoding(ContentEncoding::Identity)
.into_response(req)
}
}
#[get("/version")]
async fn version() -> Json<dto::Version> {
let current_version = dto::Version {
major: dto::API_MAJOR_VERSION,
minor: dto::API_MINOR_VERSION,
};
Json(current_version)
}
#[get("/initial_setup")]
async fn initial_setup(
user_manager: Data<user::Manager>,
) -> Result<Json<dto::InitialSetup>, APIError> {
let initial_setup = {
let users = user_manager.list().await?;
let has_any_admin = users.iter().any(|u| u.is_admin());
dto::InitialSetup {
has_any_users: has_any_admin,
}
};
Ok(Json(initial_setup))
}
#[put("/config")]
async fn apply_config(
_admin_rights: AdminRights,
config_manager: Data<config::Manager>,
config: Json<dto::Config>,
) -> Result<HttpResponse, APIError> {
config_manager.apply(&config.to_owned().into()).await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[get("/settings")]
async fn get_settings(
settings_manager: Data<settings::Manager>,
_admin_rights: AdminRights,
) -> Result<Json<dto::Settings>, APIError> {
let settings = settings_manager.read().await?;
Ok(Json(settings.into()))
}
#[put("/settings")]
async fn put_settings(
_admin_rights: AdminRights,
settings_manager: Data<settings::Manager>,
new_settings: Json<dto::NewSettings>,
) -> Result<HttpResponse, APIError> {
settings_manager
.amend(&new_settings.to_owned().into())
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[get("/mount_dirs")]
async fn list_mount_dirs(
vfs_manager: Data<vfs::Manager>,
_admin_rights: AdminRights,
) -> Result<Json<Vec<dto::MountDir>>, APIError> {
let mount_dirs = vfs_manager.mount_dirs().await?;
let mount_dirs = mount_dirs.into_iter().map(|m| m.into()).collect();
Ok(Json(mount_dirs))
}
#[put("/mount_dirs")]
async fn put_mount_dirs(
_admin_rights: AdminRights,
vfs_manager: Data<vfs::Manager>,
new_mount_dirs: Json<Vec<dto::MountDir>>,
) -> Result<HttpResponse, APIError> {
let new_mount_dirs: Vec<MountDir> = new_mount_dirs.iter().cloned().map(|m| m.into()).collect();
vfs_manager.set_mount_dirs(&new_mount_dirs).await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[get("/ddns")]
async fn get_ddns_config(
ddns_manager: Data<ddns::Manager>,
_admin_rights: AdminRights,
) -> Result<Json<dto::DDNSConfig>, APIError> {
let ddns_config = ddns_manager.config().await?;
Ok(Json(ddns_config.into()))
}
#[put("/ddns")]
async fn put_ddns_config(
_admin_rights: AdminRights,
ddns_manager: Data<ddns::Manager>,
new_ddns_config: Json<dto::DDNSConfig>,
) -> Result<HttpResponse, APIError> {
ddns_manager
.set_config(&new_ddns_config.to_owned().into())
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[get("/users")]
async fn list_users(
user_manager: Data<user::Manager>,
_admin_rights: AdminRights,
) -> Result<Json<Vec<dto::User>>, APIError> {
let users = user_manager.list().await?;
let users = users.into_iter().map(|u| u.into()).collect();
Ok(Json(users))
}
#[post("/user")]
async fn create_user(
user_manager: Data<user::Manager>,
_admin_rights: AdminRights,
new_user: Json<dto::NewUser>,
) -> Result<HttpResponse, APIError> {
let new_user = new_user.to_owned().into();
user_manager.create(&new_user).await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[put("/user/{name}")]
async fn update_user(
user_manager: Data<user::Manager>,
admin_rights: AdminRights,
name: web::Path<String>,
user_update: Json<dto::UserUpdate>,
) -> Result<HttpResponse, APIError> {
if let Some(auth) = &admin_rights.auth {
if auth.username == name.as_str() && user_update.new_is_admin == Some(false) {
return Err(APIError::OwnAdminPrivilegeRemoval);
}
}
if let Some(password) = &user_update.new_password {
user_manager.set_password(&name, password).await?;
}
if let Some(is_admin) = &user_update.new_is_admin {
user_manager.set_is_admin(&name, *is_admin).await?;
}
Ok(HttpResponse::new(StatusCode::OK))
}
#[delete("/user/{name}")]
async fn delete_user(
user_manager: Data<user::Manager>,
admin_rights: AdminRights,
name: web::Path<String>,
) -> Result<HttpResponse, APIError> {
if let Some(auth) = &admin_rights.auth {
if auth.username == name.as_str() {
return Err(APIError::DeletingOwnAccount);
}
}
user_manager.delete(&name).await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[get("/preferences")]
async fn get_preferences(
user_manager: Data<user::Manager>,
auth: Auth,
) -> Result<Json<user::Preferences>, APIError> {
let preferences = user_manager.read_preferences(&auth.username).await?;
Ok(Json(preferences))
}
#[put("/preferences")]
async fn put_preferences(
user_manager: Data<user::Manager>,
auth: Auth,
preferences: Json<user::Preferences>,
) -> Result<HttpResponse, APIError> {
user_manager
.write_preferences(&auth.username, &preferences)
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[post("/trigger_index")]
async fn trigger_index(
index: Data<Index>,
_admin_rights: AdminRights,
) -> Result<HttpResponse, APIError> {
index.trigger_reindex();
Ok(HttpResponse::new(StatusCode::OK))
}
#[post("/auth")]
async fn login(
user_manager: Data<user::Manager>,
credentials: Json<dto::Credentials>,
) -> Result<HttpResponse, APIError> {
let username = credentials.username.clone();
let user::AuthToken(token) = user_manager
.login(&credentials.username, &credentials.password)
.await?;
let is_admin = user_manager.is_admin(&credentials.username).await?;
let authorization = dto::Authorization {
username: username.clone(),
token,
is_admin,
};
let response = HttpResponse::Ok().json(authorization);
Ok(response)
}
#[get("/browse")]
async fn browse_root(
index: Data<Index>,
_auth: Auth,
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
let result = index.browse(Path::new("")).await?;
Ok(Json(result))
}
#[get("/browse/{path:.*}")]
async fn browse(
index: Data<Index>,
_auth: Auth,
path: web::Path<String>,
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
let path = percent_decode_str(&path).decode_utf8_lossy();
let result = index.browse(Path::new(path.as_ref())).await?;
Ok(Json(result))
}
#[get("/flatten")]
async fn flatten_root(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Song>>, APIError> {
let songs = index.flatten(Path::new("")).await?;
Ok(Json(songs))
}
#[get("/flatten/{path:.*}")]
async fn flatten(
index: Data<Index>,
_auth: Auth,
path: web::Path<String>,
) -> Result<Json<Vec<index::Song>>, APIError> {
let path = percent_decode_str(&path).decode_utf8_lossy();
let songs = index.flatten(Path::new(path.as_ref())).await?;
Ok(Json(songs))
}
#[get("/random")]
async fn random(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>, APIError> {
let result = index.get_random_albums(20).await?;
Ok(Json(result))
}
#[get("/recent")]
async fn recent(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>, APIError> {
let result = index.get_recent_albums(20).await?;
Ok(Json(result))
}
#[get("/search")]
async fn search_root(
index: Data<Index>,
_auth: Auth,
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
let result = index.search("").await?;
Ok(Json(result))
}
#[get("/search/{query:.*}")]
async fn search(
index: Data<Index>,
_auth: Auth,
query: web::Path<String>,
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
let result = index.search(&query).await?;
Ok(Json(result))
}
#[get("/audio/{path:.*}")]
async fn get_audio(
vfs_manager: Data<vfs::Manager>,
_auth: Auth,
path: web::Path<String>,
) -> Result<MediaFile, APIError> {
let vfs = vfs_manager.get_vfs().await?;
let path = percent_decode_str(&path).decode_utf8_lossy();
let audio_path = vfs.virtual_to_real(Path::new(path.as_ref()))?;
let named_file = NamedFile::open(audio_path).map_err(|_| APIError::AudioFileIOError)?;
Ok(MediaFile::new(named_file))
}
#[get("/thumbnail/{path:.*}")]
async fn get_thumbnail(
vfs_manager: Data<vfs::Manager>,
thumbnails_manager: Data<thumbnail::Manager>,
_auth: Auth,
path: web::Path<String>,
options_input: web::Query<dto::ThumbnailOptions>,
) -> Result<MediaFile, APIError> {
let options = thumbnail::Options::from(options_input.0);
let vfs = vfs_manager.get_vfs().await?;
let path = percent_decode_str(&path).decode_utf8_lossy();
let image_path = vfs.virtual_to_real(Path::new(path.as_ref()))?;
let thumbnail_path = thumbnails_manager.get_thumbnail(&image_path, &options)?;
let named_file = NamedFile::open(thumbnail_path).map_err(|_| APIError::ThumbnailFileIOError)?;
Ok(MediaFile::new(named_file))
}
#[get("/playlists")]
async fn list_playlists(
playlist_manager: Data<playlist::Manager>,
auth: Auth,
) -> Result<Json<Vec<dto::ListPlaylistsEntry>>, APIError> {
let playlist_names = playlist_manager.list_playlists(&auth.username).await?;
let playlists: Vec<dto::ListPlaylistsEntry> = playlist_names
.into_iter()
.map(|p| dto::ListPlaylistsEntry { name: p })
.collect();
Ok(Json(playlists))
}
#[put("/playlist/{name}")]
async fn save_playlist(
playlist_manager: Data<playlist::Manager>,
auth: Auth,
name: web::Path<String>,
playlist: Json<dto::SavePlaylistInput>,
) -> Result<HttpResponse, APIError> {
playlist_manager
.save_playlist(&name, &auth.username, &playlist.tracks)
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[get("/playlist/{name}")]
async fn read_playlist(
playlist_manager: Data<playlist::Manager>,
auth: Auth,
name: web::Path<String>,
) -> Result<Json<Vec<index::Song>>, APIError> {
let songs = playlist_manager
.read_playlist(&name, &auth.username)
.await?;
Ok(Json(songs))
}
#[delete("/playlist/{name}")]
async fn delete_playlist(
playlist_manager: Data<playlist::Manager>,
auth: Auth,
name: web::Path<String>,
) -> Result<HttpResponse, APIError> {
playlist_manager
.delete_playlist(&name, &auth.username)
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[put("/lastfm/now_playing/{path:.*}")]
async fn lastfm_now_playing(
lastfm_manager: Data<lastfm::Manager>,
user_manager: Data<user::Manager>,
auth: Auth,
path: web::Path<String>,
) -> Result<HttpResponse, APIError> {
if !user_manager.is_lastfm_linked(&auth.username).await {
return Err(APIError::LastFMAccountNotLinked);
}
let path = percent_decode_str(&path).decode_utf8_lossy();
lastfm_manager
.now_playing(&auth.username, Path::new(path.as_ref()))
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[post("/lastfm/scrobble/{path:.*}")]
async fn lastfm_scrobble(
lastfm_manager: Data<lastfm::Manager>,
user_manager: Data<user::Manager>,
auth: Auth,
path: web::Path<String>,
) -> Result<HttpResponse, APIError> {
if !user_manager.is_lastfm_linked(&auth.username).await {
return Err(APIError::LastFMAccountNotLinked);
}
let path = percent_decode_str(&path).decode_utf8_lossy();
lastfm_manager
.scrobble(&auth.username, Path::new(path.as_ref()))
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
#[get("/lastfm/link_token")]
async fn lastfm_link_token(
lastfm_manager: Data<lastfm::Manager>,
auth: Auth,
) -> Result<Json<dto::LastFMLinkToken>, APIError> {
let user::AuthToken(value) = lastfm_manager.generate_link_token(&auth.username)?;
Ok(Json(dto::LastFMLinkToken { value }))
}
#[get("/lastfm/link")]
async fn lastfm_link(
lastfm_manager: Data<lastfm::Manager>,
user_manager: Data<user::Manager>,
payload: web::Query<dto::LastFMLink>,
) -> Result<HttpResponse, APIError> {
let auth_token = user::AuthToken(payload.auth_token.clone());
let authorization = user_manager
.authenticate(&auth_token, user::AuthorizationScope::LastFMLink)
.await?;
let lastfm_token = &payload.token;
lastfm_manager
.link(&authorization.username, lastfm_token)
.await?;
// Percent decode
let base64_content = percent_decode_str(&payload.content).decode_utf8_lossy();
// Base64 decode
let popup_content = BASE64_STANDARD_NO_PAD
.decode(base64_content.as_bytes())
.map_err(|_| APIError::LastFMLinkContentBase64DecodeError)?;
// UTF-8 decode
let popup_content_string = str::from_utf8(&popup_content)
.map_err(|_| APIError::LastFMLinkContentEncodingError)
.map(|s| s.to_owned())?;
Ok(HttpResponse::build(StatusCode::OK)
.content_type("text/html; charset=utf-8")
.body(popup_content_string))
}
#[delete("/lastfm/link")]
async fn lastfm_unlink(
lastfm_manager: Data<lastfm::Manager>,
auth: Auth,
) -> Result<HttpResponse, APIError> {
lastfm_manager.unlink(&auth.username).await?;
Ok(HttpResponse::new(StatusCode::OK))
}

View file

@ -1,13 +1,14 @@
use axum::{
body::Body,
extract::{DefaultBodyLimit, Path, Query, State},
response::{Html, IntoResponse},
routing::{delete, get, post, put},
Json, Router,
};
use axum_extra::headers::Range;
use axum_extra::TypedHeader;
use axum_range::{KnownSize, Ranged};
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use percent_encoding::percent_decode_str;
use tokio_util::io::ReaderStream;
use crate::{
app::{config, ddns, index, lastfm, playlist, settings, thumbnail, user, vfs, App},
@ -47,6 +48,7 @@ pub fn router() -> Router<App> {
.route("/playlist/:name", put(put_playlist))
.route("/playlist/:name", get(get_playlist))
.route("/playlist/:name", delete(delete_playlist))
.route("/audio/*path", get(get_audio))
.route("/thumbnail/*path", get(get_thumbnail))
.route("/lastfm/now_playing/*path", put(put_lastfm_now_playing))
.route("/lastfm/scrobble/*path", post(post_lastfm_scrobble))
@ -366,12 +368,35 @@ async fn delete_playlist(
Ok(())
}
async fn get_audio(
_auth: Auth,
State(vfs_manager): State<vfs::Manager>,
Path(path): Path<String>,
range: Option<TypedHeader<Range>>,
) -> Result<impl IntoResponse, APIError> {
let vfs = vfs_manager.get_vfs().await?;
let path = percent_decode_str(&path).decode_utf8_lossy();
let audio_path = vfs.virtual_to_real(std::path::Path::new(path.as_ref()))?;
let Ok(file) = tokio::fs::File::open(audio_path).await else {
return Err(APIError::AudioFileIOError);
};
let Ok(body) = KnownSize::file(file).await else {
return Err(APIError::AudioFileIOError);
};
let range = range.map(|TypedHeader(r)| r);
Ok(Ranged::new(range, body))
}
async fn get_thumbnail(
_auth: Auth,
State(vfs_manager): State<vfs::Manager>,
State(thumbnails_manager): State<thumbnail::Manager>,
Path(path): Path<String>,
Query(options_input): Query<dto::ThumbnailOptions>,
range: Option<TypedHeader<Range>>,
) -> Result<impl IntoResponse, APIError> {
let options = thumbnail::Options::from(options_input);
let vfs = vfs_manager.get_vfs().await?;
@ -383,7 +408,12 @@ async fn get_thumbnail(
return Err(APIError::ThumbnailFileIOError);
};
Ok(Body::from_stream(ReaderStream::new(file)))
let Ok(body) = KnownSize::file(file).await else {
return Err(APIError::ThumbnailFileIOError);
};
let range = range.map(|TypedHeader(r)| r);
Ok(Ranged::new(range, body))
}
async fn put_lastfm_now_playing(