Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Select custom thumbnails #167

Merged
merged 6 commits into from
Oct 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mobile/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"include": ["**/*"],
"references": [],
"compilerOptions": {
"outDir": "../../.moon/cache/types/apps/mobile"
"outDir": "../../.moon/cache/types/apps/mobile",
"module": "NodeNext"
}
}
1 change: 1 addition & 0 deletions apps/server/src/config/cors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ pub fn get_cors_layer(port: u16) -> CorsLayer {
Method::GET,
Method::PUT,
Method::POST,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
Method::CONNECT,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ pub enum ApiError {
Forbidden(String),
#[error("This functionality has not been implemented yet")]
NotImplemented,
#[error("This functionality is not supported")]
NotSupported,
#[error("{0}")]
ServiceUnavailable(String),
#[error("{0}")]
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/routers/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ mod tests {

use super::v1::{
auth::LoginOrRegisterArgs, job::UpdateSchedulerConfig,
metadata::MediaMetadataOverview, ClaimResponse, StumpVersion,
library::PatchLibraryThumbnail, media::PatchMediaThumbnail,
metadata::MediaMetadataOverview, series::PatchSeriesThumbnail, ClaimResponse,
StumpVersion,
};

#[allow(dead_code)]
Expand Down Expand Up @@ -54,6 +56,16 @@ mod tests {
format!("{}\n\n", ts_export::<UpdateSchedulerConfig>()?).as_bytes(),
)?;

file.write_all(
format!("{}\n\n", ts_export::<PatchMediaThumbnail>()?).as_bytes(),
)?;
file.write_all(
format!("{}\n\n", ts_export::<PatchSeriesThumbnail>()?).as_bytes(),
)?;
file.write_all(
format!("{}\n\n", ts_export::<PatchLibraryThumbnail>()?).as_bytes(),
)?;

Ok(())
}
}
10 changes: 7 additions & 3 deletions apps/server/src/routers/api/v1/filesystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ use axum_sessions::extractors::ReadableSession;
use std::path::Path;
use stump_core::{
db::query::pagination::{PageQuery, Pageable},
filesystem::{DirectoryListing, DirectoryListingFile, DirectoryListingInput},
filesystem::{
DirectoryListing, DirectoryListingFile, DirectoryListingInput, FileParts,
PathUtils,
},
};
use tracing::trace;

Expand Down Expand Up @@ -76,6 +79,7 @@ pub async fn list_directory(
let page = pagination.page.unwrap_or(1);
let page_size = pagination.page_size.unwrap_or(100);

// TODO: I haven't touched this logic in a year, it needs a bit of a refatctor (lets see how long it takes me to get to it lol)
let mut files = listing
.filter_map(|e| e.ok())
.filter_map(|f| {
Expand All @@ -93,12 +97,12 @@ pub async fn list_directory(

let path = entry.path();

let name = path.file_name().unwrap().to_str().unwrap().to_string();
let FileParts { file_name, .. } = path.file_parts();
let is_directory = path.is_dir();
let path = path.to_string_lossy().to_string();

DirectoryListingFile {
name,
name: file_name,
is_directory,
path,
}
Expand Down
163 changes: 145 additions & 18 deletions apps/server/src/routers/api/v1/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use tracing::{debug, error, trace};
use utoipa::ToSchema;

use stump_core::{
config::get_config_dir,
db::{
entity::{
library_series_ids_media_ids_include, library_thumbnails_deletion_include,
Expand All @@ -24,11 +25,14 @@ use stump_core::{
PrismaCountTrait,
},
filesystem::{
get_unknown_thumnail,
image::{
self, remove_thumbnails, remove_thumbnails_of_type, ImageProcessorOptions,
ThumbnailJob, ThumbnailJobConfig,
self, generate_thumbnail, remove_thumbnails, remove_thumbnails_of_type,
ImageFormat, ImageProcessorOptions, ThumbnailJob, ThumbnailJobConfig,
},
read_entire_file,
scanner::LibraryScanJob,
ContentType, FileParts, PathUtils,
},
prisma::{
library::{self, WhereParam},
Expand All @@ -51,12 +55,10 @@ use crate::{
};

use super::{
media::{
apply_media_age_restriction, apply_media_filters, apply_media_pagination,
get_media_thumbnail,
},
media::{apply_media_age_restriction, apply_media_filters, apply_media_pagination},
series::{
apply_series_age_restriction, apply_series_base_filters, apply_series_filters,
get_series_thumbnail,
},
};

Expand Down Expand Up @@ -84,7 +86,9 @@ pub(crate) fn mount(app_state: AppState) -> Router<AppState> {
Router::new()
.route(
"/",
get(get_library_thumbnail).delete(delete_library_thumbnails),
get(get_library_thumbnail_handler)
.patch(patch_library_thumbnail)
.delete(delete_library_thumbnails),
)
.route("/generate", post(generate_library_thumbnails)),
),
Expand Down Expand Up @@ -425,6 +429,36 @@ async fn get_library_media(
Ok(Json(Pageable::from(media)))
}

pub(crate) fn get_library_thumbnail(
library: &library::Data,
first_series: &series::Data,
first_book: &media::Data,
image_format: Option<ImageFormat>,
) -> ApiResult<(ContentType, Vec<u8>)> {
let thumbnails = get_config_dir().join("thumbnails");
let library_id = library.id.clone();

if let Some(format) = image_format.clone() {
let extension = format.extension();

let path = thumbnails.join(format!("{}.{}", library_id, extension));

if path.exists() {
tracing::trace!(?path, library_id, "Found generated library thumbnail");
return Ok((ContentType::from(format), read_entire_file(path)?));
}
} else if let Some(path) = get_unknown_thumnail(&library_id) {
tracing::debug!(path = ?path, library_id, "Found library thumbnail that does not align with config");
let FileParts { extension, .. } = path.file_parts();
return Ok((
ContentType::from_extension(extension.as_str()),
read_entire_file(path)?,
));
}

get_series_thumbnail(first_series, first_book, image_format)
}

// TODO: ImageResponse for utoipa
#[utoipa::path(
get,
Expand All @@ -441,7 +475,7 @@ async fn get_library_media(
)
)]
/// Get the thumbnail image for a library by id, if the current user has access to it.
async fn get_library_thumbnail(
async fn get_library_thumbnail_handler(
Path(id): Path<String>,
State(ctx): State<AppState>,
session: ReadableSession,
Expand All @@ -451,7 +485,7 @@ async fn get_library_thumbnail(
let user = get_session_user(&session)?;
let age_restriction = user.age_restriction;

let library_series = db
let first_series = db
.series()
// Find the first series in the library which satisfies the age restriction
.find_first(chain_optional_iter(
Expand All @@ -472,16 +506,12 @@ async fn get_library_thumbnail(
.order_by(media::name::order(Direction::Asc)),
)
.with(series::library::fetch().with(library::library_options::fetch()))
.order_by(series::name::order(Direction::Asc))
.exec()
.await?;
.await?
.ok_or(ApiError::NotFound("Library has no series".to_string()))?;

let series = library_series.ok_or(ApiError::NotFound(
"Library has no series to get thumbnail from".to_string(),
))?;
let media = series.media()?.first().ok_or(ApiError::NotFound(
"Library has no media to get thumbnail from".to_string(),
))?;
let library = series
let library = first_series
.library()?
.ok_or(ApiError::Unknown(String::from("Failed to load library")))?;
let image_format = library
Expand All @@ -490,7 +520,104 @@ async fn get_library_thumbnail(
.thumbnail_config
.map(|config| config.format);

get_media_thumbnail(media, image_format).map(ImageResponse::from)
let first_book = first_series.media()?.first().ok_or(ApiError::NotFound(
"Library has no media to get thumbnail from".to_string(),
))?;

get_library_thumbnail(library, &first_series, first_book, image_format)
.map(ImageResponse::from)
}

#[derive(Deserialize, ToSchema, specta::Type)]
pub struct PatchLibraryThumbnail {
/// The ID of the media inside the series to fetch
media_id: String,
/// The page of the media to use for the thumbnail
page: i32,
#[specta(optional)]
/// A flag indicating whether the page is zero based
is_zero_based: Option<bool>,
}

#[utoipa::path(
patch,
path = "/api/v1/libraries/:id/thumbnail",
tag = "library",
params(
("id" = String, Path, description = "The ID of the library")
),
responses(
(status = 200, description = "Successfully updated library thumbnail"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Series not found"),
(status = 500, description = "Internal server error"),
)
)]
async fn patch_library_thumbnail(
Path(id): Path<String>,
State(ctx): State<AppState>,
session: ReadableSession,
Json(body): Json<PatchLibraryThumbnail>,
) -> ApiResult<ImageResponse> {
get_session_admin_user(&session)?;

let client = ctx.get_db();

let target_page = body
.is_zero_based
.map(|is_zero_based| {
if is_zero_based {
body.page + 1
} else {
body.page
}
})
.unwrap_or(body.page);

let media = client
.media()
.find_first(vec![
media::series::is(vec![series::library_id::equals(Some(id.clone()))]),
media::id::equals(body.media_id),
])
.with(
media::series::fetch()
.with(series::library::fetch().with(library::library_options::fetch())),
)
.exec()
.await?
.ok_or(ApiError::NotFound(String::from("Media not found")))?;

if media.extension == "epub" {
return Err(ApiError::NotSupported);
}

let library = media
.series()?
.ok_or(ApiError::NotFound(String::from("Series relation missing")))?
.library()?
.ok_or(ApiError::NotFound(String::from("Library relation missing")))?;
let thumbnail_options = library
.library_options()?
.thumbnail_config
.to_owned()
.map(ImageProcessorOptions::try_from)
.transpose()?
.unwrap_or_else(|| {
tracing::warn!(
"Failed to parse existing thumbnail config! Using a default config"
);
ImageProcessorOptions::default()
})
.with_page(target_page);

let format = thumbnail_options.format.clone();
let path_buf = generate_thumbnail(&id, &media.path, thumbnail_options)?;
Ok(ImageResponse::from((
ContentType::from(format),
read_entire_file(path_buf)?,
)))
}

/// Deletes all media thumbnails in a library by id, if the current user has access to it.
Expand Down
Loading