From d01583b4069931343cf34f3c3fcc00093812214d Mon Sep 17 00:00:00 2001
From: Tobias Schmitz <tobiasschmitz2001@gmail.com>
Date: Sat, 5 Jun 2021 11:24:25 +0200
Subject: [PATCH] add api parameter for thumbnail size (#144)

* add api parameter for thumbnail size

* make max_dimension optinal in case of native resolution

* add tests for thumbnail size

* fix typo

* fix thumbnail size tests

* make unwrap more explicit

* remove print statement

* update workflows

* reduce thumbnail variations

* add removed token

* Update coverage.yml

* fix typo

* hopefully prevent coverage timeout

- split up thumnail tests
- reduce threadcount used for test execution

* get thread count using github actions specific step

* use fixed thread count of 4

* run coverage tests in release mode

* ignore large and native thumbnail_size tests in coverage
---
 .github/workflows/build.yml    |  2 +-
 .github/workflows/coverage.yml | 12 ++++----
 .github/workflows/release.yml  |  6 ++--
 .gitignore                     |  1 +
 docs/swagger/polaris-api.json  | 10 +++++++
 src/app/thumbnail/generate.rs  |  5 +++-
 src/app/thumbnail/options.rs   |  4 +--
 src/service/actix/api.rs       |  3 +-
 src/service/dto.rs             | 30 +++++++++++++++++++-
 src/service/test/media.rs      | 51 ++++++++++++++++++++++++++++++++--
 src/service/test/protocol.rs   | 36 ++++++++++++++++++------
 11 files changed, 133 insertions(+), 27 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a1cb45f..cce54b0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -20,7 +20,7 @@ jobs:
     - name: Install libsqlite3-dev
       if: contains(matrix.os, 'ubuntu') && !contains(matrix.features, 'bundle-sqlite')
       run: sudo apt-get update && sudo apt-get install libsqlite3-dev
-    - uses: actions/checkout@v1
+    - uses: actions/checkout@v2
     - uses: actions-rs/toolchain@v1
       with:
         profile: minimal
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 7a4db5d..e513267 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -15,14 +15,16 @@ jobs:
 
     steps:
     - name: Checkout Polaris
-      uses: actions/checkout@v1
+      uses: actions/checkout@v2
     - uses: actions-rs/toolchain@v1
       with:
         profile: minimal
-    - name: Install Tarpaulin
-      run: cargo install cargo-tarpaulin
-    - name: Run Tests
-      run: cargo tarpaulin --all-features --ignore-tests --out Xml
+    - name: Run Tarpaulin
+      uses: actions-rs/tarpaulin@v0.1
+      with:
+        args: '--all-features --ignore-tests'
+        out-type: Xml
+        timeout: 240
     - name: Upload Results
       uses: codecov/codecov-action@v1
       with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 25032a0..02855f5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -20,7 +20,7 @@ jobs:
           target_branch: release
           github_token: ${{ secrets.GITHUB_TOKEN }}
       - name: Checkout Release Branch
-        uses: actions/checkout@master
+        uses: actions/checkout@v2
         with:
           ref: release
       - name: Update Polaris Version in Cargo.toml
@@ -70,7 +70,7 @@ jobs:
 
     steps:
       - name: Checkout Polaris
-        uses: actions/checkout@v1
+        uses: actions/checkout@v2
         with:
           ref: release
       - name: Install Rust Toolchain
@@ -106,7 +106,7 @@ jobs:
 
     steps:
       - name: Checkout Polaris
-        uses: actions/checkout@v1
+        uses: actions/checkout@v2
         with:
           ref: release
       - name: Make release
diff --git a/.gitignore b/.gitignore
index 3bc29b8..0ae085c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ TestConfig.toml
 # Runtime artifacts
 *.sqlite
 polaris.log
+polaris.pid
 /thumbnails
 
 # Release process artifacts (usually runs on CI)
diff --git a/docs/swagger/polaris-api.json b/docs/swagger/polaris-api.json
index ed38f24..a920ac5 100644
--- a/docs/swagger/polaris-api.json
+++ b/docs/swagger/polaris-api.json
@@ -791,6 +791,16 @@
                             "type": "string"
                         }
                     },
