diff --git a/src/app/config.rs b/src/app/config.rs index e21a9d6..42cc118 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -4,11 +4,17 @@ use std::path; use crate::app::{ddns, settings, user, vfs}; -mod error; -#[cfg(test)] -mod test; +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Unspecified")] + Unspecified, +} -pub use error::*; +impl From for Error { + fn from(_: anyhow::Error) -> Self { + Error::Unspecified + } +} #[derive(Default, Deserialize)] pub struct Config { @@ -108,3 +114,90 @@ impl Manager { Ok(()) } } + +#[cfg(test)] +mod test { + + use super::*; + use crate::app::test; + use crate::test_name; + + #[test] + fn apply_saves_misc_settings() { + let ctx = test::ContextBuilder::new(test_name!()).build(); + let new_config = Config { + settings: Some(settings::NewSettings { + album_art_pattern: Some("🖼️\\.jpg".into()), + reindex_every_n_seconds: Some(100), + }), + ..Default::default() + }; + + ctx.config_manager.apply(&new_config).unwrap(); + let settings = ctx.settings_manager.read().unwrap(); + let new_settings = new_config.settings.unwrap(); + assert_eq!( + settings.index_album_art_pattern, + new_settings.album_art_pattern.unwrap() + ); + assert_eq!( + settings.index_sleep_duration_seconds, + new_settings.reindex_every_n_seconds.unwrap() + ); + } + + #[test] + fn apply_saves_mount_points() { + let ctx = test::ContextBuilder::new(test_name!()).build(); + + let new_config = Config { + mount_dirs: Some(vec![vfs::MountDir { + source: "/home/music".into(), + name: "🎵📁".into(), + }]), + ..Default::default() + }; + + ctx.config_manager.apply(&new_config).unwrap(); + let actual_mount_dirs: Vec = ctx.vfs_manager.mount_dirs().unwrap(); + assert_eq!(actual_mount_dirs, new_config.mount_dirs.unwrap()); + } + + #[test] + fn apply_saves_ddns_settings() { + let ctx = test::ContextBuilder::new(test_name!()).build(); + + let new_config = Config { + ydns: Some(ddns::Config { + host: "🐸🐸🐸.ydns.eu".into(), + username: "kfr🐸g".into(), + password: "tasty🐞".into(), + }), + ..Default::default() + }; + + ctx.config_manager.apply(&new_config).unwrap(); + let actual_ddns = ctx.ddns_manager.config().unwrap(); + assert_eq!(actual_ddns, new_config.ydns.unwrap()); + } + + #[test] + fn apply_can_toggle_admin() { + let ctx = test::ContextBuilder::new(test_name!()) + .user("Walter", "Tasty🍖", true) + .build(); + + assert!(ctx.user_manager.list().unwrap()[0].is_admin()); + + let new_config = Config { + users: Some(vec![user::NewUser { + name: "Walter".into(), + password: "Tasty🍖".into(), + admin: false, + }]), + ..Default::default() + }; + ctx.config_manager.apply(&new_config).unwrap(); + assert!(!ctx.user_manager.list().unwrap()[0].is_admin()); + } +} diff --git a/src/app/config/error.rs b/src/app/config/error.rs deleted file mode 100644 index 368ccac..0000000 --- a/src/app/config/error.rs +++ /dev/null @@ -1,11 +0,0 @@ -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("Unspecified")] - Unspecified, -} - -impl From for Error { - fn from(_: anyhow::Error) -> Self { - Error::Unspecified - } -} diff --git a/src/app/config/test.rs b/src/app/config/test.rs deleted file mode 100644 index 6141485..0000000 --- a/src/app/config/test.rs +++ /dev/null @@ -1,82 +0,0 @@ -use super::*; -use crate::app::{ddns, settings, test, user, vfs}; -use crate::test_name; - -#[test] -fn apply_saves_misc_settings() { - let ctx = test::ContextBuilder::new(test_name!()).build(); - let new_config = Config { - settings: Some(settings::NewSettings { - album_art_pattern: Some("🖼️\\.jpg".into()), - reindex_every_n_seconds: Some(100), - }), - ..Default::default() - }; - - ctx.config_manager.apply(&new_config).unwrap(); - let settings = ctx.settings_manager.read().unwrap(); - let new_settings = new_config.settings.unwrap(); - assert_eq!( - settings.index_album_art_pattern, - new_settings.album_art_pattern.unwrap() - ); - assert_eq!( - settings.index_sleep_duration_seconds, - new_settings.reindex_every_n_seconds.unwrap() - ); -} - -#[test] -fn apply_saves_mount_points() { - let ctx = test::ContextBuilder::new(test_name!()).build(); - - let new_config = Config { - mount_dirs: Some(vec![vfs::MountDir { - source: "/home/music".into(), - name: "🎵📁".into(), - }]), - ..Default::default() - }; - - ctx.config_manager.apply(&new_config).unwrap(); - let actual_mount_dirs: Vec = ctx.vfs_manager.mount_dirs().unwrap(); - assert_eq!(actual_mount_dirs, new_config.mount_dirs.unwrap()); -} - -#[test] -fn apply_saves_ddns_settings() { - let ctx = test::ContextBuilder::new(test_name!()).build(); - - let new_config = Config { - ydns: Some(ddns::Config { - host: "🐸🐸🐸.ydns.eu".into(), - username: "kfr🐸g".into(), - password: "tasty🐞".into(), - }), - ..Default::default() - }; - - ctx.config_manager.apply(&new_config).unwrap(); - let actual_ddns = ctx.ddns_manager.config().unwrap(); - assert_eq!(actual_ddns, new_config.ydns.unwrap()); -} - -#[test] -fn apply_can_toggle_admin() { - let ctx = test::ContextBuilder::new(test_name!()) - .user("Walter", "Tasty🍖", true) - .build(); - - assert!(ctx.user_manager.list().unwrap()[0].is_admin()); - - let new_config = Config { - users: Some(vec![user::NewUser { - name: "Walter".into(), - password: "Tasty🍖".into(), - admin: false, - }]), - ..Default::default() - }; - ctx.config_manager.apply(&new_config).unwrap(); - assert!(!ctx.user_manager.list().unwrap()[0].is_admin()); -} diff --git a/src/app/playlist.rs b/src/app/playlist.rs index 51009be..0a4b1a1 100644 --- a/src/app/playlist.rs +++ b/src/app/playlist.rs @@ -9,11 +9,21 @@ use crate::app::index::Song; use crate::app::vfs; use crate::db::{playlist_songs, playlists, users, DB}; -mod error; -#[cfg(test)] -mod test; +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("User not found")] + UserNotFound, + #[error("Playlist not found")] + PlaylistNotFound, + #[error("Unspecified")] + Unspecified, +} -pub use error::*; +impl From for Error { + fn from(_: anyhow::Error) -> Self { + Error::Unspecified + } +} #[derive(Clone)] pub struct Manager { @@ -251,3 +261,125 @@ struct NewPlaylistSong { struct User { id: i32, } + +#[cfg(test)] +mod test { + use std::path::{Path, PathBuf}; + + use crate::app::test; + use crate::test_name; + + const TEST_USER: &str = "test_user"; + const TEST_PASSWORD: &str = "password"; + const TEST_PLAYLIST_NAME: &str = "Chill & Grill"; + const TEST_MOUNT_NAME: &str = "root"; + + #[test] + fn save_playlist_golden_path() { + let ctx = test::ContextBuilder::new(test_name!()) + .user(TEST_USER, TEST_PASSWORD, false) + .build(); + + ctx.playlist_manager + .save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &Vec::new()) + .unwrap(); + + let found_playlists = ctx.playlist_manager.list_playlists(TEST_USER).unwrap(); + assert_eq!(found_playlists.len(), 1); + assert_eq!(found_playlists[0], TEST_PLAYLIST_NAME); + } + + #[test] + fn save_playlist_is_idempotent() { + let ctx = test::ContextBuilder::new(test_name!()) + .user(TEST_USER, TEST_PASSWORD, false) + .mount(TEST_MOUNT_NAME, "test-data/small-collection") + .build(); + + ctx.index.update().unwrap(); + + let playlist_content: Vec = ctx + .index + .flatten(Path::new(TEST_MOUNT_NAME)) + .unwrap() + .into_iter() + .map(|s| s.path) + .collect(); + assert_eq!(playlist_content.len(), 13); + + ctx.playlist_manager + .save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content) + .unwrap(); + + ctx.playlist_manager + .save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content) + .unwrap(); + + let songs = ctx + .playlist_manager + .read_playlist(TEST_PLAYLIST_NAME, TEST_USER) + .unwrap(); + assert_eq!(songs.len(), 13); + } + + #[test] + fn delete_playlist_golden_path() { + let ctx = test::ContextBuilder::new(test_name!()) + .user(TEST_USER, TEST_PASSWORD, false) + .build(); + + let playlist_content = Vec::new(); + + ctx.playlist_manager + .save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content) + .unwrap(); + + ctx.playlist_manager + .delete_playlist(TEST_PLAYLIST_NAME, TEST_USER) + .unwrap(); + + let found_playlists = ctx.playlist_manager.list_playlists(TEST_USER).unwrap(); + assert_eq!(found_playlists.len(), 0); + } + + #[test] + fn read_playlist_golden_path() { + let ctx = test::ContextBuilder::new(test_name!()) + .user(TEST_USER, TEST_PASSWORD, false) + .mount(TEST_MOUNT_NAME, "test-data/small-collection") + .build(); + + ctx.index.update().unwrap(); + + let playlist_content: Vec = ctx + .index + .flatten(Path::new(TEST_MOUNT_NAME)) + .unwrap() + .into_iter() + .map(|s| s.path) + .collect(); + assert_eq!(playlist_content.len(), 13); + + ctx.playlist_manager + .save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content) + .unwrap(); + + let songs = ctx + .playlist_manager + .read_playlist(TEST_PLAYLIST_NAME, TEST_USER) + .unwrap(); + + assert_eq!(songs.len(), 13); + assert_eq!(songs[0].title, Some("Above The Water".to_owned())); + + let first_song_path: PathBuf = [ + TEST_MOUNT_NAME, + "Khemmis", + "Hunted", + "01 - Above The Water.mp3", + ] + .iter() + .collect(); + assert_eq!(songs[0].path, first_song_path.to_str().unwrap()); + } +} diff --git a/src/app/playlist/error.rs b/src/app/playlist/error.rs deleted file mode 100644 index e9f9e11..0000000 --- a/src/app/playlist/error.rs +++ /dev/null @@ -1,15 +0,0 @@ -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("User not found")] - UserNotFound, - #[error("Playlist not found")] - PlaylistNotFound, - #[error("Unspecified")] - Unspecified, -} - -impl From for Error { - fn from(_: anyhow::Error) -> Self { - Error::Unspecified - } -} diff --git a/src/app/playlist/test.rs b/src/app/playlist/test.rs deleted file mode 100644 index 2df21b3..0000000 --- a/src/app/playlist/test.rs +++ /dev/null @@ -1,118 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::app::test; -use crate::test_name; - -const TEST_USER: &str = "test_user"; -const TEST_PASSWORD: &str = "password"; -const TEST_PLAYLIST_NAME: &str = "Chill & Grill"; -const TEST_MOUNT_NAME: &str = "root"; - -#[test] -fn save_playlist_golden_path() { - let ctx = test::ContextBuilder::new(test_name!()) - .user(TEST_USER, TEST_PASSWORD, false) - .build(); - - ctx.playlist_manager - .save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &Vec::new()) - .unwrap(); - - let found_playlists = ctx.playlist_manager.list_playlists(TEST_USER).unwrap(); - assert_eq!(found_playlists.len(), 1); - assert_eq!(found_playlists[0], TEST_PLAYLIST_NAME); -} - -#[test] -fn save_playlist_is_idempotent() { - let ctx = test::ContextBuilder::new(test_name!()) - .user(TEST_USER, TEST_PASSWORD, false) - .mount(TEST_MOUNT_NAME, "test-data/small-collection") - .build(); - - ctx.index.update().unwrap(); - - let playlist_content: Vec = ctx - .index - .flatten(Path::new(TEST_MOUNT_NAME)) - .unwrap() - .into_iter() - .map(|s| s.path) - .collect(); - assert_eq!(playlist_content.len(), 13); - - ctx.playlist_manager - .save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content) - .unwrap(); - - ctx.playlist_manager - .save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content) - .unwrap(); - - let songs = ctx - .playlist_manager - .read_playlist(TEST_PLAYLIST_NAME, TEST_USER) - .unwrap(); - assert_eq!(songs.len(), 13); -} - -#[test] -fn delete_playlist_golden_path() { - let ctx = test::ContextBuilder::new(test_name!()) - .user(TEST_USER, TEST_PASSWORD, false) - .build(); - - let playlist_content = Vec::new(); - - ctx.playlist_manager - .save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content) - .unwrap(); - - ctx.playlist_manager - .delete_playlist(TEST_PLAYLIST_NAME, TEST_USER) - .unwrap(); - - let found_playlists = ctx.playlist_manager.list_playlists(TEST_USER).unwrap(); - assert_eq!(found_playlists.len(), 0); -} - -#[test] -fn read_playlist_golden_path() { - let ctx = test::ContextBuilder::new(test_name!()) - .user(TEST_USER, TEST_PASSWORD, false) - .mount(TEST_MOUNT_NAME, "test-data/small-collection") - .build(); - - ctx.index.update().unwrap(); - - let playlist_content: Vec = ctx - .index - .flatten(Path::new(TEST_MOUNT_NAME)) - .unwrap() - .into_iter() - .map(|s| s.path) - .collect(); - assert_eq!(playlist_content.len(), 13); - - ctx.playlist_manager - .save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content) - .unwrap(); - - let songs = ctx - .playlist_manager - .read_playlist(TEST_PLAYLIST_NAME, TEST_USER) - .unwrap(); - - assert_eq!(songs.len(), 13); - assert_eq!(songs[0].title, Some("Above The Water".to_owned())); - - let first_song_path: PathBuf = [ - TEST_MOUNT_NAME, - "Khemmis", - "Hunted", - "01 - Above The Water.mp3", - ] - .iter() - .collect(); - assert_eq!(songs[0].path, first_song_path.to_str().unwrap()); -} diff --git a/src/app/settings.rs b/src/app/settings.rs index 1d79a27..7c29f46 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -6,9 +6,27 @@ use std::time::Duration; use crate::db::{misc_settings, DB}; -mod error; +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Missing auth secret")] + AuthSecretNotFound, + #[error("Auth secret does not have the expected format")] + InvalidAuthSecret, + #[error("Missing index sleep duration")] + IndexSleepDurationNotFound, + #[error("Missing index album art pattern")] + IndexAlbumArtPatternNotFound, + #[error("Index album art pattern is not a valid regex")] + IndexAlbumArtPatternInvalid, + #[error("Unspecified")] + Unspecified, +} -pub use error::*; +impl From for Error { + fn from(_: anyhow::Error) -> Self { + Error::Unspecified + } +} #[derive(Clone, Default)] pub struct AuthSecret { diff --git a/src/app/settings/error.rs b/src/app/settings/error.rs deleted file mode 100644 index d906a90..0000000 --- a/src/app/settings/error.rs +++ /dev/null @@ -1,21 +0,0 @@ -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("Missing auth secret")] - AuthSecretNotFound, - #[error("Auth secret does not have the expected format")] - InvalidAuthSecret, - #[error("Missing index sleep duration")] - IndexSleepDurationNotFound, - #[error("Missing index album art pattern")] - IndexAlbumArtPatternNotFound, - #[error("Index album art pattern is not a valid regex")] - IndexAlbumArtPatternInvalid, - #[error("Unspecified")] - Unspecified, -} - -impl From for Error { - fn from(_: anyhow::Error) -> Self { - Error::Unspecified - } -} diff --git a/src/app/thumbnail.rs b/src/app/thumbnail.rs index 7e78f14..9864952 100644 --- a/src/app/thumbnail.rs +++ b/src/app/thumbnail.rs @@ -1,17 +1,29 @@ use anyhow::*; -use image::ImageOutputFormat; +use image::{DynamicImage, GenericImage, GenericImageView, ImageBuffer, ImageOutputFormat}; +use std::cmp; use std::collections::hash_map::DefaultHasher; use std::fs::{self, File}; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; -mod generate; -mod options; -mod read; +use crate::utils::{get_audio_format, AudioFormat}; -pub use generate::*; -pub use options::*; -pub use read::*; +#[derive(Debug, Hash)] +pub struct Options { + pub max_dimension: Option, + pub resize_if_almost_square: bool, + pub pad_to_square: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { + max_dimension: Some(400), + resize_if_almost_square: true, + pad_to_square: true, + } + } +} #[derive(Clone)] pub struct Manager { @@ -66,3 +78,178 @@ impl Manager { hasher.finish() } } + +fn generate_thumbnail(image_path: &Path, options: &Options) -> Result { + 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.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; + + let mut final_image; + if is_almost_square && options.resize_if_almost_square { + final_image = source_image.thumbnail_exact(out_dimension, out_dimension); + } else if options.pad_to_square { + let scaled_image = source_image.thumbnail(out_dimension, out_dimension); + let (scaled_width, scaled_height) = scaled_image.dimensions(); + let background = image::Rgb([255, 255_u8, 255_u8]); + final_image = DynamicImage::ImageRgb8(ImageBuffer::from_pixel( + out_dimension, + out_dimension, + background, + )); + final_image.copy_from( + &scaled_image, + (out_dimension - scaled_width) / 2, + (out_dimension - scaled_height) / 2, + )?; + } else { + final_image = source_image.thumbnail(out_dimension, out_dimension); + } + + Ok(final_image) +} + +fn read(image_path: &Path) -> Result { + match get_audio_format(image_path) { + Some(AudioFormat::AIFF) => read_aiff(image_path), + Some(AudioFormat::APE) => read_ape(image_path), + Some(AudioFormat::FLAC) => read_flac(image_path), + Some(AudioFormat::MP3) => read_mp3(image_path), + Some(AudioFormat::MP4) => read_mp4(image_path), + Some(AudioFormat::MPC) => read_ape(image_path), + Some(AudioFormat::OGG) => read_vorbis(image_path), + Some(AudioFormat::OPUS) => read_opus(image_path), + Some(AudioFormat::WAVE) => read_wave(image_path), + None => Ok(image::open(image_path)?), + } +} + +fn read_ape(_: &Path) -> Result { + bail!("Embedded images are not supported in APE files"); +} + +fn read_flac(path: &Path) -> Result { + let tag = metaflac::Tag::read_from_path(path)?; + + if let Some(p) = tag.pictures().next() { + return Ok(image::load_from_memory(&p.data)?); + } + + bail!( + "Embedded flac artwork not found for file: {}", + path.display() + ); +} + +fn read_mp3(path: &Path) -> Result { + let tag = id3::Tag::read_from_path(path)?; + read_id3(path, &tag) +} + +fn read_aiff(path: &Path) -> Result { + let tag = id3::Tag::read_from_aiff_path(path)?; + read_id3(path, &tag) +} + +fn read_wave(path: &Path) -> Result { + let tag = id3::Tag::read_from_wav_path(path)?; + read_id3(path, &tag) +} + +fn read_id3(path: &Path, tag: &id3::Tag) -> Result { + if let Some(p) = tag.pictures().next() { + return Ok(image::load_from_memory(&p.data)?); + } + + bail!( + "Embedded id3 artwork not found for file: {}", + path.display() + ); +} + +fn read_mp4(path: &Path) -> Result { + let tag = mp4ameta::Tag::read_from_path(path)?; + + match tag.artwork().map(|d| d.data) { + Some(v) => Ok(image::load_from_memory(v)?), + _ => bail!( + "Embedded mp4 artwork not found for file: {}", + path.display() + ), + } +} + +fn read_vorbis(_: &Path) -> Result { + bail!("Embedded images are not supported in Vorbis files"); +} + +fn read_opus(_: &Path) -> Result { + bail!("Embedded images are not supported in Opus files"); +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn can_read_artwork_data() { + let ext_img = image::open("test-data/artwork/Folder.png") + .unwrap() + .to_rgb8(); + let embedded_img = image::open("test-data/artwork/Embedded.png") + .unwrap() + .to_rgb8(); + + let folder_img = read(Path::new("test-data/artwork/Folder.png")) + .unwrap() + .to_rgb8(); + assert_eq!(folder_img, ext_img); + + let aiff_img = read(Path::new("test-data/artwork/sample.aif")) + .unwrap() + .to_rgb8(); + assert_eq!(aiff_img, embedded_img); + + let ape_img = read(Path::new("test-data/artwork/sample.ape")) + .map(|d| d.to_rgb8()) + .ok(); + assert_eq!(ape_img, None); + + let flac_img = read(Path::new("test-data/artwork/sample.flac")) + .unwrap() + .to_rgb8(); + assert_eq!(flac_img, embedded_img); + + let mp3_img = read(Path::new("test-data/artwork/sample.mp3")) + .unwrap() + .to_rgb8(); + assert_eq!(mp3_img, embedded_img); + + let m4a_img = read(Path::new("test-data/artwork/sample.m4a")) + .unwrap() + .to_rgb8(); + assert_eq!(m4a_img, embedded_img); + + let ogg_img = read(Path::new("test-data/artwork/sample.ogg")) + .map(|d| d.to_rgb8()) + .ok(); + assert_eq!(ogg_img, None); + + let opus_img = read(Path::new("test-data/artwork/sample.opus")) + .map(|d| d.to_rgb8()) + .ok(); + assert_eq!(opus_img, None); + + let wave_img = read(Path::new("test-data/artwork/sample.wav")) + .unwrap() + .to_rgb8(); + assert_eq!(wave_img, embedded_img); + } +} diff --git a/src/app/thumbnail/generate.rs b/src/app/thumbnail/generate.rs deleted file mode 100644 index 38b3720..0000000 --- a/src/app/thumbnail/generate.rs +++ /dev/null @@ -1,42 +0,0 @@ -use anyhow::*; -use image::{DynamicImage, GenericImage, GenericImageView, ImageBuffer}; -use std::cmp; -use std::path::*; - -use crate::app::thumbnail::{read, Options}; - -pub fn generate_thumbnail(image_path: &Path, options: &Options) -> Result { - 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.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; - - let mut final_image; - if is_almost_square && options.resize_if_almost_square { - final_image = source_image.thumbnail_exact(out_dimension, out_dimension); - } else if options.pad_to_square { - let scaled_image = source_image.thumbnail(out_dimension, out_dimension); - let (scaled_width, scaled_height) = scaled_image.dimensions(); - let background = image::Rgb([255, 255_u8, 255_u8]); - final_image = DynamicImage::ImageRgb8(ImageBuffer::from_pixel( - out_dimension, - out_dimension, - background, - )); - final_image.copy_from( - &scaled_image, - (out_dimension - scaled_width) / 2, - (out_dimension - scaled_height) / 2, - )?; - } else { - final_image = source_image.thumbnail(out_dimension, out_dimension); - } - - Ok(final_image) -} diff --git a/src/app/thumbnail/options.rs b/src/app/thumbnail/options.rs deleted file mode 100644 index 3219245..0000000 --- a/src/app/thumbnail/options.rs +++ /dev/null @@ -1,16 +0,0 @@ -#[derive(Debug, Hash)] -pub struct Options { - pub max_dimension: Option, - pub resize_if_almost_square: bool, - pub pad_to_square: bool, -} - -impl Default for Options { - fn default() -> Self { - Self { - max_dimension: Some(400), - resize_if_almost_square: true, - pad_to_square: true, - } - } -} diff --git a/src/app/thumbnail/read.rs b/src/app/thumbnail/read.rs deleted file mode 100644 index 2ee2b3f..0000000 --- a/src/app/thumbnail/read.rs +++ /dev/null @@ -1,142 +0,0 @@ -use anyhow::{bail, Result}; -use image::DynamicImage; -use std::path::Path; - -use crate::utils; -use crate::utils::AudioFormat; - -pub fn read(image_path: &Path) -> Result { - match utils::get_audio_format(image_path) { - Some(AudioFormat::AIFF) => read_aiff(image_path), - Some(AudioFormat::APE) => read_ape(image_path), - Some(AudioFormat::FLAC) => read_flac(image_path), - Some(AudioFormat::MP3) => read_mp3(image_path), - Some(AudioFormat::MP4) => read_mp4(image_path), - Some(AudioFormat::MPC) => read_ape(image_path), - Some(AudioFormat::OGG) => read_vorbis(image_path), - Some(AudioFormat::OPUS) => read_opus(image_path), - Some(AudioFormat::WAVE) => read_wave(image_path), - None => Ok(image::open(image_path)?), - } -} - -fn read_ape(_: &Path) -> Result { - bail!("Embedded images are not supported in APE files"); -} - -fn read_flac(path: &Path) -> Result { - let tag = metaflac::Tag::read_from_path(path)?; - - if let Some(p) = tag.pictures().next() { - return Ok(image::load_from_memory(&p.data)?); - } - - bail!( - "Embedded flac artwork not found for file: {}", - path.display() - ); -} - -fn read_mp3(path: &Path) -> Result { - let tag = id3::Tag::read_from_path(path)?; - - read_id3(path, &tag) -} - -fn read_aiff(path: &Path) -> Result { - let tag = id3::Tag::read_from_aiff_path(path)?; - - read_id3(path, &tag) -} - -fn read_wave(path: &Path) -> Result { - let tag = id3::Tag::read_from_wav_path(path)?; - - read_id3(path, &tag) -} - -fn read_id3(path: &Path, tag: &id3::Tag) -> Result { - if let Some(p) = tag.pictures().next() { - return Ok(image::load_from_memory(&p.data)?); - } - - bail!( - "Embedded id3 artwork not found for file: {}", - path.display() - ); -} - -fn read_mp4(path: &Path) -> Result { - let tag = mp4ameta::Tag::read_from_path(path)?; - - match tag.artwork().map(|d| d.data) { - Some(v) => Ok(image::load_from_memory(v)?), - _ => bail!( - "Embedded mp4 artwork not found for file: {}", - path.display() - ), - } -} - -fn read_vorbis(_: &Path) -> Result { - bail!("Embedded images are not supported in Vorbis files"); -} - -fn read_opus(_: &Path) -> Result { - bail!("Embedded images are not supported in Opus files"); -} - -#[test] -fn can_read_artwork_data() { - let ext_img = image::open("test-data/artwork/Folder.png") - .unwrap() - .to_rgb8(); - let embedded_img = image::open("test-data/artwork/Embedded.png") - .unwrap() - .to_rgb8(); - - let folder_img = read(Path::new("test-data/artwork/Folder.png")) - .unwrap() - .to_rgb8(); - assert_eq!(folder_img, ext_img); - - let aiff_img = read(Path::new("test-data/artwork/sample.aif")) - .unwrap() - .to_rgb8(); - assert_eq!(aiff_img, embedded_img); - - let ape_img = read(Path::new("test-data/artwork/sample.ape")) - .map(|d| d.to_rgb8()) - .ok(); - assert_eq!(ape_img, None); - - let flac_img = read(Path::new("test-data/artwork/sample.flac")) - .unwrap() - .to_rgb8(); - assert_eq!(flac_img, embedded_img); - - let mp3_img = read(Path::new("test-data/artwork/sample.mp3")) - .unwrap() - .to_rgb8(); - assert_eq!(mp3_img, embedded_img); - - let m4a_img = read(Path::new("test-data/artwork/sample.m4a")) - .unwrap() - .to_rgb8(); - assert_eq!(m4a_img, embedded_img); - - let ogg_img = read(Path::new("test-data/artwork/sample.ogg")) - .map(|d| d.to_rgb8()) - .ok(); - assert_eq!(ogg_img, None); - - let opus_img = read(Path::new("test-data/artwork/sample.opus")) - .map(|d| d.to_rgb8()) - .ok(); - assert_eq!(opus_img, None); - - let wave_img = read(Path::new("test-data/artwork/sample.wav")) - .unwrap() - .to_rgb8(); - assert_eq!(wave_img, embedded_img); -} diff --git a/src/app/vfs.rs b/src/app/vfs.rs index dda592e..21f3c7e 100644 --- a/src/app/vfs.rs +++ b/src/app/vfs.rs @@ -7,9 +7,6 @@ use std::path::{self, Path, PathBuf}; use crate::db::{mount_points, DB}; -#[cfg(test)] -mod test; - #[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Eq, Queryable, Serialize)] #[diesel(table_name = mount_points)] pub struct MountDir { @@ -115,3 +112,83 @@ impl Manager { Ok(()) } } + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn converts_virtual_to_real() { + let vfs = VFS::new(vec![Mount { + name: "root".to_owned(), + source: Path::new("test_dir").to_owned(), + }]); + let real_path: PathBuf = ["test_dir", "somewhere", "something.png"].iter().collect(); + let virtual_path: PathBuf = ["root", "somewhere", "something.png"].iter().collect(); + let converted_path = vfs.virtual_to_real(virtual_path.as_path()).unwrap(); + assert_eq!(converted_path, real_path); + } + + #[test] + fn converts_virtual_to_real_top_level() { + let vfs = VFS::new(vec![Mount { + name: "root".to_owned(), + source: Path::new("test_dir").to_owned(), + }]); + let real_path = Path::new("test_dir"); + let converted_path = vfs.virtual_to_real(Path::new("root")).unwrap(); + assert_eq!(converted_path, real_path); + } + + #[test] + fn converts_real_to_virtual() { + let vfs = VFS::new(vec![Mount { + name: "root".to_owned(), + source: Path::new("test_dir").to_owned(), + }]); + let virtual_path: PathBuf = ["root", "somewhere", "something.png"].iter().collect(); + let real_path: PathBuf = ["test_dir", "somewhere", "something.png"].iter().collect(); + let converted_path = vfs.real_to_virtual(real_path.as_path()).unwrap(); + assert_eq!(converted_path, virtual_path); + } + + #[test] + fn cleans_path_string() { + let mut correct_path = path::PathBuf::new(); + if cfg!(target_os = "windows") { + correct_path.push("C:\\"); + } else { + correct_path.push("/usr"); + } + correct_path.push("some"); + correct_path.push("path"); + + let tests = if cfg!(target_os = "windows") { + vec![ + r#"C:/some/path"#, + r#"C:\some\path"#, + r#"C:\some\path\"#, + r#"C:\some\path\\\\"#, + r#"C:\some/path//"#, + ] + } else { + vec![ + r#"/usr/some/path"#, + r#"/usr\some\path"#, + r#"/usr\some\path\"#, + r#"/usr\some\path\\\\"#, + r#"/usr\some/path//"#, + ] + }; + + for test in tests { + let mount_dir = MountDir { + source: test.to_owned(), + name: "name".to_owned(), + }; + let mount: Mount = mount_dir.into(); + assert_eq!(mount.source, correct_path); + } + } +} diff --git a/src/app/vfs/test.rs b/src/app/vfs/test.rs deleted file mode 100644 index 9579be6..0000000 --- a/src/app/vfs/test.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::path::{Path, PathBuf}; - -use super::*; - -#[test] -fn converts_virtual_to_real() { - let vfs = VFS::new(vec![Mount { - name: "root".to_owned(), - source: Path::new("test_dir").to_owned(), - }]); - let real_path: PathBuf = ["test_dir", "somewhere", "something.png"].iter().collect(); - let virtual_path: PathBuf = ["root", "somewhere", "something.png"].iter().collect(); - let converted_path = vfs.virtual_to_real(virtual_path.as_path()).unwrap(); - assert_eq!(converted_path, real_path); -} - -#[test] -fn converts_virtual_to_real_top_level() { - let vfs = VFS::new(vec![Mount { - name: "root".to_owned(), - source: Path::new("test_dir").to_owned(), - }]); - let real_path = Path::new("test_dir"); - let converted_path = vfs.virtual_to_real(Path::new("root")).unwrap(); - assert_eq!(converted_path, real_path); -} - -#[test] -fn converts_real_to_virtual() { - let vfs = VFS::new(vec![Mount { - name: "root".to_owned(), - source: Path::new("test_dir").to_owned(), - }]); - let virtual_path: PathBuf = ["root", "somewhere", "something.png"].iter().collect(); - let real_path: PathBuf = ["test_dir", "somewhere", "something.png"].iter().collect(); - let converted_path = vfs.real_to_virtual(real_path.as_path()).unwrap(); - assert_eq!(converted_path, virtual_path); -} - -#[test] -fn cleans_path_string() { - let mut correct_path = path::PathBuf::new(); - if cfg!(target_os = "windows") { - correct_path.push("C:\\"); - } else { - correct_path.push("/usr"); - } - correct_path.push("some"); - correct_path.push("path"); - - let tests = if cfg!(target_os = "windows") { - vec![ - r#"C:/some/path"#, - r#"C:\some\path"#, - r#"C:\some\path\"#, - r#"C:\some\path\\\\"#, - r#"C:\some/path//"#, - ] - } else { - vec![ - r#"/usr/some/path"#, - r#"/usr\some\path"#, - r#"/usr\some\path\"#, - r#"/usr\some\path\\\\"#, - r#"/usr\some/path//"#, - ] - }; - - for test in tests { - let mount_dir = MountDir { - source: test.to_owned(), - name: "name".to_owned(), - }; - let mount: Mount = mount_dir.into(); - assert_eq!(mount.source, correct_path); - } -}