+                    {
+                        "name": "size",
+                        "in": "query",
+                        "description": "The maximum size of the thumbnail, either small (400x400), large (1200x1200) or native",
+                        "schema": {
+                            "type": "string",
+                            "enum": ["small", "large", "native"],
+                            "default": "small"
+                        }
+                    },
                     {
                         "name": "pad",
                         "in": "query",
diff --git a/src/app/thumbnail/generate.rs b/src/app/thumbnail/generate.rs
index 4698fdb..94c56bc 100644
--- a/src/app/thumbnail/generate.rs
+++ b/src/app/thumbnail/generate.rs
@@ -9,7 +9,10 @@ pub fn generate_thumbnail(image_path: &Path, options: &Options) -> Result<Dynami
 	let source_image = DynamicImage::ImageRgb8(read(image_path)?.into_rgb8());
 	let (source_width, source_height) = source_image.dimensions();
 	let largest_dimension = cmp::max(source_width, source_height);
-	let out_dimension = cmp::min(options.max_dimension, largest_dimension);
+	let out_dimension = cmp::min(
+		options.max_dimension.unwrap_or(largest_dimension),
+		largest_dimension,
+	);
 
 	let source_aspect_ratio: f32 = source_width as f32 / source_height as f32;
 	let is_almost_square = source_aspect_ratio > 0.8 && source_aspect_ratio < 1.2;
diff --git a/src/app/thumbnail/options.rs b/src/app/thumbnail/options.rs
index 281e2e4..3219245 100644
--- a/src/app/thumbnail/options.rs
+++ b/src/app/thumbnail/options.rs
@@ -1,6 +1,6 @@
 #[derive(Debug, Hash)]
 pub struct Options {
-	pub max_dimension: u32,
+	pub max_dimension: Option<u32>,
 	pub resize_if_almost_square: bool,
 	pub pad_to_square: bool,
 }
@@ -8,7 +8,7 @@ pub struct Options {
 impl Default for Options {
 	fn default() -> Self {
 		Self {
-			max_dimension: 400,
+			max_dimension: Some(400),
 			resize_if_almost_square: true,
 			pad_to_square: true,
 		}
diff --git a/src/service/actix/api.rs b/src/service/actix/api.rs
index 7097546..fdc7659 100644
--- a/src/service/actix/api.rs
+++ b/src/service/actix/api.rs
@@ -703,8 +703,7 @@ async fn get_thumbnail(
 	path: web::Path<String>,
 	options_input: web::Query<dto::ThumbnailOptions>,
 ) -> Result<NamedFile, APIError> {
-	let mut options = thumbnail::Options::default();
-	options.pad_to_square = options_input.pad.unwrap_or(options.pad_to_square);
+	let options = thumbnail::Options::from(options_input.0);
 
 	let thumbnail_path = block(move || {
 		let vfs = vfs_manager.get_vfs()?;
diff --git a/src/service/dto.rs b/src/service/dto.rs
index 75d4769..b5fa39c 100644
--- a/src/service/dto.rs
+++ b/src/service/dto.rs
@@ -1,6 +1,6 @@
 use serde::{Deserialize, Serialize};
 
-use crate::app::{config, ddns, settings, user, vfs};
+use crate::app::{config, ddns, settings, thumbnail, user, vfs};
 
 pub const API_MAJOR_VERSION: i32 = 6;
 pub const API_MINOR_VERSION: i32 = 0;
@@ -39,9 +39,37 @@ pub struct AuthQueryParameters {
 
 #[derive(Serialize, Deserialize)]
 pub struct ThumbnailOptions {
+	pub size: Option<ThumbnailSize>,
 	pub pad: Option<bool>,
 }
 
+impl From<ThumbnailOptions> for thumbnail::Options {
+	fn from(dto: ThumbnailOptions) -> Self {
+		let mut options = thumbnail::Options::default();
+		options.max_dimension = dto.size.map_or(options.max_dimension, Into::into);
+		options.pad_to_square = dto.pad.unwrap_or(options.pad_to_square);
+		options
+	}
+}
+
+#[derive(Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ThumbnailSize {
+	Small,
+	Large,
+	Native,
+}
+
+impl Into<Option<u32>> for ThumbnailSize {
+	fn into(self) -> Option<u32> {
+		match self {
+			Self::Small => Some(400),
+			Self::Large => Some(1200),
+			Self::Native => None,
+		}
+	}
+}
+
 #[derive(Debug, PartialEq, Serialize, Deserialize)]
 pub struct ListPlaylistsEntry {
 	pub name: String,
diff --git a/src/service/test/media.rs b/src/service/test/media.rs
index 9c2bf61..9c171b9 100644
--- a/src/service/test/media.rs
+++ b/src/service/test/media.rs
@@ -1,6 +1,7 @@
 use http::{header, HeaderValue, StatusCode};
 use std::path::PathBuf;
 
+use crate::service::dto::ThumbnailSize;
 use crate::service::test::{constants::*, protocol, ServiceType, TestService};
 use crate::test_name;
 
@@ -84,8 +85,9 @@ fn thumbnail_requires_auth() {
 		.iter()
 		.collect();
 
+	let size = None;
 	let pad = None;
-	let request = protocol::thumbnail(&path, pad);
+	let request = protocol::thumbnail(&path, size, pad);
 	let response = service.fetch(&request);
 	assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
 }
@@ -102,8 +104,9 @@ fn thumbnail_golden_path() {
 		.iter()
 		.collect();
 
+	let size = None;
 	let pad = None;
-	let request = protocol::thumbnail(&path, pad);
+	let request = protocol::thumbnail(&path, size, pad);
 	let response = service.fetch_bytes(&request);
 	assert_eq!(response.status(), StatusCode::OK);
 }
@@ -116,8 +119,50 @@ fn thumbnail_bad_path_returns_not_found() {
 
 	let path: PathBuf = ["not_my_collection"].iter().collect();
 
+	let size = None;
 	let pad = None;
-	let request = protocol::thumbnail(&path, pad);
+	let request = protocol::thumbnail(&path, size, pad);
 	let response = service.fetch(&request);
 	assert_eq!(response.status(), StatusCode::NOT_FOUND);
 }
+
+#[test]
+fn thumbnail_size_default() {
+	thumbnail_size(&test_name!(), None, None, 400);
+}
+
+#[test]
+fn thumbnail_size_small() {
+	thumbnail_size(&test_name!(), Some(ThumbnailSize::Small), None, 400);
+}
+
+#[test]
+#[cfg(not(tarpaulin))]
+fn thumbnail_size_large() {
+	thumbnail_size(&test_name!(), Some(ThumbnailSize::Large), None, 1200);
+}
+
+#[test]
+#[cfg(not(tarpaulin))]
+fn thumbnail_size_native() {
+	thumbnail_size(&test_name!(), Some(ThumbnailSize::Native), None, 1423);
+}
+
+fn thumbnail_size(name: &str, size: Option<ThumbnailSize>, pad: Option<bool>, expected: u32) {
+	let mut service = ServiceType::new(name);
+	service.complete_initial_setup();
+	service.login_admin();
+	service.index();
+	service.login();
+
+	let path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic", "Folder.png"]
+		.iter()
+		.collect();
+
+	let request = protocol::thumbnail(&path, size, pad);
+	let response = service.fetch_bytes(&request);
+	assert_eq!(response.status(), StatusCode::OK);
+	let thumbnail = image::load_from_memory(response.body()).unwrap().to_rgb8();
+	assert_eq!(thumbnail.width(), expected);
+	assert_eq!(thumbnail.height(), expected);
+}
diff --git a/src/service/test/protocol.rs b/src/service/test/protocol.rs
index 1123ae7..0ff2b7a 100644
--- a/src/service/test/protocol.rs
+++ b/src/service/test/protocol.rs
@@ -1,9 +1,9 @@
-use http::{method::Method, Request};
+use http::{Method, Request};
 use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
 use std::path::Path;
 
-use crate::app::user;
 use crate::service::dto;
+use crate::{app::user, service::dto::ThumbnailSize};
 
 pub fn web_index() -> Request<()> {
 	Request::builder()
@@ -200,14 +200,32 @@ pub fn audio(path: &Path) -> Request<()> {
 		.unwrap()
 }
 
-pub fn thumbnail(path: &Path, pad: Option<bool>) -> Request<()> {
+pub fn thumbnail(path: &Path, size: Option<ThumbnailSize>, pad: Option<bool>) -> Request<()> {
 	let path = path.to_string_lossy();
-	let mut endpoint = format!("/api/thumbnail/{}", url_encode(path.as_ref()));
-	match pad {
-		Some(true) => endpoint.push_str("?pad=true"),
-		Some(false) => endpoint.push_str("?pad=false"),
-		None => (),
-	};
+	let mut params = String::new();
+	if let Some(s) = size {
+		params.push('?');
+		match s {
+			ThumbnailSize::Small => params.push_str("size=small"),
+			ThumbnailSize::Large => params.push_str("size=large"),
+			ThumbnailSize::Native => params.push_str("size=native"),
+		};
+	}
+	if let Some(p) = pad {
+		if params.is_empty() {
+			params.push('?');
+		} else {
+			params.push('&');
+		}
+		if p {
+			params.push_str("pad=true");
+		} else {
+			params.push_str("pad=false");
+		};
+	}
+
+	let endpoint = format!("/api/thumbnail/{}{}", url_encode(path.as_ref()), params);
+
 	Request::builder()
 		.method(Method::GET)
 		.uri(&endpoint)